Files
gstack/bin/gstack-version-bump
T
Garry TanandClaude Fable 5 07b59e396c v1.75.0.0 feat: ponytail import wave — simplification review lens, arm benchmark, reuse ladder, instruction-tier digest (#2722)
* feat(autoplan): eng review always runs last — the gate reviews the final amended plan

Reorder the pipeline to CEO -> Design (if UI scope) -> DX (if developer-facing
scope) -> Eng. The old order (CEO -> Design -> Eng -> DX) let DX findings land
AFTER the required gate signed off, so eng validated a stale plan.

Accept-all semantics made explicit: every AskUserQuestion resolves to the
recommended option; premises no longer pause the pipeline mid-run (clearly-wrong
ones queue as User-Challenge items at the single Final Approval Gate). Eng's
Codex voice now sees the DX consensus summary. New free static test pins the
order; the chain E2E gains DX-between and Eng-terminal assertions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(review): simplification specialist — advisory over-engineering lens with ponytail's tag vocabulary

New 8th Review Army specialist (DIFF_LINES > 100, --simplification force flag)
hunting unrequested STRUCTURE only: delete/stdlib/native/speculative/shrink
closed tags, one-line findings, lines_removable field. speculative: replaces
ponytail's yagni: tag — we import the lens, not the posture; coverage stays
sacred (Completeness Gaps owns it, suppressions inlined, shrink needs >=5 lines).

Advisory carve-out in the merge step: advisory findings are excluded from
quality_score and the findings-count header, render with an [ADVISORY] label,
and are ASK-only in Fix-First. Zero-findings case prints the lens-scoped
'Simplification: lean already — nothing to cut.' from the PARENT (the
specialist keeps the exact NO FINDINGS contract); with findings, the parent
prints 'net: -N lines possible' summed from lines_removable.

Tests: static pins for the carve-out + early-out contract (gen-skill-docs),
two periodic e2e cases with planted fixtures — activation (over-build traps:
hand-rolled Intl, one-impl abstract, dead config) and false-flag precision
(a lean ETHOS 'choose A' diff must yield NO FINDINGS).

Inspired by dietrichgebert/ponytail's /ponytail-review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(preamble): reuse ladder in Search Before Building — rungs 2-5 of ponytail's ladder, completeness kept

Tier-3+ skills gain a per-edit reflex the section only stated as research
discipline: before writing new code, stop at the first rung that holds —
repo helper, stdlib, native platform feature, installed dependency — then
build the COMPLETE version of what remains. The closing clause is the
explicit reconciliation with Boil the Ocean: the ladder governs structure,
never coverage. Rungs 1/6/7 (YAGNI / one line / minimum that works) are
deliberately NOT imported.

Also ports ponytail's root-cause rule: one guard in the shared function
beats a guard in every caller.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(preamble): bounded-closer output rule for tier-2+ skills

After completing work, skills report in a few short lines — what changed,
what was skipped, what to watch — and cut any explanation that outgrows the
change. Explicit exemptions protect every mandated output: decision briefs,
completion-status blocks, user-requested explanations, and report-shaped
skills' report formats (the report IS the work in /qa-only, /plan-*-review,
/retro, /document-generate).

Rationale is signal-to-noise, not tokens: ponytail's own benchmark shows
terse prose alone doesn't cut cost (caveman arm: -20% LOC, +7% tokens), and
independent replications found its 'skipped on purpose' essays ate the code
savings. Includes a good/bad closer example pair per the model-overlay
guidance that a positive example beats a 'don't be verbose' instruction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(resolvers): terse-mode savings claim matches measurement — 2.6KB, not 3-5KB

Measured on the v1.71 render: --explain-level=terse saves exactly 2,611 bytes
per tier-2+ skill. The old ~3-5KB claim predated the preamble restructuring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(retro,preamble): gstack-shortcut debt ledger — accepted shortcuts leave a joined trail

When the user accepts an option that is BOTH Completeness <= 7 AND a
durable-scope call, the decision ledger entry (gstack-decision-log, ceiling +
upgrade trigger in the rationale) is the source of truth, and the agent marks
each cut corner in code with gstack-shortcut(dec-<id>): <ceiling>, upgrade
when <trigger> — same edit, no follow-up question, never agent-initiated.

/retro Step 11.5 harvests markers into a debt ledger (grep || true — zero
matches is the healthy case; skill installs and docs excluded), joins on the
decision id so nothing double-counts, tags unlinked and no-trigger rot risks,
and closes with 'N markers, M with no trigger.'

/review suppressions: a marker with ceiling+trigger downgrades a would-be
Completeness Gaps finding to acknowledged debt. Redaction test pins that the
marker ships untouched (the ledger is the point) — it does not match the
TODO(owner) hygiene shape.

Format from dietrichgebert/ponytail's ponytail-debt; store inverted to gstack's
existing decision ledger.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: refresh golden ship baselines after preamble additions (reuse ladder + bounded closer)

The golden-file regression test pins the rendered ship skill byte-for-byte;
the WS3/WS7 preamble sections are deliberate changes, so the baselines
re-capture per the goldens' own update protocol.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(hosts): instruction-only tier — a 2KB committed rules digest any agent host can read

New agents-digest/gstack-AGENTS.md (1,765 bytes, hard 2,048-byte budget):
gstack's ethos one-liners, the reuse ladder, and voice rules for hosts with
no install arm — Zed, Amp, Jules, or any AGENTS.md-reading agent. Generated
by scripts/gen-agents-digest.ts, auto-refreshed by gen:skill-docs, committed
like llms.txt so setup's explainer arms can point at it before any toolchain
exists. First line carries the gstack version as its own staleness nudge.

Delivery is print-path + user-performed copy ONLY: setup never writes or
overwrites a user's AGENTS.md (a test pins this — no cp/ln/mv/redirect into
AGENTS.md anywhere in setup). openclaw and hermes explainer arms print the
path; slate keeps routing to the full Claude install and gbrain ships from
its own repo. HostConfig gains the optional install.instructionTier slot,
declared by both instruction-tier hosts. README host table now matches what
setup actually does.

