From e2d9c798b2a69cd7870513f1d280d757a4e527de Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 1 Nov 2026 21:02:04 -0800 Subject: [PATCH] v1.92.57.0 fix(hermes): plain skill-start fence that Tirith can resolve, so Hermes can start gstack skills (GST-882) (#3825) * fix: plain skill-start fence that a static resolver can follow (GST-882) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: checked-in renders keep the lookup as plain lines; regenerate goldens, lower size pins (GST-882) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: Codex preamble expectation follows the plain start lookup (GST-882) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (runtime-root header documents the plain skill-start fence, GST-882) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: Step 11 review fixes: troubleshooting names the skill-start exception; parity test pins the start lookup to the later-fence root (GST-882) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: type the parity test's env so the test typecheck ratchet stays clean (GST-882) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: changelog fragment for GST-882 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: writing-style Codex fence expectation follows the plain start lookup (GST-882) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 11 ++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- docs/troubleshooting.md | 6 +- package.json | 2 +- .../preamble/generate-preamble-bash.ts | 9 +- scripts/resolvers/runtime-root.ts | 46 +++++++- test/fixtures/golden/agy-ship-SKILL.md | 7 +- test/fixtures/golden/codex-ship-SKILL.md | 7 +- test/fixtures/golden/factory-ship-SKILL.md | 7 +- test/fixtures/skill-md-size-allowlist.json | 102 +++++++++--------- test/gen-skill-docs.test.ts | 8 +- test/gstack-skill-start.test.ts | 5 +- test/skill-start-plain.test.ts | 89 +++++++++++++++ test/writing-style-resolver.test.ts | 2 +- 15 files changed, 225 insertions(+), 80 deletions(-) create mode 100644 test/skill-start-plain.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a662182b..f3e5d12f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [1.92.57.0] - 2026-11-02 + +**Hermes can start gstack skills again (GST-882).** On stock Hermes, the Tirith scanner flagged every gstack skill's first command as HIGH ("Nested executable body could not be resolved"), so `gstack-skill-start` was denied in 8 of 8 trials. Tirith-only findings can't go on Hermes's permanent allowlist, so there was no workaround. The start line Tirith couldn't follow was a grouped fallback that picked the script through a variable. + +### Fixed +- **The skill-start line on Codex, Hermes and the other env-var hosts is plain shell.** A skill installed for one folder (Hermes, and the Codex install `./setup` writes) now checks for that folder's `bin/gstack-skill-start` and then runs it by its literal path. Tirith 0.4.2 allows that line where it blocked the old one. A checked-in render keeps the same lookup order (an exported `GSTACK_ROOT`, then the repo's own install, then the global one) as plain lines, without the `{ …; exit 1;}` group. When no install exists, both print the same "no install found" line with the setup command, and the start command then fails. +- **An install-root render's first block no longer honors an exported `GSTACK_ROOT`.** Tirith blocks any start command picked through a variable. Later blocks still honor it. `docs/troubleshooting.md` names this exception. + +### Tests +- `test/skill-start-plain.test.ts`: on every host and both render kinds, the start fence has no grouped fallback, and a missing install fails loudly under `bash -u`. The install-root start command is a literal path that runs, and a checked-in render finds the global install. On each env-var host, the start lookup picks the same root as later blocks for global, repo-local and exported installs. + ## [1.92.56.0] - 2026-11-02 **`gstack-ship-receipt write` records the session kind on Windows.** Without `--session-kind` or `GSTACK_SESSION_KIND`, it asks the `gstack-session-kind` bash helper. It used to spawn that helper by path, which never starts on Windows, so every /ship receipt there carried no `session_kind`. It now starts the helper through `lib/script-invocation.ts`, which uses Git Bash on Windows. This is the /ship half of #3541. diff --git a/VERSION b/VERSION index dd192841b..49a9e011a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.56.0 +1.92.57.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index e8c563d3f..87679d15d 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.56.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.57.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 159dda2f4..1e113eb92 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -880,7 +880,11 @@ pick a new `--output`), then copy the recovery file to where you want it. **Meaning.** On Codex, Factory, OpenCode, Cursor, Copilot and Kiro every bash block finds gstack on its own: an exported `GSTACK_ROOT` (with `bin/` and `lib/`), then a repo-local install, then the host's global install -(`$CODEX_HOME/skills/gstack` on Codex). None existed. +(`$CODEX_HOME/skills/gstack` on Codex). None existed. A skill installed for one +folder (Hermes, or the Codex install `./setup` writes) is the exception for its +first block: that block runs the folder's own `bin/gstack-skill-start` and +ignores an exported `GSTACK_ROOT`. When the folder is missing, the block prints +this line and its start command then fails. **Fix.** diff --git a/package.json b/package.json index bd579db43..fa24179e7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.92.56", + "version": "1.92.57", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/scripts/resolvers/preamble/generate-preamble-bash.ts b/scripts/resolvers/preamble/generate-preamble-bash.ts index f31997570..87f6f8935 100644 --- a/scripts/resolvers/preamble/generate-preamble-bash.ts +++ b/scripts/resolvers/preamble/generate-preamble-bash.ts @@ -1,6 +1,6 @@ import type { TemplateContext } from '../types'; import { getHostConfig } from '../../../hosts/index'; -import { runtimeRootPrelude } from '../runtime-root'; +import { skillStartLines } from '../runtime-root'; /** * Preamble bootstrap (token-reduction Phase 1). @@ -25,11 +25,10 @@ import { runtimeRootPrelude } from '../runtime-root'; */ export function generatePreambleBash(ctx: TemplateContext): string { const hostConfig = getHostConfig(ctx.host); - const runtimeRoot = hostConfig.usesEnvVars ? `${runtimeRootPrelude(ctx)}\n` : ''; + const start = hostConfig.usesEnvVars ? skillStartLines(ctx) : null; + const runtimeRoot = start ? `${start.lookup}\n` : ''; const brainHealthFlag = ctx.host === 'gbrain' || ctx.host === 'hermes' ? ' --brain-health' : ''; - const startCommand = hostConfig.usesEnvVars - ? `"${ctx.paths.binDir}/gstack-skill-start"` - : `${ctx.paths.binDir}/gstack-skill-start`; + const startCommand = start ? start.command : `${ctx.paths.binDir}/gstack-skill-start`; const entry = ['plan-design-review', 'plan-eng-review'].includes(ctx.skillName) ? `## Preamble (after scope gate) diff --git a/scripts/resolvers/runtime-root.ts b/scripts/resolvers/runtime-root.ts index 31df6b168..ad000e3a0 100644 --- a/scripts/resolvers/runtime-root.ts +++ b/scripts/resolvers/runtime-root.ts @@ -3,8 +3,9 @@ * bash finds gstack on Codex and every other `usesEnvVars` host. * * Those hosts run each fenced block in a fresh shell, so `GSTACK_ROOT`, `B`, - * `D` and `P` set by an earlier block are gone. The preamble's root resolution and - * the per-fence prelude come from `runtimeRootPrelude()`; `insertRuntimePreludes()` + * `D` and `P` set by an earlier block are gone. The preamble's skill-start fence + * comes from `skillStartLines()` and the per-fence prelude 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. @@ -13,6 +14,9 @@ * 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. + * The skill-start fence differs (GST-882): an install-root render calls the + * literal install root's start script and ignores an exported `GSTACK_ROOT`, and + * with no root either render prints that line and lets the call fail. */ import { getHostConfig } from '../../hosts/index'; import { toShellPath, type TemplateContext } from './types'; @@ -37,13 +41,45 @@ const uses = (name: string) => new RegExp(`\\$\\{?${name}\\b`); */ 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 fix = installFix(ctx); 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;}`; + return `${exported}{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/${host.localSkillRoot};[ -d "$_r/bin" ]||_r=${globalRoot(ctx)};[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). ${fix}">&2;exit 1;};GSTACK_ROOT=$_r;}`; +} + +const installFix = (ctx: TemplateContext) => `Fix: ./setup --host ${getHostConfig(ctx.host).name} from your gstack checkout; ./setup --status shows it.`; +const globalRoot = (ctx: TemplateContext) => { + const host = getHostConfig(ctx.host); + return host.name === 'codex' ? '${CODEX_HOME:-~/.codex}/skills/gstack' : `~/${host.globalRoot}`; +}; + +/** + * The skill-start fence on env-var hosts (GST-882). Hermes's Tirith blocks a + * start command it cannot resolve statically, a grouped fallback or a + * variable-selected path alike, and no allowlist entry clears that finding. An + * install-root render therefore names the literal start path; a checked-in + * render keeps resolveRoot's lookup order as plain lines. Either way a missing + * install prints the fix, and the call then fails. + */ +export function skillStartLines(ctx: TemplateContext): { lookup: string; command: string } { + const fix = installFix(ctx); + if (ctx.installRoot) { + const root = ctx.installRoot.replace(/\/+$/, ''); + return { + lookup: `[ -x "${root}/bin/gstack-skill-start" ]||echo "gstack: no install found (tried ${root}). ${fix}">&2`, + command: `"${root}/bin/gstack-skill-start"`, + }; + } + return { + lookup: [ + `[ -d "\${GSTACK_ROOT:-/-}/lib" ]&&[ -d "$GSTACK_ROOT/bin" ]||GSTACK_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)/${getHostConfig(ctx.host).localSkillRoot}`, + `[ -d "$GSTACK_ROOT/bin" ]||GSTACK_ROOT=${globalRoot(ctx)}`, + `[ -d "$GSTACK_ROOT/bin" ]||echo "gstack: no install found (tried $GSTACK_ROOT). ${fix}">&2`, + ].join('\n'), + command: '"$GSTACK_ROOT/bin/gstack-skill-start"', + }; } const DERIVED: Record = { BIN: 'bin', BROWSE: 'browse/dist', DESIGN: 'design/dist', MAKE_PDF: 'make-pdf/dist' }; diff --git a/test/fixtures/golden/agy-ship-SKILL.md b/test/fixtures/golden/agy-ship-SKILL.md index 8d7459135..832c00e42 100644 --- a/test/fixtures/golden/agy-ship-SKILL.md +++ b/test/fixtures/golden/agy-ship-SKILL.md @@ -17,9 +17,10 @@ This skill follows gstack shared preamble `6872f1ec4eb9` (AskUserQuestion format ## Preamble (run first) ```bash -[ -d "${GSTACK_ROOT:-/-}/bin" ]&&[ -d "$GSTACK_ROOT/lib" ]||{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/.agents/skills/gstack;[ -d "$_r/bin" ]||_r=~/.gemini/antigravity-cli/skills/gstack;[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). Fix: ./setup --host agy from your gstack checkout; ./setup --status shows it.">&2;exit 1;};GSTACK_ROOT=$_r;} -GSTACK_BIN=$GSTACK_ROOT/bin -"$GSTACK_BIN/gstack-skill-start" --skill "ship" --model "gemini" +[ -d "${GSTACK_ROOT:-/-}/lib" ]&&[ -d "$GSTACK_ROOT/bin" ]||GSTACK_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)/.agents/skills/gstack +[ -d "$GSTACK_ROOT/bin" ]||GSTACK_ROOT=~/.gemini/antigravity-cli/skills/gstack +[ -d "$GSTACK_ROOT/bin" ]||echo "gstack: no install found (tried $GSTACK_ROOT). Fix: ./setup --host agy from your gstack checkout; ./setup --status shows it.">&2 +"$GSTACK_ROOT/bin/gstack-skill-start" --skill "ship" --model "gemini" ``` **Antigravity tool names:** `AskUserQuestion` means your `ask_question` tool (options as choices); there is no `mcp__*__AskUserQuestion` variant. The Bash tool means `run_command`, the Read tool `view_file`, the Write tool `write_to_file`, the Edit tool `replace_file_content` (`multi_replace_file_content` for several edits in one file), Grep `grep_search`, Glob `find_by_name`, and the Agent tool `invoke_subagent`. There is no Skill tool: load a skill by reading its SKILL.md with `view_file`. If a step says to call `ExitPlanMode`, tell the user the plan is ready and wait. `view_file` can return only part of a long file: when a SKILL.md or a file it tells you to read ends before its last line, read the remaining line ranges before acting on it. diff --git a/test/fixtures/golden/codex-ship-SKILL.md b/test/fixtures/golden/codex-ship-SKILL.md index a79b3abb0..3ae35dbb3 100644 --- a/test/fixtures/golden/codex-ship-SKILL.md +++ b/test/fixtures/golden/codex-ship-SKILL.md @@ -17,9 +17,10 @@ This skill follows gstack shared preamble `2af24c2d75e4` (AskUserQuestion format ## Preamble (run first) ```bash -[ -d "${GSTACK_ROOT:-/-}/bin" ]&&[ -d "$GSTACK_ROOT/lib" ]||{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/.agents/skills/gstack;[ -d "$_r/bin" ]||_r=${CODEX_HOME:-~/.codex}/skills/gstack;[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). Fix: ./setup --host codex from your gstack checkout; ./setup --status shows it.">&2;exit 1;};GSTACK_ROOT=$_r;} -GSTACK_BIN=$GSTACK_ROOT/bin -"$GSTACK_BIN/gstack-skill-start" --skill "ship" --model "gpt" +[ -d "${GSTACK_ROOT:-/-}/lib" ]&&[ -d "$GSTACK_ROOT/bin" ]||GSTACK_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)/.agents/skills/gstack +[ -d "$GSTACK_ROOT/bin" ]||GSTACK_ROOT=${CODEX_HOME:-~/.codex}/skills/gstack +[ -d "$GSTACK_ROOT/bin" ]||echo "gstack: no install found (tried $GSTACK_ROOT). Fix: ./setup --host codex from your gstack checkout; ./setup --status shows it.">&2 +"$GSTACK_ROOT/bin/gstack-skill-start" --skill "ship" --model "gpt" ``` Read the echoed `KEY: value` STATUS lines — they drive every preamble rule diff --git a/test/fixtures/golden/factory-ship-SKILL.md b/test/fixtures/golden/factory-ship-SKILL.md index 1099c7e0c..1eabf0836 100644 --- a/test/fixtures/golden/factory-ship-SKILL.md +++ b/test/fixtures/golden/factory-ship-SKILL.md @@ -19,9 +19,10 @@ This skill follows gstack shared preamble `99cd6cb5d373` (AskUserQuestion format ## Preamble (run first) ```bash -[ -d "${GSTACK_ROOT:-/-}/bin" ]&&[ -d "$GSTACK_ROOT/lib" ]||{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/.factory/skills/gstack;[ -d "$_r/bin" ]||_r=~/.factory/skills/gstack;[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). Fix: ./setup --host factory from your gstack checkout; ./setup --status shows it.">&2;exit 1;};GSTACK_ROOT=$_r;} -GSTACK_BIN=$GSTACK_ROOT/bin -"$GSTACK_BIN/gstack-skill-start" --skill "ship" --model "claude" +[ -d "${GSTACK_ROOT:-/-}/lib" ]&&[ -d "$GSTACK_ROOT/bin" ]||GSTACK_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)/.factory/skills/gstack +[ -d "$GSTACK_ROOT/bin" ]||GSTACK_ROOT=~/.factory/skills/gstack +[ -d "$GSTACK_ROOT/bin" ]||echo "gstack: no install found (tried $GSTACK_ROOT). Fix: ./setup --host factory from your gstack checkout; ./setup --status shows it.">&2 +"$GSTACK_ROOT/bin/gstack-skill-start" --skill "ship" --model "claude" ``` Read the echoed `KEY: value` STATUS lines — they drive every preamble rule diff --git a/test/fixtures/skill-md-size-allowlist.json b/test/fixtures/skill-md-size-allowlist.json index 2547e6adb..6aaf50c25 100644 --- a/test/fixtures/skill-md-size-allowlist.json +++ b/test/fixtures/skill-md-size-allowlist.json @@ -2,56 +2,56 @@ "_comment": "Generated SKILL.md files over the limit, pinned at their exact character count (#3199). test/skill-md-size-guard.test.ts fails when a pinned skill grows past its pin or shrinks below it: lower the pin when a skill shrinks and delete it once the skill is within the limit. Raising a pin is an owner decision. The shared-preamble hoist (GSTA-96) moved every tier 2+ skill's shared preamble into sections/preamble.md; the pins below are what remains.", "limit": 100000, "pins": { - "agy/gstack-autoplan": 109666, - "agy/gstack-plan-design-review": 109116, - "agy/gstack-plan-devex-review": 114957, - "agy/gstack-plan-eng-review": 143590, - "agy/gstack-review": 104276, - "codex/gstack-autoplan": 108409, - "codex/gstack-plan-design-review": 110473, - "codex/gstack-plan-devex-review": 116465, - "codex/gstack-plan-eng-review": 144755, - "codex/gstack-review": 103799, - "copilot/gstack-autoplan": 109109, - "copilot/gstack-plan-design-review": 109382, - "copilot/gstack-plan-devex-review": 114419, - "copilot/gstack-plan-eng-review": 143755, - "copilot/gstack-review": 103857, - "cursor/gstack-autoplan": 108341, - "cursor/gstack-plan-design-review": 107924, - "cursor/gstack-plan-devex-review": 113689, - "cursor/gstack-plan-eng-review": 142345, - "cursor/gstack-review": 122147, - "factory/gstack-autoplan": 110619, - "factory/gstack-plan-design-review": 111022, - "factory/gstack-plan-devex-review": 117052, - "factory/gstack-plan-eng-review": 145453, - "factory/gstack-review": 125920, - "gbrain/gstack-autoplan": 108748, - "gbrain/gstack-plan-eng-review": 124972, - "gemini/gstack-autoplan": 108899, - "gemini/gstack-plan-design-review": 109210, - "gemini/gstack-plan-devex-review": 114247, - "gemini/gstack-plan-eng-review": 143588, - "gemini/gstack-review": 103679, - "hermes/gstack-autoplan": 108751, - "hermes/gstack-plan-eng-review": 125008, - "kiro/gstack-autoplan": 108115, - "kiro/gstack-plan-design-review": 107770, - "kiro/gstack-plan-devex-review": 113541, - "kiro/gstack-plan-eng-review": 142207, - "kiro/gstack-review": 121971, - "openclaw/gstack-autoplan": 108571, - "openclaw/gstack-plan-eng-review": 123487, - "opencode/gstack-autoplan": 109090, - "opencode/gstack-plan-design-review": 109238, - "opencode/gstack-plan-devex-review": 114297, - "opencode/gstack-plan-eng-review": 143614, - "opencode/gstack-review": 122769, - "slate/gstack-autoplan": 108228, - "slate/gstack-plan-design-review": 107847, - "slate/gstack-plan-devex-review": 113615, - "slate/gstack-plan-eng-review": 142276, - "slate/gstack-review": 122059 + "agy/gstack-autoplan": 109657, + "agy/gstack-plan-design-review": 109107, + "agy/gstack-plan-devex-review": 114948, + "agy/gstack-plan-eng-review": 143581, + "agy/gstack-review": 104267, + "codex/gstack-autoplan": 108400, + "codex/gstack-plan-design-review": 110464, + "codex/gstack-plan-devex-review": 116456, + "codex/gstack-plan-eng-review": 144746, + "codex/gstack-review": 103790, + "copilot/gstack-autoplan": 109100, + "copilot/gstack-plan-design-review": 109373, + "copilot/gstack-plan-devex-review": 114410, + "copilot/gstack-plan-eng-review": 143746, + "copilot/gstack-review": 103848, + "cursor/gstack-autoplan": 108332, + "cursor/gstack-plan-design-review": 107915, + "cursor/gstack-plan-devex-review": 113680, + "cursor/gstack-plan-eng-review": 142336, + "cursor/gstack-review": 122138, + "factory/gstack-autoplan": 110610, + "factory/gstack-plan-design-review": 111013, + "factory/gstack-plan-devex-review": 117043, + "factory/gstack-plan-eng-review": 145444, + "factory/gstack-review": 125911, + "gbrain/gstack-autoplan": 108739, + "gbrain/gstack-plan-eng-review": 124963, + "gemini/gstack-autoplan": 108890, + "gemini/gstack-plan-design-review": 109201, + "gemini/gstack-plan-devex-review": 114238, + "gemini/gstack-plan-eng-review": 143579, + "gemini/gstack-review": 103670, + "hermes/gstack-autoplan": 108742, + "hermes/gstack-plan-eng-review": 124999, + "kiro/gstack-autoplan": 108106, + "kiro/gstack-plan-design-review": 107761, + "kiro/gstack-plan-devex-review": 113532, + "kiro/gstack-plan-eng-review": 142198, + "kiro/gstack-review": 121962, + "openclaw/gstack-autoplan": 108562, + "openclaw/gstack-plan-eng-review": 123478, + "opencode/gstack-autoplan": 109081, + "opencode/gstack-plan-design-review": 109229, + "opencode/gstack-plan-devex-review": 114288, + "opencode/gstack-plan-eng-review": 143605, + "opencode/gstack-review": 122760, + "slate/gstack-autoplan": 108219, + "slate/gstack-plan-design-review": 107838, + "slate/gstack-plan-devex-review": 113606, + "slate/gstack-plan-eng-review": 142267, + "slate/gstack-review": 122050 } } diff --git a/test/gen-skill-docs.test.ts b/test/gen-skill-docs.test.ts index b6296dc27..c932dd13d 100644 --- a/test/gen-skill-docs.test.ts +++ b/test/gen-skill-docs.test.ts @@ -3125,9 +3125,11 @@ describe('Codex generation (--host codex)', () => { 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. - expect(content).toContain('$GSTACK_BIN/gstack-skill-start'); + // is the bin asset the preamble resolves through the same roots, as plain + // lines (GST-882), and the question-preference runtime call resolves + // through $GSTACK_BIN. + expect(content).toContain('[ -d "$GSTACK_ROOT/bin" ]||GSTACK_ROOT=${CODEX_HOME:-~/.codex}/skills/gstack'); + expect(content).toContain('"$GSTACK_ROOT/bin/gstack-skill-start"'); expect(content).toContain('$GSTACK_BIN/gstack-question-preference'); // The upgrade-skill doc reference moved into the script's upgrade-flow // block, resolved $0-relative ($_ROOT_DIR) — host-neutral by construction, diff --git a/test/gstack-skill-start.test.ts b/test/gstack-skill-start.test.ts index 1fd895132..3764ba518 100644 --- a/test/gstack-skill-start.test.ts +++ b/test/gstack-skill-start.test.ts @@ -118,7 +118,8 @@ describe('gstack-skill-start contract', () => { // Worktree-isolated Claude Code sessions refuse a start command named by a // variable, a `[ -x ] ||` fallback, or a trailing `|| echo` (reproduced // with the pinned CLI). The Claude render is the literal install path; - // env-var hosts reach the script through $GSTACK_BIN. A missing helper is + // checked-in env-var host renders reach it through $GSTACK_ROOT/bin (an + // install-root render names the literal path, GST-882). A missing helper is // covered by the degraded-mode prose, not a shell fallback. const fence = (text: string) => { const at = text.search(/^## Preamble \((?:run first|after scope gate)\)/m); @@ -130,7 +131,7 @@ describe('gstack-skill-start contract', () => { } for (const host of ['codex', 'gbrain']) { const lines = fence(generatePreamble({ skillName: 'ship', tmplPath: 'ship/SKILL.md.tmpl', host, paths: HOST_PATHS[host], preambleTier: 4 } as TemplateContext)).split('\n'); - expect(lines.at(-1)).toMatch(/^"\$GSTACK_BIN\/gstack-skill-start" --skill "ship" --model "[^"]+"( --brain-health)?$/); + expect(lines.at(-1)).toMatch(/^"\$GSTACK_ROOT\/bin\/gstack-skill-start" --skill "ship" --model "[^"]+"( --brain-health)?$/); expect(lines.join('\n')).not.toMatch(/_SS|\$PPID|\|\| echo|\[ -x/); } }); diff --git a/test/skill-start-plain.test.ts b/test/skill-start-plain.test.ts new file mode 100644 index 000000000..a81ba628a --- /dev/null +++ b/test/skill-start-plain.test.ts @@ -0,0 +1,89 @@ +/** + * GST-882: the skill-start fence must be plain shell a static resolver can + * follow. Hermes's Tirith 0.4.2 blocks a grouped fallback + * (`[ -d … ]||{ …; exit 1;}`) and any variable-selected command with HIGH + * "Nested executable body could not be resolved", which no allowlist entry can + * clear, so every gstack skill start was denied. An install-root render names + * the literal start path; every render keeps a loud failure when gstack is + * missing. + */ +import { 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 { generatePreambleBash } from '../scripts/resolvers/preamble/generate-preamble-bash'; +import { insertRuntimePreludes, runtimeRootPrelude } from '../scripts/resolvers/runtime-root'; +import { HOST_PATHS, type TemplateContext } from '../scripts/resolvers/types'; + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gst882-')); +const ctx = (host: string, installRoot: string | null) => + ({ host, paths: HOST_PATHS[host], skillName: 'ship', tmplPath: '', model: 'claude', installRoot }) as TemplateContext; +const startFence = (c: TemplateContext) => insertRuntimePreludes(generatePreambleBash(c), c).match(/```bash\n([\s\S]*?)\n```/)![1]; +const GROUPED = /(?:^|[;&|]\s*)\{\s|exit 1;\s*\}/m; +const sh = (body: string, env: Record, cwd = tmp) => + spawnSync('bash', ['-uc', body], { cwd, encoding: 'utf8', timeout: 10_000, env: { PATH: '/usr/bin:/bin', ...env } }); +function stubStart(root: string) { + fs.mkdirSync(path.join(root, 'bin'), { recursive: true }); + fs.mkdirSync(path.join(root, 'lib'), { recursive: true }); + fs.writeFileSync(path.join(root, 'bin', 'gstack-skill-start'), '#!/bin/sh\necho "STARTED $0 $*"\n', { mode: 0o755 }); +} + +describe('skill-start fence is plain shell on every host (GST-882)', () => { + for (const host of ALL_HOST_CONFIGS) { + for (const installRoot of [null, path.join(tmp, `${host.name}-install`)]) { + const label = `${host.name} ${installRoot ? 'install-root' : 'checked-in'} render`; + test(`${label}: no grouped fallback`, () => { + expect(startFence(ctx(host.name, installRoot))).not.toMatch(GROUPED); + }); + if (!host.usesEnvVars) continue; + test(`${label}: a missing install fails loudly`, () => { + const r = sh(startFence(ctx(host.name, installRoot)), { HOME: path.join(tmp, 'empty-home') }); + expect(r.status).not.toBe(0); + expect(r.stderr).toContain('gstack: no install found'); + expect(r.stderr).toContain(`./setup --host ${host.name}`); + }); + } + if (!host.usesEnvVars) continue; + test(`${host.name} install-root render: the start command is a literal path that runs`, () => { + const root = path.join(tmp, `${host.name}-literal`); + const fence = startFence(ctx(host.name, root)); + const command = fence.split('\n').at(-1)!; + expect(command).toStartWith(`"${root}/bin/gstack-skill-start" --skill "ship"`); + expect(fence).not.toContain('$'); + stubStart(root); + const r = sh(fence, { HOME: path.join(tmp, 'empty-home') }); + expect(r.status, r.stderr).toBe(0); + expect(r.stdout).toContain(`STARTED ${root}/bin/gstack-skill-start --skill ship`); + }); + test(`${host.name} checked-in render: finds the global install`, () => { + const home = path.join(tmp, `${host.name}-home`); + const global = path.join(home, host.name === 'codex' ? '.codex/skills/gstack' : host.globalRoot); + stubStart(global); + const r = sh(startFence(ctx(host.name, null)), { HOME: home }); + expect(r.status, r.stderr).toBe(0); + expect(r.stdout).toContain('STARTED '); + expect(r.stdout).toContain('--skill ship'); + }); + test(`${host.name} checked-in render: the start lookup picks the root later fences pick`, () => { + const base = path.join(tmp, `${host.name}-parity`); + const home = path.join(base, 'home'); + const repo = path.join(base, 'repo'); + const exported = path.join(base, 'exported'); + stubStart(path.join(home, host.name === 'codex' ? '.codex/skills/gstack' : host.globalRoot)); + fs.mkdirSync(repo, { recursive: true }); + spawnSync('git', ['init', '-q', repo], { timeout: 10_000 }); + const c = ctx(host.name, null); + const lookup = startFence(c).split('\n').slice(0, -1).join('\n'); + const root = (body: string, env: Record) => sh(`${body}\necho "ROOT=$GSTACK_ROOT"`, env, repo).stdout.trim().split('\n').at(-1); + for (const layout of ['global', 'repo-local', 'exported']) { + if (layout === 'repo-local') stubStart(path.join(repo, host.localSkillRoot)); + if (layout === 'exported') stubStart(exported); + const env: Record = layout === 'exported' ? { HOME: home, GSTACK_ROOT: exported } : { HOME: home }; + expect(root(lookup, env)).toBe(root(runtimeRootPrelude(c), env)); + if (layout === 'exported') expect(root(lookup, env)).toBe(`ROOT=${exported}`); + } + }); + } +}); diff --git a/test/writing-style-resolver.test.ts b/test/writing-style-resolver.test.ts index 092c2c200..b2adc3621 100644 --- a/test/writing-style-resolver.test.ts +++ b/test/writing-style-resolver.test.ts @@ -94,7 +94,7 @@ describe('Writing Style preamble section', () => { const fenceLine = out.split('\n').find(l => l.includes('gstack-skill-start" --skill')); expect(fenceLine).toBeDefined(); expect(fenceLine).not.toMatch(/~\/\.claude\//); - expect(fenceLine).toContain('$GSTACK_BIN'); + expect(fenceLine).toContain('$GSTACK_ROOT/bin/'); }); test('tier 1 preamble does NOT include Writing Style section', () => {