Files
gstack/docs/ADDING_A_HOST.md
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

373 lines
19 KiB
Markdown

# Adding a New Host to gstack
gstack uses a declarative host config system. Each supported AI coding agent
(Claude, Codex, Factory, Kiro, OpenCode, Slate, Cursor, OpenClaw, Hermes,
GBrain) is defined as a typed TypeScript config object built by the
`defineHost()` factory. Rendering a new host means creating one file and
re-exporting it: the generator, tests, and validation pick it up. Installing
it is not free: setup still has one install arm per installable host (see
"What setup changes on disk" below), so an installable host also needs a setup
arm, an entry in the install registry tier table, and a certification run
before it is labelled `full`.
## Install ownership rules
These rules hold for every host, every scope, and every upgrade path
(`./setup`, `./setup --refresh-registered`, `/gstack-upgrade`, the team-mode
auto-update). Each refusal names what was left alone, the rule that applied,
and the exact command to proceed.
1. **Project-local setup never silently replaces a global install.** Setup
run from a project-vendored copy, or from any checkout other than the one a
global install links to, leaves that global install alone and prints
`./setup --global` as the way to replace it on purpose. (Codex:
`setup:186-207`, #2879. Claude: the global `~/.claude/skills/gstack` link.)
2. **An explicit `--host X` never mutates another host.** `--host codex`
writes only Codex's skills directory and the checkout's own `.agents/`
render. It does not heal, register, or remove Claude Code hooks in
`settings.json`, and it does not touch the Claude gbrain render (#2347).
One deliberate exception: the one-time `/claude` → `/claude-code` rename
repair (`bin/gstack-migrate-claude-code`) renames gstack-owned entries in
every install that shares the checkout.
3. **Upgrades refresh exactly the registered installs.** Every install setup
activates is recorded in the install registry. Upgrades run
`./setup --refresh-registered`, which runs setup once per registered host
of that checkout (printing the source first) and nothing else. Installs
owned by another checkout, and project installs of another project, are
listed with the command that refreshes them there; they are never repointed
(#1925).
4. **User-owned files survive.** Setup, `gstack-relink`, and
`gstack-uninstall` delete or link over an entry only with proof that gstack
created it (the #2119 ownership gate); anything else is reported and left.
### The install registry
`$GSTACK_STATE_ROOT/installs.tsv` (mode 0600) is the one record of installs.
Only `bin/gstack-install-registry.sh` writes it, under a lock directory with an
atomic rename. Columns: host, scope (`global`/`project`), project, destination
(the skills directory the host discovers), install root, source realpath,
version, prefix setting, render, updated-at. A row publishes only after the
host arm finished activating. Rows whose install root is gone are dropped
(`gstack_install_registry_reconcile`) by every refresh and by
`gstack-uninstall`, so an upgrade never resurrects an uninstalled host.
`./setup --status` prints one row per install (host, tier, scope, result,
version, destination, source) without building or writing anything, plus any
pre-registry install it finds on disk, labelled `unregistered` with the
command that registers it. A Codex install captured by a project-vendored copy
(#2879) is reported with both choices instead of being repointed. The setup
summary, the `/gstack-upgrade` summary, and `--status` share one renderer
(`gstack_install_registry_render`).
### Migrations
`gstack-upgrade/migrations/v*.sh` are state-root migrations. Their marker is
`$GSTACK_STATE_ROOT/.last-setup-version` (read from `~/.gstack` once for
installs that predate the move). It advances only past migrations that
succeeded; a failed migration stops the sequence, prints a `failed` row with
the retry command, and runs again on the next `./setup`. Install and host
migrations are tracked per install by the registry row's version.
### What setup changes on disk
Inventory of every setup side effect, by phase (line numbers are approximate):
| Phase | Writes | Scope |
|---|---|---|
| Build | `browse/dist`, `design/dist`, `make-pdf/dist`, `bin/gstack-cso-*` in the source checkout | source |
| Generation | `.agents/`, `.kiro/`, `.factory/`, `.opencode/`, `.cursor/` renders in the source checkout; `gstack-patch-names` rewrites Claude `name:` fields in place | source |
| Chromium | Playwright cache (`~/.cache/ms-playwright`), lock under the state root | machine |
| Config | `skill_prefix`, `timeline_stop_hook`, team-mode keys in `$GSTACK_STATE_ROOT/config.yaml` | state root |
| Claude arm | `~/.claude/skills/gstack` link, one dir per skill with a `SKILL.md` link, alias copies | selected host |
| Codex arm | `${CODEX_HOME:-~/.codex}/skills/gstack` runtime root, `gstack-*` links into `.agents/skills`, `.agents/skills/gstack` sidecar | selected host |
| Kiro, Factory, OpenCode, Cursor, Copilot arms | the host's `skills/gstack` runtime root (built beside the live one and swapped in) and `gstack-*` links | selected host |
| OpenCode commands | `~/.config/opencode/commands/gstack-*.md` with a managed marker (#2629); user command files are never touched | selected host |
| Registry | one row per activated install in `installs.tsv` | state root |
| Migrations | whatever each `v*.sh` repairs; marker in the state root | state root |
| Claude hooks | `~/.claude/settings.json` (heal, SessionStart, plan-tune, Stop) — only when Claude is selected | Claude |
| gbrain | `gbrain-detection.json`; the Claude render in `$GSTACK_STATE_ROOT/render/claude` — only when Claude is selected | state root / Claude |
Activation today: on macOS and Linux every install is a set of symlinks into
the source checkout, so a `git pull` changes the active payload before setup
runs and "keep the prior payload" means "keep the prior checkout" — which the
upgrade skill does on the vendored path (`.bak` restore) and records on the
git path (`PRE_UPGRADE_COMMIT`). On Windows, where setup copies instead of
linking, every copy is staged as `<dst>.gstack-new.<pid>` and swapped in only
when complete; an interrupted swap is repaired on the next run (a missing
destination is restored from `<dst>.gstack-old.<pid>`). Versioned payload
directories for the symlink platforms are not built yet.
### Install-context render contract
Committed renders name each host's default install root. A render for any
other root (project install, `CLAUDE_CONFIG_DIR`, renamed checkout) is made
with `gen-skill-docs --host <h> --install-root <abs path> --out-dir <tmp>
--link-root <dir>`: every default-root path becomes the given root, and
resolvers see it as `TemplateContext.installRoot` (null keeps committed bytes).
Per-install renders live in `$GSTACK_STATE_ROOT/render/installs/<host>-<id>/`
(`<id>` keyed by the root's realpath) and are recorded in the registry's render
column. Setup makes one for a Claude install outside `~/.claude/skills/gstack`
(renamed checkout, project-vendored copy, `CLAUDE_CONFIG_DIR`) and a global Codex
install outside `~/.codex/skills/gstack` (`CODEX_HOME`); repo-local Codex installs
resolve their root at run time. A root that is not a plain path (whitespace,
shell metacharacters) is named through a `<host>-<id>.root` alias symlink:
worktree-isolated Claude Code refuses a command path containing a space in any
quoting.
## Host tiers and capabilities
Every host declares `tier` and `capabilities` (`scripts/host-config.ts`).
One vocabulary is used everywhere: `./setup --help`, setup and `--status`
rows, host errors, and the README host matrix, all checked against
`hosts/index.ts` by `test/host-config.test.ts`.
| Tier | Meaning |
|---|---|
| `full` | Installs, renders, passes the conformance kit, and has a dated certification record below. |
| `experimental` | Installs and renders and passes the conformance kit; no certification record yet. |
| `instruction-only` | No install arm. `./setup --host X` prints what to copy and changes nothing. |
Rendering alone never certifies `full`.
| Capability | Values | Used for |
|---|---|---|
| `toolExecution` | boolean | preambles and `bin/` helpers run |
| `questions` | `native` / `prose` | structured question tool vs plain-text questions |
| `planMode` | boolean | plan-mode transitions |
| `delegation` | boolean | sub-agents |
| `browser` | boolean (needs `toolExecution`) | gstack's browser |
| `safetyHooks` | `enforced` / `advisory` | `/careful`, `/freeze`, `/guard` block (Claude) or only warn |
Hosts with `safetyHooks: 'advisory'` get one line at the top of `/careful`,
`/freeze` and `/guard`: "not enforced on <host>: advisory, not blocked".
## Certify your host
1. Pass the conformance kit: `bun test test/host-conformance.test.ts` installs
every installable host into a temp HOME, checks discovery directories, no
other host's variant, no duplicates, `./setup --status`, and an upgrade from
the previous release's install layout.
2. Run one representative workflow for real on the host (for example
`/review` on a small diff), and an upgrade from an existing install.
3. Add a row below with the date, host version, gstack version, and where the
transcript lives, then set `tier: 'full'`.
| Host | Date | Host version | gstack version | Evidence |
|---|---|---|---|---|
| claude | 2026-10-02 | Claude Code 2.1.284 (CI image pin) | v1.91.13.0 (1c54555) | gate-tier evals, real Claude Code sessions: https://github.com/garrytan/gstack/actions/runs/37033755821 |
Codex stays `experimental` until its periodic cases (`codex-discover-skill`,
`codex-review`) pass on the CI image's pinned Codex CLI and a row is added.
## GitHub Copilot CLI (#393)
Shipped as `experimental` (`hosts/copilot.ts`, `./setup --host copilot`):
skills in `~/.copilot/skills/gstack-*`, runtime root `~/.copilot/skills/gstack`
with a `.source-path` marker so `/gstack-upgrade` finds the checkout, names
prefixed `gstack-` (Copilot's built-in `/review` would win otherwise),
`disable-model-invocation: true` on sensitive skills, and a one-paragraph
tool-name glossary (`ask_user`, `exit_plan_mode`, `task`, `view`, `skill`)
inserted ahead of the preamble's STATUS rules. A shared directory was not
enough: Copilot CLI stopped reading `~/.claude/skills` in 1.0.36, gstack never
writes `~/.agents/skills`, and a Codex render carries Codex paths and the
Codex model profile.
Disposition of the community attempts:
| PR | Decision | Why |
|---|---|---|
| #2323 (@andrey-esipov) | Selectively reused, with credit | Kept: `~/.copilot/skills` and `.github/skills` roots, `copilot`-binary detection, `.source-path`, the `gstack-` prefix, the 1024-char description limit, uninstall coverage. Not kept: new schema fields and generator edits (predate `defineHost()`), an unguarded `rm -rf` of the runtime root (#2142), links to `setup`/`scripts`/Claude sources inside a recursively scanned dir, a test-only install backdoor in setup, and a blanket AskUserQuestion → `ask_user` rewrite. |
| #396 (@ridermw) | Rejected | Sed-rewrites Codex output into a shared `.agents/skills`; its E2E runner copies real `~/.copilot` credentials into a temp HOME. Its session scanner is a candidate for a later `/retro global` change. |
| #487 (@ridermw) | Rejected | Relies on `~/.claude/skills`, which Copilot CLI no longer reads. |
| #1852 (@lolisaigao1234) | Rejected | Detects `gh` instead of `copilot`, installs to a directory Copilot does not scan, links `bin` without `lib`. |
Unverified without a real Copilot CLI (why it is not `full`): autopilot may
answer `ask_user` itself, plan mode blocks mutating shell commands, symlinked
skill dirs need Copilot CLI 1.0.62 or later, and `COPILOT_HOME` other than
`~/.copilot` is refused for now.
## Instruction-only hosts
OpenClaw, Hermes, Slate and GBrain have no install arm. Hermes stays
instruction-only until someone certifies a native install (#2826 measured one
on Hermes 0.21.0): `./setup --host hermes` prints the digest path and the
generator command, and the Hermes render writes `name: gstack-<skill>` so
Hermes, which indexes skills by frontmatter name, cannot shadow them (#2825).
## How it works
```
hosts/
├── define-host.ts # defineHost() factory: shared defaults + derived fields
├── claude.ts # Primary host
├── codex.ts # OpenAI Codex CLI
├── factory.ts # Factory Droid
├── kiro.ts # Amazon Kiro
├── opencode.ts # OpenCode
├── slate.ts # Slate (Random Labs)
├── cursor.ts # Cursor
├── openclaw.ts # OpenClaw
├── hermes.ts # Hermes (Nous Research)
├── gbrain.ts # GBrain
└── index.ts # Registry: imports all, derives Host type
```
Each config file calls `defineHost()` and exports the resulting `HostConfig`
object, which tells the generator:
- Where to put generated skills (paths)
- How to transform frontmatter (allowlist/denylist fields)
- What Claude-specific references to rewrite (paths, tool names)
- What binary to detect for auto-install
- What resolver sections to suppress
- What assets to symlink at install time
The generator, setup script, platform-detect, uninstall, health checks, worktree
copy, and tests all read from these configs. None of them have per-host code.
## Step-by-step: add a new host
### 1. Create the config file
Configs are built with the `defineHost()` factory in `hosts/define-host.ts`.
You only write the fields that differ from the common external-host defaults;
everything else is derived from the host name. A fully-default host is two
fields (see `hosts/slate.ts` or `hosts/cursor.ts`):
```typescript
import { defineHost } from './define-host';
const myhost = defineHost({
name: 'myhost',
displayName: 'MyHost',
});
export default myhost;
```
That expands to the full `HostConfig` with these defaults:
- `cliCommand: 'myhost'` (the name; binary for `command -v` detection)
- `cliAliases: []`
- `defaultModel: 'claude'` (model overlay used when generation gets no explicit `--model`; codex overrides to `'gpt'`)
- `globalRoot` / `localSkillRoot`: `.myhost/skills/gstack`, `hostSubdir`: `.myhost`
- `usesEnvVars: true` (false only for Claude, which uses literal `~` paths)
- `frontmatter`: allowlist keeping `name` + `description`, no description limit
- `generation`: no metadata file, `skipSkills: []` (both outside-review skills are enabled; Claude and Codex explicitly omit their own wrapper)
- `pathRewrites`: the standard trio derived from the resolved paths
(`~/.claude/skills/gstack` → `~/{globalRoot}`, `.claude/skills/gstack` →
`{localSkillRoot}`, `.claude/skills` → `{hostSubdir}/skills`)
- `suppressedResolvers`: the GBrain pair (`GBRAIN_CONTEXT_LOAD`, `GBRAIN_SAVE_RESULTS`)
- `runtimeRoot`: the shared asset list (`bin`, `browse/dist`, `browse/bin`,
`gstack-upgrade`, `ETHOS.md` + review checklist files)
- `install`: `{ linkingStrategy: 'symlink-generated' }`
- `learningsMode: 'basic'`
Override any field by passing it to `defineHost()`. Two path-rewrite options:
- `extraPathRewrites`: appends entries AFTER the derived trio (e.g. kiro's
codex-path cleanup, or `{ from: 'CLAUDE.md', to: 'AGENTS.md' }` for
AGENTS.md hosts). Use this when the standard trio is right but you need more.
- `pathRewrites`: replaces the derived list entirely. Only for non-mechanical
cases — codex and factory rewrite the global path to `$GSTACK_ROOT` and add
an extra review-path rewrite; claude has an empty list.
The two are mutually exclusive (the factory throws if you pass both).
Shared constants exported from `define-host.ts` for spread-composition:
`CROSS_MODEL_RESOLVERS` (outside-provider review resolvers plus Review Army,
suppressed on hosts that opt out; Codex keeps outside reviews and suppresses
Review Army), `GBRAIN_RESOLVERS` (the default
suppression pair), and `EXEC_STYLE_TOOL_REWRITES` (the OpenClaw-style
lowercase-tool rewrites shared by openclaw and gbrain).
Good examples: `hosts/opencode.ts` (path + runtimeRoot overrides),
`hosts/factory.ts` (tool rewrites and conditional fields), `hosts/hermes.ts`
(AGENTS.md host with custom tool rewrites and resolver composition).
### 2. Register in the index
Edit `hosts/index.ts`:
```typescript
import myhost from './myhost';
// Add to ALL_HOST_CONFIGS array:
export const ALL_HOST_CONFIGS: HostConfig[] = [
claude, codex, factory, kiro, opencode, slate, cursor, openclaw, hermes, gbrain, myhost
];
// Add to re-exports:
export { claude, codex, factory, kiro, opencode, slate, cursor, openclaw, hermes, gbrain, myhost };
```
### 3. Add to .gitignore
Add `.myhost/` to `.gitignore` (generated skill docs are gitignored).
### 4. Generate and verify
```bash
# Generate skill docs for the new host
bun run gen:skill-docs --host myhost
# Verify output exists
ls .myhost/skills/gstack-*/SKILL.md
# Generate for all hosts (includes the new one)
bun run gen:skill-docs --host all
# Validate all host content and tracked output freshness, including the new host
bun run skill:check
# Claude install paths are rejected in prose; legitimate Bash fallbacks are allowed.
```
### 5. Run tests
```bash
bun test test/gen-skill-docs.test.ts
bun test test/host-config.test.ts
```
The parameterized smoke tests automatically pick up the new host. They verify: output exists, no path leakage, valid frontmatter,
freshness check passes, and outside-review skills match each host's exclusions.
### 6. Update README.md
Add install instructions for the new host in the appropriate section.
## Config field reference
See `scripts/host-config.ts` for the full `HostConfig` interface with JSDoc
comments on every field.
Key fields:
| Field | Purpose |
|-------|---------|
| `tier` | `full`, `experimental`, or `instruction-only` (see Host tiers) |
| `capabilities` | What the host runtime offers; rendering and docs must not claim more |
| `frontmatter.nameMatchesDirectory` | Write `gstack-<skill>` into `name:` for hosts that index by frontmatter name |
| `defaultModel` | Model overlay rendered when generation gets no explicit `--model` (validated against `ALL_MODEL_NAMES` in `scripts/models.ts`) |
| `frontmatter.mode` | `allowlist` (keep only listed) or `denylist` (strip listed) |
| `frontmatter.descriptionLimit` | Max chars, `null` for no limit |
| `frontmatter.descriptionLimitBehavior` | `error` (fail build), `truncate`, `warn` |
| `frontmatter.conditionalFields` | Add fields based on template values (e.g., sensitive → disable-model-invocation) |
| `frontmatter.renameFields` | Rename template fields (e.g., voice-triggers → triggers) |
| `pathRewrites` | Literal replaceAll on content. Order matters. Replaces the derived trio. |
| `extraPathRewrites` | (defineHost input only) Appended after the derived trio. |
| `toolRewrites` | Rewrite Claude tool names (e.g., "use the Bash tool" → "run this command") |
| `suppressedResolvers` | Resolver functions that return empty for this host |
| `coAuthorTrailer` | Git co-author string for commits |
| `boundaryInstruction` | Anti-prompt-injection warning for cross-model invocations |
## Validation
The `validateHostConfig()` function in `scripts/host-config.ts` checks:
- Name: lowercase alphanumeric with hyphens
- CLI command: alphanumeric with hyphens/underscores
- `defaultModel`: must be a known model family from `scripts/models.ts` `ALL_MODEL_NAMES`
- Paths: safe characters only (alphanumeric, `.`, `/`, `$`, `{}`, `~`, `-`, `_`)
- No duplicate names, hostSubdirs, or globalRoots across configs
Run `bun run scripts/host-config-export.ts validate` to check all configs.