diff --git a/.gitignore b/.gitignore index 813873f3b..0db200bfb 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,8 @@ lib/cso/images/gstack-cso-preparation .gstack/* !.gstack/ship-policy.json !.gstack/ship-policy.example.json +!.gstack/generated.json +!.gstack/pregate.json .claude/skills/ .claude/gstack-rendered/ .claude/scheduled_tasks.lock diff --git a/.gstack/generated.json b/.gstack/generated.json new file mode 100644 index 000000000..012b7807a --- /dev/null +++ b/.gstack/generated.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://github.com/garrytan/gstack/docs/pregate.md#generated-v1", + "entries": [ + { + "id": "skill-docs", + "command": "bun run gen:skill-docs --host all", + "outputs": [ + "SKILL.md", + "*/SKILL.md", + "*/sections/*.md", + "review/design-checklist.md", + "qa/templates/functional-report-template.md", + "openclaw/gstack-*-CLAUDE.md", + "lib/dom-dump.js", + "gstack/llms.txt", + "agents-digest/gstack-AGENTS.md", + "bin/gstack-doctor-components.sh" + ], + "inputs": [ + "SKILL.md.tmpl", + "*/SKILL.md.tmpl", + "*/sections/*.tmpl", + "*/sections/manifest.json", + "*/templates/*.tmpl", + "openclaw/*.tmpl", + "hosts/**", + "model-overlays/**", + "scripts/gen-skill-docs.ts", + "scripts/gen-llms-txt.ts", + "scripts/gen-agents-digest.ts", + "scripts/gen-doctor-components.ts", + "scripts/discover-skills.ts", + "scripts/external-skill-names.ts", + "scripts/models.ts", + "scripts/resolvers/**", + "lib/doctor-components.ts", + "lib/dom-dump-script.ts", + "VERSION" + ], + "note": "every host render plus the agents digest, llms.txt and the doctor component table" + }, + { + "id": "ship-goldens", + "command": "bun scripts/gen-ship-goldens.ts", + "outputs": ["test/fixtures/golden/*-ship-SKILL.md"], + "inputs": ["ship/SKILL.md.tmpl", "ship/sections/manifest.json", "hosts/**", "scripts/resolvers/**", "scripts/gen-skill-docs.ts", "VERSION"], + "note": "byte-pinned renders of /ship for the claude, codex, factory and agy hosts (test/host-config.test.ts)" + } + ] +} diff --git a/.gstack/pregate.json b/.gstack/pregate.json new file mode 100644 index 000000000..69573040e --- /dev/null +++ b/.gstack/pregate.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://github.com/garrytan/gstack/docs/pregate.md#pregate-v1", + "runner": "bun test {files}", + "dependencies": { + ".github/workflows/**": ["test/pregate-selection.test.ts", "test/free-tests-workflow-wiring.test.ts"], + "scripts/gen-ship-goldens.ts": ["test/host-config.test.ts"], + "bun.lock": [], + ".gitignore": [], + ".gitattributes": [], + "docs/**": [], + "*.md": [], + "LICENSE": [], + "licenses/**": [], + "contrib/**": [], + "supabase/**": [] + }, + "selection": { + "free-tests.yml/free-suite": "bun run scripts/test-free-shards.ts --list", + "windows-free-tests.yml/windows-free-shard": "bun run scripts/test-free-shards.ts --windows-only --list" + }, + "warn": [], + "strays": { "allow": [] } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index ca85c4cca..40a97980f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [1.91.77.0] - 2026-10-11 + +**`/ship` now runs a two-stage pre-gate before anything paid, regenerates generated files from a committed registry, and links issues only on explicit intent (multi-agent wave C-pregate, plan items C4, C6, C8 tier 1).** + +- **`gstack-pregate` (`lib/pregate/*`).** Stage 1 is the mechanical preflight (`regen`, `secrets`, `strays`, `literals`), stage 2 runs the test lanes the touched files select, with a separate clock each; results record the tree identity they ran against and go stale when the tree moves, so `gstack-ship-receipt write --pregate` refuses on `PREGATE_STALE`, `LANE_FAILED`, `INCOMPLETE` or `REMOTE_UNCLEARED` and writes nothing. Lanes come from the repo's own workflow files; a lane this machine cannot run becomes a persisted `requires-remote` obligation cleared with `gstack-pregate clear --run-url`. `secrets` and required lanes cannot be downgraded in `.gstack/pregate.json`; repo-declared commands run only from `/ship` or with `--allow-repo-commands`, otherwise the check is `incomplete`, never `pass`. +- **`gstack-regen` + `.gstack/generated.json`.** `check` runs each registered generator in a scratch worktree and diffs the outputs; `write` records sha256 pre/post; gstack registers `skill-docs` and `ship-goldens` (`scripts/gen-ship-goldens.ts`). +- **`gstack-issue-links`.** `Fixes #N` only from a `Fixes`/`Closes`/`Resolves` trailer or `--issue`; bare mentions, partial work and cross-repo references render as `Refs`; an unreadable closure issue downgrades to `Refs` with `ISSUE_UNREADABLE`; `--ci-run ` adds `Flake: #N (run )`. Step 18 renders the `## Issues` section from it. +- **Step 5 lane example** collects every background lane's PID under `set -o pipefail` and fails on any non-zero exit, so a `| tail`-hidden failure can no longer pass. +- Tier 2 checks (`guards`, `ratchets`, `lint`, `links`, `hermetic`, `patches`, `integrator`) are named in `docs/pregate.md` as a follow-up; 14 new result codes with troubleshooting anchors. + ## [1.91.75.0] - 2026-10-11 **CSO paid producers no longer fail a successful audit because the final summary quoted the helper's own run ID. Credential-class text still withholds the output and fails the receipt, and the reason now names the matching redactor rules and counts.** diff --git a/VERSION b/VERSION index 023afaf52..2acb061ca 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.75.0 +1.91.77.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index e0c372eb9..dbdf5664f 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.75.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.77.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index 39e109f0b..8236b66f5 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -137,6 +137,20 @@ gstack_result_anchor() { SPEND_CAP_EXCEEDED) printf '%s' 'spend-cap-exceeded' ;; CONSENSUS_MISSING) printf '%s' 'consensus-missing' ;; OUTSIDE_RUNNER_UNAVAILABLE) printf '%s' 'outside-runner-unavailable' ;; + REGEN_REGISTRY_INVALID) printf '%s' 'regen-registry-invalid' ;; + REGEN_CONTAINMENT) printf '%s' 'regen-containment' ;; + REGEN_STALE) printf '%s' 'regen-stale' ;; + REGEN_COMMAND_FAILED) printf '%s' 'regen-command-failed' ;; + PREGATE_CONFIG_INVALID) printf '%s' 'pregate-config-invalid' ;; + PREGATE_SECRETS) printf '%s' 'pregate-secrets' ;; + PREGATE_STRAYS) printf '%s' 'pregate-strays' ;; + PREGATE_LITERALS) printf '%s' 'pregate-literals' ;; + PREGATE_NO_LANE) printf '%s' 'pregate-no-lane' ;; + PREGATE_LANE_FAILED) printf '%s' 'pregate-lane-failed' ;; + PREGATE_INCOMPLETE) printf '%s' 'pregate-incomplete' ;; + PREGATE_STALE) printf '%s' 'pregate-stale' ;; + PREGATE_REMOTE_UNCLEARED) printf '%s' 'pregate-remote-uncleared' ;; + ISSUE_UNREADABLE) printf '%s' 'issue-unreadable' ;; *) return 1 ;; esac } diff --git a/bin/gstack-issue-links b/bin/gstack-issue-links new file mode 100755 index 000000000..afbaa07ec --- /dev/null +++ b/bin/gstack-issue-links @@ -0,0 +1,95 @@ +#!/usr/bin/env bun +/** + * gstack-issue-links — issue links by intent for /ship Step 18 (plan C6). + * + * gstack-issue-links [--base ] [--issue ]... [--ci-run ]... [--repo ] [--out ] [--json] + * + * Reads `git log origin/..HEAD` and classifies every issue mention: + * `Fixes #N` only on explicit closure intent (a Fixes/Closes/Resolves trailer + * or --issue), `Refs #N` for everything else (bare mentions, partial fixes, + * deferred asks, cross-repo ids). Each closure issue is read through + * gstack-issue-guard; unreadable ones are downgraded to Refs + * (ISSUE_UNREADABLE). Each --ci-run id is searched among open issues whose + * body names it (bot flake issues) and rendered `Flake: #N (run )`. + * Prints the `## Issues` lines; --json / --out emit the receipt's `issues` + * object for `gstack-ship-receipt write --issues @file`. Logic: lib/issue-links.ts. + */ +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { EXIT, exitTableHelp } from '../lib/headless-artifacts'; +import { mentionsIn, receiptIssues, renderIssueLines, resolveLinks, type IssueMention } from '../lib/issue-links'; +import { resultLine } from '../lib/result-codes'; +import { resolveBaseBranch } from '../lib/ship-policy'; + +const HELP = `gstack-issue-links — Fixes only on explicit closure intent; everything else is Refs (docs/pregate.md#issue-links) + + --base base branch (default: origin/HEAD, else main); commits origin/..HEAD are read + --issue explicit closure intent (repeatable); still read through gstack-issue-guard + --ci-run a failing CI run id (repeatable); open issues naming it become Flake: #N (run ) + --repo repository for gh (default: the current remote) + --out write the issues object (fixes, refs, flakes) for gstack-ship-receipt write --issues @file + --json JSON only on stdout + +Exit 0 with links or none; 1 when a closure issue was unreadable (its link is Refs); 2 usage. +${exitTableHelp()}`; + +function flags(args: string[], name: string): string[] { + const out: string[] = []; + for (let i = 0; i < args.length; i++) if (args[i] === name && args[i + 1] !== undefined) out.push(args[++i]!); + return out; +} +function git(args: string[], cwd: string): string | undefined { + const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000, maxBuffer: 64 * 1024 * 1024 }); + return r.status === 0 ? r.stdout : undefined; +} + +function main(argv: string[]): number { + if (argv.includes('--help') || argv.includes('-h')) { process.stdout.write(HELP + '\n'); return EXIT.ok; } + const json = argv.includes('--json'); + const repoRoot = git(['rev-parse', '--show-toplevel'], process.cwd())?.trim() ?? process.cwd(); + const base = flags(argv, '--base')[0] ?? resolveBaseBranch(repoRoot); + const explicit: number[] = []; + for (const raw of flags(argv, '--issue')) { + if (!/^\d+$/.test(raw)) { process.stderr.write(`gstack-issue-links: --issue must be a positive integer, got ${JSON.stringify(raw)}\n`); return EXIT.usage; } + explicit.push(Number(raw)); + } + const runs = flags(argv, '--ci-run'); + if (runs.some(r => !/^[A-Za-z0-9_.-]{1,64}$/.test(r))) { process.stderr.write('gstack-issue-links: --ci-run ids are alphanumeric\n'); return EXIT.usage; } + const repoFlag = flags(argv, '--repo')[0]; + const repoArgs = repoFlag ? ['--repo', repoFlag] : []; + const binDir = path.dirname(Bun.fileURLToPath(import.meta.url)); + + const log = git(['log', '--format=%H%n%B%x00', `origin/${base}..HEAD`], repoRoot) ?? git(['log', '--format=%H%n%B%x00', `${base}..HEAD`], repoRoot); + if (log === undefined) { process.stderr.write(`gstack-issue-links: cannot read commits ${base}..HEAD (fetch origin/${base} first)\n`); return EXIT.usage; } + const mentions: IssueMention[] = []; + for (const entry of log.split('\0')) { + const nl = entry.indexOf('\n'); + if (nl < 0) continue; + mentions.push(...mentionsIn(entry.slice(nl + 1), `commit ${entry.slice(0, nl).trim().slice(0, 12)}`)); + } + const readable = (issue: number): boolean => { + const r = spawnSync(path.join(binDir, 'gstack-issue-guard'), ['issue', String(issue)], { cwd: repoRoot, encoding: 'utf8', timeout: 60_000, maxBuffer: 16 * 1024 * 1024 }); + return r.status === 0 && r.stdout.length > 0; + }; + const flakes: Array<{ issue: number; run: string }> = []; + for (const run of runs) { + const r = spawnSync('gh', ['issue', 'list', ...repoArgs, '--state', 'open', '--search', `"${run}" in:body`, '--limit', '20', '--json', 'number'], { cwd: repoRoot, encoding: 'utf8', timeout: 60_000 }); + if (r.status !== 0) { process.stderr.write(`gstack-issue-links: flake search for run ${run} failed: ${(r.stderr ?? '').trim()}\n`); continue; } + try { for (const row of JSON.parse(r.stdout) as Array<{ number: number }>) flakes.push({ issue: row.number, run }); } + catch { process.stderr.write(`gstack-issue-links: flake search for run ${run} returned no JSON\n`); } + } + const links = resolveLinks(mentions, explicit, readable, flakes); + for (const n of links.unreadable) process.stderr.write(resultLine('gstack-issue-links', 'ISSUE_UNREADABLE', `#${n}`) + '\n'); + const out = flags(argv, '--out')[0]; + if (out) { fs.mkdirSync(path.dirname(out), { recursive: true }); fs.writeFileSync(out, JSON.stringify(receiptIssues(links), null, 2) + '\n'); } + if (json) process.stdout.write(JSON.stringify({ ...receiptIssues(links), cross: links.cross, unreadable: links.unreadable, lines: renderIssueLines(links) }) + '\n'); + else { + const lines = renderIssueLines(links); + process.stdout.write(lines.length ? lines.join('\n') + '\n' : 'ISSUE_LINKS: none\n'); + for (const m of mentions) process.stderr.write(` ${m.intent === 'fixes' ? 'closure' : 'reference'} ${m.repo ? `${m.repo}#` : '#'}${m.issue} ← ${m.source}\n`); + } + return links.unreadable.length ? EXIT.fail : EXIT.ok; +} + +process.exit(main(process.argv.slice(2))); diff --git a/bin/gstack-pregate b/bin/gstack-pregate new file mode 100755 index 000000000..b5c1a121c --- /dev/null +++ b/bin/gstack-pregate @@ -0,0 +1,167 @@ +#!/usr/bin/env bun +/** + * gstack-pregate — cheap checks before anything paid or remote (plan C8). + * + * gstack-pregate [run] [--stage preflight|tests|all] [--only ] [--base ] + * [--allow-repo-commands] [--policy-from base|head] [--out ] + * [--timeout ] [--run ] [--explain] [--json] + * gstack-pregate verify [--out ] [--json] publication check: current tree, no fail/incomplete, obligations cleared + * gstack-pregate clear --remote --run-url | --bundle-lane