mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
* feat(skills): absorb pstack's blast radius, transcript recall, vet-the-number and design-twice as lazy sections
/investigate gains sections/blast-radius.md (the one safety fact, the five-rung
evidence ladder, look where grep stops, confirmed and cleared risks apart) with a
Phase 4 pointer, and the shared-premise red flag before the 3-strike rule.
/context-restore is carved for the first time: on NO_CHECKPOINTS, sections/transcripts.md
mines this workspace's own transcripts (host stores declared in hosts/*.ts and rendered
by TRANSCRIPT_SOURCES / TRANSCRIPT_DISCOVERY), a fixed 7-day window, never another
project's folder, subagents return only findings, redaction before anything leaves,
git and gh verify every artifact, a status-tagged brief. /benchmark is carved for the
first time: sections/vet-the-number.md asks the seven questions before Phase 5 reports
a comparison. /plan-eng-review gains sections/design-twice.md (a second structurally
distinct shape compared on interface depth, the eight design red flags), bound by the
autoplan methodology snapshot alongside review-sections.md.
Ideas from pstack (Lauren Tan, MIT), rewritten in gstack's words; NOTICE.md. Garry,
2026-10-26: "Do it, send". Guards: carve-guards entries and measured lines, eleven
plan-eng-review pins re-measured, periodic carve-section-loading cases for the two new
carves, free tests with a fixture transcript.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(pstack-c1): fold design-twice into Section 1 of the eng review section, per-host transcript table, fixture and slice-cap repairs
The remote free run reported nine failures in seven files, all from this
branch: four tests encode plan-eng-review's one companion section
(eng-scope-entry-ap, autoplan-methodology-names, plan-review-cases,
review-checklists), so the design-twice pass now lives inside Section 1 of
sections/review-sections.md with the skeleton, manifest and
bin/gstack-autoplan-snapshot.ts back at main; the Codex render of
context-restore named ~/.claude/ through the all-hosts transcript table, so
{{TRANSCRIPT_SOURCES}} renders this host's row only; the transcript fixture
is declared in FREE_FIXTURES; and the two new periodic cases took the census
to 39 slices, so evals-periodic.yml's eval-slices max-parallel goes 38 -> 40.
Pins: 11 plan-eng-review re-pins at +2,896; maxSizeRatio 1.2400 (measured
1.2389); Codex gpt-5.4 overlay 159,588 bytes.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pre-landing review fixes
The discovery block takes the none branch from hosts/*.ts's transcripts
declaration instead of a host-name fallthrough, and the resolver header says
where each half lives; the T2 subagent brief frames the transcript as data,
not instructions; the test asserts table/block agreement per host, imports
CAPY_NO_TRANSCRIPTS, and drops the fixture-shape case nothing reads.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: ADDING_A_HOST.md lists the optional transcripts HostConfig field
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>
263 lines
11 KiB
TypeScript
263 lines
11 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;
|
|
|
|
// --- Session transcripts ---
|
|
/**
|
|
* Where this host keeps its own chat transcripts on disk, read by
|
|
* /context-restore's no-checkpoint fallback (sections/transcripts.md).
|
|
* `root` is the path pattern shown to the user; `layout` says how the
|
|
* current workspace's files are told apart from another project's. Absent:
|
|
* the host declares no transcript store, and the skill says so and falls
|
|
* back to the shared record and git.
|
|
*/
|
|
transcripts?: { root: string; layout: 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;
|
|
}
|