diff --git a/CHANGELOG.md b/CHANGELOG.md index a5ce67333..44c7e7490 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [1.91.353.0] - 2026-10-31 + +**`/ship` Step 12 now has a way through for repos whose unit PRs never change VERSION, and an unattended `/ship` no longer writes a version bump nobody approved.** On gstack-evals (no policy file, so `bump: ask`), `--keep-current`, `--version 0.0.1` and `--accept-incomplete` all stopped with `BUMP_LEVEL needed` (gstack-evals#85), and on gstack-evals#89 an unattended run took the recommended level and wrote 0.0.2 before anyone was asked (GST-380). + +### Added +- **`"bump": "never"` in `.gstack/ship-policy.json`** for a repo whose unit PRs never version. Step 12 prints `VERSION: kept (policy: bump never; no write)`, asks nothing and refuses `--bump`; a branch that moved VERSION stops until it is put back. `gstack-restamp --next` then needs an explicit `--bump` or `--version`. The value needs `min_gstack_version` at its introducing release and cannot be combined with `queue_mode: stamp-at-merge`, `train` or `changelog_fragments`, which stamp a version at every merge. + +### Fixed +- **`gstack-ship-release version`**: on a branch whose VERSION still equals the base, `--keep-current` and `--version ` print `VERSION: kept`, write nothing and read no queue. Passing `--bump` with either stops and names the conflict. The `BUMP_LEVEL needed` stop now names `--keep-current` too. +- **No version write before the owner answers.** Under `bump: ask`, when `gstack-session-kind` reports anything but `interactive` (unattended, spawned, OpenClaw, headless or CI), a Step 12 call that would write a version stops with `BUMP_ANSWER_REQUIRED` and writes nothing until it is rerun with `--answer ""` (one line, at most 200 characters). This covers a FRESH `--bump` and a `--version` rewrite of an already bumped branch. `bump: patch` is a standing answer and needs no `--answer`. `ship/DRIVER.md` lists the stop as an approval, so a brief's "take the recommended option" no longer answers it. + ## [1.91.352.0] - 2026-10-31 **gstack now knows particle-1 and has measured it on its own tasks.** particle-1 (Particle AI, October 28, 2026) passed 15/15 cells on a preregistered five-task panel, the same as Sonnet 5.5 and Opus 5.5, at $0.007 per task against Sonnet 5.5's $0.037 (run `2026-10-30-0105`, Claude Code through OpenRouter). The panel is easy, so it shows no measured difference between the models, not that they are equal. Results and every cell are in [docs/evals/particle-1-2026-10.md](docs/evals/particle-1-2026-10.md). diff --git a/VERSION b/VERSION index 53700a2e0..02ab04384 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.352.0 +1.91.353.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 2fb6476fd..d48f320aa 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.352.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.353.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-ship-policy b/bin/gstack-ship-policy index 7437dfd43..f01db4679 100755 --- a/bin/gstack-ship-policy +++ b/bin/gstack-ship-policy @@ -18,7 +18,7 @@ import * as path from 'node:path'; import { EXIT, exitTableHelp } from '../lib/headless-artifacts'; import { resultLine } from '../lib/result-codes'; import { - POLICY_KEYS, POLICY_KEY_INTRODUCED, SHIP_POLICY_PATH, examplePolicyJson, explainPolicy, isUnavailable, loadPolicyFromBase, loadPolicyFromHead, + BUMP_NEVER_KEY_VERSION, POLICY_KEYS, POLICY_KEY_INTRODUCED, SHIP_POLICY_PATH, examplePolicyJson, explainPolicy, isUnavailable, loadPolicyFromBase, loadPolicyFromHead, minimumReader, policyUnavailableLine, renderEffective, resolveBaseBranch, type LoadedPolicy, } from '../lib/ship-policy'; @@ -87,8 +87,8 @@ function main(argv: string[]): number { const loaded = load(root, rest, cmd === 'validate' ? 'head' : 'base'); if (typeof loaded === 'number') return loaded; const set = POLICY_KEYS.filter(k => loaded.values[k] && loaded.values[k] !== 'default'); - const introduced = Object.fromEntries(set.map(k => [k, POLICY_KEY_INTRODUCED[k]])); - const minReader = set.length ? minimumReader(introduced) : null; + const introduced = Object.fromEntries(set.map(k => [k, k === 'bump' && loaded.policy.bump === 'never' ? BUMP_NEVER_KEY_VERSION : POLICY_KEY_INTRODUCED[k]])); + const minReader = set.length ? minimumReader(Object.fromEntries(set.map(k => [k, loaded.policy[k]]))) : null; if (json) { process.stdout.write(JSON.stringify({ valid: loaded.errors.length === 0, present: loaded.present, source: loaded.source, policy: loaded.policy, values: loaded.values, introduced, min_reader: minReader, errors: loaded.errors }) + '\n'); return loaded.errors.length ? EXIT.fail : EXIT.ok; diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 025ada534..0cf827ca5 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -39,7 +39,7 @@ refuses the file (`POLICY_GSTACK_TOO_OLD`). `validate` ends with | Key | Values | Default | Meaning | |---|---|---|---| -| `bump` | `patch` \| `ask` | `ask` | `patch` skips the MINOR/MAJOR prompt in `/ship` Step 12 and `gstack-restamp --next` bumps PATCH; `ask` keeps the prompt. | +| `bump` | `patch` \| `ask` \| `never` | `ask` | `patch` skips the MINOR/MAJOR prompt in `/ship` Step 12 and `gstack-restamp --next` bumps PATCH; `ask` keeps the prompt, and in a session no person answers any version Step 12 would write (`--bump` on a fresh branch, `--version ` on a bumped one) stops with `BUMP_ANSWER_REQUIRED` until `--answer ""`; `never` is for a repo whose unit PRs never change VERSION: Step 12 keeps it with no question, refuses `--bump`, and `gstack-restamp --next` needs an explicit `--bump` or `--version` (the release process bumps). CI's Version Gate passes a head that keeps VERSION; a release PR that moves it takes the ordinary bump and collision rules. `never` needs `min_gstack_version` at its introducing release and cannot be combined with `queue_mode: stamp-at-merge`, `train` or `changelog_fragments`, which stamp a version at every merge. | | `queue_mode` | `claim-at-open` \| `stamp-at-merge` | `claim-at-open` | See [stamp-at-merge](#stamp-at-merge). `claim-at-open` is today's queue (`gstack-next-version` claims a slot when the PR opens). | | `release_tool` | command or `null` | `null` | The repo's own release command (`bun run release:restamp`). Runs in an isolated staging worktree under the [release-tool contract](#release-tool-contract). | | `release_outputs` | globs | `[]` | Paths the release tool may write beyond the version files, lockfiles, CHANGELOG and `stamp_paths`. Anything else is `RESTAMP_OUTPUT_OUTSIDE_ALLOWED`. | @@ -866,6 +866,8 @@ which carry the workflow consequences the skill prose would otherwise spell out: | `ship: shared merge queue: …` | in a coordinator-run queue, `gstack-ship-queue ready`, `gate-ahead` and `stamp` replace the restamp calls; see [Run a shared merge queue](#run-a-shared-merge-queue) | | `ship: gate-ahead: …` | with `.gstack/tmp/gate-ahead.json` present, Step 16 reuses only the lanes `gstack-tree-receipt … --json > .gstack/tmp/tree-receipt.json` marks reusable; stamp-sensitive and not-eligible lanes run live; `--force-gate ` is the coordinator's call | | `ship: bump patch: …` | Step 12 never asks for MINOR or MAJOR; PATCH or MICRO by size | +| `ship: bump never: …` | Step 12 prints `VERSION: kept (policy: bump never; no write)`, asks nothing and refuses `--bump`; a branch that moved VERSION stops until it is put back | +| `ship: bump ask: …` | Step 12 asks for MINOR and MAJOR; on FRESH, `--keep-current` (or `--version `) keeps VERSION, and in a session no person answers any version Step 12 would write waits for `--answer` | | `ship: preregistration: …` | `gstack-evidence ancestor --from-policy --base ` must print only `ANCESTOR: ok` before push | | `ship: release_tool: …` | the command runs only inside `gstack-restamp` (staging worktree, outputs confined to `release_outputs`) | | `ship: review_scope …` | Step 9.4 item 0 runs `gstack-review-scope classify` on each pass's findings; see [review_scope](#review-scope) | diff --git a/lib/restamp.ts b/lib/restamp.ts index c9b4c7707..06b4a8828 100644 --- a/lib/restamp.ts +++ b/lib/restamp.ts @@ -672,6 +672,7 @@ export function restamp(o: RestampOptions): RestampResult { try { const { loaded, label } = loadPolicy(o); if (loaded.errors.length) throw new Refusal(loaded.errors[0]!.code, `${SHIP_POLICY_PATH} (${label}): ${loaded.errors[0]!.message} at ${loaded.errors[0]!.path}; nothing written`); + if (loaded.policy.bump === 'never' && !o.bump && !o.version) throw new Refusal('RESTAMP_VERSION_SOURCE', `${SHIP_POLICY_PATH} (${label}): bump never names no level; a release stamp passes --bump or --version ; nothing written`); const commands = declaredCommands(loaded.policy); if (commands.length && !o.allowRepoCommands) { throw new Refusal('REPO_COMMANDS_NOT_ALLOWED', `repo commands not executed (pass --allow-repo-commands): ${commands.map(c => `${c.key}: ${c.command}`).join('; ')}; nothing written`, { commands }); diff --git a/lib/ship-policy.ts b/lib/ship-policy.ts index acd3f423c..524aeb71f 100644 --- a/lib/ship-policy.ts +++ b/lib/ship-policy.ts @@ -21,7 +21,7 @@ import { cmpVersion, parseVersion } from './version-source'; export const SHIP_POLICY_PATH = '.gstack/ship-policy.json'; export const SHIP_POLICY_SCHEMA = 'https://github.com/garrytan/gstack/docs/ship-policy.md#schema-v1'; -export type BumpPolicy = 'patch' | 'ask'; +export type BumpPolicy = 'patch' | 'ask' | 'never'; export type QueueMode = 'claim-at-open' | 'stamp-at-merge'; export type HistoryPolicy = 'merge-only' | 'squash-ok'; /** `frozen`: /ship fixes the diff in pass 1, defers unchanged code to issues and reviews only the range after; `all`: every finding is fixed now. */ @@ -29,7 +29,7 @@ export type ReviewScope = 'frozen' | 'diff-plus-blocking' | 'all'; export interface ShipPolicy { $schema?: string; - /** `patch` skips the MINOR/MAJOR prompts in /ship Step 12; `ask` keeps them. */ + /** `patch` skips the MINOR/MAJOR prompts in /ship Step 12; `ask` keeps them; `never` keeps VERSION on every unit PR. */ bump: BumpPolicy; /** `stamp-at-merge`: no claim at PR open; stamp when the PR is next. `claim-at-open` is today's queue. */ queue_mode: QueueMode; @@ -128,7 +128,7 @@ export const POLICY_KEYS = Object.keys(DEFAULT_POLICY) as PolicyKey[]; /** What each key means, printed by `init --explain` (JSON carries no comments). */ export const POLICY_EXPLANATIONS: Record = { - bump: 'patch | ask. `patch` skips the MINOR/MAJOR prompt in /ship Step 12.', + bump: 'patch | ask | never. `patch` skips the MINOR/MAJOR prompt in /ship Step 12; `ask` keeps it, and a session no person answers needs the owner\'s words (--answer) before a bump is written; `never` keeps VERSION on every unit PR (Step 12 writes nothing and asks nothing; the release process bumps). `never` needs min_gstack_version and rules out stamp-at-merge, train and changelog_fragments.', queue_mode: 'claim-at-open | stamp-at-merge. stamp-at-merge stamps when the PR is next (`gstack-restamp --next`), from the base branch version at that moment.', release_tool: 'The repo’s own release command, run in an isolated staging worktree with an enumerated allowed-output set. Never runs without /ship or --allow-repo-commands.', release_outputs: 'Globs the release tool may write beyond the version files, lockfiles, CHANGELOG and stamp_paths. Any other output refuses the restamp.', @@ -181,6 +181,8 @@ export const TRAIN_KEYS_VERSION = '1.91.146.0'; export const REVIEW_SCOPE_KEY_VERSION = '1.91.256.0'; export const DOCS_CAPS_KEY_VERSION = '1.91.270.0'; export const CHANGELOG_FRAGMENTS_KEY_VERSION = '1.91.336.0'; +/** `bump: never` is a value of an older key, so the per-key floor check misses it; compatErrors checks it by value. */ +export const BUMP_NEVER_KEY_VERSION = '1.91.353.0'; export const POLICY_KEY_INTRODUCED: Record = { 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', @@ -200,14 +202,14 @@ const newer = (a: string, b: string): boolean => cmpVersion(parseVersion(a)!, pa export function runningGstackVersion(): string { let own = '0.0.0.0'; try { own = fs.readFileSync(path.resolve(import.meta.dir, '..', 'VERSION'), 'utf8').trim(); } catch {} - const known = Object.values(POLICY_KEY_INTRODUCED).reduce((a, b) => (newer(b, a) ? b : a)); + const known = [...Object.values(POLICY_KEY_INTRODUCED), BUMP_NEVER_KEY_VERSION].reduce((a, b) => (newer(b, a) ? b : a)); return parseVersion(own) && !newer(known, own) ? own : known; } /** The newest introducing version among the keys a parsed policy sets: the gstack a crew needs to read it. */ export function minimumReader(raw: Record): string { return Object.keys(raw).filter((k): k is PolicyKey => POLICY_KEYS.includes(k as PolicyKey)) - .map(k => POLICY_KEY_INTRODUCED[k]).reduce((a, b) => (newer(b, a) ? b : a), POLICY_KEY_INTRODUCED.bump); + .map(k => (k === 'bump' && raw.bump === 'never' ? BUMP_NEVER_KEY_VERSION : POLICY_KEY_INTRODUCED[k])).reduce((a, b) => (newer(b, a) ? b : a), POLICY_KEY_INTRODUCED.bump); } export interface PolicyError { code: ResultCodeName; path: string; message: string } @@ -339,7 +341,11 @@ export function validatePolicy(raw: unknown, opts: { gstackVersion?: string } = const oneOf = (key: string, values: readonly string[]) => { if (key in obj && !values.includes(obj[key] as string)) errors.push({ code: 'POLICY_INVALID', path: key, message: `must be one of ${values.join(' | ')}, got ${JSON.stringify(obj[key])}` }); }; - oneOf('bump', ['patch', 'ask']); + oneOf('bump', ['patch', 'ask', 'never']); + if (obj.bump === 'never') { + const stamps = [obj.queue_mode === 'stamp-at-merge' && 'queue_mode stamp-at-merge', obj.train != null && 'train', obj.changelog_fragments != null && 'changelog_fragments'].filter(Boolean); + if (stamps.length) errors.push({ code: 'POLICY_INVALID', path: 'bump', message: `never keeps VERSION on every PR, but ${stamps.join(' and ')} stamps a version at every merge; drop one` }); + } oneOf('queue_mode', ['claim-at-open', 'stamp-at-merge']); oneOf('history', ['merge-only', 'squash-ok']); oneOf('review_scope', ['frozen', 'diff-plus-blocking', 'all']); @@ -412,6 +418,9 @@ function compatErrors(obj: Record, running: string, errors: Pol if (typeof min === 'string' && !newer(since, min)) continue; errors.push({ code: 'POLICY_INVALID', path: key, message: `introduced in gstack ${since}; set min_gstack_version to ${since} or later so an older gstack refuses this policy by name` }); } + if (obj.bump === 'never' && !(typeof min === 'string' && !newer(BUMP_NEVER_KEY_VERSION, min))) { + errors.push({ code: 'POLICY_INVALID', path: 'bump', message: `never was introduced in gstack ${BUMP_NEVER_KEY_VERSION}; set min_gstack_version to ${BUMP_NEVER_KEY_VERSION} or later so an older gstack refuses this policy by name` }); + } } /** @@ -575,7 +584,9 @@ export function shipConsequences(policy: ShipPolicy, base: string): string[] { } else out.push('queue_mode claim-at-open: Step 12 runs as written (claims through gstack-next-version)'); const frag = policy.changelog_fragments?.dir.replace(/\/+$/, ''); if (frag) out.push(`changelog_fragments ${frag}: Step 12 writes no version and Step 13 writes the entry as ${frag}/.md (the entry text only, no heading; \`bump: minor\` or \`bump: major\` front matter only when the change warrants more than the policy bump), never an [Unreleased] section; lint it with \`gstack-prose-lint ${frag}/.md --gate\`; \`gstack-ship-queue land\` assigns the version at merge (docs/ship-policy.md#changelog-fragments)`); - out.push(policy.bump === 'patch' ? 'bump patch: Step 12 never asks for MINOR or MAJOR; PATCH or MICRO by size' : 'bump ask: Step 12 asks for MINOR and MAJOR as written'); + out.push(policy.bump === 'patch' ? 'bump patch: Step 12 never asks for MINOR or MAJOR; PATCH or MICRO by size' + : policy.bump === 'never' ? 'bump never: Step 12 keeps VERSION (no write, no question) and refuses --bump; the release process bumps outside /ship' + : 'bump ask: Step 12 asks for MINOR and MAJOR as written; in a session no person answers, a bump waits for the owner\'s words (--answer)'); out.push(policy.review_scope === 'all' ? 'review_scope all: Step 9.4 fixes every finding on every pass (gstack-review-scope classify --policy all)' : policy.review_scope === 'diff-plus-blocking' diff --git a/lib/ship-release.ts b/lib/ship-release.ts index ec9fc81ff..9e379303d 100644 --- a/lib/ship-release.ts +++ b/lib/ship-release.ts @@ -18,8 +18,9 @@ import { bin, evidenceExempt, firstLine, flagReader, fragmentPath, gh, git, host export const RELEASE_HELP = `gstack-ship-release — /ship Steps 12 and 15-17 (ship/SKILL.md) - version --base [--bump micro|patch|minor|major] [--version ] [--keep-current] [--accept-incomplete] [--why ] - Step 12: classify, pick the queue-aware version, write it with --regen-digest --changelog, log the decision + version --base [--bump micro|patch|minor|major] [--version ] [--keep-current] [--accept-incomplete] [--why ] [--answer ] + Step 12: classify, pick the queue-aware version, write it with --regen-digest --changelog, log the decision; + --keep-current (or --version ) keeps VERSION; under bump ask a session no person answers needs --answer before a bump is written finish --base --reviewed-tree --docs-post [--lane