Inspired by dietrichgebert/ponytail's instruction-tier AGENTS.md fallback —
one generated source, never per-host hand copies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(preamble): AskUserQuestion repetition cut — gated, passed NOT-WORSE A/B

Removes the duplicate statements v1.71's compaction left in the
AskUserQuestion Format section: the completeness rule restated in the prose
triad, the auto-decide marker syntax stated twice, the Conductor-flakiness
explanation stated twice, and the self-check's full triad restatement. Every
verbosity floor and all 14 format pins stay (Layer 0 green).

The gate this decision rested on ran before landing (new periodic
skill-e2e-auq-repetition-cut-ab.test.ts, pre-cut ref 3263fffe vs this
render, same harness as auq-verbose-vs-carved-ab): POST 7/7 format elements,
substance 5 — identical to PRE. No degradation; the load-bearing-repetition
hypothesis did not hold for these duplicates.

Net: -236 bytes per tier-2+ skill (~9.7KB corpus). Golden ship baselines
re-captured for the deliberate change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(evals): with-skill vs without-skill arm benchmark — measures whether gstack's behavioral layer earns its tokens

Ponytail's honest-benchmark method pointed at gstack itself: 3 build-shaped
tasks (native-platform over-build trap, CRUD endpoint, bug fix with planted
decoys) x 2 arms, real claude -p sessions, scored on the git diff left
behind. A research instrument, not a release gate — no assertion compares
arm scores.

Arms use the PROVEN project-scope pattern: the with-arm installs a
build-discipline skill (extracted reuse-ladder + bounded-closer content, not
whole-file copies) into the fixture's .claude/skills/ with a CLAUDE.md
routing line and an explicit invocation; a live spike confirmed claude -p
discovers and invokes project-scope skills via the Skill tool (3 turns,
exact-output probe). Fixtures are git init + local bare origin; diff capture
is three lines of git, no worktree machinery.

Failure taxonomy: zero-diff arms are VALID scored cells (deterministic
0/none, no API call), harvest failures record harvest:null, judge_error
cells are excluded from aggregates but named in the report — nothing drops
silently. armJudge: fixed sonnet judge, 0-3 unrequested-structure rubric,
must name the construct or say none, bounded retry-on-malformed; callJudge
gains optional temperature/max_tokens (defaults unchanged). recordE2E now
populates tokens_used for every E2E. Eval schema v2: harvest gains
{insertions, deletions, net}, tolerant reads keep v1 runs comparable.

Registered periodic in E2E_TIERS + touchfiles (with the auq-repetition-cut
A/B); periodic detach timeout raised to the new shard-census floor. Free
selftest (8 tests, zero API) pins fixtures, extraction, arm asymmetry, diff
capture, judge plumbing, and the retry bound.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: absorb the ponytail-import wave into the guard fixtures — ceilings, schema pin, triad phrasing

Skeleton ceilings re-captured for the 17 carved skills the wave deliberately
grew (reuse ladder + bounded closer + shortcut trail, net of the gated -236B
AUQ cut), each with its measured size in the comment per the carve-guards
protocol. eval-store schema pin updated to v2 (harvest gains
insertions/deletions/net). The AUQ prose-triad keeps its pinned per-choice
phrasing ('explicit on EACH choice') while still deferring the score scale to
the canonical Format rule — the shipped cut is strictly closer to the pre-cut
text than the render that already passed the NOT-WORSE gate. Autoplan carve
anchors follow the Phase 2.5 renumbering. Golden ship baselines re-captured.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: observability partial-file pin follows eval-store schema v2

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(test-runner): GSTACK_FREE_JOBS + opt-in flaky-retry pass for syscall-supervised sandboxes

