Files
gstack/scripts/resolvers/composition.ts
T
garrytan c050f18371 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).
2026-10-03 20:59:44 +00:00

118 lines
5.7 KiB
TypeScript

import { toShellPath, type TemplateContext } from './types';
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 {
if (ctx.skillName !== 'autoplan' || args?.length) {
throw new Error('AUTOPLAN_PUBLICATION_HOOK is only valid in autoplan without arguments');
}
if (ctx.host !== 'claude') return '';
// Use the same installed runtime path as Claude's existing skill hooks.
// Emitting this through a host-specific resolver also keeps other hosts free
// of the generator's destructive-command advisory for safety hooks.
const unavailable = JSON.stringify({ hookSpecificOutput: {
hookEventName: 'PreToolUse', permissionDecision: 'deny',
permissionDecisionReason: 'Autoplan publication guard is unavailable. Restore the installed autoplan/bin/phase-publication-hook before continuing this skill.',
} });
const shellWord = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
const script = `S="${toShellPath(ctx.paths.skillRoot)}/autoplan/bin/phase-publication-hook"
if [ -f "$S" ]; then exec bash "$S"; fi
printf '%s\\n' ${shellWord(unavailable)}`;
const command = `bash -c ${shellWord(script)}`;
// Keep each tool literal: the early-question launchers admit these hooks
// through their existing finite matcher policy.
const entries = ['Read', 'Agent'].map(tool => ` - matcher: "${tool}"
hooks:
- type: command
command: ${JSON.stringify(command)}
statusMessage: "Checking Autoplan phase publication..."`).join('\n');
return `hooks:\n PreToolUse:\n${entries}`;
}
/**
* {{INVOKE_SKILL:skill-name}} — emits prose instructing Claude to read
* another skill's SKILL.md and follow it, skipping preamble sections.
*
* Supports optional skip= parameter for additional sections to skip:
* {{INVOKE_SKILL:plan-ceo-review:skip=Outside Voice,Design Outside Voices}}
*/
export function generateInvokeSkill(ctx: TemplateContext, args?: string[]): string {
const skillName = args?.[0];
if (!skillName || skillName === '') {
throw new Error('{{INVOKE_SKILL}} requires a skill name, e.g. {{INVOKE_SKILL:plan-ceo-review}}');
}
// Parse optional skip= parameter from args[1+]
const extraSkips = (args?.slice(1) || [])
.filter(a => a.startsWith('skip='))
.flatMap(a => a.slice(5).split(','))
.map(s => s.trim())
.filter(Boolean);
const DEFAULT_SKIPS = [
'Preamble (run first)',
'AskUserQuestion Format',
'Completeness Principle — Boil the Ocean',
'Search Before Building',
'Contributor Mode',
'Completion Status Protocol',
'Telemetry (run last)',
'Step 0: Detect platform and base branch',
'Review Readiness Dashboard',
'Plan File Review Report',
'Prerequisite Skill Offer',
'Plan Status Footer',
];
const allSkips = [...DEFAULT_SKIPS, ...extraSkips];
return `Read the \`/${skillName}\` skill file at \`${ctx.paths.skillRoot}/${skillName}/SKILL.md\` using the Read tool.
**If unreadable:** Skip with "Could not load /${skillName} — skipping." and continue.
Follow its instructions from top to bottom, **skipping these sections when present** (already handled by the parent skill):
${allSkips.map(s => `- ${s}`).join('\n')}
Execute every other section at full depth. When the loaded skill's instructions are complete, continue with the next step below.`;
}
/** Autoplan reads methodology from this host's skill registry, not its runtime assets. */
export function generateAutoplanReviewFile(ctx: TemplateContext, args?: string[]): string {
const skill = args?.[0];
if (!skill || !['plan-ceo-review', 'plan-design-review', 'plan-devex-review', 'plan-eng-review'].includes(skill)) {
throw new Error('AUTOPLAN_REVIEW_FILE requires an autoplan review skill');
}
const withSections = args?.[1] === 'with-sections';
if ((args?.length ?? 0) > 2 || (args?.[1] !== undefined && !withSections)) {
throw new Error('AUTOPLAN_REVIEW_FILE only accepts with-sections');
}
// Every host prepares an explicit bound artifact before create. Inline hosts
// supply one complete source file; Claude supplies main plus its carved section.
if (withSections) {
const phase = skill === 'plan-devex-review' ? 'dx' : skill.split('-')[1]!;
return `\`methodologyPath\` from \`bun "<SNAPSHOT_TOOL>" methodology ${phase} "<REVIEW_SKILL>" "<RESTORE_PATH>"\``;
}
if (ctx.host === 'claude') return `\`${ctx.paths.skillRoot}/${skill}/SKILL.md\``;
const host = getHostConfig(ctx.host);
const file = `gstack-${skill}/SKILL.md`;
const local = `${path.posix.dirname(host.localSkillRoot)}/${file}`;
const global = `~/${path.posix.dirname(host.globalRoot)}/${file}`;
// Resolve from the discovered entrypoint's directory: GSTACK_ROOT is an
// independently configurable runtime asset tree, not a skill registry.
// This also preserves custom CODEX_HOME installations without guessing HOME.
// Other hosts inline the review sections into this full registry file.
return `the sibling registry file \`../${file}\`, relative to the installed \`/autoplan\` SKILL.md directory (local: \`${local}\`; global: \`${global}\`${ctx.host === 'codex' ? ', or the corresponding skills directory under CODEX_HOME when configured' : ''})`;
}
/** Resolve once to a literal path; later phase commands run in fresh shells. */
export function generateAutoplanSnapshotTool(ctx: TemplateContext): string {
return `\`\`\`bash
${runtimeRootPrelude(ctx)}
bun -e 'console.log(require("fs").realpathSync(process.argv[1]))' "${toShellPath(ctx.paths.binDir)}/gstack-autoplan-snapshot.ts"
\`\`\``;
}