Files
gstack/scripts/host-config.ts
T
ac20ef1e13 v1.91.16.0 fix: safe installs and upgrades, skills start everywhere, truthful completion (parallel wave) (#3014)
* fix(make-pdf,design): real TOC page numbers and anchors; design models move to gpt-5.5 + gpt-image-2 (#2903, #2807)

* ci: pin every action to a SHA, Scorecard, checksummed Bun, scoped Codex CLI in the CI image, macOS named-regressions job (#1948, #1706, #1997)

* fix(cso,browse): Windows/macOS lease identity, retention skips bad runs, truthful 'not assessed' reports, env.kv false positive, Windows browse spawn fixes (#2894, #2912, #2048, #2637, #2805)

* fix(outside-voice): one Codex model precedence for probe and dispatch, bounded timeouts with KILL escalation, probe cache identity, guarded mktemp, skills hidden in nested Codex (#2776, #2914, #2787, #2881, #2847)

* feat(setup): install registry, ./setup --status, staged activation, per-scope migrations, host tiers and conformance kit, OpenCode/Hermes fixes, Copilot CLI (experimental), disabled_skills, slimmer CLAUDE.md (#1925, #2879, #2347, #2338, #2626, #2629, #2825, #2015, #393, #1206, #2096)

Co-authored-by: Andrey Esipov <61357035+andrey-esipov@users.noreply.github.com>
Co-authored-by: Matthew Williams <5863886+ridermw@users.noreply.github.com>
Co-authored-by: Rocky Wu <92764750+lolisaigao1234@users.noreply.github.com>

* fix(preamble): skills start in worktree-isolated Claude Code sessions — one literal skill-start line, context-recovery helper, no eval in skill blocks, $N token tripwire, honest plan-mode precedence (#2763, #2896, #2851)

* fix(setup): per-install renders for renamed, vendored and custom-config installs, space-safe alias roots, per-host /cso launcher path (#1882, #2906)

* test: free tests never write the real HOME (home-write tripwire), no orphaned browse daemons, symlinked-TMPDIR (macOS) fixes, gbrain-on-PATH isolation (#2895, #2816, #2829, #2841, #2982, #2911, #2909, #2908, #2910)

* chore: integrate wave follow-ups — regenerate all hosts, README model docs, TODOS, touchfiles, duration seeds, Bun 1.3 telemetry test fix, replay fixtures pinned to their recorded bytes

* v1.91.14.0: release metadata

* test(browse): group-kill cleanup falls back to the leader when macOS returns EPERM for a live group

* ci: Codex login steps run under bash (the CI container's default sh rejects pipefail)

* fix: full-suite repairs — private state roots for 7 real-HOME writers, truthful home-guard attribution with --attribute-home, gstack-paths shim in ship-hook fixtures, Copilot in team-init probes, test-of-test detector, cookie review fixture pinned to the approved excerpt

* fix(windows): LF for Dockerfiles, normalized home-guard exclusions, path.relative fixture containment, ship push test spawn timeouts

* test(design-detect): widen the whole-scan budget test's timing margins so a loaded macOS runner can't time out the single-batch call

* fix(evals): qa protocol names defined for the judge, async PTY judge so overlapping sessions keep their cleanup deadline, bws setup cases use the standard JUDGE_MS tier

* test(pty): the non-blocking judge case is POSIX-only (its fake judge is a shebang script, like the PTY harness it covers)

* test(evals): ship-coverage-audit names absolute skill paths, uses Read, and skips the Step 7 artifact nothing consumes (removes the mechanism behind 3 of 4 historical timeouts)

* test: deflake terminal-agent concurrent startup (write the placeholder record before spawning, like the product) and qa-evidence resumed pipe (barrier on a full high-water mark instead of a sleep)

* fix(design): daemon log stream handles open errors instead of crashing the daemon; shutdown test awaits the stubbed exit

* test(evals): skillify checks the product's resolved state root (no GSTACK_HOME bypass), negative cases check every root; tpa shims live outside the agent's cwd and answer consistently

* test(evals): coverage-audit detector splits parallel columns at │ rails and [+] groups; coverage prompts name absolute skill paths; TODOs from the gate eval census

* fix(plan-eng-review): scope gate always sends the menu (no headless 'Scope pending' exit); PTY harness ends idle turn-ends and API-error panels promptly with a precise diagnostic

* test(evals): one position-bound coverage-read rule credits read-only && chains (labels, context ranges, git displays in either order) and still rejects forged output

* fix(ship): documentation section has one saved source inserted by cat (never retyped); spawned-marker evidence is the child output; refreshed codex/factory ship goldens

* test(docsync): the byte-exact insert positive is POSIX-only (the bash-command observer refuses backslash paths by design)

* test(evals): plan-design-with-ui focus-title rule accepts the comma-less 'dimensions or focus' wording (replayed on the captured CI run, negative controls kept)

* fix(qa-callers): exploratory preflight says the scope/method Reads come after exploratory.md; design-review scan check credits an executed scan proven by one engine run over the changed files

---------

Co-authored-by: Andrey Esipov <61357035+andrey-esipov@users.noreply.github.com>
Co-authored-by: Matthew Williams <5863886+ridermw@users.noreply.github.com>
Co-authored-by: Rocky Wu <92764750+lolisaigao1234@users.noreply.github.com>
2026-10-03 07:54:09 -07:00

252 lines
10 KiB
TypeScript

/**
* Declarative host config system.
*
* Each supported host (Claude, Codex, Factory, OpenCode, OpenClaw, etc.) is
* defined as a typed HostConfig object in hosts/*.ts. This module provides
* the interface, loader, and validator.
*
* Architecture:
* hosts/*.ts → hosts/index.ts → host-config.ts (this file)
* │ │
* └── typed configs ──────────────────→ consumed by gen-skill-docs.ts,
* setup (via host-config-export.ts),
* skill-check.ts, worktree.ts,
* platform-detect, uninstall
*/
import type { Model } from './models';
import { validateModel } from './models';
export type HostTier = 'full' | 'experimental' | 'instruction-only';
export const HOST_TIERS: readonly HostTier[] = ['full', 'experimental', 'instruction-only'];
export interface HostCapabilities {
/** Runs shell commands, so bin/ helpers and preambles execute. */
toolExecution: boolean;
/** 'native': a structured question tool; 'prose': questions are plain text. */
questions: 'native' | 'prose';
/** Has plan-mode transitions (enter/exit plan mode). */
planMode: boolean;
/** Can delegate work to sub-agents. */
delegation: boolean;
/** Can drive gstack's browser (needs toolExecution). */
browser: boolean;
/** 'enforced': hooks can block a tool call; 'advisory': safety skills only warn. */
safetyHooks: 'enforced' | 'advisory';
}
export interface HostConfig {
/** Unique host identifier (e.g., 'opencode'). Must match filename in hosts/. */
name: string;
/** Human-readable name for UI/logs (e.g., 'OpenCode'). */
displayName: string;
/** Binary name for `command -v` detection (e.g., 'opencode'). */
cliCommand: string;
/** Alternative binary names (e.g., ['droid'] for factory). */
cliAliases?: string[];
/** Model overlay used when generation does not receive an explicit --model. */
defaultModel: Model;
// --- Support Contract (docs/ADDING_A_HOST.md "Host tiers and capabilities") ---
/**
* full: installs, renders, and has a dated certification record (a real
* workflow run plus an upgrade from an existing install).
* experimental: installs and renders; not certified by a real run.
* instruction-only: no install arm; setup prints instructions and changes nothing.
*/
tier: HostTier;
/** What the host runtime offers. Rendering and docs must not claim more. */
capabilities: HostCapabilities;
// --- Path Configuration ---
/** Global install path relative to $HOME (e.g., '.config/opencode/skills/gstack'). */
globalRoot: string;
/** Project-local skill path relative to repo root (e.g., '.opencode/skills/gstack'). */
localSkillRoot: string;
/** Gitignored directory under repo root for generated docs (e.g., '.opencode'). */
hostSubdir: string;
/** Whether preamble generates $GSTACK_ROOT env vars (true for non-Claude hosts). */
usesEnvVars: boolean;
// --- Frontmatter Transformation ---
frontmatter: {
/** 'allowlist': ONLY keepFields survive. 'denylist': strip listed fields. */
mode: 'allowlist' | 'denylist';
/** Fields to preserve (allowlist mode only). */
keepFields?: string[];
/** Fields to remove (denylist mode only). */
stripFields?: string[];
/** Max chars for description field. null = no limit. */
descriptionLimit?: number | null;
/** What to do when description exceeds limit. Default: 'error'. */
descriptionLimitBehavior?: 'error' | 'truncate' | 'warn';
/** Additional frontmatter fields to inject (host-wide). */
extraFields?: Record<string, unknown>;
/**
* Write the installed directory name (gstack-<skill>) into `name:`. For
* hosts that index and de-duplicate skills by frontmatter name, where a
* bare `review` would be shadowed by any other `review` skill (#2825).
*/
nameMatchesDirectory?: boolean;
/** Rename fields from template (e.g., { 'voice-triggers': 'triggers' }). */
renameFields?: Record<string, string>;
/** Conditionally add fields based on template frontmatter values. */
conditionalFields?: Array<{ if: Record<string, unknown>; add: Record<string, unknown> }>;
};
// --- Generation ---
generation: {
/** Whether to create a metadata file alongside skills (always openai.yaml; gen-skill-docs hardcodes the format). */
generateMetadata: boolean;
/** Skill directories to exclude from generation for this host. */
skipSkills?: string[];
/** Skill directories to include (allowlist). Union logic: include minus skip. */
includeSkills?: string[];
};
// --- Content Rewrites ---
/** Literal string replacements on generated SKILL.md content. Order matters, replaceAll. */
pathRewrites: Array<{ from: string; to: string }>;
/** Tool name string replacements on content. */
toolRewrites?: Record<string, string>;
/** Resolver functions that return empty string for this host. */
suppressedResolvers?: string[];
// --- Runtime Root ---
runtimeRoot: {
/** Explicit asset list for global install symlinks (no globs). */
globalSymlinks: string[];
/** Dir → explicit file list for selective file linking. */
globalFiles?: Record<string, string[]>;
};
// --- Install Behavior ---
install: {
/** How skills are linked into the host dir. */
linkingStrategy: 'real-dir-symlink' | 'symlink-generated';
/**
* Instruction-only fallback tier: a committed rules digest this host's
* users can hand-copy when there is no full install arm. Delivery is
* print-path + user-performed copy ONLY — setup must never write or
* overwrite a user's own AGENTS.md. See scripts/gen-agents-digest.ts.
*/
instructionTier?: { rulesFile: string };
};
// --- Host-Specific Behavioral Config ---
/** Git co-author trailer string. */
coAuthorTrailer?: string;
/** Learnings implementation: 'full' = cross-project, 'basic' = simple. */
learningsMode?: 'full' | 'basic';
/** Anti-prompt-injection boundary instruction for cross-model invocations. */
boundaryInstruction?: string;
}
// --- Validation ---
const NAME_REGEX = /^[a-z][a-z0-9-]*$/;
const PATH_REGEX = /^[a-zA-Z0-9_.\/${}~-]+$/;
const CLI_REGEX = /^[a-z][a-z0-9_-]*$/;
export function validateHostConfig(config: HostConfig, validResolverNames?: ReadonlySet<string>): string[] {
const errors: string[] = [];
if (!NAME_REGEX.test(config.name)) {
errors.push(`name '${config.name}' must be lowercase alphanumeric with hyphens`);
}
if (!config.displayName) {
errors.push('displayName is required');
}
if (!CLI_REGEX.test(config.cliCommand)) {
errors.push(`cliCommand '${config.cliCommand}' contains invalid characters`);
}
if (config.cliAliases) {
for (const alias of config.cliAliases) {
if (!CLI_REGEX.test(alias)) {
errors.push(`cliAlias '${alias}' contains invalid characters`);
}
}
}
if (!HOST_TIERS.includes(config.tier)) {
errors.push(`tier '${config.tier}' must be one of ${HOST_TIERS.join(', ')}`);
}
const caps = config.capabilities;
if (!caps) {
errors.push('capabilities are required');
} else {
if (caps.browser && !caps.toolExecution) errors.push('capabilities.browser requires toolExecution');
if (!['native', 'prose'].includes(caps.questions)) errors.push(`capabilities.questions must be 'native' or 'prose'`);
if (!['enforced', 'advisory'].includes(caps.safetyHooks)) errors.push(`capabilities.safetyHooks must be 'enforced' or 'advisory'`);
}
if (config.tier === 'instruction-only' && !config.install.instructionTier && config.name !== 'slate' && config.name !== 'gbrain') {
errors.push('instruction-only hosts must declare install.instructionTier (what setup tells the user to copy)');
}
const modelError = validateModel(config.defaultModel);
if (modelError) {
errors.push(`defaultModel ${modelError}`);
}
if (!PATH_REGEX.test(config.globalRoot)) {
errors.push(`globalRoot '${config.globalRoot}' contains invalid characters`);
}
if (!PATH_REGEX.test(config.localSkillRoot)) {
errors.push(`localSkillRoot '${config.localSkillRoot}' contains invalid characters`);
}
if (!PATH_REGEX.test(config.hostSubdir)) {
errors.push(`hostSubdir '${config.hostSubdir}' contains invalid characters`);
}
if (!['allowlist', 'denylist'].includes(config.frontmatter.mode)) {
errors.push(`frontmatter.mode must be 'allowlist' or 'denylist'`);
}
if (!['real-dir-symlink', 'symlink-generated'].includes(config.install.linkingStrategy)) {
errors.push(`install.linkingStrategy must be 'real-dir-symlink' or 'symlink-generated'`);
}
// Cross-check suppressedResolvers against the known resolver names (injected to avoid a
// circular import on the resolver registry). A typo would otherwise silently no-op: the
// generator short-circuits suppressed names before the "unknown placeholder" throw, so an
// unknown entry never surfaces at generation time either.
if (validResolverNames && config.suppressedResolvers) {
for (const name of config.suppressedResolvers) {
if (!validResolverNames.has(name)) {
errors.push(`suppressedResolvers entry '${name}' is not a known resolver`);
}
}
}
return errors;
}
export function validateAllConfigs(configs: HostConfig[], validResolverNames?: ReadonlySet<string>): string[] {
const errors: string[] = [];
// Per-config validation
for (const config of configs) {
const configErrors = validateHostConfig(config, validResolverNames);
errors.push(...configErrors.map(e => `[${config.name}] ${e}`));
}
// Cross-config uniqueness checks
const hostSubdirs = new Map<string, string>();
const globalRoots = new Map<string, string>();
const names = new Map<string, string>();
for (const config of configs) {
if (names.has(config.name)) {
errors.push(`Duplicate name '${config.name}' (also used by ${names.get(config.name)})`);
}
names.set(config.name, config.name);
if (hostSubdirs.has(config.hostSubdir)) {
errors.push(`Duplicate hostSubdir '${config.hostSubdir}' (${config.name} and ${hostSubdirs.get(config.hostSubdir)})`);
}
hostSubdirs.set(config.hostSubdir, config.name);
if (globalRoots.has(config.globalRoot)) {
errors.push(`Duplicate globalRoot '${config.globalRoot}' (${config.name} and ${globalRoots.get(config.globalRoot)})`);
}
globalRoots.set(config.globalRoot, config.name);
}
return errors;
}