GSTACK_FREE_JOBS overrides the computed shard count (the free runner's
analogue of the paid runner's EVALS_JOBS). On Vercel sandboxes, PID 1
installs a seccomp filter whose supervisor spuriously fails access(2) for
busy processes — measured: 200/200 git-init probes fail 'Cannot access work
tree: Permission denied' while the suite runs at 6 shards, 0/200 idle;
statx succeeds while access fails on the same path in the same process.
One serial mega-shard maximizes per-process pressure and fails too; 2
shards is the measured sweet spot.

GSTACK_FREE_RETRY_FLAKY=1 (default OFF — dev boxes should see flakes)
re-runs attributed failures once, serially, capped at 5 files; a clean
retry downgrades to a loud FLAKY-PASS naming the offenders, a repeat
failure stays red, timeouts and unattributed failures never retry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(browse): portable temp paths — TEMP_DIRS allowlist, tmpdir()-based test files

Local path validation now accepts os.tmpdir() alongside the classic /tmp
(new TEMP_DIRS in platform.ts): on macOS os.tmpdir() is /var/folders/...,
and TMPDIR-honoring CI/sandbox environments point it elsewhere entirely —
both are legitimate scratch space. Remote file serving (TEMP_ONLY) stays
pinned to TEMP_DIR alone; no change to the exfil boundary.

commands.test.ts drops 41 hardcoded /tmp literals for a tmpp() helper on
os.tmpdir() (two message assertions now reference the same variable), and
path-validation's symlink-escape test targets /etc/hosts instead of
/etc/crontab — the target must EXIST for realpath to resolve the link (a
dangling target falls back to the link's own path and passes vacuously),
and /etc/crontab is absent on Amazon Linux.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(config): portable sha256 — Linux ships sha256sum, not shasum

resolve-user-slug and endpoint hashing exited 127 on Amazon Linux (shasum
is a macOS/perl tool). New _sha256_hex helper prefers sha256sum and falls
back to shasum, matching gstack-verify-gate's existing pattern; both call
sites converted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(next-version): only trust ls-remote when origin is actually configured

Without the guard, git DWIMs the literal 'origin' as an ssh host/path; on
hosts whose transport launders exit codes the probe 'succeeds' with zero
branches and the allocator silently sees an empty queue — the exact
duplicate-allocation failure (#2545) fetchGitClaimed exists to prevent.
git remote get-url origin gates the probe; absence falls through to the
existing local-refs path with its staleness warning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(testing): sandbox-doctor — one command makes a cloud sandbox run the suite green

Measured failure taxonomy for Vercel/Conductor sandboxes (missing /dev/fd,
64M /dev/shm, seccomp-supervisor access(2) EACCES under load, uid-1000
processes with FULL capabilities defeating chmod-denial tests, no X server,
no git identity, Conductor git-shim exit-code laundering) plus the
idempotent script that treats all of it and seeds the run recipe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(config): converge on main's self-contained sha8_of — its tests extract the function standalone

The merge kept a branch-local _sha256_hex helper; main's v1.72 landed the
same portability fix inline WITH tests that extract sha8_of()'s text and run
it under a shim-only PATH — a helper call can't satisfy that shape. Adopt
the landed implementation at both hash sites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: coverage for GSTACK_FREE_JOBS override and failingFiles attribution

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: coverage for TEMP_DIRS widening and remote-serving TEMP_ONLY asymmetry

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: coverage for gstack-shortcut marker grammar and retro harvest joint

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: coverage for sandbox-doctor shell syntax and idempotency guards

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test-runner): empty-shard outcome carries failingFiles; harden flaky-retry list

The empty-shard early return omitted the (required) failingFiles field —
tsc TS2741 — feeding undefined into the flaky-retry flatMap. Also drop the
dead 'else if (worst !== 0)' guard (the enclosing if already pins it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(release): version-bump write regenerates the version-stamped agents digest

agents-digest/gstack-AGENTS.md embeds VERSION in its first line and is
byte-freshness-gated (test/agents-digest.test.ts + Skill Docs Freshness CI),
but nothing in the release path regenerated it — every version-bumping ship
of this repo would land red. write now spawns the repo's own generator when
present (agentsDigest true/false/null in the output JSON), and ship's
evidence gate allow-lists the digest alongside VERSION/package.json.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(setup): instruction-tier explainer prints the script-anchored digest path

$(pwd) printed a nonexistent path when setup ran from any other directory;
both arms now share one print_instruction_tier() using SOURCE_GSTACK_DIR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(digest): broaden AGENTS.md writer tripwire; pin digest-resolver ladder lockstep

The print-path-only guard now catches tee/install/rsync/dd/truncate, >>
appends, and laundered variable-destination writes. New test ties the
digest's hand-rendered reuse-ladder text to the preamble resolver so an
edit to either fails CI instead of shipping drift.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(retro): shortcut harvest drops placeholder markers and convention docs

The Step 11.5 grep matched documentation mentions (dec-<id>, dec-*) in
checklists, resolver sources, and convention tests, reporting phantom debt
rows on gstack itself. A trailing filter kills placeholder forms; prose
tells the agent to discard convention-quoting hits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): advisory findings count in per-specialist stats

Without this, simplification (all-advisory by construction) would log
findings:0 every run and auto-gate itself into permanent silence after 10
dispatches. The advisory carve-out governs score and header only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(evals): arm-benchmark harvest and judge hardening

- Harvest diffs against the recorded seed SHA (origin/main is movable by an
  agent that commits AND pushes; a recorded SHA is not).
- Fixtures get a node_modules .gitignore and the git wrapper a 64MB
  maxBuffer, so a vendored-dependency arm is scored instead of killing the
  cell.
- The judge diff cap is a named constant with loud truncation (log +
  judge_reasoning suffix).
- Judge prompt block markers carry a per-call random sentinel, so a diff
  containing a faked closing marker cannot escape the data block.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(evals): AUQ A/B vendored pre-cut arm + judge-error inconclusive taxonomy

- The PRE arm read a branch-local SHA (3263fffe) that becomes unreachable on
  fresh clones after the squash-merge; the pre-cut render is now a vendored
  fixture.
- A judge failure on one side no longer coerces substance to 0 (which
  fabricated DEGRADATION on POST-side failures and masked regressions on
  PRE-side failures): null substance = inconclusive, format still gates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: regression pin for the originConfigured guard vs laundering git shims

On healthy hosts the guarded and unguarded paths behave identically, so a
revert passes the suite; only a shim that makes 'git ls-remote' exit 0 with
empty output (the Conductor wrapper's observed behavior) exposes it. Pins
that the empty 'successful' probe is never trusted as an empty queue.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sandbox-doctor): missing /dev/shm no longer aborts the doctor under set -eu

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(touchfiles): close dep-list gaps for the new evals

- arm-benchmark entries gain ship/SKILL.md (buildBehavioralSkill extracts
  sections from the rendered ship skill)
- review-army-simplification entries gain their planted fixtures + test file
- auq-repetition-cut-ab gains llm-judge.ts and the vendored PRE fixture

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: re-capture context-budget fixture — lock the WS6-3 reduction and Step 9 deltas

Per the ratchet protocol: the AUQ repetition cut shrank per-skill eager
tokens but the fixture was never re-captured, leaving the win unlocked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(release): digest regen is an explicit --regen-digest opt-in, not presence-sniffed code exec

Review (security) caught the cycle-1 fix executing any repo's
scripts/gen-agents-digest.ts on plain 'write' — arbitrary code exec from a
hostile clone on a routine bump, contradicting the binary's own containment
posture. The regen still runs the TARGET repo's generator (a 'trusted' copy
beside the binary would false-red the freshness gate on version drift), but
only under the flag: /ship passes it deliberately, in a repo whose code the
operator already executes (its test suite). Plain write is side-effect-free
again. Also: uniform output shape (agentsDigest: null on the JSON-manifest
branch), a REAL generator round-trip test replacing the misnamed lockstep
check, and land-and-deploy's evidence gate gets the same digest allow-path
as ship so the two grading surfaces agree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test-runner): flaky-retry vetoes on ANY unattributable failure evidence

The gate equated 'some failure attributed' with 'all failures attributed': a
shard with one attributed failure plus a headerless failure, an unhandled
error between tests, or a truncated run (no terminal summary) qualified for
retry — re-running only failingFiles and masking the rest as FLAKY-PASS,
re-opening the silent-truncation hole the strict classifier closes.
FreeShardOutcome now carries unattributedFailures; nonzero vetoes the retry.
Pins: mixed shard, truncated-with-attributed shard, empty-shard field values.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(next-version): a configured origin advertising zero heads is never trusted

