From e4041f7a7fee18a1a1209141486992f5fe22f682 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Thu, 23 Apr 2026 23:03:27 -0700 Subject: [PATCH] v1.11.0.0 feat(ship): workspace-aware version allocation (#1168) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: bin/gstack-next-version util + workspace_root config key Host-aware (GitHub + GitLab + unknown) VERSION allocator. Queries the open PR queue, fetches each PR's VERSION at head, scans configurable Conductor sibling worktrees for WIP work, and picks the next free slot at the requested bump level. Pure reader, never writes files. /ship consumes the JSON and decides. Co-Authored-By: Claude Opus 4.7 (1M context) * test: fixture tests for gstack-next-version 21 pure-function tests covering parseVersion / bumpVersion / cmpVersion / pickNextSlot (with 8 collision scenarios) / markActiveSiblings (4 cases) plus one CLI smoke test against the live repo. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(scripts): detect-bump + compare-pr-version helpers Shared between /ship (legacy path) and the CI version-gate job. detect-bump: derive bump level from VERSION diff. compare-pr-version: CI gate logic with three exit paths (pass / block / fail-open). Co-Authored-By: Claude Opus 4.7 (1M context) * feat(ci): version-gate + pr-title-sync workflows (GitHub + GitLab) Merge-time collision gate. Fail-open on util errors (network, auth, bug), fail-closed on confirmed collisions. pr-title-sync rewrites the PR title when VERSION changes on push, only for titles that already carry the v prefix (custom titles left alone). GitLab CI mirrors both jobs for host parity. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(skills): queue-aware /ship + drift abort in /land-and-deploy + advisory in /review ship Step 12: queue-aware version pick (FRESH path) + drift detection (ALREADY_BUMPED path). Prompts user to rebump when queue moved, runs the full ship metadata path (VERSION, package.json, CHANGELOG header, PR title) on the rebump so nothing goes stale. ship Step 19: PR title format v : — version ALWAYS first. Rerun path updates title (not just body) when VERSION changed. land-and-deploy Step 3.4: detect drift, ABORT with instruction to rerun /ship. Never auto-mutates from land. review Step 3.4: advisory one-line queue status. Non-blocking. Goldens refreshed for all three hosts (claude/codex/factory). Co-Authored-By: Claude Opus 4.7 (1M context) * feat(skill): /landing-report read-only queue dashboard Standalone skill that renders the current PR queue, sibling worktrees, and what all four bump levels would claim. Pure reader. Useful when running many parallel Conductor workspaces to see what's in flight before shipping anything. Co-Authored-By: Claude Opus 4.7 (1M context) * docs: versioning invariant in CLAUDE.md Document that VERSION is a monotonic sequence, not a strict semver commitment. Bump level expresses intent; queue-advance within a level is permitted. Prevents future re-litigation of the workspace-aware ship design. Co-Authored-By: Claude Opus 4.7 (1M context) * chore: bump version and changelog (v1.8.0.0) Co-Authored-By: Claude Opus 4.7 (1M context) * fix(ship): exclude current PR from queue-awareness (self-reference bug) Version gate flagged PR #1168 as stale because the util counted the PR itself as a queued claim. The exclude filter removes that self-reference. New --exclude-pr flag on bin/gstack-next-version. CI workflows pass github.event.pull_request.number / CI_MERGE_REQUEST_IID. Local /ship auto-detects via gh pr view when the flag isn't passed, with a warning recording the auto-exclusion so it's observable. Caught during the first live ship through the v1.8.0.0 gate — the kind of dogfood the whole release is designed for. Co-Authored-By: Claude Opus 4.7 (1M context) * Merge remote-tracking branch 'origin/main' into garrytan/workspace-aware-ship Rebumped v1.8.0.0 -> v1.11.0.0 (minor-past main's v1.10.1.0) using bin/gstack-next-version — the same queue-aware path this branch introduces. CHANGELOG repositioned so v1.11.0.0 sits above main's new entries (v1.10.1.0 / v1.10.0.0 / v1.9.0.0). Conflicts resolved: - VERSION, package.json: rebumped to v1.11.0.0 (util-picked) - bin/gstack-config: merged both lists (workspace_root + gbrain keys) - CHANGELOG.md: hoisted v1.11.0.0 entry above main's new entries Pre-existing failures in main (4) documented but not fixed in this PR: 1. gstack-brain-sync secret scan > blocks bearer-json (brain-sync tests) 2. no files larger than 2MB (security-bench fixture, already TODO'd) 3. selectTests > skill-specific change (touchfiles scoping) 4. Opus 4.7 overlay pacing directive (expectation stale after v1.10.1.0 removed the Fan out nudge) Co-Authored-By: Claude Opus 4.7 (1M context) * ci: re-trigger PR workflows after merge --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/pr-title-sync.yml | 64 ++ .github/workflows/version-gate.yml | 74 ++ .gitlab-ci.yml | 72 ++ CHANGELOG.md | 51 + CLAUDE.md | 10 + TODOS.md | 20 + VERSION | 2 +- bin/gstack-config | 16 +- bin/gstack-next-version | 477 ++++++++ land-and-deploy/SKILL.md | 43 + land-and-deploy/SKILL.md.tmpl | 43 + landing-report/SKILL.md | 1178 ++++++++++++++++++++ landing-report/SKILL.md.tmpl | 163 +++ package.json | 2 +- review/SKILL.md | 22 + review/SKILL.md.tmpl | 22 + scripts/compare-pr-version.ts | 82 ++ scripts/detect-bump.ts | 31 + ship/SKILL.md | 44 +- ship/SKILL.md.tmpl | 44 +- test/fixtures/golden/claude-ship-SKILL.md | 44 +- test/fixtures/golden/codex-ship-SKILL.md | 44 +- test/fixtures/golden/factory-ship-SKILL.md | 44 +- test/gstack-next-version.test.ts | 182 +++ 24 files changed, 2728 insertions(+), 46 deletions(-) create mode 100644 .github/workflows/pr-title-sync.yml create mode 100644 .github/workflows/version-gate.yml create mode 100644 .gitlab-ci.yml create mode 100755 bin/gstack-next-version create mode 100644 landing-report/SKILL.md create mode 100644 landing-report/SKILL.md.tmpl create mode 100644 scripts/compare-pr-version.ts create mode 100644 scripts/detect-bump.ts create mode 100644 test/gstack-next-version.test.ts diff --git a/.github/workflows/pr-title-sync.yml b/.github/workflows/pr-title-sync.yml new file mode 100644 index 00000000..023f5f66 --- /dev/null +++ b/.github/workflows/pr-title-sync.yml @@ -0,0 +1,64 @@ +name: PR Title Sync + +on: + pull_request: + types: [opened, synchronize, edited] + paths: + - 'VERSION' + +concurrency: + group: pr-title-sync-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + sync: + name: Sync PR title to VERSION + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + if: github.actor != 'github-actions[bot]' + steps: + - name: Checkout PR head + uses: actions/checkout@v4 + with: + fetch-depth: 1 + ref: ${{ github.event.pull_request.head.sha }} + + - name: Read VERSION + current title + id: inspect + run: | + set -euo pipefail + VERSION=$(cat VERSION | tr -d '[:space:]') + TITLE=$(jq -r '.pull_request.title' "$GITHUB_EVENT_PATH") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + # Only rewrite titles that ALREADY follow the v prefix pattern. + # Custom titles (no prefix) are left alone — user kept them intentionally. + if printf '%s' "$TITLE" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+ '; then + PREFIX=$(printf '%s' "$TITLE" | awk '{print $1}') + REST=$(printf '%s' "$TITLE" | sed 's/^v[0-9][0-9.]* //') + { + echo "prefix=$PREFIX" + echo "rest=$REST" + echo "eligible=true" + } >> "$GITHUB_OUTPUT" + else + echo "eligible=false" >> "$GITHUB_OUTPUT" + fi + + - name: Rewrite title if version changed + if: steps.inspect.outputs.eligible == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUM: ${{ github.event.pull_request.number }} + NEW_V: ${{ steps.inspect.outputs.version }} + OLD_PREFIX: ${{ steps.inspect.outputs.prefix }} + REST: ${{ steps.inspect.outputs.rest }} + run: | + if [ "v$NEW_V" = "$OLD_PREFIX" ]; then + echo "Title already matches v$NEW_V; no change." + exit 0 + fi + NEW_TITLE="v$NEW_V $REST" + echo "Rewriting: $OLD_PREFIX ... → v$NEW_V ..." + gh pr edit "$PR_NUM" --title "$NEW_TITLE" diff --git a/.github/workflows/version-gate.yml b/.github/workflows/version-gate.yml new file mode 100644 index 00000000..262baf6e --- /dev/null +++ b/.github/workflows/version-gate.yml @@ -0,0 +1,74 @@ +name: Version Gate + +on: + pull_request: + paths: + - 'VERSION' + - 'CHANGELOG.md' + - 'package.json' + +concurrency: + group: version-gate-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + check: + name: Check VERSION is not stale vs queue + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + steps: + - name: Checkout PR head + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Read versions + id: versions + run: | + set -euo pipefail + PR_VERSION=$(cat VERSION | tr -d '[:space:]') + BASE_REF="${{ github.event.pull_request.base.ref }}" + git fetch origin "$BASE_REF" --depth=1 --quiet || true + BASE_VERSION=$(git show "origin/$BASE_REF:VERSION" 2>/dev/null | tr -d '[:space:]' || echo "0.0.0.0") + { + echo "pr_version=$PR_VERSION" + echo "base_version=$BASE_VERSION" + echo "base_ref=$BASE_REF" + } >> "$GITHUB_OUTPUT" + + - name: Detect bump level + id: bump + run: | + LEVEL=$(bun run scripts/detect-bump.ts "${{ steps.versions.outputs.base_version }}" "${{ steps.versions.outputs.pr_version }}") + echo "level=$LEVEL" >> "$GITHUB_OUTPUT" + + - name: Query queue (util) — fail-open on error + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set +e + bun run bin/gstack-next-version \ + --base "${{ steps.versions.outputs.base_ref }}" \ + --bump "${{ steps.bump.outputs.level }}" \ + --current-version "${{ steps.versions.outputs.base_version }}" \ + --workspace-root null \ + --exclude-pr "${{ github.event.pull_request.number }}" \ + > next.json 2> next.err + RC=$? + if [ "$RC" != "0" ] || [ ! -s next.json ]; then + echo '{"offline":true}' > next.json + echo "::warning::util exit=$RC — failing open. stderr:" + cat next.err || true + fi + + - name: Compare PR VERSION to next free slot + env: + PR_VERSION: ${{ steps.versions.outputs.pr_version }} + run: | + bun run scripts/compare-pr-version.ts next.json "${{ github.event.pull_request.number }}" diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 00000000..7e5e1fa3 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,72 @@ +# GitLab CI parity for workspace-aware ship. +# Mirrors .github/workflows/version-gate.yml and pr-title-sync.yml. +# Projects that mirror to GitLab get the same protection as GitHub. + +stages: + - check + +variables: + BUN_VERSION: "1.3.10" + +.setup-bun: &setup-bun + - apt-get update -qq && apt-get install -qq -y curl jq git + - curl -fsSL https://bun.sh/install | bash -s "bun-v$BUN_VERSION" + - export PATH="$HOME/.bun/bin:$PATH" + +version-gate: + stage: check + image: debian:stable-slim + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + changes: + - VERSION + - CHANGELOG.md + - package.json + script: + - *setup-bun + - PR_VERSION=$(cat VERSION | tr -d '[:space:]') + - BASE_VERSION=$(git show "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME:VERSION" 2>/dev/null | tr -d '[:space:]' || echo "0.0.0.0") + - LEVEL=$(bun run scripts/detect-bump.ts "$BASE_VERSION" "$PR_VERSION") + # Util fail-open: on non-zero exit, emit offline marker + - | + set +e + bun run bin/gstack-next-version \ + --base "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" \ + --bump "$LEVEL" \ + --current-version "$BASE_VERSION" \ + --workspace-root null \ + --exclude-pr "$CI_MERGE_REQUEST_IID" \ + > next.json + RC=$? + if [ "$RC" != "0" ] || [ ! -s next.json ]; then + echo '{"offline":true}' > next.json + echo "WARNING: util exit=$RC — failing open" + fi + set -e + - PR_VERSION="$PR_VERSION" bun run scripts/compare-pr-version.ts next.json "$CI_MERGE_REQUEST_IID" + +pr-title-sync: + stage: check + image: debian:stable-slim + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + changes: + - VERSION + script: + - apt-get update -qq && apt-get install -qq -y curl jq git + - curl -fsSL https://gitlab.com/gitlab-org/cli/-/releases/permalink/latest/downloads/glab_linux_amd64.deb -o glab.deb && dpkg -i glab.deb + - VERSION=$(cat VERSION | tr -d '[:space:]') + - TITLE="$CI_MERGE_REQUEST_TITLE" + - | + if printf '%s' "$TITLE" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+ '; then + PREFIX=$(printf '%s' "$TITLE" | awk '{print $1}') + REST=$(printf '%s' "$TITLE" | sed 's/^v[0-9][0-9.]* //') + if [ "v$VERSION" != "$PREFIX" ]; then + echo "Rewriting: $PREFIX ... → v$VERSION ..." + glab mr update "$CI_MERGE_REQUEST_IID" -t "v$VERSION $REST" + else + echo "Title already matches v$VERSION; no change." + fi + else + echo "Title does not use v prefix — leaving alone." + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e45cf13..311b2525 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,56 @@ # Changelog +## [1.11.0.0] - 2026-04-23 + +## **Workspace-aware ship. Two open PRs can't both claim the same VERSION anymore.** + +If you run gstack in multiple Conductor windows at once, you've probably seen this: two branches bump to the same version, whoever merges second silently overwrites the first one's CHANGELOG entry or lands with a duplicate header, and nobody notices until a `grep "^## \["` later. This release makes that collision impossible by construction. `/ship` now queries the open PR queue, sees what versions are already claimed, and picks the next free slot at your chosen bump level. If a collision is detected between ship and land, the land step aborts and tells you to rerun `/ship` rather than silently overwriting. A new `/landing-report` command shows the whole queue on demand. + +### What changes for you + +Run `/ship` in one Conductor window while another has an open PR claiming v1.7.0.0. Your ship now sees the claim, renders a queue table, and picks the next free slot above it (same bump level). The PR title starts with `v` so landing order is visible in `gh pr list` without opening each PR. If a sibling workspace has uncommitted work at a higher VERSION and looks active (commit in the last 24h), `/ship` asks whether to wait for them or advance past. If the queue shifts between ship and merge, CI's new version-gate catches it, and rerunning `/ship` rewrites VERSION, package.json, CHANGELOG, and the PR title atomically. This very release dogfooded the drift path: the original ship at v1.8.0.0 went stale when three other PRs landed first, and the merge-back-to-main rebump (v1.8.0.0 → v1.11.0.0) happened via the same queue-aware codepath it introduces. + +### What shipped (by the numbers) + +- `bin/gstack-next-version` — ~390-line Bun/TS util. 21 passing fixture tests covering happy path, 8 collision scenarios, offline fallback, fork-PR filtering, sibling activity detection, self-PR auto-exclusion. +- Host parity: GitHub + GitLab both supported. CI gates: `.github/workflows/version-gate.yml`, `.github/workflows/pr-title-sync.yml`, plus `.gitlab-ci.yml` mirror. +- Fail-open semantics on util errors (network, auth, bug). A gstack bug never freezes your merge queue. Fail-closed on confirmed collisions. +- `/landing-report` skill — read-only dashboard showing queue, siblings, and what all four bump levels would claim. +- `workspace_root` config key, default `$HOME/conductor/workspaces`, null disables sibling scan for non-Conductor users. + +### What this means for teams running parallel workspaces + +If you're routinely running 3-10 Conductor windows against the same repo, this is the capability that lets the model scale. Before: you mostly got away with it because you noticed collisions by eye. After: the queue is an observable surface, and the system refuses to ship a stale version. `/landing-report` is the new "where am I in line" check when you're about to open PR #6 for the day. Run it before `/ship` if you want to see what's coming without shipping. + +### Itemized changes + +#### Added + +- `bin/gstack-next-version`. Host-aware (GitHub + GitLab + unknown) VERSION allocator. Queries open PRs, fetches each PR's VERSION at head (bounded concurrency, 10 parallel), scans sibling Conductor worktrees, picks the next free slot. Pure reader, never writes files. Supports `--exclude-pr ` to filter out the PR being checked (prevents self-reference when CI runs against the PR's own VERSION). +- `scripts/detect-bump.ts`, `scripts/compare-pr-version.ts`. CI gate helpers. Three exit paths: pass, block on confirmed collision, fail-open on util errors. +- `.github/workflows/version-gate.yml`. Merge-time collision gate. Runs when VERSION/CHANGELOG/package.json changes on a PR. +- `.github/workflows/pr-title-sync.yml`. Auto-rewrites PR title when VERSION changes on push, only for titles already carrying the `v` prefix (custom titles left alone, idempotent). +- `.gitlab-ci.yml`. GitLab CI parity. Both jobs mirrored with the same fail-open semantics. +- `landing-report/SKILL.md.tmpl`. New `/landing-report` or `/gstack-landing-report` skill. Read-only dashboard. +- `bin/gstack-config`. New `workspace_root` key. Default `$HOME/conductor/workspaces`, `null` disables sibling scan. + +#### Changed + +- `ship/SKILL.md.tmpl` Step 12. Queue-aware VERSION pick in FRESH path, drift detection in ALREADY_BUMPED path. On detected drift the user is prompted to rebump, which runs the full metadata path (VERSION + package.json + CHANGELOG header + PR title) atomically so nothing goes stale. +- `ship/SKILL.md.tmpl` Step 19. PR title format is now `v : `, version ALWAYS first. Rerun path updates the title (not just the body) when VERSION changed. Both GitHub and GitLab paths. +- `land-and-deploy/SKILL.md.tmpl`. New Step 3.4 pre-merge drift detection. Aborts with a clear rerun-/ship instruction rather than auto-mutating files. Rerunning `/ship` is the clean path because ship owns the full metadata flow. +- `review/SKILL.md.tmpl`. New Step 3.4 advisory one-liner showing queue status. Non-blocking. +- `CLAUDE.md`. Versioning invariant paragraph. Documents that VERSION is a monotonic sequence, not a strict semver commitment, and queue-advance within a bump level is permitted. + +#### Fixed + +- Self-reference bug in the version gate. The first live CI run (PR #1168 at v1.8.0.0) was rejected as "stale" because the util counted the PR being checked as a queued claim, inflating the next slot by one. Fixed with `--exclude-pr` flag + `gh pr view` auto-detect so the util silently filters the current branch's PR. Caught and fixed in the same ship — exactly the dogfood loop the release is designed for. + +#### For contributors + +- `test/gstack-next-version.test.ts`. 21 pure-function tests (parseVersion / bumpVersion / cmpVersion / pickNextSlot with 8 collision scenarios / markActiveSiblings 4 cases) plus a CLI smoke test against the live repo. +- Golden ship fixtures refreshed for all three hosts (claude, codex, factory) after Step 12 and Step 19 template changes. This is exactly the blast radius Codex flagged during the CEO review (cross-model tension #8), handled in the same PR rather than as a follow-up. + ## [1.10.1.0] - 2026-04-23 ## **We tried to make Opus 4.7 faster with a prompt. Measurement said it got slower. Pulled the bullet.** diff --git a/CLAUDE.md b/CLAUDE.md index b77b304f..ca1c5b99 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -407,6 +407,16 @@ No auto-merging. No "I'll just clean this up." ## CHANGELOG + VERSION style +**Versioning invariant (workspace-aware ship).** VERSION is a monotonic ordered +release identifier, not a strict semver commitment. The bump level +(major/minor/patch/micro) expresses intent at ship time. Queue-advancing past a +claimed version within the same bump level is explicitly permitted — if branch A +claims v1.7.0.0 as a MINOR and branch B is also a MINOR, B lands at v1.8.0.0 +(still a MINOR relative to main). Downstream consumers must NOT rely on +"MINOR = feature-only, PATCH = fix-only" as a strict contract. This is why +`bin/gstack-next-version` advances within the chosen bump level rather than +repicking the level when collisions happen. + **VERSION and CHANGELOG are branch-scoped.** Every feature branch that ships gets its own version bump and CHANGELOG entry. The entry describes what THIS branch adds — not what was already on main. diff --git a/TODOS.md b/TODOS.md index cfe61548..1dda875b 100644 --- a/TODOS.md +++ b/TODOS.md @@ -1,5 +1,25 @@ # TODOS +## Testing + +### `security-bench-haiku-responses.json` is 27MB, violates the 2MB tracked-file gate + +**What:** `browse/test/fixtures/security-bench-haiku-responses.json` landed on main at v1.6.4.0 (PR #1135) at 27MB. The `no compiled binaries in git > git tracks no files larger than 2MB` gate in `test/skill-validation.test.ts:1623` fails on main and on every feature branch that merges main afterward. + +**Why:** The fixture is a legitimate CI replay corpus (real Haiku responses from the 500-case BrowseSafe-Bench) used to verify the ensemble classifier deterministically. But 13x over the 2MB limit means it will keep failing the validation test for every future ship. + +**Pros:** Removes a pre-existing failure that wastes a triage slot in every /ship run. + +**Cons:** Moving to git-lfs adds a dependency. Splitting into chunks risks breaking the bench test. External hosting adds a CI fetch step. + +**Context:** Noticed during workspace-aware-ship /ship on 2026-04-23 when the post-merge test suite flagged this single failure. Introduced on main in PR #1135 (`v1.6.4.0: cut Haiku classifier FP from 44% to 23%`), commit d75402bb. Two reasonable paths: (a) split into multiple ≤2MB chunks and load them in the bench test, (b) move to git-lfs. + +**Effort:** M (human: ~2-3h / CC: ~20 min) +**Priority:** P1 (not blocking ship, but every future /ship triages the same failure) +**Depends on:** nothing + +--- + ## Context skills ### `/context-save --lane` + `/context-restore --lane` for parallel workstreams diff --git a/VERSION b/VERSION index 3647b707..62bf50d2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.10.1.0 +1.11.0.0 diff --git a/bin/gstack-config b/bin/gstack-config index 967478b0..9973f398 100755 --- a/bin/gstack-config +++ b/bin/gstack-config @@ -78,6 +78,13 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne # gstack_contributor: false # true = file field reports when gstack misbehaves # skip_eng_review: false # true = skip eng review gate in /ship (not recommended) # +# ─── Workspace-aware ship ──────────────────────────────────────────── +# workspace_root: $HOME/conductor/workspaces # Where /ship looks for sibling +# # Conductor worktrees when picking a VERSION slot. +# # Set to "null" to disable sibling scanning entirely. +# # Non-Conductor users can point this at any directory +# # that holds parallel worktrees of the same repo. +# ' # DEFAULTS table — canonical default values for known keys. @@ -96,6 +103,7 @@ lookup_default() { codex_reviews) echo "enabled" ;; gstack_contributor) echo "false" ;; skip_eng_review) echo "false" ;; + workspace_root) echo "$HOME/conductor/workspaces" ;; cross_project_learnings) echo "" ;; # intentionally empty → unset triggers first-time prompt gbrain_sync_mode) echo "off" ;; gbrain_sync_mode_prompted) echo "false" ;; @@ -162,8 +170,8 @@ case "${1:-}" in echo "# ─── Active values (including defaults for unset keys) ───" for KEY in proactive routing_declined telemetry auto_upgrade update_check \ skill_prefix checkpoint_mode checkpoint_push codex_reviews \ - gstack_contributor skip_eng_review gbrain_sync_mode \ - gbrain_sync_mode_prompted; do + gstack_contributor skip_eng_review workspace_root \ + gbrain_sync_mode gbrain_sync_mode_prompted; do VALUE=$(grep -E "^${KEY}:" "$CONFIG_FILE" 2>/dev/null | tail -1 | awk '{print $2}' | tr -d '[:space:]' || true) SOURCE="default" if [ -n "$VALUE" ]; then @@ -178,8 +186,8 @@ case "${1:-}" in echo "# gstack-config defaults" for KEY in proactive routing_declined telemetry auto_upgrade update_check \ skill_prefix checkpoint_mode checkpoint_push codex_reviews \ - gstack_contributor skip_eng_review gbrain_sync_mode \ - gbrain_sync_mode_prompted; do + gstack_contributor skip_eng_review workspace_root \ + gbrain_sync_mode gbrain_sync_mode_prompted; do printf ' %-24s %s\n' "$KEY:" "$(lookup_default "$KEY")" done ;; diff --git a/bin/gstack-next-version b/bin/gstack-next-version new file mode 100755 index 00000000..e10485d9 --- /dev/null +++ b/bin/gstack-next-version @@ -0,0 +1,477 @@ +#!/usr/bin/env bun +// gstack-next-version — host-aware VERSION allocator for /ship. +// +// Queries the PR queue (GitHub or GitLab), fetches each open PR's VERSION, +// scans configurable Conductor sibling worktrees, picks the next free version +// slot at the requested bump level, and emits the whole picture as JSON. +// +// Contract: util NEVER writes files or mutates state. Pure reader + reporter. +// /ship consumes the JSON and decides what to do. +// +// Usage: +// gstack-next-version --base --bump \ +// --current-version [--workspace-root |null] [--json] +// +// Exit codes: +// 0 — emitted JSON successfully (may include "offline":true or "host":"unknown") +// 2 — invalid arguments +// 3 — util bug (unexpected exception) + +import { execFileSync, spawnSync } from "node:child_process"; +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; + +type Bump = "major" | "minor" | "patch" | "micro"; +type Version = [number, number, number, number]; + +type ClaimedPR = { + pr: number; + branch: string; + version: string; + url?: string; +}; + +type Sibling = { + path: string; + branch: string; + version: string; + last_commit_ts: number; + has_open_pr: boolean; + is_active: boolean; +}; + +type Output = { + version: string; + current_version: string; + base_version: string; + bump: Bump; + host: "github" | "gitlab" | "unknown"; + offline: boolean; + claimed: ClaimedPR[]; + siblings: Sibling[]; + active_siblings: Sibling[]; + reason: string; + warnings: string[]; +}; + +const ACTIVE_SIBLING_MAX_AGE_S = 24 * 60 * 60; +const GH_API_CONCURRENCY = 10; + +function parseVersion(s: string): Version | null { + const m = s.trim().match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/); + if (!m) return null; + return [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])]; +} + +function fmtVersion(v: Version): string { + return v.join("."); +} + +function bumpVersion(v: Version, level: Bump): Version { + switch (level) { + case "major": + return [v[0] + 1, 0, 0, 0]; + case "minor": + return [v[0], v[1] + 1, 0, 0]; + case "patch": + return [v[0], v[1], v[2] + 1, 0]; + case "micro": + return [v[0], v[1], v[2], v[3] + 1]; + } +} + +function cmpVersion(a: Version, b: Version): number { + for (let i = 0; i < 4; i++) { + if (a[i] !== b[i]) return a[i] - b[i]; + } + return 0; +} + +// Collision resolution: bump past the highest claimed within the same level. +// Semantics: if my bump is MINOR and the queue claims 1.7.0.0, I advance to +// 1.8.0.0 (still a MINOR relative to main). Preserves ship-time intent. +function pickNextSlot(base: Version, claimed: Version[], level: Bump): { version: Version; reason: string } { + let candidate = bumpVersion(base, level); + const sortedClaimed = [...claimed].sort(cmpVersion); + const highest = sortedClaimed[sortedClaimed.length - 1]; + if (highest && cmpVersion(highest, base) > 0) { + // Queue already advanced past base; bump past the highest claim. + const bumpedPastHighest = bumpVersion(highest, level); + if (cmpVersion(bumpedPastHighest, candidate) > 0) { + return { version: bumpedPastHighest, reason: `bumped past claimed ${fmtVersion(highest)}` }; + } + } + return { version: candidate, reason: "no collision; clean bump from base" }; +} + +function runCommand(cmd: string, args: string[], timeoutMs = 15000): { ok: boolean; stdout: string; stderr: string } { + const r = spawnSync(cmd, args, { encoding: "utf8", timeout: timeoutMs }); + return { + ok: r.status === 0 && !r.error, + stdout: r.stdout ?? "", + stderr: r.stderr ?? (r.error ? String(r.error) : ""), + }; +} + +function detectHost(): "github" | "gitlab" | "unknown" { + const remote = runCommand("git", ["remote", "get-url", "origin"]); + if (remote.ok) { + const url = remote.stdout.trim(); + if (url.includes("github.com")) return "github"; + if (url.includes("gitlab")) return "gitlab"; + } + const gh = runCommand("gh", ["auth", "status"]); + if (gh.ok) return "github"; + const glab = runCommand("glab", ["auth", "status"]); + if (glab.ok) return "gitlab"; + return "unknown"; +} + +function readBaseVersion(base: string, warnings: string[]): string { + // git fetch is best-effort; we tolerate failure and fall back to whatever + // origin/ currently points at. + runCommand("git", ["fetch", "origin", base, "--quiet"], 10000); + const r = runCommand("git", ["show", `origin/${base}:VERSION`]); + if (!r.ok) { + warnings.push(`could not read VERSION at origin/${base}; assuming 0.0.0.0`); + return "0.0.0.0"; + } + return r.stdout.trim(); +} + +async function fetchGithubClaimed(base: string, excludePR: number | null, warnings: string[]): Promise<{ claimed: ClaimedPR[]; offline: boolean }> { + const list = runCommand("gh", [ + "pr", + "list", + "--state", + "open", + "--base", + base, + "--limit", + "200", + "--json", + "number,headRefName,headRepositoryOwner,url,isDraft", + ]); + if (!list.ok) { + warnings.push(`gh pr list failed: ${list.stderr.trim().slice(0, 200)}`); + return { claimed: [], offline: true }; + } + let prs: { + number: number; + headRefName: string; + headRepositoryOwner?: { login: string }; + url: string; + isDraft: boolean; + }[]; + try { + prs = JSON.parse(list.stdout); + } catch (e) { + warnings.push(`gh pr list returned invalid JSON`); + return { claimed: [], offline: true }; + } + // Determine our repo owner to filter out fork PRs. `gh api contents?ref=` + // resolves to OUR repo regardless of where the PR originated, so fork PRs would + // otherwise return our main's VERSION as a phantom claim. + const viewer = runCommand("gh", ["repo", "view", "--json", "owner", "-q", ".owner.login"]); + const myOwner = viewer.ok ? viewer.stdout.trim() : ""; + const sameRepoPRs = (myOwner + ? prs.filter((p) => (p.headRepositoryOwner?.login ?? "") === myOwner) + : prs + ).filter((p) => excludePR === null || p.number !== excludePR); + // Fetch each PR's VERSION at its head in parallel (bounded concurrency). + const results: ClaimedPR[] = []; + const queue = [...sameRepoPRs]; + const workers = Array.from({ length: Math.min(GH_API_CONCURRENCY, sameRepoPRs.length) }, async () => { + while (queue.length) { + const pr = queue.shift(); + if (!pr) return; + // gh passes branch name via argv, not shell — safe. + const content = runCommand("gh", [ + "api", + `repos/{owner}/{repo}/contents/VERSION?ref=${encodeURIComponent(pr.headRefName)}`, + "-q", + ".content", + ]); + if (!content.ok) { + warnings.push(`PR #${pr.number}: could not fetch VERSION (fork or private)`); + continue; + } + let versionStr: string; + try { + versionStr = Buffer.from(content.stdout.trim(), "base64").toString("utf8").trim(); + } catch { + warnings.push(`PR #${pr.number}: VERSION is not valid base64`); + continue; + } + if (!parseVersion(versionStr)) { + warnings.push(`PR #${pr.number}: VERSION is malformed (${versionStr})`); + continue; + } + results.push({ pr: pr.number, branch: pr.headRefName, version: versionStr, url: pr.url }); + } + }); + await Promise.all(workers); + return { claimed: results, offline: false }; +} + +async function fetchGitlabClaimed(base: string, excludePR: number | null, warnings: string[]): Promise<{ claimed: ClaimedPR[]; offline: boolean }> { + const list = runCommand("glab", [ + "mr", + "list", + "--opened", + "--target-branch", + base, + "--output", + "json", + "--per-page", + "200", + ]); + if (!list.ok) { + warnings.push(`glab mr list failed: ${list.stderr.trim().slice(0, 200)}`); + return { claimed: [], offline: true }; + } + let mrs: { iid: number; source_branch: string; web_url: string }[]; + try { + mrs = JSON.parse(list.stdout); + } catch { + warnings.push(`glab mr list returned invalid JSON`); + return { claimed: [], offline: true }; + } + if (excludePR !== null) { + mrs = mrs.filter((mr) => mr.iid !== excludePR); + } + const results: ClaimedPR[] = []; + for (const mr of mrs) { + const content = runCommand("glab", [ + "api", + `projects/:id/repository/files/VERSION?ref=${encodeURIComponent(mr.source_branch)}`, + ]); + if (!content.ok) { + warnings.push(`MR !${mr.iid}: could not fetch VERSION`); + continue; + } + try { + const j = JSON.parse(content.stdout); + const versionStr = Buffer.from(j.content, "base64").toString("utf8").trim(); + if (!parseVersion(versionStr)) { + warnings.push(`MR !${mr.iid}: VERSION malformed (${versionStr})`); + continue; + } + results.push({ pr: mr.iid, branch: mr.source_branch, version: versionStr, url: mr.web_url }); + } catch { + warnings.push(`MR !${mr.iid}: unexpected glab api response`); + } + } + return { claimed: results, offline: false }; +} + +function resolveWorkspaceRoot(override?: string): string | null { + if (override === "null") return null; + if (override) return override; + const r = runCommand(join(__dirname, "gstack-config"), ["get", "workspace_root"]); + const configured = r.ok ? r.stdout.trim() : ""; + if (configured === "null") return null; + if (configured) return configured; + // Default: $HOME/conductor/workspaces/ + return join(homedir(), "conductor", "workspaces"); +} + +function currentRepoSlug(): string { + const r = runCommand("git", ["remote", "get-url", "origin"]); + if (!r.ok) return ""; + // Extract "owner/repo" from URL like git@github.com:owner/repo.git + const m = r.stdout.trim().match(/[:/]([^/]+\/[^/]+?)(?:\.git)?$/); + return m ? m[1] : ""; +} + +function scanSiblings(root: string | null, claimed: ClaimedPR[], warnings: string[]): Sibling[] { + if (!root || !existsSync(root)) return []; + const mySlug = currentRepoSlug(); + if (!mySlug) { + warnings.push("could not determine current repo slug; skipping sibling scan"); + return []; + } + const repoName = mySlug.split("/").pop() ?? ""; + // Conductor layout: /// + const repoDir = join(root, repoName); + if (!existsSync(repoDir)) return []; + const myAbsPath = resolve(process.cwd()); + const results: Sibling[] = []; + for (const name of readdirSync(repoDir)) { + const p = join(repoDir, name); + if (resolve(p) === myAbsPath) continue; + try { + const s = statSync(p); + if (!s.isDirectory()) continue; + } catch { + continue; + } + if (!existsSync(join(p, ".git")) && !existsSync(join(p, ".git/HEAD"))) continue; + const versionFile = join(p, "VERSION"); + if (!existsSync(versionFile)) continue; + let version: string; + try { + version = readFileSync(versionFile, "utf8").trim(); + if (!parseVersion(version)) continue; + } catch { + continue; + } + const branchR = runCommand("git", ["-C", p, "rev-parse", "--abbrev-ref", "HEAD"]); + if (!branchR.ok) continue; + const branch = branchR.stdout.trim(); + const commitTsR = runCommand("git", ["-C", p, "log", "-1", "--format=%ct"]); + const last_commit_ts = commitTsR.ok ? Number(commitTsR.stdout.trim()) : 0; + const has_open_pr = claimed.some((c) => c.branch === branch); + results.push({ + path: p, + branch, + version, + last_commit_ts, + has_open_pr, + is_active: false, + }); + } + return results; +} + +function markActiveSiblings(siblings: Sibling[], baseVersion: Version): Sibling[] { + const now = Math.floor(Date.now() / 1000); + return siblings.map((s) => { + const v = parseVersion(s.version); + const isAhead = v ? cmpVersion(v, baseVersion) > 0 : false; + const isFresh = s.last_commit_ts > 0 && now - s.last_commit_ts < ACTIVE_SIBLING_MAX_AGE_S; + const is_active = isAhead && isFresh && !s.has_open_pr; + return { ...s, is_active }; + }); +} + +function parseArgs(argv: string[]): { base: string; bump: Bump; current: string; workspaceRoot?: string; excludePR: number | null; help: boolean } { + let base = ""; + let bump: Bump | "" = ""; + let current = ""; + let workspaceRoot: string | undefined; + let excludePR: number | null = null; + let help = false; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--base") base = argv[++i] ?? ""; + else if (a === "--bump") bump = (argv[++i] ?? "") as Bump; + else if (a === "--current-version") current = argv[++i] ?? ""; + else if (a === "--workspace-root") workspaceRoot = argv[++i]; + else if (a === "--exclude-pr") { + const n = Number(argv[++i]); + excludePR = Number.isFinite(n) && n > 0 ? n : null; + } + else if (a === "-h" || a === "--help") help = true; + } + if (help) return { base: "", bump: "micro", current: "", excludePR: null, help: true }; + if (!base) base = "main"; + if (!bump) { + console.error("Error: --bump is required (major|minor|patch|micro)"); + process.exit(2); + } + if (!["major", "minor", "patch", "micro"].includes(bump)) { + console.error(`Error: --bump must be major|minor|patch|micro (got ${bump})`); + process.exit(2); + } + return { base, bump: bump as Bump, current, workspaceRoot, excludePR, help: false }; +} + +// Auto-detect: if --exclude-pr wasn't passed, check whether the current branch +// already has an open PR and exclude it by default. This prevents the self- +// reference bug where /ship's own PR inflates the queue on rerun. +function autoDetectExcludePR(): number | null { + const r = runCommand("gh", ["pr", "view", "--json", "number", "-q", ".number"]); + if (!r.ok) return null; + const n = Number(r.stdout.trim()); + return Number.isFinite(n) && n > 0 ? n : null; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + console.log( + "Usage: gstack-next-version --base --bump --current-version [--workspace-root ]", + ); + process.exit(0); + } + const warnings: string[] = []; + const host = detectHost(); + const baseVersion = args.current || readBaseVersion(args.base, warnings); + const baseParsed = parseVersion(baseVersion); + if (!baseParsed) { + console.error(`Error: could not parse base version '${baseVersion}'`); + process.exit(2); + } + + const excludePR = args.excludePR ?? autoDetectExcludePR(); + if (excludePR !== null && args.excludePR === null) { + warnings.push(`auto-excluded PR #${excludePR} (current branch's own PR)`); + } + + let claimed: ClaimedPR[] = []; + let offline = false; + if (host === "github") { + ({ claimed, offline } = await fetchGithubClaimed(args.base, excludePR, warnings)); + } else if (host === "gitlab") { + ({ claimed, offline } = await fetchGitlabClaimed(args.base, excludePR, warnings)); + } else { + warnings.push("host unknown; queue-awareness unavailable"); + } + + // Only count PRs that actually bumped VERSION past base as real "claims". + // A PR whose VERSION equals base's VERSION hasn't claimed anything. + const realClaims = claimed.filter((c) => { + const v = parseVersion(c.version); + return v !== null && cmpVersion(v, baseParsed) > 0; + }); + const claimedVersions = realClaims + .map((c) => parseVersion(c.version)) + .filter((v): v is Version => v !== null); + + const { version: picked, reason } = pickNextSlot(baseParsed, claimedVersions, args.bump); + + const workspaceRoot = resolveWorkspaceRoot(args.workspaceRoot); + const siblings = markActiveSiblings(scanSiblings(workspaceRoot, claimed, warnings), baseParsed); + const activeSiblings = siblings.filter((s) => s.is_active); + + // If an active sibling outranks our pick, bump past it (same bump level). + let finalVersion = picked; + let finalReason = reason; + const activeAhead = activeSiblings + .map((s) => parseVersion(s.version)) + .filter((v): v is Version => v !== null) + .filter((v) => cmpVersion(v, finalVersion) >= 0); + if (activeAhead.length) { + const highest = activeAhead.sort(cmpVersion)[activeAhead.length - 1]; + finalVersion = bumpVersion(highest, args.bump); + finalReason = `bumped past active sibling ${fmtVersion(highest)}`; + } + + const out: Output = { + version: fmtVersion(finalVersion), + current_version: args.current || baseVersion, + base_version: baseVersion, + bump: args.bump, + host, + offline, + claimed: realClaims, + siblings, + active_siblings: activeSiblings, + reason: finalReason, + warnings, + }; + process.stdout.write(JSON.stringify(out, null, 2) + "\n"); +} + +// Pure-function exports for testing +export { parseVersion, fmtVersion, bumpVersion, cmpVersion, pickNextSlot, markActiveSiblings }; + +// Only run main() when invoked as a script, not when imported by tests. +if (import.meta.main) { + main().catch((e) => { + console.error("Unexpected error:", e?.stack ?? e); + process.exit(3); + }); +} diff --git a/land-and-deploy/SKILL.md b/land-and-deploy/SKILL.md index 536c13e3..d6aa1ff6 100644 --- a/land-and-deploy/SKILL.md +++ b/land-and-deploy/SKILL.md @@ -1447,6 +1447,49 @@ If timeout (15 min): **STOP.** "CI has been running for over 15 minutes — that --- +## Step 3.4: VERSION drift detection (workspace-aware ship) + +Before gathering readiness evidence, verify that the VERSION this PR claims is still the next free slot. A sibling workspace may have shipped and landed since `/ship` ran, leaving this PR's VERSION stale. + +```bash +BRANCH_VERSION=$(git show HEAD:VERSION 2>/dev/null | tr -d '\r\n[:space:]' || echo "") +BASE_BRANCH=$(gh pr view --json baseRefName -q .baseRefName 2>/dev/null || echo main) +BASE_VERSION=$(git show origin/$BASE_BRANCH:VERSION 2>/dev/null | tr -d '\r\n[:space:]' || echo "") + +# Imply bump level by comparing branch VERSION to base (crude but good enough for drift detection) +# We don't need the exact original level — we just need "a level" that passes to the util. +# If the minor digit advanced, call it minor; patch digit, patch; etc. If base > branch, skip (not ours to land). +# For simplicity: use "patch" as a conservative default; util handles collision-past regardless of input level. +QUEUE_JSON=$(bun run bin/gstack-next-version \ + --base "$BASE_BRANCH" \ + --bump patch \ + --current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}') +NEXT_SLOT=$(echo "$QUEUE_JSON" | jq -r '.version // empty') +OFFLINE=$(echo "$QUEUE_JSON" | jq -r '.offline // false') +``` + +Behavior: + +1. If `OFFLINE=true` or the util fails: print `⚠ VERSION drift check unavailable (util offline) — proceeding with PR version v`. Continue to Step 3.5. CI's version-gate job is the backstop. + +2. If `BRANCH_VERSION` is already `>=` than `NEXT_SLOT`: no drift (or our PR is ahead of the queue). Continue. + +3. If drift is detected (a PR landed ahead of us and `BRANCH_VERSION < NEXT_SLOT`): **STOP** and print exactly: + ``` + ⚠ VERSION drift detected. + This PR claims: v + Next free slot: v (queue moved since last /ship) + + Rerun /ship from the feature branch to reconcile. /ship's ALREADY_BUMPED + branch will detect the drift and rewrite VERSION + CHANGELOG header + PR title + atomically. Do NOT merge from here — the landed PR would overwrite the other + branch's CHANGELOG entry or land with a duplicate version header. + ``` + + Exit non-zero. Do NOT auto-bump from `/land-and-deploy` — rerunning `/ship` is the clean path (it already handles VERSION + package.json + CHANGELOG header + PR title atomically via Step 12 ALREADY_BUMPED detection). + +--- + ## Step 3.5: Pre-merge readiness gate **This is the critical safety check before an irreversible merge.** The merge cannot diff --git a/land-and-deploy/SKILL.md.tmpl b/land-and-deploy/SKILL.md.tmpl index c5a35110..a08debea 100644 --- a/land-and-deploy/SKILL.md.tmpl +++ b/land-and-deploy/SKILL.md.tmpl @@ -328,6 +328,49 @@ If timeout (15 min): **STOP.** "CI has been running for over 15 minutes — that --- +## Step 3.4: VERSION drift detection (workspace-aware ship) + +Before gathering readiness evidence, verify that the VERSION this PR claims is still the next free slot. A sibling workspace may have shipped and landed since `/ship` ran, leaving this PR's VERSION stale. + +```bash +BRANCH_VERSION=$(git show HEAD:VERSION 2>/dev/null | tr -d '\r\n[:space:]' || echo "") +BASE_BRANCH=$(gh pr view --json baseRefName -q .baseRefName 2>/dev/null || echo main) +BASE_VERSION=$(git show origin/$BASE_BRANCH:VERSION 2>/dev/null | tr -d '\r\n[:space:]' || echo "") + +# Imply bump level by comparing branch VERSION to base (crude but good enough for drift detection) +# We don't need the exact original level — we just need "a level" that passes to the util. +# If the minor digit advanced, call it minor; patch digit, patch; etc. If base > branch, skip (not ours to land). +# For simplicity: use "patch" as a conservative default; util handles collision-past regardless of input level. +QUEUE_JSON=$(bun run bin/gstack-next-version \ + --base "$BASE_BRANCH" \ + --bump patch \ + --current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}') +NEXT_SLOT=$(echo "$QUEUE_JSON" | jq -r '.version // empty') +OFFLINE=$(echo "$QUEUE_JSON" | jq -r '.offline // false') +``` + +Behavior: + +1. If `OFFLINE=true` or the util fails: print `⚠ VERSION drift check unavailable (util offline) — proceeding with PR version v`. Continue to Step 3.5. CI's version-gate job is the backstop. + +2. If `BRANCH_VERSION` is already `>=` than `NEXT_SLOT`: no drift (or our PR is ahead of the queue). Continue. + +3. If drift is detected (a PR landed ahead of us and `BRANCH_VERSION < NEXT_SLOT`): **STOP** and print exactly: + ``` + ⚠ VERSION drift detected. + This PR claims: v + Next free slot: v (queue moved since last /ship) + + Rerun /ship from the feature branch to reconcile. /ship's ALREADY_BUMPED + branch will detect the drift and rewrite VERSION + CHANGELOG header + PR title + atomically. Do NOT merge from here — the landed PR would overwrite the other + branch's CHANGELOG entry or land with a duplicate version header. + ``` + + Exit non-zero. Do NOT auto-bump from `/land-and-deploy` — rerunning `/ship` is the clean path (it already handles VERSION + package.json + CHANGELOG header + PR title atomically via Step 12 ALREADY_BUMPED detection). + +--- + ## Step 3.5: Pre-merge readiness gate **This is the critical safety check before an irreversible merge.** The merge cannot diff --git a/landing-report/SKILL.md b/landing-report/SKILL.md new file mode 100644 index 00000000..33361e64 --- /dev/null +++ b/landing-report/SKILL.md @@ -0,0 +1,1178 @@ +--- +name: landing-report +version: 0.1.0 +description: | + Read-only queue dashboard for workspace-aware ship. Shows which VERSION slots + are currently claimed by open PRs, which sibling Conductor workspaces have + WIP work likely to ship soon, and what slot /ship would pick next. No + mutations — just a snapshot. Use when asked to "landing report", "what's in + the queue", "show me open PRs", or "which version do I claim next". (gstack) +triggers: + - landing report + - version queue + - ship queue + - what version comes next + - show open PR versions +allowed-tools: + - Bash + - Read +--- + + + +# /landing-report — Version Queue Dashboard + +## Preamble (run first) + +```bash +_UPD=$(~/.claude/skills/gstack/bin/gstack-update-check 2>/dev/null || .claude/skills/gstack/bin/gstack-update-check 2>/dev/null || true) +[ -n "$_UPD" ] && echo "$_UPD" || true +mkdir -p ~/.gstack/sessions +touch ~/.gstack/sessions/"$PPID" +_SESSIONS=$(find ~/.gstack/sessions -mmin -120 -type f 2>/dev/null | wc -l | tr -d ' ') +find ~/.gstack/sessions -mmin +120 -type f -exec rm {} + 2>/dev/null || true +_PROACTIVE=$(~/.claude/skills/gstack/bin/gstack-config get proactive 2>/dev/null || echo "true") +_PROACTIVE_PROMPTED=$([ -f ~/.gstack/.proactive-prompted ] && echo "yes" || echo "no") +_BRANCH=$(git branch --show-current 2>/dev/null || echo "unknown") +echo "BRANCH: $_BRANCH" +_SKILL_PREFIX=$(~/.claude/skills/gstack/bin/gstack-config get skill_prefix 2>/dev/null || echo "false") +echo "PROACTIVE: $_PROACTIVE" +echo "PROACTIVE_PROMPTED: $_PROACTIVE_PROMPTED" +echo "SKILL_PREFIX: $_SKILL_PREFIX" +source <(~/.claude/skills/gstack/bin/gstack-repo-mode 2>/dev/null) || true +REPO_MODE=${REPO_MODE:-unknown} +echo "REPO_MODE: $REPO_MODE" +_LAKE_SEEN=$([ -f ~/.gstack/.completeness-intro-seen ] && echo "yes" || echo "no") +echo "LAKE_INTRO: $_LAKE_SEEN" +_TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || true) +_TEL_PROMPTED=$([ -f ~/.gstack/.telemetry-prompted ] && echo "yes" || echo "no") +_TEL_START=$(date +%s) +_SESSION_ID="$$-$(date +%s)" +echo "TELEMETRY: ${_TEL:-off}" +echo "TEL_PROMPTED: $_TEL_PROMPTED" +# Writing style verbosity (V1: default = ELI10, terse = tighter V0 prose. +# Read on every skill run so terse mode takes effect without a restart.) +_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default") +if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi +echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL" +# Question tuning (see /plan-tune). Observational only in V1. +_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false") +echo "QUESTION_TUNING: $_QUESTION_TUNING" +mkdir -p ~/.gstack/analytics +if [ "$_TEL" != "off" ]; then +echo '{"skill":"landing-report","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true +fi +# zsh-compatible: use find instead of glob to avoid NOMATCH error +for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do + if [ -f "$_PF" ]; then + if [ "$_TEL" != "off" ] && [ -x "~/.claude/skills/gstack/bin/gstack-telemetry-log" ]; then + ~/.claude/skills/gstack/bin/gstack-telemetry-log --event-type skill_run --skill _pending_finalize --outcome unknown --session-id "$_SESSION_ID" 2>/dev/null || true + fi + rm -f "$_PF" 2>/dev/null || true + fi + break +done +# Learnings count +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true +_LEARN_FILE="${GSTACK_HOME:-$HOME/.gstack}/projects/${SLUG:-unknown}/learnings.jsonl" +if [ -f "$_LEARN_FILE" ]; then + _LEARN_COUNT=$(wc -l < "$_LEARN_FILE" 2>/dev/null | tr -d ' ') + echo "LEARNINGS: $_LEARN_COUNT entries loaded" + if [ "$_LEARN_COUNT" -gt 5 ] 2>/dev/null; then + ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 3 2>/dev/null || true + fi +else + echo "LEARNINGS: 0" +fi +# Session timeline: record skill start (local-only, never sent anywhere) +~/.claude/skills/gstack/bin/gstack-timeline-log '{"skill":"landing-report","event":"started","branch":"'"$_BRANCH"'","session":"'"$_SESSION_ID"'"}' 2>/dev/null & +# Check if CLAUDE.md has routing rules +_HAS_ROUTING="no" +if [ -f CLAUDE.md ] && grep -q "## Skill routing" CLAUDE.md 2>/dev/null; then + _HAS_ROUTING="yes" +fi +_ROUTING_DECLINED=$(~/.claude/skills/gstack/bin/gstack-config get routing_declined 2>/dev/null || echo "false") +echo "HAS_ROUTING: $_HAS_ROUTING" +echo "ROUTING_DECLINED: $_ROUTING_DECLINED" +# Vendoring deprecation: detect if CWD has a vendored gstack copy +_VENDORED="no" +if [ -d ".claude/skills/gstack" ] && [ ! -L ".claude/skills/gstack" ]; then + if [ -f ".claude/skills/gstack/VERSION" ] || [ -d ".claude/skills/gstack/.git" ]; then + _VENDORED="yes" + fi +fi +echo "VENDORED_GSTACK: $_VENDORED" +echo "MODEL_OVERLAY: claude" +# Checkpoint mode (explicit = no auto-commit, continuous = WIP commits as you go) +_CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode 2>/dev/null || echo "explicit") +_CHECKPOINT_PUSH=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_push 2>/dev/null || echo "false") +echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE" +echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH" +# Detect spawned session (OpenClaw or other orchestrator) +[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true +``` + +If `PROACTIVE` is `"false"`, do not proactively suggest gstack skills AND do not +auto-invoke skills based on conversation context. Only run skills the user explicitly +types (e.g., /qa, /ship). If you would have auto-invoked a skill, instead briefly say: +"I think /skillname might help here — want me to run it?" and wait for confirmation. +The user opted out of proactive behavior. + +If `SKILL_PREFIX` is `"true"`, the user has namespaced skill names. When suggesting +or invoking other gstack skills, use the `/gstack-` prefix (e.g., `/gstack-qa` instead +of `/qa`, `/gstack-ship` instead of `/ship`). Disk paths are unaffected — always use +`~/.claude/skills/gstack/[skill-name]/SKILL.md` for reading skill files. + +If output shows `UPGRADE_AVAILABLE `: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (auto-upgrade if configured, otherwise AskUserQuestion with 4 options, write snooze state if declined). + +If output shows `JUST_UPGRADED ` AND `SPAWNED_SESSION` is NOT set: tell +the user "Running gstack v{to} (just updated!)" and then check for new features to +surface. For each per-feature marker below, if the marker file is missing AND the +feature is plausibly useful for this user, use AskUserQuestion to let them try it. +Fire once per feature per user, NOT once per upgrade. + +**In spawned sessions (`SPAWNED_SESSION` = "true"): SKIP feature discovery entirely.** +Just print "Running gstack v{to}" and continue. Orchestrators do not want interactive +prompts from sub-sessions. + +**Feature discovery markers and prompts** (one at a time, max one per session): + +1. `~/.claude/skills/gstack/.feature-prompted-continuous-checkpoint` → + Prompt: "Continuous checkpoint auto-commits your work as you go with `WIP:` prefix + so you never lose progress to a crash. Local-only by default — doesn't push + anywhere unless you turn that on. Want to try it?" + Options: A) Enable continuous mode, B) Show me first (print the section from + the preamble Continuous Checkpoint Mode), C) Skip. + If A: run `~/.claude/skills/gstack/bin/gstack-config set checkpoint_mode continuous`. + Always: `touch ~/.claude/skills/gstack/.feature-prompted-continuous-checkpoint` + +2. `~/.claude/skills/gstack/.feature-prompted-model-overlay` → + Inform only (no prompt): "Model overlays are active. `MODEL_OVERLAY: {model}` + shown in the preamble output tells you which behavioral patch is applied. + Override with `--model` when regenerating skills (e.g., `bun run gen:skill-docs + --model gpt-5.4`). Default is claude." + Always: `touch ~/.claude/skills/gstack/.feature-prompted-model-overlay` + +After handling JUST_UPGRADED (prompts done or skipped), continue with the skill +workflow. + +If `WRITING_STYLE_PENDING` is `yes`: You're on the first skill run after upgrading +to gstack v1. Ask the user once about the new default writing style. Use AskUserQuestion: + +> v1 prompts = simpler. Technical terms get a one-sentence gloss on first use, +> questions are framed in outcome terms, sentences are shorter. +> +> Keep the new default, or prefer the older tighter prose? + +Options: +- A) Keep the new default (recommended — good writing helps everyone) +- B) Restore V0 prose — set `explain_level: terse` + +If A: leave `explain_level` unset (defaults to `default`). +If B: run `~/.claude/skills/gstack/bin/gstack-config set explain_level terse`. + +Always run (regardless of choice): +```bash +rm -f ~/.gstack/.writing-style-prompt-pending +touch ~/.gstack/.writing-style-prompted +``` + +This only happens once. If `WRITING_STYLE_PENDING` is `no`, skip this entirely. + +If `LAKE_INTRO` is `no`: Before continuing, introduce the Completeness Principle. +Tell the user: "gstack follows the **Boil the Lake** principle — always do the complete +thing when AI makes the marginal cost near-zero. Read more: https://garryslist.org/posts/boil-the-ocean" +Then offer to open the essay in their default browser: + +```bash +open https://garryslist.org/posts/boil-the-ocean +touch ~/.gstack/.completeness-intro-seen +``` + +Only run `open` if the user says yes. Always run `touch` to mark as seen. This only happens once. + +If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: After the lake intro is handled, +ask the user about telemetry. Use AskUserQuestion: + +> Help gstack get better! Community mode shares usage data (which skills you use, how long +> they take, crash info) with a stable device ID so we can track trends and fix bugs faster. +> No code, file paths, or repo names are ever sent. +> Change anytime with `gstack-config set telemetry off`. + +Options: +- A) Help gstack get better! (recommended) +- B) No thanks + +If A: run `~/.claude/skills/gstack/bin/gstack-config set telemetry community` + +If B: ask a follow-up AskUserQuestion: + +> How about anonymous mode? We just learn that *someone* used gstack — no unique ID, +> no way to connect sessions. Just a counter that helps us know if anyone's out there. + +Options: +- A) Sure, anonymous is fine +- B) No thanks, fully off + +If B→A: run `~/.claude/skills/gstack/bin/gstack-config set telemetry anonymous` +If B→B: run `~/.claude/skills/gstack/bin/gstack-config set telemetry off` + +Always run: +```bash +touch ~/.gstack/.telemetry-prompted +``` + +This only happens once. If `TEL_PROMPTED` is `yes`, skip this entirely. + +If `PROACTIVE_PROMPTED` is `no` AND `TEL_PROMPTED` is `yes`: After telemetry is handled, +ask the user about proactive behavior. Use AskUserQuestion: + +> gstack can proactively figure out when you might need a skill while you work — +> like suggesting /qa when you say "does this work?" or /investigate when you hit +> a bug. We recommend keeping this on — it speeds up every part of your workflow. + +Options: +- A) Keep it on (recommended) +- B) Turn it off — I'll type /commands myself + +If A: run `~/.claude/skills/gstack/bin/gstack-config set proactive true` +If B: run `~/.claude/skills/gstack/bin/gstack-config set proactive false` + +Always run: +```bash +touch ~/.gstack/.proactive-prompted +``` + +This only happens once. If `PROACTIVE_PROMPTED` is `yes`, skip this entirely. + +If `HAS_ROUTING` is `no` AND `ROUTING_DECLINED` is `false` AND `PROACTIVE_PROMPTED` is `yes`: +Check if a CLAUDE.md file exists in the project root. If it does not exist, create it. + +Use AskUserQuestion: + +> gstack works best when your project's CLAUDE.md includes skill routing rules. +> This tells Claude to use specialized workflows (like /ship, /investigate, /qa) +> instead of answering directly. It's a one-time addition, about 15 lines. + +Options: +- A) Add routing rules to CLAUDE.md (recommended) +- B) No thanks, I'll invoke skills manually + +If A: Append this section to the end of CLAUDE.md: + +```markdown + +## Skill routing + +When the user's request matches an available skill, invoke it via the Skill tool. The +skill has multi-step workflows, checklists, and quality gates that produce better +results than an ad-hoc answer. When in doubt, invoke the skill. A false positive is +cheaper than a false negative. + +Key routing rules: +- Product ideas, "is this worth building", brainstorming → invoke /office-hours +- Strategy, scope, "think bigger", "what should we build" → invoke /plan-ceo-review +- Architecture, "does this design make sense" → invoke /plan-eng-review +- Design system, brand, "how should this look" → invoke /design-consultation +- Design review of a plan → invoke /plan-design-review +- Developer experience of a plan → invoke /plan-devex-review +- "Review everything", full review pipeline → invoke /autoplan +- Bugs, errors, "why is this broken", "wtf", "this doesn't work" → invoke /investigate +- Test the site, find bugs, "does this work" → invoke /qa (or /qa-only for report only) +- Code review, check the diff, "look at my changes" → invoke /review +- Visual polish, design audit, "this looks off" → invoke /design-review +- Developer experience audit, try onboarding → invoke /devex-review +- Ship, deploy, create a PR, "send it" → invoke /ship +- Merge + deploy + verify → invoke /land-and-deploy +- Configure deployment → invoke /setup-deploy +- Post-deploy monitoring → invoke /canary +- Update docs after shipping → invoke /document-release +- Weekly retro, "how'd we do" → invoke /retro +- Second opinion, codex review → invoke /codex +- Safety mode, careful mode, lock it down → invoke /careful or /guard +- Restrict edits to a directory → invoke /freeze or /unfreeze +- Upgrade gstack → invoke /gstack-upgrade +- Save progress, "save my work" → invoke /context-save +- Resume, restore, "where was I" → invoke /context-restore +- Security audit, OWASP, "is this secure" → invoke /cso +- Make a PDF, document, publication → invoke /make-pdf +- Launch real browser for QA → invoke /open-gstack-browser +- Import cookies for authenticated testing → invoke /setup-browser-cookies +- Performance regression, page speed, benchmarks → invoke /benchmark +- Review what gstack has learned → invoke /learn +- Tune question sensitivity → invoke /plan-tune +- Code quality dashboard → invoke /health +``` + +Then commit the change: `git add CLAUDE.md && git commit -m "chore: add gstack skill routing rules to CLAUDE.md"` + +If B: run `~/.claude/skills/gstack/bin/gstack-config set routing_declined true` +Say "No problem. You can add routing rules later by running `gstack-config set routing_declined false` and re-running any skill." + +This only happens once per project. If `HAS_ROUTING` is `yes` or `ROUTING_DECLINED` is `true`, skip this entirely. + +If `VENDORED_GSTACK` is `yes`: This project has a vendored copy of gstack at +`.claude/skills/gstack/`. Vendoring is deprecated. We will not keep vendored copies +up to date, so this project's gstack will fall behind. + +Use AskUserQuestion (one-time per project, check for `~/.gstack/.vendoring-warned-$SLUG` marker): + +> This project has gstack vendored in `.claude/skills/gstack/`. Vendoring is deprecated. +> We won't keep this copy up to date, so you'll fall behind on new features and fixes. +> +> Want to migrate to team mode? It takes about 30 seconds. + +Options: +- A) Yes, migrate to team mode now +- B) No, I'll handle it myself + +If A: +1. Run `git rm -r .claude/skills/gstack/` +2. Run `echo '.claude/skills/gstack/' >> .gitignore` +3. Run `~/.claude/skills/gstack/bin/gstack-team-init required` (or `optional`) +4. Run `git add .claude/ .gitignore CLAUDE.md && git commit -m "chore: migrate gstack from vendored to team mode"` +5. Tell the user: "Done. Each developer now runs: `cd ~/.claude/skills/gstack && ./setup --team`" + +If B: say "OK, you're on your own to keep the vendored copy up to date." + +Always run (regardless of choice): +```bash +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true +touch ~/.gstack/.vendoring-warned-${SLUG:-unknown} +``` + +This only happens once per project. If the marker file exists, skip entirely. + +If `SPAWNED_SESSION` is `"true"`, you are running inside a session spawned by an +AI orchestrator (e.g., OpenClaw). In spawned sessions: +- Do NOT use AskUserQuestion for interactive prompts. Auto-choose the recommended option. +- Do NOT run upgrade checks, telemetry prompts, routing injection, or lake intro. +- Focus on completing the task and reporting results via prose output. +- End with a completion report: what shipped, decisions made, anything uncertain. + +## AskUserQuestion Format + +**ALWAYS follow this structure for every AskUserQuestion call. Every element is non-skippable. If you find yourself about to skip any of them, stop and back up.** + +### Required shape + +Every AskUserQuestion reads like a decision brief, not a bullet list: + +``` +D + +ELI10: + +Stakes if we pick wrong: + +Recommendation: because + +Completeness: A=X/10, B=Y/10 (or: Note: options differ in kind, not coverage — no completeness score) + +Pros / cons: + +A)