Files
gstack/lib/doctor-components.ts
T
Garry Tanandcapy-ai[bot] 10b77de4f2 v1.91.284.0 feat(absorb-tstack): /modernize skill, per-skill Codex default prompts, gstack-post --attribute, the unattended grant and a CONTRIBUTING porting section (#3497)
* feat(absorb-tstack): /modernize skill, per-skill Codex default prompts, gstack-post --attribute, the unattended grant and a CONTRIBUTING porting section

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

* fix(modernize): read the PR text through gstack-issue-guard; re-measure the context ceiling

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

* fix: pre-landing review fixes

- gstack-post: with --attribute, report scan findings at the body file's own
  line numbers and a finding inside the callout as PART: attribute (the
  coordinates were shifted by the three callout lines)
- /modernize: route commit messages through gstack-issue-guard with the rest
  of the PR text, read isCrossRepository and maintainerCanModify, fetch a fork
  head from pull/N/head, refuse a PR whose head is the base branch, and list
  moved files NUL-safely
- docs/unattended.md: say that /ship never rebases or force-pushes and an
  unattended /modernize stops at its verdict
- CHANGELOG: seven skills carry a codex_default_prompt, not six

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

* fix: /modernize fetches a fork head with a forced refspec and stops when the PR moved

A rerun after the author force-pushed left refs/modernize/pr-N stale (the
non-forced refspec is rejected with exit 0 through the pipe), and the lease
pinned to the current remote head would then have let the rebased stale head
overwrite the author's newer commits. The fetch is forced, Steps 2 and 4 stop
when the head is not the one observed in Step 1, and the head-is-base guard
applies only to same-repository PRs (a fork's main into main is a valid head).

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

* fix: /modernize stops on merge commits in the PR range and pushes a fork head to the fork

A plain git rebase drops merge commits and git range-diff compares only the
non-merge patches, so a change authored in a PR's merge commit disappeared
while the proof printed = for every patch and Step 5 force-pushed the loss.
Step 4 now counts merges in $MERGE_BASE..$OLD_HEAD and stops when there are
any, switches with -C so a rerun after a refused lease does not fail on the
existing branch, and asks for a cross-repository diff to be read as untrusted
input before the repository's test command runs it. Step 5 pushes a fork head
to the fork's URL with the same pinned lease instead of to origin, where the
lease could never match.

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

* test: cover the Codex default-prompt render, the openai.yaml ownership proof and the unattended grant

Adds the /ship coverage audit's tests: test/host-renders-openai-yaml-proof.test.ts
drives bin/gstack-host-renders.sh's weakened proof against a rendered yaml, a
hand-written one and a generic-prompt one; test/unattended-grant.test.ts pins the
docs/unattended.md grant section and the handoff pointer; test/gen-skill-docs.test.ts
adds modernize to the skills whose codex_default_prompt must reach openai.yaml.

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

* docs: name the static lists a new skill still needs and the Codex metadata file

From the ship documentation audit: CONTRIBUTING's new-skill checklist said no
static list needs a row, which this PR disproved (lib/doctor-components.ts
SKILL_REQUIREMENTS and the catalog rows); the host comparison table and
docs/ADDING_A_HOST.md gain the agents/openai.yaml default_prompt source; a blank
line before the new docs/unattended.md grant heading.

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

* fix: pre-landing review fixes

CHANGELOG [Unreleased] gate-integrity record: the catalog and context-budget
figures re-measured on top of main (1,314 -> 1,359; modernize 9466;
alwaysOnTotal 7458 -> 7880), as the merge commit's ratchets state.

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

* docs: name the optional codex_default_prompt key in the new-skill checklist

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

* test(parity): re-pin the ship union cap at 1.7405 after the main merge

GSTA-36 grant 2026-10-27. On the tree merged with f27a7605 the ship union is 326,691 / 187,706 = 1.7404 against #3487's 1.7400; the only ship bytes this PR adds are the handoff section's one-line pointer. Cap 1.7400 -> 1.7405, measurement appended to the comment.

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

* docs(changelog): the [Unreleased] gate-integrity bullet names the ship cap re-pin

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

* fix: pre-landing review fixes

CHANGELOG [Unreleased]: end the Codex default-prompts sentence at
"generator input only." so bin/gstack-prose-lint --unreleased --gate
(main's new quality-gate step) passes, and restate the gate-integrity
bullet's figures for the tree merged with 1f246e35: ship cap
1.7524 -> 1.7528 (329,005 / 187,706), modernize 9466 -> 3772 after the
preamble hoist, alwaysOnTotal 7769 -> 7880, catalog 1,359 unchanged.

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

* fix: pre-landing review fixes

CHANGELOG [Unreleased] gate-integrity bullet names the current ship cap pin
(1.7525 -> 1.7529, measured 329,024 / 187,706 on the 392f50d4 merge) and keeps
the 1.7528 pin in the earlier-merges clause.

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-27 22:23:20 +00:00

316 lines
15 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, floor, wrapper). `pins`, `state-root`,
* `floor` and `wrapper` SKIP with a reason when they cannot be
* evaluated; a SKIP never fails a run. WARN never fails a run
* either: `wrapper` WARNs (an owner action) for a stale Capy skill
* copy the run did not use; `floor` WARNs when the installed gstack
* is below the floor the project declares (`min_gstack_version` in
* its .gstack/ship-policy.json), with the upgrade as the fix; `pins`
* WARNs when only the project's Node pin is unmet (the Node on PATH
* differs from the pin; Bun pins stay FAIL, because gstack runs on
* Bun). Only the security floor (`--min-revision`, capy/support.env
* GSTACK_MIN_REVISION) fails a session for being old.
* 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.
*
* Install profiles (INSTALL_PROFILES) are SKILL_REQUIREMENTS rows that name an
* install shape instead of a skill directory. `--for review-voice` is the one
* today: a dispatched reviewer's machine gets a clone or fast-forward and Bun
* at gstack's own minimum (the project's pins are never applied, so `pins`
* SKIPs), no ./setup and no host profiles, and the trailer certifies only the
* read-only helpers (`helpers`). Its verdict covers no host, so admission
* refuses every workflow with a `--for <skill>` fix.
*/
import { RESULT_CODES } from './result-codes';
import { SHIP_POLICY_PATH } from './ship-policy';
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: 'helpers', title: 'read-only helpers (gstack-pregate sealed, gstack-plan-reality, gstack-artifact, gstack-autoplan reads)', kind: 'component' },
{ 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' },
{ id: 'floor', title: "the project's gstack floor (min_gstack_version in .gstack/ship-policy.json)", kind: 'core' },
{ id: 'wrapper', title: 'Capy skill copy (bootstrapper 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: [] };
const REVIEW_VOICE: SkillRequirement = { requires: ['helpers'], optional: [] };
/** SKILL_REQUIREMENTS rows that are install profiles, not skill directories (see the header). */
export const INSTALL_PROFILES: readonly string[] = ['review-voice'];
/**
* `--for none`: the session runs no gstack workflow on this machine (a
* coordinator or liaison thread). The check certifies nothing and installs
* nothing; its trailer is `gstack: ok not-needed for=none (<notes>)`, exit 0,
* which admits no workflow (no install verdict is written, so admission still
* refuses with a `--for <skill>` fix). The bootstrapper, the session wrapper
* and the installer all honour it, so a machine without a checkout gets the
* same outcome as one with a checkout.
*/
export const NOT_NEEDED_PROFILE = 'none';
export const NOT_NEEDED_REVISION = 'not-needed';
/** The project file and key the `floor` row reads (the same key gstack-ship-queue validates). */
export const PROJECT_FLOOR_FILE = SHIP_POLICY_PATH;
export const PROJECT_FLOOR_FIELD = 'min_gstack_version';
/** The read-only helpers the `helpers` row launches (`<bin> --help` loads each module graph with the launch Bun). */
export const REVIEW_VOICE_HELPERS: readonly string[] = ['gstack-pregate', 'gstack-plan-reality', 'gstack-artifact', 'gstack-autoplan'];
/** The checkout root the Capy skill bootstrapper resolves by default (GSTACK_CAPY_ROOT unset). */
export const CAPY_INSTALL_ROOT = '$HOME/.local/share/gstack';
export const GSTACK_REPO_URL = 'https://github.com/garrytan/gstack.git';
/**
* The install command for a machine without the Drive skill: clone into ROOT
* when no checkout is there, then run the checkout's own bootstrapper (the
* file the Drive skill holds) by absolute path with that root, so the floor
* check and the fast-forward still run and the caller's working directory,
* and so its project, is kept. No `; ` inside, so it can ride a fix clause.
*/
export function capyBootstrapCommand(skill: string, root: string = CAPY_INSTALL_ROOT): string {
return `test -f "${root}/setup" || git clone --quiet --single-branch --depth 1 ${GSTACK_REPO_URL} "${root}" && GSTACK_CAPY_ROOT="${root}" bash "${root}/capy/skill/gstack/scripts/install.sh"${skill ? ` --for ${skill}` : ''}`;
}
/**
* 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,
'design-motion': 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,
captain: 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,
modernize: CLAUDE_ONLY,
retro: CLAUDE_ONLY,
'setup-deploy': CLAUDE_ONLY,
'setup-gbrain': CLAUDE_ONLY,
'sync-gbrain': CLAUDE_ONLY,
'test-audit': CLAUDE_ONLY,
ubicloud: CLAUDE_ONLY,
unfreeze: CLAUDE_ONLY,
'review-voice': REVIEW_VOICE,
};
/**
* 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_HELPERS="${REVIEW_VOICE_HELPERS.join(' ')}"`,
`GSTACK_DOCTOR_NOT_NEEDED="${NOT_NEEDED_PROFILE}"`,
`GSTACK_DOCTOR_NOT_NEEDED_REVISION="${NOT_NEEDED_REVISION}"`,
`GSTACK_PROJECT_FLOOR_FILE="${PROJECT_FLOOR_FILE}"`,
`GSTACK_PROJECT_FLOOR_FIELD="${PROJECT_FLOOR_FIELD}"`,
'',
'# 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_doctor_install_profile NAME — true for an install profile (helper runtime: no ./setup, no host profiles, project pins not applied).',
'gstack_doctor_install_profile() {',
' case "$1" in',
caseArm(INSTALL_PROFILES, 'return 0'),
' *) return 1 ;;',
' esac',
'}',
'',
'# gstack_capy_bootstrap_cmd ROOT [SKILL] — the install command for a machine without the Drive skill (capyBootstrapCommand).',
'gstack_capy_bootstrap_cmd() {',
` printf 'test -f "%s/setup" || git clone --quiet --single-branch --depth 1 %s "%s" && GSTACK_CAPY_ROOT="%s" bash "%s/capy/skill/gstack/scripts/install.sh"' "$1" '${GSTACK_REPO_URL}' "$1" "$1" "$1"`,
` [ -z "\${2:-}" ] || printf ' --for %s' "$2"`,
'}',
'',
'# 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');
}