The originConfigured guard covered only the no-origin laundering case. With
origin configured (the normal Conductor worktree state), the laundering shim
makes a failed ls-remote exit 0 with empty stdout — read as 'the queue is
empty', the exact duplicate-allocation bug (#2545) one layer up. A reachable
remote always advertises at least its default branch, so an exit-0 zero-head
probe now falls back to local refs/remotes/origin with a laundering-specific
warning. Regression test shims git for both configurations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sandbox-doctor): loud on git-shim patch drift; document the retry-contract override

- The /conductor/bin/git patch was a silent no-op if the shim's bytes drift
  from the exact pattern — now warns that laundering is NOT fixed.
- The bashrc block documents why GSTACK_FREE_RETRY_FLAKY=1 deliberately
  overrides the runner's default-OFF contract on this sandbox, and how to
  undo it.
- Test pins the guarded shm form (missing /dev/shm must not abort set -eu).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(digest): pin the script-anchored explainer path; catch declaration-prefixed writers

- Asserts $SOURCE_GSTACK_DIR/agents-digest path and forbids $(pwd)/agents-digest
  (the cycle-1 fix was revertible without failing anything).
- The laundered-assignment arm now matches local/export/declare/readonly/typeset
  prefixed assignments — the likeliest in-function writer shape in setup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(evals): arm-benchmark selftest runs FREE on every PR

The selftest lived inside the paid skill-e2e-* file, so fixture-integrity
and plumbing pins executed weekly at best — a broken fixture would ship past
every gating check and be discovered when the periodic run burned money on a
dead instrument. Harness extracted to test/helpers/arm-benchmark-harness.ts,
selftest to test/arm-benchmark-selftest.test.ts (free suite). Touchfiles:
harness added to the three benchmark dep lists; the auq-repetition-cut-ab
tier comment now states the MANUAL re-run obligation honestly (periodic runs
force EVALS_ALL, so dep lists cannot auto-trigger it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: re-capture context-budget fixture after cycle-2 template deltas

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sandbox-doctor): keep both heredoc bodies under the 512B pipe-deadlock window

The cycle-2 additions pushed the python-patch and bashrc heredocs into the
512-65536B window test/heredoc-pipe-deadlock.test.ts guards (sh scripts get
no BASH_COMPAT escape hatch). Same content, tighter prose; the drift warning
now reuses the patch pattern variable instead of a second literal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): a gstack-shortcut marker only suppresses findings when its decision id resolves in the ledger

Cross-model catch (Claude adversarial + Codex agreed): any diff author could
fabricate a marker and silence Completeness review of that gap. Reviewers
now resolve the dec-id via gstack-decision-search; an orphan marker is
reported as a forged suppression, not honored as debt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(autoplan): define the B2 gate path — accepted premise challenges amend the plan and re-run Eng

The final gate offered B2 (respond to User Challenges) but the option
handler table omitted it, leaving accepted challenges with no amendment or
Eng re-review path. B2 now walks challenges one at a time; an accepted one
amends the plan and re-runs Eng (the gate always reviews the final plan),
sharing D's 3-cycle cap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(evals): arm benchmark runs each fixture's functional oracle — correctness before LOC

The plan's metric order is diff-quality FIRST, but cells never ran the
fixtures' own run-tests.js, so a refusal, a broken implementation, and
working code were indistinguishable in aggregates (Codex adversarial catch).
Tasks with an oracle declare checkCmd; every cell records checks=pass|fail|none
in the report line and eval store. Selftest pins the oracle declarations and
that the planted bug fails its own check pre-fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ship): check the bump's agentsDigest result; state the --regen-digest trust envelope honestly

A failed digest regen warned and moved on — ship now instructs re-running
the generator and staging the digest with the bump (the freshness check
stays red otherwise). The 'no-op everywhere else' phrasing oversold safety:
the step now names what executes and why that is inside the envelope Step 5
already opened (the repo's own test suite).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test-runner): GSTACK_FREE_JOBS accepts digits only — parseInt truncation defeated the loud-failure contract

'2abc' silently became 2 and '3.7' became 3 despite the error text claiming
a positive-integer requirement. Strict /^\d+$/ pre-check; both shapes pinned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sandbox-doctor): atomic git-shim patch, :99-socket Xvfb check, dnf gate, non-interactive sudo

- The /conductor/bin/git patch writes tmp-then-rename with a .orig backup —
  a concurrently spawned git can never exec a truncated shim.
- Xvfb running-check looks for the :99 socket, not any-display pgrep.
- Xvfb install is dnf-gated so non-dnf distros degrade to a warning instead
  of aborting the remaining fixes under set -eu.
- The bashrc /dev/fd restore uses sudo -n || true — no password prompt at
  every shell start on non-passwordless machines.
- BASH_COMPAT=50 keeps heredoc bodies off the bash pipe window.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(build): a failed agents-digest regen fails gen-skill-docs instead of deferring the red to CI

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(browse): an untrustable TMPDIR (/, $HOME, a cwd ancestor) never widens the local allowlist

