mirror of
https://github.com/garrytan/gstack.git
synced 2026-08-22 22:17:16 +02:00
* feat: model taxonomy gains gpt-5.6-sol + per-host generation defaults
Adds 'gpt-5.6-sol' to the model taxonomy with exact-match-only resolution
(Terra/Luna/suffixed IDs deliberately fall back to generic gpt) and replaces
the hardcoded 'claude' generation default with a validated
HostConfig.defaultModel: codex renders the gpt profile when --model is
absent, every other host keeps claude. Codex ship golden regenerated
accordingly; ADDING_A_HOST documents the new field.
* feat: gpt-5.6-sol bounded-scope overlay + scope-aware resolvers
The Sol profile pins the explicit task as the lake: adjacent work is
report-only, investigation is bounded, runs terminate on one clean
verification pass, and the AskUserQuestion decision-brief format is never
trimmed. The overlay wrapper grants scope-interpretation precedence while
concrete workflow steps, gates, and skill-mandated re-verification loops
still win. Sol-specific Completeness Principle and first-run intro copy.
New SETUP_COMMAND resolver renders './setup --host <host>' for every
non-claude host so generated upgrade skills reinstall their own host.
* feat: setup reads the Codex model from config.toml
New resolve-codex-generation-model.ts reads the top-level model from
${CODEX_HOME:-~/.codex}/config.toml, validates against the model allowlist,
strips control characters from every config-derived string it surfaces,
guards against non-absolute config locations, and warns on Sol near-misses.
setup runs it on EVERY invocation (read-only TOML lookup) so a plain
./setup can never clobber a Sol user's rendered profile with the hardcoded
fallback; --model <id> overrides for one run and prints the persistence
hint. Kiro installs render the claude profile before copying (Kiro fronts
Claude-family models), rewrite the baked setup command to --host kiro, and
restore the resolved Codex profile after; the codex skills path honors
CODEX_HOME. Static pins cover the resolver wiring, fail-closed exit,
quoted argv, and the Kiro sandwich.
* feat: hermetic Codex runner hardening + Sol scope-termination E2E
The Codex E2E runner copies auth.json only (operator plugins, MCP servers,
rules, and skills no longer leak into hermetic evals), pins CODEX_HOME to
the temp dir, and supports per-run model, TOML overrides, and
--ignore-user-config. New periodic E2E installs the FULL generated
investigate skill on gpt-5.6-sol against a planted one-line bug with decoy
TODOs: the fix must land inside the boundary (untracked files counted via
git status --porcelain), decoys stay byte-identical, the regression oracle
survives unweakened, nothing gets committed, all within 30 tool calls.
The shared .agents tree is snapshotted and restored exactly in beforeAll;
fixture commits disable gpg signing. Wired into the periodic CI matrix,
paid-shard globs, eval scripts, touchfiles/E2E_TIERS
(codex-sol-scope-termination), and diff-based selection. Real-file
periodic-tier classification pins both codex E2Es out of the gate tier.
Free-tier test proves an explicit --model overrides the host default
through the real generation CLI.
* chore: bump version and changelog (v1.67.2.0)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: post-ship documentation sync for v1.67.2.0
- README: Codex skills path is CODEX_HOME-aware; state that
--model overrides detection for one run only (persist via
the Codex config.toml model key)
- CONTRIBUTING: add the model-overlay axis to the per-host
config table (per-host defaultModel, override precedence)
- CLAUDE.md: eval results dir is ~/.gstack/projects/<slug>/evals/
(legacy fallback ~/.gstack-dev/evals/), matching eval-store.ts
and the eval:* CLI headers
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: post-ship documentation sync (v1.67.2.0)
Sol exact-match and near-miss warning documented in README; CODEX_HOME-aware
uninstall and troubleshooting paths; hermetic auth.json-only detail and the
build-clobber gotcha in CLAUDE.md; eval-store location corrected in
ARCHITECTURE.md; defaultModel row in the ADDING_A_HOST field reference;
resolver test count corrected in the CHANGELOG entry.
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
196 lines
7.7 KiB
TypeScript
196 lines
7.7 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 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;
|
|
|
|
// --- 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>;
|
|
/** 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';
|
|
};
|
|
|
|
// --- 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`);
|
|
}
|
|
}
|
|
}
|
|
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;
|
|
}
|