C1: every env-var host fence resolves its own gstack root

Codex and the other env-var hosts run each block in a fresh shell, so
GSTACK_ROOT/BIN, B and D set by the preamble were empty in later blocks
(/bin/gstack-slug, B=/browse). scripts/resolvers/runtime-root.ts now owns
root resolution: a valid exported GSTACK_ROOT (bin/ and lib/), else the
per-install literal root (no git call), else repo-local, else the global root
(CODEX_HOME on Codex); no root prints the setup fix line and exits. One
post-render pass inserts it (<=400 bytes) into every fence that uses these
variables without assigning them; B/D come from the same helper as BROWSE
and DESIGN SETUP. Claude output is byte-identical.

Migrated pins of the old preamble lines (gen-skill-docs.test.ts) and a
fixture whose exported GSTACK_ROOT lacked lib/ (skill-positional-literals).
This commit is contained in:
garrytan committed 2026-10-03 20:59:44 +00:00
1 parent b4f1bd9034
commit c050f18371
10 files changed
+315 -33

No files matched your search

+3 -2
View File
@@ -22,6 +22,7 @@ import type { Host, TemplateContext } from './resolvers/types';
import { HOST_PATHS } from './resolvers/types';
import { RESOLVERS } from './resolvers/index';
import { usesLazySections } from './resolvers/sections';
import { insertRuntimePreludes } from './resolvers/runtime-root';
import { ALL_HOST_NAMES, resolveHostArg, getHostConfig } from '../hosts/index';
import type { HostConfig } from './host-config';
@@ -839,7 +840,7 @@ function processTemplate(tmplPath: string, host: Host, options: RenderOptions):
// --out-dir: repoint section-base paths to the out-dir (no-op otherwise).
if (host === 'claude') content = rewriteSectionBase(content, options.contentLinkRoot);
content = rewriteInstallRoot(content, currentHostConfig, options.installRoot);
content = rewriteInstallRoot(insertRuntimePreludes(content, ctx), currentHostConfig, options.installRoot);
return { outputPath, content, symlinkLoop, metadata };
}
@@ -885,7 +886,7 @@ function processSectionTemplate(
// repoint those to the out-dir too (no-op when --out-dir is unset).
content = rewriteSectionBase(content, options.contentLinkRoot);
}
content = rewriteInstallRoot(content, hostConfig, options.installRoot);
content = rewriteInstallRoot(insertRuntimePreludes(content, ctx), hostConfig, options.installRoot);
// Plain generated header (no frontmatter to insert after).
content = GENERATED_HEADER.replace('{{SOURCE}}', path.basename(sectionTmplPath)) + content;
+3 -8
View File
@@ -1,4 +1,5 @@
import { type TemplateContext, toShellPath } from './types';
import { binaryAssignment } from './runtime-root';
import { COMMAND_DESCRIPTIONS } from '../../browse/src/commands';
import { SNAPSHOT_FLAGS } from '../../browse/src/snapshot-flags';
@@ -118,10 +119,7 @@ export function generateBrowseSetup(ctx: TemplateContext): string {
return `## SETUP (run this check BEFORE any browse command)
\`\`\`bash
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
B=""
[ -n "$_ROOT" ] && [ -x "$_ROOT/${ctx.paths.localSkillRoot}/browse/dist/browse" ] && B="$_ROOT/${ctx.paths.localSkillRoot}/browse/dist/browse"
[ -z "$B" ] && B="${toShellPath(ctx.paths.browseDir)}/browse"
${binaryAssignment(ctx, 'browse')}
if [ -x "$B" ]; then
echo "READY: $B"
else
@@ -178,10 +176,7 @@ export function generateBrowseFallback(ctx: TemplateContext): string {
const setup = `### Find the \`$B\` binary
\`\`\`bash
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
B=""
[ -n "$_ROOT" ] && [ -x "$_ROOT/${ctx.paths.localSkillRoot}/browse/dist/browse" ] && B="$_ROOT/${ctx.paths.localSkillRoot}/browse/dist/browse"
[ -z "$B" ] && B="${toShellPath(ctx.paths.browseDir)}/browse"
${binaryAssignment(ctx, 'browse')}
[ -x "$B" ] && echo "READY: $B" || echo "NEEDS_SETUP"
\`\`\`
+2 -2
View File
@@ -1,7 +1,7 @@
import { toShellPath, type TemplateContext } from './types';
import { outsideVoiceRuntime } from './outside-voice';
import * as path from 'path';
import { getHostConfig } from '../../hosts';
import { runtimeRootPrelude } from './runtime-root';
/** Claude's scoped hook enforces the parent publication boundary during /autoplan. */
export function generateAutoplanPublicationHook(ctx: TemplateContext, args?: string[]): string {
@@ -111,7 +111,7 @@ export function generateAutoplanReviewFile(ctx: TemplateContext, args?: string[]
/** Resolve once to a literal path; later phase commands run in fresh shells. */
export function generateAutoplanSnapshotTool(ctx: TemplateContext): string {
return `\`\`\`bash
${outsideVoiceRuntime(ctx)}
${runtimeRootPrelude(ctx)}
bun -e 'console.log(require("fs").realpathSync(process.argv[1]))' "${toShellPath(ctx.paths.binDir)}/gstack-autoplan-snapshot.ts"
\`\`\``;
}
+3 -8
View File
@@ -1,5 +1,6 @@
import { outsideVoiceFailurePolicy, outsideVoiceFor, outsideVoiceInvocation, outsideVoicePreflight, outsideVoiceProvenance } from './outside-voice';
import { type TemplateContext, toShellPath } from './types';
import { binaryAssignment } from './runtime-root';
import { AI_SLOP_BLACKLIST, OPENAI_HARD_REJECTIONS, OPENAI_LITMUS_CHECKS, CC_BACKGROUND_DEFAULT_SINCE } from './constants';
import { OVERUSED_FONTS_DISPLAY, BANNED_FONTS, FONTS_BODY_UI_OK, FONTS_MONO_OK, FONTS_VERIFIED_FREE, HANDOFF_COMMANDS, selectCatalog, catalogEntries, renderCatalog, detectorSlopEntries, judgmentTellEntries } from '../../lib/design-catalog';
import { SENTINEL, DETECT_EXIT_ECHO, DETECT_LIMITS } from '../../lib/design-detect-contract';
@@ -1118,10 +1119,7 @@ export function generateDesignSetup(ctx: TemplateContext): string {
return `## DESIGN SETUP (run this check BEFORE any design mockup command)
\`\`\`bash
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
D=""
[ -n "$_ROOT" ] && [ -x "$_ROOT/${ctx.paths.localSkillRoot}/design/dist/design" ] && D="$_ROOT/${ctx.paths.localSkillRoot}/design/dist/design"
[ -z "$D" ] && D="${toShellPath(ctx.paths.designDir)}/design"
${binaryAssignment(ctx, 'design')}
if [ -x "$D" ]; then
echo "DESIGN_READY: $D"
else
@@ -1160,10 +1158,7 @@ export function generateDesignMockup(ctx: TemplateContext): string {
return `## Visual Design Exploration
\`\`\`bash
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
D=""
[ -n "$_ROOT" ] && [ -x "$_ROOT/${ctx.paths.localSkillRoot}/design/dist/design" ] && D="$_ROOT/${ctx.paths.localSkillRoot}/design/dist/design"
[ -z "$D" ] && D="${toShellPath(ctx.paths.designDir)}/design"
${binaryAssignment(ctx, 'design')}
[ -x "$D" ] && echo "DESIGN_READY" || echo "DESIGN_NOT_AVAILABLE"
\`\`\`
+1 -1
View File
@@ -19,7 +19,7 @@ _ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
P=""
[ -n "$MAKE_PDF_BIN" ] && [ -x "$MAKE_PDF_BIN" ] && P="$MAKE_PDF_BIN"
[ -z "$P" ] && [ -n "$_ROOT" ] && [ -x "$_ROOT/${ctx.paths.localSkillRoot}/make-pdf/dist/pdf" ] && P="$_ROOT/${ctx.paths.localSkillRoot}/make-pdf/dist/pdf"
[ -z "$P" ] && P="${toShellPath(ctx.paths.makePdfDir)}/pdf"
[ -z "$P" ] && P="${toShellPath(ctx.paths.skillRoot)}/make-pdf/dist/pdf"
if [ -x "$P" ]; then
echo "MAKE_PDF_READY: $P"
alias _p_="$P" # shellcheck alias helper (not exported)
@@ -1,5 +1,6 @@
import type { TemplateContext } from '../types';
import { getHostConfig } from '../../../hosts/index';
import { runtimeRootPrelude } from '../runtime-root';
/**
* Preamble bootstrap (token-reduction Phase 1).
@@ -24,15 +25,7 @@ import { getHostConfig } from '../../../hosts/index';
*/
export function generatePreambleBash(ctx: TemplateContext): string {
const hostConfig = getHostConfig(ctx.host);
const runtimeRoot = hostConfig.usesEnvVars
? `_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
GSTACK_ROOT="$HOME/${hostConfig.globalRoot}"
[ -n "$_ROOT" ] && [ -d "$_ROOT/${ctx.paths.localSkillRoot}" ] && GSTACK_ROOT="$_ROOT/${ctx.paths.localSkillRoot}"
GSTACK_BIN="$GSTACK_ROOT/bin"
GSTACK_BROWSE="$GSTACK_ROOT/browse/dist"
GSTACK_DESIGN="$GSTACK_ROOT/design/dist"
`
: '';
const runtimeRoot = hostConfig.usesEnvVars ? `${runtimeRootPrelude(ctx)}\n` : '';
const brainHealthFlag = ctx.host === 'gbrain' || ctx.host === 'hermes' ? ' --brain-health' : '';
const startCommand = hostConfig.usesEnvVars
? `"${ctx.paths.binDir}/gstack-skill-start"`
+110
View File
@@ -0,0 +1,110 @@
/**
* Runtime root for env-var hosts (C1, #1159): the one owner of how generated
* bash finds gstack on Codex and every other `usesEnvVars` host.
*
* Those hosts run each fenced block in a fresh shell, so `GSTACK_ROOT`, `B`
* and `D` set by an earlier block are gone. The preamble's root resolution and
* the per-fence prelude come from `runtimeRootPrelude()`; `insertRuntimePreludes()`
* is the single post-render pass (gen-skill-docs, before rewriteInstallRoot)
* that puts it into every fence that uses these variables without assigning
* them. Claude output is returned unchanged.
*
* Precedence: an exported `GSTACK_ROOT` that contains `bin/` and `lib/`; then,
* on a per-install render, the literal install root (no git call); otherwise the
* repo-local install, then the host's global root (`CODEX_HOME` on Codex). No
* root prints one line naming what was tried and the setup command, and exits.
*/
import { getHostConfig } from '../../hosts/index';
import { toShellPath, type TemplateContext } from './types';
/** Per-fence byte budget for the prelude (T-ENG1), asserted by the INV-3 test. */
export const PRELUDE_BYTE_BUDGET = 400;
const ROOT_VARS = /\$\{?GSTACK_(?:ROOT|BIN|BROWSE|DESIGN|MAKE_PDF)\b/;
const assigns = (name: string) => new RegExp(`(?:^|[\\s;&|(])${name}=`, 'm');
const uses = (name: string) => new RegExp(`\\$\\{?${name}\\b`);
/**
* Root resolution only (sets `GSTACK_ROOT`). Kept compact: it repeats in every
* fence that needs it, under PRELUDE_BYTE_BUDGET together with its derived vars.
*/
function resolveRoot(ctx: TemplateContext): string {
const host = getHostConfig(ctx.host);
const fix = `Fix: ./setup --host ${host.name} from your gstack checkout; ./setup --status shows it.`;
const exported = '[ -d "${GSTACK_ROOT:-/-}/bin" ]&&[ -d "$GSTACK_ROOT/lib" ]||';
if (ctx.installRoot) {
return `${exported}{ GSTACK_ROOT="${ctx.installRoot.replace(/\/+$/, '')}";[ -d "$GSTACK_ROOT/bin" ]||{ echo "gstack: no install found (tried $GSTACK_ROOT). ${fix}">&2;exit 1;};}`;
}
const global = host.name === 'codex' ? '${CODEX_HOME:-~/.codex}/skills/gstack' : `~/${host.globalRoot}`;
return `${exported}{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/${host.localSkillRoot};[ -d "$_r/bin" ]||_r=${global};[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). ${fix}">&2;exit 1;};GSTACK_ROOT=$_r;}`;
}
const DERIVED: Record<string, string> = { BIN: 'bin', BROWSE: 'browse/dist', DESIGN: 'design/dist', MAKE_PDF: 'make-pdf/dist' };
/** The preamble's root resolution: GSTACK_ROOT plus GSTACK_BIN. */
export function runtimeRootPrelude(ctx: TemplateContext): string {
if (!getHostConfig(ctx.host).usesEnvVars) return '';
return `${resolveRoot(ctx)}\nGSTACK_BIN=$GSTACK_ROOT/bin`;
}
/**
* Lines that assign `B` (browse) or `D` (design). BROWSE SETUP, DESIGN SETUP and
* the prelude all use this, so a later fence runs the binary setup checked.
* Env-var hosts derive from `GSTACK_ROOT`, which the prelude already points at
* the repo-local install when one exists; other hosts keep their
* repo-local-first probe.
*/
export function binaryAssignment(ctx: TemplateContext, tool: 'browse' | 'design'): string {
const v = tool === 'browse' ? 'B' : 'D';
const dir = tool === 'browse' ? ctx.paths.browseDir : ctx.paths.designDir;
if (getHostConfig(ctx.host).usesEnvVars) return `${v}=$GSTACK_ROOT/${tool}/dist/${tool}`;
return `_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
${v}=""
[ -n "$_ROOT" ] && [ -x "$_ROOT/${ctx.paths.localSkillRoot}/${tool}/dist/${tool}" ] && ${v}="$_ROOT/${ctx.paths.localSkillRoot}/${tool}/dist/${tool}"
[ -z "$${v}" ] && ${v}="${toShellPath(dir)}/${tool}"`;
}
/** The prelude one fence needs, or '' when it needs none. */
export function fencePrelude(ctx: TemplateContext, body: string): string {
if (!getHostConfig(ctx.host).usesEnvVars) return '';
const needB = uses('B').test(body) && !assigns('B').test(body);
const needD = uses('D').test(body) && !assigns('D').test(body);
if (assigns('GSTACK_ROOT').test(body)) return '';
const needRoot = needB || needD || ROOT_VARS.test(body);
const derived = Object.entries(DERIVED)
.filter(([name]) => uses(`GSTACK_${name}`).test(body) && !assigns(`GSTACK_${name}`).test(body))
.map(([name, dir]) => `GSTACK_${name}=$GSTACK_ROOT/${dir}`);
return [
needRoot ? resolveRoot(ctx) : '',
derived.join(' '),
needB ? binaryAssignment(ctx, 'browse') : '',
needD ? binaryAssignment(ctx, 'design') : '',
].filter(Boolean).join('\n');
}
/**
* Post-render pass: insert the prelude at the top of every top-level ```bash
* fence that needs it. Fences nested inside a longer fence are examples, not
* commands, and are left alone; an indented fence gets indented prelude lines.
*/
export function insertRuntimePreludes(content: string, ctx: TemplateContext): string {
if (!getHostConfig(ctx.host).usesEnvVars) return content;
const lines = content.split('\n');
const out: string[] = [];
for (let i = 0; i < lines.length; i++) {
const open = lines[i].match(/^(\s*)(`{3,})(.*)$/);
out.push(lines[i]);
if (!open) continue;
let end = i + 1;
while (end < lines.length && !new RegExp(`^\\s*\`{${open[2].length},}\\s*$`).test(lines[end])) end++;
const body = lines.slice(i + 1, end);
if (open[2].length === 3 && open[3].trim() === 'bash') {
const prelude = fencePrelude(ctx, body.join('\n'));
if (prelude) out.push(...prelude.split('\n').map(line => open[1] + line));
}
out.push(...body);
if (end < lines.length) out.push(lines[end]);
i = end;
}
return out.join('\n');
}
+185
View File
@@ -0,0 +1,185 @@
/**
* INV-3 render-level conformance for env-var hosts (C1, #1159).
*
* Codex and every `usesEnvVars` host run each fenced bash block in a fresh
* shell, so a block that uses `$GSTACK_*`, `$B` or `$D` must resolve them
* itself. Renders every host once into a temp dir and checks every fence of
* every SKILL.md and section file; executes the shared prelude under `env -i`
* and `set -u` against temp install layouts.
*/
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';
import { binaryAssignment, fencePrelude, insertRuntimePreludes, PRELUDE_BYTE_BUDGET, runtimeRootPrelude } from '../scripts/resolvers/runtime-root';
import { HOST_PATHS, type TemplateContext } from '../scripts/resolvers/types';
const ENV_HOSTS = ALL_HOST_CONFIGS.filter(h => h.usesEnvVars);
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-env-fences-'));
const renderDir = path.join(tmp, 'render');
interface Fence { file: string; line: number; body: string }
/** Top-level ```bash fences (CommonMark: a longer outer fence hides inner ones). */
function bashFences(text: string, file = ''): Fence[] {
const lines = text.split('\n');
const out: Fence[] = [];
for (let i = 0; i < lines.length; i++) {
const open = lines[i].match(/^(\s*)(`{3,})(.*)$/);
if (!open) continue;
let end = i + 1;
const close = new RegExp(`^\\s*\`{${open[2].length},}\\s*$`);
while (end < lines.length && !close.test(lines[end])) end++;
if (open[2].length === 3 && open[3].trim() === 'bash') {
out.push({ file, line: i + 1, body: lines.slice(i + 1, end).map(l => l.startsWith(open[1]) ? l.slice(open[1].length) : l).join('\n') });
}
i = end;
}
return out;
}
function renderedDocs(root: string, hostSubdir: string | null): string[] {
const base = hostSubdir ? path.join(root, hostSubdir, 'skills') : root;
const out: string[] = [];
const walk = (dir: string, depth: number) => {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) {
if (!hostSubdir && depth === 0 && (e.name.startsWith('.') || e.name === 'node_modules')) continue;
walk(p, depth + 1);
} else if (e.name === 'SKILL.md' || (e.name.endsWith('.md') && path.basename(dir) === 'sections')) out.push(p);
}
};
walk(base, 0);
return out;
}
const ctx = (host: string, installRoot: string | null = null): TemplateContext => ({ host, skillName: 'review', tmplPath: '', paths: HOST_PATHS[host], installRoot });
function sh(script: string, cwd: string, env: Record<string, string>) {
return spawnSync('bash', ['-uc', script], { cwd, encoding: 'utf8', timeout: 10_000, env: { PATH: process.env.PATH ?? '/usr/bin:/bin', ...env } });
}
function mkroot(dir: string) {
for (const sub of ['bin', 'lib', 'browse/dist', 'design/dist']) fs.mkdirSync(path.join(dir, sub), { recursive: true });
return dir;
}
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('C1: every env-var host fence resolves its own runtime paths', () => {
test('each fence that uses $GSTACK_*, $B or $D assigns them in the same fence, within the byte budget', () => {
const problems: string[] = [];
let checked = 0;
for (const host of ENV_HOSTS) {
for (const file of renderedDocs(renderDir, host.hostSubdir)) {
for (const fence of bashFences(fs.readFileSync(file, 'utf8'), path.relative(renderDir, file))) {
const usesRoot = /\$\{?GSTACK_(?:ROOT|BIN|BROWSE|DESIGN|MAKE_PDF)\b/.test(fence.body);
const usesB = /\$\{?B\b/.test(fence.body);
const usesD = /\$\{?D\b/.test(fence.body);
if (!usesRoot && !usesB && !usesD) continue;
checked++;
const assigned = (name: string) => new RegExp(`(?:^|[\\s;&|(])${name}=`, 'm').test(fence.body);
if (usesRoot && !assigned('GSTACK_ROOT')) problems.push(`${fence.file}:${fence.line} uses GSTACK_* without resolving GSTACK_ROOT`);
if (usesB && !assigned('B')) problems.push(`${fence.file}:${fence.line} uses $B without deriving it`);
if (usesD && !assigned('D')) problems.push(`${fence.file}:${fence.line} uses $D without deriving it`);
if (fence.body.includes('gstack: no install found')) {
const prelude = fence.body.split('\n').filter(l => /^\[ -d "\$\{GSTACK_ROOT:-\/-\}\/bin" \]|^(?:GSTACK_(?:BIN|BROWSE|DESIGN|MAKE_PDF)=\$GSTACK_ROOT\/\S+ ?)+$|^[BD]=\$GSTACK_ROOT\//.test(l));
const bytes = Buffer.byteLength(prelude.join('\n') + '\n');
if (bytes > PRELUDE_BYTE_BUDGET) problems.push(`${fence.file}:${fence.line} prelude is ${bytes} bytes (budget ${PRELUDE_BYTE_BUDGET})`);
if (fence.body.split('gstack: no install found').length !== 2) problems.push(`${fence.file}:${fence.line} carries the prelude more than once`);
}
}
}
}
expect(checked).toBeGreaterThan(1000);
expect(problems.slice(0, 40)).toEqual([]);
});
test('Claude output is untouched by the pass', () => {
const claude = renderedDocs(renderDir, null);
expect(claude.length).toBeGreaterThan(50);
for (const file of claude) {
const text = fs.readFileSync(file, 'utf8');
expect(insertRuntimePreludes(text, ctx('claude'))).toBe(text);
expect(text).not.toContain('gstack: no install found');
}
expect(runtimeRootPrelude(ctx('claude'))).toBe('');
});
test('a fence that already assigns GSTACK_ROOT gets no prelude; a $B-only fence derives B from the BROWSE SETUP helper', () => {
expect(fencePrelude(ctx('codex'), 'GSTACK_ROOT=/x\n"$GSTACK_BIN/gstack-slug"')).toBe('');
const b = fencePrelude(ctx('codex'), '$B goto https://example.com');
expect(b.split('\n').at(-1)).toBe(binaryAssignment(ctx('codex'), 'browse'));
expect(b).toContain('gstack: no install found');
expect(fencePrelude(ctx('codex'), 'echo plain')).toBe('');
expect(fencePrelude(ctx('codex'), 'B=x; $B goto y')).toBe('');
});
});
describe('C1: the prelude resolves the right root in a fresh shell (env -i, set -u)', () => {
const echo = '\necho "ROOT=$GSTACK_ROOT BIN=$GSTACK_BIN"';
for (const host of ENV_HOSTS) {
test(`${host.name}: exported, repo-local, global and missing roots`, () => {
const w = fs.mkdtempSync(path.join(tmp, `${host.name}-`));
const home = path.join(w, 'home');
const prelude = runtimeRootPrelude(ctx(host.name));
const global = mkroot(path.join(home, host.name === 'codex' ? '.codex/skills/gstack' : host.globalRoot));
const repo = path.join(w, 'repo');
fs.mkdirSync(repo);
expect(spawnSync('git', ['init', '-q'], { cwd: repo, timeout: 10_000 }).status).toBe(0);
const outside = fs.mkdtempSync(path.join(w, 'outside-'));
let r = sh(prelude + echo, outside, { HOME: home });
expect(r.stdout.trim(), r.stderr).toBe(`ROOT=${global} BIN=${global}/bin`);
const local = mkroot(path.join(repo, host.localSkillRoot));
r = sh(prelude + echo, repo, { HOME: home });
expect(r.stdout.trim(), r.stderr).toBe(`ROOT=${local} BIN=${local}/bin`);
const exported = mkroot(path.join(w, 'exported'));
r = sh(prelude + echo, repo, { HOME: home, GSTACK_ROOT: exported });
expect(r.stdout.trim(), r.stderr).toBe(`ROOT=${exported} BIN=${exported}/bin`);
const noLib = path.join(w, 'nolib');
fs.mkdirSync(path.join(noLib, 'bin'), { recursive: true });
r = sh(prelude + echo, repo, { HOME: home, GSTACK_ROOT: noLib });
expect(r.stdout.trim(), r.stderr).toBe(`ROOT=${local} BIN=${local}/bin`);
if (host.name === 'codex') {
const codexHome = mkroot(path.join(w, 'codex-home', 'skills', 'gstack'));
r = sh(prelude + echo, outside, { HOME: home, CODEX_HOME: path.join(w, 'codex-home') });
expect(r.stdout.trim(), r.stderr).toBe(`ROOT=${codexHome} BIN=${codexHome}/bin`);
}
r = sh(prelude + echo, outside, { HOME: path.join(w, 'empty-home') });
expect(r.status).not.toBe(0);
expect(r.stdout).not.toContain('ROOT=');
expect(r.stderr).toContain('gstack: no install found (tried ');
expect(r.stderr).toContain(`Fix: ./setup --host ${host.name} from your gstack checkout; ./setup --status shows it.`);
});
}
test('a per-install render uses the literal root with no git call', () => {
const w = fs.mkdtempSync(path.join(tmp, 'literal-'));
const root = mkroot(path.join(w, 'install'));
const stub = path.join(w, 'stub');
fs.mkdirSync(stub);
fs.writeFileSync(path.join(stub, 'git'), `#!/bin/sh\necho called >> "${path.join(w, 'git.log')}"\n`, { mode: 0o755 });
const prelude = runtimeRootPrelude(ctx('codex', root));
expect(prelude).not.toContain('git ');
const r = sh(prelude + echo, w, { HOME: path.join(w, 'home'), PATH: `${stub}:/usr/bin:/bin` });
expect(r.stdout.trim(), r.stderr).toBe(`ROOT=${root} BIN=${root}/bin`);
expect(fs.existsSync(path.join(w, 'git.log'))).toBe(false);
fs.rmSync(path.join(root, 'bin'), { recursive: true });
const missing = sh(prelude + echo, w, { HOME: path.join(w, 'home'), PATH: `${stub}:/usr/bin:/bin` });
expect(missing.status).not.toBe(0);
expect(missing.stderr).toContain(`gstack: no install found (tried ${root})`);
});
});
+4 -2
View File
@@ -2917,7 +2917,9 @@ describe('Codex generation (--host codex)', () => {
// Check a skill that has a preamble (review is a good candidate)
const content = fs.readFileSync(path.join(AGENTS_DIR, 'gstack-review', 'SKILL.md'), 'utf-8');
expect(content).toContain('GSTACK_ROOT');
expect(content).toContain('$_ROOT/.agents/skills/gstack');
// C1: one shared root resolution — repo-local first, then CODEX_HOME's global root.
expect(content).toContain('_r=$(git rev-parse --show-toplevel 2>/dev/null)/.agents/skills/gstack');
expect(content).toContain('_r=${CODEX_HOME:-~/.codex}/skills/gstack');
// Phase 1/2: config reads moved into gstack-skill-start — the fence itself
// is the bin asset the preamble must resolve through $GSTACK_BIN, and the
// question-preference runtime call still resolves the same way.
@@ -3233,7 +3235,7 @@ describe('Factory generation (--host factory)', () => {
test('Factory preamble uses .factory paths', () => {
const content = fs.readFileSync(path.join(FACTORY_DIR, 'gstack-review', 'SKILL.md'), 'utf-8');
expect(content).toContain('GSTACK_ROOT');
expect(content).toContain('$_ROOT/.factory/skills/gstack');
expect(content).toContain('_r=$(git rev-parse --show-toplevel 2>/dev/null)/.factory/skills/gstack');
expect(content).toContain('$GSTACK_BIN/gstack-config');
});
});
+2 -1
View File
@@ -93,10 +93,11 @@ for (const host of ['claude', 'codex'] as const) for (const args of [[], tenArgu
test(`${label}: upgrade snooze advances the same-version level`, () => {
mkdirSync(join(root, '.gstack'), { recursive: true });
writeFileSync(join(root, '.gstack/update-snoozed'), '{new} 1 0\n');
const block = skill(host, 'gstack-upgrade').match(/```bash\n((?:GSTACK_STATE_ROOT=\$\([^\n]*gstack-paths --get GSTACK_STATE_ROOT[^\n]*\n)?_SNOOZE_FILE=[\s\S]*?)```/)![1];
const block = skill(host, 'gstack-upgrade').match(/```bash\n((?:\[ -d "\$\{GSTACK_ROOT[^\n]*\n(?:GSTACK_BIN=[^\n]*\n)?)?(?:GSTACK_STATE_ROOT=\$\([^\n]*gstack-paths --get GSTACK_STATE_ROOT[^\n]*\n)?_SNOOZE_FILE=[\s\S]*?)```/)![1];
// The block resolves the state root through the installed helper first.
const bin = join(root, '.claude/skills/gstack/bin');
mkdirSync(bin, { recursive: true });
mkdirSync(join(dirname(bin), 'lib'), { recursive: true }); // C1: an exported GSTACK_ROOT needs bin/ and lib/
for (const name of ['gstack-paths', 'gstack-state-root.sh']) writeFileSync(join(bin, name), readFileSync(join(import.meta.dir, '../bin', name)), { mode: 0o755 });
expect(run(apply(block), { GSTACK_STATE_ROOT: join(root, '.gstack'), GSTACK_BIN: bin, GSTACK_ROOT: dirname(bin) }).status).toBe(0);
expect(readFileSync(join(root, '.gstack/update-snoozed'), 'utf8')).toMatch(/^\{new\} 2 \d+\n$/);