TEMP_DIRS honors os.tmpdir() at daemon start; a daemon launched with
TMPDIR=/ would have trusted the whole filesystem for local path validation
for its lifetime. Subprocess pins cover /, $HOME, cwd-ancestor rejection and
that a benign distinct TMPDIR (the sandbox recipe's $HOME/tmp) stays honored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: zero-heads warning names the benign cause too; digest path declaration made load-bearing; ratchet re-capture

- The ls-remote zero-heads warning no longer accuses an empty remote of
  running a laundering shim.
- instructionTier.rulesFile now must equal the generator's DIGEST_RELPATH
  (and setup must print it) — the declaration fails with the real path
  instead of lying silently.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: file ship-time follow-ups in TODOS

skillify HOME-override gate red (pre-existing, proven on main), the
auq-verbose-vs-carved-ab branch-local ref, eval-store harvest union,
evidence digest allow-path scoping, and the WS6-2 dead-frontmatter live-host
verification deferral.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* v1.73.0.0 chore: version bump + CHANGELOG — ponytail import wave

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: raise ship skeleton parity ceiling — measured 75,592 after the v1.73 release-step prose

The --regen-digest trust-envelope paragraph (Step 12) and the evidence-gate
digest note (Step 16) grew the ship skeleton past the previous 75,420
ceiling. Re-measured per the deliberate-change protocol.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: update project documentation for v1.73.0.0

- README.md, docs/skills.md, AGENTS.md: /autoplan phase order corrected to
  CEO → design → DX → eng (eng always last); /review rows note the advisory
  simplification lens
- docs/PROJECT_STRUCTURE.md: add agents-digest/, gen-agents-digest.ts,
  sandbox-doctor.sh, test-free-shards.ts to the annotated tree
- CONTRIBUTING.md: document GSTACK_FREE_JOBS, GSTACK_FREE_RETRY_FLAKY, and
  the sandbox-doctor one-command fixer in the Tier 1 test section

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: apply cross-model doc-review fixes for v1.73.0.0

- README.md: host table gains the OpenClaw explainer arm row (setup has the
  arm; the table claimed to match setup)
- docs/skills.md: /review completeness-gaps section documents the
  gstack-shortcut(dec-<id>) acknowledged-debt suppression and orphan-marker
  flagging; /autoplan deep-dive states the recommended-option default with
  the 6 principles as tie-breakers
- CONTRIBUTING.md: host count 8 -> 10 (Hermes, GBrain), supported-hosts list
  completed
- docs/TESTING_INTERNALS.md: sandbox recipe says to source ~/.bashrc after
  the doctor seeds it; GSTACK_FREE_JOBS wording fixed from "caps" to
  "overrides in either direction" (matches the un-clamped runner)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): temp-dirs asymmetry pins are topology-aware; TMPDIR probes are POSIX-only

CI exposed two wrong assumptions in the new temp-dirs tests, neither a
product bug:

- The remote-serving asymmetry test assumed a distinct os.tmpdir() lies
  OUTSIDE TEMP_DIR, but the free-shard runner nests each child's TMPDIR
  inside /tmp on CI — a file there is under TEMP_DIR, so serving it
  remotely is legitimate. The test now pins the actual exfil boundary on
  every topology (a cwd project file is locally readable, never remotely
  servable) and branches the os.tmpdir() case on nested-vs-outside.
  Reproduced locally with TMPDIR=/tmp/nested-tmp before fixing.

- The untrustable-TMPDIR subprocess probes set TMPDIR, which Windows
  os.tmpdir() ignores (reads TEMP/TMP) — and on Windows TEMP_DIR is
  DEFINED as os.tmpdir(), so the fixed+movable two-dir topology the guard
  filters does not exist there. Probes now skip on Windows with that
  rationale; the benign-TMPDIR assertion compares realpaths.

Verified under all three POSIX topologies: TMPDIR=$HOME/tmp (outside),
TMPDIR=/tmp/nested-tmp (CI shard shape), TMPDIR unset (identical).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(build): DIGEST_RELPATH is a forward-slash literal on every platform

