diff --git a/.github/docker/Dockerfile.ci b/.github/docker/Dockerfile.ci index efe5aca83..841291cca 100644 --- a/.github/docker/Dockerfile.ci +++ b/.github/docker/Dockerfile.ci @@ -60,6 +60,21 @@ RUN curl --retry 5 --retry-delay 5 --retry-connrefused -fsSL \ && rm -rf /var/lib/apt/lists/* \ && git --no-lazy-fetch --version +# Partial clone: the eval jobs check out with `filter: blob:none` and read +# older files through on-demand blob fetches (.github/scripts/history-read.sh). +# Prove this Git makes a blobless clone and fetches a missing blob, so the image +# fails here instead of in a slice's history read. +RUN set -e; d=$(mktemp -d); \ + git init -q -b main "$d/src"; echo one > "$d/src/f"; git -C "$d/src" add f; \ + git -C "$d/src" -c user.name=ci -c user.email=ci@localhost commit -qm one; \ + echo two > "$d/src/f"; git -C "$d/src" -c user.name=ci -c user.email=ci@localhost commit -qam two; \ + git -C "$d/src" config uploadpack.allowFilter true; \ + git clone -q --filter=blob:none --no-local "file://$d/src" "$d/clone"; \ + test "$(git -C "$d/clone" config remote.origin.promisor)" = true; \ + if GIT_NO_LAZY_FETCH=1 git -C "$d/clone" cat-file -e HEAD~1:f 2>/dev/null; then echo "blob present: not a partial clone" >&2; exit 1; fi; \ + test "$(git -C "$d/clone" show HEAD~1:f)" = one; \ + rm -rf "$d"; echo "partial clone: ok ($(git --version))" + RUN python3 -m venv /tmp/gstack-ci-venv \ && /tmp/gstack-ci-venv/bin/python -m pip --version \ && rm -rf /tmp/gstack-ci-venv diff --git a/.github/scripts/history-read.sh b/.github/scripts/history-read.sh new file mode 100755 index 000000000..cb3c094d1 --- /dev/null +++ b/.github/scripts/history-read.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Read one file at a commit in a CI checkout that may be a blobless clone +# (actions/checkout `filter: blob:none`), where git fetches an old file's +# contents on demand and that fetch can fail. lib/history-read.ts is the +# TypeScript twin. +# +# Usage: history-read.sh [--absent ] +# +# A read that `git show` answers is the file. When it fails, existence comes +# from the ref's tree (`git ls-tree --full-tree`, local data only): a path the +# tree does not list is absent, so this prints (default: nothing) and +# exits 0. A listed path is retried, 3 tries in all in a partial clone (only a +# fetch can recover) and none otherwise, waiting HISTORY_READ_BACKOFF_SECONDS +# (default 2) times the attempt between tries. Anything else (a ref that does +# not resolve, a fetch that keeps failing) exits 1 with HISTORY_READ_FAILED +# naming the ref, the path and the git version, and never reads as absent. +# docs/troubleshooting.md#history-read-failed +set -euo pipefail + +if [ "$#" -ne 2 ] && { [ "$#" -ne 4 ] || [ "$3" != --absent ]; }; then + echo "usage: history-read.sh [--absent ]" >&2 + exit 2 +fi +ref=$1 file=$2 absent=${4:-} +out=$(mktemp) err=$(mktemp) +trap 'rm -f "$out" "$err"' EXIT + +fail() { + echo "::error::cannot read ${file} at ${ref}. $1 ($(git --version)). Fix: see docs/troubleshooting.md#history-read-failed (HISTORY_READ_FAILED)" >&2 + exit 1 +} + +if git show "${ref}:${file}" >"$out" 2>"$err"; then cat "$out"; exit 0; fi +listed=$(git ls-tree --full-tree "$ref" -- "$file" 2>"$err") || fail "git ls-tree failed: $(head -n 1 "$err")" +if [ -z "$listed" ]; then + printf '%s' "$absent" + exit 0 +fi + +tries=1 +if git config --get-regexp '^(remote\..+\.promisor|extensions\.partialclone)$' | grep -qvi ' false$'; then tries=3; fi +try=1 +while [ "$try" -lt "$tries" ]; do + sleep "$(( try * ${HISTORY_READ_BACKOFF_SECONDS:-2} ))" + if git show "${ref}:${file}" >"$out" 2>"$err"; then cat "$out"; exit 0; fi + try=$(( try + 1 )) +done +fail "git show failed ${tries} time(s): $(head -n 1 "$err")" diff --git a/.github/workflows/evals-main-backstop.yml b/.github/workflows/evals-main-backstop.yml index 2826bf353..2c3f30303 100644 --- a/.github/workflows/evals-main-backstop.yml +++ b/.github/workflows/evals-main-backstop.yml @@ -49,7 +49,17 @@ jobs: - name: Read the train policy (main) id: policy run: | - train=$(git show "${GITHUB_SHA}:.gstack/ship-policy.json" 2>/dev/null | jq -c 'if (.train | type) == "object" then .train else null end' 2>/dev/null || true) + ref="$GITHUB_SHA" file=.gstack/ship-policy.json policy=null + read_failed() { echo "::error::cannot read ${file} at ${ref}. $1 ($(git --version)). Fix: see docs/troubleshooting.md#history-read-failed (HISTORY_READ_FAILED)" >&2; exit 1; } + listed=$(git ls-tree --full-tree "$ref" -- "$file") || read_failed "git ls-tree failed" + if [ -n "$listed" ]; then + for try in 1 2 3; do + policy=$(git show "${ref}:${file}") && break + if [ "$try" = 3 ]; then read_failed "git show failed 3 times"; fi + sleep $((try * 2)) + done + fi + train=$(jq -c 'if (.train | type) == "object" then .train else null end' <<<"$policy") || { echo "::error::${GITHUB_SHA}:.gstack/ship-policy.json is not valid JSON; fix the policy file" >&2; exit 1; } if [ -z "$train" ] || [ "$train" = null ]; then echo "train=false" >> "$GITHUB_OUTPUT" echo "- Backstop inert: main's .gstack/ship-policy.json declares no train block; per-PR gates cover main" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/evals.yml b/.github/workflows/evals.yml index e7b81e55b..fd5ed557c 100644 --- a/.github/workflows/evals.yml +++ b/.github/workflows/evals.yml @@ -179,6 +179,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 + filter: blob:none persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 @@ -193,9 +194,19 @@ jobs: DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} run: | base="${BASE_SHA:-origin/${DEFAULT_BRANCH}}" - train=$(git show "${base}:.gstack/ship-policy.json" 2>/dev/null | jq -c 'if (.train | type) == "object" then .train else null end' 2>/dev/null || true) - artifact=$(jq -r '.record_artifact // ""' <<<"${train:-null}" 2>/dev/null || true) - check=$(jq -r '.full_gate_check // ""' <<<"${train:-null}" 2>/dev/null || true) + ref="$base" file=.gstack/ship-policy.json policy=null + read_failed() { echo "::error::cannot read ${file} at ${ref}. $1 ($(git --version)). Fix: see docs/troubleshooting.md#history-read-failed (HISTORY_READ_FAILED)" >&2; exit 1; } + listed=$(git ls-tree --full-tree "$ref" -- "$file") || read_failed "git ls-tree failed" + if [ -n "$listed" ]; then + for try in 1 2 3; do + policy=$(git show "${ref}:${file}") && break + if [ "$try" = 3 ]; then read_failed "git show failed 3 times"; fi + sleep $((try * 2)) + done + fi + train=$(jq -c 'if (.train | type) == "object" then .train else null end' <<<"$policy") || { echo "::error::${base}:.gstack/ship-policy.json is not valid JSON; fix the policy file" >&2; exit 1; } + artifact=$(jq -r '.record_artifact // ""' <<<"${train:-null}") + check=$(jq -r '.full_gate_check // ""' <<<"${train:-null}") if [ -z "$train" ] || [ "$train" = null ]; then echo "train=false" >> "$GITHUB_OUTPUT" echo "- Train policy: none at ${base}; this run routes as before" >> "$GITHUB_STEP_SUMMARY" @@ -470,6 +481,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 + filter: blob:none persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 @@ -528,6 +540,7 @@ jobs: # Preserve full history for merge-base diff selection. Moving the # planner off the eval image must not change its selection inputs. fetch-depth: 0 + filter: blob:none persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 @@ -670,6 +683,7 @@ jobs: # shards on the lane's first live run ("ambiguous argument # 'main...HEAD'"). The manifest still governs WHICH shards run. fetch-depth: 0 + filter: blob:none persist-credentials: false - name: Fix bun temp @@ -903,6 +917,7 @@ jobs: with: ref: ${{ inputs.screen_merge_sha }} fetch-depth: 0 + filter: blob:none persist-credentials: false - name: Fix bun temp @@ -1029,6 +1044,7 @@ jobs: with: ref: ${{ fromJSON(needs.route.outputs.bisect_refs)[matrix.slice] }} fetch-depth: 0 + filter: blob:none persist-credentials: false - name: Verify the prefix checkout @@ -1173,6 +1189,7 @@ jobs: # history of its prefix commits T1..Tk for the train record. ref: ${{ inputs.screen_merge_sha }} fetch-depth: ${{ needs.plan-slices.outputs.mode == 'train' && '0' || '1' }} + filter: blob:none persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 diff --git a/.github/workflows/free-durations-record.yml b/.github/workflows/free-durations-record.yml index 9101104d3..8faa4598f 100644 --- a/.github/workflows/free-durations-record.yml +++ b/.github/workflows/free-durations-record.yml @@ -71,6 +71,9 @@ jobs: windows: uses: ./.github/workflows/windows-free-tests.yml + permissions: + contents: read + actions: read with: record_durations: true diff --git a/.github/workflows/free-tests.yml b/.github/workflows/free-tests.yml index 569b858fe..966419ad3 100644 --- a/.github/workflows/free-tests.yml +++ b/.github/workflows/free-tests.yml @@ -14,6 +14,15 @@ name: Free Tests # is permanent false comfort. # # Local `bun run test` still uses the existing bounded process pool. +# +# One full suite per tree (GSTA-156, docs/pregate.md#ci-reuse). The aggregate +# uploads a `tested-tree` record (tree.json) only after strict verification +# passes. A push to main first runs `reuse`: when the merged pull request's +# head has the pushed tree and a green pull_request or workflow_dispatch run of +# this workflow verifies (lib/run-evidence.ts), every suite job is skipped and +# the aggregate links that run instead. Any other answer, a reuse failure +# included, runs every job as before. Pull request and dispatch runs never +# consume; reused runs never upload a record, so they are never reused. on: pull_request: @@ -54,7 +63,40 @@ permissions: contents: read jobs: + # Main pushes only, and the one job with actions: read (it reads this + # repository's runs and their tested-tree artifacts). Every suite job below + # runs unless this job succeeded and reused a run; a skipped or failed reuse + # job runs them all. Their conditions start with !cancelled() because a + # skipped needed job would otherwise skip its dependents on pull_request and + # dispatch runs, where reuse never runs. + reuse: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + actions: read + outputs: + reused: ${{ steps.find.outputs.reused }} + run_id: ${{ steps.find.outputs.run_id }} + run_url: ${{ steps.find.outputs.run_url }} + tree: ${{ steps.find.outputs.tree }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.4.2 + - id: find + name: Find a green pull request or dispatch run of this exact tree + env: + GH_TOKEN: ${{ github.token }} + run: bun run scripts/ci-tree-reuse.ts find --workflow free-tests.yml --profile ci-reuse/free-tests + free-plan: + needs: reuse + if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }} runs-on: ubuntu-24.04 timeout-minutes: 5 outputs: @@ -65,8 +107,10 @@ jobs: with: persist-credentials: false # Full history: the seed ratchet compares the allowlist with its - # merge-base copy, which a depth-1 checkout cannot read. + # merge-base copy, which a depth-1 checkout cannot read. Blobless: + # that copy is fetched on demand, and a failed read fails the ratchet. fetch-depth: 0 + filter: blob:none - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.4.2 @@ -104,6 +148,8 @@ jobs: if-no-files-found: error cso-macos-launcher: + needs: reuse + if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }} runs-on: macos-latest timeout-minutes: 15 steps: @@ -142,6 +188,8 @@ jobs: # default, and the job fails if that layout is not symlinked. It also sources # the Codex probe from zsh, macOS's default shell (#3024; no paid call). macos-named-regressions: + needs: reuse + if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }} runs-on: macos-latest timeout-minutes: 20 steps: @@ -186,6 +234,8 @@ jobs: bun test "${files[@]}" cso-windows-launcher: + needs: reuse + if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }} runs-on: windows-latest timeout-minutes: 15 steps: @@ -214,6 +264,8 @@ jobs: retention-days: 14 cso-docker-integration: + needs: reuse + if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }} runs-on: ubuntu-24.04 timeout-minutes: 20 steps: @@ -244,6 +296,8 @@ jobs: DOCKER_HOST: unix:///var/run/docker.sock typecheck: + needs: reuse + if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }} runs-on: ubuntu-24.04 timeout-minutes: 10 steps: @@ -267,6 +321,8 @@ jobs: # the quick tier also runs on it. Its pin equals GSTACK_BUN_DEFAULT; # test/bun-version-drift.test.ts checks this job exists and agrees. quick-bun-default: + needs: reuse + if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }} runs-on: ubicloud-standard-8 timeout-minutes: 30 steps: @@ -363,7 +419,8 @@ jobs: run: xvfb-run -a bun run test:quick free-suite: - needs: free-plan + needs: [free-plan, reuse] + if: ${{ !cancelled() && needs.free-plan.result == 'success' && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }} runs-on: ${{ needs.free-plan.outputs.runner }} timeout-minutes: 20 strategy: @@ -588,13 +645,38 @@ jobs: # Branch protection already requires the `free-tests` context. Keep that # stable name as an always-running aggregate so every platform-specific CSO # gate is merge-blocking without a separate branch-protection migration. + # Its first step picks one of two exclusive paths before any download: a + # verified reuse (main pushes only) links the reused run and passes; anything + # else takes the strict path below unchanged, which alone uploads tested-tree. free-tests: if: always() - needs: [free-suite, typecheck, quick-bun-default, cso-macos-launcher, macos-named-regressions, cso-windows-launcher, cso-docker-integration] + needs: [reuse, free-suite, typecheck, quick-bun-default, cso-macos-launcher, macos-named-regressions, cso-windows-launcher, cso-docker-integration] runs-on: ubuntu-24.04 timeout-minutes: 5 steps: + - name: Take the reuse path only for a verified main-push reuse + id: route + env: + REUSE_RESULT: ${{ needs.reuse.result }} + REUSED: ${{ needs.reuse.outputs.reused }} + REUSE_RUN_ID: ${{ needs.reuse.outputs.run_id }} + REUSE_RUN_URL: ${{ needs.reuse.outputs.run_url }} + REUSE_TREE: ${{ needs.reuse.outputs.tree }} + run: | + set -eu + if [ "$REUSE_RESULT" != success ] || [ "$REUSED" != true ]; then + echo 'reused=false' >> "$GITHUB_OUTPUT" + exit 0 + fi + test "$GITHUB_EVENT_NAME" = push + test "$GITHUB_REF" = refs/heads/main + case "$REUSE_RUN_ID" in ''|*[!0-9]*) echo "::error::the reuse job named no run id"; exit 1 ;; esac + case "$REUSE_TREE" in ''|*[!0-9a-f]*) echo "::error::the reuse job named no tree"; exit 1 ;; esac + test "$REUSE_RUN_URL" = "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$REUSE_RUN_ID" + echo "Reused $REUSE_RUN_URL, which passed on tree $REUSE_TREE; this push ran no suite of its own." >> "$GITHUB_STEP_SUMMARY" + echo 'reused=true' >> "$GITHUB_OUTPUT" - name: Require the free suite, typecheck, the quick tier on the default Bun, macOS regressions, and every CSO platform gate + if: steps.route.outputs.reused != 'true' env: FREE_SUITE_RESULT: ${{ needs.free-suite.result }} TYPECHECK_RESULT: ${{ needs.typecheck.result }} @@ -613,16 +695,20 @@ jobs: test "$CSO_WINDOWS_RESULT" = success test "$CSO_DOCKER_RESULT" = success - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + if: steps.route.outputs.reused != 'true' with: persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + if: steps.route.outputs.reused != 'true' with: bun-version: 1.4.2 - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 + if: steps.route.outputs.reused != 'true' with: name: free-plan path: ${{ runner.temp }} - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 + if: steps.route.outputs.reused != 'true' with: pattern: free-result-* merge-multiple: true @@ -631,10 +717,27 @@ jobs: # flake-ledger.jsonl). A green run is where a retried flake hides, so the # summary runs before strict verification. - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 + if: steps.route.outputs.reused != 'true' with: pattern: flake-ledger-* path: ${{ runner.temp }}/flake-ledgers - name: Summarize flaky passes absorbed by the serial retry + if: steps.route.outputs.reused != 'true' run: bun run scripts/test-health-report.ts flake-summary "$RUNNER_TEMP/flake-ledgers" >> "$GITHUB_STEP_SUMMARY" - name: Require exact coverage and complete strict results + if: steps.route.outputs.reused != 'true' run: bun run scripts/test-free-shards.ts --ci-verify "$RUNNER_TEMP/free-plan.json" --results "$RUNNER_TEMP/free-results" + # Only after strict verification passed, so a red or partial run never + # leaves a record; the reuse path never reaches here. + - name: Record the tested tree + if: steps.route.outputs.reused != 'true' + env: + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: bun run scripts/ci-tree-reuse.ts record --plan "$RUNNER_TEMP/free-plan.json" --results "$RUNNER_TEMP/free-results" --os Linux,macOS,Windows --out "$RUNNER_TEMP/tested-tree/tree.json" + - name: Upload the tested tree + if: steps.route.outputs.reused != 'true' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: tested-tree + path: ${{ runner.temp }}/tested-tree/tree.json + if-no-files-found: error diff --git a/.github/workflows/version-gate.yml b/.github/workflows/version-gate.yml index 1be24d4e6..b64ec092a 100644 --- a/.github/workflows/version-gate.yml +++ b/.github/workflows/version-gate.yml @@ -23,6 +23,9 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 + # Blobless: full history with file contents fetched on demand; the + # reads below fail loudly when a fetch fails (.github/scripts/history-read.sh). + filter: blob:none ref: ${{ github.event.pull_request.head.sha }} # The head's release_tool runs inside the gate (lib/version-gate-train.ts); # no token may sit in the checkout it stages. The base fetch below is public. @@ -49,8 +52,12 @@ jobs: set -euo pipefail PR_VERSION=$(cat VERSION | tr -d '[:space:]') BASE_REF="${{ github.event.pull_request.base.ref }}" - git fetch origin "$BASE_REF" --quiet || true - BASE_VERSION=$(git show "origin/$BASE_REF:VERSION" 2>/dev/null | tr -d '[:space:]' || echo "0.0.0.0") + for try in 1 2 3; do + git fetch origin "$BASE_REF" --quiet && break + if [ "$try" = 3 ]; then echo "::error::git fetch origin $BASE_REF failed 3 times ($(git --version)). Fix: see docs/troubleshooting.md#history-read-failed (HISTORY_READ_FAILED)"; exit 1; fi + sleep $((try * 2)) + done + BASE_VERSION=$(bash .version-gate/.github/scripts/history-read.sh "origin/$BASE_REF" VERSION --absent 0.0.0.0 | tr -d '[:space:]') { echo "pr_version=$PR_VERSION" echo "base_version=$BASE_VERSION" diff --git a/.github/workflows/windows-free-tests.yml b/.github/workflows/windows-free-tests.yml index 6d19e5b48..33e896c42 100644 --- a/.github/workflows/windows-free-tests.yml +++ b/.github/workflows/windows-free-tests.yml @@ -19,6 +19,12 @@ name: Windows Free Tests # # Native browser qualification campaigns (Windows cookie extraction, launch # diagnostics) are dispatch-only in native-qualification.yml. +# +# One full suite per tree (GSTA-156, docs/pregate.md#ci-reuse), as in +# free-tests.yml: the aggregate uploads `tested-tree` only after strict +# verification passes, and a push to main whose merged pull request already +# ran this workflow green on the same tree reuses that run. Pull request, +# dispatch and duration-recording runs never consume. on: pull_request: @@ -50,8 +56,36 @@ permissions: contents: read jobs: + # Main pushes only, and the one job with actions: read; see free-tests.yml. + # A skipped or failed reuse job runs every job below as before. + reuse: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + actions: read + outputs: + reused: ${{ steps.find.outputs.reused }} + run_id: ${{ steps.find.outputs.run_id }} + run_url: ${{ steps.find.outputs.run_url }} + tree: ${{ steps.find.outputs.tree }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.4.2 + - id: find + name: Find a green pull request or dispatch run of this exact tree + env: + GH_TOKEN: ${{ github.token }} + run: bun run scripts/ci-tree-reuse.ts find --workflow windows-free-tests.yml --profile ci-reuse/windows-free-tests + windows-plan: - if: ${{ !inputs.record_durations }} + needs: reuse + if: ${{ !cancelled() && !inputs.record_durations && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }} runs-on: ubuntu-24.04 timeout-minutes: 5 outputs: @@ -79,7 +113,8 @@ jobs: if-no-files-found: error windows-free-shard: - needs: windows-plan + needs: [windows-plan, reuse] + if: ${{ !cancelled() && needs.windows-plan.result == 'success' && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }} runs-on: windows-latest timeout-minutes: 15 strategy: @@ -184,11 +219,33 @@ jobs: windows-free-tests: if: ${{ always() && !inputs.record_durations }} - needs: [windows-plan, windows-free-shard] + needs: [reuse, windows-plan, windows-free-shard] runs-on: ubuntu-24.04 timeout-minutes: 5 steps: + - name: Take the reuse path only for a verified main-push reuse + id: route + env: + REUSE_RESULT: ${{ needs.reuse.result }} + REUSED: ${{ needs.reuse.outputs.reused }} + REUSE_RUN_ID: ${{ needs.reuse.outputs.run_id }} + REUSE_RUN_URL: ${{ needs.reuse.outputs.run_url }} + REUSE_TREE: ${{ needs.reuse.outputs.tree }} + run: | + set -eu + if [ "$REUSE_RESULT" != success ] || [ "$REUSED" != true ]; then + echo 'reused=false' >> "$GITHUB_OUTPUT" + exit 0 + fi + test "$GITHUB_EVENT_NAME" = push + test "$GITHUB_REF" = refs/heads/main + case "$REUSE_RUN_ID" in ''|*[!0-9]*) echo "::error::the reuse job named no run id"; exit 1 ;; esac + case "$REUSE_TREE" in ''|*[!0-9a-f]*) echo "::error::the reuse job named no tree"; exit 1 ;; esac + test "$REUSE_RUN_URL" = "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$REUSE_RUN_ID" + echo "Reused $REUSE_RUN_URL, which passed on tree $REUSE_TREE; this push ran no suite of its own." >> "$GITHUB_STEP_SUMMARY" + echo 'reused=true' >> "$GITHUB_OUTPUT" - name: Require the plan and every Windows shard + if: steps.route.outputs.reused != 'true' env: PLAN_RESULT: ${{ needs.windows-plan.result }} SHARD_RESULT: ${{ needs.windows-free-shard.result }} @@ -197,22 +254,40 @@ jobs: test "$PLAN_RESULT" = success test "$SHARD_RESULT" = success - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + if: steps.route.outputs.reused != 'true' with: persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + if: steps.route.outputs.reused != 'true' with: bun-version: 1.4.2 - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 + if: steps.route.outputs.reused != 'true' with: name: windows-plan path: ${{ runner.temp }} - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 + if: steps.route.outputs.reused != 'true' with: pattern: windows-result-* merge-multiple: true path: ${{ runner.temp }}/windows-results - name: Require exact coverage and complete strict results + if: steps.route.outputs.reused != 'true' run: bun run scripts/test-free-shards.ts --windows-only --ci-verify "$RUNNER_TEMP/windows-plan.json" --results "$RUNNER_TEMP/windows-results" + # Only after strict verification passed; the reuse path never reaches here. + - name: Record the tested tree + if: steps.route.outputs.reused != 'true' + env: + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: bun run scripts/ci-tree-reuse.ts record --plan "$RUNNER_TEMP/windows-plan.json" --results "$RUNNER_TEMP/windows-results" --os Windows --out "$RUNNER_TEMP/tested-tree/tree.json" + - name: Upload the tested tree + if: steps.route.outputs.reused != 'true' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: tested-tree + path: ${{ runner.temp }}/tested-tree/tree.json + if-no-files-found: error windows-record-durations: if: ${{ inputs.record_durations }} diff --git a/.gitignore b/.gitignore index 6a3ae8915..b042b9ded 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ .env node_modules/ +node_modules.gstack-migrate/ +node_modules.gstack-migrate.lock/ dist/ browse/dist/ design/dist/ @@ -40,6 +42,7 @@ lib/cso/images/gstack-cso-preparation .cursor/ .copilot/ .agy/ +.gemini-cli/ .openclaw/ .hermes/ .gbrain/ diff --git a/.gstack/pregate.json b/.gstack/pregate.json index 0454e74f3..c424957cc 100644 --- a/.gstack/pregate.json +++ b/.gstack/pregate.json @@ -19,6 +19,10 @@ "free-tests.yml/free-suite": "bun run scripts/test-free-shards.ts --list", "windows-free-tests.yml/windows-free-shard": "bun run scripts/test-free-shards.ts --windows-only --list" }, + "run_profiles": { + "ci-reuse/free-tests": { "workflow": "free-tests.yml", "events": ["pull_request", "workflow_dispatch"], "jobs": ["free-plan", "typecheck", "quick-bun-default", "cso-macos-launcher", "macos-named-regressions", "cso-windows-launcher", "cso-docker-integration", "free-tests"], "artifact": "tested-tree", "os": "Linux" }, + "ci-reuse/windows-free-tests": { "workflow": "windows-free-tests.yml", "events": ["pull_request", "workflow_dispatch"], "jobs": ["windows-plan", "windows-free-tests"], "artifact": "tested-tree", "os": "Windows" } + }, "warn": [], "strays": { "allow": ["modules/loadouts.json", "modules/loadouts.schema.json"] } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 19e50d361..c44245e20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,265 @@ # Changelog +## [1.92.42.0] - 2026-11-01 + +**A /ship review pass that only left notes now binds at Step 11.5.** A pass whose findings were all fixed, deferred or noted has no open defect, but a driver could still log it as `issues_found`. Step 11.5 then refused it, and the only way out was an extra rebind pass (CAPY-2, qm#2837). When the tree changes during a pass, the refusal now names the start and end trees and the cheapest repair (GST-430). + +**Receipt carry and write handle results PRs with thousands of files (GST-448).** A PR that adds about 8,300 run receipts (harness-evals#147) refused every carry with `git could not compute HEAD's product patch-id: git diff … failed`, and every base move cost it about 3 hours of /ship. The name-only listing ran past a 1 MB output buffer. + +### Fixed +- **`gstack-ship-receipt carry` and `write`** read git's path listings with a 256 MB buffer instead of the 1 MB default, so a large PR's `product_patch_id` is recorded and carried. The product patch-id streams `git diff` through a file under the git directory (`.git/gstack-patch-id`) into `git patch-id --verbatim` and no longer holds the diff in memory; a directory a killed run left is removed after an hour. The per-path walk compares only touched paths that changed at either head or either base, so a 6,000-path PR refuses or carries in seconds. +- **/ship Step 1's carry check takes the scoped-review route (GST-442).** When the base edited context next to a reviewed hunk, `gstack-ship-preflight` now prints `NEXT: carry route`: fix what the base merge broke, commit and push, write the packet, one fix-check, then `carry --scoped-review`, instead of running the full loop. `carry --dry-run` writes no packet and offers this route only when none of carry's other checks would refuse. +- **Step 16 names finish's scoped-review STOP and the `--docs-requested` fix (GST-444).** Stage 2 sends a STOP that names a scoped-review packet to its fix-check and `--context-reviewed` rerun, and stage 3 offers a fix the audit's result named before a re-audit or a named risk. The RECEIPT_CARRY_PRODUCT_CHANGED fix text points to the scoped-review refusal. Trims in Steps 3, 12 and 16 keep ship/SKILL.md under its cap. +- **`gstack-tree-receipt`** fails closed (`EVIDENCE_IDENTITY_UNKNOWN`) when git cannot produce the diff between the gated and current heads, and names git's reason. Before, it read a failed diff as no changes. +- **`gstack-review-log`** records a completed `review` or `review-fix-check` row claimed as `issues_found` as `clean` with zero counts when every finding's logger-stamped action is `fixed` or `auto-fixed`, or `deferred` or `noted` on a finding marked `blocking: false` that is not CRITICAL, and the findings account for the claimed issue and critical counts. A skipped finding, a gate finding left open, a CRITICAL or blocking note, a finding with no action, or an incomplete pass keeps the claimed status. +- **Findings files** (`--findings`) count `auto-fixed` as resolved, the same as `fixed`, and `deferred` or `noted` only below the floor. Their gate rows are stamped like inline ones, so an unattended run's gate stays open whatever its status or disposition says. +- **`review_freshness`** reads a claimed clean row as UNVERIFIED when a finding is a gate left `open`, a CRITICAL or blocking deferral or note, or any other unresolved CRITICAL finding. +- **`review_freshness`** for a pass whose tree moved now reads `content changed during review: the tree moved from to while ran …`, followed by the repair: keep the checkout frozen until every reviewer returns, then rerun on the frozen tree (docs/troubleshooting.md#review-content-changed). + +### Tests +- `test/ship-rereview-triggers.test.ts`: preflight on a founderstar-style adjacent edit prints the scoped-review `NEXT: carry route`, and its dry run writes no packet. `test/ship-control-flow.test.ts`: Step 1.4 and Step 16 stages 2 and 3 name the routes. +- `test/ship-receipt-carry-large-diff.test.ts`: a PR that adds 6,000 receipts under a long directory name writes a receipt with its product patch-id and carries across a base merge. Negative control: one rescored receipt still refuses with `RECEIPT_CARRY_PRODUCT_CHANGED`. +- `test/review-notes-only-pass.test.ts`: a pass with three notes is recorded clean and reads CURRENT. Negative controls: a skipped finding, an open gate finding (also under a caller's resolved status), a CRITICAL or blocking note, a note with no `blocking` field, counts the findings do not cover, a finding with no action, and an incomplete pass keep `issues_found`. A claimed clean row with an open gate, a CRITICAL deferral, or a skipped or action-less CRITICAL finding is not CURRENT, while kept and restored gates, skipped advisories and an INFORMATIONAL deferral stay CURRENT. A `--findings` gate row stays open in an unattended run. Also checks the reason a pass gets when its tree moved. +- `test/ship-receipt-carry-large-diff.test.ts` also: the patch-id file lives under the git directory (an unwritable TMPDIR still carries; an unwritable spot refuses by name), and a stale directory is swept. `test/train-gate.test.ts`: a diff git cannot produce refuses the tree receipt with git's reason. `test/ship-rereview-triggers.test.ts`: a dry run the receipt would refuse anyway names that refusal. + +## [1.92.41.0] - 2026-11-01 + +**The Ubicloud runner works on macOS and on a project with no VMs (#3680, #3679, #3678).** On a Mac, `gstack-ubi pack` failed, so every `sync`, `run` and `verify` broke after the VM was already created, and `gstack-ubi usage` was a syntax error that also switched off the vCPU quota check. On any machine, an empty Ubicloud project made `reap` and every `up` die. All three were reported by Jared. + +### Fixed +- **`pack` on macOS.** macOS's tar read pack's options as file names and shipped an archive holding only `.git`. Pack now builds the archive in two steps that work with both macOS and GNU tar, and keeps macOS tar from adding hidden `._*` copies of files that carry extended attributes. (#3680) +- **`usage` and the quota check on macOS.** One line of the usage report was a syntax error for macOS's awk. `usage` now also exits 1 instead of printing a partial count when Ubicloud can't list the VMs or read a size, and skips a VM destroyed between the two reads. When usage can't be read, the quota check retries while `UBI_QUOTA_WAIT` has time left, then says so in the log and leaves the decision to Ubicloud. (#3679) +- **`reap` and `up` on an empty project.** An empty VM list was read as one VM named `-`, so they died with `invalid VM name '-'`. (#3678) + +## [1.92.40.0] - 2026-11-01 + +**A subagent's /ship cost now says it is an upper bound.** A Capy subagent cannot read its own jam counter, so its /ship read `"this"`, the root thread, whose counter also holds every sibling lane. BUDGET_FINAL and the notes printed that total as if it were the run's own cost. + +### Added +- **`gstack-ship-budget start --meter-scope self|tree`.** A run started with `tree`, or with `--subagent` (or `GSTACK_SHIP_SUBAGENT=1`) and `--reading-from this`, is tree-scoped: every BUDGET, NOTE, BUDGET_FINAL and BUDGET_TIMELINE line it prints is followed by `METER: tree (root counter, upper bound)`. +- **`METER_SCOPE_PARENT`.** A subagent reading `"this"` that asks for `--meter-scope self` is refused with exit 2 and the fix `--meter-scope tree` (docs/troubleshooting.md#meter-scope-parent). + +### Changed +- The Capy skill and docs/ship-budget.md tell a subagent to start with `--subagent --reading-from this --meter-scope tree`, and say exact per-run cost comes from a root replay thread. The driver's Step 22 carries the `METER: tree` line into the report. + +### Tests +- `test/ship-budget-meter-scope.test.ts`: tree runs label every cost line, self runs print none, the refusal and its troubleshooting anchor, and the docs pointers. + +## [1.92.39.0] - 2026-11-01 + +**A push to main no longer runs Free Tests and Windows Free Tests again on a tree its pull request already passed.** 22 of 22 merges in 30 hours (Oct 27–29) reran both suites on the identical tree, about 66 Linux shard-minutes and 44 Windows minutes each. Now a `reuse` job, on main pushes only, finds the merged pull request, requires its head's tree to equal the pushed tree, and verifies that head's newest green `pull_request` or `workflow_dispatch` run with the same checks `gstack-pregate clear --run` uses. When it verifies, every suite job is skipped and the required check links the reused run. Anything else runs every job as before. The same verifier backs a new `--from-run` flag for the pregate tests stage. + +### Added +- **Main-push reuse** in `free-tests.yml` and `windows-free-tests.yml` (`scripts/ci-tree-reuse.ts`, docs/pregate.md#ci-reuse). The `reuse` job is the only one with `actions: read`, and it reads at most 10 pull requests and 10 candidate runs. A newer red run on the head vetoes an older green one. A run that reused another run never counts. Pull request and dispatch runs never consume, and a skipped or failed `reuse` job runs every job exactly as before. The required `free-tests` and `windows-free-tests` aggregates pick their path before any download, and the strict path is unchanged. +- **`tested-tree` records.** Both aggregates upload `tree.json` only after strict verification passes: run, attempt, event, sha, head, tree, workflow, OS list, every planned test file and the test count. +- **`gstack-pregate --stage tests --from-run `** (docs/pregate.md#from-run). The selected local test files a verified Free Tests run on this exact tree lists count as passed and are recorded under `from_run` with the run receipt, and the rest still run. A refused run prints `REUSE: refused reason=` and runs everything. It changes only the tests stage; `--stage all` still runs every preflight check, the secrets scan included. +- **gstack's run profiles** in `.gstack/pregate.json`: `ci-reuse/free-tests` (the complete Free Tests run) and `ci-reuse/windows-free-tests`. Neither is named after a lane, so macOS and Windows lane clears and `gstack-ship-queue ready --dispatch-ci` keep recording the run URL. Lane profiles and dispatch-ci verification wait until open pull requests carry the `tested-tree` producer (GST-555). + +### Changed +- **Run profiles may accept `pull_request` runs.** Such a run counts only when its `tree.json` names the head and the merge commit, and the merge commit's tree equals the head's tree, which means the head already held its base. `tree.json` may list every OS its test jobs ran on, and the profile's OS must be one of them. +- **`free-durations-record.yml` grants its Windows caller job `contents: read` and `actions: read`.** That job calls `windows-free-tests.yml`, whose new `reuse` job asks for `actions: read`, and a called workflow can't hold more than its caller grants. Without the grant, GitHub rejects the scheduled duration run before it starts. +- **A run whose `tree.json` lists no test files or zero tests never counts** for `--from-run` or main-push reuse (`no_tests`). + +### Tests +- `test/ci-tree-reuse.test.ts`: candidate discovery with fake GitHub data (pull request found or missing, unmerged, fork head, tree mismatch, reused record, newer red run veto, dispatch and pull_request candidates, the 10-run bound, API errors), the `tree.json` producer, and the CLI refusing every event but a main push. +- `test/ci-reuse-workflow-wiring.test.ts`: both workflows. It covers the push-only reuse job and its permissions, each suite job's condition evaluated for a skipped, failed and successful reuse, and status functions on every downstream job. It also covers pregate still requiring every test lane, the aggregate's route step run under bash for each input, and the profiles naming real jobs and never a lane. +- `test/cso-distribution.test.ts`: the `free-tests` aggregate still requires `cso-docker-integration` on its strict path, and only a verified reuse skips that job. +- `test/pregate-from-run.test.ts` failed on the old code. `test/run-evidence.test.ts` adds the `pull_request` and OS-list cases and `verifyFromRun`, including the zero-test refusal, which failed on the old code. +- `test/free-durations-record-workflow.test.ts` pins the Windows caller grant, and `test/ci-reuse-workflow-wiring.test.ts` requires every caller of either workflow to grant `actions: read`; it failed without the grant. + +## [1.92.38.0] - 2026-11-01 + +**brain-sync no longer gets stuck in `push_failed` when another machine changed a file you have edited locally.** After a rejected push, brain-sync fetches and merges the other machine's commits. Git refuses that merge when a file it would update has uncommitted local edits. brain-sync hid git's error and hit the same refusal on every run, so cross-machine sync stopped for good (#3704). + +### Fixed +- **The retry merge sets aside local edits to the files it changes and puts them back** (`bin/gstack-brain-sync`). Other local edits stay where they are, file names are never read as patterns, and only the stash entry this run made is put back. A failed merge is aborted, so it never leaves conflict markers or a half-finished merge in `~/.gstack`. +- **When a local edit conflicts with the other machine's change, the other machine's version stays.** The local edit is kept in a git stash. The status names the stash and the exact `git -C ~/.gstack --literal-pathspecs restore --source= -- ` command that brings it back. A later sync never pushes the old local copy over the other machine's change. Files with a merge driver (`*.jsonl`, the union-merged plans) merge as before. +- **`push_failed` now says why.** It carries git's merge reason (for example `CONFLICT (content): Merge conflict in developer-profile.json`) and the real push error line instead of git's `To ` banner. + +### Tests +- `test/brain-sync.test.ts`: a dirty file the remote didn't touch, a dirty file the remote also changed (including a second sync and the restore command), local-delete/remote-edit, local-edit/remote-delete, a conflicting file whose name looks like a pattern, a file that never checks out clean, a stash push that saves nothing, and a committed conflict. Five of the eight fail on main. + +## [1.92.37.0] - 2026-11-01 + +- **A Red Team finding that quotes `origin/` no longer stops `/ship`'s review** (`lib/review-brief.ts`, GST-797). The renderer checked each finished brief for unfilled slots like ``, `` and `` after the reviewers' findings and past learnings had been pasted in, so a finding that quoted one failed the render with `BRIEF_RENDER_FAILED: red-team: unfilled placeholder ` (seen on #3795). A quoted `` was also quietly swapped for the commit sha. The check now runs on the brief before that text goes in, and the quoted text reaches the reader exactly as written. A slot the section really leaves open still fails the render. A brief whose findings and learnings quote no slot name renders the same bytes as before. + +## [1.92.36.0] - 2026-11-01 + +**/ship's review collector keeps every severity label again, so a blocker named after "no CRITICAL issues except" is no longer lost.** v1.92.29.0 (GST-766) skipped any severity word that followed "no", "nothing", "none" and similar words, to stop a reviewer's closing line like "found nothing CRITICAL" from counting as a finding. That also threw away real blockers named in an exception clause: "No CRITICAL issues except the token leak in log.ts:3", "Nothing CRITICAL apart from the auth bypass (auth.ts:9)" and four more wordings collected nothing at all, so the gate never saw them. The collector now reads labels exactly as it did before v1.92.29.0. The reviewer's own Severity and Blocks fields are still recorded beside the label (`stated`), as in v1.92.29.0. + +### Known +- **A reviewer's closing summary collects at its label again.** Lines like "found nothing CRITICAL" or "Neither finding is CRITICAL." collect as CRITICAL, the same as before v1.92.29.0. They are known phantoms. Each stays CRITICAL in the judged set (lead judgment can fold it only into a finding at least as severe), so it counts toward what blocks. A lost blocker costs more than an extra one. + +### Tests +- `test/review-dispatch.test.ts`: the six exception-clause wordings collect CRITICAL for every reader. GST-766's test that a negated label collects nothing now checks that each of those lines keeps its label. GST-435's label-keeping probes (table rows, FIXABLE, negated subjects, closing summaries kept as known phantoms) are added with their HARN-15 and GEVA-77 fixtures under `test/fixtures/review-ledger/`. + +### Docs +- `docs/review-policy.md` (collect) says a negated severity word still collects at its label. + +## [1.92.35.0] - 2026-11-01 + +- **A `.gstack/pregate.json` that only raises `timeout_s` no longer changes lane verdicts, `warn: ["lanes"]` no longer lets a failing test pass, and a `[]` declaration no longer skips a deleted test's suite** (GST-775, from gbrain-evals #272 and GBEV-22). + - Before, any policy file, even `{"timeout_s": 1800}`, made a touched file with no test lane a hard `PREGATE_NO_LANE` that stopped before any test ran. Now only a policy that declares lanes (a `dependencies`, `empty_lanes`, `selection` or `local` key, even an empty one) does that. A policy with none of them keeps the no-policy verdict: `warn` `PREGATE_NO_POLICY` with a starter, the selected tests run, and a red test is still `PREGATE_LANE_FAILED`. A hand-written policy that sets only `runner`, `guards`, `strays`, `allowlist` or `remote_gate` moves from the hard failure to that warning; add `dependencies` to keep unmapped files failing. See docs/pregate.md#lane-requirements. + - `warn: ["lanes"]` used to downgrade the missing-lane failure before the tests ran, so a red test beside an unmapped file passed. Now the selected tests run first. A red lane is `fail`, an unfinished one `incomplete`, a test only a platform lane runs stays `requires-remote`, and only a green run becomes `warn` `PREGATE_NO_LANE` naming the unmapped files. + - A `[]` declaration that matches a deleted test (the `"**": []` catch-all) used to clear it before the removed-test handling, so its owning suite did not run. Now the deleted test still runs its owning suite, and a file naming it that a `[]` covers, such as a durations list, counts as covered. + +## [1.92.34.0] - 2026-11-01 + +- `gstack-ship-queue wave plan` no longer packs the whole READY queue into one area. Shared files now link two PRs into one group only when `git merge-tree` finds a real conflict between them in a code file or skill template. PRs of the same area still pack together as before. Docs, other Markdown and JSON ledgers never link PRs, since most PRs add to them. Before, any shared file linked two PRs, so `docs/troubleshooting.md` and a few ledgers chained 55 READY PRs into eight waves all labeled `setup`. With this change, the 57-PR queue later that day gives five groups split by area. A READY head whose CI is red or still running now prints `NOT_GREEN: # (...)` and stays out of the plan, as `ready` would refuse it. A head whose CI cannot be read fails the plan with `QUEUE_PROOF_MISSING`, the same as an unreadable PR. Renamed files are compared by their real paths (GST-770). + +## [1.92.33.0] - 2026-11-01 + +**Eleven flaky tests from the 2026-10-31 flaky pass stop failing correct runs, and six more now name their cause when they fail.** Three paid detectors missed correct replies, one product script copied a file git was rewriting, and the free-test reds came from a binary's slow first launch on a fresh Windows runner, a fixture written in two steps and a cleanup that raced the runner's own scanner. + +### Fixed +- **`scripts/ubicloud/ubi-runner.sh`**: `pack` copies the live `.git/index` with `cat` instead of `cp`. Git renames `index.lock` over the index, and GNU cp 9.4 then fails with "replaced while being copied" (Free Tests 41073582974). A loop that swaps the index during 300 packs failed 10 times before and 0 after. +- **Windows cold first launch** (`test/helpers/warm-first-exec.ts`): `test/land-and-deploy-flow.test.ts`, `test/skill-positional-literals.test.ts`, `test/team-init-hook-shell-neutral.test.ts` and `browse/test/cookie-import-node.test.ts` launch jq, awk or PowerShell once, unasserted, before the timed calls. On 12 fresh windows-latest VMs (run 41205346554) a first launch cost jq 60-250 ms, awk 190-290 ms and PowerShell 1.7-3.1 s more than a warm one, and in every red the file's first launch was the call that hit its kill (Windows Free Tests 41195098239, 41196095371, 41175329114, 41174352235, 41175308095, 41187420609, 41152107410). Every timed call keeps its kill. +- **`browse/test/terminal-agent-publication-lock.test.ts`**: the preload writes its `ready` file to a temporary name and renames it, so the kill hook never parses a half-written file (Free Tests 41156375412). +- **`test/qa-only-browser-probe.test.ts`**: on a Windows EBUSY the cleanup asks Restart Manager who holds browse.exe, waits up to 5 s only when the holder is outside the test (the runner's `provjobd.exe`), then removes once more; a test-host holder or a second failure throws with the owners named (Windows Free Tests 41068546881, 41172879765). +- **`test/strict-output-settlement.test.ts`**: the missing-close case gives its child a 2 s wall instead of 150 ms. It asserts no elapsed time, and a child on a loaded Windows runner took over 150 ms to exit (Windows Free Tests 41164892775). + +### Gate integrity +- **plan-design-with-ui-scope** (`test/skill-e2e-plan-design-with-ui.test.ts`): the focus question also counts when its "All 7" option carries a letter, as in "A) All 7 passes" (paid gates 41178131219 and 41186376825). Both questions are saved as fixtures; the phrase not leading the label, a 4/7 rating and a label without "focus" still count zero. +- **review-coverage-audit** (`test/helpers/coverage-audit-evidence.ts`): a grep display over two or more literal files after the reads no longer voids the whole call (gate 41149166998). An option, a substitution, a redirect or a tampered read still gets no credit, and a quoted file name with a space stays one operand. +- **tpa-absent** (`test/helpers/third-party-actions.ts`): the passive pitch's negation check covers only the clause that holds "can be downloaded", so "Aside is not installed but can be downloaded at aside.com" counts once; "cannot", "never", "not something that" and "is not available to download" still count zero. The clause scan is linear: 112 KB of model text takes about 0.2 s, not 7 s. + +### Changed +- Seven tests and helpers gain diagnostics so the next red names its cause. The workflow judge records response bytes and the time of the last byte, so a silent socket and a model thinking on a live connection look different (gate 41185637598); `test/gstack-ship-queue.test.ts` keeps the refusing call's stderr; the ship-budget harness names a call that ended without an exit code; `test/helpers/plan-review-board-feedback.ts`, `test/team-init-hook-shell-neutral.test.ts` and `test/ship-hook-windows-paths.test.ts` print each step's or spawn's time; the ios-qa loopback tests use a listener that drops every connection and a 10 s proxy timeout, so a stuck hop fails as a named 504 instead of a bare 30 s test timeout. + +## [1.92.32.0] - 2026-11-01 + +**Four flakes from the 2026-10-29 flaky pass stop failing correct runs.** Three free-tier tests failed on harness timing (a half-written marker file, a Windows kill that left a process tree behind, a hook kill set at 5 s), and the plan-devex-finding-floor paid case waited out 600 s after a provider error. Each repair has a free test that fails without it. + +### Fixed +- **`browse/test/xvfb.test.ts`**: the welcome marker is written with `atomicWriteSync`, so the test never parses a half-written file (Linux Free Tests 40791645916 and 40847105381: "JSON Parse error: Unexpected EOF"). +- **`test/ship-publication-gates.test.ts`**: each publication block runs under a 27 s case deadline, below bun's 30 s test timeout. A block still running at the deadline, or one whose background child still holds its output, is killed with its whole process tree before cleanup, and the error names the stage and what the earlier stages took. Four Windows runs (40803875149, 40820968738, 40823026209, 40826743317) hit bun's timeout, which killed only the bash child, so cleanup failed with EBUSY and hid the timeout. +- **`test/hook-scripts.test.ts`**: a hook that is killed or cannot spawn throws with its signal and elapsed time instead of reading as exit 1. Three Windows runs (40792942747, 40805828986, 40816343665) went red at 5.0 to 5.17 s, which was the old kill. +- **plan-devex-finding-floor** (`test/helpers/pty/screen.ts`, `test/helpers/pty/runners/floor.ts`): the floor runner stops at an idle plan-mode turn and reports a provider error ("API Error: Connection lost mid-response", paid run 40851840940) as `api_error`, instead of waiting out 600 s as `observer_timeout`. A question already on screen is assessed first, and the idle screen must hold still for 10 s, so a question is still credited even when its transcript line lands late. The plan-mode footer counts as idle only without its running "esc to interrupt" segment. + +### Changed +- `browse/test/cookie-import-native.test.ts`: when the real Playwright pipe smoke never reaches `cookie_read`, the failure names the worker's reply and Playwright's launch error. The same failure repeated on PR #3606's Free Tests run 40888372424 (shard 10). + +### Gate integrity +- `test/hook-scripts.test.ts`: the hook spawn kill moves from 5 s to 25 s (`HOOK_SPAWN_TIMEOUT_MS`), under bun's 30 s per-test timeout. Measured from the shard logs of 77 Windows Free Tests runs on 2026-10-29 (40811056776 through 40888378463): 10,608 passing check-careful tests, p95 0.98 s, p99.9 1.85 s, max 4.22 s; the file's first hook test p50 1.54 s, p95 2.14 s, max 4.22 s. The old 5 s kill sat 0.8 s above the slowest pass, and the one red in that set (40816343665) stopped at 5.00 s, the kill itself. 25 s is about six times the slowest pass and stays under bun's 30 s per-test timeout, so a stalled hook is reported by the harness with its signal and elapsed time instead of by bun's generic timeout. The kill lives only in the test (no product code reads it), no test asserts a duration, and the file still runs 266 tests at base and head. A hook slowed into the 5 to 25 s range no longer fails this file. +- `scripts/harness-version.json`: `test/helpers/pty/runners/floor.ts` and `test/helpers/pty/screen.ts` are recorded as non-behavioral at HARNESS_VERSION 6, so no pass-rate series resets. The floor runner ends on `api_error` only after the idle screen held still for 10 s with no question to credit, and an idle turn with no question stays idle. Such a trial failed before as `observer_timeout` at the deadline and fails now, earlier, as `api_error` or with the idle-turn summary. A running footer still waits for the deadline. The session ledger can now record `failure_cause` `api_error` or `refusal` for such a session, while `failure_class` and pass rates stay the same. +- `test/ship-publication-gates.test.ts`: the per-block 30 s spawn timeout becomes one 27 s deadline for the whole case, setup included, with a process-tree kill. + +### Tests +- Unit cases for the idle plan-mode turn end with negative controls (a running footer, extra footer text, a cut footer), a `planModeApiErrorIdle` capture in `test/fixtures/pty-idle-turn-end.json`, a POSIX case where a prose finding question printed before the error panel is still credited, also when its transcript line lands after the screen goes idle (it failed as `api_error` before the reorder), and a POSIX case where a block's background child holds its output past the deadline (it hung 30 s before the fix). + +## [1.92.31.0] - 2026-11-01 + +**Nine paid ship rule-case flakes from the 2026-10-29 window stop failing correct runs.** Six qa-callers trials and five ship-docsync trials failed on calls the declared interface could not tell apart from a violation, or on a turn budget the actor spent out of order. Each was replayed from its retained capture, and each repair has a free test that fails without it. + +### Fixed +- **qa-callers fixture prompt** (`test/helpers/qa-callers-fixture.ts`): the parent is told that a child starts in the product directory, to write each command exactly as it must run (no "in " paraphrase, `cd` or `git -C`) and to name each file the child reads as one literal path. Six trials across late-input, plan-checks and small-cli had a child run `git -C diff` or `cd` into the checklist directory after a paraphrased prompt. The ship caller's scheduling contract now lists Step 9's review-plan command and gate-scan block with the first diff discovery: three late-input trials hit 25 of 25 turns after running the gate scan only after the receipt, or never. The detector is unchanged. +- **ship-docsync detectors** (`test/helpers/docsync-observer.ts`): a declared read command given `--help` counts as a read that reads nothing, with its operands still checked (current, completion and store trials joined `cat --help`). The wrong-path exit-127 excuse covers the docs cap gate (`gstack-ship-receipt docs-caps`) when the declared path then runs (stale-after). A line whose first unpiped `cat` printed the whole phase file still counts as reading it when a later separator failed under zsh (failure). +- **ship-docsync-completion** (`test/helpers/docsync-contract.ts`): only the accepted docs attempt is held to the strict completion contract; an attempt the parent rejected and re-audited reports only its id, status and edited files. + +### Gate integrity +- `test/helpers/docsync-observer.ts`: three acceptances widen, each pinned to its captured run with negative controls (redirects, pipes into the read, chained mutations, `cd`, wrong subcommands, a binary present at the wrong path, a truncated or errored read). +- `test/helpers/docsync-contract.ts` and `test/skill-e2e-ship-docsync.test.ts`: an earlier rejected docs attempt is no longer parsed with the accepted attempt's strict contract; a malformed earlier report still throws, and the accepted attempt stays strict. + +## [1.92.30.0] - 2026-11-01 + +**The free suite's shards finish closer together: slow test files are split and the packer plans from fresh timings.** The committed duration seed was missing more than a hundred free test files, so CI guessed the same time for every shard and the slowest shard ran far past the mean. No product code changed. Every split file runs the same tests as before. + +### Changed +- **Split free test files.** Each of these went over the 60 s seed ceiling and is now two to five files, with test bodies moved unchanged and shared setup moved into `test/helpers/`: `test/host-renders-prune.test.ts`, `test/ship-queue-train.test.ts`, `test/claude-overlay-setup-lifecycle.test.ts`, `test/claude-overlay-setup-installs.test.ts`, `test/setup-codex-scope-namespaces.test.ts`, `test/setup-codex-scope-review-boundaries.test.ts`, `test/docsync-fault-interface.test.ts`, `test/shared-libs-checker-interface-evidence.test.ts` and `test/ubicloud-runner.test.ts`. The last one was 127 s and is now five files of 16 to 31 s, with its fake Ubicloud API and fake VM in `test/helpers/ubi-runner-fake-vm.ts`. +- **Windows lane.** `test/gstack-doctor.test.ts`, the Windows lane's longest file, is now three files. +- **Duration seeds.** `scripts/free-test-durations.json` (1,470 Linux files) and `scripts/free-test-durations-windows.json` (822 curated Windows files) were re-recorded at this branch by `free-durations-record.yml` run 41229924108. Linux files over 5 s recorded a median 1.26x slower than this branch's earlier recording on 2026-10-29 (run 40818261447). A second recording, run 41226501374, matched that at 1.29x, so the slowdown is the runner's current speed, not one noisy run. + +### Gate integrity +- `scripts/free-test-seed-allowlist.json` is unchanged from main. `test/setup-codex-scope.test.ts` stays on it at 77 s. No entry was added. +- `scripts/lib/windows-curation.ts` gives each new part the same Windows scope as the file it came from. `test/ubicloud-runner-tiers.test.ts` gets an explicit exclusion, because its shims now live in a helper the source scan doesn't read. +- `test/test-of-test-ratchet.test.ts` lists the split files whose product reach now lives in a shared helper, each with its reason. `scripts/typecheck-test-baseline.json` moves one known diagnostic, with its code, into `test/helpers/shared-libs-checker-evidence.ts`. + +## [1.92.29.0] - 2026-11-01 + +- `/ship`'s review collector no longer turns a severity word in a negation into a finding. A fix-check reader that closed with "found nothing CRITICAL" was recorded as a CRITICAL finding with no file or line, and the judge can't drop a finding, so the run carried a phantom blocker that could stop the review loop at its cap (GST-766, from GEVA-92 on gstack-evals #108). A label right after "no", "nothing", "not", "none", "zero", "without" or "never" (optionally followed by a word like "new" or "remaining", and through Markdown emphasis or brackets), and a label joined to it by a comma, a slash, "or", "nor" or "and", now counts as no finding. A label that opens a field ("High: …") is still collected, and a prose finding worded around a negated label still arrives through its Severity or Blocks field. +- Each finding `/ship` collects now keeps the severity and Blocks its reviewer wrote (`stated` in merged.jsonl), and a deferred-review issue for a finding that carries them shows them next to the collected label, as in "CRITICAL (the reviewer wrote Severity: Low, Blocks: no)". Step 9.4 doesn't copy `stated` into its findings file yet, because that instruction lives in a reviewer brief that can change only after a no-loss replay. HARN-1 and GSTA-135 saw FIXABLE findings collected as CRITICAL while the reviewer's text said "Severity: Low. Blocks: no.", and nothing in the run showed the disagreement. What blocks a review pass is unchanged. + +## [1.92.28.0] - 2026-11-01 + +**A PR reviewed before #3654 keeps its /ship receipt after it merges main and moves its entry into `changelog.d/` (GST-343, #3606).** #3656 taught carry that a fragment is release metadata, but the base merge also brought in `changelog.d/README.md`. Carry counted that README as a product change, dropped the fragment rule for the whole range, and refused with RECEIPT_CARRY_PRODUCT_CHANGED although the product patch-id was unchanged. + +### Fixed +- **Only the branch's own changes turn release-metadata paths back into product** (`bin/gstack-ship-receipt`). A metadata path whose change is not a stamp still counts as product when the branch changed it against the base. A change a base merge brought in no longer does, because its patch against the base is the same at both heads. A README edit the PR makes itself is still refused. Replayed on #3606 (49666bc8 to e28bb62): carry now reports "base merge + release metadata". + +### Tests +- `test/gstack-ship-receipt.test.ts`: the fragment carry case now adds `changelog.d/README.md` on main together with the policy, the way #3654 did. It failed before this fix with RECEIPT_CARRY_PRODUCT_CHANGED. + +## [1.92.27.0] - 2026-11-01 + +**/ship on Capy no longer spends calls reading its review sections.** In the GSTA-193 replay of PR #34 (C1-34), 13 of 42 parent calls only read part of a section (review-army.md, the core checklist, Steps 8.1-8.2, adversarial.md, documentation.md) or ran a step that belonged in the next program. Each phase program now ends by returning the next phase's sections, unchanged and split at headings, in the space its own 32 KB return has left. What reviewers see, which reviewers run, their depth and the blocking floor are unchanged. + +### Added +- **`gstack-ship-parts next --run --phase --room `** returns the section parts the next phase uses, in run order, each byte once per run. A part due before that phase that did not fit is named, with `NEXT: parts` and the one call that fetches it. review-army.md, adversarial.md, documentation.md and review/checklist.md are covered whole, and Steps 8.1-8.2 of plan-completion.md exactly. A renamed split heading stops the run with the heading and file named. +- **The Capy pass program** adds those parts to each poll and finished-pass return once Phase 2 has started them, so Phase 4's judgment and Step 11's synthesis need no read call of their own. + +### Fixed +- **Step 14.5 runs through the pass program.** `gstack-review-dispatch pass --audit-only` makes the docs reader the whole pass, with no reviewer plan, and the program uses it when `step` is `14.5`. Before, the driver named a one-reader program the bin could not build (GSTA-196 replay of PR #36). +- **Decision 3.5's fix-check runs through the pass program.** `gstack-review-dispatch pass` now builds a fix-check plan, rendering the reader from `--fix-list`, `--hunks`, `--tests` and `--callers`, byte for byte the brief `render --role fix-check` writes. The program takes them as `fixCheck` and runs the plan's voices in the same call. Before, the parent walked the fix-check by hand. +- **Step 11's budget note counts only Step 11.** The voices stage keeps its own starting reading (`mark --before … --voices`). Before, it reused the pass's reading, so the GSTA-196 replay's Step 11 note showed 16.64M tokens against 4.17M of real growth. +- **`gstack-ship-budget calls --events ~/…` finds the file.** A leading `~` or `~/` now means the home directory. Before, a literal `~` from a transcript path failed the read, so the run reported no `CALLS:` line (gstack-evals#94, GST-438). +- **A repeat pass reads the range, as the sections say.** The pass program takes `diffBase` and passes it as `--diff-base`; the driver and the Capy loader set it to RANGE_BASE on pass 2 and later, so specialists, the Red Team and Step 11's voices get `DIFF_BASE=` byte for byte as `render` writes it. The targeted reader keeps the merge base. Before, every pass read the whole branch. The GSTA-197 replay's pass 2 re-sent pass 1's brief and its readers used 10.42M tokens against pass 1's 4.68M. +- **Step 11 records a start for each row it logs.** Two `logStart` entries for one skill (the native and outside `adversarial-review` rows) now each take their own `gstack-review-log --start` token. Before, the second was dropped as a repeat. +- **The pass finds its fork point against origin/, as preflight does.** With no local `main`, or one behind origin/main, `gstack-review-dispatch pass` used to stop on "no merge base" or review from the stale branch. It now tries `origin/` first, the way `render` and preflight already do (GSTA-198 replay). +- **A section return that never reached the parent can be fetched again.** `gstack-ship-parts next` now marks what it returns as sent, the next call promotes it, and `next --resend` (the pass program's `resendParts`) sends it again. Its `PARTS:` and `NEXT: parts` lines count against `--room`, the run directory in the `NEXT: parts` command is shell-quoted, a corrupt `state.json` stops with its path, and Step 11's sections are due by Phase 4, where Step 11 runs. The pass program keeps its last `NEXT:` line when its return is capped and prints `PARTS: skipped (…)` when it finds no parts state. Before, a lost or capped return dropped section text or the next step without a trace (PR #3670 review). +- **A scoped repeat pass leaves its fix-check to Step 3.5.** After main's GST-619, a scoped pass stages its readers on the fix range and names its fix-check outside the manifest; the pass program takes `fixCheck` files only on a `fix-check` step. The driver and the Capy skill pass `diffBase` on a full repeat pass only, so a scoped pass's pending Step 11 voices keep the merge base and only its Red Team reads the fix range. + +## [1.92.26.0] - 2026-11-01 + +**/retro shows which skills fought you this week: a "Friction by skill" row lists the top three skills by questions asked, errors and aborts in this project.** Before, /retro counted skill runs but said nothing about which skills caused back-and-forth, so a skill that kept stopping to ask questions or kept failing never showed up in the report you use to decide what to fix next. + +### Added +- **`gstack-retro-metrics` prints `FRICTION:` lines** (top three skills, with runs, questions, typed answers, errors and aborts) plus a `FRICTION_SOURCES:` line, computed by `lib/retro-friction.ts` for the exact retro window. Runs and outcomes come from this project's `timeline.jsonl`, which is written on every run whether or not telemetry is on, unattended sessions included. Questions come from this project's question log when question tuning is on; when it is off, the row says questions are not tracked instead of showing zero. Only outcomes actually recorded as error or abort count, machine-made answers are not counted as questions, and questions the Claude Code hook logs without knowing which skill asked are left out, so no question counts twice and no `/unknown` row appears. Another project's logs never count. If Bun is missing or the helper fails, the script prints `FRICTION: unavailable` and the rest of the metrics are unchanged. +- **/retro shows a "Friction by skill" row** when any skill had friction in the window, for example `/ship 7 in 12 runs (4 questions, 3 aborts)`. + +### Changed +- **/retro's optional-input notes are shorter**: each one says "Read the path it prints" instead of repeating the path, which keeps /retro's always-loaded prompt under its size cap with the new row. + +### Tests +- `test/retro-friction.test.ts`: window edges, explicit outcomes only (the stop hook's `unknown` completions are ignored), hook rows taking precedence over agent rows, hook rows with no skill left out, repeated tool calls, machine answers, unsafe skill names, malformed lines, ties and the top three. +- `test/gstack-retro-metrics.test.ts`: a project with both logs plus another project's timeline (not counted), an empty prior window, and a failing `bun` that still ends with `RETRO_METRICS_END: ok`. + +Thanks to @thesohamdatta for the idea in #3417. This is a new implementation; no code from that PR was used. + +## [1.92.25.0] - 2026-11-01 + +- In a repository that merges the base during `/ship` (any `queue_mode` but `stamp-at-merge`), Step 3 now stops by name when an earlier merge, cherry-pick or revert is still unfinished (`STOP: an unfinished cherry-pick is in progress (CHERRY_PICK_HEAD): …`), with any conflicted files listed, and runs no `git merge`. Conflicted files left with no operation in progress, as after a conflicted `git stash pop`, stop with those files named instead of a merge message. Before, both fell through to `git merge`, so on a machine whose shared-tree guard refuses `git merge`, the guard's refusal was the only message (GST-732). A branch or tag named `MERGE_HEAD`, `CHERRY_PICK_HEAD` or `REVERT_HEAD` is not mistaken for one. The ship skill's Step 3 now names the `MERGE: up to date` outcome. + +## [1.92.24.0] - 2026-11-01 + +- `/ship` can finish after the owner accepts a Step 14.5 documentation risk. `gstack-docs-candidate snapshot --exception .gstack/tmp/docs-audit.json` writes the post-audit snapshot from the blocked record, naming who accepted it, the date, how and the base SHA, and `gstack-ship-release finish` prints that exception on its `DOCS:` line. Finish still stops on a blocked audit with no accepted exception, and on an exception snapshot whose docs-audit record no longer carries it (GST-713). + +## [1.92.23.0] - 2026-11-01 + +- **OpenClaw and Hermes: opt-in start hook.** `gstack-harness register openclaw|hermes --start-hook` (or `gstack-config set harness_start_hook on`, or `GSTACK_HARNESS_START_HOOK=1`) adds a `gstack-start` plugin that puts a one-line note on every user turn telling the agent to run a gstack skill's start command as written. Agents in these harnesses read gstack's skills but ran that command on 1 of 29 chances. gstack turns the plugin on through each harness's own CLI, checks that it is active, and exits 4 when it is not. It is off by default, the choice is saved across upgrades, a plugin you disable in the harness stays off, and `--no-start-hook`, unregister and uninstall take it out. See docs/hosts.md#start-hook. + +## [1.92.22.0] - 2026-11-01 + +**gstack installs into Gemini CLI, and installs made before v1.91.231.0 finally drop the test and eval packages they never needed.** `./setup --host gemini` puts every gstack skill in `~/.gemini/skills`, where Gemini CLI 0.66.0 lists them (#1247). Separately, `./setup` used to treat any checkout that had TypeScript installed as a contributor's, so every install made before v1.91.231.0 (which ran a full `bun install`) kept about 510 MB of dev packages in `node_modules` forever (#3447). + +### Added +- **Gemini CLI host** (experimental): `./setup --host gemini` (alias `gemini-cli`; `--host auto` picks it only when `gemini` is on PATH) links `gstack-` directories into `~/.gemini/skills` plus the `~/.gemini/skills/gstack` runtime root. The render goes to `.gemini-cli/` with frontmatter names that match their directories, `CLAUDE.md` read as `GEMINI.md`, and a tool glossary for Gemini CLI's tool names and for Antigravity CLI, which can read the same folder. Ask for a skill by name (`gstack-office-hours`); Gemini CLI loads it with `activate_skill`. `gstack-uninstall` removes only gstack-managed entries there, so your own skills stay. `--local --host gemini` is refused with a note that Gemini CLI loads workspace skills only in trusted folders. docs/ADDING_A_HOST.md lists the verified facts. +- **An explicit contributor marker**: `bin/dev-setup` runs `git config gstack.contributor true`, and `GSTACK_CONTRIBUTOR=1` marks a single run. A marked clone keeps the full install on every `./setup`. + +### Changed +- **An unmarked clone that still has the dev toolchain moves to production dependencies once.** setup takes a lock in the checkout, moves `node_modules` aside, installs production dependencies, checks every runtime package resolves, then deletes the old copy. If the install fails, the full `node_modules` goes back and setup prints `SETUP_DEPS_MIGRATE_FAILED` with the cause; nothing is removed and the next `./setup` tries again. CI, and a checkout whose git config cannot be read, never migrate. On a real install `node_modules` went from 1079 MB to 569 MB. Bun's shared package cache is left alone, because other projects can read it through symlinks. + +### Tests +- test/setup-production-deps.test.ts: the marker (git config, env, CI, unreadable git config), the one-time migration and its no-op rerun, restore on a failed or unverified install, recovery of an interrupted move, the lock, an untouched Bun cache, and a real-Bun fixture for a marked and an unmarked clone. test/uninstall.test.ts: Gemini CLI entries removed, the user's own kept. Host config, conformance, install-registry, render-prune and runtime-root tests cover the new host. + +## [1.92.21.0] - 2026-11-01 + +**The PR and train path check out blobless clones, and the history reads they gate on fail loudly instead of reading a failed fetch as "absent" (GSTA-203 PR 5).** Free Tests' plan job, the Version Gate and E2E Evals' route, screen summary, planner, slices, reruns, bisect and report checked out every file of every commit (about 232 MB) to read a handful of old files. They now check out with `filter: blob:none` (about 17 MB), and git fetches an old file's contents the first time something reads it. + +### Changed +- **Blobless checkouts** (`filter: blob:none`) in `free-tests.yml` (free-plan), `version-gate.yml` and `evals.yml` (route, screen-summary, plan-slices, eval-slices, eval-rerun, train-bisect, slices-report). The release, CSO, periodic, marathon, sweep and recorder workflows and the main backstop keep full clones. `test/ci-history-read.test.ts` names each with its reason and fails on any other full-history checkout without the filter, printing the file and line to fix. +- **History reads fail loudly.** The train-policy reads in the route and backstop jobs stay inline (git and jq only, so no repository code decides whether a PR push starts paid work). The Version Gate's base `VERSION` read goes through `.github/scripts/history-read.sh` from the gate's merge-commit checkout, and its train classification through the TypeScript twin, `lib/history-read.ts`, which also lists the merge base's changed files without rename detection so that diff needs no file contents. Each one checks existence with `git ls-tree`, reads with `git show` (3 tries in a partial clone) and fails with `HISTORY_READ_FAILED` naming the ref, the path and the git version (docs/troubleshooting.md#history-read-failed). A file that is really absent keeps its old meaning. A ship policy that is not valid JSON now fails the route and backstop jobs instead of reading as "no train". The Version Gate goes red when `git fetch origin ` fails 3 times instead of carrying on. `gstack-restamp` and `gstack-ship-queue land` read the base branch's CHANGELOG the same way, so a read that fails stops them with `HISTORY_READ_FAILED` instead of treating the base as having no released sections. +- **The eval image asserts partial clone support.** `.github/docker/Dockerfile.ci` makes a blobless clone and fetches a missing blob at build time, so an image whose git cannot do that fails to build. + ## [1.92.20.0] - 2026-11-01 **`/retro global` now sees Kimi Code, OMP and Antigravity sessions (#2917, #1977).** `gstack-global-discover` scanned only Claude Code, Codex and Gemini. A week spent in other agent CLIs went uncounted, and nothing showed a zero to notice. diff --git a/CLAUDE.md b/CLAUDE.md index d425dc6e7..f2a630b7b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,7 +129,7 @@ and commit both; never edit the generated file (`test/design-checklist-sync.test.ts` fails on drift). Generation uses each host's `defaultModel` (`claude` for most hosts, `gpt` -for Codex, `gemini` for Antigravity's `agy`) unless `--model` is explicit. Codex installs additionally read the +for Codex, `gemini` for Antigravity's `agy` and Gemini CLI) unless `--model` is explicit. Codex installs additionally read the top-level model from `${CODEX_HOME:-~/.codex}/config.toml`; rerun `./setup --host codex` after changing that model. Note: `bun run build` and a bare `gen:skill-docs --host codex` render the host default (gpt) — if your diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 715592905..6132d56ac 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,7 +14,7 @@ bun install # install dependencies bin/dev-setup # activate dev mode ``` -`./setup` on its own installs production dependencies only. `bun install` (and `bin/dev-setup`, which runs it) adds the dev toolchain the tests and evals need: the Claude Agent SDK, TypeScript and Prettier. Once they are installed, a later `./setup` runs the full install too, so they stay at `bun.lock`'s versions. +`./setup` on its own installs production dependencies only. `bin/dev-setup` adds the dev toolchain the tests and evals need (the Claude Agent SDK, TypeScript and Prettier) and marks the clone as a contributor's with `git config gstack.contributor true`. In a marked clone a later `./setup` runs the full install too, so the toolchain stays at `bun.lock`'s versions. In an unmarked clone `./setup` moves back to production dependencies, which is how installs made before v1.91.231.0 drop the toolchain they never needed. If you ran plain `bun install` instead of `bin/dev-setup`, run `git config gstack.contributor true` once, or set `GSTACK_CONTRIBUTOR=1` for a single run. > **Full clone vs shallow.** The README's user-facing install uses `--depth 1` for speed. As a contributor, use a full clone (no `--depth` flag) — you'll need history for `git log`, `git blame`, `git bisect`, and reviewing PRs against earlier versions. If you already have a `--depth 1` clone from following the README, promote it to a full clone with `git fetch --unshallow`. @@ -736,11 +736,11 @@ worth the review overhead. ## Multi-host development -gstack generates SKILL.md files for 10 hosts from one set of `.tmpl` templates. +gstack generates SKILL.md files for 13 hosts from one set of `.tmpl` templates. Each host is a typed config in `hosts/*.ts`. The generator reads these configs to produce host-appropriate output (different frontmatter, paths, tool names). -**Supported hosts:** Claude (primary), Codex, Factory, Kiro, OpenCode, Slate, Cursor, OpenClaw, Hermes, GBrain. +**Supported hosts:** Claude (primary), Codex, Factory, Kiro, OpenCode, Slate, Cursor, OpenClaw, Hermes, GBrain, Copilot, Antigravity, Gemini CLI. ### Generating for all hosts @@ -749,7 +749,7 @@ to produce host-appropriate output (different frontmatter, paths, tool names). bun run gen:skill-docs # Claude (default) bun run gen:skill-docs --host codex # Codex bun run gen:skill-docs --host opencode # OpenCode -bun run gen:skill-docs --host all # All 10 hosts +bun run gen:skill-docs --host all # All 13 hosts # Or use build, which does all hosts + compiles binaries bun run build diff --git a/README.md b/README.md index c10f1ae0f..011832a81 100644 --- a/README.md +++ b/README.md @@ -77,12 +77,13 @@ installs and passes the conformance tests but has no certification run yet; | Kiro | experimental | `--host kiro` → `~/.kiro/skills/gstack-*/` | ask for `gstack-office-hours` | advisory, not blocked | Codex CLI, signed in | `./setup --host kiro` | | GitHub Copilot CLI | experimental | `--host copilot` → `~/.copilot/skills/gstack-*/` | `/gstack-office-hours` | advisory, not blocked | Codex CLI, signed in | `./setup --host copilot` | | Antigravity CLI | experimental | `--host agy` (or `--host antigravity`) → `~/.gemini/antigravity-cli/skills/gstack-*/` | `/gstack-office-hours` | advisory, not blocked | Codex CLI, signed in | `./setup --host agy` | +| Gemini CLI | experimental | `--host gemini` (or `--host gemini-cli`) → `~/.gemini/skills/gstack-*/` | ask for `gstack-office-hours` | advisory, not blocked | Codex CLI, signed in | `./setup --host gemini` | | Slate | instruction-only | `--host slate` (points at the Claude install; Slate reads `.claude/skills`) | `/office-hours` via the Claude install | advisory, not blocked | — | `./setup --host claude` | | OpenClaw | experimental | `--host openclaw` (five task skills and one `gstack` router skill in OpenClaw's skills folder; automatic when setup runs inside OpenClaw; ACP route in [docs/OPENCLAW.md](docs/OPENCLAW.md)) | ask for the task; the agent opens the matching task skill or the `gstack` router | advisory, not blocked | — | `./setup --host openclaw` | | Hermes | experimental | `--host hermes` (every skill in `$HERMES_HOME/skills/gstack`; automatic when setup runs inside Hermes) | ask for the task, or `/gstack-review` | advisory, not blocked | — | `./setup --host hermes` | | GBrain (mod) | instruction-only | `--host gbrain` (brain-aware variants ship from the GBrain repo) | — | advisory, not blocked | — | — | -Per-host notes (agent harnesses, Copilot, Antigravity, OpenClaw and ClawHub, the Codex behavioral profile and model tiers, outside-review providers, the design binary's models) are in [docs/hosts.md](docs/hosts.md). Any rules-reading agent (Zed, Amp, Jules) can use the 2KB [agents digest](agents-digest/gstack-AGENTS.md) with no install. Adding a host: [docs/ADDING_A_HOST.md](docs/ADDING_A_HOST.md). +Per-host notes (agent harnesses, Copilot, Antigravity, Gemini CLI, OpenClaw and ClawHub, the Codex behavioral profile and model tiers, outside-review providers, the design binary's models) are in [docs/hosts.md](docs/hosts.md). Any rules-reading agent (Zed, Amp, Jules) can use the 2KB [agents digest](agents-digest/gstack-AGENTS.md) with no install. Adding a host: [docs/ADDING_A_HOST.md](docs/ADDING_A_HOST.md). ## See it work diff --git a/TODOS.md b/TODOS.md index cdbabf43e..a95320ccd 100644 --- a/TODOS.md +++ b/TODOS.md @@ -5537,12 +5537,12 @@ Garry for each; revisit 2026-10-29 unless a date says otherwise. (`['paste', 'enter', 'end_turn']`). Wait for the CLI's own write instead of a timer (docs/TESTING_INTERNALS.md#flake-ledger). **Effort:** S. **Priority:** P2. - **Split the free files the weekly seed puts over 60 s** — - `test/setup-install-registry.test.ts` (84 s), `test/brain-sync.test.ts` (89 s) - and `test/setup-codex-scope.test.ts` (64 s) are allowlisted with this todo in - `scripts/free-test-seed-allowlist.json` (2026-10-19 recording); - `test/host-renders-prune.test.ts` (57 s) and - `test/setup-codex-scope-namespaces.test.ts` are next. Split - each by describe block, rerun `bun run test:ubicloud --record-durations` and + `test/setup-install-registry.test.ts` (117 s), `test/brain-sync.test.ts` (91 s) + and `test/setup-codex-scope.test.ts` (77 s) are allowlisted with this todo in + `scripts/free-test-seed-allowlist.json` (2026-10-31 recording, run 41229924108). + `test/host-renders-prune.test.ts`, `test/setup-codex-scope-namespaces.test.ts` + and seven other files the recordings put over 60 s were split in + GSTA-151. Split each remaining file by describe block, rerun `bun run test:ubicloud --record-durations` and delete the entries. **Effort:** S each. **Priority:** P2. ## /write-skill follow-ups (filed via /autoplan on GSTA-133, 2026-10-27) diff --git a/VERSION b/VERSION index dbf7a7040..bb114e4c2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.20.0 +1.92.42.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 45e74e9c0..0f21b65e0 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.20.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.42.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/dev-setup b/bin/dev-setup index f77026715..9a8f6f6f6 100755 --- a/bin/dev-setup +++ b/bin/dev-setup @@ -22,9 +22,12 @@ if [ ! -f "$REPO_ROOT/.env" ]; then fi fi -# 2. Install dependencies, devDependencies included. Always run: a checkout -# first installed by ./setup has production dependencies only, and an -# up-to-date install is a no-op. +# 2. Install dependencies, devDependencies included, and mark this clone as a +# contributor's (#3447) so ./setup keeps the dev toolchain instead of moving it +# to the production-only install. Always run: a checkout first installed by +# ./setup has production dependencies only, and an up-to-date install is a no-op. +git -C "$REPO_ROOT" config --local gstack.contributor true 2>/dev/null \ + || echo "Warning: could not set git config gstack.contributor in $REPO_ROOT; export GSTACK_CONTRIBUTOR=1 before ./setup to keep the dev toolchain." >&2 echo "Installing dependencies..." (cd "$REPO_ROOT" && bun install) diff --git a/bin/gstack-brain-sync b/bin/gstack-brain-sync index 0c5d07afb..25625c2bb 100755 --- a/bin/gstack-brain-sync +++ b/bin/gstack-brain-sync @@ -585,6 +585,60 @@ transcripts_consented() { transcript_policy --check 2>/dev/null } +# After a rejected push: merge origin/ into HEAD (#3704). git refuses +# a merge that would overwrite uncommitted edits to tracked files, and that +# refusal used to repeat on every run, so local edits to the paths the merge +# changes are set aside first and put back after; other local edits stay in +# place, and only a stash entry this call created is put back. Paths are +# literal, never patterns. A failed merge is aborted, so no half-finished merge +# or conflict markers block the next commit; git's reason lands in MERGE_ERR. +# When putting an edit back conflicts with what the merge brought in, the +# merged version stays in the working tree, so a later sync never publishes +# the stale local side over the other machine's change, and the local edit +# stays in the kept stash entry, which the status names with the command +# that restores it. +MERGE_ERR="" +merge_origin_keeping_local_edits() { + local branch="$1" out rc=0 p stash_before="" stash_sha="" + local -a lit=(git --literal-pathspecs -C "$GSTACK_HOME") blocking=() + while IFS= read -r -d '' p; do + "${lit[@]}" diff --quiet --no-renames HEAD "origin/$branch" -- "$p" 2>/dev/null || blocking+=("$p") + done < <("${lit[@]}" diff -z --no-renames --name-only HEAD 2>/dev/null) + if [ "${#blocking[@]}" -gt 0 ]; then + stash_before=$("${lit[@]}" rev-parse -q --verify refs/stash 2>/dev/null) + if ! out=$("${lit[@]}" stash push -q -m "gstack-brain-sync: local edits before merging origin/$branch" -- "${blocking[@]}" 2>&1); then + MERGE_ERR="could not set local edits aside: $(printf '%s\n' "$out" | head -1)" + return 1 + fi + stash_sha=$("${lit[@]}" rev-parse -q --verify refs/stash 2>/dev/null) + [ "$stash_sha" != "$stash_before" ] || stash_sha="" + fi + out=$("${lit[@]}" merge --no-edit "origin/$branch" 2>&1) || rc=$? + if [ "$rc" -ne 0 ]; then + MERGE_ERR=$(printf '%s\n' "$out" | grep -m1 -E '^(CONFLICT|error|fatal)' || printf '%s\n' "$out" | head -1) + "${lit[@]}" merge --abort >/dev/null 2>&1 || true + fi + [ -n "$stash_sha" ] || return "$rc" + out=$("${lit[@]}" stash pop -q 2>&1) && return "$rc" + local paths="" args="" failed=0 + while IFS= read -r -d '' p; do + if "${lit[@]}" cat-file -e "HEAD:$p" 2>/dev/null; then + "${lit[@]}" checkout -q HEAD -- "$p" >/dev/null 2>&1 || failed=1 + else + { "${lit[@]}" rm -q --cached -- "$p" >/dev/null 2>&1 && rm -f -- "$GSTACK_HOME/$p"; } || failed=1 + fi + paths="$paths ${p}" + args="$args $(printf '%q' "$p")" + done < <("${lit[@]}" diff -z --name-only --diff-filter=U 2>/dev/null) + "${lit[@]}" reset -q >/dev/null 2>&1 || failed=1 + if [ -z "$paths" ] || [ "$failed" = 1 ]; then + MERGE_ERR="local edits could not be put back after merging origin/$branch ($(printf '%s\n' "$out" | head -1)); they are saved in git -C $GSTACK_HOME stash list" + return 1 + fi + ST_NOTE="$ST_NOTE; local edits to$paths conflicted with the merged remote version, which is kept; the local edits are saved in stash $stash_sha (get them back: git -C $GSTACK_HOME --literal-pathspecs restore --source=$stash_sha --$args)" + return "$rc" +} + # Before every push: scan the unpublished range for transcript pages outside # the current consent; when some exist, the helper rewrites gstack-brain-sync's # own unpublished commits without them, or refuses (exit 3) when it cannot. @@ -1379,7 +1433,7 @@ subcmd_once() { bash -c 'git -C "$1" fetch origin 2>/dev/null' _ "$GSTACK_HOME"; then local branch branch=$(git -C "$GSTACK_HOME" rev-parse --abbrev-ref HEAD 2>/dev/null || echo main) - if git -C "$GSTACK_HOME" merge --no-edit "origin/$branch" >/dev/null 2>&1 && guard_unpublished_range; then + if merge_origin_keeping_local_edits "$branch" && guard_unpublished_range; then validate_outgoing_paths || exit 0 if GSTACK_HOME="$GSTACK_HOME" _receipted_git closed brain-sync "$push_host" curated-memory-git-push "artifacts_sync_mode!=off" \ bash -c 'git -C "$1" push origin HEAD 2>/dev/null' _ "$GSTACK_HOME"; then @@ -1391,7 +1445,10 @@ subcmd_once() { fi fi # Commit exists locally; the run-start detector re-pushes it next time. - write_status "push_failed" "push failed: $(printf '%s' "$push_err" | head -1); commit retained locally, will retry next run" + local push_reason + push_reason=$(printf '%s\n' "$push_err" | grep -v -E '^(To |hint:)' | head -1) + [ -n "$push_reason" ] || push_reason=$(printf '%s\n' "$push_err" | head -1) + write_status "push_failed" "push failed: $push_reason${MERGE_ERR:+; merging origin failed: $MERGE_ERR}; commit retained locally, will retry next run" finalize_queue "$snapshot_file" "$class_file" "$paths_file" exit 0 } diff --git a/bin/gstack-config b/bin/gstack-config index 8992806e2..fce1672af 100755 --- a/bin/gstack-config +++ b/bin/gstack-config @@ -208,6 +208,14 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne # # the same model; every reviewer keeps full. # Docs: docs/model-policy.md # +# harness_start_hook: # on | off. on: ./setup and gstack-harness add the +# # gstack-start plugin to OpenClaw and Hermes registrations +# # (a one-line note on every turn to run the start +# # command of a skill; edits openclaw.json or config.yaml via the +# # harness CLI). off: takes it out. Absent = each +# # registration keeps its saved choice (default off). +# # GSTACK_HARNESS_START_HOOK=1|0 overrides it. Docs: docs/hosts.md +# # ─── Advanced ──────────────────────────────────────────────────────── # codex_reviews: enabled # Outside review (Codex or Claude Code by harness): sends # # the review prompt and repository content to that model. @@ -368,6 +376,7 @@ lookup_default() { agent_model) echo "" ;; # empty = host (agents keep their own model); gstack-models resolve --role agent codex_effort) echo "" ;; # empty = each gate's own effort; /codex --effort, then GSTACK_CODEX_EFFORT, win (gstack-codex-probe run-with-timeout --effort) review_effort_tiers) echo "on" ;; # on | off — gstack-review-plan effort tiers (docs/model-policy.md#reasoning-effort) + harness_start_hook) echo "" ;; # on | off — empty = each harness registration keeps its saved choice (docs/hosts.md#start-hook) ship_greptile_early) echo "true" ;; # true | false — /ship's early Greptile PR pr_wait_gating_bots) echo "greptile,macroscope" ;; # review bots gstack-pr-wait --until ready waits for pr_wait_advisory_bots) echo "coderabbit" ;; # review bots gstack-pr-wait reports but never waits for @@ -833,6 +842,10 @@ $_gstack_legacy_root" echo "Error: review_effort_tiers '$VALUE' not recognized. Valid values: on, off. Existing value left unchanged." >&2 exit 1 fi + if [ "$KEY" = "harness_start_hook" ] && [ "$VALUE" != "on" ] && [ "$VALUE" != "off" ]; then + echo "Error: harness_start_hook '$VALUE' not recognized. Valid values: on, off. Existing value left unchanged. fix: gstack-config unset harness_start_hook keeps each registration's saved choice (docs/hosts.md#start-hook)" >&2 + exit 1 + fi if [ "$KEY" = "codex_effort" ]; then case "$VALUE" in minimal|low|medium|high|xhigh|max|ultra) ;; @@ -1100,7 +1113,7 @@ $_gstack_legacy_root" ship_measure_judge_outputs ship_measure_ask_per_trial_usd ship_measure_budget_usd \ ship_measure_max_rounds ship_measure_sweep_cases ship_measure_sweep_budget_usd ship_rerun_backend test_backend \ plan_review_tier implementation_tier model_frontier_claude model_frontier_openai \ - model_smart_claude model_smart_openai codex_effort review_effort_tiers agent_model; do + model_smart_claude model_smart_openai codex_effort review_effort_tiers agent_model harness_start_hook; do gstack_config_select "$KEY" VALUE="$_gstack_cfg_value" SOURCE="default" @@ -1125,7 +1138,7 @@ $_gstack_legacy_root" ship_measure_judge_outputs ship_measure_ask_per_trial_usd ship_measure_budget_usd \ ship_measure_max_rounds ship_measure_sweep_cases ship_measure_sweep_budget_usd ship_rerun_backend test_backend \ plan_review_tier implementation_tier model_frontier_claude model_frontier_openai \ - model_smart_claude model_smart_openai codex_effort review_effort_tiers agent_model; do + model_smart_claude model_smart_openai codex_effort review_effort_tiers agent_model harness_start_hook; do printf ' %-24s %s\n' "$KEY:" "$(lookup_default "$KEY")" done ;; diff --git a/bin/gstack-docs-candidate b/bin/gstack-docs-candidate index a5728877f..517541019 100755 --- a/bin/gstack-docs-candidate +++ b/bin/gstack-docs-candidate @@ -4,7 +4,7 @@ * * Usage (run inside the repository): * gstack-docs-candidate snapshot --out --audit-id --mode edit|read-only - * --base [--select ]... [--docs ]... + * --base [--select ]... [--docs ]... [--exception ] * gstack-docs-candidate compare * gstack-docs-candidate attempt --state * gstack-docs-candidate [snapshot|compare] --help @@ -16,6 +16,9 @@ * selected paths are the committed, staged, unstaged and untracked paths. * --docs takes files or directories (directories expand to their tracked and * untracked files). It prints a short summary; the JSON file is the record. + * --exception names the blocked .gstack/tmp/docs-audit.json an owner accepted + * (Step 14.5 Blocked recovery): the record then carries that exception, so + * Step 16 can tell an accepted risk from a skipped audit. * * compare re-reads the same state and prints one JSON object: whether HEAD or * the index changed, which recorded paths' content changed or disappeared, and @@ -32,6 +35,7 @@ import { spawnSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; +import { parseReceiptDocs, type ReceiptDocs } from '../lib/ship-receipt-docs'; function git(args: string[]): string { const r = spawnSync('git', args, { encoding: 'utf8', env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' }, maxBuffer: 256 * 1024 * 1024 }); @@ -90,10 +94,20 @@ function one(args: string[], name: string): string { return values[0]!; } -const USAGE = `usage: gstack-docs-candidate snapshot --out --audit-id --mode edit|read-only --base [--select ]... [--docs ]... +const USAGE = `usage: gstack-docs-candidate snapshot --out --audit-id --mode edit|read-only --base [--select ]... [--docs ]... [--exception ] gstack-docs-candidate compare gstack-docs-candidate attempt --state `; +/** The owner's accepted Step 14.5 exception from the blocked .gstack/tmp/docs-audit.json record, read by the receipt's own parser; throws when the record is not blocked or names no complete exception. */ +function acceptedException(file: string): NonNullable & { scope: string } { + let audit: unknown; + try { audit = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (error) { throw new Error(`--exception ${file} is not readable JSON: ${(error as Error).message}`); } + const parsed = parseReceiptDocs(audit); + const problem = !parsed.ok ? parsed.message : parsed.docs.status !== 'blocked' ? `its status is ${parsed.docs.status}, not blocked` : !parsed.docs.exception ? 'it names no accepted exception' : null; + if (problem || !parsed.ok || !parsed.docs.exception) throw new Error(`--exception ${file}: ${problem}. Only an owner-accepted documentation risk writes this snapshot (ship/sections/documentation.md, Blocked recovery step 3)`); + return { ...parsed.docs.exception, scope: parsed.docs.scope }; +} + /** Each field compare needs that the record lacks or types wrongly; empty for a valid record. */ function recordProblems(record: any): string[] { if (!record || typeof record !== 'object' || Array.isArray(record)) return ['not a JSON object']; @@ -113,7 +127,7 @@ function main(argv: string[]): void { return; } if (command === 'snapshot') { - const known = new Set(['--out', '--audit-id', '--mode', '--base', '--select', '--docs']); + const known = new Set(['--out', '--audit-id', '--mode', '--base', '--select', '--docs', '--exception']); for (let i = 0; i < args.length; i += 2) if (!known.has(args[i]!)) throw new Error(`unknown argument: ${args[i]}`); const out = path.resolve(one(args, '--out')); const top = fs.realpathSync(git(['rev-parse', '--show-toplevel']).trim()); @@ -121,6 +135,8 @@ function main(argv: string[]): void { const mode = one(args, '--mode'); if (mode !== 'edit' && mode !== 'read-only') throw new Error('--mode must be edit or read-only'); const base = git(['rev-parse', '--verify', one(args, '--base') + '^{commit}']).trim(); + const exceptionFile = flag(args, '--exception')[0]; + const exception = exceptionFile === undefined ? undefined : acceptedException(exceptionFile); process.chdir(top); const now = state(base); const listed = nul(git(['ls-files', '-z', '--cached', '--others', '--exclude-standard'])); @@ -128,10 +144,11 @@ function main(argv: string[]): void { : [...new Set([...now.committed, ...now.staged, ...now.unstaged, ...now.untracked])].sort(); const docs = expand(listed, flag(args, '--docs')); const tracked = [...new Set([...selected, ...docs, ...now.staged, ...now.unstaged, ...now.untracked])].sort(); - const record = { schema_version: 1, audit_id: one(args, '--audit-id'), mode, base, ...now, selected, docs, hashes: hashes(tracked) }; + const record = { schema_version: 1, audit_id: one(args, '--audit-id'), mode, base, ...now, selected, docs, hashes: hashes(tracked), ...(exception ? { exception } : {}) }; fs.writeFileSync(out, JSON.stringify(record, null, 2) + '\n', { mode: 0o600 }); console.log(`CANDIDATE: ${out}`); console.log(`base=${base} head=${now.head} branch=${now.branch || '(detached)'} mode=${mode}`); + if (exception) console.log(`EXCEPTION: accepted by ${exception.accepted_by} ${exception.date} via ${exception.via}`); console.log(`committed=${now.committed.length} staged=${now.staged.length} unstaged=${now.unstaged.length} untracked=${now.untracked.length} selected=${selected.length} docs=${docs.length} hashed=${tracked.length}`); for (const [label, list] of [['committed', now.committed], ['staged', now.staged], ['unstaged', now.unstaged], ['untracked', now.untracked], ['docs', docs]] as const) { if (list.length) console.log(`${label}: ${list.map(p => JSON.stringify(p)).join(' ')}`); diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index 09a4bd780..9645f7992 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -141,6 +141,7 @@ gstack_result_anchor() { BROWSER_UNAVAILABLE) printf '%s' 'browser-ensure-failed' ;; INSTALL_PREFLIGHT_FAILED) printf '%s' 'capy-install-preflight-failed' ;; SETUP_PS1_EMPTY_ARGUMENT) printf '%s' 'setup-ps1-empty-argument' ;; + SETUP_DEPS_MIGRATE_FAILED) printf '%s' 'setup-deps-migrate-failed' ;; POLICY_SOURCE_UNAVAILABLE) printf '%s' 'policy-source-unavailable' ;; POLICY_INVALID) printf '%s' 'policy-invalid' ;; POLICY_CONTAINMENT) printf '%s' 'policy-containment' ;; @@ -360,6 +361,7 @@ gstack_result_anchor() { UPGRADE_SKIPPED) printf '%s' 'upgrade-skipped' ;; BUDGET_CAP_REACHED) printf '%s' 'budget-cap-reached' ;; BUDGET_READING_MISSING) printf '%s' 'budget-reading-missing' ;; + METER_SCOPE_PARENT) printf '%s' 'meter-scope-parent' ;; BUDGET_READING_REJECTED) printf '%s' 'budget-reading-rejected' ;; BUDGET_METER_UNAVAILABLE) printf '%s' 'budget-meter-unavailable' ;; BUDGET_LEDGER_INVALID) printf '%s' 'budget-ledger-invalid' ;; diff --git a/bin/gstack-harness b/bin/gstack-harness index 5bd39f320..a80024b9f 100755 --- a/bin/gstack-harness +++ b/bin/gstack-harness @@ -8,10 +8,16 @@ * * Usage: * gstack-harness detect [--json] - * gstack-harness register [--skills router|hybrid|all] [--skills-dir ] [--context ] [--print] [--yes] [--no-registry] + * gstack-harness register [--skills router|hybrid|all] [--skills-dir ] [--context ] [--start-hook|--no-start-hook] [--print] [--yes] [--no-registry] * --context writes gstack's marked routing block into that file only * (opt-in: setup, upgrades and refresh never add one, and a block you * delete stays deleted); it prints the file and its scope before writing + * --start-hook (OpenClaw, Hermes; opt-in) adds the gstack-start plugin, which + * puts a one-line note on every user turn to run a gstack skill's start + * command, and turns it on through the harness's own commands (this edits + * openclaw.json or Hermes's config.yaml); --no-start-hook takes it out. The + * choice is saved; GSTACK_HARNESS_START_HOOK=1|0 or gstack-config + * harness_start_hook on|off make it too. Exit 4: skills registered, hook not active * gstack-harness unregister [--remote] * gstack-harness status [--json] [--no-check] * gstack-harness refresh @@ -46,11 +52,18 @@ function receiptLines(r: RegisterResult): string[] { 'gstack-harness: ' + t.displayName + ': ' + r.skills + ' gstack skills ' + how + ' at ' + r.dest + ' (' + r.status + (r.written ? ', ' + r.written + ' files written' : '') + ')', ...(r.router ? [' router: ' + r.router + ' (register with --skills all to list every skill natively)'] : []), ...r.kept.map(k => ' kept your edit: ' + k), + ...(r.hook ? [' start hook: ' + r.hook.state + (r.hook.state === 'active' ? ' (new sessions get the note; a running gateway picks it up when it reloads)' : '') + (r.hook.detail ? ' - ' + r.hook.detail : '') + (r.hook.fix ? '; ' + r.hook.fix : '')] : []), ' ledger: ' + r.ledger, ' remove: ' + self + ' unregister ' + r.id, ]; } const receipt = (r: RegisterResult) => console.log(receiptLines(r).join('\n')); +/** A start hook this run turned on, or was asked for, that did not end up active: exit 4 after the receipt. */ +function hookFailed(r: RegisterResult): number { + if (!r.hook?.required || r.hook.state === 'active') return 0; + console.error(new HarnessError('HARNESS_HOOK_INACTIVE', TARGETS[r.target].displayName + ' start hook is ' + r.hook.state + (r.hook.detail ? ': ' + r.hook.detail : '') + (r.hook.fix ? '. Turn it on: ' + r.hook.fix : '')).report()); + return 4; +} function usage(code: number): never { const text = fs.readFileSync(process.argv[1], 'utf8').split('\n').slice(1).filter(l => l.startsWith(' *')).map(l => l.slice(3)).join('\n'); @@ -73,6 +86,7 @@ async function main(): Promise { const mode = flag('--skills') as SkillsMode | undefined; const skillsDir = flag('--skills-dir'); const context = flag('--context'); const print = bool('--print'); const yes = bool('--yes'); const noRegistry = bool('--no-registry'); + const startHook = bool('--start-hook') ? true : bool('--no-start-hook') ? false : undefined; const t = getTarget(argv.shift() ?? ''); if (print) { console.log(printPayload(checkout)); return 0; } if (t.name === 'qm') { @@ -88,10 +102,10 @@ async function main(): Promise { return 3; } if (context) (json ? console.error : console.log)('gstack-harness: context block: ' + path.resolve(context) + ' (' + contextScope(t, context, env, home) + ' scope)'); - const r = register({ ...base, target: t, mode, skillsDir, context, registry: !noRegistry }); + const r = register({ ...base, target: t, mode, skillsDir, context, registry: !noRegistry, startHook }); if (json) console.log(JSON.stringify({ ...r, receipt: receiptLines(r).map(l => l.trim()).join('; ') })); else receipt(r); - return 0; + return hookFailed(r); } case 'unregister': { const remote = bool('--remote'); @@ -113,23 +127,25 @@ async function main(): Promise { const extra = (['muse', 'grok'] as TargetName[]).filter(n => TARGETS[n].present(env, home)).map(n => ({ target: n, ...(check ? harnessSees(TARGETS[n], '', env, ['review', 'gstack-review'], true) : { visible: 'unknown' as const }) })); if (json) { console.log(JSON.stringify({ registrations: rows, detected: extra })); return 0; } if (!rows.length && !extra.length) console.log('gstack-harness: no harness registrations on this machine'); - for (const r of rows) console.log(r.target + '\t' + r.mode + '\t' + r.dest + '\t' + r.files + ' files\t' + (r.stale ? 'stale (' + r.version + '; refresh: ' + self + ' refresh)' : r.version) + '\tvisible to harness: ' + r.visible + (r.checked ? ' (' + r.checked + ')' : '') + (r.missing?.length ? ' missing: ' + r.missing.slice(0, 5).join(', ') : '') + '\t' + r.id); + for (const r of rows) console.log(r.target + '\t' + r.mode + '\t' + r.dest + '\t' + r.files + ' files\t' + (r.stale ? 'stale (' + r.version + '; refresh: ' + self + ' refresh)' : r.version) + '\tvisible to harness: ' + r.visible + (r.checked ? ' (' + r.checked + ')' : '') + (r.missing?.length ? ' missing: ' + r.missing.slice(0, 5).join(', ') : '') + '\t' + r.id + + (r.hook ? '\n start hook: ' + r.hook.state + (r.hook.detail ? ' - ' + r.hook.detail : '') + (r.hook.state !== 'active' && r.hook.fix ? '; ' + r.hook.fix : '') : '')); for (const x of extra) console.log(x.target + '\tdetect\t~/.claude/skills\t' + (x.visible === 'yes' ? 'discovered (Claude-rendered, unverified)' : 'unverified') + (x.checked ? ' (' + x.checked + ')' : '')); return 0; } case 'refresh': { - for (const r of refresh(base)) if (r.status !== 'unchanged') receipt(r); - return 0; + let code = 0; + for (const r of refresh(base)) { if (r.status !== 'unchanged' || r.hook?.required || r.hook?.retried || r.hook?.detail) receipt(r); code ||= hookFailed(r); } + return code; } case 'auto': { const d = detect(env, home); let failed = 0; const done = new Set(); for (const n of d.inside.filter(n => TARGETS[n].mechanism === 'skill-folder')) { - try { const r = register({ ...base, target: TARGETS[n], registry: true }); done.add(r.id); receipt(r); } + try { const r = register({ ...base, target: TARGETS[n], registry: true }); done.add(r.id); receipt(r); hookFailed(r); } catch (e) { if (e instanceof HarnessError) { console.error(e.report()); failed = 1; } else throw e; } } - try { for (const r of refresh(base, done)) if (r.status !== 'unchanged') receipt(r); } + try { for (const r of refresh(base, done)) { if (r.status !== 'unchanged' || r.hook?.required || r.hook?.retried || r.hook?.detail) receipt(r); hookFailed(r); } } catch (e) { if (e instanceof HarnessError) { console.error(e.report()); failed = 1; } else throw e; } for (const n of d.present.filter(n => !d.inside.includes(n) && TARGETS[n].mechanism === 'skill-folder' && !listLedgers(stateRoot).some(l => l.ledger.meta.target === n))) { console.log('gstack-harness: ' + TARGETS[n].displayName + ' is installed here; to give it gstack skills: ' + self + ' register ' + n); diff --git a/bin/gstack-harness-ledger.sh b/bin/gstack-harness-ledger.sh index 210e85326..6d24acb4b 100644 --- a/bin/gstack-harness-ledger.sh +++ b/bin/gstack-harness-ledger.sh @@ -20,12 +20,25 @@ # (ledgers without the row: when only whitespace is left). Folders gstack # created are removed only when empty. A QM pack is never touched here: detaching it from # the org is `gstack-harness unregister qm --remote`. +# +# A registration with the start hook on (meta start_hook on) owns a second root, the +# gstack-start plugin folder in the harness profile (meta hook_root). Before its files go, +# gstack_harness_hook_release turns the plugin off through the harness's own CLI +# (lib/harness-start-hook.ts), the same step `register --no-start-hook` runs. _gstack_harness_sha() { if command -v sha256sum >/dev/null 2>&1; then sha256sum ${1:+"$1"} | cut -d' ' -f1 else shasum -a 256 ${1:+"$1"} | cut -d' ' -f1; fi } +# True when $1 is below $2 and nothing between them is a symlink ($2 itself may be one). +_gstack_harness_below() { + local p="$1" top="$2" + [ -n "$top" ] || return 1 + case "$p" in "$top"/*) ;; *) return 1 ;; esac + while [ "$p" != "$top" ]; do [ -L "$p" ] && return 1; p="$(dirname "$p")"; done +} + # True when $1 is below $2 and no path from $2 down to $1 is a symlink. _gstack_harness_owned() { local p="$1" dest="$2" @@ -57,16 +70,53 @@ _gstack_harness_cut_block() { rm -f "$tmp" } +# Turns a registration's start hook off in its harness, from the ledger: OpenClaw drops +# plugins.entries.gstack-start; Hermes removes the plugin and its config entries when its +# folder holds exactly the files gstack wrote, unedited, and is reached without a symlink +# below the profile; otherwise it only disables it so an edit or a kept copy survives. The harness runs with PATH, HOME and the profile only. Without the harness on +# PATH it prints the command to run instead. +gstack_harness_hook_release() { + local ledger="$1" kind a b target="" on="" profile="" root="" edited=0 to="" sub cmd n=0 safe=1 total + [ -f "$ledger" ] || return 0 + while IFS=$'\t' read -r kind a b; do + case "$kind" in + meta) case "$a" in target) target="$b" ;; start_hook) on="$b" ;; hook_profile) profile="$b" ;; hook_root) root="$b" ;; esac ;; + file) if [ -n "$root" ]; then case "$b" in "$root"/*) if [ -f "$b" ]; then n=$((n + 1)); [ "$(_gstack_harness_sha "$b")" != "$a" ] && edited=1; fi ;; esac; fi ;; + esac + done < "$ledger" + { [ "$on" = on ] && [ -n "$profile" ] && [ -n "$root" ]; } || return 0 + if [ -d "$root" ]; then + _gstack_harness_below "$root" "$profile" || { safe=0; edited=1; } + total="$(find "$root" -type f ! -path "$root/__pycache__/*" 2>/dev/null | wc -l | tr -d ' ')" + [ "${total:-0}" -gt "$n" ] && edited=1 + fi + command -v timeout >/dev/null 2>&1 && to="timeout 60" + case "$target" in + openclaw) cmd="config unset plugins.entries.gstack-start" + if command -v openclaw >/dev/null 2>&1 && env -i PATH="$PATH" HOME="$HOME" OPENCLAW_STATE_DIR="$profile" $to openclaw $cmd /dev/null 2>&1; then : + else echo " start hook: turn it off in OpenClaw yourself: OPENCLAW_STATE_DIR=\"$profile\" openclaw $cmd"; fi ;; + hermes) sub=remove; [ "$edited" = 1 ] && sub=disable + if command -v hermes >/dev/null 2>&1 && env -i PATH="$PATH" HOME="$HOME" HERMES_HOME="$profile" $to hermes plugins "$sub" gstack-start /dev/null 2>&1; then : + else echo " start hook: turn it off in Hermes yourself: HERMES_HOME=\"$profile\" hermes plugins disable gstack-start"; fi ;; + esac + if [ "$safe" = 1 ] && [ -d "$root/__pycache__" ] && _gstack_harness_owned "$root/__pycache__" "$root"; then + rm -f "$root"/__pycache__/*.pyc + rmdir "$root/__pycache__" 2>/dev/null + fi + return 0 +} + gstack_harness_unregister_ledger() { - local ledger="$1" stamp kind a b target="" dest="" pack="" pad="" padpath="" + local ledger="$1" stamp kind a b target="" dest="" pack="" pad="" padpath="" hroot="" hprof="" : "${GSTACK_STATE_ROOT:?gstack-harness-ledger: GSTACK_STATE_ROOT is not set}" [ -f "$ledger" ] || return 0 stamp="$(date -u +%Y-%m-%dT%H-%M-%SZ)" + gstack_harness_hook_release "$ledger" while IFS=$'\t' read -r kind a b; do case "$kind" in - meta) case "$a" in target) target="$b" ;; dest) dest="$b" ;; qm_pack) pack="$b" ;; esac ;; + meta) case "$a" in target) target="$b" ;; dest) dest="$b" ;; qm_pack) pack="$b" ;; hook_root) hroot="$b" ;; hook_profile) hprof="$b" ;; esac ;; file) - { [ -f "$b" ] && _gstack_harness_owned "$b" "$dest"; } || continue + { [ -f "$b" ] && { _gstack_harness_owned "$b" "$dest" || { _gstack_harness_owned "$b" "$hroot" && _gstack_harness_below "$hroot" "$hprof"; }; }; } || continue if [ "$(_gstack_harness_sha "$b")" = "$a" ]; then rm -f "$b" else mv "$b" "$b.edited-$stamp" && echo " kept your edit: $b.edited-$stamp"; fi ;; blockpad) pad="$a"; padpath="$b" ;; @@ -75,7 +125,10 @@ gstack_harness_unregister_ledger() { done < "$ledger" while IFS=$'\t' read -r kind a b; do [ "$kind" = dir ] && [ -d "$b" ] && [ ! -L "$b" ] || continue - case "$b" in "$dest"/*) _gstack_harness_owned "$b" "$dest" || continue ;; esac + case "$b" in + "$dest"/*) _gstack_harness_owned "$b" "$dest" || continue ;; + *) if [ -n "$hprof" ]; then case "$b" in "$hprof"/*) _gstack_harness_below "$b" "$hprof" || continue ;; esac; fi ;; + esac rmdir "$b" 2>/dev/null done < "$ledger" case "$target" in diff --git a/bin/gstack-host-renders.sh b/bin/gstack-host-renders.sh index 83e667c87..b0295a6fb 100644 --- a/bin/gstack-host-renders.sh +++ b/bin/gstack-host-renders.sh @@ -24,7 +24,7 @@ # host:render-dir for every host whose render lives in its own checkout dir. # Claude renders into the tracked tree. test/host-renders.test.ts keeps this # equal to hostSubdir in hosts/*.ts. -GSTACK_HOST_RENDER_DIRS="codex:.agents kiro:.kiro factory:.factory opencode:.opencode cursor:.cursor copilot:.copilot agy:.agy slate:.slate openclaw:.openclaw hermes:.hermes gbrain:.gbrain" +GSTACK_HOST_RENDER_DIRS="codex:.agents kiro:.kiro factory:.factory opencode:.opencode cursor:.cursor copilot:.copilot agy:.agy gemini:.gemini-cli slate:.slate openclaw:.openclaw hermes:.hermes gbrain:.gbrain" gstack_render_hosts_file() { printf '%s/.gstack-installed-hosts\n' "$1"; } diff --git a/bin/gstack-install-registry.sh b/bin/gstack-install-registry.sh index 0fe5e9e91..27ed91c57 100644 --- a/bin/gstack-install-registry.sh +++ b/bin/gstack-install-registry.sh @@ -184,7 +184,7 @@ gstack_install_render_for() { gstack_host_tier() { case "$1" in claude) echo full ;; - codex|kiro|factory|opencode|cursor|copilot|agy|openclaw|hermes) echo experimental ;; + codex|kiro|factory|opencode|cursor|copilot|agy|gemini|openclaw|hermes) echo experimental ;; slate|gbrain) echo instruction-only ;; *) echo unknown ;; esac @@ -256,6 +256,7 @@ gstack_install_known_roots() { printf 'cursor\tglobal\t%s\t%s\n' "$HOME/.cursor/skills" "$HOME/.cursor/skills/gstack" printf 'copilot\tglobal\t%s\t%s\n' "$HOME/.copilot/skills" "$HOME/.copilot/skills/gstack" printf 'agy\tglobal\t%s\t%s\n' "$HOME/.gemini/antigravity-cli/skills" "$HOME/.gemini/antigravity-cli/skills/gstack" + printf 'gemini\tglobal\t%s\t%s\n' "$HOME/.gemini/skills" "$HOME/.gemini/skills/gstack" proj="$(git rev-parse --show-toplevel 2>/dev/null || true)" if [ -n "$proj" ] && [ "$proj" != "$HOME" ]; then printf 'claude\tproject\t%s\t%s\n' "$proj/.claude/skills" "$proj/.claude/skills/gstack" diff --git a/bin/gstack-pregate b/bin/gstack-pregate index 10da90e78..772bec1b1 100755 --- a/bin/gstack-pregate +++ b/bin/gstack-pregate @@ -4,7 +4,7 @@ * * gstack-pregate [run] [--stage preflight|tests|all] [--only ] [--base ] * [--allow-repo-commands] [--policy-from base|head] [--out ] - * [--timeout ] [--stall-timeout ] [--remote --spend-cap ] [--run ] [--explain] [--json] + * [--timeout ] [--stall-timeout ] [--remote --spend-cap ] [--run ] [--from-run ] [--explain] [--json] * gstack-pregate verify [--out ] [--json] publication check: current tree, no fail/incomplete, obligations cleared * gstack-pregate clear --remote --run-url | --run [--run …] [--allow-repo-commands] | --bundle-lane