Files
gstack/bin/gstack-version-bump
T
Garry Tan 46c1fae7f1 v1.54.0.0 feat: carve /ship into skeleton + on-demand sections (-59% always-loaded) (#1806)
* feat(test): transcript-section-logger + ship-action fingerprint (T10)

Pure-analysis module over a SkillTestResult/NDJSON transcript:
- extractSectionReads(): which sections/*.md a run opened (post-carve check)
- extractShipActions(): observable action fingerprint (merge/test/bump/
  changelog/commit/push/pr) that works on the MONOLITH too, so a baseline
  captured before the carve can detect a sectioned-ship regression
- baseline read/write + compareShipActions() for baseline-first dogf(T10)

Baseline-first answers the Codex outside-voice critique that a logger in the
same PR as the carve is post-failure telemetry without a pre-carve reference.

11 unit tests, all green. Paid monolith baseline capture runs separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(pipeline): section discovery + generation machinery (T9)

- discover-skills.ts: discoverSectionTemplates() scans <skill>/sections/*.md.tmpl
- gen-skill-docs.ts: extract resolvePlaceholders + applyHostRewrites + buildContext
  as shared helpers (processTemplate and the new processSectionTemplate both call
  them, so a sanitization/rewrite fix can't miss sections) [C1]
- processSectionTemplate: body-fragment generation (no frontmatter/catalog/voice),
  parent-skill TemplateContext (skillName pinned to parent, not 'sections', so
  appliesTo gating + tier behave identically), per-host output routing
- --host all now fails the build on ANY host failure, not just claude, so a stale
  external-host output can't slip the freshness gate [Codex outside-voice #9]

Inert until a skill is carved (no sections/ dirs exist yet). Refactor is
output-neutral: gen:skill-docs --dry-run --host all reports 0 STALE.

5 discovery unit tests + 389 gen-skill-docs tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(setup): install sections/ for cherry-pick targets (claude + kiro) (T9)

Two install targets cherry-pick SKILL.md and would leave a carved skill's
sections/ behind, 404ing a runtime 'Read sections/<name>.md':
- link_claude_skill_dirs: link the sections/ subdir via _link_or_copy (windows
  gets a fresh copy on every ./setup)
- kiro per-skill loop: sed-rewrite + copy each sections/* so paths resolve under
  ~/.kiro, not ~/.codex/~/.claude

codex/factory/opencode link the whole generated dir, so sections ride free.
Addresses Codex outside-voice #4/#6 (runtime pathing landmine). Inert until a
skill is carved. Static-tripwire test + windows-fallback invariant green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(ship): gstack-version-bump CLI — tested idempotency classify + write (T9)

Hybrid CLI extraction (CM1): the deterministic core of ship Step 12 becomes a
tested CLI instead of bash prose the agent re-derives each run.
- classify: FRESH/ALREADY_BUMPED/DRIFT_STALE_PKG/DRIFT_UNEXPECTED from VERSION
  vs origin/<base>:VERSION vs package.json.version (pure reader)
- write: validated dual-write to VERSION + package.json (FRESH bump)
- repair: DRIFT_STALE_PKG sync, no re-bump
Bump-LEVEL choice + queue collision stay agent judgment; slot pick stays
bin/gstack-next-version. This removes the re-bump-a-shipped-branch footgun from
skippable prose into code that can't be skipped or misread.

15 tests (exhaustive state matrix + write/repair fs + real-git classify).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(parity): sectioned-skill parity capability — guards the carve (T9)

Carved skills (skeleton + sections/*.md) need parity checks that see relocated
content, or moving a phrase into a section reads as 'lost':
- readSkillForParity(): union skeleton + all sections/*.md
- checkSkillParity sectioned mode: content checks against the union; minBytes/
  maxSizeRatio against union bytes (total behavior preserved); maxSkeletonBytes
  asserts the always-loaded skeleton actually shrank. Lowering minBytes to fit a
  small skeleton would otherwise make the size floor toothless [Codex #12].

Built + tested BEFORE the carve so ship's invariant can flip to sectioned in the
same commit it lands. Monolith path byte-identical (verified: pre-existing
investigate 1.053 ratio drift fails the same with this change stashed).

7 sectioned-parity tests + existing parity tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(ship): carve into skeleton + on-demand sections (Claude) (T9)

ship/SKILL.md drops 167KB → 68.7KB (~59% of the always-loaded skill) by moving
8 prose-heavy steps into ship/sections/*.md, read on demand:
tests, test-coverage, plan-completion, review-army, greptile, adversarial,
changelog, pr-body. Step 12's version logic now calls the tested
gstack-version-bump CLI instead of inline bash.

Claude-first (S2): {{SECTION:id}} emits a STOP-Read pointer on Claude (skeleton +
generated section files) and INLINES the content on every other host, so external
hosts keep the full monolith — verified factory at 162KB with no sections dir.
{{SECTION_INDEX:ship}} renders the situation→section table from the PASSIVE
manifest (CM2 / v2_PLAN.md:663); required-reads live only in test fixtures.
Multi-pass resolve expands inlined sections' own resolvers.

Parity: ship invariant flipped to sectioned (union content checks + maxSkeletonBytes
asserts the shrink). Carve-fallout fixed across gen-skill-docs/skill-validation/
golden/plan-completion/#1539/size-budget tests via skeleton+sections union reads.
Free suite green except the pre-existing investigate parity drift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(ship): manifest-consistency + context-parity + requiredReads helper (T9)

Free deterministic guards for the carve:
- required-reads.ts + unit test: assertRequiredReads(run, requiredFiles) — the
  mechanical layer-5 check that the agent Read the sections its situation needs
  (required set comes from the fixture, not the passive manifest)
- section-manifest-consistency: 3-tier orphan classification (generated orphan +
  hand-edited generated file → FAIL; manifest orphan → WARN per v2_PLAN.md) and
  pins the PASSIVE-manifest contract (no applies_when/required_for)
- template-context-parity: generated sections have zero unresolved placeholders
  and gated resolvers (ADVERSARIAL_STEP/CONFIDENCE_CALIBRATION/CHANGELOG_WORKFLOW)
  rendered — proving sections resolve with the parent skillName, not 'sections'

16 tests, all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(ship): section-loading E2E + idempotency CLI detection (T9)

- skill-e2e-ship-section-loading.test.ts (new, periodic): runs real /ship in plan
  mode against a fresh version-changing fixture and asserts the agent Read the
  required sections (review-army + changelog). Runs against the INSTALLED skill
  (~/.claude/skills/gstack/ship), not repo paths, so install-layout 404s surface
  [Codex outside-voice #5]. Layer-5 mechanical guard against silent section-skip.
- skill-e2e-ship-idempotency.test.ts: detection updated for the carve — Step 12
  now runs gstack-version-bump classify (JSON "state":"ALREADY_BUMPED") instead
  of the inline bash echo (STATE: ALREADY_BUMPED). Accept both; add a
  gstack-version-bump-write re-bump regression signal.
- touchfiles: register ship-section-loading (periodic) + extend idempotency deps
  with bin/gstack-version-bump + scripts/resolvers/sections.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(ship): union-read redaction wiring test for the carve (T9)

main's PR-body redaction-at-sink lives in sections/pr-body.md.tmpl after the
carve, not the skeleton template. Read skeleton + section templates union so the
redaction-wiring assertions follow the relocated content. 9/9 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* v1.54.0.0 feat: carve /ship into skeleton + on-demand sections (-59% always-loaded)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 12:09:10 -07:00

213 lines
8.2 KiB
TypeScript
Executable File

#!/usr/bin/env bun
// gstack-version-bump — deterministic version-state classifier + writer for /ship.
//
// Extracted from ship Step 12 prose (v2 plan T9, hybrid CLI extraction). The
// idempotency classification and the dual-write to VERSION + package.json are
// pure deterministic logic; running them as tested code removes the single
// worst /ship footgun — re-bumping an already-shipped branch — from prose the
// agent could skip or misread when the step lives in a lazy-loaded section.
//
// What STAYS agent judgment (NOT here): the bump-LEVEL decision (micro/patch vs
// minor/major, which may AskUserQuestion on feature signals) and the queue
// collision prompt. The slot pick itself is bin/gstack-next-version. This CLI
// only answers "what state am I in?" and "write this exact version".
//
// Subcommands:
// classify --base <branch> [--version-path <p>]
// Compares VERSION vs origin/<base>:VERSION vs package.json.version.
// Emits JSON: { state, baseVersion, currentVersion, pkgVersion, pkgExists }
// state ∈ FRESH | ALREADY_BUMPED | DRIFT_STALE_PKG | DRIFT_UNEXPECTED
// Exit 0 on a decidable state (incl. DRIFT_UNEXPECTED — it's a real state
// the caller must handle), exit 2 on bad args / unresolvable base.
//
// write --version <X.Y.Z.W> [--version-path <p>]
// Validates the 4-digit pattern, writes VERSION + package.json.version.
// Use for the FRESH bump (or an approved queue rebump). Exit 3 on a
// half-write (VERSION written, package.json failed) so the caller knows
// drift exists; the next classify() will report DRIFT_STALE_PKG.
//
// repair [--version-path <p>]
// DRIFT_STALE_PKG path: sync package.json.version to the current VERSION
// file. No bump. Validates the VERSION pattern first.
//
// Contract: classify NEVER writes. write/repair mutate VERSION + package.json
// only. No git mutation, no network. Mirrors gstack-next-version's reader/writer
// split so /ship composes them.
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { join } from "node:path";
const VERSION_RE = /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/;
const DEFAULT = "0.0.0.0";
type State = "FRESH" | "ALREADY_BUMPED" | "DRIFT_STALE_PKG" | "DRIFT_UNEXPECTED";
function fail(msg: string, code = 2): never {
process.stderr.write(`gstack-version-bump: ${msg}\n`);
process.exit(code);
}
function argVal(args: string[], flag: string): string | undefined {
const i = args.indexOf(flag);
return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
}
/** Resolve the VERSION file path: --version-path, else .gstack/version-path, else "VERSION". */
function resolveVersionPath(cwd: string, explicit?: string): string {
if (explicit) return join(cwd, explicit);
const pin = join(cwd, ".gstack", "version-path");
if (existsSync(pin)) {
const p = readFileSync(pin, "utf-8").trim();
if (p) return join(cwd, p);
}
return join(cwd, "VERSION");
}
function readVersionFile(p: string): string {
try {
const v = readFileSync(p, "utf-8").replace(/[\r\n\s]/g, "");
return v || DEFAULT;
} catch {
return DEFAULT;
}
}
/** package.json version + existence, parsed without spawning node. */
function readPkgVersion(cwd: string): { exists: boolean; version: string } {
const pkgPath = join(cwd, "package.json");
if (!existsSync(pkgPath)) return { exists: false, version: "" };
let raw: string;
try {
raw = readFileSync(pkgPath, "utf-8");
} catch {
return { exists: true, version: "" };
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
fail("package.json is not valid JSON. Fix the file before re-running /ship.", 2);
}
const version = (parsed as { version?: unknown })?.version;
return { exists: true, version: typeof version === "string" ? version : "" };
}
function writePkgVersion(cwd: string, version: string): void {
const pkgPath = join(cwd, "package.json");
const raw = readFileSync(pkgPath, "utf-8");
const parsed = JSON.parse(raw) as Record<string, unknown>;
parsed.version = version;
writeFileSync(pkgPath, JSON.stringify(parsed, null, 2) + "\n");
}
function baseVersion(cwd: string, base: string, versionRel: string): string {
// Verify the base ref resolves, mirroring the Step 12 guard.
try {
execFileSync("git", ["rev-parse", "--verify", `origin/${base}`], { cwd, stdio: "ignore" });
} catch {
fail(`Unable to resolve origin/${base}. Run 'git fetch origin' or verify the base branch exists.`, 2);
}
try {
const out = execFileSync("git", ["show", `origin/${base}:${versionRel}`], { cwd }).toString();
const v = out.replace(/[\r\n\s]/g, "");
return v || DEFAULT;
} catch {
// VERSION absent on base (new repo / new file) → treat as 0.0.0.0.
return DEFAULT;
}
}
function classifyState(current: string, base: string, pkgExists: boolean, pkgVersion: string): State {
if (current === base) {
// VERSION unchanged vs base. A diverging package.json means someone hand-edited
// package.json bypassing /ship — unsafe to guess which is authoritative.
if (pkgExists && pkgVersion && pkgVersion !== current) return "DRIFT_UNEXPECTED";
return "FRESH";
}
// VERSION already moved past base.
if (pkgExists && pkgVersion && pkgVersion !== current) return "DRIFT_STALE_PKG";
return "ALREADY_BUMPED";
}
function cmdClassify(args: string[], cwd: string): void {
const base = argVal(args, "--base");
if (!base) fail("classify requires --base <branch>", 2);
const versionPath = resolveVersionPath(cwd, argVal(args, "--version-path"));
const versionRel = argVal(args, "--version-path") ?? "VERSION";
const current = readVersionFile(versionPath);
const baseV = baseVersion(cwd, base!, versionRel);
const pkg = readPkgVersion(cwd);
const state = classifyState(current, baseV, pkg.exists, pkg.version);
process.stdout.write(
JSON.stringify({
state,
baseVersion: baseV,
currentVersion: current,
pkgVersion: pkg.version || null,
pkgExists: pkg.exists,
}) + "\n",
);
// DRIFT_UNEXPECTED is a real, decidable state — the caller stops on it, but the
// classification itself succeeded, so exit 0. (Bad args / unresolvable base are
// the only exit-2 cases.)
}
function cmdWrite(args: string[], cwd: string): void {
const version = argVal(args, "--version");
if (!version) fail("write requires --version <X.Y.Z.W>", 2);
if (!VERSION_RE.test(version!)) {
fail(`NEW_VERSION (${version}) does not match MAJOR.MINOR.PATCH.MICRO. Aborting.`, 2);
}
const versionPath = resolveVersionPath(cwd, argVal(args, "--version-path"));
writeFileSync(versionPath, version + "\n");
if (existsSync(join(cwd, "package.json"))) {
try {
writePkgVersion(cwd, version!);
} catch {
fail(
"failed to update package.json. VERSION was written but package.json is now stale. " +
"Re-run — classify will report DRIFT_STALE_PKG and repair will sync it.",
3,
);
}
}
process.stdout.write(JSON.stringify({ wrote: version, packageJson: existsSync(join(cwd, "package.json")) }) + "\n");
}
function cmdRepair(args: string[], cwd: string): void {
const versionPath = resolveVersionPath(cwd, argVal(args, "--version-path"));
const current = readVersionFile(versionPath);
if (!VERSION_RE.test(current)) {
fail(
`VERSION file contents (${current}) do not match MAJOR.MINOR.PATCH.MICRO. ` +
"Refusing to propagate invalid semver into package.json. Fix VERSION, then re-run /ship.",
2,
);
}
if (!existsSync(join(cwd, "package.json"))) {
fail("repair: no package.json to sync.", 2);
}
try {
writePkgVersion(cwd, current);
} catch {
fail("drift repair failed — could not update package.json.", 3);
}
process.stdout.write(JSON.stringify({ repaired: current }) + "\n");
}
// Exported for unit tests (pure logic, no I/O).
export { classifyState, VERSION_RE, type State };
if (import.meta.main) {
const [sub, ...rest] = process.argv.slice(2);
const cwd = process.cwd();
switch (sub) {
case "classify": cmdClassify(rest, cwd); break;
case "write": cmdWrite(rest, cwd); break;
case "repair": cmdRepair(rest, cwd); break;
default:
fail("usage: gstack-version-bump <classify|write|repair> [flags]", 2);
}
}