path.join built it with backslashes on Windows, so the wiring test's
string comparisons against setup and hosts/*.ts (which carry the
forward-slash literal) could never match there — windows-free-tests red.
path.join(root, DIGEST_RELPATH) at the write site normalizes fine.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sandbox-doctor): bashrc block re-heals the /dev/shm remount on sandbox restart

The 4G remount does not survive restarts; a reverted 64M shm made the
multi-tab browse handoff test fail consistently under suite concurrency
(observed live: two consecutive full-run failures, green in isolation,
green again after remounting). Same guarded arithmetic as the doctor body.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): close the cross-shard porcelain race that failed Windows CI

Two-part fix for the gen-skill-docs-out-dir isolation-pin failure:

- cookie-import-browser built its scratch cookie DBs inside the TRACKED
  browse/test/fixtures/ dir (created in beforeAll, deleted in afterAll), so
  they flash as untracked files mid-run — a concurrent shard's porcelain
  snapshot caught the window on Windows. The DBs now live in a per-run
  tmpdir; zero source-tree writes.

- gen-skill-docs-out-dir is the free suite's only LIVE porcelain-snapshot
  test, so it joins TREE_MUTATING (the serial quiet window): any concurrent
  transient tree-write can race it, and its own spawned render rewrites
  llms.txt/agents-digest in place (idempotent on a fresh tree).

The race is pre-existing; this branch's +5 test files reshuffled shard
composition and exposed it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* v1.75.0.0 chore: queue-advance rebump — perth-v2 landed v1.74.0.0 on main

The v1.73.0.0 slot this branch claimed was superseded when #2721 merged;
same MINOR level relative to main per the versioning invariant. CHANGELOG
entry renumbered (1.73.0.0 was branch-internal and never landed on main),
digest restamped via --regen-digest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test-runner): duration-packed walls keep the per-file floor — predictions don't transfer across machines

The committed duration seed is recorded on fast CI; a syscall-supervised
sandbox replays the same files 2-4x slower. Observed post-merge: a 253-file
shard predicted ~242s was wall-killed at its predicted-x3 725s wall while
genuinely progressing (the old count heuristic guaranteed 1265s). Packed
walls may be looser than the count floor, never tighter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 10:10:35 -07:00

578 lines
26 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>] [--regen-digest]
// Validates the 4-digit pattern, writes VERSION + package.json.version.
// --regen-digest (opt-in): additionally run the target repo's own
// scripts/gen-agents-digest.ts when present, so the version-stamped
// committed digest stays freshness-green through the bump. Executes
// target-repo code — callers pass it only for repos whose code they
// already execute (/ship does).
// 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 + the manifest
// + npm lockfiles (package-lock.json / npm-shrinkwrap.json, when present) —
// plus, under the explicit --regen-digest opt-in only, the committed agents
// digest (whose first line embeds VERSION and is byte-freshness-gated by
// test/agents-digest.test.ts and the Skill Docs Freshness CI check, so the
// write that changes VERSION must regenerate it or every release commit of
// the gstack repo goes red). No git mutation, no network. Mirrors
// gstack-next-version's reader/writer split so /ship composes them.
//
// Manifest resolution (all three subcommands accept --package-json-path):
// --package-json-path <p> → .gstack/package-json-path → ./package.json
// A repo whose only Node package lives in a subdirectory (web/, app/,
// frontend/) has no ROOT package.json. The tool used to report
// pkgExists:false there and write VERSION alone, leaving the manifest to be
// bumped by hand — the drift this tool exists to prevent, in the one layout
// where it silently did nothing (#2531).
//
// npm semver (decision 11, v1.67 fix-wave plan): VERSION is the 4-digit
// MAJOR.MINOR.PATCH.MICRO source of truth; npm rejects a fourth component,
// so the manifest and its lockfiles carry the npm-valid 3-digit translation
// (1.67.0.0 → 1.67.0). classify judges drift against the translated form
// (accepting the pre-v1.67 1:1 mirror as in-sync until the next write).
import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { extractVersion, isJsonVersionPath, npmVersion, setVersionInJson } from "../lib/version-source";
// 3- or 4-digit (#2501). gstack's own VERSION stays 4-digit MAJOR.MINOR.PATCH.
// MICRO and stays the source of truth, but a repo whose pinned version source
// is a package.json holds plain 3-digit semver, and rejecting it here meant
// /ship could not write a version at all in such a repo. See lib/version-source.ts.
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;
}
/**
* Containment guard: `.gstack/version-path` and `.gstack/package-json-path`
* are repo-controlled content. Without this, a cloned repo pinning
* `../../victim.json` — or an in-repo symlink pointing outside — turns a
* routine bump into an arbitrary file overwrite outside the repository.
* Rejects absolute paths, lexical `..` escapes, and symlink escapes (the
* deepest EXISTING ancestor is realpath'd, so a not-yet-created VERSION
* file is still checked through its parent directory).
*/
function assertRepoContained(cwd: string, rel: string, source: string): void {
const root = realpathSync(cwd);
const abs = resolve(root, rel);
const lex = relative(root, abs);
if (isAbsolute(rel) || lex === "" || lex.startsWith("..") || isAbsolute(lex)) {
fail(`${source} ('${rel}') resolves outside the repository. Refusing to read or write it.`, 2);
}
let probe = abs;
while (!existsSync(probe)) {
const parent = dirname(probe);
if (parent === probe) break;
probe = parent;
}
let real: string;
try {
real = realpathSync(probe);
} catch {
return; // vanished between existsSync and realpath — the read/write will fail honestly on its own
}
if (real !== root && !real.startsWith(root + sep)) {
fail(`${source} ('${rel}') resolves through a symlink to outside the repository. Refusing to read or write it.`, 2);
}
}
/**
* Resolve the version file's path RELATIVE to the repo root: --version-path,
* else .gstack/version-path, else "VERSION".
*
* The relative form is what matters — `git show origin/<base>:<rel>` needs it
* (an absolute path is unusable there). Callers used to
* derive versionRel from the CLI flag alone (#2462), so a repo using the
* .gstack/version-path pin had its local (pinned) version compared against
* the BASE's root VERSION file: two different files. On a repo with no root
* VERSION the base then always read 0.0.0.0, making every branch look FRESH —
* and the pinned-JSON handling never engaged without the explicit flag.
* Resolving once, here, keeps base and current reads in step.
*/
function resolveVersionRel(cwd: string, explicit?: string): string {
if (explicit) {
const rel = explicit.trim();
assertRepoContained(cwd, rel, "--version-path");
return rel;
}
const pin = join(cwd, ".gstack", "version-path");
if (existsSync(pin)) {
const p = readFileSync(pin, "utf-8").split("\n")[0]?.trim() ?? "";
if (p) {
assertRepoContained(cwd, p, ".gstack/version-path");
return p;
}
}
return "VERSION";
}
function readVersionFile(p: string, versionRel = "VERSION"): string {
try {
// extractVersion (#2501): a .json version-path is read as JSON (.version),
// not whitespace-stripped raw text that turns a package.json into garbage.
const v = extractVersion(readFileSync(p, "utf-8"), versionRel);
return v || DEFAULT;
} catch {
return DEFAULT;
}
}
/**
* Resolve the manifest path: --package-json-path, else
* .gstack/package-json-path, else "package.json" (#2531, mirrors
* resolveVersionRel). A repo whose only Node package lives in a
* subdirectory (web/, app/, frontend/) has no ROOT package.json, so the
* old join(cwd, "package.json") reported pkgExists:false and every bump
* silently wrote VERSION alone — leaving the manifest to be edited by
* hand, which is exactly the drift this tool exists to prevent.
*/
function resolvePkgPath(cwd: string, explicit?: string): string {
if (explicit) {
const rel = explicit.trim();
assertRepoContained(cwd, rel, "--package-json-path");
return join(cwd, rel);
}
const pin = join(cwd, ".gstack", "package-json-path");
if (existsSync(pin)) {
const p = readFileSync(pin, "utf-8").split("\n")[0]?.trim() ?? "";
if (p) {
assertRepoContained(cwd, p, ".gstack/package-json-path");
return join(cwd, p);
}
}
return join(cwd, "package.json");
}
/** package.json version + existence, parsed without spawning node. */
function readPkgVersion(pkgPath: string): { exists: boolean; version: string } {
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(`${pkgPath} 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(pkgPath: string, version: string): void {
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");
}
/**
* npm records the package version twice in its lockfiles — top-level
* `version` and, in lockfileVersion >= 2, `packages[""].version` (the entry
* describing the root package itself) — and `npm install` keeps both in
* step. Nothing else in a release does, so a lockfile left behind drifts one
* field per bump until someone runs npm, dirtying the tree on the next
* `npm install` far from the cause (#2567). Pure JSON edit: no npm spawn,
* no dependency-tree churn.
*
* Synced ONLY when the file already exists — never created (gstack itself
* is bun-only; decision pinned in the v1.67 fix-wave plan).
* npm-shrinkwrap.json shares the format and, when present, is what npm
* actually honors, so both names are covered. Returns the names synced.
*/
const NPM_LOCKFILES = ["package-lock.json", "npm-shrinkwrap.json"];
function syncNpmLockfiles(dir: string, version: string, root: string): string[] {
const synced: string[] = [];
for (const name of NPM_LOCKFILES) {
const lockPath = join(dir, name);
if (!existsSync(lockPath)) continue;
// A lockfile that is a symlink out of the repo would make this write an
// arbitrary-file overwrite (same class as the version-path pin escape).
// Skip with a warning — unlike the pins, a weird lockfile shouldn't
// brick the whole bump.
try {
const realRoot = realpathSync(root);
const realLock = realpathSync(lockPath);
if (realLock !== realRoot && !realLock.startsWith(realRoot + sep)) {
process.stderr.write(`WARNING: ${name} resolves outside the repository (symlink); not synced.\n`);
continue;
}
} catch {
continue;
}
const parsed = JSON.parse(readFileSync(lockPath, "utf-8")) as Record<string, unknown>;
parsed.version = version;
const packages = parsed.packages as Record<string, Record<string, unknown>> | undefined;
if (packages && typeof packages[""] === "object" && packages[""] !== null) {
packages[""].version = version;
}
writeFileSync(lockPath, JSON.stringify(parsed, null, 2) + "\n");
synced.push(name);
}
return synced;
}
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();
return extractVersion(out, versionRel) || DEFAULT;
} catch {
// VERSION absent on base (new repo / new file) → treat as 0.0.0.0.
return DEFAULT;
}
}
/**
* `expectedPkg` is what the manifest SHOULD hold for the current VERSION —
* the npm-valid 3-digit translation (decision 11: npm rejects a fourth
* component, so a correctly-synced `1.67.0` must not read as drift against
* `1.67.0.0` forever). The historical 1:1 mirror (pre-v1.67 installs whose
* package.json still carries the 4-digit form) is also accepted as in-sync;
* write/repair migrate those to the translated form on the next release.
*/
function classifyState(
current: string,
base: string,
pkgExists: boolean,
pkgVersion: string,
expectedPkg: string = current,
): State {
const pkgAgrees =
!pkgExists || !pkgVersion || pkgVersion === expectedPkg || pkgVersion === current;
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 (!pkgAgrees) return "DRIFT_UNEXPECTED";
return "FRESH";
}
// VERSION already moved past base.
if (!pkgAgrees) 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 versionRel = resolveVersionRel(cwd, argVal(args, "--version-path"));
const versionPath = join(cwd, versionRel);
const current = readVersionFile(versionPath, versionRel);
const baseV = baseVersion(cwd, base!, versionRel);
// When the version-path IS a package.json (#2501), that file is the single
// source of truth and the "VERSION vs package.json" drift states cannot
// arise — they are the same file. Reporting it as its own pkg keeps DRIFT_*
// out of the classification instead of inventing a disagreement between a
// file and itself.
const jsonSource = isJsonVersionPath(versionRel);
const pkgPath = jsonSource ? versionPath : resolvePkgPath(cwd, argVal(args, "--package-json-path"));
const pkg = jsonSource
? { exists: existsSync(versionPath), version: current === DEFAULT ? "" : current }
: readPkgVersion(pkgPath);
// Decision 11: the manifest carries the npm-valid 3-digit translation of
// the 4-digit VERSION; drift is judged against the translated form. A
// JSON version-path is its own source of truth, so its expected form is
// the version itself.
const expectedPkg = jsonSource ? current : npmVersion(current);
const state = classifyState(current, baseV, pkg.exists, pkg.version, expectedPkg);
// Surface version-file absence so callers (and /ship) can tell "version is
// genuinely 0.0.0.0" from "we made up 0.0.0.0 because the file is missing"
// (#2600). Without this, the DRIFT_STALE_PKG dispatch on a missing VERSION
// would feed repair a fabricated version that passes the shape check.
const versionFileExists = existsSync(versionPath);
process.stdout.write(
JSON.stringify({
state,
baseVersion: baseV,
currentVersion: current,
pkgVersion: pkg.version || null,
pkgExists: pkg.exists,
pkgPath: pkg.exists ? relative(cwd, pkgPath) : null,
expectedPkgVersion: pkg.exists ? expectedPkg : null,
versionFileExists,
}) + "\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.)
}
/**
* gstack-repo-only side effect, EXPLICITLY OPT-IN (--regen-digest):
* agents-digest/gstack-AGENTS.md embeds VERSION in its first line and is
* freshness-gated (committed bytes must equal the generated bytes), so the
* bump that changes VERSION regenerates it in the same write. The regen runs
* the TARGET repo's own generator (its bytes must match the target's
* freshness test, so a "trusted" copy beside this binary would false-red on
* version drift) — which is code execution from the bumped repo. That is why
* the flag exists: presence-sniffing alone would turn a routine bump in a
* hostile clone into arbitrary code exec. /ship passes the flag deliberately,
* in a repo whose code the operator is already executing (its test suite).
* Without the flag, write has NO side effects beyond VERSION/manifest/locks.
* Regen failure warns rather than failing the bump — the deterministic
* freshness test stays red until it's rerun, so nothing rots silently.
*/
function regenAgentsDigest(versionPath: string, optedIn: boolean): boolean | null {
if (!optedIn) return null;
const root = dirname(versionPath);
const gen = join(root, "scripts", "gen-agents-digest.ts");
if (!existsSync(gen) || !existsSync(join(root, "agents-digest", "gstack-AGENTS.md"))) return null;
try {
execFileSync("bun", [gen], { cwd: root, stdio: "pipe", timeout: 30_000 });
return true;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(
`write: VERSION updated but the agents digest regen failed (${msg.slice(0, 200)}). ` +
"Run 'bun scripts/gen-agents-digest.ts' manually — test/agents-digest.test.ts stays red until then.\n",
);
return false;
}
}
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 versionRel = resolveVersionRel(cwd, argVal(args, "--version-path"));
const versionPath = join(cwd, versionRel);
// A package.json version-path (#2501) is written in place, keeping the rest
// of the file intact — and it is the ONLY file written. Also syncing a root
// package.json here would be a guess about which of two JSON files the repo
// actually publishes from; in a monorepo whose truth is frontend/package.json
// the root one either doesn't exist or isn't the version users see.
if (isJsonVersionPath(versionRel)) {
if (!existsSync(versionPath)) {
fail(`write: ${versionRel} does not exist. Check --version-path / .gstack/version-path.`, 2);
}
// Decision 11: a JSON manifest can only carry npm-valid semver. A repo
// whose package.json still mirrors the legacy 4-digit form and pins it as
// the version-path would otherwise get "1.67.0.1" written into a manifest
// npm rejects forever — with no drift state to catch it (a JSON source is
// self-consistent by construction).
const jsonV = npmVersion(version!);
let manifestWritten = false;
let lockSynced: string[] = [];
try {
writeFileSync(versionPath, setVersionInJson(readFileSync(versionPath, "utf-8"), jsonV));
manifestWritten = true;
// The pinned manifest's OWN lockfiles (beside it) stay in step too.
lockSynced = syncNpmLockfiles(dirname(versionPath), jsonV, cwd);
} catch {
fail(
manifestWritten
? `write: ${versionRel} was updated but its npm lockfiles were not (corrupt lockfile?). ` +
"Fix or delete the lockfile beside it, then re-run write with the same --version."
: `write: failed to update ${versionRel} (is it valid JSON?).`,
3,
);
}
if (jsonV !== version) {
process.stderr.write(
`write: ${versionRel} carries the npm-valid translation ${jsonV} (a JSON manifest cannot hold 4-digit ${version}). ` +
"Consecutive MICRO releases translate to the SAME manifest version — pin a plain VERSION file if that matters.\n",
);
}
process.stdout.write(
JSON.stringify({
wrote: jsonV,
// Only surfaced when a 4-digit request was translated (the healthy
// 3-digit-pinned path is an identity write).
...(jsonV !== version ? { requestedVersion: version } : {}),
versionPath: versionRel,
packageJson: true,
packageLock: lockSynced.length > 0,
// Uniform output shape with the plain-VERSION branch. Always null
// here: a JSON-manifest version source implies no plain VERSION file
// for the digest generator to stamp.
agentsDigest: null,
}) + "\n",
);
return;
}
const pkgPath = resolvePkgPath(cwd, argVal(args, "--package-json-path"));
const hasPkg = existsSync(pkgPath);
writeFileSync(versionPath, version + "\n");
const digestRegen = regenAgentsDigest(versionPath, args.includes("--regen-digest"));
let lockSynced: string[] = [];
// Decision 11: the manifest (and its lockfiles) carry the npm-valid
// 3-digit translation — npm rejects a fourth component, so mirroring the
// raw 4-digit form breaks `npm ci` in any repo npm actually manages.
// VERSION keeps the full 4-digit form; it stays the source of truth.
const manifestV = npmVersion(version!);
if (hasPkg) {
let pkgWritten = false;
try {
writePkgVersion(pkgPath, manifestV);
pkgWritten = true;
lockSynced = syncNpmLockfiles(dirname(pkgPath), manifestV, cwd);
} catch {
// Accurate recovery per failure point: classify only reads
// package.json (never lockfiles), so "re-run and repair" is only true
// when package.json itself is the stale file.
fail(
pkgWritten
? `VERSION and ${relative(cwd, pkgPath)} were written but the npm lockfiles were not ` +
"(corrupt lockfile?). classify cannot see lockfile drift — fix or delete the lockfile, " +
"then re-run write with the same --version."
: `failed to update ${relative(cwd, pkgPath)}. 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: hasPkg,
packageJsonPath: hasPkg ? relative(cwd, pkgPath) : null,
packageJsonVersion: hasPkg ? manifestV : null,
packageLock: lockSynced.length > 0,
// null = not the gstack repo (no digest to regen); true/false = regen outcome
agentsDigest: digestRegen,
}) + "\n",
);
}
function cmdRepair(args: string[], cwd: string): void {
const versionRel = resolveVersionRel(cwd, argVal(args, "--version-path"));
const versionPath = join(cwd, versionRel);
// Nothing to repair when the version lives in a package.json (#2501): there
// is no second file to drift from, and classify never reports DRIFT_* for
// that shape.
if (isJsonVersionPath(versionRel)) {
process.stdout.write(
JSON.stringify({ repaired: null, reason: `${versionRel} is the single source of truth; no drift possible` }) + "\n",
);
return;
}
// Guard: if the VERSION file does not exist, readVersionFile folds that into
// DEFAULT ("0.0.0.0") — a structurally valid but fabricated version. The
// shape check below (VERSION_RE) would pass it, and we would write 0.0.0
// into package.json, regressing it below where it started (#2600).
if (!existsSync(versionPath)) {
fail(
`VERSION file not found at ${versionRel}. ` +
"Cannot repair package.json without a real version to sync. " +
"Pass --version-path or set .gstack/version-path if the file lives elsewhere.",
2,
);
}
const current = readVersionFile(versionPath, versionRel);
// Guard against readVersionFile folding "file exists but is empty / unparsable"
// into DEFAULT ("0.0.0.0") — same data-corruption pathway as file-missing (#2600).
// A fabricated version must never propagate into package.json. But DEFAULT is
// ambiguous: a VERSION file that GENUINELY reads "0.0.0.0" (a brand-new repo)
// is a legitimate version, not the sentinel. Disambiguate on the raw bytes:
// if the trimmed file content itself matches the version shape, proceed with
// the repair; reject only when the raw content is empty or unparseable.
if (current === DEFAULT) {
let rawTrimmed = "";
try {
rawTrimmed = readFileSync(versionPath, "utf-8").trim();
} catch {
rawTrimmed = "";
}
if (!VERSION_RE.test(rawTrimmed)) {
fail(
`VERSION file at ${versionRel} is empty or contains no parsable version. ` +
"Cannot repair package.json with a fabricated version.",
2,
);
}
}
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,
);
}
const pkgPath = resolvePkgPath(cwd, argVal(args, "--package-json-path"));
if (!existsSync(pkgPath)) {
fail(`repair: no package.json to sync (looked at ${relative(cwd, pkgPath)}).`, 2);
}
// Decision 11: repair syncs the manifest + lockfiles to the npm-valid
// 3-digit translation of the current VERSION.
const manifestV = npmVersion(current);
try {
writePkgVersion(pkgPath, manifestV);
syncNpmLockfiles(dirname(pkgPath), manifestV, cwd);
} catch {
fail("drift repair failed — could not update package.json/npm lockfiles.", 3);
}
process.stdout.write(
JSON.stringify({
repaired: current,
packageJsonPath: relative(cwd, pkgPath),
packageJsonVersion: manifestV,
}) + "\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);
}
}