Files
gstack/lib/doctor-components.ts
Garry Tanandcapy-ai[bot] ada251bbc0 v1.91.96.0 fix: honest install and self-upgrade on Capy (field wave A, items 1-5) (#3164)
* docs(designs): gstack field wave plan (2026-10-12) and the shared simulated Capy host fixture

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

* feat(doctor): --min-revision, a launch Bun found off PATH, planning-only pins SKIP

gstack-doctor --check gains --min-revision <v>|unknown: below it the revision
row FAILs with REVISION_BELOW_MINIMUM naming the Capy wrapper or
bin/gstack-upgrade; unknown says so and never claims the minimum. The runtime
and pins rows name a supported Bun found in $BUN_INSTALL/bin (not on PATH)
with the export/GSTACK_LAUNCH fix (gstack_bun_launch_dir, one helper in
bin/gstack-bun-version.sh). --for a planning-only workflow (autoplan, the plan
reviews, office-hours) reports a repo test-pin mismatch as SKIP pins
(planning-only; ...). New result codes UPGRADE_REFUSED, UPGRADE_FETCH_FAILED,
REVISION_BELOW_MINIMUM, CAPY_WRAPPER_STALE, INSTALL_UNVERIFIED with
troubleshooting anchors; REVISION_UNMET's fix names bin/gstack-upgrade.

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

* feat(install): install verdict and install admission at skill start

bin/gstack-install-verdict.sh (Bash) and lib/install-verdict.ts (TypeScript)
are the one reader of the install verdict gstack-capy-install writes to
<state root>/install-verdicts/<sha256(checkout realpath)>.json (checkout,
HEAD, workflow and required components, planning-only pins, trailer). The
source checkout and capy/support.env resolve through gstack_source_root /
sourceRoot, which follow an installed root's .source-path.

gstack-skill-start, after SESSION_KIND: an unattended session (any host)
refuses on a PATH Bun below the supported minimum; with GSTACK_CAPY=1 an
unattended or explicit spawned session also refuses without a passing
verdict for this checkout, HEAD and workflow (INSTALL_UNVERIFIED). A refusal
prints the INSTALL: row, an install-refused instruction block bound to its
SESSION_ID, GSTACK_RESULT ... status=refused run=- and exits 3. Ambient
spawned sessions warn; GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1 admits and says so.
capy/support.env declares GSTACK_MIN_REVISION and GSTACK_CAPY_MIN_WRAPPER.

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

* feat(capy-install): launch PATH, Bun reuse and CI pins, setup skip on HEAD, verdict, --check

The GSTACK_LAUNCH line carries PATH=<dir>:$PATH when the certified Bun is not
first on PATH, plus GSTACK_CAPY=1 and GSTACK_INSTALL_VERDICT, and
GSTACK_LAUNCH_PATH: <dir>; the doctor runs with that PATH. A supported Bun in
$BUN_INSTALL/bin is reused instead of downloaded again. Project pins are read
with bin/gstack-runtime-pins.ts (CI lanes included): when every exact pin is
gstack's verified version the archive is installed alongside a different PATH
Bun; any other exact pin is RUNTIME_PIN_CONFLICT. Both ./setup runs are
skipped when this HEAD was set up with the same hosts, model and browser flag
and the check passes (never after a fast-forward); a failed setup ends with
gstack: fail setup and is retried next run. --check certifies only;
GSTACK_CAPY_LOCK_HELD=1 accepts the bootstrapper's held lock; result anchors
come from the generated components table; REV_NOTE names bin/gstack-upgrade.

Intentional pin change: test/gstack-capy-install.test.ts --revision trailer
now names <root>/bin/gstack-upgrade (the old text named a command that never
existed).

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

* feat(upgrade): bin/gstack-upgrade, the unattended fast-forward every fix line names

--check fetches and prints current|behind <installed> -> <remote> (<n>
commits); the default fast-forwards to the remote default branch tip and
reinstalls (bin/gstack-capy-install on a Capy install, else ./setup
--refresh-registered), completing a pending reinstall on the next run. It
refuses (UPGRADE_REFUSED, exit 3, nothing changed) a dirty tracked byte, a
diverged commit, a pinned checkout, the incoming Bun floor and a held
.setup-lock; never stashes or resets; an unreachable remote is
UPGRADE_FETCH_FAILED. Fetches are receipted (fail-open). The unattended
skill-end UPGRADE_AVAILABLE line is followed by UPGRADE_COMMAND naming it.

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

* feat(capy): vendored Capy skill, frozen bootstrapper and full wrapper bin/gstack-capy-session

capy/skill/gstack/ is the Drive skill's source of truth. scripts/install.sh
is a frozen bootstrapper: it prints gstack-capy-skill: wrapper <n> (<path>),
clones or fetches under .gstack-capy-install.lock, reads the remote
capy/support.env (FETCH_HEAD), fast-forwards a clean unpinned checkout below
GSTACK_MIN_REVISION (refusing dirty, diverged or pinned checkouts with the
exact commands; GSTACK_CAPY_AUTO_UPGRADE=0 disables), and execs the
checkout's own bin/gstack-capy-session with GSTACK_CAPY_LOCK_HELD=1 so the
held lock is never taken twice. Offline it says the remote minimum is
unknown. A Drive copy older than GSTACK_CAPY_MIN_WRAPPER prints an owner
action (CAPY_WRAPPER_STALE). SKILL.md: check at session start, install
lazily before the first workflow, the GSTACK_LAUNCH line, the trailer rule,
the subagent first-report line, gstack-pregate sealed before any push, and
the autoplan/DRIVER.md pointer. The context-budget fixture gains the skill's
entry.

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

* docs: Capy install layers, lazy install, snapshot bake, forward-only recovery, raising the minimum

docs/capy-install.md describes the three layers (Drive bootstrapper, full
wrapper, installer), the lazy install, the GSTACK_LAUNCH line, install
admission, baking gstack into a Capy snapshot, and that recovery after an
interrupted fast-forward completes or refuses the pending install stages
(checking out an older commit is not a rollback). docs/CHANGELOG_STYLE.md
(the release doc; there is no docs/RELEASING.md) says when
GSTACK_MIN_REVISION may be raised.

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

* refactor(capy): the stale-copy owner action moves from the frozen bootstrapper to bin/gstack-capy-session

The checkout-owned full wrapper reads GSTACK_CAPY_MIN_WRAPPER through
gstack_support_env_get(gstack_source_root) and compares it with the
bootstrapper revision it was handed, so the rule can change without
recopying the Drive skill. The bootstrapper keeps only what a stale checkout
cannot do: clone, fetch, read the remote minimum, fast-forward, exec.

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

* v1.91.96.0: honest install and self-upgrade on Capy; supported minimum 1.91.96.0

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

* test: drop the helper-only Capy host fixture test (test-of-test ratchet); lane tests exercise the helper

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

* test: bound the new sync spawns in the install-verdict and wrapper tests (spawnSync tripwire)

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

* ci(paid-pr-profile): the vendored Capy skill is free-only; free-only files are not prompts needing paid coverage

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

* chore(harness): non-behavioral HARNESS_VERSION decision for the PR-profile free-only rule

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

* test(doctor): lane A runtime and pins cases are platform-neutral (bash paths; a lane-free .bun-version pin)

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

* docs(designs): field wave plan with amendments and the gate rev 2-3 review record (ARTIFACT_VALID, 3 gate revisions)

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

* fix(capy-install): follow a project's exact Bun pin above the minimum when gstack has its verified archive (adds Bun 1.4.3)

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

* docs: the installer follows a verified exact project Bun pin (capy-install, troubleshooting, changelog)

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

* fix(capy-install): the install reason names the project's pin and gstack's real minimum

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>
2026-10-13 14:41:58 +00:00

233 lines
9.6 KiB
TypeScript

/**
* doctor-components — the one component table behind `gstack-doctor --check`.
*
* Row ids, `--for <skill>` resolution, `--require <csv>`, the trailer and the
* `--json` keys all come from here, so a parent agent never maps between
* vocabularies. The doctor is Bash 3.2 and must report with Bun absent, so it
* cannot import this file: scripts/gen-doctor-components.ts renders it into
* bin/gstack-doctor-components.sh (sourced by bin/gstack-doctor-check.sh) and
* test/doctor-components.test.ts keeps the committed render fresh, the way
* agents-digest/gstack-AGENTS.md is kept fresh.
*
* Row semantics (printed as `PASS | FAIL | SKIP <id>`):
* core always evaluated and always required (runtime, pins, state root,
* privacy, cores, revision). `pins` and `state-root` SKIP with a
* reason when they cannot be evaluated; a SKIP never fails a run.
* host a host profile. Required when `--for`/`--require` names it, or by
* default when it is installed from this checkout. With no flag and
* no host installed, `claude` (setup's default host) is required so
* an empty machine is never `ok`.
* component a built or rendered artifact (patch, browse bundle, browser).
* SKIP (not_applicable) when the selected workflow does not need it.
* optional present-or-absent helpers (Codex CLI, CSO native helper). SKIP
* (optional) when absent and not required; FAIL only when required.
*/
import { RESULT_CODES } from './result-codes';
export type ComponentKind = 'core' | 'host' | 'component' | 'optional';
export interface DoctorComponent {
id: string;
title: string;
kind: ComponentKind;
}
export const DOCTOR_COMPONENTS: readonly DoctorComponent[] = [
{ id: 'claude', title: 'Claude skills', kind: 'host' },
{ id: 'codex', title: 'Codex skills', kind: 'host' },
{ id: 'patch', title: 'Astra behavioral patch in the Codex render', kind: 'component' },
{ id: 'codex-cli', title: 'Codex CLI (outside voice)', kind: 'optional' },
{ id: 'browse-bundle', title: 'browse bundle', kind: 'component' },
{ id: 'browser', title: 'Chromium launch and render', kind: 'component' },
{ id: 'cso', title: 'CSO native helper', kind: 'optional' },
{ id: 'runtime', title: 'gstack runtime (Bun)', kind: 'core' },
{ id: 'pins', title: 'project pins', kind: 'core' },
{ id: 'state-root', title: 'state root', kind: 'core' },
{ id: 'privacy', title: 'privacy', kind: 'core' },
{ id: 'cores', title: 'cores', kind: 'core' },
{ id: 'revision', title: 'revision', kind: 'core' },
] as const;
export type ComponentId = (typeof DOCTOR_COMPONENTS)[number]['id'];
export const COMPONENT_IDS: readonly ComponentId[] = DOCTOR_COMPONENTS.map(c => c.id);
/** What a workflow needs beyond the core rows. `optional` rows SKIP when absent. */
export interface SkillRequirement {
requires: readonly ComponentId[];
optional: readonly ComponentId[];
}
const PLANNING: SkillRequirement = { requires: ['claude', 'codex', 'patch'], optional: ['codex-cli'] };
const BROWSER: SkillRequirement = { requires: ['claude', 'codex', 'patch', 'browse-bundle', 'browser'], optional: ['codex-cli'] };
const CLAUDE_ONLY: SkillRequirement = { requires: ['claude'], optional: [] };
/**
* Every skill directory in the repo is classified here; test/doctor-components.test.ts
* fails when a skill is added without a row. Planning skills need both host
* profiles and the Astra patch (the outside voice reads the Codex render);
* browser skills add the browse bundle and Chromium; /cso adds the native
* helper; /codex needs the CLI itself; everything else runs on the Claude
* profile alone.
*/
export const SKILL_REQUIREMENTS: Readonly<Record<string, SkillRequirement>> = {
autoplan: PLANNING,
'plan-ceo-review': PLANNING,
'plan-eng-review': PLANNING,
'plan-design-review': PLANNING,
'plan-devex-review': PLANNING,
'plan-tune': PLANNING,
'office-hours': PLANNING,
spec: PLANNING,
review: PLANNING,
ship: PLANNING,
investigate: PLANNING,
codex: { requires: ['claude', 'codex', 'patch', 'codex-cli'], optional: [] },
cso: { requires: ['claude', 'cso'], optional: [] },
browse: BROWSER,
qa: BROWSER,
'qa-only': BROWSER,
'design-review': BROWSER,
'design-consultation': BROWSER,
'design-html': BROWSER,
'devex-review': BROWSER,
scrape: BROWSER,
skillify: BROWSER,
canary: BROWSER,
benchmark: BROWSER,
'make-pdf': BROWSER,
diagram: BROWSER,
'pair-agent': BROWSER,
'open-gstack-browser': BROWSER,
'setup-browser-cookies': BROWSER,
'connect-chrome': BROWSER,
'land-and-deploy': BROWSER,
'benchmark-models': CLAUDE_ONLY,
careful: CLAUDE_ONLY,
'claude-code': CLAUDE_ONLY,
'context-restore': CLAUDE_ONLY,
'context-save': CLAUDE_ONLY,
'design-shotgun': CLAUDE_ONLY,
'deslop-shared-libs': CLAUDE_ONLY,
'document-generate': CLAUDE_ONLY,
'document-release': CLAUDE_ONLY,
'eval-plan': CLAUDE_ONLY,
freeze: CLAUDE_ONLY,
'gstack-upgrade': CLAUDE_ONLY,
guard: CLAUDE_ONLY,
health: CLAUDE_ONLY,
'ios-clean': CLAUDE_ONLY,
'ios-design-review': CLAUDE_ONLY,
'ios-fix': CLAUDE_ONLY,
'ios-qa': CLAUDE_ONLY,
'ios-sync': CLAUDE_ONLY,
'landing-report': CLAUDE_ONLY,
learn: CLAUDE_ONLY,
retro: CLAUDE_ONLY,
'setup-deploy': CLAUDE_ONLY,
'setup-gbrain': CLAUDE_ONLY,
'sync-gbrain': CLAUDE_ONLY,
'test-audit': CLAUDE_ONLY,
unfreeze: CLAUDE_ONLY,
};
/**
* Planning-only workflows: they read and review a plan but never run the
* target repository's tests, so a repo test-pin mismatch is reported as
* `SKIP pins (planning-only; ...)` for `--for <skill>` instead of failing.
* Workflows that run the repo's tests (ship, review, qa, pregate) keep `pins`
* required.
*/
export const PINS_ADVISORY_SKILLS: readonly string[] = [
'autoplan', 'plan-ceo-review', 'plan-eng-review', 'plan-design-review', 'plan-devex-review', 'office-hours',
];
/** Rows always evaluated and required, in print order. */
export const CORE_IDS: readonly ComponentId[] = DOCTOR_COMPONENTS.filter(c => c.kind === 'core').map(c => c.id);
export const OPTIONAL_IDS: readonly ComponentId[] = DOCTOR_COMPONENTS.filter(c => c.kind === 'optional').map(c => c.id);
export const HOST_IDS: readonly ComponentId[] = DOCTOR_COMPONENTS.filter(c => c.kind === 'host').map(c => c.id);
/** The SKIP reason the installer and the doctor print for a lazily installed browser. */
export const BROWSER_LAZY_REASON = 'lazy; gstack-browser-ensure installs on first use';
/** Render the table as Bash 3.2 (sourced by bin/gstack-doctor-check.sh). */
export function renderDoctorComponentsSh(): string {
const caseArm = (ids: readonly string[], body: string) => ` ${ids.join('|')}) ${body} ;;`;
const groups = new Map<string, string[]>();
for (const [skill, req] of Object.entries(SKILL_REQUIREMENTS)) {
const key = `${req.requires.join(' ')}\t${req.optional.join(' ')}`;
groups.set(key, [...(groups.get(key) ?? []), skill]);
}
const requireArms: string[] = [];
const optionalArms: string[] = [];
for (const [key, skills] of groups) {
const [requires, optional] = key.split('\t');
requireArms.push(caseArm(skills, `printf '%s' '${requires}'`));
optionalArms.push(caseArm(skills, `printf '%s' '${optional}'`));
}
return [
'# shellcheck shell=bash',
'# gstack-doctor-components.sh — GENERATED from lib/doctor-components.ts and',
'# lib/result-codes.ts by scripts/gen-doctor-components.ts (bun run gen:skill-docs). Do not edit; the',
'# freshness test is test/doctor-components.test.ts. Sourced by',
'# bin/gstack-doctor-check.sh; Bash 3.2 builtins only.',
'',
`GSTACK_DOCTOR_COMPONENTS="${COMPONENT_IDS.join(' ')}"`,
`GSTACK_DOCTOR_CORE="${CORE_IDS.join(' ')}"`,
`GSTACK_DOCTOR_HOSTS="${HOST_IDS.join(' ')}"`,
`GSTACK_DOCTOR_OPTIONAL="${OPTIONAL_IDS.join(' ')}"`,
`GSTACK_DOCTOR_SKILLS="${Object.keys(SKILL_REQUIREMENTS).join(' ')}"`,
`GSTACK_BROWSER_LAZY_REASON="${BROWSER_LAZY_REASON}"`,
'',
'# gstack_doctor_component_title ID — the human title; returns 1 for an unknown id.',
'gstack_doctor_component_title() {',
' case "$1" in',
...DOCTOR_COMPONENTS.map(c => caseArm([c.id], `printf '%s' '${c.title.replace(/'/g, "'\\''")}'`)),
' *) return 1 ;;',
' esac',
'}',
'',
'# gstack_doctor_component_kind ID — core | host | component | optional; returns 1 for an unknown id.',
'gstack_doctor_component_kind() {',
' case "$1" in',
...DOCTOR_COMPONENTS.map(c => caseArm([c.id], `printf '%s' '${c.kind}'`)),
' *) return 1 ;;',
' esac',
'}',
'',
'# gstack_doctor_skill_requires SKILL — the non-core ids the skill needs; returns 1 for an unknown skill.',
'gstack_doctor_skill_requires() {',
' case "$1" in',
...requireArms,
' *) return 1 ;;',
' esac',
'}',
'',
'# gstack_doctor_skill_optional SKILL — ids that SKIP when absent; returns 1 for an unknown skill.',
'gstack_doctor_skill_optional() {',
' case "$1" in',
...optionalArms,
' *) return 1 ;;',
' esac',
'}',
'',
'# gstack_doctor_skill_pins_advisory SKILL — true for a planning-only workflow (a repo test-pin mismatch SKIPs pins).',
'gstack_doctor_skill_pins_advisory() {',
' case "$1" in',
caseArm(PINS_ADVISORY_SKILLS, 'return 0'),
' *) return 1 ;;',
' esac',
'}',
'',
'# gstack_result_anchor CODE — the docs/troubleshooting.md anchor for a lib/result-codes.ts code; returns 1 for an unknown code.',
'gstack_result_anchor() {',
' case "$1" in',
...Object.entries(RESULT_CODES).map(([code, row]) => caseArm([code], `printf '%s' '${row.anchor}'`)),
' *) return 1 ;;',
' esac',
'}',
'',
].join('\n');
}