diff --git a/README.md b/README.md index f1990a2ac..775523902 100644 --- a/README.md +++ b/README.md @@ -205,7 +205,7 @@ The same `/qa` and `/qa-only` commands test CLIs, APIs, jobs, workers and webhoo | `/setup-gbrain` | **GBrain Onboarding** — from zero to running gbrain in under 5 minutes. PGLite local, Supabase existing URL, or auto-provision a new Supabase project via Management API. MCP registration for Claude Code + per-repo trust triad (read-write/read-only/deny). [Full guide](USING_GBRAIN_WITH_GSTACK.md). | | `/sync-gbrain` | **Keep Brain Current** — re-index this repo's code into gbrain via `gbrain sources add` + `gbrain sync --strategy code`, refresh the `## GBrain Search Guidance` block in CLAUDE.md, and auto-remove guidance when the capability check fails. `--incremental` (default), `--full`, `--dry-run`. Idempotent; safe to re-run. | | `/gstack-upgrade` | **Self-Updater** — upgrade gstack to latest. Detects global vs vendored install, syncs both, shows what changed. | -| `/captain` | **Crew Controller** — run many coding agents from one controller: lanes send a READY block and never merge, the controller merges through trains (stamp on call, verify, merge with the expected head), keeps the digest, relays approvals in a fixed shape and runs a daily flaky-fix pass. Also answers to `/crew`. [Team guide](docs/captain.md). | +| `/captain` | **Crew Controller** (Capy only: [gstack on Capy](#gstack-on-capy)) — run many coding agents from one controller: lanes send a READY block and never merge, the controller merges through trains (stamp on call, verify, merge with the expected head), keeps the digest, relays approvals in a fixed shape and runs a daily flaky-fix pass. Also answers to `/crew`. [Team guide](docs/captain.md). | | `/ios-qa` | **iOS Live-Device QA (v1.43.0.0+)** — drive a real iPhone or iPad over USB CoreDevice via an embedded `StateServer` in the app. Read Swift source, codegen typed `@Observable` accessors, run the agent loop. Optional `--tailnet` flag exposes the device to OpenClaw or any HTTP-capable agent on your Tailscale tailnet so remote agents can run iOS QA without ever touching the hardware. Capability-tier allowlist (observe/interact/mutate/restore), per-device session lock, audit log. | | `/ios-fix`, `/ios-design-review`, `/ios-clean`, `/ios-sync` | iOS bug-fix loop, designer's-eye HIG audit, debug-bridge cleanup, and accessor resync. See `docs/skills.md`. End-to-end walkthrough: [docs/howto-ios-testing-with-gstack.md](docs/howto-ios-testing-with-gstack.md). | @@ -225,6 +225,10 @@ The four setup paths, the per-repo trust policy and memory sync, in brief: [docs Other references: [docs/gbrain-sync.md](docs/gbrain-sync.md) (sync-specific guide) • [docs/gbrain-sync-errors.md](docs/gbrain-sync-errors.md) (error index) +## gstack on Capy + +On a [Capy](https://capy.ai) machine, `./setup` also loads gstack's crew tools: `/captain` to run many agent threads from one controller, a merge queue that lands verified pull requests, receipts that tie each merge to the head that was reviewed and tested, and spend caps for `/ship` and `/autoplan`. They live in [crew/](crew/README.md) and load only on Capy, so any other install is unchanged by them. + ## Platform notes macOS and Linux get full support. Windows and Gemini models get minimal support: install and the core skills work, and nothing more is promised. See [support tiers](docs/support-tiers.md). diff --git a/bin/gstack-install-registry.sh b/bin/gstack-install-registry.sh index 9e6b46804..6acb2764c 100644 --- a/bin/gstack-install-registry.sh +++ b/bin/gstack-install-registry.sh @@ -347,3 +347,19 @@ gstack_disabled_normalize() { # entries are still removed. Same list as RETIRED_SKILLS in # scripts/discover-skills.ts (test/retired-skills.test.ts). GSTACK_RETIRED_SKILLS="plan-eng-review investigate" + +# GSTACK_CREW_SKILLS — the gstack-on-Capy skills (crew/README.md). Only a Capy +# machine registers them; anywhere else they get no entry on any host and an +# existing gstack-owned entry is removed, like a retired skill. Same list as +# CREW_SKILLS in scripts/discover-skills.ts (test/crew-skills-on-capy.test.ts). +GSTACK_CREW_SKILLS="captain" + +# gstack_on_capy — true on a Capy machine: the test gstack-capy-install's bun +# shim and gstack-update-channel.sh already use (CAPY_MACHINE_CONFIG set, or +# ~/.capy/system present). +gstack_on_capy() { [ -n "${CAPY_MACHINE_CONFIG:-}" ] || [ -d "${HOME:-/nonexistent}/.capy/system" ]; } + +# GSTACK_UNREGISTERED_SKILLS — what setup and gstack-relink keep off every host +# on this machine: the retired skills, plus the crew skills off Capy. +GSTACK_UNREGISTERED_SKILLS="$GSTACK_RETIRED_SKILLS" +gstack_on_capy || GSTACK_UNREGISTERED_SKILLS="$GSTACK_RETIRED_SKILLS $GSTACK_CREW_SKILLS" diff --git a/bin/gstack-relink b/bin/gstack-relink index a41fcfc52..24bc04492 100755 --- a/bin/gstack-relink +++ b/bin/gstack-relink @@ -72,10 +72,11 @@ if [ -f "$SCRIPT_DIR/gstack-install-registry.sh" ]; then . "$SCRIPT_DIR/gstack-install-registry.sh" DISABLED="$(gstack_disabled_normalize "$("$GSTACK_CONFIG" get disabled_skills 2>/dev/null || true)")" fi -# Retired skills (GSTACK_RETIRED_SKILLS) lose their entry the same way; only -# the user's DISABLED list is reported and passed to the router. +# Retired skills, and the crew skills off a Capy machine +# (GSTACK_UNREGISTERED_SKILLS), lose their entry the same way; only the user's +# DISABLED list is reported and passed to the router. OFF="$DISABLED" -[ -z "${GSTACK_RETIRED_SKILLS:-}" ] || OFF="$(gstack_disabled_normalize "$GSTACK_RETIRED_SKILLS $DISABLED")" +[ -z "${GSTACK_UNREGISTERED_SKILLS:-}" ] || OFF="$(gstack_disabled_normalize "$GSTACK_UNREGISTERED_SKILLS $DISABLED")" DISABLED_COUNT=0 # #2569: rendered :user variants (brain-aware blocks) live in an UNTRACKED diff --git a/changelog.d/crew-tools-load-on-capy-only-gst-1833.md b/changelog.d/crew-tools-load-on-capy-only-gst-1833.md new file mode 100644 index 000000000..85b7c8a57 --- /dev/null +++ b/changelog.d/crew-tools-load-on-capy-only-gst-1833.md @@ -0,0 +1 @@ +- **`/captain` is now installed only on Capy machines.** gstack's crew tools for Capy (the Captain skill, and later the merge queue, receipts and spend caps) are being gathered under `crew/`, which `./setup` and `gstack-relink` load only where `CAPY_MACHINE_CONFIG` is set or `~/.capy/system` exists, the test the Capy installer already used. Everywhere else `/captain` gets no entry on any host, and an entry an earlier install made is removed on the next setup or upgrade; a skill of your own named `captain` is left alone. Every other skill, the install size and the install time are unchanged. The README has a short "gstack on Capy" section, and crew/README.md says what Capy users get. diff --git a/crew/README.md b/crew/README.md new file mode 100644 index 000000000..3985963fc --- /dev/null +++ b/crew/README.md @@ -0,0 +1,37 @@ +# gstack on Capy + +This folder holds the parts of gstack that only Capy crews use: the tools that +run a crew of coding agents under a Captain. It stays in the public repo, but +`./setup` loads it only on a Capy machine. Anywhere else, setup skips it and +gstack works exactly as it does without it. + +## What Capy users get on top of gstack + +- **Captains.** `/captain` runs many agent threads from one controller: lanes + send a READY block and never merge, the controller keeps the digest, relays + approvals in a fixed shape and runs a daily flaky-test pass + ([docs/captain.md](../docs/captain.md)). +- **A merge queue.** `gstack-ship-queue` lands verified pull requests alone or + in trains, checks the exact head before it merges, and stamps the version + only at release time. +- **Receipts.** `/ship` leaves a receipt for the head it checked, so the + controller can tell that a pull request was reviewed and tested at the commit + it merges. +- **Budgets.** `/ship` and `/autoplan` meter their own spend against the cap a + brief sets, and stop or hand off before they cross it + ([docs/ship-budget.md](../docs/ship-budget.md)). + +How the whole factory fits together: [capy/README.md](../capy/README.md). + +## How it is loaded + +A Capy machine is one where `CAPY_MACHINE_CONFIG` is set or `~/.capy/system` +exists, the same test the Capy installer (`bin/gstack-capy-install`) already +uses. `gstack_on_capy` in `bin/gstack-install-registry.sh` is that test, and +`GSTACK_CREW_SKILLS` there lists the crew skills. On a Capy machine setup and +`gstack-relink` register them like any other skill; elsewhere they get no entry +on any host, and an entry an earlier install made is removed. + +The crew tooling moves here in stages. Code that no public skill calls comes +first; the steps `/ship`, `/review` and `/autoplan` take only for crews move +after they call it through one switch. diff --git a/docs/skills.md b/docs/skills.md index c406491ff..31aba91e6 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -45,7 +45,7 @@ Detailed guides for every gstack skill — philosophy, workflow, and examples. | [`/write-skill`](#write-skill) | **Skill Editor** | Write or improve an agent skill without bloat: edit ladder, size per level, skill-to-bin drift check, full diets with a before/after test. | | [`/test-audit`](#test-audit) | **Test Auditor** | Sweep existing tests for low-value, implementation-coupled or duplicate tests. Report-only unless you approve a batch. | | [`/landing-report`](#landing-report) | **Ship Queue Dashboard** | Read-only snapshot of the workspace-aware ship queue. Which version slots are claimed, which sibling workspaces have WIP. | -| [`/captain`](#captain) | **Crew Controller** | Run many coding agents from one controller. Lanes build and send a READY block; the controller validates it, merges through trains (stamp on call, verify, merge with the expected head, verify again; `train land` per slot under `changelog_fragments`), keeps the digest, relays approvals in three fixed shapes and runs a daily flaky-fix pass. Also answers to `/crew`. | +| [`/captain`](#captain) | **Crew Controller** (Capy only: [crew/](../crew/README.md)) | Run many coding agents from one controller. Lanes build and send a READY block; the controller validates it, merges through trains (stamp on call, verify, merge with the expected head, verify again; `train land` per slot under `changelog_fragments`), keeps the digest, relays approvals in three fixed shapes and runs a daily flaky-fix pass. Also answers to `/crew`. | | [`/benchmark-models`](#benchmark-models) | **Model Benchmark** | Side-by-side cross-model benchmark for skills (Claude vs GPT vs Gemini, plus particle-1 and DeepSeek V4.1 Flash through OpenRouter). Latency, tokens, cost, optional LLM-judged quality. | | [`/eval-plan`](#eval-plan) | **Eval Planner** | Preregister a paid eval (bars, arms, decoys, stop rules), dry-run it for $0 with the stub model, pilot three priced items, then approve the spend from the extrapolation table. | | | | | @@ -1458,7 +1458,7 @@ Remove the `/freeze` boundary, allowing edits everywhere again. The hooks stay r One controller, many lanes. `/captain` (also `/crew`) teaches a coordinating agent the operating model that keeps dozens of worker threads merging into one base branch without -turning it red. +turning it red. `./setup` installs it only on a Capy machine; see [crew/README.md](../crew/README.md). ```text You: /captain diff --git a/lib/harness-register.ts b/lib/harness-register.ts index 452788699..e5430e3dc 100644 --- a/lib/harness-register.ts +++ b/lib/harness-register.ts @@ -19,7 +19,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { spawnSync } from 'node:child_process'; -import { RETIRED_SKILLS } from '../scripts/discover-skills'; +import { unregisteredSkills } from '../scripts/discover-skills'; import { bunIsolationArgs } from './bun-isolation'; import { runBashSync } from './script-invocation'; import { frontmatterList, routerLine } from './skill-triggers'; @@ -234,13 +234,15 @@ function skillDirs(root: string, triggersByName = new Map()): /** The skills a target's payload carries: the host render (made for this checkout as the install root) or the checkout's committed Claude render. */ export function sourceSkills(target: Target, checkout: string, scratch: string): SkillEntry[] { - if (target.render === 'claude') return skillDirs(checkout).filter(s => s.name !== 'gstack' && !RETIRED_SKILLS.includes(s.name)); + const off = unregisteredSkills(); + const registered = (s: SkillEntry) => !off.includes(s.name.replace(/^gstack-/, '')); + if (target.render === 'claude') return skillDirs(checkout).filter(s => s.name !== 'gstack' && registered(s)); const out = path.join(scratch, 'render'); fs.rmSync(out, { recursive: true, force: true }); const r = spawnSync(process.execPath, [...bunIsolationArgs(), 'scripts/gen-skill-docs.ts', '--host', target.name, '--install-root', checkout, '--out-dir', out], { cwd: checkout, encoding: 'utf8', timeout: 300_000 }); if (r.status !== 0) throw new HarnessError('HARNESS_RENDER_FAILED', 'could not render ' + target.name + ' skills: ' + ((r.stderr || r.stdout || '').trim().split('\n').slice(-3).join(' | '))); - const skills = skillDirs(path.join(out, '.' + target.name, 'skills'), new Map(skillDirs(checkout).map(s => [s.name, s.triggers]))); + const skills = skillDirs(path.join(out, '.' + target.name, 'skills'), new Map(skillDirs(checkout).map(s => [s.name, s.triggers]))).filter(registered); if (!skills.length) throw new HarnessError('HARNESS_RENDER_FAILED', 'the ' + target.name + ' render produced no skills'); return skills; } diff --git a/scripts/discover-skills.ts b/scripts/discover-skills.ts index 1d1cbf876..4df28e590 100644 --- a/scripts/discover-skills.ts +++ b/scripts/discover-skills.ts @@ -4,6 +4,7 @@ */ import * as fs from 'fs'; +import * as os from 'os'; import * as path from 'path'; import type { HostConfig } from './host-config'; @@ -20,6 +21,23 @@ const SKIP = new Set(['node_modules', '.git', 'dist']); */ export const RETIRED_SKILLS = ['plan-eng-review', 'investigate']; +/** + * The gstack-on-Capy skills (crew/README.md): registered only on a Capy + * machine, and kept off every host elsewhere like a retired skill. Same list + * as GSTACK_CREW_SKILLS in bin/gstack-install-registry.sh, and the same Capy + * test as its gstack_on_capy. + */ +export const CREW_SKILLS = ['captain']; + +export function onCapyMachine(env: NodeJS.ProcessEnv = process.env, home = os.homedir()): boolean { + return Boolean(env.CAPY_MACHINE_CONFIG) || fs.existsSync(path.join(home, '.capy', 'system')); +} + +/** What no host registers on this machine: the retired skills, plus the crew skills off Capy. */ +export function unregisteredSkills(env: NodeJS.ProcessEnv = process.env, home = os.homedir()): string[] { + return onCapyMachine(env, home) ? RETIRED_SKILLS : [...RETIRED_SKILLS, ...CREW_SKILLS]; +} + /** The generator and its coverage checks use the same include-minus-skip rule. */ export function includesSkill(host: HostConfig, skillDir: string): boolean { const { includeSkills, skipSkills } = host.generation; diff --git a/setup b/setup index 636b22eda..54ffa7b93 100755 --- a/setup +++ b/setup @@ -1440,9 +1440,10 @@ fi # membership inline with "${_DISABLED_SKILLS:- }", so a linker extracted on its # own (tests) behaves as "nothing disabled". _USER_DISABLED_SKILLS="$(gstack_disabled_normalize "$("$GSTACK_CONFIG" get disabled_skills 2>/dev/null || true)")" -# Retired skills (GSTACK_RETIRED_SKILLS) take the same no-entry path on every -# host; only the user's own list is reported and passed to the router. -_DISABLED_SKILLS="$(gstack_disabled_normalize "$GSTACK_RETIRED_SKILLS $_USER_DISABLED_SKILLS")" +# Retired skills, and the crew skills off a Capy machine +# (GSTACK_UNREGISTERED_SKILLS), take the same no-entry path on every host; only +# the user's own list is reported and passed to the router. +_DISABLED_SKILLS="$(gstack_disabled_normalize "$GSTACK_UNREGISTERED_SKILLS $_USER_DISABLED_SKILLS")" # Every render of the router (committed-path renders of env-var hosts and the # per-install renders) omits disabled skills (C8); the default Claude router is # rendered in place by gstack-relink. diff --git a/test/crew-skills-on-capy.test.ts b/test/crew-skills-on-capy.test.ts new file mode 100644 index 000000000..5f39a0c02 --- /dev/null +++ b/test/crew-skills-on-capy.test.ts @@ -0,0 +1,147 @@ +/** + * gstack on Capy (crew/README.md): the crew skills (/captain) are registered + * only on a Capy machine, the same test gstack-capy-install's bun shim uses + * (CAPY_MACHINE_CONFIG set, or ~/.capy/system present). Everywhere else setup + * and gstack-relink treat them like a retired skill: no entry on any host, and + * an existing gstack-owned entry is removed on the next setup or upgrade. + */ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { bunIsolationArgs } from '../lib/bun-isolation'; +import { CREW_SKILLS, RETIRED_SKILLS, onCapyMachine, unregisteredSkills } from '../scripts/discover-skills'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const REGISTRY = path.join(ROOT, 'bin', 'gstack-install-registry.sh'); +const BINS = ['gstack-config', 'gstack-relink', 'gstack-patch-names', 'gstack-skill-name.sh', 'gstack-state-root.sh', 'gstack-install-registry.sh']; + +let tmp: string; +let home: string; +beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-crew-skills-')); + home = path.join(tmp, 'home'); + fs.mkdirSync(home); +}); +afterEach(() => fs.rmSync(tmp, { recursive: true, force: true })); + +const onCapy = () => fs.mkdirSync(path.join(home, '.capy', 'system'), { recursive: true }); +const registryVar = (name: string, env: Record = {}) => { + const r = spawnSync('bash', ['-c', `. "$1" && printf %s "$${name}"`, '_', REGISTRY], { + encoding: 'utf8', timeout: 10_000, env: { ...process.env, HOME: home, CAPY_MACHINE_CONFIG: '', ...env }, + }); + expect(r.status, r.stderr).toBe(0); + return r.stdout.split(/\s+/).filter(Boolean); +}; + +test('setup, gstack-relink and the harness registrar name the same crew skills', () => { + expect(registryVar('GSTACK_CREW_SKILLS')).toEqual(CREW_SKILLS); + expect(CREW_SKILLS).toEqual(['captain']); +}); + +describe('which skills stay unregistered', () => { + test('off Capy: the retired skills and the crew skills', () => { + expect(registryVar('GSTACK_UNREGISTERED_SKILLS')).toEqual([...RETIRED_SKILLS, ...CREW_SKILLS]); + expect(onCapyMachine({}, home)).toBe(false); + expect(unregisteredSkills({}, home)).toEqual([...RETIRED_SKILLS, ...CREW_SKILLS]); + }); + + test('on a Capy machine (~/.capy/system): only the retired skills', () => { + onCapy(); + expect(registryVar('GSTACK_UNREGISTERED_SKILLS')).toEqual(RETIRED_SKILLS); + expect(onCapyMachine({}, home)).toBe(true); + expect(unregisteredSkills({}, home)).toEqual(RETIRED_SKILLS); + }); + + test('on a Capy machine (CAPY_MACHINE_CONFIG): only the retired skills', () => { + expect(registryVar('GSTACK_UNREGISTERED_SKILLS', { CAPY_MACHINE_CONFIG: '/etc/capy/machine.json' })).toEqual(RETIRED_SKILLS); + expect(unregisteredSkills({ CAPY_MACHINE_CONFIG: '/etc/capy/machine.json' }, home)).toEqual(RETIRED_SKILLS); + }); +}); + +describe('gstack-relink registers /captain only on Capy', () => { + let install: string; + let skills: string; + const relink = () => spawnSync('bash', [path.join(install, 'bin', 'gstack-relink')], { + encoding: 'utf8', + timeout: 60_000, + env: { + ...process.env, HOME: home, CAPY_MACHINE_CONFIG: '', GSTACK_HOME: path.join(tmp, 'state'), GSTACK_STATE_ROOT: '', GSTACK_STATE_DIR: '', + GSTACK_INSTALL_DIR: install, GSTACK_SKILLS_DIR: skills, GSTACK_SETUP_RUNNING: '', + }, + }); + + beforeEach(() => { + install = path.join(tmp, 'gstack'); + skills = path.join(tmp, 'skills'); + fs.mkdirSync(path.join(install, 'bin'), { recursive: true }); + for (const b of BINS) { + fs.copyFileSync(path.join(ROOT, 'bin', b), path.join(install, 'bin', b)); + fs.chmodSync(path.join(install, 'bin', b), 0o755); + } + for (const skill of ['captain', 'ship']) { + fs.mkdirSync(path.join(install, skill)); + fs.writeFileSync(path.join(install, skill, 'SKILL.md'), `---\nname: ${skill}\ndescription: test\n---\n# ${skill}\n`); + } + fs.mkdirSync(skills, { recursive: true }); + }); + + test('off Capy there is no captain entry, and the other skills link as before', () => { + const r = relink(); + expect(r.status, r.stderr).toBe(0); + expect(fs.lstatSync(path.join(skills, 'captain'), { throwIfNoEntry: false })).toBeUndefined(); + expect(fs.existsSync(path.join(skills, 'ship', 'SKILL.md'))).toBe(true); + expect(r.stdout).not.toContain('Disabled (gstack-config disabled_skills'); + }); + + test('on Capy captain links like any skill', () => { + onCapy(); + const r = relink(); + expect(r.status, r.stderr).toBe(0); + expect(fs.existsSync(path.join(skills, 'captain', 'SKILL.md'))).toBe(true); + expect(fs.existsSync(path.join(skills, 'ship', 'SKILL.md'))).toBe(true); + }); + + test('an install that leaves Capy loses the gstack-owned captain entry and keeps a user\'s own', () => { + onCapy(); + expect(relink().status).toBe(0); + expect(fs.existsSync(path.join(skills, 'captain', 'SKILL.md'))).toBe(true); + fs.rmSync(path.join(home, '.capy'), { recursive: true }); + const r = relink(); + expect(r.status, r.stderr).toBe(0); + expect(fs.lstatSync(path.join(skills, 'captain'), { throwIfNoEntry: false })).toBeUndefined(); + + fs.mkdirSync(path.join(skills, 'captain')); + const own = '---\nname: captain\ndescription: mine\n---\nmy own captain\n'; + fs.writeFileSync(path.join(skills, 'captain', 'SKILL.md'), own); + expect(relink().status).toBe(0); + expect(fs.readFileSync(path.join(skills, 'captain', 'SKILL.md'), 'utf8')).toBe(own); + }); +}); + +describe('the harness registrar registers /captain only on Capy', () => { + const lib = path.join(ROOT, 'lib', 'harness-register.ts'); + const sourceNames = (env: Record = {}) => { + const probe = `const { sourceSkills, TARGETS } = await import(${JSON.stringify(lib)}); for (const t of ['dots', 'hermes']) console.log(t + ':' + sourceSkills(TARGETS[t], ${JSON.stringify(ROOT)}, process.env.SCRATCH).map(s => s.name).join(' '));`; + const r = spawnSync(process.execPath, [...bunIsolationArgs(), '-e', probe], { + encoding: 'utf8', timeout: 120_000, env: { ...process.env, HOME: home, CAPY_MACHINE_CONFIG: '', SCRATCH: path.join(tmp, 'scratch'), ...env }, + }); + expect(r.status, r.stderr).toBe(0); + return Object.fromEntries(r.stdout.trim().split('\n').map(l => [l.slice(0, l.indexOf(':')), l.slice(l.indexOf(':') + 1).split(' ')])); + }; + + test('off Capy neither the Claude render nor a host render carries captain', () => { + const { dots, hermes } = sourceNames(); + expect(dots).toContain('ship'); + expect(dots).not.toContain('captain'); + expect(hermes).toContain('gstack-ship'); + expect(hermes).not.toContain('gstack-captain'); + }, 120_000); + + test('on Capy both renders carry captain', () => { + const { dots, hermes } = sourceNames({ CAPY_MACHINE_CONFIG: '/etc/capy/machine.json' }); + expect(dots).toContain('captain'); + expect(hermes).toContain('gstack-captain'); + }, 120_000); +});