Files
gstack/lib/install-verdict.ts
T
Garry Tanandcapy-ai[bot] c71d764ba4 v1.91.115.0 feat: field list 2 wave A: autoplan round cap and public export, eval-plan diagnostics and model rules, --for review-voice (#3234)
* fix(capy): the install trailer says behind=<n> and its fix, so a stale install shows in every relayed trailer (field list 2 item 13, #2378)

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

* fix(capy): a lease records its holder's start identity, so a pid reused after a wake is reclaimed; a skipped session-start upgrade ends fail behind, never ok (GBRA-105, GBRA-106)

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

* fix(lease): stale needs proof (unreadable or malformed identities keep the lease), ps identity is timezone-independent, and reclaim never deletes a concurrent renewal

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

* feat(autoplan): enforced round cap and spend budget, deferred close, public export without machine paths, opt-in durable checkpoints (field list 2 W5-W7)

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

* fix(lease): one directory lock for publish, prune and reclaim; reclaim rereads the record judged stale, so a renewal is never deleted or hidden

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

* fix(lease): the directory lock names its holder; broken only when the holder is dead or its pid reused, unlock removes only its own lock, touch exits 3 under a live holder

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

* fix(lease): breakers serialize on .lock.break and reread the lock before removing it; gstack-skill-start reports a lease the held lock kept out

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

* docs(lease): name the double-crash limit of breaking a dead lock

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

* feat(capy): --for review-voice install profile, no-Drive bootstrap command, reviewer prompt trailer (field list 2 W11)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 89bf556c11)

* test: pin review-voice row, helpers row and the no-Drive fallback in existing install tests

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 7f6c70459a)

* test: the DRIVER template names the reviewer trailer relay (pregate lane)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 2545d9a6d2)

* test(context-budget): capy skill eager ceiling for the review-voice subagent paragraph (W11; ratchet protocol, capture value)

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

* feat(eval-plan): prereg diagnostics dry run (W8) and .gstack/eval-models.json lint (W9), WIP

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit b509096218)

* feat(eval-plan): W8/W9 tests, docs/eval-models.md, troubleshooting anchors, /eval-plan steps

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 9335e45de2)

* test(eval-plan): eval-plan/SKILL.md names only defined codes and accepted flags

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit a7a974d268)

* test(review-voice): the launch line points at the verdict's bun-only directory (#3233's launch contract)

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

* test(third-party-actions): the Aside drive-offer detector matches the product name, not the English idiom (paid run 38988927989 tpa-absent-linux false positive)

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

