Install /captain only on Capy machines (GST-1833) (#4143)

* Load the Capy crew skills only on Capy machines (GST-1833)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Add changelog entry for Capy-only crew skills (GST-1833)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Test that the harness registrar leaves /captain out off Capy

Review-Finding: p1/specialist-batched#2 reader=testing
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Say in the skills guide that /captain installs only on Capy machines

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>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-07 22:18:18 +00:00
1 parent 7cb151bff7
commit bb3b7d072a
10 files changed
+239 -12

No files matched your search

+5 -1
View File
@@ -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).
+16
View File
@@ -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"
+4 -3
View File
@@ -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
@@ -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.
+37
View File
@@ -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.
+2 -2
View File
@@ -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
+5 -3
View File
@@ -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<string, string[]>()):
/** 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;
}
+18
View File
@@ -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;
+4 -3
View File
@@ -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.
+147
View File
@@ -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<string, string> = {}) => {
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<string, string> = {}) => {
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);
});