mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-15 12:57:22 +01:00
v1.94.30.0 stamp: merge main ade35bc813 into #3920 (land)
This commit is contained in:
commit
0c6820051b
55 files changed
+2378
-180
No files matched your search
+1
-1
@@ -54,7 +54,7 @@ function newestVerified(src: RunSource, name: string, profile: RunProfile, head:
|
||||
for (const run of listed) {
|
||||
const v = verifyRun(src.deps, { repo: src.repo, runRef: String(run.id), profileName: name, profile, vetoHeads: [head], now: src.now, unchanged: src.unchanged });
|
||||
if (v.ok) return v;
|
||||
refusals.push(`run ${run.id}: ${v.reason}`);
|
||||
refusals.push(`run ${run.id}: ${v.reason}${v.fix ? ` (the pull request's base moved after the run; fix: ${v.fix})` : ''}`);
|
||||
}
|
||||
return no('no_verified_run', `${name}: ${refusals.join('; ')}`);
|
||||
}
|
||||
|
||||
+5
-3
@@ -26,11 +26,12 @@ import { readLedger, release, reserve, settle, SpendCapExceeded, summarize, type
|
||||
export const PREREG_SECTIONS = ['Bars', 'Arms', 'Comparison', 'Decoys', 'Stop rules', 'Held-out exposure'] as const;
|
||||
/** `## Comparison`: `stack: loaded` when any arm loads gstack or another skill stack, `stack: none` with a `why:` otherwise. */
|
||||
export interface ComparisonCheck { stack: 'loaded' | 'none' | null; why: string | null; errors: string[] }
|
||||
/** `## No-gstack arm`, required when `stack: loaded`: one `<model>#no-gstack` Arms line per model, and the inputs, prompt, panel and report it shares with the variants. */
|
||||
/** `## No-gstack arm`, required when `stack: loaded`: one `<model>#no-gstack` Arms line per model, the inputs, prompt, panel and report it shares with the variants, and its masking, install and audit lines. */
|
||||
export interface NoGstackArmCheck { required: boolean; ok: boolean; arms: string[]; problems: string[] }
|
||||
export interface PreregCheck { ok: boolean; present: string[]; missing: string[]; empty: string[]; comparison: ComparisonCheck; no_gstack: NoGstackArmCheck; diagnostics: PreregDiagnostics | null }
|
||||
export const NO_GSTACK_LOADOUT = 'no-gstack';
|
||||
export const NO_GSTACK_KEYS = ['arms', 'inputs', 'prompt', 'panel', 'report'] as const;
|
||||
/** masking, install and audit keep the arm clean (TESTING_INTERNALS.md#no-gstack-arm): an arm that read a gstack skill file is contaminated. */
|
||||
export const NO_GSTACK_KEYS = ['arms', 'inputs', 'prompt', 'panel', 'report', 'masking', 'install', 'audit'] as const;
|
||||
|
||||
export function preregTemplate(title = 'Eval preregistration'): string {
|
||||
return `# ${title}
|
||||
@@ -45,7 +46,7 @@ export function preregTemplate(title = 'Eval preregistration'): string {
|
||||
<!-- \`stack: loaded\` when any arm loads gstack or another skill stack (review variants, preamble loadouts, skill diets, a model panel under a stack); otherwise \`stack: none\` and a \`why:\` line. -->
|
||||
|
||||
## No-gstack arm
|
||||
<!-- Required with \`stack: loaded\`: \`arms:\` one \`<model>#no-gstack\` Arms line per model; \`inputs:\` the same rows and fixture; \`prompt:\` the task only (no SKILL.md, preamble, hooks or installed skills); \`panel:\` the same E2E kind, trials and judge; \`report:\` its row beside the variants. -->
|
||||
<!-- Required with \`stack: loaded\`: \`arms:\` one \`<model>#no-gstack\` Arms line per model; \`inputs:\` the same rows and fixture; \`prompt:\` the task only (no SKILL.md, preamble, hooks or installed skills); \`panel:\` the same E2E kind, trials and judge; \`report:\` its row beside the variants; \`masking:\` how the run agent is kept from gstack (an empty directory bind-mounted over ~/.capy/drive/user-garry-tan and ~/.capy/drive/org-gstack-gbrain before it starts, or a container that sees no Capy drive); \`install:\` no gstack at ~/.local/share/gstack or ~/.claude/skills; \`audit:\` the per-arm drive-read audit (an arm that read a gstack skill file is contaminated and gets zero credit). -->
|
||||
|
||||
## Decoys
|
||||
<!-- Inputs the scorer must reject or score low; how many, how they were made, what a decoy pass proves. -->
|
||||
@@ -112,6 +113,7 @@ function checkNoGstackArm(text: string, stack: ComparisonCheck['stack']): NoGsta
|
||||
if (!body?.length) return { required: true, ok: false, arms: [], problems: ['missing section'] };
|
||||
const kv = keyValues(body);
|
||||
const problems = NO_GSTACK_KEYS.filter(k => !kv[k]).map(k => `missing ${k}`);
|
||||
for (const k of ['masking', 'install', 'audit'] as const) if (/^(none|n\/a|no|-)\.?$/i.test(kv[k] ?? '')) problems.push(`${k}: ${kv[k]} is not a plan`);
|
||||
const listed = (kv.arms ?? '').split(/[\s,]+/).map(a => a.replace(/^`|`$/g, '')).filter(Boolean);
|
||||
const ids = armIdentities(text);
|
||||
for (const a of listed) {
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* inventory — every part of gstack, its load per run and its evidence (ETHOS.md: 9. Earn Its Place).
|
||||
*
|
||||
* Keys are the skill directories discoverTemplates() finds (the root skill as `gstack`) plus
|
||||
* SHARED_KEYS. The ledger, docs/inventory/evidence.json, is hand-kept: each row is checked by its
|
||||
* status's own fields (validateEntry). buildInventory joins the ledger with lib/context-bill.ts
|
||||
* buildBill (offline token estimates) and the dispatch counts ship and autoplan already document,
|
||||
* each found by its quoted line so a citation that drifts is named. Read-only except the CLI's write.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { discoverTemplates } from '../scripts/discover-skills';
|
||||
import { buildBill, contentClass, TOKEN_DIVISOR, TOKEN_DIVISORS } from './context-bill';
|
||||
import { CAPY_INSTALL_ROOT } from './doctor-components';
|
||||
|
||||
export const LEDGER_PATH = 'docs/inventory/evidence.json';
|
||||
export const DOC_PATH = 'docs/inventory.md';
|
||||
export const ROOT_KEY = 'gstack';
|
||||
|
||||
export interface SharedPart { key: string; label: string; load: string; files: string[] }
|
||||
/** Shared parts: sized from their files (catalog from the bill's frontmatter). */
|
||||
export const SHARED_PARTS: SharedPart[] = [
|
||||
{ key: 'preamble', label: 'shared preamble (sections/preamble.md, identical in every tier 2+ skill)', load: 'every tier 2+ skill run, inside that skill\'s load', files: ['ship/sections/preamble.md'] },
|
||||
{ key: 'catalog', label: 'skill catalog (every skill\'s frontmatter)', load: 'every session', files: [] },
|
||||
{ key: 'capy-skill', label: 'capy/skill/gstack/SKILL.md', load: 'every Capy session', files: ['capy/skill/gstack/SKILL.md'] },
|
||||
{ key: 'capy-factory', label: 'capy/skill/gstack/factory.md', load: 'every Capy session', files: ['capy/skill/gstack/factory.md'] },
|
||||
{ key: 'ship-review-panel', label: '/ship review panel (review-army and adversarial sections)', load: 'every /ship run, by depth', files: ['ship/sections/review-army.md', 'ship/sections/adversarial.md'] },
|
||||
{ key: 'ship-docs-audit', label: '/ship docs audit (Step 14.5)', load: 'every /ship run', files: ['ship/sections/documentation.md'] },
|
||||
{ key: 'autoplan-phases', label: '/autoplan CEO, design, DX and eng phases', load: 'every /autoplan run, by scope', files: ['autoplan/sections/ceo-phase.md', 'autoplan/sections/design-phase.md', 'autoplan/sections/dx-phase.md', 'autoplan/sections/eng-phase.md'] },
|
||||
];
|
||||
export const SHARED_KEYS = SHARED_PARTS.map(p => p.key);
|
||||
|
||||
export interface Dispatch { skill: 'ship' | 'autoplan'; what: string; count: string; file: string; quote: string }
|
||||
/** Paid dispatches per run, from the budget tables and docs that already state them (no new instrumentation). */
|
||||
export const DISPATCHES: Dispatch[] = [
|
||||
{ skill: 'ship', what: 'Step 9 review, pass 1, light depth, docs or tests only', count: '1 reader (the core checklist)', file: 'docs/review-policy.md', quote: '| **light**, runtime 0 |' },
|
||||
{ skill: 'ship', what: 'Step 9 review, pass 1, light depth, code', count: '3 readers (core, one batched specialist, native adversarial), plus the outside voice on a diff that publishes numbers', file: 'docs/review-policy.md', quote: '| **light**, runtime above 0 |' },
|
||||
{ skill: 'ship', what: 'Step 9 review, pass 1, standard depth', count: 'the batched specialist, the Red Team at 200 runtime lines, adversarial and outside voice', file: 'docs/review-policy.md', quote: '| **standard** |' },
|
||||
{ skill: 'ship', what: 'Step 9 review, pass 1, full depth', count: 'the batched specialist on the whole diff and the Red Team always', file: 'docs/review-policy.md', quote: '| **full** |' },
|
||||
{ skill: 'ship', what: 'targeted reader on money, gate, auth or secret code', count: '+1 at every depth', file: 'docs/review-policy.md', quote: '`gstack-review-plan` dispatches one' },
|
||||
{ skill: 'ship', what: 'audits (Steps 7, 8 and 14.5)', count: '3', file: 'lib/ship-budget-calibration.json', quote: 'each audit dispatch (Steps 7, 8, 14.5)' },
|
||||
{ skill: 'ship', what: 'measured: one /ship child (gstack-evals#31)', count: '10 review dispatches', file: 'lib/ship-budget-calibration.json', quote: 'its 10 review dispatches' },
|
||||
{ skill: 'ship', what: 'prior cost of one dispatch on the Capy meter', count: '7,430,000 tokens', file: 'lib/ship-budget-calibration.json', quote: '"per_dispatch": 7430000' },
|
||||
{ skill: 'autoplan', what: 'voices per phase (native and outside)', count: '2', file: 'autoplan/DRIVER.md.tmpl', quote: 'per voice. Dispatch the native prompt to a subagent on your model and the outside prompt to' },
|
||||
{ skill: 'autoplan', what: 'phases per round: CEO and Eng always, Design with UI scope, DX with developer-facing scope', count: '2 to 4, so 4 to 8 voice dispatches a round', file: 'autoplan/SKILL.md.tmpl', quote: 'Phases MUST run in strict order: CEO → Design (if UI scope) → DX (if developer-facing' },
|
||||
{ skill: 'autoplan', what: 'CEO spec review loop', count: 'up to 3 launches', file: 'autoplan/sections/ceo-phase.md.tmpl', quote: 'three-launch cap' },
|
||||
{ skill: 'autoplan', what: 'review rounds per phase (new unattended runs)', count: 'up to 2', file: 'lib/autoplan-budget.ts', quote: 'export const DEFAULT_MAX_ROUNDS = 2;' },
|
||||
{ skill: 'autoplan', what: 'reserved per voice on Capy or under a spend cap', count: '$5', file: 'lib/autoplan-budget.ts', quote: 'export const DEFAULT_HOST_ESTIMATE_USD = 5;' },
|
||||
];
|
||||
|
||||
export const STATUSES = ['helps', 'no-effect', 'hurts', 'untested', 'safety'] as const;
|
||||
export type Status = (typeof STATUSES)[number];
|
||||
export const R48_VERDICTS = ['KEEP', 'FIX-METHOD', 'CUT-CANDIDATE'] as const;
|
||||
/** R54: the frontier models under test; a row may add at most one named low-cost primary, never Haiku or Sonnet. */
|
||||
export const FRONTIER_MODELS = ['claude-opus-5-5', 'gpt-6.1-sol'];
|
||||
/** Why an earlier result does not count as fair evidence yet. */
|
||||
export const PRIOR_WHY: Record<string, string> = {
|
||||
stub: 'ran on the Agent SDK stub prompt, not the claude_code preset',
|
||||
'small-tier': 'Haiku, Sonnet or another non-frontier model only',
|
||||
unmasked: 'no mask recorded: the no-gstack arm predates the masking rule and was not re-checked',
|
||||
ceiling: 'control at the ceiling, so no difference could show',
|
||||
'no-true-no-gstack-arm': 'the none arm still had gstack installed',
|
||||
'no-exposure': 'the model never used the part',
|
||||
'not-claude-code': 'ran outside Claude Code (Messages API, Codex or Capy)',
|
||||
'variant-only': 'variant against variant, no no-gstack arm',
|
||||
};
|
||||
const FIELDS = ['status', 'grandfathered', 'plan', 'prior', 'reason', 'owner', 'measure_by', 'sources', 'models', 'prompt', 'scope', 'masked', 'r48', 'seed', 'aliases', 'note'];
|
||||
|
||||
export type LedgerCode =
|
||||
| 'LEDGER_PARSE' | 'LEDGER_KEY_MISSING' | 'LEDGER_KEY_EXTRA' | 'LEDGER_STATUS_INVALID' | 'LEDGER_FIELD_UNKNOWN'
|
||||
| 'LEDGER_SOURCES_MISSING' | 'LEDGER_MODELS_NOT_FRONTIER' | 'LEDGER_PROMPT_NOT_PRESET' | 'LEDGER_SCOPE_MISSING'
|
||||
| 'LEDGER_UNMASKED' | 'LEDGER_R48_MISSING' | 'LEDGER_PLAN_MISSING' | 'LEDGER_PLAN_NONE_NEW' | 'LEDGER_PLAN_NOT_CONCRETE'
|
||||
| 'LEDGER_REASON_MISSING' | 'LEDGER_OWNER_MISSING' | 'LEDGER_MEASURE_BY_MISSING' | 'LEDGER_PRIOR_INVALID' | 'LEDGER_FIELD_INVALID'
|
||||
| 'INVENTORY_SOURCE_MISSING' | 'INVENTORY_PART_MISSING';
|
||||
export interface LedgerProblem { key: string; code: LedgerCode; message: string }
|
||||
export interface LedgerEntry { status: Status; grandfathered?: boolean; plan?: string; prior?: Array<{ ref: string; why: string[]; note?: string }>; reason?: string; owner?: string; measure_by?: string; sources?: string[]; models?: string[]; prompt?: string; scope?: string; masked?: boolean; r48?: { verdict: string; ref: string }; seed?: string; aliases?: string[]; note?: string }
|
||||
export interface Ledger { parts: Record<string, LedgerEntry>; unmapped?: Array<{ row: string; why: string }>; [k: string]: unknown }
|
||||
|
||||
const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
const str = (v: unknown): v is string => typeof v === 'string' && v.trim() !== '';
|
||||
const validR48 = (v: unknown) => isObj(v) && (R48_VERDICTS as readonly string[]).includes(v.verdict as string) && str(v.ref);
|
||||
|
||||
function frontierModels(v: unknown): boolean {
|
||||
if (!Array.isArray(v) || !v.length || !v.every(str)) return false;
|
||||
const ids = v.map(m => m.slice(m.lastIndexOf('/') + 1));
|
||||
const other = ids.filter(m => !FRONTIER_MODELS.includes(m));
|
||||
return ids.length > other.length && other.length <= 1 && !other.some(m => /haiku|sonnet/i.test(m));
|
||||
}
|
||||
|
||||
/** Problems with one ledger row, by its status's own fields (helps, no-effect, hurts, untested, safety). */
|
||||
export function validateEntry(key: string, entry: unknown): LedgerProblem[] {
|
||||
const problems: LedgerProblem[] = [];
|
||||
const add = (code: LedgerCode, message: string) => problems.push({ key, code, message });
|
||||
if (!isObj(entry)) { add('LEDGER_STATUS_INVALID', 'the row is not an object'); return problems; }
|
||||
for (const f of Object.keys(entry)) if (!FIELDS.includes(f)) add('LEDGER_FIELD_UNKNOWN', `unknown field ${f}; fields: ${FIELDS.join(', ')}`);
|
||||
const prior = entry.prior;
|
||||
if (prior !== undefined && !(Array.isArray(prior) && prior.every(p => isObj(p) && str(p.ref) && Array.isArray(p.why) && p.why.length > 0 && p.why.every(w => typeof w === 'string' && Object.hasOwn(PRIOR_WHY, w)))))
|
||||
add('LEDGER_PRIOR_INVALID', `prior needs [{ref, why: [...]}] with why from: ${Object.keys(PRIOR_WHY).join(', ')}`);
|
||||
if (entry.aliases !== undefined && !(Array.isArray(entry.aliases) && entry.aliases.length > 0 && entry.aliases.every(str))) add('LEDGER_FIELD_INVALID', 'aliases needs a list of names');
|
||||
const status = entry.status;
|
||||
if (status === 'helps' || status === 'no-effect' || status === 'hurts') {
|
||||
if (!Array.isArray(entry.sources) || !entry.sources.length || !entry.sources.every(str)) add('LEDGER_SOURCES_MISSING', `${status} needs sources: one or more run ids or results files`);
|
||||
if (!frontierModels(entry.models)) add('LEDGER_MODELS_NOT_FRONTIER', `models must include ${FRONTIER_MODELS.join(' or ')} plus at most one named low-cost primary, never Haiku or Sonnet (R54)`);
|
||||
if (entry.prompt !== 'preset') add('LEDGER_PROMPT_NOT_PRESET', 'prompt must be "preset" (the claude_code system prompt, not the Agent SDK stub)');
|
||||
if (!str(entry.scope)) add('LEDGER_SCOPE_MISSING', 'scope must say what was measured');
|
||||
if (entry.masked !== true) add('LEDGER_UNMASKED', 'masked must be true: the no-gstack arm was masked from the Capy drives and gstack installs');
|
||||
if (status !== 'helps' && !validR48(entry.r48)) add('LEDGER_R48_MISSING', `${status} needs r48: {verdict: ${R48_VERDICTS.join('|')}, ref}`);
|
||||
} else if (status === 'untested') {
|
||||
const grandfathered = entry.grandfathered === true;
|
||||
if (!str(entry.plan)) add('LEDGER_PLAN_MISSING', 'untested needs plan: a reference, or "none" on a grandfathered row');
|
||||
else if (entry.plan === 'none') { if (!grandfathered) add('LEDGER_PLAN_NONE_NEW', 'plan: none is only for grandfathered rows; a new part names its measurement plan'); }
|
||||
else if (!grandfathered && !/^\S+\/tasks\.json$|^\S*prereg[^\s/]*\.md$/i.test(entry.plan)) add('LEDGER_PLAN_NOT_CONCRETE', 'a new part\'s plan is the path of a test/fixtures/skill-evals/<name>/tasks.json or of an /eval-plan preregistration (a .md file named for its prereg)');
|
||||
if (!grandfathered) {
|
||||
if (!str(entry.reason)) add('LEDGER_REASON_MISSING', 'a new untested part states its reason');
|
||||
if (!str(entry.owner)) add('LEDGER_OWNER_MISSING', 'a new untested part names its owner');
|
||||
if (!str(entry.measure_by) || !/^\d{4}-\d{2}-\d{2}$/.test(entry.measure_by) || Number.isNaN(Date.parse(entry.measure_by))) add('LEDGER_MEASURE_BY_MISSING', 'a new untested part needs measure_by: YYYY-MM-DD');
|
||||
}
|
||||
} else if (status === 'safety') {
|
||||
if (!str(entry.reason)) add('LEDGER_REASON_MISSING', 'safety needs reason: what the guard protects');
|
||||
} else add('LEDGER_STATUS_INVALID', `status must be one of ${STATUSES.join(', ')}`);
|
||||
if (entry.r48 !== undefined && !validR48(entry.r48) && status !== 'no-effect' && status !== 'hurts') add('LEDGER_R48_MISSING', `r48 needs {verdict: ${R48_VERDICTS.join('|')}, ref}`);
|
||||
return problems;
|
||||
}
|
||||
|
||||
/** Coverage (no missing, no extra key) then every row's own schema. */
|
||||
export function validateLedger(ledger: unknown, keys: string[]): LedgerProblem[] {
|
||||
if (!isObj(ledger) || !isObj(ledger.parts)) return [{ key: '(ledger)', code: 'LEDGER_PARSE', message: `${LEDGER_PATH} needs a parts object` }];
|
||||
const parts = ledger.parts;
|
||||
return [
|
||||
...keys.filter(k => !(k in parts)).map(k => ({ key: k, code: 'LEDGER_KEY_MISSING' as const, message: `no row in ${LEDGER_PATH}` })),
|
||||
...Object.keys(parts).filter(k => !keys.includes(k)).map(k => ({ key: k, code: 'LEDGER_KEY_EXTRA' as const, message: 'not an inventory key (the key list is in docs/inventory.md)' })),
|
||||
...keys.filter(k => k in parts).flatMap(k => validateEntry(k, parts[k])),
|
||||
];
|
||||
}
|
||||
|
||||
export function loadLedger(root: string): { ledger: Ledger | null; problems: LedgerProblem[] } {
|
||||
try {
|
||||
return { ledger: JSON.parse(fs.readFileSync(path.join(root, LEDGER_PATH), 'utf8')), problems: [] };
|
||||
} catch (e) {
|
||||
return { ledger: null, problems: [{ key: '(ledger)', code: 'LEDGER_PARSE', message: `cannot read ${LEDGER_PATH}: ${(e as Error).message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
/** Why root is a gstack install the CLI must never write into, or null: setup's skills/gstack folders and the Capy checkout (GSTACK_CAPY_ROOT, else CAPY_INSTALL_ROOT). */
|
||||
export function installReason(root: string, env: NodeJS.ProcessEnv = process.env, home: string = os.homedir()): string | null {
|
||||
const real = (p: string) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
|
||||
if ([path.resolve(root), real(root)].some(p => /[\\/]skills[\\/]gstack$/.test(p))) return 'a skills/gstack install';
|
||||
const capy = (env.GSTACK_CAPY_ROOT || CAPY_INSTALL_ROOT).replace(/^\$HOME/, home);
|
||||
return real(root) === real(capy) ? `the Capy install (${capy})` : null;
|
||||
}
|
||||
|
||||
/** The skill keys (discoverTemplates, root skill as gstack, symlinked folders excluded) then SHARED_KEYS. */
|
||||
export function inventoryKeys(root: string): string[] {
|
||||
const skills = discoverTemplates(root).map(t => path.dirname(t.tmpl)).map(d => (d === '.' ? ROOT_KEY : d));
|
||||
return [...skills.sort(), ...SHARED_KEYS];
|
||||
}
|
||||
|
||||
/** Installed skill name to key: gstack-<name> is <name>; gstack-upgrade keeps its name. */
|
||||
export const keyOf = (name: string) => (name !== 'gstack-upgrade' && name.startsWith('gstack-') ? name.slice('gstack-'.length) : name);
|
||||
|
||||
/** The status's evidence in one phrase: sources, plan or reason. */
|
||||
export function evidenceRef(e: LedgerEntry): string {
|
||||
if (e.status === 'untested') return `plan: ${e.plan}${e.grandfathered ? ' (grandfathered)' : ` (owner ${e.owner}, measure by ${e.measure_by})`}`;
|
||||
if (e.status === 'safety') return `reason: ${e.reason}`;
|
||||
return `sources: ${(e.sources ?? []).join(', ')}${e.r48 ? `; R48 ${e.r48.verdict}` : ''}`;
|
||||
}
|
||||
|
||||
/** gstack-skill-bill's EVIDENCE line for one skill folder in a gstack checkout. */
|
||||
export function skillEvidence(root: string, dir: string): { key: string; status: Status | 'missing' | 'unreadable'; ref: string; line: string } {
|
||||
const key = path.relative(root, dir) || ROOT_KEY;
|
||||
const loaded = loadLedger(root);
|
||||
if (loaded.problems.length) {
|
||||
const ref = `${loaded.problems[0]!.message}; fix the file, then run bin/gstack-inventory --check`;
|
||||
return { key, status: 'unreadable', ref, line: `EVIDENCE: unreadable: ${ref}` };
|
||||
}
|
||||
const entry = loaded.ledger?.parts?.[key];
|
||||
if (!entry) {
|
||||
const ref = `no ${key} row in ${LEDGER_PATH}; add one (ETHOS.md: 9. Earn Its Place)`;
|
||||
return { key, status: 'missing', ref, line: `EVIDENCE: missing: ${ref}` };
|
||||
}
|
||||
const ref = evidenceRef(entry);
|
||||
return { key, status: entry.status, ref, line: `EVIDENCE: ${entry.status} ${ref}` };
|
||||
}
|
||||
|
||||
export interface CitedDispatch extends Dispatch { line: number | null }
|
||||
const tokensOf = (rel: string, bytes: number) => Math.round(bytes / (TOKEN_DIVISORS[contentClass(rel)] ?? TOKEN_DIVISOR));
|
||||
|
||||
/** Ledger, dispatch citations and shared-part files; no bill, so --check stays fast. */
|
||||
export function checkInventory(root: string): { keys: string[]; ledger: Ledger | null; dispatches: CitedDispatch[]; problems: LedgerProblem[] } {
|
||||
const keys = inventoryKeys(root);
|
||||
const loaded = loadLedger(root);
|
||||
const problems = [...loaded.problems, ...(loaded.ledger ? validateLedger(loaded.ledger, keys) : [])];
|
||||
const dispatches = DISPATCHES.map(d => {
|
||||
const file = path.join(root, d.file);
|
||||
const idx = fs.existsSync(file) ? fs.readFileSync(file, 'utf8').split('\n').findIndex(l => l.includes(d.quote)) : -1;
|
||||
if (idx < 0) problems.push({ key: `dispatch:${d.skill}`, code: 'INVENTORY_SOURCE_MISSING', message: `${d.file} no longer holds "${d.quote}"; update DISPATCHES in lib/inventory.ts` });
|
||||
return { ...d, line: idx < 0 ? null : idx + 1 };
|
||||
});
|
||||
for (const part of SHARED_PARTS) for (const f of part.files) if (!fs.existsSync(path.join(root, f)))
|
||||
problems.push({ key: part.key, code: 'INVENTORY_PART_MISSING', message: `${f} is missing; update SHARED_PARTS in lib/inventory.ts` });
|
||||
return { keys, ledger: loaded.ledger, dispatches, problems };
|
||||
}
|
||||
|
||||
export interface PartRow { key: string; kind: 'skill' | 'shared'; label: string; loads: string; loadTokens: number | null; onDemandTokens: number | null; alwaysOnTokens: number | null; status: Status | 'missing'; ref: string }
|
||||
export interface Inventory {
|
||||
commit: string; date: string; tokenSource: string; keys: string[]; parts: PartRow[];
|
||||
tiers: Array<{ tier: string; skills: string[] }>; dispatches: CitedDispatch[];
|
||||
prior: Array<{ key: string; ref: string; why: string[]; note?: string }>;
|
||||
aliases: Array<{ key: string; names: string[] }>;
|
||||
unmapped: Array<{ row: string; why: string }>; problems: LedgerProblem[];
|
||||
}
|
||||
|
||||
function git(root: string, args: string[]): string | null {
|
||||
const r = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8', timeout: 10_000 });
|
||||
return r.status === 0 ? r.stdout.trim() : null;
|
||||
}
|
||||
|
||||
/** The commit the snapshot measured, its date, and whether the tree had other changes. */
|
||||
function measuredAt(root: string): { commit: string; date: string } {
|
||||
const top = git(root, ['rev-parse', '--show-toplevel']);
|
||||
if (!top || path.resolve(top) !== path.resolve(root)) return { commit: 'none', date: 'none' };
|
||||
const dirty = git(root, ['status', '--porcelain', '--', '.', `:!${DOC_PATH}`]);
|
||||
return { commit: (git(root, ['rev-parse', '--short=9', 'HEAD']) ?? 'none') + (dirty ? ' (with uncommitted changes)' : ''), date: git(root, ['log', '-1', '--format=%cs']) ?? 'none' };
|
||||
}
|
||||
|
||||
/** Joins the ledger with the context bill: one row per key, ranked by load per run. */
|
||||
export function buildInventory(root: string): Inventory {
|
||||
const checked = checkInventory(root);
|
||||
const bill = buildBill(root);
|
||||
const resolved = path.resolve(root);
|
||||
const bySkill = new Map(bill.skills.map(s => [path.relative(resolved, s.dir) || ROOT_KEY, s]));
|
||||
const parts = checked.ledger?.parts ?? {};
|
||||
const skillKeys = checked.keys.filter(k => !SHARED_KEYS.includes(k));
|
||||
const evidence = (key: string) => (parts[key] ? { status: parts[key]!.status, ref: evidenceRef(parts[key]!) } : { status: 'missing' as const, ref: 'no row' });
|
||||
const rows: PartRow[] = skillKeys.map(key => {
|
||||
const s = bySkill.get(key);
|
||||
const hoisted = path.join(root, key, 'sections', 'preamble.md');
|
||||
const shared = fs.existsSync(hoisted) ? tokensOf(`${key}/sections/preamble.md`, fs.statSync(hoisted).size) : 0;
|
||||
const onDemand = s && key !== ROOT_KEY ? Math.max(0, Math.round(s.totalMdTokens - s.perInvocationTokens - shared)) : null;
|
||||
return { key, kind: 'skill', label: key === ROOT_KEY ? 'root skill (/gstack router)' : `/${key}`, loads: 'every use', loadTokens: s ? Math.round(s.perInvocationTokens) : null, onDemandTokens: onDemand, alwaysOnTokens: s ? Math.round(s.frontmatterTokens) : null, ...evidence(key) };
|
||||
});
|
||||
const catalog = skillKeys.reduce((n, k) => n + (bySkill.get(k)?.frontmatterTokens ?? 0), 0);
|
||||
for (const part of SHARED_PARTS) {
|
||||
const sizes = part.files.map(f => { const file = path.join(root, f); return fs.existsSync(file) ? tokensOf(f, fs.statSync(file).size) : 0; });
|
||||
const tokens = part.key === 'catalog' ? Math.round(catalog) : sizes.reduce((a, b) => a + b, 0);
|
||||
rows.push({ key: part.key, kind: 'shared', label: part.label, loads: part.load, loadTokens: tokens, onDemandTokens: null, alwaysOnTokens: part.key === 'catalog' ? tokens : null, ...evidence(part.key) });
|
||||
}
|
||||
rows.sort((a, b) => (b.loadTokens ?? -1) - (a.loadTokens ?? -1) || a.key.localeCompare(b.key));
|
||||
const tiers = new Map<string, string[]>();
|
||||
for (const key of skillKeys) {
|
||||
const tmpl = path.join(root, key === ROOT_KEY ? '' : key, 'SKILL.md.tmpl');
|
||||
const tier = /^preamble-tier:\s*(\d+)/m.exec(fs.readFileSync(tmpl, 'utf8'))?.[1] ?? 'none';
|
||||
tiers.set(tier, [...(tiers.get(tier) ?? []), key]);
|
||||
}
|
||||
return {
|
||||
...measuredAt(root),
|
||||
tokenSource: bill.tokenSource,
|
||||
keys: checked.keys,
|
||||
parts: rows,
|
||||
tiers: [...tiers].sort(([a], [b]) => a.localeCompare(b)).map(([tier, skills]) => ({ tier, skills })),
|
||||
dispatches: checked.dispatches,
|
||||
prior: checked.keys.flatMap(key => (parts[key]?.prior ?? []).map(p => ({ key, ...p }))),
|
||||
aliases: checked.keys.filter(k => parts[k]?.aliases?.length).map(k => ({ key: k, names: parts[k]!.aliases! })),
|
||||
unmapped: checked.ledger?.unmapped ?? [],
|
||||
problems: checked.problems,
|
||||
};
|
||||
}
|
||||
|
||||
const n = (v: number | null) => (v === null ? 'n/a' : v.toLocaleString('en-US'));
|
||||
const cell = (s: string) => s.replace(/\|/g, '\\|').replace(/\n/g, ' ');
|
||||
|
||||
function renderRanking(inv: Inventory): string[] {
|
||||
const out = ['## Parts by load per run', '', 'Load per run: a skill\'s SKILL.md plus the files it must read on every use (`perInvocationTokens` in lib/context-bill.ts); a shared part\'s files. Tier 2+ skills also read the shared preamble once per session (its own row). On demand: the skill\'s other sections/ and references/ files, read when a step asks for them; files a shared row also counts, such as /ship\'s review sections, appear in both. Always-on: frontmatter every session\'s skill scanner reads.', '',
|
||||
'| Rank | Part | Kind | Load per run (tokens) | On demand (tokens) | Always-on (tokens) | Loads | Status | Source, plan or reason |', '|---|---|---|---|---|---|---|---|---|'];
|
||||
inv.parts.forEach((p, i) => out.push(`| ${i + 1} | ${p.key} | ${p.kind} | ${n(p.loadTokens)} | ${n(p.onDemandTokens)} | ${n(p.alwaysOnTokens)} | ${cell(p.loads)} | ${p.status} | ${cell(p.ref)} |`));
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderMeasureNext(inv: Inventory): string[] {
|
||||
const out = ['## Measure next', '', 'Untested parts, largest load per run first. A fair test runs the claude_code preset, frontier models (Opus 5.5 and GPT-6.1 Sol, plus at most one named low-cost primary) and a masked no-gstack arm ([TESTING_INTERNALS.md](TESTING_INTERNALS.md#no-gstack-arm)). Safety rows are not on this list.', '',
|
||||
'| Order | Part | Load per run (tokens) | Plan |', '|---|---|---|---|'];
|
||||
inv.parts.filter(p => p.status === 'untested').forEach((p, i) => out.push(`| ${i + 1} | ${p.key} | ${n(p.loadTokens)} | ${cell(p.ref.replace(/^plan: /, ''))} |`));
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderShared(inv: Inventory): string[] {
|
||||
const out = ['## Shared parts and preamble tiers', '', '| Tier | Skills | What loads |', '|---|---|---|'];
|
||||
const what: Record<string, string> = { '1': 'a short preamble inline in SKILL.md', none: 'no preamble' };
|
||||
for (const t of inv.tiers) out.push(`| ${t.tier} | ${t.skills.join(', ')} | ${what[t.tier] ?? 'the shared sections/preamble.md (row preamble), once per session'} |`);
|
||||
out.push('', ...SHARED_PARTS.map(p => `- **${p.key}**: ${p.label}${p.files.length ? ` (${p.files.join(', ')})` : ''}; loads ${p.load}.`));
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderDispatches(inv: Inventory): string[] {
|
||||
const out = ['## Paid dispatches per run', '', 'Each count is quoted from the file and line named; `bin/gstack-inventory --check` fails when a quoted line moves away.', '', '| Skill | What | Count | Source |', '|---|---|---|---|'];
|
||||
for (const d of inv.dispatches) out.push(`| ${d.skill} | ${cell(d.what)} | ${cell(d.count)} | ${d.line ? `${d.file}:${d.line}` : `${d.file}: source not found`} |`);
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderPrior(inv: Inventory): string[] {
|
||||
const out = ['## Earlier results that do not count yet', '', ...Object.entries(PRIOR_WHY).map(([k, v]) => `- \`${k}\`: ${v}.`), '', '| Part | Result | Why it does not count | Note |', '|---|---|---|---|'];
|
||||
for (const p of inv.prior) out.push(`| ${p.key} | ${cell(p.ref)} | ${p.why.join(', ')} | ${cell(p.note ?? '')} |`);
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderKeys(inv: Inventory): string[] {
|
||||
const skills = inv.keys.filter(k => !SHARED_KEYS.includes(k));
|
||||
return ['## Key list and aliases', '', `${inv.keys.length} keys: ${skills.length} skills (the folders \`discoverTemplates()\` finds, the root skill as \`gstack\`) and ${SHARED_KEYS.length} shared parts.`, '',
|
||||
`- Skills: ${skills.join(', ')}.`, `- Shared: ${SHARED_KEYS.join(', ')}.`,
|
||||
'- Installed names: `gstack-<name>` is key `<name>` (`gstack-ship` is `ship`); `gstack-upgrade` keeps its name; the root skill installs as `gstack`.',
|
||||
...(inv.aliases.length ? [`- Other names (the ledger's \`aliases\`): ${inv.aliases.map(a => `${a.names.join(', ')} → ${a.key}`).join('; ')}.`] : [])];
|
||||
}
|
||||
|
||||
function renderNotHere(inv: Inventory): string[] {
|
||||
return ['## Not in this inventory', '',
|
||||
'- Out of scope: openclaw/skills/* and browser-skills/* (not gstack skills an agent loads from this checkout), and connect-chrome (a symlink to open-gstack-browser, counted once).',
|
||||
...inv.unmapped.map(u => `- Evidence-table row "${u.row}": ${u.why}.`)];
|
||||
}
|
||||
|
||||
export function renderInventory(inv: Inventory): string {
|
||||
const count = (s: string) => inv.parts.filter(p => p.status === s).length;
|
||||
const planned = inv.parts.filter(p => p.status === 'untested' && !p.ref.startsWith('plan: none')).length;
|
||||
return [
|
||||
'# gstack inventory: every part, its load per run and its evidence', '',
|
||||
`Generated by \`bin/gstack-inventory\` from [evidence.json](inventory/evidence.json), the hand-kept ledger, and lib/context-bill.ts. Measured at commit ${inv.commit} (${inv.date}). This is a dated snapshot: rerun \`bin/gstack-inventory\` to refresh it; \`bin/gstack-inventory --check\` validates the ledger. The rule: [ETHOS.md: 9. Earn Its Place](../ETHOS.md#9-earn-its-place).`, '',
|
||||
`Token counts are offline estimates (${inv.tokenSource}, about ±40%), not billed tokens.`, '',
|
||||
'| Status | Parts |', '|---|---|',
|
||||
...(['helps', 'no-effect', 'hurts'] as const).map(s => `| ${s} | ${count(s)} |`),
|
||||
`| untested, with a plan | ${planned} |`, `| untested, plan none | ${count('untested') - planned} |`, `| safety | ${count('safety')} |`, '',
|
||||
...renderRanking(inv), '', ...renderMeasureNext(inv), '', ...renderShared(inv), '', ...renderDispatches(inv), '',
|
||||
...renderPrior(inv), '', ...renderKeys(inv), '', ...renderNotHere(inv), '',
|
||||
].join('\n');
|
||||
}
|
||||
@@ -38,9 +38,14 @@ import { displayNamePattern, workflowCallers } from './workflows';
|
||||
export const CHECKS: readonly CheckDef[] = [regenCheck, secretsCheck, straysCheck, literalsCheck, timingCheck, guardsCheck, lanesCheck];
|
||||
export const CHECK_IDS = CHECKS.map(c => c.id);
|
||||
export const PREGATE_OUT_REL = '.gstack/tmp/pregate.json';
|
||||
/** Where `--stage guards` (the pre-push housekeeping guards) writes by default, so it never replaces the full run's file. */
|
||||
export const PREGATE_GUARDS_OUT_REL = '.gstack/tmp/pregate-guards.json';
|
||||
/** The checks `--stage guards` runs: generated-file freshness and the repo's guards, each only for the files the diff touched. */
|
||||
export const PREPUSH_CHECK_IDS = ['regen', 'guards'] as const;
|
||||
export const PREGATE_TIER2 = ['ratchets', 'lint', 'links', 'hermetic', 'patches', 'integrator'] as const;
|
||||
|
||||
export interface RunOptions { stage: Stage | 'all'; only?: string[]; run?: string }
|
||||
/** `guards` selects PREPUSH_CHECK_IDS (preflight checks): the pre-push run, docs/pregate.md#prepush. */
|
||||
export interface RunOptions { stage: Stage | 'guards' | 'all'; only?: string[]; run?: string }
|
||||
|
||||
/** Apply the repo's `warn` downgrades: never `secrets`. `lanes` applies its own (a touched file with no lane, after the selected tests ran), so nothing it fails is downgraded here. */
|
||||
export function applyDowngrades(ctx: PregateContext, result: CheckResult, def: CheckDef): CheckResult {
|
||||
@@ -83,7 +88,7 @@ const git = (cwd: string, args: string[]): string | null => {
|
||||
};
|
||||
|
||||
export async function runPregate(ctx: PregateContext, o: RunOptions): Promise<PregateFile> {
|
||||
const selected = CHECKS.filter(c => (o.stage === 'all' || c.stage === o.stage) && (!o.only || o.only.includes(c.id)));
|
||||
const selected = o.stage === 'guards' ? CHECKS.filter(c => (PREPUSH_CHECK_IDS as readonly string[]).includes(c.id)) : CHECKS.filter(c => (o.stage === 'all' || c.stage === o.stage) && (!o.only || o.only.includes(c.id)));
|
||||
if (selected.some(c => c.id === 'lanes')) { const line = autoCarry(ctx); if (line) ctx.progress(line); }
|
||||
const checks: CheckResult[] = [];
|
||||
const clocks: Record<Stage, number> = { preflight: 0, tests: 0 };
|
||||
@@ -99,7 +104,7 @@ export async function runPregate(ctx: PregateContext, o: RunOptions): Promise<Pr
|
||||
const file: PregateFile = {
|
||||
schema_version: 1, tree: ctx.wtree, head: ctx.head, base: `${ctx.baseRef}@${ctx.baseSha.slice(0, 12)}`, generated_at: new Date().toISOString(),
|
||||
policy: ctx.configLabel, ...(ctx.configDigest ? { policy_digest: ctx.configDigest } : {}), ...(ctx.absentUserPolicy ? { policy_user_path: ctx.absentUserPolicy } : {}), registry: ctx.registryLabel, run: o.run, scratch: ctx.scratch,
|
||||
stage1_ms: o.stage === 'tests' ? undefined : clocks.preflight, stage2_ms: o.stage === 'preflight' ? undefined : clocks.tests,
|
||||
stage1_ms: o.stage === 'tests' ? undefined : clocks.preflight, stage2_ms: o.stage === 'preflight' || o.stage === 'guards' ? undefined : clocks.tests,
|
||||
checks: checks.map(c => { const { remote: _r, ...rest } = c; return rest; }),
|
||||
requires_remote: remote,
|
||||
};
|
||||
|
||||
@@ -22,8 +22,8 @@ export const EVAL_RESULT_CODES = {
|
||||
// Garry, 2026-10-31: "all of our head to head tests have to test no gstack from here".
|
||||
PREREG_NO_GSTACK_ARM: {
|
||||
anchor: 'prereg-no-gstack-arm',
|
||||
summary: 'the Comparison says stack: loaded and the No-gstack arm section is missing, lacks a key, or does not give every model its own <model>#no-gstack Arms line',
|
||||
fix: 'add `## No-gstack arm` with arms:, inputs:, prompt:, panel: and report:, and list each <model>#no-gstack arm under Arms; when no arm loads a skill stack, say stack: none with a why: line',
|
||||
summary: 'the Comparison says stack: loaded and the No-gstack arm section is missing, lacks a key (masking:, install: and audit: included), says none for a masking key, or does not give every model its own <model>#no-gstack Arms line',
|
||||
fix: 'add `## No-gstack arm` with arms:, inputs:, prompt:, panel:, report:, masking:, install: and audit:, and list each <model>#no-gstack arm under Arms; when no arm loads a skill stack, say stack: none with a why: line',
|
||||
},
|
||||
EVAL_REPORT_NO_GSTACK_ROW: {
|
||||
anchor: 'eval-report-no-gstack-row',
|
||||
|
||||
+11
-5
@@ -51,7 +51,7 @@ export interface RemoteGateRuns { profiles: string[]; inventory: string; skipped
|
||||
export interface ApiRun {
|
||||
id: number; run_attempt: number; status: string; conclusion: string | null; event: string; head_sha: string;
|
||||
path?: string; created_at?: string; run_started_at?: string; updated_at?: string;
|
||||
repository?: { full_name: string } | null; head_repository?: { full_name: string } | null;
|
||||
repository?: { full_name: string } | null; head_repository?: { full_name: string } | null; head_branch?: string | null;
|
||||
}
|
||||
export interface ApiJob { name: string; conclusion: string | null; completed_at?: string | null }
|
||||
export interface TreeRecord { run_id?: unknown; run_attempt?: unknown; event?: unknown; sha?: unknown; head_sha?: unknown; tree?: unknown; reused?: unknown; reuse?: unknown; os?: unknown; files?: unknown; tests?: unknown; skipped_files?: unknown }
|
||||
@@ -77,7 +77,8 @@ export type RunRefusal = 'run_ref_invalid' | 'other_repository' | 'fork_run' | '
|
||||
| 'artifact_reused' | 'tree_mismatch' | 'newer_run_vetoes' | 'run_changed' | 'run_stale' | 'os_mismatch' | 'api_error';
|
||||
/** `skipped`: the files whose every test skipped on the runner (tree.json `skipped_files`, GST-1190); undefined when an older tree.json does not say. */
|
||||
export interface RunEvidence { run_id: number; run_attempt: number; head_sha: string; tree: string; profile: string; event: string; finished_at: string; files: string[]; tests: number | null; skipped?: string[]; skipped_jobs?: string[] }
|
||||
export type RunVerdict = { ok: true; evidence: RunEvidence } | { ok: false; reason: RunRefusal; detail: string };
|
||||
/** `fix`: the one command that clears a refusal the caller can repair, such as a pull_request run that tested a merge with an older base. */
|
||||
export type RunVerdict = { ok: true; evidence: RunEvidence } | { ok: false; reason: RunRefusal; detail: string; fix?: string };
|
||||
|
||||
const SHA = /^[0-9a-f]{40}$/;
|
||||
const RUN_EVENTS = ['push', 'workflow_dispatch', 'pull_request'];
|
||||
@@ -145,7 +146,7 @@ export function runProfileErrors(profiles: unknown, gateRuns: unknown): Array<{
|
||||
return errors;
|
||||
}
|
||||
|
||||
const fail = (reason: RunRefusal, detail: string): RunVerdict => ({ ok: false, reason, detail });
|
||||
const fail = (reason: RunRefusal, detail: string, fix?: string): RunVerdict => ({ ok: false, reason, detail, ...(fix ? { fix } : {}) });
|
||||
/** When a run's newest attempt started. A run vetoes the candidate when it started at or after the second the candidate was created: a red re-run of an older run counts, and re-running the candidate does not hide a failure in between. */
|
||||
const started = (r: ApiRun): string => r.run_started_at ?? r.created_at ?? '';
|
||||
export const normalPath = (f: string): string => f.replace(/^(\.\/)+/, '');
|
||||
@@ -209,12 +210,17 @@ export function verifyRun(deps: RunDeps, o: { repo: string; runRef: string; prof
|
||||
if (wrong.length) return fail('artifact_mismatch', `${at}'s ${p.artifact} records ${wrong.map(([k, got, want]) => `${k} ${String(got)} (expected ${want})`).join(', ')}`);
|
||||
const headTree = deps.commitTree(run.head_sha);
|
||||
if (!headTree) return fail('api_error', `git cannot read the tree of ${run.head_sha}`);
|
||||
if (rec.tree !== headTree) return fail('tree_mismatch', `${at} recorded tree ${String(rec.tree)}, but its head ${run.head_sha.slice(0, 12)} has tree ${headTree}`);
|
||||
const baseMoved = `gh workflow run ${p.workflow} --ref ${run.head_branch || '<branch>'}, then gstack-ci-carry wait`;
|
||||
if (rec.tree !== headTree) {
|
||||
const testedMerge = pr && typeof rec.sha === 'string' && SHA.test(rec.sha) && deps.commitTree(rec.sha) === rec.tree;
|
||||
if (testedMerge) return fail('tree_mismatch', `${at} tested merge commit ${(rec.sha as string).slice(0, 12)} (tree ${String(rec.tree)}), not its head ${run.head_sha.slice(0, 12)}'s tree ${headTree}: the pull request was behind its base (the base moved after the run, or the branch lacks its tip)`, baseMoved);
|
||||
return fail('tree_mismatch', `${at} recorded tree ${String(rec.tree)}, but its head ${run.head_sha.slice(0, 12)} has tree ${headTree}`);
|
||||
}
|
||||
if (pr) {
|
||||
if (typeof rec.sha !== 'string' || !SHA.test(rec.sha)) return fail('artifact_mismatch', `${at}'s ${p.artifact} names no merge commit (sha) for its pull_request run`);
|
||||
const mergeTree = deps.commitTree(rec.sha);
|
||||
if (!mergeTree) return fail('api_error', `git cannot read the tree of merge commit ${rec.sha}`);
|
||||
if (mergeTree !== headTree) return fail('tree_mismatch', `${at} tested merge commit ${rec.sha.slice(0, 12)} (tree ${mergeTree}), not its head ${run.head_sha.slice(0, 12)}'s tree ${headTree}: the pull request was behind its base`);
|
||||
if (mergeTree !== headTree) return fail('tree_mismatch', `${at} tested merge commit ${rec.sha.slice(0, 12)} (tree ${mergeTree}), not its head ${run.head_sha.slice(0, 12)}'s tree ${headTree}: the pull request was behind its base (the base moved after the run, or the branch lacks its tip)`, baseMoved);
|
||||
}
|
||||
const oses = Array.isArray(rec.os) ? rec.os : [rec.os];
|
||||
if (p.os && !oses.includes(p.os)) return fail('os_mismatch', `${at} ran on ${oses.filter(Boolean).map(String).join(', ') || 'an unrecorded OS'}; ${o.profileName} requires ${p.os}`);
|
||||
|
||||
+1
-1
@@ -248,7 +248,7 @@ export const BASE_RED_KEY_VERSION = '1.91.374.0';
|
||||
export const TRAIN_GATES_KEY_VERSION = '1.92.0.0';
|
||||
export const STAMP_PLACEHOLDERS_KEY_VERSION = '1.94.16.0';
|
||||
export const REVIEW_EXCLUDE_KEY_VERSION = '1.93.33.0';
|
||||
export const REVIEW_POLICY_KEY_VERSION = '1.95.0.0';
|
||||
export const REVIEW_POLICY_KEY_VERSION = '1.94.30.0';
|
||||
export const POLICY_KEY_INTRODUCED: Record<PolicyKey, string> = {
|
||||
bump: '1.91.73.0', queue_mode: '1.91.73.0', release_tool: '1.91.73.0', release_outputs: '1.91.73.0', stamp_paths: '1.91.73.0',
|
||||
changelog: '1.91.73.0', mirror: '1.91.73.0', history: '1.91.73.0', preregistration_shas: '1.91.73.0', stamp_sensitive_gates: '1.91.73.0',
|
||||
|
||||
@@ -24,6 +24,8 @@ export const PREFLIGHT_HELP = `gstack-ship-preflight — /ship Steps 1-3 and Ste
|
||||
--lanes serial|parallel how Step 5 runs its lanes; unset, one at a time on 4 or fewer cores, all at once above
|
||||
--policy-from head Step 5's pregate reads the branch's .gstack/pregate.json, which may only add test lanes
|
||||
(a branch that adds the lane for a file it touches); anything else is PREGATE_HEAD_POLICY_LOOSENS
|
||||
--timeout <s> Step 5's pregate --timeout for each runner process, 1 to 3600; unset, the base .gstack/pregate.json
|
||||
timeout_s applies, else 600 s, and a longer lane is PREGATE_INCOMPLETE
|
||||
gstack-ship-preflight --tests-only --base <base> --policy-from head --simulate [--allow-repo-commands] --lane <label>=<command>...
|
||||
a dry run that proves a new ship_lanes_from mapping: which lanes the branch's own map would carry from a verified
|
||||
CI run at this tree. Every line starts SIMULATION:; no lane runs, no evidence or ledger row is written and no
|
||||
@@ -153,8 +155,8 @@ function merge(cwd: string, base: string, r: Report): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function tests(cwd: string, base: string, list: { label: string; command: string }[], policyFrom: string, run: { concurrency: number; line: string }, r: Report): Promise<void> {
|
||||
const pg = bin(cwd, 'gstack-pregate', ['--allow-repo-commands', '--base', base, '--policy-from', policyFrom], 3_600_000);
|
||||
async function tests(cwd: string, base: string, list: { label: string; command: string }[], policyFrom: string, timeout: string | undefined, run: { concurrency: number; line: string }, r: Report): Promise<void> {
|
||||
const pg = bin(cwd, 'gstack-pregate', ['--allow-repo-commands', '--base', base, '--policy-from', policyFrom, ...(timeout ? ['--timeout', timeout] : [])], 3_600_000);
|
||||
const refusal = pg.status === 3 ? /\((PREGATE_[A-Z_]+|POLICY_[A-Z_]+)[;)]/.exec(pg.stderr)?.[1] : undefined;
|
||||
const verdict = refusal ? `refused (${refusal})` : /^PREGATE: (.+?) tree=/m.exec(pg.stdout + pg.stderr)?.[1] ?? `unreadable (exit ${pg.status})`;
|
||||
r.add(`PREGATE: ${verdict}`);
|
||||
@@ -219,6 +221,8 @@ export async function preflight(argv: readonly string[], cwd: string): Promise<{
|
||||
}
|
||||
const policyFrom = flag('--policy-from') ?? 'base';
|
||||
if (!['base', 'head'].includes(policyFrom)) return { text: 'gstack-ship-preflight: --policy-from needs base|head\n', exit: 2 };
|
||||
const timeout = flag('--timeout');
|
||||
if (has('--timeout') && !(timeout && /^[1-9]\d*$/.test(timeout) && Number(timeout) <= 3600)) return { text: 'gstack-ship-preflight: --timeout needs whole seconds from 1 to 3600\n', exit: 2 };
|
||||
if (!base || typeof list === 'string') return { text: `gstack-ship-preflight: ${typeof list === 'string' ? list : '--base <base> is required'}\n`, exit: 2 };
|
||||
if (has('--tests-only') && !list.length) return { text: 'gstack-ship-preflight: --tests-only needs at least one --lane <label>=<command>\n', exit: 2 };
|
||||
if (has('--simulate')) return has('--tests-only') && policyFrom === 'head' && list.length ? simulate(cwd, base, list, has('--allow-repo-commands')) : { text: 'gstack-ship-preflight: --simulate needs --tests-only --policy-from head and at least one --lane <label>=<command>\n', exit: 2 };
|
||||
@@ -227,7 +231,7 @@ export async function preflight(argv: readonly string[], cwd: string): Promise<{
|
||||
if (typeof concurrency === 'string') return { text: `gstack-ship-preflight: ${concurrency}\n`, exit: 2 };
|
||||
const r = new Report();
|
||||
if (has('--tests-only') || integrate(cwd, base, r)) {
|
||||
if (list.length) await tests(cwd, base, list, policyFrom, { concurrency, line: `LANES: ${concurrency === 1 ? 'serial' : 'parallel'} (${mode ? '--lanes' : `${os.availableParallelism()} cores`})` }, r);
|
||||
if (list.length) await tests(cwd, base, list, policyFrom, timeout, { concurrency, line: `LANES: ${concurrency === 1 ? 'serial' : 'parallel'} (${mode ? '--lanes' : `${os.availableParallelism()} cores`})` }, r);
|
||||
else if (r.exit === 0) r.next('Step 4: confirm the test framework, then rerun with --tests-only --lane <label>=<command> per suite (Step 5)');
|
||||
}
|
||||
return { text: r.render(), exit: r.exit };
|
||||
|
||||
+38
-1
@@ -22,6 +22,7 @@ import { parseReceiptDocs, type ReceiptDocs } from './ship-receipt-docs';
|
||||
import { resolveStateRoot } from './state-root';
|
||||
import { bumpVersion, cmpVersion, fmtVersion, parseVersion, versionWidth, type Bump } from './version-source';
|
||||
import { waitForRun } from './ci-carry';
|
||||
import { PREPUSH_CHECK_IDS } from './pregate/index';
|
||||
import { buildRunCheck } from './pregate/run-check';
|
||||
import { bin, carryLane, carryNoneLine, evidenceExempt, firstLine, flagReader, fragmentPath, gh, git, hostedOrigin, laneLine, lanes, mappedLanes, releasePaths, Report, runLanes, sh, shipPolicy, short, stampedFiles } from './ship-bin-common';
|
||||
|
||||
@@ -40,7 +41,8 @@ export const RELEASE_HELP = `gstack-ship-release — /ship Steps 12 and 15-17 (s
|
||||
A lane the base branch maps in .gstack/pregate.json ship_lanes_from is carried from a verified CI run at this exact
|
||||
tree, else deferred (EVIDENCE: DEFERRED): the pregate then runs its preflight stage here and its tests stage in accept
|
||||
push --branch <branch-name> [--gate-open <n>] [--gate-open-approved] [--early [--base <base>]]
|
||||
Step 17 after its guard block: the saved-branch check, the open-gate check, the sealed guard, the idempotency check, git push -u;
|
||||
Step 17 after its guard block: the saved-branch check, the open-gate check, the sealed guard, the pre-push housekeeping
|
||||
guards (gstack-pregate --stage guards; a pass at HEAD's tree is reused), the idempotency check, git push -u;
|
||||
--early (the Phase 2 push before review, so CI runs beside it) also runs pregate's secrets scan on the commits first
|
||||
accept --base <base> --lane <label>=<command>... [--wait <s>]
|
||||
after push: final-tree CI acceptance. Waits up to --wait seconds (default 170) for the CI run of each mapped profile at
|
||||
@@ -503,6 +505,38 @@ export async function accept(argv: readonly string[], cwd: string): Promise<Repo
|
||||
return r.next('Step 18: the receipt records ci_carried from the ledger');
|
||||
}
|
||||
|
||||
/**
|
||||
* The pre-push housekeeping guards (docs/pregate.md#prepush): `gstack-pregate --stage guards` before every push, early or
|
||||
* final, so a stale golden, a size ratchet or a type error fails here instead of a CI round. A pass recorded at HEAD's
|
||||
* exact tree by this step or by finish's preflight (both under .git, which no commit can carry) is reused, never rerun.
|
||||
*/
|
||||
function prepushGuards(cwd: string, branch: string, base: string | undefined): { line: string; stop?: string } {
|
||||
const gitPath = (name: string) => path.resolve(cwd, git(cwd, ['rev-parse', '--git-path', `gstack/${name}`]).stdout.trim() || `.git/gstack/${name}`);
|
||||
const tree = git(cwd, ['rev-parse', 'HEAD^{tree}']).stdout.trim();
|
||||
const out = gitPath('pregate-guards.json');
|
||||
for (const file of [out, gitPath('pregate-finish-preflight.json')]) {
|
||||
let rec: { tree?: string; checks?: { id: string; status: string; detail?: string }[] };
|
||||
try { rec = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { continue; }
|
||||
const rows = PREPUSH_CHECK_IDS.map(id => rec.checks?.find(c => c.id === id));
|
||||
if (tree && rec.tree === tree && rows.every(row => row?.status === 'pass')) return { line: `GUARDS: pass (reused from ${path.basename(file)} at tree ${tree.slice(0, 12)}: ${rows.map(row => `${row!.id} ${row!.detail ?? 'pass'}`).join('; ')})` };
|
||||
}
|
||||
fs.rmSync(out, { force: true });
|
||||
const pg = bin(cwd, 'gstack-pregate', ['--stage', 'guards', '--allow-repo-commands', '--json', '--out', out, ...(base ? ['--base', base] : [])], 3_600_000);
|
||||
let res: { status?: string; verdict?: string; tree?: string; code?: string; detail?: string; fix?: string; checks?: { id: string; status: string; detail?: string; fix?: string }[] } = {};
|
||||
try { res = JSON.parse(pg.stdout.trim().split('\n').pop() ?? ''); } catch { /* unreadable: stops below */ }
|
||||
const heads = res.status === 'refused' && res.code === 'POLICY_SOURCE_UNAVAILABLE' ? git(cwd, ['ls-remote', '--heads', 'origin'], 120_000) : null;
|
||||
if (heads && heads.status !== 0) return { line: 'GUARDS: not run (origin unreachable)', stop: `cannot verify the remote branch (${firstLine(heads.stderr)}): restore access before pushing` };
|
||||
if (heads && heads.stdout.split('\n').every(l => !l.trim() || l.trim().endsWith(`\trefs/heads/${branch}`))) return { line: `GUARDS: not run (origin has no branch but ${branch}, so no base to read guards from)` };
|
||||
if (res.status === 'refused') return { line: `GUARDS: refused ${res.code}`, stop: `the pre-push guards could not run (${res.detail}; fix: ${res.fix}): repair it, then rerun push` };
|
||||
const bad = (res.checks ?? []).filter(c => c.status !== 'pass' && c.status !== 'warn').map(c => `${c.id}: ${c.fix ?? c.detail ?? c.status}`).join('; ');
|
||||
if (res.tree && res.tree !== tree) return { line: `GUARDS: not certified (they checked the working tree ${res.tree.slice(0, 12)}, not HEAD's tree ${tree.slice(0, 12)})`, stop: `the working tree differs from HEAD (uncommitted or untracked files): commit or stash them, then rerun push${bad ? `; the guards also reported ${bad}` : ''}` };
|
||||
if (git(cwd, ['rev-parse', 'HEAD^{tree}']).stdout.trim() !== tree) return { line: 'GUARDS: not certified (HEAD moved while the guards ran)', stop: 'HEAD changed while the guards ran: rerun push' };
|
||||
const verdict = res.verdict ?? `unreadable (exit ${pg.status}: ${firstLine(pg.stderr)})`;
|
||||
const line = `GUARDS: ${verdict}${res.checks ? ` (${res.checks.map(c => `${c.id} ${c.detail ?? c.status}`).join('; ')})` : ''}`;
|
||||
if (pg.status === 0 && /^(pass|warn)/.test(verdict)) return { line };
|
||||
return { line, stop: `the pre-push guards did not pass (${bad || firstLine(pg.stderr || pg.stdout)}): fix it, commit, then rerun push` };
|
||||
}
|
||||
|
||||
export function push(argv: readonly string[], cwd: string): Report {
|
||||
const { flag, has } = flagReader(argv);
|
||||
const r = new Report();
|
||||
@@ -532,6 +566,9 @@ export function push(argv: readonly string[], cwd: string): Report {
|
||||
r.add(`SECRETS: ${verdict} (early push)`);
|
||||
if (scan.status !== 0 || !/^(pass|warn)/.test(verdict)) return r.stop(`the secrets scan did not pass before the early push (${firstLine(scan.stderr) || verdict}): remove the finding from the commits, then rerun push --early`);
|
||||
}
|
||||
const guards = prepushGuards(cwd, branch, flag('--base'));
|
||||
r.add(guards.line);
|
||||
if (guards.stop) return r.stop(guards.stop);
|
||||
const local = git(cwd, ['rev-parse', 'HEAD']).stdout.trim();
|
||||
const remote = git(cwd, ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], 120_000);
|
||||
if (remote.status !== 0) return r.stop(`cannot verify the remote branch (${firstLine(remote.stderr)}): restore access before pushing`);
|
||||
|
||||
+7
-1
@@ -11,6 +11,7 @@ import { homedir } from "node:os";
|
||||
import { basename, dirname, join, relative, resolve } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { estimateTokens } from "./context-bill";
|
||||
import { skillEvidence } from "./inventory";
|
||||
|
||||
export const DUE_PCT = 10;
|
||||
|
||||
@@ -28,6 +29,8 @@ export interface SkillBillReport {
|
||||
rows: LevelRow[];
|
||||
baseline: Baseline;
|
||||
evalTasks?: EvalTasks | null;
|
||||
/** gstack checkout only: the skill's row in docs/inventory/evidence.json (ETHOS.md: 9. Earn Its Place). */
|
||||
evidence?: ReturnType<typeof skillEvidence>;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
@@ -196,8 +199,10 @@ export function buildSkillBill(dir: string): SkillBillReport {
|
||||
for (const f of onDemandFiles(dir)) rows.push(row("on-demand", dir, f));
|
||||
const warnings: string[] = [];
|
||||
let evalTasks: EvalTasks | null | undefined;
|
||||
let evidence: SkillBillReport["evidence"];
|
||||
if (kind === "gstack-checkout") {
|
||||
const root = gstackRootOf(dir)!;
|
||||
evidence = skillEvidence(root, dir);
|
||||
const file = join(root, "test", "fixtures", "skill-evals", name, "tasks.json");
|
||||
evalTasks = existsSync(file) ? { ...checkEvalTasks(file), path: `test/fixtures/skill-evals/${name}/tasks.json` } : null;
|
||||
if (evalTasks?.problems.length) warnings.push(`EVAL_TASKS_INVALID: ${evalTasks.problems[0]}${evalTasks.problems.length > 1 ? ` (+${evalTasks.problems.length - 1} more)` : ""}. fix: the task format in docs/designs/SKILL_EVAL_CONTRACT.md`);
|
||||
@@ -206,7 +211,7 @@ export function buildSkillBill(dir: string): SkillBillReport {
|
||||
warnings.push("RENDER_STALE: SKILL.md.tmpl changed but SKILL.md did not. fix: bun run gen:skill-docs");
|
||||
}
|
||||
if (kind === "gstack-install") warnings.push("INSTALLED_GSTACK_SKILL: gstack-upgrade overwrites this folder. fix: edit <skill>/SKILL.md.tmpl in a gstack checkout, or copy the skill to a folder you own");
|
||||
return { name, dir, kind, authored, rows, baseline: findBaseline(dir, name, authored), evalTasks, warnings };
|
||||
return { name, dir, kind, authored, rows, baseline: findBaseline(dir, name, authored), evalTasks, evidence, warnings };
|
||||
}
|
||||
|
||||
export function renderSkillBill(r: SkillBillReport): string {
|
||||
@@ -217,6 +222,7 @@ export function renderSkillBill(r: SkillBillReport): string {
|
||||
else lines.push(`BASELINE: ${b.status} (${b.reason})`);
|
||||
if (r.evalTasks) lines.push(`EVAL TASKS: ${r.evalTasks.path} (${r.evalTasks.count} tasks)`);
|
||||
else if (r.evalTasks === null) lines.push(`EVAL TASKS: none; a diet writes test/fixtures/skill-evals/${r.name}/tasks.json before any cut`);
|
||||
if (r.evidence) lines.push(r.evidence.line);
|
||||
lines.push(...r.warnings);
|
||||
lines.push(`Commit line: Skill-Size: <before>→<after> bytes (${r.authored})`);
|
||||
return lines.join("\n");
|
||||
|
||||
Reference in new issue
Block a user