* fix(autoplan): export refuses artifact paths outside the run before writing; a deferred close keeps earlier undecided findings; reopening CEO grants Eng its rerun (review of #3234)

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

* test(autoplan-budget): bind the reservation expectation to lib/autoplan-budget's default estimate (test-of-test ratchet)

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

* fix(evals): the docs observer accepts the spawned-prefixed gstack-skill-start --help read

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

* fix(windows): curate capy-review-voice out of the Windows subset (POSIX launcher contract)

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

* docs: changelog entry for field list 2 wave A

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

* chore: stamp v1.91.115.0

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-17 02:40:45 +00:00

205 lines
11 KiB
TypeScript

/**
* install-verdict — the TypeScript twin of bin/gstack-install-verdict.sh: the
* one reader of the install verdict gstack-capy-install writes, and the
* install-admission rule gstack-skill-start (Bash) and gstack-autoplan's
* mutating verbs share. test/install-verdict.test.ts pins both sides.
*
* Verdict file: <state root>/install-verdicts/<sha256(checkout realpath)>/<sha256(project realpath | "none")>.json,
* then the path the GSTACK_LAUNCH line names as GSTACK_INSTALL_VERDICT, then
* the schema 1 file keyed by the checkout alone (<sha256(checkout)>.json).
* The project is the working directory's git toplevel unless that is the
* checkout itself. It is the one record the bun shim, the Bash twin,
* lib/pregate/runtime.ts and admission read.
*
* Admission: applies to unattended and spawned sessions. It refuses (exit 3,
* `status=refused`) in unattended sessions and explicit
* GSTACK_SESSION_KIND=spawned sessions with GSTACK_CAPY=1 when the running Bun
* is below gstack's supported minimum and, with GSTACK_CAPY=1 only, when the
* verdict is missing, failing, for another checkout or HEAD, narrower than
* the workflow, for another project when the workflow runs the repo's tests,
* or records a Bun other than the one running (BUN_RESOLUTION_DRIFT). A
* verdict for an install profile (`for=review-voice`) covers no host, so it
* refuses every workflow with the full-install `--for <skill>` command. Other
* spawned sessions only warn.
* GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1 admits and sets `overridden` (the caller
* records it as a run deviation).
*/
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { INSTALL_PROFILES, PINS_ADVISORY_SKILLS, SKILL_REQUIREMENTS } from './doctor-components';
import type { ResultCodeName } from './result-codes';
import { resolveStateRoot } from './state-root';
export interface InstallVerdict {
schema_version: 1 | 2;
checkout: string;
head: string;
for: string;
required: string[];
pins_planning_only: boolean;
ok: boolean;
trailer: string;
min_revision: string;
launch_path: string;
time: string;
/** Schema 2: the project realpath ('' for none) and the Bun resolution recorded for it. */
project?: string;
pin?: string;
bun_path?: string;
bun_version?: string;
bun_rule?: string;
bun_install?: string;
}
export interface InstallAdmission {
/** The session kind is one admission covers (unattended or spawned). */
applies: boolean;
/** Stop: print `line`, then `GSTACK_RESULT: skill=<s> status=refused run=<dir>`, exit 3. */
refuse: boolean;
/** Ambient spawned session: print `line` and continue. */
warn: boolean;
/** GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1 admitted a failing check; record `detail` as a deviation. */
overridden: boolean;
status: 'ok' | 'runtime' | 'verdict';
detail: string;
code?: ResultCodeName;
/** The `INSTALL: fail|warn …` or `INSTALL_VERDICT: …` line; empty when nothing to say. */
line: string;
}
type Env = Record<string, string | undefined>;
/** The source checkout behind an install root: its recorded .source-path, else the root itself (realpath). */
export function sourceRoot(gstackRoot: string): string {
const recorded = path.join(gstackRoot, '.source-path');
let src = gstackRoot;
if (fs.existsSync(recorded)) {
const line = fs.readFileSync(recorded, 'utf8').split('\n')[0].trim();
if (line && fs.existsSync(line)) src = line;
}
return fs.realpathSync(src);
}
const sha256 = (text: string) => createHash('sha256').update(text).digest('hex');
/** The verdict for (checkout, project); `project` null or '' is "none". */
export function installVerdictPath(checkout: string, stateRoot: string, project: string | null = null): string {
return path.join(stateRoot, 'install-verdicts', sha256(checkout), `${sha256(project || 'none')}.json`);
}
/** The schema 1 verdict keyed by the checkout alone (read-only fallback). */
export function legacyInstallVerdictPath(checkout: string, stateRoot: string): string {
return path.join(stateRoot, 'install-verdicts', `${sha256(checkout)}.json`);
}
/** The project a verdict is keyed by for `cwd`: its git toplevel realpath, null outside a repo or inside the checkout. */
export function installProject(checkout: string, cwd: string = process.cwd()): string | null {
const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', timeout: 10_000 });
const top = r.status === 0 ? (r.stdout ?? '').trim() : '';
if (!top) return null;
try { const real = fs.realpathSync(top); return real === checkout ? null : real; } catch { return null; }
}
/** The verdict file admission reads: (checkout, project), then GSTACK_INSTALL_VERDICT, then the schema 1 file. */
export function findInstallVerdict(checkout: string, project: string | null, env: Env): string {
const stateRoot = resolveStateRoot(env);
const keyed = installVerdictPath(checkout, stateRoot, project);
if (fs.existsSync(keyed)) return keyed;
if (env.GSTACK_INSTALL_VERDICT && fs.existsSync(env.GSTACK_INSTALL_VERDICT)) return env.GSTACK_INSTALL_VERDICT;
const legacy = legacyInstallVerdictPath(checkout, stateRoot);
return fs.existsSync(legacy) ? legacy : keyed;
}
export function readInstallVerdict(file: string): InstallVerdict | null {
try {
const v = JSON.parse(fs.readFileSync(file, 'utf8'));
return v && (v.schema_version === 1 || v.schema_version === 2) ? v as InstallVerdict : null;
} catch {
return null;
}
}
const below = (a: string, b: string) => {
const x = a.split(/[.+-]/).map(Number), y = b.split('.').map(Number);
for (let i = 0; i < 3; i++) if ((x[i] || 0) !== (y[i] || 0)) return (x[i] || 0) < (y[i] || 0);
return /-/.test(a);
};
function supportedBun(gstackRoot: string): string {
const text = fs.readFileSync(path.join(gstackRoot, 'bin/gstack-bun-version.sh'), 'utf8');
return text.match(/^GSTACK_BUN_TESTED="([0-9.]+)"/m)?.[1] ?? '0.0.0';
}
export interface VerdictProblem { detail: string; code: ResultCodeName; fix?: string }
/** Why the install verdict for (checkout, project of `cwd`) does not admit `skill`; null when it does. `bunPath` '' skips the running-Bun check. */
export function verdictProblem(skill: string, gstackRoot: string, env: Env, bunPath: string, bunVersion: string, cwd?: string): VerdictProblem | null {
const src = sourceRoot(gstackRoot);
const project = installProject(src, cwd);
const file = findInstallVerdict(src, project, env);
const v = readInstallVerdict(file);
const problem = (detail: string): VerdictProblem => ({ detail, code: 'INSTALL_UNVERIFIED' });
if (!v) return problem(`no install verdict at ${file} for ${src}`);
if (!v.ok) return problem(`the install verdict says: ${v.trailer}`);
if (v.checkout !== src) return problem(`the install verdict is for checkout ${v.checkout}, not ${src}`);
const head = spawnSync('git', ['-C', src, 'rev-parse', 'HEAD'], { encoding: 'utf8', timeout: 10_000 }).stdout?.trim() ?? '';
if (!head || v.head !== head) return problem(`the install verdict is for another HEAD (${v.head.slice(0, 12)}; ${src} is at ${head.slice(0, 12)})`);
const advisory = PINS_ADVISORY_SKILLS.includes(skill);
if (v.pins_planning_only && !advisory) return problem(`the install verdict is planning-only (pins skipped for ${v.for}) and ${skill} runs the repo's tests`);
if (v.schema_version !== 1 && !advisory && (v.project ?? '') !== (project ?? '')) {
return { ...problem(`the install verdict checked ${v.project || 'no project'}, not ${project ?? 'this directory (no project)'}, and ${skill} runs the repo's tests`),
fix: `run the Capy skill installer (scripts/install.sh --for ${skill}) from ${project ?? 'this directory'}, then launch with the GSTACK_LAUNCH line it prints` };
}
const missing = (SKILL_REQUIREMENTS[skill]?.requires ?? []).find(id => !v.required.includes(id));
if (missing && INSTALL_PROFILES.includes(v.for)) {
return { ...problem(`the install verdict certified for=${v.for}, which does not cover ${missing} that ${skill} needs (${v.for} certifies only the read-only helpers)`),
fix: `run GSTACK_CAPY_ROOT=${src} bash ${src}/capy/skill/gstack/scripts/install.sh --for ${skill} (the full install: ./setup and host profiles), then launch with the GSTACK_LAUNCH line it prints` };
}
if (missing) return problem(`the install verdict certified for=${v.for}, which does not cover ${missing} that ${skill} needs`);
if (v.bun_path && bunPath && bunPath !== v.bun_path) {
return { detail: `bun ${bunVersion} at ${bunPath} is running, but the install verdict recorded bun ${v.bun_version} at ${v.bun_path}`, code: 'BUN_RESOLUTION_DRIFT',
fix: `launch with the GSTACK_LAUNCH line (PATH=${path.dirname(v.bun_path)}:$PATH), or re-run the Capy skill installer with --check (scripts/install.sh --check --for ${skill}) to see what changed` };
}
return null;
}
function realBunPath(): string {
try { return fs.realpathSync(process.execPath); } catch { return process.execPath; }
}
/**
* The admission decision for `skill` run from the gstack install at
* `gstackRoot` (the directory holding bin/). `bunVersion` defaults to the Bun
* running this process; `env` to process.env.
*/
export function checkInstallAdmission(opts: { skill: string; gstackRoot: string; env?: Env; bunVersion?: string; bunPath?: string; cwd?: string }): InstallAdmission {
const env = opts.env ?? process.env;
const kind = env.GSTACK_SESSION_KIND === 'unattended' || env.GSTACK_SESSION_KIND === 'spawned' ? env.GSTACK_SESSION_KIND
: env.OPENCLAW_SESSION ? 'spawned' : 'other';
const none: InstallAdmission = { applies: false, refuse: false, warn: false, overridden: false, status: 'ok', detail: '', line: '' };
if (kind === 'other') return none;
const capy = env.GSTACK_CAPY === '1';
const hard = kind === 'unattended' || (env.GSTACK_SESSION_KIND === 'spawned' && capy);
const minimum = supportedBun(opts.gstackRoot);
const bun = opts.bunVersion ?? process.versions.bun ?? '0.0.0';
let status: InstallAdmission['status'] = 'ok', detail = '', fix = '', code: ResultCodeName | undefined;
if (below(bun, minimum)) {
status = 'runtime'; code = 'RUNTIME_BELOW_MINIMUM';
detail = `bun ${bun} is below the supported minimum ${minimum}`;
fix = `run the Capy skill installer (scripts/install.sh --for ${opts.skill}), then launch with its GSTACK_LAUNCH line`;
} else if (capy) {
const p = verdictProblem(opts.skill, opts.gstackRoot, env, opts.bunPath ?? (opts.bunVersion ? '' : realBunPath()), bun, opts.cwd);
if (p) {
status = p.code === 'BUN_RESOLUTION_DRIFT' ? 'runtime' : 'verdict'; code = p.code; detail = p.detail;
fix = p.fix ?? `run the Capy skill installer (scripts/install.sh --for ${opts.skill}) on this machine, then launch with the GSTACK_LAUNCH line it prints`;
}
}
const base = { ...none, applies: true, status, detail, code };
if (status === 'ok') return base;
if (env.GSTACK_ALLOW_UNSUPPORTED_RUNTIME === '1') return { ...base, overridden: true, line: `INSTALL_VERDICT: overridden (GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1): ${detail}` };
const line = `INSTALL: ${hard ? 'fail' : 'warn'} ${status} — ${detail}; fix: ${fix} (${code})`;
return { ...base, refuse: hard, warn: !hard, line };
}