diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d6b53e48d..3fdfdc9f2 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,6 +11,16 @@ updates: dependency-type: "production" development-dependencies: dependency-type: "development" + # The windowsHide patch (package.json patchedDependencies, #2160/#1989) is + # keyed to one playwright-core version; a minor or major bump drops it and + # brings back Windows console flashes. Patch updates still flow. The 1.64 + # migration (re-key the patch, the Dia/cookie qualification pins, the + # arm64 artifact name) is owned by Garry, revisit 2026-11-15 (#2923). + ignore: + - dependency-name: "playwright" + update-types: ["version-update:semver-minor", "version-update:semver-major"] + - dependency-name: "playwright-core" + update-types: ["version-update:semver-minor", "version-update:semver-major"] open-pull-requests-limit: 5 - package-ecosystem: "github-actions" diff --git a/.github/workflows/arm-setup-smoke.yml b/.github/workflows/arm-setup-smoke.yml index 014e9c513..9f7cc26fe 100644 --- a/.github/workflows/arm-setup-smoke.yml +++ b/.github/workflows/arm-setup-smoke.yml @@ -133,7 +133,7 @@ jobs: CONTAINER - name: Retain source seals and native smoke evidence if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 with: name: native-arm-setup-${{ github.run_id }}-${{ github.run_attempt }} path: ${{ runner.temp }}/arm-evidence/ diff --git a/.github/workflows/cso-runtime-images.yml b/.github/workflows/cso-runtime-images.yml index 601d0c8c4..e0af153d7 100644 --- a/.github/workflows/cso-runtime-images.yml +++ b/.github/workflows/cso-runtime-images.yml @@ -46,7 +46,7 @@ jobs: - name: Record the requalification trigger inputs of this source commit run: bun run scripts/cso-requalification.ts triggers > requalification.json - if: github.event_name == 'workflow_dispatch' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: cso-requalification-triggers path: requalification.json @@ -276,7 +276,7 @@ jobs: --arg commit "$GITHUB_SHA" --arg workflow "$workflow" --arg sbom "$sbom_digest" \ --arg provenance "$provenance_digest" --argjson versions "$CSO_VERSIONS" \ '{schemaVersion:1,helperAbi:3,state:"staged",buildRevision:$buildRevision,runtimeId:$runtimeId,image:$image,platform:$platform,stack:$stack,versions:$versions,sourceCommit:$commit,workflow:$workflow,sbomDigest:$sbom,provenanceDigest:$provenance,verifiedProvenance:true,qualification:"pending native and private release gates; no automatic promotion"}' > staged-image.json - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: cso-staged-${{ matrix.stack }}-${{ matrix.arch }} path: | @@ -309,12 +309,12 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.4.2 - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: cso-staged-${{ matrix.stack }}-${{ matrix.arch }} path: staged-application - if: matrix.stack == 'rails' - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: cso-staged-postgresql-${{ matrix.arch }} path: staged-postgresql @@ -366,7 +366,7 @@ jobs: bun run scripts/cso-native-evidence.ts --stack "$CSO_STACK" --platform "$CSO_PLATFORM" \ --junit native-tests.junit.xml --staged staged-application/staged-image.json \ --output qualification-evidence.json - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: cso-qualification-gates-${{ matrix.stack }}-${{ matrix.arch }} path: | diff --git a/.github/workflows/cso-runtime-promote.yml b/.github/workflows/cso-runtime-promote.yml index e3575309f..f8bc2f2ff 100644 --- a/.github/workflows/cso-runtime-promote.yml +++ b/.github/workflows/cso-runtime-promote.yml @@ -171,7 +171,7 @@ jobs: Validation: runtime promotion contracts, distribution contracts, compiled CSO helper build, candidate attestation verification, and exact committed-byte comparison. EOF gh pr create --base main --head "$branch" --title "Promote qualified CSO runtime catalog" --body-file pr-body.md - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 with: name: cso-runtime-catalog-candidate path: | diff --git a/.github/workflows/cso-runtime-qualification.yml b/.github/workflows/cso-runtime-qualification.yml index d86c22b23..c1d3a5a26 100644 --- a/.github/workflows/cso-runtime-qualification.yml +++ b/.github/workflows/cso-runtime-qualification.yml @@ -150,7 +150,7 @@ jobs: --evidence-root qualification-evidence \ --output runtime-catalog.proof.json rm runtime-catalog.proof.json - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 with: name: cso-qualified-runtime-statements path: | diff --git a/.github/workflows/cso-scanner-images.yml b/.github/workflows/cso-scanner-images.yml index 655b00f2a..a41713ddb 100644 --- a/.github/workflows/cso-scanner-images.yml +++ b/.github/workflows/cso-scanner-images.yml @@ -253,7 +253,7 @@ jobs: printf '%s\n' "$CSO_UPSTREAM" > upstream-evidence.json printf '%s\n' "$CSO_SBOM_GENERATOR" > sbom-generator-evidence.json sha256sum "$output" staged-profile.json version.sha256 declared-assets.json public-image.json sbom.spdx.json provenance-verification.json sbom-verification.json upstream-evidence.json sbom-generator-evidence.json upstream-base-*-verification.json sbom-generator-*-verification.json > evidence.sha256 - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: cso-scanner-profile-${{ matrix.scanner }}-${{ matrix.arch }} path: | @@ -290,7 +290,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.4.2 - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: pattern: cso-scanner-profile-* path: qualified-artifacts @@ -305,7 +305,7 @@ jobs: image_prefix="ghcr.io/${GITHUB_REPOSITORY,,}/cso-scanners/" bun run scripts/cso-scanner-catalog.ts assemble qualified-fragments lib/cso/scanner-images/catalog.json catalog-proposal.json --revision "$revision" --source-commit "$GITHUB_SHA" --workflow "$workflow" --image-prefix "$image_prefix" bun run scripts/cso-scanner-catalog.ts validate catalog-proposal.json - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: cso-scanner-catalog-proposal path: catalog-proposal.json @@ -330,7 +330,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.4.2 - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: cso-scanner-catalog-proposal path: promotion diff --git a/.github/workflows/eval-sweep.yml b/.github/workflows/eval-sweep.yml index a4e8bd926..4f0fca0ee 100644 --- a/.github/workflows/eval-sweep.yml +++ b/.github/workflows/eval-sweep.yml @@ -180,7 +180,7 @@ jobs: - name: Upload the sweep report if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: ship-measure-sweep-report path: | @@ -191,7 +191,7 @@ jobs: - name: Upload the sweep captures if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: ship-measure-sweep-captures include-hidden-files: true diff --git a/.github/workflows/evals-marathon.yml b/.github/workflows/evals-marathon.yml index 81cc72bc8..bc1dd63e2 100644 --- a/.github/workflows/evals-marathon.yml +++ b/.github/workflows/evals-marathon.yml @@ -109,7 +109,7 @@ jobs: echo "slices=$(jq -c '[range(1; .sliceCount + 1)]' /tmp/marathon-plan/manifest.json)" >> "$GITHUB_OUTPUT" echo "slice_timeouts=$(jq -ec '[0] + .plan.sliceCiTimeoutMinutes' /tmp/marathon-plan/manifest.json)" >> "$GITHUB_OUTPUT" - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: marathon-plan path: /tmp/marathon-plan/manifest.json @@ -173,7 +173,7 @@ jobs: - name: Register gstack skills for PTY tests uses: ./.github/actions/register-gstack-skills - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: marathon-plan path: /tmp/marathon-plan @@ -215,7 +215,7 @@ jobs: - name: Upload slice results if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: marathon-slice-${{ matrix.slice }}-a${{ github.run_attempt }} path: /tmp/marathon-slice-results @@ -223,7 +223,7 @@ jobs: - name: Upload native capture evidence if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: native-captures-${{ env.EVALS_RUN_ID }} include-hidden-files: true @@ -237,7 +237,7 @@ jobs: - name: Upload shard logs on failure if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: marathon-logs-slice-${{ matrix.slice }}-a${{ github.run_attempt }} include-hidden-files: true @@ -270,12 +270,12 @@ jobs: with: bun-version: 1.4.2 - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: marathon-plan path: /tmp/marathon-report - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: pattern: marathon-slice-* path: /tmp/marathon-report @@ -314,7 +314,7 @@ jobs: - name: Upload the marathon report if: always() && (steps.reconcile.outputs.exit != '0' || needs.eval-slices.result != 'success') - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: marathon-report-a${{ github.run_attempt }} path: /tmp/marathon-report.md diff --git a/.github/workflows/evals-periodic.yml b/.github/workflows/evals-periodic.yml index eaa185d18..8abb9ba0c 100644 --- a/.github/workflows/evals-periodic.yml +++ b/.github/workflows/evals-periodic.yml @@ -163,7 +163,7 @@ jobs: echo "slice_timeouts=$(jq -ec '[0] + .plan.sliceCiTimeoutMinutes' /tmp/paid-plan/manifest.json)" } >> "$GITHUB_OUTPUT" - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: paid-plan path: /tmp/paid-plan/manifest.json @@ -180,7 +180,7 @@ jobs: echo "slices=$(jq -c '[range(1; .sliceCount + 1)]' /tmp/gate-census-plan/manifest.json)" >> "$GITHUB_OUTPUT" echo "slice_timeouts=$(jq -ec '[0] + .plan.sliceCiTimeoutMinutes' /tmp/gate-census-plan/manifest.json)" >> "$GITHUB_OUTPUT" - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: gate-census-plan path: /tmp/gate-census-plan/manifest.json @@ -227,7 +227,7 @@ jobs: - name: Upload canary results if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: schema-canary-a${{ github.run_attempt }} path: /tmp/schema-canary-results @@ -296,7 +296,7 @@ jobs: - name: Register gstack skills for PTY tests uses: ./.github/actions/register-gstack-skills - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: paid-plan path: /tmp/paid-plan @@ -338,7 +338,7 @@ jobs: - name: Upload slice results if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: paid-slice-${{ matrix.slice }}-a${{ github.run_attempt }} path: /tmp/paid-slice-results @@ -346,7 +346,7 @@ jobs: - name: Upload native capture evidence if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: native-captures-${{ env.EVALS_RUN_ID }} include-hidden-files: true @@ -362,7 +362,7 @@ jobs: # longer reds its runner, but its full log is the diagnosis evidence. - name: Upload shard logs if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: paid-logs-slice-${{ matrix.slice }}-a${{ github.run_attempt }} include-hidden-files: true @@ -409,7 +409,7 @@ jobs: fetch-depth: 0 persist-credentials: false - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: paid-plan path: ${{ runner.temp }}/paid-plan @@ -477,7 +477,7 @@ jobs: - name: Upload slice results if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: paid-slice-${{ matrix.slice }}-a${{ github.run_attempt }} path: ${{ runner.temp }}/paid-slice-results @@ -485,7 +485,7 @@ jobs: - name: Upload native capture evidence if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: native-captures-${{ env.EVALS_RUN_ID }} include-hidden-files: true @@ -499,7 +499,7 @@ jobs: - name: Upload shard logs if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: paid-logs-slice-${{ matrix.slice }}-a${{ github.run_attempt }} include-hidden-files: true @@ -559,7 +559,7 @@ jobs: - run: bun run build - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: gate-census-plan path: /tmp/gate-census-plan @@ -601,7 +601,7 @@ jobs: - name: Upload census results if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: gate-census-${{ matrix.slice }}-a${{ github.run_attempt }} path: /tmp/gate-census-results @@ -609,7 +609,7 @@ jobs: - name: Upload native capture evidence if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: native-captures-${{ env.EVALS_RUN_ID }} include-hidden-files: true @@ -647,24 +647,24 @@ jobs: with: bun-version: 1.4.2 - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: paid-plan path: /tmp/paid-report # One directory per attempt-scoped slice artifact (no merge): shard # records never overwrite each other and the first attempt decides. - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: pattern: paid-slice-* path: /tmp/paid-report - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: gate-census-plan path: /tmp/gate-census-report - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: pattern: gate-census-[0-9]* path: /tmp/gate-census-report @@ -705,7 +705,7 @@ jobs: - name: Upload trial outcomes for pass-rate history if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: trial-outcomes-periodic-a${{ github.run_attempt }} path: | @@ -715,7 +715,7 @@ jobs: - name: Upload gate census trial outcomes for pass-rate history if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: trial-outcomes-gate-census-a${{ github.run_attempt }} path: | @@ -824,7 +824,7 @@ jobs: - name: Upload the census report if: always() && (steps.reconcile.outputs.exit != '0' || steps.gate-reconcile.outputs.exit != '0' || steps.pass-rates.outputs.exit != '0' || needs.eval-slices.result != 'success' || !contains(fromJSON('["success","skipped"]'), needs.eval-codex-slices.result) || needs.gate-census.result != 'success') - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: census-report-a${{ github.run_attempt }} path: /tmp/census-report.md diff --git a/.github/workflows/evals.yml b/.github/workflows/evals.yml index 484cda7f5..716b6a22e 100644 --- a/.github/workflows/evals.yml +++ b/.github/workflows/evals.yml @@ -222,7 +222,7 @@ jobs: || echo "- Receipt recovery crashed; reuse stays off this run" >> "$GITHUB_STEP_SUMMARY" - name: Hand the receipt store to plan-slices - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: receipt-store path: /tmp/gstack-eval-input-cache @@ -291,7 +291,7 @@ jobs: - name: Download this PR's receipt store if: github.event_name == 'pull_request' || inputs.pr_receipts != '' continue-on-error: true - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: receipt-store path: /tmp/gstack-eval-input-cache @@ -329,7 +329,7 @@ jobs: if: github.event_name == 'pull_request' run: mkdir -p /tmp/paid-plan/store && cp -a /tmp/gstack-eval-input-cache/. /tmp/paid-plan/store/ - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: paid-plan path: | @@ -404,7 +404,7 @@ jobs: - name: Register gstack skills for PTY smokes uses: ./.github/actions/register-gstack-skills - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: paid-plan path: /tmp/paid-plan @@ -460,7 +460,7 @@ jobs: # attempt and never replace (or collide with) the first attempt's. - name: Upload slice results if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: paid-slice-${{ matrix.slice }}-a${{ github.run_attempt }} path: /tmp/paid-slice-results @@ -468,7 +468,7 @@ jobs: - name: Upload native capture evidence if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: native-captures-${{ env.EVALS_RUN_ID }} include-hidden-files: true @@ -486,7 +486,7 @@ jobs: # longer reds its runner, but its full log is the diagnosis evidence. - name: Upload shard logs if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: paid-logs-slice-${{ matrix.slice }}-a${{ github.run_attempt }} include-hidden-files: true @@ -528,7 +528,7 @@ jobs: with: bun-version: 1.4.2 - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: paid-plan path: /tmp/paid-report @@ -536,7 +536,7 @@ jobs: # One directory per attempt-scoped slice artifact (no merge): shard # records can never overwrite each other, and the report keeps the # first attempt's verdict. - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: pattern: paid-slice-* path: /tmp/paid-report @@ -583,7 +583,7 @@ jobs: - name: Upload reconciliation output for the comment job if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: report-verdict-a${{ github.run_attempt }} path: | @@ -596,7 +596,7 @@ jobs: - name: Upload trial outcomes for pass-rate history if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: trial-outcomes-pr-a${{ github.run_attempt }} path: /tmp/paid-report/trial-outcomes.jsonl @@ -622,12 +622,12 @@ jobs: # `issues` permission, not `pull-requests` (#1802 CI fix). issues: write steps: - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: paid-plan path: /tmp/paid-report - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: report-verdict-a${{ github.run_attempt }} path: /tmp/verdict diff --git a/.github/workflows/model-policy-freshness.yml b/.github/workflows/model-policy-freshness.yml index b55f4bf2b..84abdb087 100644 --- a/.github/workflows/model-policy-freshness.yml +++ b/.github/workflows/model-policy-freshness.yml @@ -41,7 +41,7 @@ jobs: run: bun scripts/model-policy-freshness.ts - name: Preserve evidence report if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 with: name: model-policy-freshness-${{ github.run_id }}-${{ github.run_attempt }} path: ${{ runner.temp }}/model-policy-freshness.json diff --git a/.github/workflows/native-qualification.yml b/.github/workflows/native-qualification.yml index 176e881e5..fedf9c2e6 100644 --- a/.github/workflows/native-qualification.yml +++ b/.github/workflows/native-qualification.yml @@ -42,7 +42,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 with: bun-version: 1.4.2 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24.18.0 - name: Install pinned dependencies @@ -54,7 +54,7 @@ jobs: run: ./.github/scripts/run-cookie-native-qualification.ps1 -OutputRoot "$env:RUNNER_TEMP" - name: Preserve qualification evidence if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 with: name: cookie-native-qualification path: ${{ runner.temp }}/cookie-native-qualification-*/ @@ -71,7 +71,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 with: bun-version: 1.4.2 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24.18.0 - name: Configure git identity (required by tests that init temp repos) @@ -97,7 +97,7 @@ jobs: exit "$status" - name: Upload diagnostic logs if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 with: name: windows-native-diagnostics path: ${{ runner.temp }}/gstack-free-test-*.log @@ -118,7 +118,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 with: bun-version: 1.4.2 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 if: inputs.mode == 'dia-launch-comparison' with: node-version: 24.18.0 @@ -147,7 +147,7 @@ jobs: run: bun --no-env-file --no-install --no-macros --config=/dev/null .github/scripts/run-dia-native-qualification.ts --launch-comparison "$COMPARISON_RUNTIME" - name: Preserve only the sanitized qualification receipt if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 with: name: ${{ inputs.mode == 'dia-gui-readiness' && 'dia-gui-readiness' || inputs.mode == 'dia-launch-comparison' && format('dia-launch-comparison-{0}', matrix.runtime) || 'dia-native-qualification' }} path: ${{ runner.temp }}/dia-native-qualification.json diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 982f7cf45..601e3ebab 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -35,18 +35,18 @@ jobs: with: persist-credentials: false - - uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 + - uses: ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e # v2.5.0 with: results_file: results.sarif results_format: sarif publish_results: true - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: scorecard-sarif path: results.sarif retention-days: 5 - - uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + - uses: github/codeql-action/upload-sarif@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 with: sarif_file: results.sarif diff --git a/.github/workflows/version-gate.yml b/.github/workflows/version-gate.yml index 05aad51f7..e034aaa3e 100644 --- a/.github/workflows/version-gate.yml +++ b/.github/workflows/version-gate.yml @@ -2,6 +2,7 @@ name: Version Gate on: pull_request: + branches: [main] paths: - 'VERSION' - 'CHANGELOG.md' diff --git a/.github/workflows/windows-free-tests.yml b/.github/workflows/windows-free-tests.yml index 8420a7dd3..824ccfc48 100644 --- a/.github/workflows/windows-free-tests.yml +++ b/.github/workflows/windows-free-tests.yml @@ -61,7 +61,7 @@ jobs: run: | matrix=$(bun run scripts/test-free-shards.ts --windows-only --ci-plan "$RUNNER_TEMP/windows-plan.json" --shards 6) echo "matrix=$matrix" >> "$GITHUB_OUTPUT" - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: windows-plan path: ${{ runner.temp }}/windows-plan.json @@ -84,11 +84,11 @@ jobs: with: bun-version: 1.4.2 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24.18.0 - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: windows-plan path: ${{ runner.temp }} @@ -143,7 +143,7 @@ jobs: - name: Upload strict shard result if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: windows-result-${{ matrix.shard }} path: ${{ runner.temp }}/windows-results/*.json @@ -153,7 +153,7 @@ jobs: # not causes, so complete shard logs are kept on every run. - name: Upload full shard logs if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: windows-free-test-shard-logs-${{ matrix.shard }} path: | @@ -164,7 +164,7 @@ jobs: - name: Upload flake ledger if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: flake-ledger-windows-${{ matrix.shard }} path: ${{ runner.temp }}/flake-ledger.jsonl @@ -191,11 +191,11 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.4.2 - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: windows-plan path: ${{ runner.temp }} - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: pattern: windows-result-* merge-multiple: true @@ -214,7 +214,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.4.2 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24.18.0 - name: Prepare the same inputs as a Windows shard @@ -240,7 +240,7 @@ jobs: if: always() shell: bash run: cp scripts/free-test-durations.json "$RUNNER_TEMP/free-test-durations-windows.json" - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 if: always() with: name: free-test-durations-windows diff --git a/.github/workflows/windows-pregate-e2e.yml b/.github/workflows/windows-pregate-e2e.yml new file mode 100644 index 000000000..3c225d19b --- /dev/null +++ b/.github/workflows/windows-pregate-e2e.yml @@ -0,0 +1,232 @@ +name: Windows Pregate E2E + +# Native proof for #3197 on windows-latest: Bun on Windows cannot execute an +# extensionless shebang bin (bin/gstack-wtree, gstack-slug, gstack-redact), +# so every TypeScript caller goes through lib/script-invocation.ts, which +# hands bash bins to Git Bash and bun bins to Bun. This job drives the real +# CLIs from TypeScript entry points against a fixture repository: +# - the resolver finds Git Bash from `git --exec-path`; +# - gstack-restamp --dry-run fingerprints the tree and stamps; +# - gstack-regen check (scratch worktree from the fingerprint, the entry's +# command through `bash -c`) and write (the no-scratch path); +# - gstack-evidence records a content fingerprint, its command through bash -c; +# - gstack-gate-diff records a candidate with a tree id, not `+nowtree`; +# - a hook invocation is timed cold and warm (the cached resolution); +# - a missing GSTACK_GIT_BASH refuses with BASH_UNAVAILABLE, not `()`; +# - the same journey under a pinned, checksum-verified PortableGit found +# only through `git --exec-path`. +# `gstack-pregate run` joins this job in the follow-up that migrates the +# pregate consumers (item 10b). +# +# Runner: GitHub-hosted free windows-latest. ~4-6 min. + +on: + pull_request: + branches: [main] + paths: + - 'lib/script-invocation.ts' + - 'lib/regen.ts' + - 'lib/restamp.ts' + - 'lib/gbrain-exec.ts' + - 'lib/gate-diff/**' + - 'lib/pregate/**' + - 'lib/result-codes.ts' + - 'bin/gstack-wtree' + - 'bin/gstack-slug' + - 'bin/gstack-path-spelling.sh' + - 'bin/gstack-restamp' + - 'bin/gstack-regen' + - 'bin/gstack-evidence' + - 'bin/gstack-gate-diff' + - 'bin/gstack-redact' + - 'hosts/claude/hooks/**' + - '.github/workflows/windows-pregate-e2e.yml' + workflow_dispatch: + +concurrency: + group: windows-pregate-e2e-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + +env: + PORTABLE_GIT_VERSION: 2.56.0.windows.2 + PORTABLE_GIT_FILE: PortableGit-2.56.0.2-64-bit.7z.exe + PORTABLE_GIT_SHA256: 075e158ef8e1f0ab80b347e245405d3eca735c2dc88fd8e032e137d0ca61f61b + +jobs: + windows-pregate: + runs-on: windows-latest + timeout-minutes: 20 + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.4.2 + + - name: Configure git identity + run: | + git config --global user.email "windows-pregate-e2e@gstack.test" + git config --global user.name "Windows Pregate E2E" + git config --global init.defaultBranch main + git config --global commit.gpgsign false + shell: bash + + - name: Install dependencies + run: bun install --frozen-lockfile + shell: bash + + - name: Build the fixture repository + # A VERSION + CHANGELOG + ship policy + generated-file registry, a + # bare origin and a feature branch with an [Unreleased] entry. + run: | + set -euo pipefail + T="$RUNNER_TEMP/pregate-e2e" + rm -rf "$T"; mkdir -p "$T/home" + git init -q --bare -b main "$T/origin.git" + git init -q -b main "$T/work" + cd "$T/work" + mkdir -p generated .gstack + echo 1.2.3.0 > VERSION + printf '{ "name": "fixture", "version": "1.2.3" }\n' > package.json + printf '%s\n' "const fs=require('fs');const v=fs.readFileSync('VERSION','utf8').trim();fs.writeFileSync('generated/digest.md','# digest v'+v+'\\n');" > gen.js + node gen.js + printf '# Changelog\n\n## [1.2.3.0] - 2026-10-01\n\n- base release\n' > CHANGELOG.md + cat > .gstack/ship-policy.json <<'JSON' + { "$schema": "https://github.com/garrytan/gstack/docs/ship-policy.md#schema-v1", "bump": "patch", "queue_mode": "stamp-at-merge", "release_tool": "node gen.js", "release_outputs": ["generated/**"], "stamp_paths": [], "changelog": "CHANGELOG.md", "history": "squash-ok", "stamp_sensitive_gates": [] } + JSON + cat > .gstack/generated.json <<'JSON' + { "$schema": "https://github.com/garrytan/gstack/docs/pregate.md#generated-v1", "entries": [ { "id": "digest", "command": "node gen.js", "outputs": ["generated/**"], "inputs": ["VERSION", "gen.js"] } ] } + JSON + git add -A && git commit -qm base + git remote add origin "$T/origin.git" && git push -q origin main && git remote set-head origin main + git checkout -qb feature + echo feature > feature.txt + printf '# Changelog\n\n## [Unreleased]\n\n- feature change\n\n## [1.2.3.0] - 2026-10-01\n\n- base release\n' > CHANGELOG.md + git add -A && git commit -qm feature + shell: bash + + - name: The resolver finds the runner's Git Bash from git --exec-path + run: | + set -euo pipefail + git --version + OUT=$(bun -e 'import { resolveWindowsBash } from "./lib/script-invocation"; const r = resolveWindowsBash(); console.log(JSON.stringify(r)); process.exit(r.ok ? 0 : 1);') + echo "$OUT" + echo "$OUT" | grep -q '"source":"git --exec-path"' || (echo "Git Bash was not found through git --exec-path" && exit 1) + echo "$OUT" | grep -qiE '"bash":"[^"]*/(usr/)?bin/bash\.exe"' || (echo "the resolved bash is not a Git Bash bash.exe" && exit 1) + shell: bash + + - name: A hook invocation, timed cold and warm (cached Git Bash resolution) + run: | + set -euo pipefail + T="$RUNNER_TEMP/hook-timing" + rm -rf "$T"; mkdir -p "$T" + PAYLOAD='{"session_id":"e2e","hook_event_name":"PostToolUse","tool_name":"AskUserQuestion","tool_use_id":"TOOL_ID","tool_input":{"questions":[{"question":"Ship it?","options":[{"label":"Ship now (recommended)"},{"label":"Hold"}]}]},"tool_response":{"answers":[{"option_label":"Ship now"}]}}' + run_hook() { + local start end + start=$(date +%s%3N) + printf '%s' "${PAYLOAD/TOOL_ID/$1}" | (cd "$T" && GSTACK_STATE_ROOT="$T" GSTACK_QUESTION_LOG_NO_DERIVE=1 bun "$GITHUB_WORKSPACE/hosts/claude/hooks/question-log-hook.ts") + end=$(date +%s%3N) + echo $((end - start)) + } + COLD=$(run_hook toolu_cold) + test -f "$T/cache/windows-bash.json" || (echo "the hook did not cache its Git Bash resolution" && exit 1) + cat "$T/cache/windows-bash.json" + WARM=$(run_hook toolu_warm) + echo "question-log-hook: cold ${COLD} ms, warm ${WARM} ms" + echo "### question-log-hook on windows-latest: cold ${COLD} ms, warm ${WARM} ms" >> "$GITHUB_STEP_SUMMARY" + LOGS=$(find "$T/projects" -name question-log.jsonl | head -n 1) + test -n "$LOGS" || (echo "the hook wrote no question-log.jsonl" && exit 1) + test "$(wc -l < "$LOGS")" -eq 2 || (echo "expected two logged events" && exit 1) + test ! -s "$T/hook-errors.log" || { cat "$T/hook-errors.log"; echo "the hook logged errors"; exit 1; } + shell: bash + + - name: A missing GSTACK_GIT_BASH refuses with BASH_UNAVAILABLE and names every candidate + run: | + set -uo pipefail + T="$RUNNER_TEMP/pregate-e2e" + cd "$T/work" + OUT=$(GSTACK_HOME="$T/home" GSTACK_GIT_BASH='C:\no-such-git\bin\bash.exe' bun "$GITHUB_WORKSPACE/bin/gstack-restamp" --base main --allow-repo-commands --next --dry-run 2>&1) && CODE=0 || CODE=$? + echo "$OUT" | tail -n 3 + test "$CODE" -ne 0 || (echo "restamp passed without a bash" && exit 1) + echo "$OUT" | grep -q 'cannot fingerprint the working tree (gstack-wtree could not start: gstack: no Git Bash found' || (echo "the refusal does not say why" && exit 1) + echo "$OUT" | grep -q 'GSTACK_GIT_BASH C:/no-such-git/bin/bash.exe: does not exist' || (echo "the refusal does not list the override" && exit 1) + echo "$OUT" | grep -q '(BASH_UNAVAILABLE)' || (echo "the refusal has no result code" && exit 1) + shell: bash + + - name: Download the pinned PortableGit and verify its checksum + run: | + set -euo pipefail + PG="$RUNNER_TEMP/PortableGit" + curl -fsSL -o "$RUNNER_TEMP/$PORTABLE_GIT_FILE" "https://github.com/git-for-windows/git/releases/download/v${PORTABLE_GIT_VERSION}/${PORTABLE_GIT_FILE}" + echo "$PORTABLE_GIT_SHA256 $RUNNER_TEMP/$PORTABLE_GIT_FILE" | sha256sum -c - + "$RUNNER_TEMP/$PORTABLE_GIT_FILE" -o"$(cygpath -w "$PG")" -y + test -f "$PG/bin/bash.exe" || (echo "PortableGit has no bin/bash.exe" && exit 1) + "$PG/cmd/git.exe" --version + shell: bash + + - name: The resolver finds the PortableGit bash through git --exec-path alone + run: | + set -euo pipefail + PG=$(cygpath -u "$RUNNER_TEMP/PortableGit") + export PATH="$PG/cmd:$PATH" + git --version + WANT=$(cygpath -m "$PG/bin/bash.exe") + OUT=$(bun -e 'import { resolveWindowsBash } from "./lib/script-invocation"; const r = resolveWindowsBash(); console.log(JSON.stringify(r)); process.exit(r.ok ? 0 : 1);') + echo "$OUT" + echo "$OUT" | grep -qiF "\"bash\":\"$WANT\"" || (echo "expected $WANT" && exit 1) + echo "$OUT" | grep -q '"source":"git --exec-path"' || (echo "PortableGit was not found through git --exec-path" && exit 1) + shell: bash + + - name: The fixture journey (restamp, regen, evidence, gate-diff) under the runner's Git and the pinned PortableGit + run: | + set -euo pipefail + journey() { + G="$PWD" + T="$RUNNER_TEMP/pregate-e2e" + export GSTACK_HOME="$T/home" GSTACK_STATE_ROOT="$T/home" + cd "$T/work" + git reset -q --hard && git clean -fdq + rm -rf "$T/home/projects" + + OUT=$(bun "$G/bin/gstack-restamp" --base main --allow-repo-commands --next --dry-run 2>&1) || { echo "$OUT"; echo "gstack-restamp --dry-run failed"; exit 1; } + echo "$OUT" | tail -n 8 + echo "$OUT" | grep -q '^+1\.2\.4\.0$' || (echo "restamp did not stamp VERSION" && exit 1) + echo "$OUT" | grep -q '^+# digest v1\.2\.4\.0$' || (echo "restamp did not run the release tool" && exit 1) + echo "$OUT" | grep -q 'GSTACK_RESULT: skill=restamp status=complete' || (echo "restamp did not complete" && exit 1) + test -z "$(git status --porcelain)" || (echo "--dry-run wrote to the tree" && exit 1) + + OUT=$(bun "$G/bin/gstack-regen" check --allow-repo-commands 2>&1) || { echo "$OUT"; echo "gstack-regen check failed"; exit 1; } + echo "$OUT" + echo "$OUT" | grep -q '^regen digest: fresh' || (echo "the regen command did not run fresh in the scratch worktree" && exit 1) + echo "$OUT" | grep -qE 'GSTACK_RESULT: skill=regen status=complete run=[0-9a-f]{40}$' || (echo "regen check has no tree fingerprint" && exit 1) + OUT=$(bun "$G/bin/gstack-regen" write --allow-repo-commands 2>&1) || { echo "$OUT"; echo "gstack-regen write failed"; exit 1; } + echo "$OUT" | grep -qE 'GSTACK_RESULT: skill=regen status=complete run=[0-9a-f]{40}$' || (echo "regen write has no tree fingerprint" && exit 1) + + bun "$G/bin/gstack-evidence" run --label tests -- 'echo evidence-ok && test -f VERSION' + LEDGER=$(find "$T/home/projects" -name '*-evidence.jsonl' | head -n 1) + test -n "$LEDGER" || (echo "no evidence ledger written" && exit 1) + tail -n 1 "$LEDGER" + tail -n 1 "$LEDGER" | grep -qE '"exit":0,' || (echo "the evidence command did not exit 0 through bash -c" && exit 1) + tail -n 1 "$LEDGER" | grep -qE '"wtree":"[0-9a-f]{40}"' || (echo "evidence recorded no content fingerprint" && exit 1) + + set +e + OUT=$(bun "$G/bin/gstack-gate-diff" main 2>&1) + CODE=$? + set -e + echo "$OUT" | tail -n 3 + test "$CODE" -eq 0 || (echo "gstack-gate-diff exited $CODE" && exit 1) + ! echo "$OUT" | grep -q 'fingerprint unavailable' || (echo "gate-diff could not fingerprint the tree" && exit 1) + echo "$OUT" | grep -qE 'candidate=[0-9a-f]{12}\+[0-9a-f]{12} ' || (echo "gate-diff candidate has no tree id" && exit 1) + } + echo "::group::runner Git for Windows ($(git --version))" + (journey) + echo "::endgroup::" + echo "::group::pinned PortableGit $PORTABLE_GIT_VERSION" + PG_CMD=$(cygpath -u "$RUNNER_TEMP/PortableGit/cmd") + (export PATH="$PG_CMD:$PATH"; git --version; journey) + echo "::endgroup::" + shell: bash diff --git a/.github/workflows/windows-setup-e2e.yml b/.github/workflows/windows-setup-e2e.yml index 195c88466..6b2b928fe 100644 --- a/.github/workflows/windows-setup-e2e.yml +++ b/.github/workflows/windows-setup-e2e.yml @@ -18,10 +18,21 @@ name: Windows Setup E2E # had the false positive recovers with the documented # `git pull --ff-only --autostash && ./setup`. # +# setup.ps1 under Windows PowerShell 5.1 (powershell.exe, #3130): its cases +# in test/setup-ps1.test.ts run with a space in the temp path, and a real +# install runs from a checkout path with a space. The held-install job +# (#3184) builds a prefix-mode v1.91.101.0 install behind this PR's tree in +# three states (clean, dirty-conflicting, already-unmerged): the old updater +# must log `HELD hook-check-unavailable`, the PowerShell recovery form that +# docs/troubleshooting.md publishes must reach the PR's tree with every +# local edit in a rescue ref, the trial and go-back forms must move the +# install, and the new updater must then take a later release whose symlink +# sorts before its target. Each job asserts the exact list of cases it ran. +# # Separate from windows-free-tests.yml because that one runs a curated # unit-test subset; this one exercises the install path itself. # -# Runner: GitHub-hosted free windows-latest. ~6-10 min total. +# Runner: GitHub-hosted free windows-latest, two parallel jobs. ~10-25 min. on: pull_request: @@ -33,6 +44,9 @@ on: - 'scripts/build-cso-windows.ps1' - 'scripts/write-version-files.sh' - 'setup' + - 'setup.ps1' + - 'test/setup-ps1.test.ts' + - 'docs/troubleshooting.md' - 'browse/src/cli.ts' - 'browse/src/find-browse.ts' - 'browse/src/server.ts' @@ -46,6 +60,9 @@ on: - 'bin/gstack-launch-probe.sh' - 'bin/gstack-hook-check' - 'bin/gstack-session-update' + - 'bin/gstack-ff-update' + - 'bin/gstack-claude-root.sh' + - 'bin/gstack-browser-ensure' - 'bin/gstack-path-spelling.sh' - 'lib/path-spelling.ts' - 'hosts/claude/hooks/**' @@ -97,6 +114,34 @@ jobs: run: bun install --frozen-lockfile shell: bash + - name: setup.ps1 cases under Windows PowerShell 5.1 with a space in the temp path (#3130) + # test/setup-ps1.test.ts runs powershell.exe on Windows: argv forwarding + # with adversarial arguments, the empty-argument refusal, and pwsh. The + # skips are its Linux-only cases: a missing prerequisite, and the Git + # Bash resolution table, whose locations are injected into pwsh. + run: | + set -euo pipefail + : > "$RUNNER_TEMP/setup-cases" + # shellcheck disable=SC2016 + MAJOR=$(powershell.exe -NoProfile -NonInteractive -Command '$PSVersionTable.PSVersion.Major' | tr -d '\r') + test "$MAJOR" = 5 || (echo "powershell.exe is PowerShell '$MAJOR', not Windows PowerShell 5.1" && exit 1) + echo ps51 >> "$RUNNER_TEMP/setup-cases" + SPACED="$RUNNER_TEMP/tmp with space" + mkdir -p "$SPACED" + TEMP="$(cygpath -w "$SPACED")" + export TEMP TMP="$TEMP" + set +e + OUT=$(bun test test/setup-ps1.test.ts 2>&1) + CODE=$? + set -e + echo "$OUT" + test "$CODE" -eq 0 || (echo "test/setup-ps1.test.ts exited $CODE" && exit 1) + grep -qE '^ *4 pass$' <<< "$OUT" || (echo "expected exactly 4 executed setup.ps1 cases" && exit 1) + grep -qE '^ *0 fail$' <<< "$OUT" || (echo "a setup.ps1 case failed" && exit 1) + grep -qE '^ *31 skip$' <<< "$OUT" || (echo "expected exactly 31 skipped setup.ps1 cases (the Linux-only prerequisite case, the 14 pwsh-on-Linux Git Bash resolution rows and the 16 shared-table rows from test/fixtures/windows-bash-resolution.json)" && exit 1) + echo setup-ps1-cases >> "$RUNNER_TEMP/setup-cases" + shell: bash + - name: Run bun run build (the previously-broken path) # This is the regression gate. Bun's Windows shell parser rejected # multiple constructs the old inline build chain used; the wave @@ -165,6 +210,33 @@ jobs: echo "runtime root: $ROOT -> source $(cat "$ROOT/.source-path")" shell: bash + - name: .\setup.ps1 installs from a checkout path with a space under Windows PowerShell 5.1 (#3130) + # A copy of this built checkout (build outputs keep their mtimes, so + # setup does not rebuild; node_modules is reinstalled) under a path + # with a space, installed through powershell.exe into an isolated HOME. + # -h stores symlinks as their targets: MSYS tar cannot extract a + # symlink that precedes its target (connect-chrome, #3184). + run: | + set -euo pipefail + SPACED="$(cygpath -u "$RUNNER_TEMP")/gstack checkout" + mkdir -p "$SPACED" + tar --exclude=./node_modules -chf - . | tar -xpf - -C "$SPACED" + E2E_HOME="$RUNNER_TEMP/gstack-ps1-home" + mkdir -p "$E2E_HOME" + set +e + OUT=$(HOME="$E2E_HOME" USERPROFILE="$(cygpath -w "$E2E_HOME")" GSTACK_SKIP_PLAYWRIGHT=1 GSTACK_STRICT_BUILD=0 \ + powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$(cygpath -w "$SPACED/setup.ps1")" --host codex 2>&1) + CODE=$? + set -e + echo "$OUT" | tail -n 40 + test "$CODE" -eq 0 || (echo ".\\setup.ps1 exited $CODE" && exit 1) + ! grep -q 'unexpected EOF' <<< "$OUT" || (echo "bash read a broken -c script (#3130)" && exit 1) + ROOT="$E2E_HOME/.codex/skills/gstack" + test -f "$ROOT/.source-path" || (echo "MISSING: $ROOT/.source-path" && exit 1) + grep -q 'gstack checkout' "$ROOT/.source-path" || (echo "the runtime root does not come from the spaced checkout: $(cat "$ROOT/.source-path")" && exit 1) + echo setup-ps1-install >> "$RUNNER_TEMP/setup-cases" + shell: bash + - name: Installed browse runs a piped chain flow from an unrelated directory run: | set -euo pipefail @@ -297,3 +369,249 @@ jobs: echo "$DOCTOR" echo "$DOCTOR" | grep -qE '^warn +cso +' || (echo "doctor did not report the CSO failure" && exit 1) shell: bash + + - name: Every setup.ps1 case ran + run: | + set -euo pipefail + cat "$RUNNER_TEMP/setup-cases" + test "$(cat "$RUNNER_TEMP/setup-cases")" = "$(printf 'ps51\nsetup-ps1-cases\nsetup-ps1-install')" || (echo "a setup.ps1 case did not run" && exit 1) + shell: bash + + held-install-journey: + # #3184: MSYS tar cannot extract a symlink that precedes its target, so + # every v1.91.101.0 install on Windows holds every release. The Git Bash + # forms of the recovery run in test/held-install-recovery.test.ts; this + # job runs the PowerShell forms under powershell.exe, verbatim from + # docs/troubleshooting.md, against the runner's real ~/.claude install + # path ($HOME in PowerShell). GSTACK_E2E_POWERSHELL and + # GSTACK_E2E_UPDATER_PATH_PREFIX exist only to run these steps off Windows. + runs-on: windows-latest + timeout-minutes: 50 + env: + GSTACK_SKIP_PLAYWRIGHT: '1' + GSTACK_STRICT_BUILD: '0' + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.4.2 + + - name: Configure git identity + run: | + git config --global user.email "windows-setup-e2e@gstack.test" + git config --global user.name "Windows Setup E2E" + git config --global init.defaultBranch main + shell: bash + + - name: Build the held-install fixture (v1.91.101.0 behind this PR's tree) + # origin's main carries this PR's tree on top of the v1.91.101.0 + # release tree (commits built from the two trees, so the shallow + # checkout's history is never needed); fix-branch adds one file for + # the trial form. + run: | + set -euo pipefail + OLD_RELEASE=47d639abffa882b8cbb641d7d1dedf08ecada9ec # v1.91.101.0 + T="$RUNNER_TEMP/held" + rm -rf "$T" && mkdir -p "$T" + git cat-file -e "$OLD_RELEASE^{commit}" 2>/dev/null || git fetch -q --no-tags --depth=1 origin "$OLD_RELEASE" + c() { git commit-tree "$@"; } + HELD=$(c -m "v1.91.101.0" "$OLD_RELEASE^{tree}") + MAIN=$(c -p "$HELD" -m "this PR's tree" 'HEAD^{tree}') + export GIT_INDEX_FILE="$T/fix.index" + git read-tree "$MAIN" + git update-index --add --cacheinfo "100644,$(printf 'trial\n' | git hash-object -w --stdin),e2e-trial-marker" + FIX=$(c -p "$MAIN" -m "a fix branch to try" "$(git write-tree)") + unset GIT_INDEX_FILE + test "$(git show "$HELD:VERSION")" = 1.91.101.0 || (echo "the held tree is not v1.91.101.0" && exit 1) + git ls-tree "$MAIN" connect-chrome | grep -q '^120000 ' || (echo "this PR's tree has no connect-chrome symlink to reproduce the hold" && exit 1) + git init -q --bare -b main "$T/origin.git" + git push -q "$T/origin.git" "$MAIN:refs/heads/main" "$FIX:refs/heads/fix-branch" + printf 'HELD=%s\nMAIN=%s\nFIX=%s\n' "$HELD" "$MAIN" "$FIX" > "$T/vars" + : > "$T/cases" + cat > "$T/lib.sh" <<'LIB' + T="$RUNNER_TEMP/held" + . "$T/vars" + INSTALL="$HOME/.claude/skills/gstack" + STATE="$HOME/.gstack" + LOG="$STATE/analytics/session-update.log" + PS="${GSTACK_E2E_POWERSHELL:-powershell.exe}" + w() { if command -v cygpath >/dev/null 2>&1; then cygpath -w "$1"; else printf '%s\n' "$1"; fi; } + die() { echo "FAIL: $*" >&2; [ ! -f "$LOG" ] || { echo "--- updater log" && cat "$LOG"; } >&2; exit 1; } + case_ok() { echo "$1" >> "$T/cases"; echo "CASE OK: $1"; } + # The command docs/troubleshooting.md publishes under . + form() { awk -v m="" '$0 == m { f = 1; next } f && /^```/ { if (o) exit; o = 1; next } f && o { print }' "$GITHUB_WORKSPACE/docs/troubleshooting.md"; } + # run_ps_form NAME [REPLACEMENT-FOR-.\setup.ps1] [BRANCH]: the form, + # verbatim, as a script under powershell.exe; prints its output. + run_ps_form() { + f=$(form "$1") + case "$f" in *'{ .\setup.ps1 }') ;; *) die "form $1 is missing or does not end in .\setup.ps1: $f" ;; esac + [ -z "${2:-}" ] || f="${f%'.\setup.ps1 }'}$2 }" + [ -z "${3:-}" ] || f=${f//''/$3} + printf '%s\r\nexit $LASTEXITCODE\r\n' "$f" > "$T/form.ps1" + "$PS" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$(w "$T/form.ps1")" 2>&1 + } + # run_updater PATTERN [SECONDS]: one session-start check with the + # throttle cleared, waiting for its background run to log PATTERN. + run_updater() { + rm -f "$STATE/.last-session-update" + n0=$( { cat "$LOG" 2>/dev/null || true; } | wc -l) + ( cd "$T" && PATH="${GSTACK_E2E_UPDATER_PATH_PREFIX:+$GSTACK_E2E_UPDATER_PATH_PREFIX:}$PATH" bash "$INSTALL/bin/gstack-session-update" ) || die "the updater exited $?" + i=0 + until tail -n +"$((n0 + 1))" "$LOG" 2>/dev/null | grep -E "$1" > "$T/updater-line"; do + i=$((i + 1)); [ "$i" -le "${2:-180}" ] || die "no updater log line matching '$1' within ${2:-180}s" + sleep 1 + done + cat "$T/updater-line" + i=0; while [ -d "$STATE/.setup-lock" ] && [ "$i" -lt 120 ]; do i=$((i + 1)); sleep 1; done + } + # install_state clean|dirty|unmerged: a prefix-mode v1.91.101.0 + # install one release behind origin's main. + install_state() { + rm -rf "$INSTALL" "$STATE" + mkdir -p "$(dirname "$INSTALL")" + git clone -q "$T/origin.git" "$INSTALL" + git -C "$INSTALL" reset -q --hard "$HELD" + GSTACK_SETUP_RUNNING=1 bash "$INSTALL/bin/gstack-config" set auto_upgrade true + GSTACK_SETUP_RUNNING=1 bash "$INSTALL/bin/gstack-config" set skill_prefix true + bash "$INSTALL/bin/gstack-patch-names" "$INSTALL" true >/dev/null + ! git -C "$INSTALL" diff --quiet -- SKILL.md '*/SKILL.md' || die "prefix mode left no footprint" + case "$1" in + dirty) + git -C "$INSTALL" diff -U0 "$HELD" "$MAIN" -- docs/troubleshooting.md > "$T/upstream.diff" + L=$(sed -n 's/^-\([^-]\)/\1/p' "$T/upstream.diff" | sed -n 1p) + [ -n "$L" ] || die "docs/troubleshooting.md has no upstream change to conflict with" + L="$L" awk '!d && $0 == ENVIRON["L"] { print $0 " LOCAL-EDIT"; d = 1; next } { print }' "$INSTALL/docs/troubleshooting.md" > "$T/edited" + cat "$T/edited" > "$INSTALL/docs/troubleshooting.md" + grep -q 'LOCAL-EDIT' "$INSTALL/docs/troubleshooting.md" || die "the local edit did not apply" + ;; + unmerged) + b=$(printf 'base\n' | git -C "$INSTALL" hash-object -w --stdin) + o=$(printf 'ours LOCAL-EDIT\n' | git -C "$INSTALL" hash-object -w --stdin) + t=$(printf 'theirs\n' | git -C "$INSTALL" hash-object -w --stdin) + git -C "$INSTALL" update-index --force-remove README.md + printf '100644 %s 1\tREADME.md\n100644 %s 2\tREADME.md\n100644 %s 3\tREADME.md\n' "$b" "$o" "$t" | git -C "$INSTALL" update-index --index-info + printf '<<<<<<< ours\nours LOCAL-EDIT\n=======\ntheirs\n>>>>>>> theirs\n' > "$INSTALL/README.md" + [ -n "$(git -C "$INSTALL" ls-files -u)" ] || die "the install has no unmerged files" + ;; + esac + } + # check_recovered STATE: at origin's main, nothing unmerged, and + # every local edit (only those) in one rescue ref. + check_recovered() { + [ "$(git -C "$INSTALL" rev-parse HEAD)" = "$MAIN" ] || die "$1: the install is at $(git -C "$INSTALL" rev-parse HEAD), not $MAIN" + [ -z "$(git -C "$INSTALL" ls-files -u)" ] || die "$1: unmerged files remain" + [ -f "$INSTALL/bin/gstack-ff-update" ] || die "$1: bin/gstack-ff-update is missing" + refs=$(git -C "$INSTALL" for-each-ref --format='%(refname)' refs/gstack/rescue) + if [ "$1" = clean ]; then + [ -z "$refs" ] || die "clean: a rescue ref was made: $refs" + return 0 + fi + [ "$(printf '%s\n' "$refs" | grep -c .)" = 1 ] || die "$1: expected one rescue ref, got: $refs" + file=docs/troubleshooting.md; [ "$1" != unmerged ] || file=README.md + git -C "$INSTALL" show "$refs:$file" > "$T/rescued" + grep -q 'LOCAL-EDIT' "$T/rescued" || die "$1: the rescue ref lacks the local edit to $file" + [ "$(git -C "$INSTALL" diff --name-only "$refs^" "$refs")" = "$file" ] || die "$1: the rescue ref holds more than the user's edit" + } + LIB + bash -n "$T/lib.sh" + shell: bash + + - name: 'Held clean and already-unmerged installs: v1.91.101.0 holds, the PowerShell recovery form recovers' + # The form's last step (.\setup.ps1) is replaced by a marker here; the + # dirty-conflicting state below runs it verbatim. + run: | + set -euo pipefail + . "$RUNNER_TEMP/held/lib.sh" + for state in clean unmerged; do + install_state "$state" + run_updater '^[^ ]+ HELD hook-check-unavailable' + case_ok "$state/v1.91.101.0-held" + CODE=0 + OUT=$(run_ps_form recovery-powershell 'Write-Output SETUP_WOULD_RUN') || CODE=$? + echo "$OUT" + [ "$CODE" -eq 0 ] || die "$state: the recovery form exited $CODE" + grep -q '^SETUP_WOULD_RUN' <<< "$OUT" || die "$state: the recovery form did not reach its setup step" + check_recovered "$state" + case_ok "$state/recovered" + done + shell: bash + + - name: 'Held dirty-conflicting install: the PowerShell recovery form, verbatim with .\setup.ps1' + run: | + set -euo pipefail + . "$RUNNER_TEMP/held/lib.sh" + install_state dirty + run_updater '^[^ ]+ HELD hook-check-unavailable' + case_ok dirty/v1.91.101.0-held + set +e + OUT=$(run_ps_form recovery-powershell) + CODE=$? + set -e + echo "$OUT" | tail -n 60 + [ "$CODE" -eq 0 ] || die "dirty: the recovery form exited $CODE" + check_recovered dirty + case_ok dirty/recovered-and-set-up + shell: bash + + - name: The PowerShell trial and go-back forms move the install + run: | + set -euo pipefail + . "$RUNNER_TEMP/held/lib.sh" + set +e + OUT=$(run_ps_form trial-powershell '' fix-branch) + CODE=$? + set -e + echo "$OUT" | tail -n 30 + [ "$CODE" -eq 0 ] || die "the trial form exited $CODE" + [ "$(git -C "$INSTALL" rev-parse HEAD)" = "$FIX" ] || die "the trial form did not check out fix-branch" + ! git -C "$INSTALL" symbolic-ref -q HEAD >/dev/null || die "the trial form left HEAD on a branch" + case_ok trial + set +e + OUT=$(run_ps_form goback-powershell) + CODE=$? + set -e + echo "$OUT" | tail -n 30 + [ "$CODE" -eq 0 ] || die "the go-back form exited $CODE" + [ "$(git -C "$INSTALL" symbolic-ref --short HEAD)" = main ] || die "the go-back form did not return to main" + [ "$(git -C "$INSTALL" rev-parse HEAD)" = "$MAIN" ] || die "main is not at $MAIN" + case_ok goback + shell: bash + + - name: The new updater takes a later release whose symlink sorts before its target + # Pushed only now (after recovery), so the hold above is + # connect-chrome's alone. The old updater's archive | tar extraction is + # what MSYS tar failed; the new one extracts without tar. + run: | + set -euo pipefail + . "$RUNNER_TEMP/held/lib.sh" + export GIT_INDEX_FILE="$T/synth.index" + git read-tree "$MAIN" + git update-index --add \ + --cacheinfo "120000,$(printf 'b-target' | git hash-object -w --stdin),e2e-synthetic/a-link" \ + --cacheinfo "100644,$(printf 'target\n' | git hash-object -w --stdin),e2e-synthetic/b-target" + SYNTH=$(git commit-tree -p "$MAIN" -m "a later release: a symlink sorted before its target" "$(git write-tree)") + unset GIT_INDEX_FILE + git push -q "$T/origin.git" "$SYNTH:refs/heads/main" + run_updater '^[^ ]+ (UPDATED|HELD|PULL_FAILED|SETUP_FAILED|MIGRATIONS_FAILED) ' 1800 + grep -q ' UPDATED ' "$T/updater-line" || die "the new updater did not log UPDATED" + [ "$(git -C "$INSTALL" rev-parse HEAD)" = "$SYNTH" ] || die "the install is not at the later release" + case_ok later-release/updated + shell: bash + + - name: Every held-install case ran + run: | + set -euo pipefail + cat "$RUNNER_TEMP/held/cases" + EXPECTED='clean/v1.91.101.0-held + clean/recovered + unmerged/v1.91.101.0-held + unmerged/recovered + dirty/v1.91.101.0-held + dirty/recovered-and-set-up + trial + goback + later-release/updated' + test "$(cat "$RUNNER_TEMP/held/cases")" = "$(printf '%s\n' "$EXPECTED" | sed 's/^ *//')" || (echo "a held-install case did not run" && exit 1) + shell: bash diff --git a/.osv-scanner.toml b/.osv-scanner.toml index 76ab7c36e..cac5e40a6 100644 --- a/.osv-scanner.toml +++ b/.osv-scanner.toml @@ -30,18 +30,21 @@ ignoreUntil = 2026-11-30T00:00:00Z [[IgnoredVulns]] id = "GHSA-5p2g-fcmc-qvqq" -# image-size 1.2.1 via html-to-docx@1.8.0 (pins ^1.0.0). No fixed release -# exists (FIXED VERSION = --; tracking: #2753). Exposure: image-size only parses images the -# user themselves embeds into their own generated .docx — no untrusted input -# path. Upgrade trigger: an image-size release with a fix, or html-to-docx -# moving off it. Tracking: #2753. -reason = "No fixed version exists; local-only input path (user's own docx images). Re-evaluate on expiry." +# image-size 1.2.1 via html-to-docx@1.8.0. image-size 2.0.3 (2026-09-14) +# fixes both advisories, but html-to-docx pins image-size ^1.0.0 and no fixed +# 1.x exists, so taking the fix is a cross-major override. Exposure: +# image-size only parses images the user themselves embeds into their own +# generated .docx; no untrusted input path. Upgrade trigger: html-to-docx +# moving off image-size 1.x, or a deliberate override to 2.x after checking +# html-to-docx's call site against 2.x's API with a docx-export smoke test. +# Tracking: #2753. +reason = "Fixed in image-size 2.0.3, but html-to-docx pins image-size ^1.0.0 (no fixed 1.x); local-only input path (user's own docx images). Re-evaluate on expiry." ignoreUntil = 2026-11-30T00:00:00Z [[IgnoredVulns]] id = "GHSA-w3rx-r6r6-pgpr" # Same package/node as GHSA-5p2g-fcmc-qvqq above; same rationale. -reason = "No fixed version exists; local-only input path (user's own docx images). Re-evaluate on expiry." +reason = "Fixed in image-size 2.0.3, but html-to-docx pins image-size ^1.0.0 (no fixed 1.x); local-only input path (user's own docx images). Re-evaluate on expiry." ignoreUntil = 2026-11-30T00:00:00Z [[IgnoredVulns]] diff --git a/CHANGELOG.md b/CHANGELOG.md index 487e88439..75563f124 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,58 @@ # Changelog +## [1.91.106.0] - 2026-10-16 + +**The 2026-10-15 issue wave is fixed, with every adopted contributor patch kept under its author.** Twenty-odd field reports and community PRs from the week after v1.91.101.0 (`docs/designs/FIX_WAVE_2026_10_15.md`): an `/autoplan` run locked by a Claude Code 2.1.300 compaction, `/cso` scans that could not finish, auto-updates that left conflict markers or held every Windows install, Windows pregate and restamp unable to run gstack's own scripts, plan reviews that lost their native second opinion on Claude Code 2.1.277+, and a batch of maintenance fixes. Each was reproduced with a test that failed first on v1.91.101.0. + +### What this means for you + +**Recovery and `/cso`** + +- **`/autoplan` continues after a Claude Code 2.1.300 auto-compaction.** The journal reader follows the compact boundary's preserved segment, so the phase guard no longer denies every phase with `close_incomplete` (#3194, adopted from #3196 by @vilano93). A compaction layout gstack does not read yet now allows with a visible warning and a decision-log row (`unrecognized_shape:compaction_unreachable`) instead of locking the run; an orphaned or not-yet-flushed record still denies. +- **`/cso` Semgrep reports parse after redaction.** A redacted bare number in a JSON report becomes a quoted marker, so the report is no longer discarded as `INVALID_OUTPUT` (#3185). Only scan stdout declared as JSON takes the JSON-aware path, and a finding that spans JSON structure still withholds the whole stream. Rerun scans that ended `INVALID_OUTPUT`. +- **`/cso` runs through OrbStack's Docker.** Docker and the watchdog invoke the trusted realpath with `argv0` `docker`, which OrbStack's multicall binary needs (#3187, adopted from #3188 by @aegixx). +- **The browse daemon recovers when every tab was closed outside browse.** The next command opens a blank tab and says so once (`open your page again with: browse goto `); `status`, `stop` and `restart` answer without opening one, and a context that cannot open a tab fails with `BROWSE_TAB_UNRECOVERABLE` (#3167, adopted from #3179 by @aftabkh4n). +- **The Bun floor check holds under zsh.** zsh now reports Bun 1.3.2 as too old instead of accepting it, so `/gstack-upgrade` holds with `BUN_TOO_OLD` until you run `bun upgrade`. A `bun --version` the check cannot parse holds with `BUN_VERSION_UNREADABLE` and asks you to reinstall Bun from https://bun.sh (#3195). + +**Install, update and Windows** + +- **Auto-update no longer leaves conflict markers in your install.** The updater and `/gstack-upgrade` share one helper, `bin/gstack-ff-update`: it saves your local edits in a rescue ref under `refs/gstack/rescue/`, moves the install with a two-way `read-tree` that refuses to overwrite anything it did not save, and prints once a day how to inspect (`git -C '' show --stat ''`), export and delete the rescue. Adopts #3189 by @BenjaminDSmithy and #3191 by @Jesssullivan. +- **Windows auto-update is no longer held on every release (#3184, reported by @wengweiling).** The hook check extracts the incoming release without `tar`, and the banner shows the hold instead of a stale "bun not found". If yours has been held since 1.91.89.0, run the recovery once (docs/troubleshooting.md#auto-update-held-install-recovery). Git Bash: + `cd ~/.claude/skills/gstack && git fetch origin main && d=$(mktemp -d) && git show FETCH_HEAD:bin/gstack-ff-update > "$d/gstack-ff-update" && bash "$d/gstack-ff-update" . FETCH_HEAD; s=$?; rm -rf "$d"; [ "$s" = 0 ] && ./setup` + Windows PowerShell: + `Set-Location -LiteralPath "$HOME\.claude\skills\gstack" -ErrorAction Stop; $gb = Join-Path (git --exec-path) '..\..\..\bin\bash.exe'; & $gb -c 'set -f; IFS=; git fetch origin main && d=$(mktemp -d) && git show FETCH_HEAD:bin/gstack-ff-update > $d/gstack-ff-update && bash $d/gstack-ff-update . FETCH_HEAD; s=$?; rm -rf -- $d; exit $s'; if ($LASTEXITCODE -eq 0) { .\setup.ps1 }` +- **`setup.ps1` works under Windows PowerShell 5.1 (#3130, reported by @gfl130902-maker).** Its `bash -c` script holds no double quotes, which 5.1 stripped; an empty argument is refused up front (`SETUP_PS1_EMPTY_ARGUMENT`). It now finds Git Bash the same way the hooks do, including next to `git --exec-path` on arm64. +- **Windows pregate, restamp, regen, gate-diff and evidence run gstack's own scripts again (#3197, reported by @exGeni).** Bun on Windows cannot execute an extensionless shebang script; every TypeScript caller now goes through `lib/script-invocation.ts`, which runs bash scripts through Git Bash. Git Bash outside `C:\Program Files\Git` (scoop, per-user, portable) is found next to `git --exec-path`. To pin one, in PowerShell: `[Environment]::SetEnvironmentVariable('GSTACK_GIT_BASH', 'C:\Program Files\Git\bin\bash.exe', 'User')`, then restart Claude Code. `GSTACK_BASH` still works as a deprecated alias; a path that does not exist is refused, and a non-MSYS `bash` on PATH (Cygwin, WSL) is no longer used. With no Git Bash, the error lists every place gstack looked and ends `(BASH_UNAVAILABLE)` (docs/troubleshooting.md#bash-unavailable). +- **Hooks are registered at your `$HOME` install when `CLAUDE_CONFIG_DIR` is a per-session directory that links back to it (#3180).** Adopts #3181 by @Brit-juho (commit author NetrioGit). +- **Cursor no longer loads every skill twice from 1.31-era unprefixed folders (#3133, first reported by @andrewkjankowski in #3084).** `./setup --host cursor` removes only folders whose `SKILL.md` links into gstack and names them. Adopts #3152 by @szsunyuan. +- **A missing `bunx` is no longer reported as a network failure (#3131, reported by @gfl130902-maker).** setup installs Chromium with `bun x`; exit 127 names the resolved Bun and how to skip the browser. +- **`gstack-evidence` records a content fingerprint in SHA-256 repositories.** + +**Plan reviews** + +- **Plan reviews get their native second opinion back on Claude Code 2.1.277+ (#3182, reported by @HuellintonMota).** Claude Code removed the TaskOutput tool, so `/plan-ceo-review`, `/plan-eng-review` and `/plan-devex-review` always reported "Outside voice unavailable" without Codex. The fallback now runs the Plan subagent in the background beside a five-minute timer; only a complete report that beats the timer counts. If you still see `(OUTSIDE_VOICE_UNAVAILABLE)`, the cause names what is missing (docs/troubleshooting.md#outside-voice-unavailable); the review-log row carries `unavailable_reason`. +- **`/plan-eng-review` reads the CEO plan (#280).** `gstack-ceo-plan-find` finds the `/plan-ceo-review` plan for this branch and the Design Doc Check reads its scope as data. A plan from another branch is disclosed (`CEO plan found (from branch , not ): `) and interactive reviewers are asked first. Thanks @szsunyuan (#3159). + +**Pregate, terminal agent and maintenance** + +- **Pregate finds tests in Next.js route groups and through tsconfig path aliases.** Repo paths with `( ) [ ] + @ $` stay whole in the selection manifest and map to their files, and `compilerOptions.paths` resolve the way TypeScript does (exact pattern, then the most specific wildcard, targets in order; relative `extends` up to 4 hops). Uncertainty selects more tests, never fewer. A file with no lane gets `--explain` lines naming the unresolved specifiers. Thanks @vocodestudios (#3176). +- **Terminal-agent shutdown no longer orphans PTY descendants that ignore SIGHUP (Linux).** Long-lived processes started from the sidebar terminal now end 3 seconds after the session closes; start them elsewhere if they must outlive it. Thanks @Y4NN777 (#3148, #3147). +- **`gstack-skill-end` writes its completion event before the final drain,** so a full-tier end boundary pushes its own latest event. Thanks @Kkkkkuro (#3198). +- **`gstack-config` compares whole root paths and prints a recovery command that runs** when a path contains a space or an apostrophe. Thanks @davidfragnoli-lang (#3186). +- **`gstack-gbrain-sync` no longer dirties a tracked `.gitignore` that already ignores the pin.** Thanks @KuroChen (#3178). +- **`GSTACK_IOS_LAUNCH_ENV`** passes a JSON object of environment variables to the app on every `gstack-ios-qa-daemon` launch: `export GSTACK_IOS_LAUNCH_ENV='{"DEBUG_BRIDGE":"1"}'`, then restart the daemon. An invalid value or a `GSTACK_` key refuses the launch with `IOS_LAUNCH_ENV_INVALID`. Thanks @Bmathews721 (split from #1796). +- **Functional `/qa` and `/qa-only` probe every documented input rule.** The contract map asks for one invalid-input probe per stated rule (allowed characters, format, sign, range, arity), so an amount documented as digits-only is tried with a non-digit, not only zero and an overflow. +- **README no longer asks you to paste a skill list into CLAUDE.md (#3169).** If you did, delete the "Available skills:" line; Claude Code lists installed skills itself. +- **Dependencies:** `sharp` is declared; `@huggingface/transformers` 4.3.0 (onnxruntime-node 1.30.0) and dev `@anthropic-ai/claude-agent-sdk` 0.2.141 clear two transitive advisories, with identical classifier scores. Thanks @sonukapoor (#3145). CI actions bumped (dependabot #3175); Dependabot holds Playwright minor and major bumps behind the Windows `windowsHide` patch. + +### For contributors + +- `lib/script-invocation.ts` is the one way a TypeScript caller runs a gstack script or bash (`resolveWindowsBash` from one fixture table, `test/fixtures/windows-bash-resolution.json`, which `setup.ps1`'s tests also drive); `test/script-invocation-tripwire.test.ts` fails on new by-path or bare-`bash` spawns. New `.github/workflows/windows-pregate-e2e.yml` exercises restamp, regen, evidence, gate-diff and the refusal on `windows-latest`. +- `lib/sanitize-echo.ts` and its Bash twin `bin/gstack-sanitize-echo.sh` are the one sanitizer for echoed external strings. +- New result codes, each with a troubleshooting entry: `BUN_VERSION_UNREADABLE`, `BROWSE_TAB_UNRECOVERABLE`, `SETUP_PS1_EMPTY_ARGUMENT`, `BASH_UNAVAILABLE`, `IOS_LAUNCH_ENV_INVALID`, `OUTSIDE_VOICE_UNAVAILABLE`. +- Gate-integrity edits: the `/autoplan` phase guard's fail-open surface grows by exactly the compaction-explained unreachable component (existing `unrecognized_shape` allow-with-warning); pregate selection can only grow for alias-using repos; the plan-eng-review prompt-size ceiling rises from 1.2170 to 1.2196 for #3159. +- Tests stop writing into your real home and stop inheriting your git hooks: ship-measure CLI tests get a private HOME (thanks @y$un_, #3163) and the redact pre-push fixtures ignore `core.hooksPath` (thanks @Lockyer228, #3156, #2967). + ## [1.91.105.0] - 2026-10-16 **The free suite stops needing its retry pass. diff --git a/README.md b/README.md index fadbffd52..b6de1e229 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ When qualified CSO runtime images are published, setup gives each automatic prel Open Claude Code and paste this. Claude does the rest. -> Install gstack: run **`git clone --single-branch --depth 1 https://github.com/garrytan/gstack.git ~/.claude/skills/gstack && cd ~/.claude/skills/gstack && ./setup`** then add a "gstack" section to CLAUDE.md that says to use the /browse skill from gstack for all web browsing, never use mcp\_\_claude-in-chrome\_\_\* tools, and lists the available skills: /office-hours, /plan-ceo-review, /plan-eng-review, /plan-design-review, /design-consultation, /design-shotgun, /design-html, /review, /deslop-shared-libs, /test-audit, /ship, /eval-plan, /land-and-deploy, /canary, /benchmark, /browse, /connect-chrome, /qa, /qa-only, /design-review, /scrape, /setup-browser-cookies, /setup-deploy, /setup-gbrain, /retro, /investigate, /document-release, /document-generate, /codex, /cso, /autoplan, /plan-devex-review, /devex-review, /careful, /freeze, /guard, /unfreeze, /gstack-upgrade, /learn. Then ask the user if they also want to add gstack to the current project so teammates get it. +> Install gstack: run **`git clone --single-branch --depth 1 https://github.com/garrytan/gstack.git ~/.claude/skills/gstack && cd ~/.claude/skills/gstack && ./setup`** then add a "gstack" section to CLAUDE.md that says to use the /browse skill from gstack for all web browsing and never use mcp\_\_claude-in-chrome\_\_\* tools. Claude Code lists the installed skills itself, so the section needs no skill list. Then ask the user if they also want to add gstack to the current project so teammates get it. ### Step 2: Team mode — auto-update for shared repos (recommended) @@ -834,19 +834,15 @@ a generated gstack SKILL.md and then edited it, that file is moved to `~/.gstack/backups/skills///SKILL.md` before gstack's is linked in, never deleted. -**Claude says it can't see the skills?** Make sure your project's `CLAUDE.md` has a gstack section. Add this: +**Claude says it can't see the skills?** Check that they are installed (`ls ~/.claude/skills`) and restart Claude Code; it lists every installed skill with its description, so CLAUDE.md does not need to enumerate them (a pasted list goes stale with each release). Your project's `CLAUDE.md` only needs the browsing rule: ``` ## gstack Use /browse from gstack for all web browsing. Never use mcp__claude-in-chrome__* tools. -Available skills: /office-hours, /plan-ceo-review, /plan-eng-review, /plan-design-review, -/design-consultation, /design-shotgun, /design-html, /review, /deslop-shared-libs, /test-audit, /ship, /eval-plan, /land-and-deploy, -/canary, /benchmark, /browse, /open-gstack-browser, /qa, /qa-only, /design-review, /scrape, -/setup-browser-cookies, /setup-deploy, /setup-gbrain, /sync-gbrain, /retro, /investigate, -/document-release, /document-generate, /codex, /cso, /autoplan, /pair-agent, /careful, /freeze, -/guard, /unfreeze, /gstack-upgrade, /learn. ``` +If an older install prompt left an "Available skills:" line in your CLAUDE.md, delete it. + ## License MIT. Free forever. Go build something. diff --git a/VERSION b/VERSION index 852dfdc70..71f208571 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.105.0 +1.91.106.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 2e819d448..e2b1b7dd5 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.105.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.106.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-browser-ensure b/bin/gstack-browser-ensure index 930ef83c2..a90f63346 100755 --- a/bin/gstack-browser-ensure +++ b/bin/gstack-browser-ensure @@ -9,8 +9,9 @@ # preflight in every browser skill (scripts/resolvers/browse.ts) runs this # helper instead, so the first browser skill run installs it. Probes a # Chromium launch through Playwright (node when present; Bun cannot launch -# Chromium on Windows) and, when the probe fails, runs `bunx playwright -# install chromium` under the same lock ./setup uses, then re-probes. +# Chromium on Windows) and, when the probe fails, runs `bun x playwright +# install chromium` (not bunx: a winget Bun ships none, #3131) under the same +# lock ./setup uses, then re-probes. # # --check probe only; never install (exit 1 when Chromium is unavailable) # --quiet print only the final line @@ -32,7 +33,7 @@ for _arg in "$@"; do --check) CHECK=1 ;; --quiet) QUIET=1 ;; --with-deps) WITH_DEPS="--with-deps" ;; - -h|--help) sed -n '3,25p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + -h|--help) sed -n '3,24p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; *) echo "gstack-browser-ensure: unknown argument '$_arg'" >&2; exit 2 ;; esac done @@ -78,7 +79,7 @@ fi probe [ "$PROBE_RC" -eq 0 ] && ok [ "$CHECK" -eq 1 ] && fail "Chromium did not launch via $ENGINE ($(first_line "$PROBE_OUT"))" "$ROOT/bin/gstack-browser-ensure (installs Chromium; ~115 MiB)" -command -v bun >/dev/null 2>&1 || fail "Chromium is not installed and bun is not on PATH to install it" "install Bun, then re-run $0" +BUN=$(command -v bun 2>/dev/null) || fail "Chromium is not installed and bun is not on PATH to install it" "install Bun, then re-run $0" # Same lock ./setup takes (#2136), so two installs never race on the cache. LOCK="${TMPDIR:-/tmp}/gstack-playwright-install.lock" @@ -102,17 +103,18 @@ log "gstack-browser-ensure: installing Playwright Chromium (one-time, ~115 MiB; started=$(date +%s) ( cd "$ROOT" - if [ -n "$OVERRIDE" ]; then PLAYWRIGHT_HOST_PLATFORM_OVERRIDE="$OVERRIDE" bunx playwright install $WITH_DEPS chromium - else bunx playwright install $WITH_DEPS chromium; fi + if [ -n "$OVERRIDE" ]; then PLAYWRIGHT_HOST_PLATFORM_OVERRIDE="$OVERRIDE" "$BUN" x playwright install $WITH_DEPS chromium + else "$BUN" x playwright install $WITH_DEPS chromium; fi ) &2 & pid=$! waited=0 while kill -0 "$pid" 2>/dev/null; do - if [ "$waited" -ge "$TIMEOUT" ]; then kill "$pid" 2>/dev/null; wait "$pid" 2>/dev/null; fail "bunx playwright install chromium exceeded ${TIMEOUT}s" "raise GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT= and re-run $0"; fi + if [ "$waited" -ge "$TIMEOUT" ]; then kill "$pid" 2>/dev/null; wait "$pid" 2>/dev/null; fail "$BUN x playwright install chromium exceeded ${TIMEOUT}s" "raise GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT= and re-run $0"; fi sleep 1; waited=$((waited + 1)) done wait "$pid"; rc=$? -[ "$rc" -eq 0 ] || fail "bunx playwright install chromium exited $rc (offline, proxy, or blocked download?)" "fix network access to the Playwright CDN, then re-run $0" +[ "$rc" -ne 127 ] || fail "$BUN x playwright install chromium exited 127 (command not found)" "install Bun from bun.sh (winget Bun ships no bunx), then re-run $0" +[ "$rc" -eq 0 ] || fail "$BUN x playwright install chromium exited $rc (offline, proxy, or blocked download?)" "fix network access to the Playwright CDN, then re-run $0" log "gstack-browser-ensure: Chromium installed in $(( $(date +%s) - started ))s" probe [ "$PROBE_RC" -eq 0 ] && ok diff --git a/bin/gstack-bun-version.sh b/bin/gstack-bun-version.sh index 900644f55..a8c769f9b 100644 --- a/bin/gstack-bun-version.sh +++ b/bin/gstack-bun-version.sh @@ -60,43 +60,57 @@ _gstack_bun_parse() { _gb_core="$((10#$_gb_maj)) $((10#$_gb_min)) $((10#$_gb_pat))" } -# _gstack_bun_below — true when version sorts below the -# release. Prerelease rule (semver): 1.3.3-canary.1 is below 1.3.3 and above -# 1.3.2, so a prerelease of the floor itself is refused. +# _gstack_bun_below — returns 0 when version sorts below +# the release, 1 when it does not, and 2 when either cannot be parsed (callers +# never read 2 as "not below"). Prerelease rule (semver): 1.3.3-canary.1 is +# below 1.3.3 and above 1.3.2, so a prerelease of the floor itself is refused. +# The release's fields are copied into quoted positional parameters before the +# second parse, so the comparison does not depend on word splitting (zsh does +# not split; #3195). _gstack_bun_below() { - _gstack_bun_parse "$2" || return 1 - set -- "$1" $_gb_core - _gstack_bun_parse "$1" || return 1 - set -- "$@" $_gb_core - [ "$5" -ne "$2" ] && { [ "$5" -lt "$2" ]; return; } - [ "$6" -ne "$3" ] && { [ "$6" -lt "$3" ]; return; } - [ "$7" -ne "$4" ] && { [ "$7" -lt "$4" ]; return; } + _gstack_bun_parse "$2" || return 2 + set -- "$1" "$_gb_maj" "$_gb_min" "$_gb_pat" + _gstack_bun_parse "$1" || return 2 + [ "$((10#$_gb_maj))" -ne "$((10#$2))" ] && { [ "$((10#$_gb_maj))" -lt "$((10#$2))" ]; return; } + [ "$((10#$_gb_min))" -ne "$((10#$3))" ] && { [ "$((10#$_gb_min))" -lt "$((10#$3))" ]; return; } + [ "$((10#$_gb_pat))" -ne "$((10#$4))" ] && { [ "$((10#$_gb_pat))" -lt "$((10#$4))" ]; return; } [ -n "$_gb_pre" ] } # gstack_bun_status [floor] — prints too-old, untested, ok or -# malformed. The optional floor lets the auto-updater apply the incoming -# release's floor with this release's comparison rule. +# malformed (the version or the floor cannot be parsed). The optional floor +# lets the auto-updater apply the incoming release's floor with this +# release's comparison rule. gstack_bun_status() { - if ! _gstack_bun_parse "$1"; then echo malformed - elif _gstack_bun_below "$1" "${2:-$GSTACK_BUN_FLOOR}"; then echo too-old - elif _gstack_bun_below "$1" "$GSTACK_BUN_TESTED"; then echo untested - else echo ok - fi + _gstack_bun_below "$1" "${2:-$GSTACK_BUN_FLOOR}" + case $? in + 0) echo too-old ;; + 1) _gstack_bun_below "$1" "$GSTACK_BUN_TESTED" + case $? in 0) echo untested ;; 1) echo ok ;; *) echo malformed ;; esac ;; + *) echo malformed ;; + esac } # gstack_bun_incoming_hold — the pre-advance check shared by # the auto-updater and /gstack-upgrade. Reads the floor from 's copy of # this file and checks the `bun` setup will run (first on PATH). Below that -# floor it prints the held reason and returns 0; otherwise it prints nothing -# and returns 1 (no bun, no floor in , or an unparseable version, which -# setup only warns on). The comparison rule is this release's. +# floor it prints the held reason and returns 0. A version it cannot compare +# also holds (bun-version-unreadable, BUN_VERSION_UNREADABLE), with the bun +# path and its output passed through gstack_sanitize_echo +# (bin/gstack-sanitize-echo.sh in ). Otherwise it prints nothing and +# returns 1 (no bun, or no floor in ). The comparison rule is this release's. gstack_bun_incoming_hold() { command -v bun >/dev/null 2>&1 || return 1 _gb_floor=$(git -C "$1" show "$2:bin/gstack-bun-version.sh" 2>/dev/null | sed -n 's/^GSTACK_BUN_FLOOR="\([0-9A-Za-z.+-]*\)".*/\1/p' | head -1) [ -n "$_gb_floor" ] || return 1 _gb_found=$(bun --version 2>/dev/null | head -1) - [ "$(gstack_bun_status "$_gb_found" "$_gb_floor")" = too-old ] || return 1 + case "$(gstack_bun_status "$_gb_found" "$_gb_floor")" in + too-old) ;; + malformed) + echo "bun-version-unreadable: could not read the version of Bun at $( (. "$1/bin/gstack-sanitize-echo.sh" && gstack_sanitize_echo "$(command -v bun)") 2>/dev/null) (bun --version printed '$( (. "$1/bin/gstack-sanitize-echo.sh" && gstack_sanitize_echo "$_gb_found") 2>/dev/null)'); gstack $(git -C "$1" show "$2:VERSION" 2>/dev/null | head -1 | tr -cd '0-9A-Za-z.+-') needs $_gb_floor or newer (BUN_VERSION_UNREADABLE)" + return 0 ;; + *) return 1 ;; + esac _gb_ver=$(git -C "$1" show "$2:VERSION" 2>/dev/null | head -1) echo "bun-too-old: found Bun $_gb_found at $(command -v bun); gstack ${_gb_ver:-update} needs $_gb_floor or newer" } diff --git a/bin/gstack-ceo-plan-find b/bin/gstack-ceo-plan-find new file mode 100755 index 000000000..56a592f32 --- /dev/null +++ b/bin/gstack-ceo-plan-find @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# gstack-ceo-plan-find — print the CEO plan a plan review should read. +# +# Usage: gstack-ceo-plan-find +# +# Prints one absolute path, or nothing when no CEO plan exists. Exit 0 either +# way; exit 2 on a usage error. A plan from another branch (precedence 2) is +# disclosed on stderr: "CEO plan found (from branch , not ): ". +# +# /plan-ceo-review saves each plan as +# $GSTACK_STATE_ROOT/projects//ceo-plans/-.md with a +# "Branch: | Mode: ..." header line. Precedence: +# 1. newest plan whose Branch line names , compared the way +# gstack-slug writes BRANCH ("/" becomes "-") +# 2. else the newest plan, disclosed on stderr +# Only top-level ceo-plans/*.md counts: the same set /design-html and +# /plan-ceo-review's prior-plans query read. +set -u + +if [ "$#" -ne 2 ] || [ -z "$1" ]; then + echo "usage: gstack-ceo-plan-find " >&2 + exit 2 +fi +SLUG=$1 +BRANCH=$2 + +eval "$("$(dirname "$0")/gstack-paths")"; : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}" + +plan_branch() { + awk '/^Branch: /{ sub(/^Branch: /, ""); sub(/\|.*/, ""); print; exit }' "$1" | tr '/' '-' | tr -cd 'a-zA-Z0-9._-' +} + +NEWEST="" +FOR_BRANCH="" +for plan in "$GSTACK_STATE_ROOT/projects/$SLUG/ceo-plans/"*.md; do + [ -f "$plan" ] || continue + if [ -z "$NEWEST" ] || [ "$plan" -nt "$NEWEST" ]; then NEWEST=$plan; fi + if [ -n "$BRANCH" ] && [ "$(plan_branch "$plan")" = "$BRANCH" ]; then + if [ -z "$FOR_BRANCH" ] || [ "$plan" -nt "$FOR_BRANCH" ]; then FOR_BRANCH=$plan; fi + fi +done + +PLAN=${FOR_BRANCH:-$NEWEST} +[ -n "$PLAN" ] || exit 0 +if [ -z "$FOR_BRANCH" ]; then + FROM=$(awk '/^Branch: /{ sub(/^Branch: /, ""); sub(/ *\|.*/, ""); print; exit }' "$PLAN" | LC_ALL=C tr -d '\000-\037\177') + echo "CEO plan found (from branch ${FROM:-unknown}, not ${BRANCH:-unknown}): $PLAN" >&2 +fi +printf '%s\n' "$PLAN" +exit 0 diff --git a/bin/gstack-claude-root.sh b/bin/gstack-claude-root.sh new file mode 100644 index 000000000..25a1926b8 --- /dev/null +++ b/bin/gstack-claude-root.sh @@ -0,0 +1,28 @@ +# shellcheck shell=bash +# gstack-claude-root.sh — sourced helper: the stable install root that Claude +# hook commands are registered at (setup's canonical-only rule). Used by setup +# and bin/gstack-memorable so both write the same path into settings.json. +# +# gstack_canonical_claude_root prints ${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/gstack, +# or $HOME/.claude/skills/gstack when that +# install exists and the config-dir root is +# missing (the installer writes to $HOME) or +# resolves to the same tree (a per-session +# CLAUDE_CONFIG_DIR whose skills link back to +# $HOME, #3180: only the $HOME spelling +# outlives the session) +# +# Neither answer is the running tree, so canonical-only still holds. No side +# effects; safe under set -u. + +gstack_canonical_claude_root() { + local root="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/gstack" + local home_root="$HOME/.claude/skills/gstack" + if [ -x "$home_root/bin/gstack-session-update" ]; then + if [ ! -x "$root/bin/gstack-session-update" ] \ + || [ "$(CDPATH= cd -- "$root" && pwd -P)" = "$(CDPATH= cd -- "$home_root" && pwd -P)" ]; then + root="$home_root" + fi + fi + printf '%s\n' "$root" +} diff --git a/bin/gstack-config b/bin/gstack-config index 53122a6be..8e33aeb9a 100755 --- a/bin/gstack-config +++ b/bin/gstack-config @@ -27,7 +27,7 @@ # Docs: https://github.com/garrytan/gstack/blob/main/docs/state-root.md set -euo pipefail -. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +[ -r "$(dirname "$0")/gstack-state-root.sh" ] && . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } gstack_state_root_select STATE_DIR="$_gstack_sr_root" CONFIG_FILE="$STATE_DIR/config.yaml" @@ -621,15 +621,14 @@ _SELF="$(cd "$(dirname "$0")" && pwd)/gstack-config" # One line when the root-selecting variables name different directories. report_root_disagreement() { - local _v _val _set="" _distinct="" + local _v _val _set="" _first="" _disagrees=0 for _v in GSTACK_STATE_ROOT GSTACK_HOME GSTACK_STATE_DIR; do eval "_val=\${$_v:-}" [ -n "$_val" ] || continue _set="$_set $_v=$_val" - case " $_distinct " in *" $_val "*) ;; *) _distinct="$_distinct $_val" ;; esac + if [ -z "$_first" ]; then _first="$_val"; elif [ "$_val" != "$_first" ]; then _disagrees=1; fi done - set -- $_distinct - [ "$#" -gt 1 ] || return 0 + [ "$_disagrees" -eq 1 ] || return 0 echo "# note: root-selecting variables disagree:$_set; using $STATE_DIR ($_gstack_sr_var wins). Explain: $(dirname "$_SELF")/gstack-paths --explain ($_STATE_DOC)" } @@ -971,7 +970,7 @@ $_gstack_legacy_root" gstack_config_select "$KEY" if [ -n "$_gstack_cfg_root" ] && [ "$_gstack_cfg_root" != "$STATE_DIR" ] && [ "$_gstack_cfg_value" != "$SAFE_VALUE" ]; then echo "gstack-config: set $KEY in $CONFIG_FILE, but $KEY is still '$_gstack_cfg_value': $_gstack_cfg_root/config.yaml sets that more restrictive value, and for privacy keys the most restrictive value across state roots wins." >&2 - echo "fix: GSTACK_STATE_ROOT='$_gstack_cfg_root' $_SELF set $KEY $SAFE_VALUE ($_STATE_DOC)" >&2 + printf 'fix: GSTACK_STATE_ROOT=%q %q set %q %q (%s)\n' "$_gstack_cfg_root" "$_SELF" "$KEY" "$SAFE_VALUE" "$_STATE_DOC" >&2 fi # Auto-relink skills when prefix setting changes (skip during setup to avoid recursive call) if { [ "$KEY" = "skill_prefix" ] || [ "$KEY" = "disabled_skills" ]; } && [ -z "${GSTACK_SETUP_RUNNING:-}" ]; then diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index 181410d40..6afe69f95 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -113,6 +113,7 @@ gstack_result_anchor() { RUNTIME_PIN_CONFLICT) printf '%s' 'capy-install-runtime-conflict' ;; 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' ;; POLICY_SOURCE_UNAVAILABLE) printf '%s' 'policy-source-unavailable' ;; POLICY_INVALID) printf '%s' 'policy-invalid' ;; POLICY_CONTAINMENT) printf '%s' 'policy-containment' ;; @@ -192,6 +193,11 @@ gstack_result_anchor() { REVISION_BELOW_MINIMUM) printf '%s' 'revision-below-minimum' ;; CAPY_WRAPPER_STALE) printf '%s' 'capy-wrapper-stale' ;; INSTALL_UNVERIFIED) printf '%s' 'install-unverified' ;; + BROWSE_TAB_UNRECOVERABLE) printf '%s' 'browse-tab-unrecoverable' ;; + BUN_VERSION_UNREADABLE) printf '%s' 'bun-version-unreadable' ;; + BASH_UNAVAILABLE) printf '%s' 'bash-unavailable' ;; + IOS_LAUNCH_ENV_INVALID) printf '%s' 'ios-launch-env-invalid' ;; + OUTSIDE_VOICE_UNAVAILABLE) printf '%s' 'outside-voice-unavailable' ;; *) return 1 ;; esac } diff --git a/bin/gstack-evidence b/bin/gstack-evidence index f56231557..20fb35cb2 100755 --- a/bin/gstack-evidence +++ b/bin/gstack-evidence @@ -57,6 +57,7 @@ import { buildBundle, checkAncestors, readBundle, renderVerification, verifyBund import { resultLine } from "../lib/result-codes"; import { isUnavailable, loadPolicyFromBase, resolveBaseBranch } from "../lib/ship-policy"; import { versionAtRef } from "../lib/restamp"; +import { bashCommandInvocation, runScriptSync, wtreeFingerprint } from "../lib/script-invocation"; const BIN_DIR = dirname(Bun.fileURLToPath(import.meta.url)); const LOG_MAX_BYTES = 2 * 1024 * 1024; @@ -195,15 +196,14 @@ function git(args: string[]): string | undefined { } } +let wtreeWarned = false; +/** Content fingerprint (SHA-1 or SHA-256 tree id); undefined grades STALE, with the reason printed once. */ function currentWtree(): string | undefined { - try { - const r = spawnSync(join(BIN_DIR, "gstack-wtree"), { encoding: "utf-8", timeout: 30000 }); - if (r.status !== 0) return undefined; - const out = (r.stdout || "").trim(); - return /^[0-9a-f]{40}$/.test(out) ? out : undefined; - } catch { - return undefined; - } + const fp = wtreeFingerprint({ cwd: process.cwd(), timeoutMs: 30000 }); + if ("tree" in fp) return fp.tree; + if (!wtreeWarned) warn(`working-tree fingerprint unavailable (${fp.error}) — evidence recorded without it (will grade STALE)`); + wtreeWarned = true; + return undefined; } function ledgerPath(): { dir: string; file: string; logsDir: string } { @@ -213,7 +213,7 @@ function ledgerPath(): { dir: string; file: string; logsDir: string } { if (home === ".gstack") throw new Error("no GSTACK_HOME/HOME — bookkeeping skipped"); // ONE gstack-slug spawn: its output carries both SLUG= and BRANCH= lines // (same branch→filename sanitization as reviews.jsonl). - const slugOut = spawnSync(join(BIN_DIR, "gstack-slug"), { encoding: "utf-8" }); + const slugOut = runScriptSync(join(BIN_DIR, "gstack-slug"), [], { encoding: "utf-8", timeout: 30000 }); const sm = (slugOut.stdout || "").match(/^SLUG=(.+)$/m); const bm = (slugOut.stdout || "").match(/^BRANCH=(.+)$/m); const slug = sm ? sm[1].trim() : "unknown"; @@ -373,7 +373,7 @@ async function cmdRun(argv: string[]): Promise { // Compound/piped commands pass as ONE string via bash -c; a multi-token argv // runs directly. The hashed command string is exact, no normalization. const commandString = cmdArgv.length === 1 ? cmdArgv[0] : cmdArgv.join(" "); - const spawnArgv = cmdArgv.length === 1 ? ["bash", "-c", cmdArgv[0]] : cmdArgv; + const shell = cmdArgv.length === 1 ? bashCommandInvocation(cmdArgv[0]) : null; const cmdSha = sha256(commandString); label = label.replace(/[^a-zA-Z0-9._-]/g, "_"); @@ -401,6 +401,8 @@ async function cmdRun(argv: string[]): Promise { warn(`scrubbed ${scrubbed.length} bun-injected dotenv var(s) from the child env: ${scrubbed.join(", ")} ` + `(GSTACK_EVIDENCE_KEEP_DOTENV=1 to keep them)`); } + if (shell && !shell.ok) throw new Error(shell.error); + const spawnArgv = shell?.ok ? [shell.cmd, ...shell.argv] : cmdArgv; proc = Bun.spawn(spawnArgv, { stdin: "inherit", stdout: "pipe", stderr: "pipe", env: spawnEnv }); } catch (e: any) { // Spawn failure (ENOENT on argv-direct form): record exit 127, propagate 127. diff --git a/bin/gstack-ff-update b/bin/gstack-ff-update new file mode 100755 index 000000000..877f27672 --- /dev/null +++ b/bin/gstack-ff-update @@ -0,0 +1,317 @@ +#!/usr/bin/env bash +# gstack-ff-update — fast-forward the gstack install at to +# without ever leaving conflict markers in it and without losing a +# local edit. The only code that moves an install's tree: the session-start +# auto-updater, /gstack-upgrade Step 4 and the held-install recovery commands +# (docs/troubleshooting.md#auto-update-held-install-recovery) call it. +# +# Self-contained on purpose: the recovery commands fetch this one file from +# the new revision (git show FETCH_HEAD:bin/gstack-ff-update) and run it in a +# private temp directory, so it sources nothing. The lock and the +# flatten-and-strip sanitizer are inlined; test/gstack-ff-update.test.ts pins +# them to their twins in bin/gstack-session-update. +# +# Inside an install-scoped lock (/gstack-ff.lock): +# 1. The working tree is captured as-is through a private index (tracked +# edits, unmerged and marker-laden files), and the real index's staged +# state separately; the real index is never touched while building them. +# 2. Edits to files the update does not change ride over it, as +# `merge --ff-only --autostash` did. Regenerable footprint files +# (SKILL.md, */SKILL.md, */sections/*.md, rewritten by prefix mode and +# gbrain blocks) ride over when the update leaves them alone and are +# replaced by the update's version otherwise; callers re-render them. +# 3. Any other edit that conflicts with the update, any staged change and +# any unmerged file is shelved into a rescue commit +# refs/gstack/rescue/: tree = the working tree as captured, first +# parent = the pre-update HEAD (so `diff ^ ` is only the +# person's delta), second parent = the staged state. It is stash-shaped, +# so `git stash show -p ` reads it too. +# 4. The tree moves with a two-way `read-tree -m -u ` on +# the private index, so git refuses to overwrite a file changed after the +# capture or an untracked file in the way; HEAD moves compare-and-swap. +# +# stdout, one line: already | ok | rescued unmerged=<0|1> +# refusals (stdout): : , and nothing in the install changed +# exit 2 usage: / target-unresolvable: / not-a-checkout: +# exit 3 autostash-unrecoverable: (edits kept where they are) +# exit 4 not-fast-forward: +# exit 5 index-unreadable: +# exit 75 ff-busy: (another update holds this install's lock) +# A rescue also prints the inspect, export and delete commands on stderr. + +set +e +export LC_ALL=C GIT_TERMINAL_PROMPT=0 + +# Twin: ff_flatten in bin/gstack-session-update (test-pinned). One line, no +# control characters or escape sequences, at most bytes. +ff_flatten() { + _ff_esc=$(printf '\033') + printf '%s' "$1" | tr '\n\r\t' ' ' | sed "s/${_ff_esc}\[[0-9;?]*[ -\/]*[@-~]//g" | tr -d '\000-\037\177' | head -c "${2:-200}" +} +ff_quote() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; } +refuse() { printf '%s: %s\n' "$2" "$(ff_flatten "$3" 300)"; exit "$1"; } + +[ $# -eq 2 ] && [ -n "$1" ] && [ -n "$2" ] || refuse 2 usage "gstack-ff-update " +DIR=$1 REV=$2 +cd -- "$DIR" 2>/dev/null || refuse 2 not-a-checkout "cannot enter $DIR" +DIR=$(pwd -P) +g() { git -c core.fsmonitor=false -c core.splitIndex=false "$@"; } +[ "$(g rev-parse --is-inside-work-tree 2>/dev/null)" = true ] && [ -z "$(g rev-parse --show-prefix 2>/dev/null)" ] \ + || refuse 2 not-a-checkout "$DIR is not the top of a git checkout" +TARGET=$(g rev-parse -q --verify "$REV^{commit}" 2>/dev/null) || refuse 2 target-unresolvable "$REV does not name a commit in $DIR" +COMMON=$(g rev-parse --path-format=absolute --git-common-dir 2>/dev/null) \ + || COMMON=$(cd -- "$(g rev-parse --git-common-dir 2>/dev/null)" 2>/dev/null && pwd -P) \ + || refuse 2 not-a-checkout "cannot locate the git directory of $DIR" +INDEX=$(g rev-parse --path-format=absolute --git-path index 2>/dev/null) \ + || INDEX="$(cd -- "$(dirname "$(g rev-parse --git-path index)")" && pwd -P)/index" + +# ── Install-scoped lock (twin of bin/gstack-session-update's lock) ── +# Staleness: a dead holder PID, or a heartbeat older than the TTL (a live +# holder freshens it every GSTACK_FF_HEARTBEAT_SECONDS, default 300 s). +LOCK_DIR="$COMMON/gstack-ff.lock" +LOCK_TTL_MINUTES=30 +MYPID=$$ +OWN_INDEX_LOCK="" TMP="" HB_PID="" +lock_is_expired() { + _hb="$LOCK_DIR/pid" + [ -f "$_hb" ] || _hb="$LOCK_DIR" + [ -n "$(find "$_hb" -maxdepth 0 -mmin +$LOCK_TTL_MINUTES 2>/dev/null)" ] +} +lock_try() { + mkdir "$LOCK_DIR" 2>/dev/null && return 0 + _lp=$(cat "$LOCK_DIR/pid" 2>/dev/null) + if lock_is_expired || { [ "${_lp:-0}" -gt 0 ] 2>/dev/null && ! kill -0 "$_lp" 2>/dev/null; }; then + mv "$LOCK_DIR" "$LOCK_DIR.reap.$$" 2>/dev/null || return 1 + rm -rf "$LOCK_DIR.reap.$$" 2>/dev/null + mkdir "$LOCK_DIR" 2>/dev/null && return 0 + fi + return 1 +} +cleanup_ff_update() { + for heartbeat in ${HB_PID:-$(jobs -p)}; do + kill "$heartbeat" 2>/dev/null + wait "$heartbeat" 2>/dev/null + done + [ -n "$OWN_INDEX_LOCK" ] && rm -f "$OWN_INDEX_LOCK" 2>/dev/null + [ -n "$TMP" ] && rm -rf "$TMP" 2>/dev/null + [ "$(cat "$LOCK_DIR/pid" 2>/dev/null)" = "$MYPID" ] && rm -rf "$LOCK_DIR" 2>/dev/null +} +_wait=${GSTACK_FF_LOCK_WAIT:-15} +case "$_wait" in ''|*[!0-9]*) _wait=15 ;; esac +until lock_try; do + [ "$_wait" -gt 0 ] || refuse 75 ff-busy "another gstack update is moving $DIR (lock $LOCK_DIR held by pid $(cat "$LOCK_DIR/pid" 2>/dev/null || echo unknown)); nothing was changed, retry in a minute" + sleep 1; _wait=$((_wait - 1)) +done +echo "$MYPID" > "$LOCK_DIR/pid" 2>/dev/null +trap cleanup_ff_update EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM +_beat=${GSTACK_FF_HEARTBEAT_SECONDS:-300} +case "$_beat" in ''|*[!0-9]*|0) _beat=300 ;; esac +( + cleanup_heartbeat() { + for sleeper in $(jobs -p); do + kill "$sleeper" 2>/dev/null + wait "$sleeper" 2>/dev/null + done + } + trap cleanup_heartbeat EXIT + trap 'exit 0' HUP INT TERM + while :; do + sleep "$_beat" & + wait "$!" || exit 0 + [ "$(cat "$LOCK_DIR/pid" 2>/dev/null)" = "$MYPID" ] || exit 0 + touch "$LOCK_DIR/pid" 2>/dev/null + done +) & +HB_PID=$! + +# ── Inside the lock ── +HEAD_OID=$(g rev-parse -q --verify 'HEAD^{commit}' 2>/dev/null) || refuse 2 not-a-checkout "$DIR has no HEAD commit" +TMP=$(mktemp -d "${TMPDIR:-/tmp}/gstack-ff-XXXXXX" 2>/dev/null) || refuse 3 autostash-unrecoverable "cannot create a temp directory under ${TMPDIR:-/tmp}; nothing was changed" +g ls-files -u -z > "$TMP/unmerged.raw" 2>"$TMP/err" || refuse 5 index-unreadable "cannot read the index of $DIR ($(head -c 200 "$TMP/err")); nothing was changed" +UNMERGED=$(tr '\0' '\n' < "$TMP/unmerged.raw") +# Nothing to move: an up-to-date install keeps its edits and staged state as they are. +if [ "$HEAD_OID" = "$TARGET" ] && [ -z "$UNMERGED" ]; then echo "already $TARGET"; exit 0; fi +g merge-base --is-ancestor "$HEAD_OID" "$TARGET" 2>/dev/null \ + || refuse 4 not-fast-forward "$TARGET is not a fast-forward of HEAD $HEAD_OID in $DIR (local commits or a diverged history); nothing was changed" + +# Exclusive real-index lock for the whole move: git commands wait or fail +# instead of racing it, and the result is installed by rename. +( set -C; : > "$INDEX.lock" ) 2>/dev/null \ + || refuse 3 autostash-unrecoverable "$INDEX.lock exists (another git command is running in $DIR); nothing was changed" +OWN_INDEX_LOCK="$INDEX.lock" + +is_footprint() { case "$1" in SKILL.md|*/SKILL.md|*/sections/*.md) return 0 ;; esac; return 1; } +# capture — the working tree as-is into a private index; prints its tree. +capture() { + if [ -f "$INDEX" ]; then cp "$INDEX" "$1" || return 1; else GIT_INDEX_FILE=$1 g read-tree "$HEAD_OID" || return 1; fi + GIT_INDEX_FILE=$1 g add -u -- . 2>>"$TMP/err" || return 1 + GIT_INDEX_FILE=$1 g update-index -q --refresh >/dev/null 2>&1 + GIT_INDEX_FILE=$1 g write-tree 2>>"$TMP/err" +} +# paths — changed paths between two trees, split into +# .user and .foot (NUL-separated). +paths() { + : > "$3.user"; : > "$3.foot" + g diff-tree -r -z --no-renames --name-only "$1" "$2" 2>/dev/null | while IFS= read -r -d '' _p; do + if is_footprint "$_p"; then printf '%s\0' "$_p" >> "$3.foot"; else printf '%s\0' "$_p" >> "$3.user"; fi + done +} +# entries — index-info lines giving 's entry for +# each listed path that differs (mode 0 removes it). +entries() { + [ -s "$3" ] || return 0 + xargs -0 git --literal-pathspecs diff-tree -r -z --no-renames "$1" "$2" -- < "$3" 2>/dev/null \ + | while IFS= read -r -d '' _meta && IFS= read -r -d '' _p; do + set -- $_meta + printf '%s %s\t%s\0' "$2" "$4" "$_p" + done +} +apply_entries() { [ -s "$2" ] || return 0; GIT_INDEX_FILE=$1 g update-index -z --index-info < "$2"; } +tree_without_footprint() { + paths "$HEAD_OID" "$2" "$TMP/fp-$1" + cp "$TMP/$1.idx" "$TMP/$1-user.idx" && entries "$2" "$HEAD_OID" "$TMP/fp-$1.foot" > "$TMP/fp-$1.info" \ + && apply_entries "$TMP/$1-user.idx" "$TMP/fp-$1.info" && GIT_INDEX_FILE="$TMP/$1-user.idx" g write-tree +} + +: > "$TMP/err" +WTREE=$(capture "$TMP/w.idx") || refuse 3 autostash-unrecoverable "cannot capture the working tree of $DIR ($(head -c 200 "$TMP/err")); nothing was changed" +# The staged state: stage 0 entries as they are; an unmerged path keeps its +# "ours" stage (else theirs, else base) so the tree can be written. +g ls-files -s -z > "$TMP/s.raw" 2>>"$TMP/err" || refuse 5 index-unreadable "cannot read the index of $DIR; nothing was changed" +TAB=$'\t' +: > "$TMP/s.info" +_cp="" _cl="" _cr=9 +while IFS= read -r -d '' _e; do + _p=${_e#*"$TAB"} + set -- ${_e%%"$TAB"*} + case "$3" in 0) _r=0 ;; 2) _r=1 ;; 3) _r=2 ;; *) _r=3 ;; esac + if [ "$_p" != "$_cp" ]; then [ -n "$_cp" ] && printf '%s\0' "$_cl" >> "$TMP/s.info"; _cp=$_p _cr=9; fi + [ "$_r" -lt "$_cr" ] && _cr=$_r _cl="$1 $2$TAB$_p" +done < "$TMP/s.raw" +[ -n "$_cp" ] && printf '%s\0' "$_cl" >> "$TMP/s.info" +GIT_INDEX_FILE="$TMP/s.idx" g read-tree --empty 2>/dev/null +apply_entries "$TMP/s.idx" "$TMP/s.info" 2>>"$TMP/err" || refuse 3 autostash-unrecoverable "cannot read the staged state of $DIR; nothing was changed" +STREE=$(GIT_INDEX_FILE="$TMP/s.idx" g write-tree 2>>"$TMP/err") || refuse 3 autostash-unrecoverable "cannot read the staged state of $DIR; nothing was changed" +cp "$TMP/w.idx" "$TMP/wu.idx"; WUSER=$(tree_without_footprint wu "$WTREE") || refuse 3 autostash-unrecoverable "cannot capture the working tree of $DIR; nothing was changed" +SUSER=$(tree_without_footprint s "$STREE") || refuse 3 autostash-unrecoverable "cannot read the staged state of $DIR; nothing was changed" +HTREE=$(g rev-parse "$HEAD_OID^{tree}") + +UNMERGED_USER=0 UNMERGED_FOOT="" +while IFS= read -r _u; do + [ -n "$_u" ] || continue + _p=$(printf '%s' "$_u" | cut -f2-) + if is_footprint "$_p"; then UNMERGED_FOOT=1; else UNMERGED_USER=1; fi +done < — " " of in , empty when absent. +lsentry() { git --literal-pathspecs ls-tree -r -z "$1" -- "$2" 2>/dev/null | tr '\0' '\n' | awk -F '\t' 'NR == 1 { split($1, a, " "); print a[1] " " a[3] }'; } +# carry_line — the index-info line that carries as captured; +# when the update also changed it, a clean three-way content merge of the +# edit onto the target (as an autostash pop would make), else return 1. +carry_line() { + _w=$(lsentry "$WTREE" "$1") + if git --literal-pathspecs diff-tree --quiet -r "$HEAD_OID" "$TARGET" -- "$1" >/dev/null 2>&1; then + printf '%s\t%s\0' "${_w:-0 $ZERO}" "$1"; return 0 + fi + _t=$(lsentry "$TARGET" "$1"); _h=$(lsentry "$HEAD_OID" "$1") + [ "$_w" = "$_t" ] && return 0 + for _e in "$_h" "$_w" "$_t"; do case "${_e%% *}" in 100644|100755) ;; *) return 1 ;; esac; done + g cat-file blob "${_h#* }" > "$TMP/m.base" && g cat-file blob "${_w#* }" > "$TMP/m.ours" && g cat-file blob "${_t#* }" > "$TMP/m.theirs" || return 1 + g merge-file -p -q "$TMP/m.ours" "$TMP/m.base" "$TMP/m.theirs" > "$TMP/m.out" 2>/dev/null || return 1 + _o=$(g hash-object -w "$TMP/m.out") || return 1 + _m=${_t%% *}; [ "${_w%% *}" = "${_h%% *}" ] || _m=${_w%% *} + printf '%s %s\t%s\0' "$_m" "$_o" "$1" +} + +paths "$HEAD_OID" "$WTREE" "$TMP/delta" +SHELVE=0 +[ "$UNMERGED_USER" = 1 ] && SHELVE=1 +[ "$SUSER" = "$HTREE" ] || SHELVE=1 +: > "$TMP/carry.user"; : > "$TMP/carry.foot" +if [ "$SHELVE" = 0 ]; then + while IFS= read -r -d '' _p; do + carry_line "$_p" >> "$TMP/carry.user" || { SHELVE=1; break; } + done < "$TMP/delta.user" +fi +# A footprint edit rides over when it merges cleanly; otherwise, or when it +# is unmerged, the target's version wins (callers re-render it). +while IFS= read -r -d '' _p; do + [ -n "$UNMERGED_FOOT" ] && printf '%s\n' "$UNMERGED" | cut -f2- | grep -qxF -- "$_p" && continue + carry_line "$_p" >> "$TMP/carry.foot" +done < "$TMP/delta.foot" +[ "$SHELVE" = 1 ] && [ "$WUSER" = "$HTREE" ] && [ "$SUSER" = "$HTREE" ] && [ "$UNMERGED_USER" = 0 ] && SHELVE=0 + +# The result: the target, plus the edits that ride over it. +GIT_INDEX_FILE="$TMP/r.idx" g read-tree "$TARGET" 2>>"$TMP/err" || refuse 3 autostash-unrecoverable "cannot read $TARGET; nothing was changed" +{ [ "$SHELVE" = 0 ] && cat "$TMP/carry.user"; cat "$TMP/carry.foot"; } > "$TMP/carry.info" +apply_entries "$TMP/r.idx" "$TMP/carry.info" || refuse 3 autostash-unrecoverable "cannot build the updated tree; nothing was changed" +RTREE=$(GIT_INDEX_FILE="$TMP/r.idx" g write-tree 2>>"$TMP/err") || refuse 3 autostash-unrecoverable "cannot build the updated tree; nothing was changed" + +RESCUE="" REF="" +if [ "$SHELVE" = 1 ]; then + _id() { GIT_AUTHOR_NAME='gstack rescue' GIT_AUTHOR_EMAIL='gstack@localhost' GIT_COMMITTER_NAME='gstack rescue' GIT_COMMITTER_EMAIL='gstack@localhost' "$@"; } + _label="" + [ "$UNMERGED_USER" = 1 ] && _label=" +Needs manual resolution: the install had unmerged files; their captured +content may contain conflict markers." + SCOMMIT=$(printf 'gstack rescue: staged state before updating to %s\n' "$TARGET" \ + | _id g commit-tree --no-gpg-sign "$SUSER" -p "$HEAD_OID" 2>>"$TMP/err") \ + || refuse 3 autostash-unrecoverable "cannot write the rescue commit ($(head -c 200 "$TMP/err")); nothing was changed" + RESCUE=$(printf 'gstack rescue: local edits that conflicted with %s\n\nPre-update HEAD: %s\nTarget: %s\nRestore out of the live install: git diff ^ > patch, then git apply -3 in a scratch checkout.\n%s\n' \ + "$TARGET" "$HEAD_OID" "$TARGET" "$_label" \ + | _id g commit-tree --no-gpg-sign "$WUSER" -p "$HEAD_OID" -p "$SCOMMIT" 2>>"$TMP/err") \ + || refuse 3 autostash-unrecoverable "cannot write the rescue commit ($(head -c 200 "$TMP/err")); nothing was changed" + REF="refs/gstack/rescue/$RESCUE" + if ! g update-ref -m "gstack-ff-update: rescue before $TARGET" "$REF" "$RESCUE" "" 2>/dev/null; then + [ "$(g rev-parse -q --verify "$REF" 2>/dev/null)" = "$RESCUE" ] \ + || refuse 3 autostash-unrecoverable "rescue ref $REF already exists and names another commit; nothing was changed" + fi +fi +unrescue() { [ -n "$REF" ] && g update-ref -d "$REF" "$RESCUE" 2>/dev/null; } + +# The working tree must still be exactly what was captured. +: > "$TMP/err" +RECHECK=$(capture "$TMP/w2.idx") +if [ "$RECHECK" != "$WTREE" ] || [ "$(g rev-parse -q --verify HEAD 2>/dev/null)" != "$HEAD_OID" ]; then + unrescue + refuse 3 autostash-unrecoverable "files in $DIR changed while the update ran; nothing was changed, your edits are where they were" +fi + +if ! GIT_INDEX_FILE="$TMP/w.idx" g read-tree -m -u "$WTREE" "$RTREE" 2>"$TMP/err"; then + unrescue + refuse 3 autostash-unrecoverable "git refused to update $DIR: $(head -c 200 "$TMP/err"); nothing was changed, your edits are where they were" +fi +if ! g update-ref -m "gstack-ff-update: fast-forward to $TARGET" HEAD "$TARGET" "$HEAD_OID" 2>"$TMP/err"; then + GIT_INDEX_FILE="$TMP/w.idx" g read-tree -m -u "$RTREE" "$WTREE" 2>/dev/null + unrescue + refuse 3 autostash-unrecoverable "HEAD of $DIR moved during the update; the working tree was put back" +fi +# The real index becomes the target with the carried edits unstaged. +cp "$TMP/w.idx" "$TMP/f.idx" +paths "$RTREE" "$TARGET" "$TMP/unstage" +cat "$TMP/unstage.user" "$TMP/unstage.foot" > "$TMP/unstage.all" +entries "$RTREE" "$TARGET" "$TMP/unstage.all" > "$TMP/unstage.info" +apply_entries "$TMP/f.idx" "$TMP/unstage.info" +GIT_INDEX_FILE="$TMP/f.idx" g update-index -q --refresh >/dev/null 2>&1 +cat "$TMP/f.idx" > "$INDEX.lock" && mv -f "$INDEX.lock" "$INDEX" && OWN_INDEX_LOCK="" + +if [ "$SHELVE" = 1 ] && [ -n "$REF" ]; then + _q=$(ff_quote "$DIR") + { + echo "gstack-ff-update: your local edits in $DIR conflicted with the update; they are saved in $REF and the install now runs $TARGET." + [ "$UNMERGED_USER" = 1 ] && echo " They include unmerged files: this rescue needs manual resolution." + echo " Inspect: git -C $_q show --stat '$REF'" + echo " Export: git -C $_q diff '$REF^' '$REF' > ~/gstack-local-edits.patch (then git apply -3 it in a scratch checkout, not in the install)" + echo " Delete: git -C $_q update-ref -d '$REF'" + } >&2 + echo "rescued $HEAD_OID $TARGET $REF unmerged=$UNMERGED_USER" +else + echo "ok $HEAD_OID $TARGET" +fi +exit 0 diff --git a/bin/gstack-gate-diff b/bin/gstack-gate-diff index 3544416c7..5df86f046 100755 --- a/bin/gstack-gate-diff +++ b/bin/gstack-gate-diff @@ -26,14 +26,14 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; -import { spawnSync } from 'node:child_process'; import { scanCandidate, parseGateIntegrityBlock, specDirectoryHoldsSpecs, type FileDiff, type CommitInfo } from '../lib/gate-diff/index'; import { commitInfo, commitsInRange, defaultBase, diffCommit, diffWorkingTree, firstParent, headSha, mergeBase, noBaseFix, readClaudeMd, repoRoot, - resolvesToCommit, treePaths, untrackedAsAdded, wtreeFingerprint, + resolvesToCommit, treePaths, untrackedAsAdded, } from '../lib/gate-diff/git'; import { renderDiagnostics, renderJsonl, renderJsonlBody, renderTable } from '../lib/gate-diff/render'; import { resolveStateRoot } from '../lib/state-root'; +import { runScriptSync, wtreeFingerprint } from '../lib/script-invocation'; const BIN_DIR = path.dirname(Bun.fileURLToPath(import.meta.url)); const USAGE = `usage: gstack-gate-diff [base-ref] [--format table|jsonl] [--commit ] [--help] @@ -79,7 +79,8 @@ function parseArgs(argv: string[]): Args | string { function projectSlug(cwd: string): string { try { - const r = spawnSync(path.join(BIN_DIR, 'gstack-slug'), [], { cwd, encoding: 'utf-8', timeout: 30_000 }); + const r = runScriptSync(path.join(BIN_DIR, 'gstack-slug'), [], { cwd, encoding: 'utf-8', timeout: 30_000 }); + if (r.error) console.error(`gstack-gate-diff: gstack-slug failed (${r.error.message}); artifact filed under projects/unknown`); const m = (r.stdout || '').match(/^SLUG=(.+)$/m); return m ? m[1].trim() : 'unknown'; } catch (e: any) { @@ -154,9 +155,9 @@ function main(argv: string[]): number { } files = [...diffWorkingTree(mb, root), ...untrackedAsAdded(root, root)]; commits = commitsInRange(mb, head, root); - const wtree = wtreeFingerprint(BIN_DIR, root); - if (!wtree) console.error('gstack-gate-diff: working-tree fingerprint unavailable (gstack-wtree failed); candidate recorded without it'); - candidate = `${mb.slice(0, 12)}+${wtree ? wtree.slice(0, 12) : 'nowtree'}`; + const fp = wtreeFingerprint({ cwd: root }); + if ('error' in fp) console.error(`gstack-gate-diff: working-tree fingerprint unavailable (${fp.error}); candidate recorded without it`); + candidate = `${mb.slice(0, 12)}+${'tree' in fp ? fp.tree.slice(0, 12) : 'nowtree'}`; specIsTestPath = specDirectoryHoldsSpecs(treePaths(root)); } diff --git a/bin/gstack-gbrain-sync.ts b/bin/gstack-gbrain-sync.ts index f35014d4e..47b9be292 100644 --- a/bin/gstack-gbrain-sync.ts +++ b/bin/gstack-gbrain-sync.ts @@ -30,7 +30,7 @@ */ import { existsSync, statSync, mkdirSync, writeFileSync, readFileSync, unlinkSync, renameSync, realpathSync, readdirSync, appendFileSync } from "fs"; -import { join, dirname } from "path"; +import { join, dirname, basename, isAbsolute } from "path"; import { execSync, spawnSync } from "child_process"; import { homedir, hostname } from "os"; import { createHash } from "crypto"; @@ -1474,13 +1474,32 @@ async function runCodeImport(args: CliArgs): Promise { } /** - * Ensure `.gbrain-source` is listed in the consumer repo's `.gitignore`. + * Ensure `.gbrain-source` is ignored in the consumer repo. * - * Idempotent: only appends when the entry is not already present (matched on - * trimmed lines so a leading/trailing whitespace difference doesn't add a - * second copy). Wraps writes in try/catch so a read-only checkout or weird + * Idempotent. A trimmed `.gbrain-source` line is enough, and so is a + * pattern in a tracked `.gitignore` that already ignores the pin (an + * ignore-everything `*` covers it); appending a second line would dirty that + * tracked file. Global excludes and `.git/info/exclude` do not count: they + * are invisible to teammates' clones, which would then commit the pin + * (#1384). Wraps writes in try/catch so a read-only checkout or weird * perms logs a warning and lets the rest of the sync continue. */ +function trackedGitignoreIgnoresGbrainSource(root: string): boolean { + const check = spawnSync("git", ["-C", root, "-c", "core.excludesFile=", "check-ignore", "-v", "-z", "--stdin"], { + input: ".gbrain-source\0", + encoding: "utf-8", + timeout: 5_000, + }); + if (check.status !== 0) return false; + const [source = "", , pattern = ""] = check.stdout.split("\0"); + if (pattern.startsWith("!") || basename(source) !== ".gitignore" || isAbsolute(source)) return false; + const tracked = spawnSync("git", ["-C", root, "ls-files", "--error-unmatch", "--", `:(top,literal)${source}`], { + stdio: "ignore", + timeout: 5_000, + }); + return tracked.status === 0; +} + export function ensureGbrainSourceGitignored(root: string): void { const gitignorePath = join(root, ".gitignore"); try { @@ -1493,7 +1512,7 @@ export function ensureGbrainSourceGitignored(root: string): void { const alreadyIgnored = existing .split("\n") .some((line) => line.trim() === ".gbrain-source"); - if (alreadyIgnored) { + if (alreadyIgnored || trackedGitignoreIgnoresGbrainSource(root)) { return; } const sep = existing.length > 0 && !existing.endsWith("\n") ? "\n" : ""; @@ -1643,6 +1662,9 @@ function runMemoryIngest(args: CliArgs, previous: SyncState, consent: Transcript }; } +export const NO_BASH_SUMMARY = + "skipped (no bash found; set GSTACK_GIT_BASH (old name: GSTACK_BASH) to your Git bash.exe. PowerShell: [Environment]::SetEnvironmentVariable('GSTACK_GIT_BASH', 'C:\\Program Files\\Git\\bin\\bash.exe', 'User'), then restart Claude Code; or add it to the \"env\" block of ~/.claude/settings.json)"; + function runBrainSyncPush(args: CliArgs): StageResult { const t0 = Date.now(); @@ -1670,7 +1692,7 @@ function runBrainSyncPush(args: CliArgs): StageResult { ran: false, ok: true, duration_ms: Date.now() - t0, - summary: "skipped (no bash found; set GSTACK_BASH to your Git bash.exe)", + summary: NO_BASH_SUMMARY, }; } diff --git a/bin/gstack-memorable b/bin/gstack-memorable index 482c66277..7c018fbed 100755 --- a/bin/gstack-memorable +++ b/bin/gstack-memorable @@ -37,6 +37,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" GSTACK_CONFIG="$SCRIPT_DIR/gstack-config" . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } +. "$(dirname "$0")/gstack-claude-root.sh" || { echo "$0: $(dirname "$0")/gstack-claude-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade" >&2; exit 1; } gstack_state_root_select STATE_DIR="$_gstack_sr_root" SETTINGS_FILE="${GSTACK_SETTINGS_FILE:-${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json}" @@ -53,12 +54,8 @@ VENDOR_OWN_RE='[Mm]emorable.*hook\s+user-prompt' # Canonical install root — the hook command MUST point at the stable install, # never at the tree this CLI happens to run from (setup's phantom-hooks rule). -# Copied from setup:2481-2489; TODO D24 extracts a shared helper. -CANONICAL_GSTACK_ROOT="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/gstack" -if [ ! -x "$CANONICAL_GSTACK_ROOT/bin/gstack-session-update" ] \ - && [ -x "$HOME/.claude/skills/gstack/bin/gstack-session-update" ]; then - CANONICAL_GSTACK_ROOT="$HOME/.claude/skills/gstack" -fi +# Setup registers through the same helper, so both write one path. +CANONICAL_GSTACK_ROOT="$(gstack_canonical_claude_root)" HOOK_CMD_PATH="$CANONICAL_GSTACK_ROOT/$HOOK_REL" # Mutations go through the CANONICAL hook manager so the code that registers # is the code that will run. Every verb falls back to this tree's copy when the diff --git a/bin/gstack-sanitize-echo.sh b/bin/gstack-sanitize-echo.sh new file mode 100644 index 000000000..1c73fd188 --- /dev/null +++ b/bin/gstack-sanitize-echo.sh @@ -0,0 +1,19 @@ +# shellcheck shell=bash +# gstack-sanitize-echo.sh — the Bash twin of lib/sanitize-echo.ts, the one +# sanitizer for an external string gstack echoes into a message, a log line or +# a key=value state file (command output, stderr, a version string, a path). +# Sourced, never executed. test/sanitize-echo.test.ts runs both twins, under +# bash, zsh and bash --posix, on one table. +# +# gstack_sanitize_echo [TEXT] — TEXT (stdin when no argument): CR, LF and TAB +# become spaces, every other ASCII control byte (ESC included) and DEL is +# removed, runs of spaces collapse, and at most 200 bytes of valid UTF-8 with +# no leading or trailing space are printed, then a newline. +gstack_sanitize_echo() { + { if [ "$#" -gt 0 ]; then printf '%s' "$1"; else cat; fi; } | + LC_ALL=C tr '\r\n\t' ' ' | LC_ALL=C tr -d '\000-\037\177' | LC_ALL=C tr -s ' ' | + LC_ALL=C sed -e 's/^ //' | head -c 200 | + LC_ALL=C sed -E -e "s/($(printf '[\360-\377][\200-\277][\200-\277]|[\340-\377][\200-\277]|[\300-\377]'))\$//" -e 's/ $//' | + LC_ALL=C tr -d '\n' + printf '\n' +} diff --git a/bin/gstack-session-update b/bin/gstack-session-update index 844ec6133..7166abc6d 100755 --- a/bin/gstack-session-update +++ b/bin/gstack-session-update @@ -9,7 +9,9 @@ # release's Bun floor (bin/gstack-bun-version.sh) and checks the `bun` setup # will run; below it the update is held with reason bun-too-old and nothing # changes (E1). Then it parse-checks every hook the incoming revision registers -# (bin/gstack-hook-check on a `git archive` of that revision): Claude Code runs +# (bin/gstack-hook-check on that revision, extracted through a private index +# with symlinks as plain files, never tar: MSYS tar cannot create a link +# before its target, #3184): Claude Code runs # hooks straight from this checkout, so an unparseable hook would block tool # calls the moment the checkout moved. The incoming revision's own checker # decides; the installed one decides only when that gives no verified report. @@ -18,6 +20,10 @@ # hook-check-unavailable. Updaters older than a check cannot protect their # first pull. # +# The fast-forward itself is bin/gstack-ff-update: edits that conflict with +# the release are shelved in refs/gstack/rescue/, never left as conflict +# markers, and the next session start names them (at most once a day). +# # Stages (B11): pull, setup and migrations are tracked separately. The # "just upgraded" marker is written only when every stage succeeded. A failed # stage is persisted in $STATE_DIR/session-update-pending and retried even @@ -46,6 +52,8 @@ LOCK_DIR="$STATE_DIR/.setup-lock" LOG_FILE="$STATE_DIR/analytics/session-update.log" PENDING_FILE="$STATE_DIR/session-update-pending" HOOK_CHECK="$(cd "$(dirname "$0")" && pwd)/gstack-hook-check" +FF_UPDATE="$(cd "$(dirname "$0")" && pwd)/gstack-ff-update" +RESCUE_FILE="$STATE_DIR/session-update-rescues" # Path spelling (Git Bash /c/x vs native C:/x) for the held-reason prefix strip. . "$(cd "$(dirname "$0")" && pwd)/gstack-path-spelling.sh" 2>/dev/null || { gstack_path_shell() { printf '%s\n' "$1"; }; gstack_path_native() { printf '%s\n' "$1"; }; } THROTTLE_SECONDS=3600 # 1 hour @@ -54,6 +62,13 @@ log_entry() { mkdir -p "$(dirname "$LOG_FILE")" echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $1" >> "$LOG_FILE" 2>/dev/null || true # best-effort: logging never blocks a session } +# Twin: ff_flatten in bin/gstack-ff-update (test-pinned). One line, no +# control characters or escape sequences, at most bytes. +ff_flatten() { + _ff_esc=$(printf '\033') + printf '%s' "$1" | tr '\n\r\t' ' ' | sed "s/${_ff_esc}\[[0-9;?]*[ -\/]*[@-~]//g" | tr -d '\000-\037\177' | head -c "${2:-200}" +} +sq() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; } # hook_report — sets _hr_out to the checker's report on # and returns 0 only when the report is verified and complete: exit 0 # with no fail line or exit 1 with one, every line `ok|missing ` or @@ -85,8 +100,12 @@ hook_report() { return 1 fi } -# hook_incoming_hold — parse-check the hooks of (extracted with -# git archive) before the live checkout moves. The incoming revision's own +# hook_incoming_hold — parse-check the hooks of before the live +# checkout moves. is extracted without tar and without symlinks: a +# private index (the real one is never touched) and checkout-index with +# core.symlinks=false, so a link becomes a plain file holding its target +# path, which the hook check never reads (test/session-update-hook-hold.test.ts +# keeps hooked paths and their imports free of symlinks). The incoming revision's own # bin/gstack-hook-check decides, so one checker bug in this install cannot hold # every later release; when it gives no verified report (crash, empty output, # unparseable), the installed checker decides. Prints the held reason and @@ -97,8 +116,9 @@ hook_incoming_hold() { _hc_dir=$(mktemp -d "${TMPDIR:-/tmp}/gstack-session-hooks-XXXXXX" 2>/dev/null) \ || { echo "hook-check-unavailable: cannot create a temp directory under ${TMPDIR:-/tmp}"; return 0; } _hc_out="" _hc_why="" - if ! git -C "$GSTACK_DIR" archive --format=tar "$1" 2>/dev/null | tar -xf - -C "$_hc_dir" 2>/dev/null; then - _hc_why="git archive of the incoming revision failed" + if ! ( export GIT_INDEX_FILE="$_hc_dir.index" + git -C "$GSTACK_DIR" read-tree "$1" && git -C "$GSTACK_DIR" -c core.symlinks=false checkout-index -a --prefix="$_hc_dir/" ) 2>"$_hc_dir.err"; then + _hc_why="cannot extract the incoming revision: $(ff_flatten "$(head -c 200 "$_hc_dir.err" 2>/dev/null)" 200)" elif [ -f "$_hc_dir/bin/gstack-hook-check" ] && hook_report "$_hc_dir/bin/gstack-hook-check" "$_hc_dir"; then _hc_out=$_hr_out if ! printf '%s\n' "$_hc_out" | grep -q '^fail '; then @@ -118,7 +138,7 @@ hook_incoming_hold() { fi fi _hc_fail=$(hook_hold_path "$(printf '%s\n' "$_hc_out" | sed -n 's/^fail [^ ]* //p' | head -1)") - rm -rf "$_hc_dir" "$_hc_dir.err" + rm -rf "$_hc_dir" "$_hc_dir.err" "$_hc_dir.index" if [ -n "$_hc_why" ]; then printf 'hook-check-unavailable: %s\n' "$_hc_why" | head -c 300 return 0 @@ -135,6 +155,12 @@ hook_hold_path() { done printf '%s\n' "$_hp" } +# rescue_note — remember a rescue for the session-start +# banner (shown at most once a day until the ref is deleted). +rescue_note() { + grep -q "^$1 " "$RESCUE_FILE" 2>/dev/null && return 0 + printf '%s %s -\n' "$1" "$2" >> "$RESCUE_FILE" 2>/dev/null +} pending_get() { sed -n "s/^$1=//p" "$PENDING_FILE" 2>/dev/null | head -1; } # pending_save — persist the P_* stage state; nothing pending removes the file. pending_save() { @@ -155,25 +181,64 @@ if [ "$AUTO" != "true" ]; then exit 0 fi -# ── A stage that did not finish: one line per session start, with the fix ── +# ── Session-start lines: a held update first, then a stage that did not +# finish, then edits a recovery shelved (daily). Each names its fix; every +# path in a printed command is shell-quoted. ── +_Q=$(sq "$GSTACK_DIR") +_WHERE="in a terminal; Git Bash on Windows" +# A printed instruction (the egress tripwire skips printf lines): nothing runs it here. +_FF_FIX=$( + printf "cd %s && git fetch origin && bash bin/gstack-ff-update . '@{upstream}' && ./setup" "$_Q") +_P_PULL="" _P_STAGES="" if [ -f "$PENDING_FILE" ]; then _P_STAGES="$(pending_get stages)"; _P_REASON="$(pending_get reason)"; _P_PULL="$(pending_get pull)" - if [ -n "$_P_STAGES" ]; then - _P_FIX="cd $GSTACK_DIR && ./setup" - case "$_P_REASON" in bun*) _P_FIX="install Bun (https://bun.sh), then $_P_FIX" ;; esac - echo "gstack auto-update: ${_P_STAGES} did not finish (${_P_REASON:-unknown reason}); installed skills may be out of date. gstack retries automatically. Fix now: $_P_FIX" - elif [ -n "$_P_PULL" ]; then - case "$_P_PULL" in - bun-too-old*) - echo "gstack auto-update: update held ($_P_PULL); nothing was installed or changed. Fix now: bun upgrade && cd $GSTACK_DIR && git pull --ff-only && ./setup" ;; - hook-does-not-parse*) - echo "gstack auto-update: update held ($_P_PULL); nothing was installed or changed, and your current hooks keep running. This is a gstack bug; report ${_P_PULL#hook-does-not-parse: } at https://github.com/garrytan/gstack/issues. gstack retries at the next check. https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#auto-update-hook-does-not-parse" ;; - hook-check-unavailable*) - echo "gstack auto-update: update held ($_P_PULL); nothing was installed or changed, and your current hooks keep running. gstack retries at the next check. Fix now: cd $GSTACK_DIR && git pull --ff-only --autostash && ./setup (setup checks every hook it registers). https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#auto-update-hook-check-unavailable" ;; - *) - echo "gstack auto-update: git pull did not finish ($_P_PULL); gstack is not updating. Fix now: cd $GSTACK_DIR && git pull --ff-only && ./setup" ;; - esac - fi +fi +# Unmerged files in the install: no stage fix, only the rescue-first recovery. +_UNM=$(git -C "$GSTACK_DIR" ls-files -u 2>/dev/null | head -1 | cut -f2-) +if [ -n "$_UNM" ]; then + _P_STAGES="" + case "$_P_PULL" in unmerged-files*) ;; *) _P_PULL="unmerged-files: the install has unmerged files (first: $_UNM)" ;; esac +fi +case "$_P_PULL" in + "") ;; + bun-too-old*) + echo "gstack auto-update: update held ($_P_PULL); nothing was installed or changed. Fix now ($_WHERE): bun upgrade && $_FF_FIX" ;; + bun-version-unreadable*) + echo "gstack auto-update: update held ($_P_PULL); nothing was installed or changed. Fix now: reinstall Bun (curl -fsSL https://bun.sh/install | bash), check that bun --version prints a version, then cd $GSTACK_DIR && git pull --ff-only && ./setup. https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#bun-version-unreadable" ;; + hook-does-not-parse*) + echo "gstack auto-update: update held ($_P_PULL); nothing was installed or changed, and your current hooks keep running. This is a gstack bug; report ${_P_PULL#hook-does-not-parse: } at https://github.com/garrytan/gstack/issues. gstack retries at the next check. https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#auto-update-hook-does-not-parse" ;; + hook-check-unavailable*) + echo "gstack auto-update: update held ($_P_PULL); nothing was installed or changed, and your current hooks keep running. gstack retries at the next check. Fix now ($_WHERE): $_FF_FIX (setup checks every hook it registers). https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#auto-update-hook-check-unavailable" ;; + unmerged-files*) + echo "gstack auto-update: update held ($_P_PULL); an earlier merge, rebase or stash pop left conflicts in the install, and an update would bury them. Fix now ($_WHERE): $_FF_FIX (saves every edit, the unmerged files included, in a rescue ref before it updates). https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#auto-update-unmerged-files" ;; + autostash-unrecoverable*) + echo "gstack auto-update: update held ($_P_PULL); your local edits are where they were and nothing was installed. Look first: git -C $_Q status --short. Fix now ($_WHERE): $_FF_FIX (once the cause above is gone). https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#auto-update-autostash-unrecoverable" ;; + *) + echo "gstack auto-update: git pull did not finish ($_P_PULL); gstack is not updating. Fix now ($_WHERE): $_FF_FIX" ;; +esac +if [ -n "$_P_STAGES" ]; then + _P_FIX="cd $_Q && ./setup" + case "$_P_REASON" in bun*) _P_FIX="install Bun (https://bun.sh), then $_P_FIX" ;; esac + echo "gstack auto-update: ${_P_STAGES} did not finish (${_P_REASON:-unknown reason}); installed skills may be out of date. gstack retries automatically. Fix now: $_P_FIX" +fi +if [ -s "$RESCUE_FILE" ]; then + _TODAY=$(date -u +%Y-%m-%d) + : > "$RESCUE_FILE.tmp.$$" + while read -r _R_REF _R_UNM _R_SHOWN; do + [ -n "$_R_REF" ] && git -C "$GSTACK_DIR" rev-parse -q --verify "$_R_REF^{commit}" >/dev/null 2>&1 || continue + if [ "$_R_SHOWN" != "$_TODAY" ]; then + _R_FILES=$(git -C "$GSTACK_DIR" diff --name-only "$_R_REF^" "$_R_REF" 2>/dev/null) + _R_N=$(printf '%s\n' "$_R_FILES" | grep -c .) + _R_LIST=$(printf '%s\n' "$_R_FILES" | head -3 | paste -sd, - | sed 's/,/, /g') + [ "$_R_N" -gt 3 ] && _R_LIST="$_R_LIST and $((_R_N - 3)) more" + _R_NOTE="" + [ "$_R_UNM" = 1 ] && _R_NOTE=" It includes files that were already unmerged: it needs manual resolution." + echo "gstack auto-update: your local edits in the install ($(ff_flatten "$_R_LIST" 300)) conflicted with an update, so they were saved in $_R_REF and the install runs the update.$_R_NOTE Inspect ($_WHERE): git -C $_Q show --stat '$_R_REF'. To keep them, export only your changes and apply them in a scratch checkout, not in the install: git -C $_Q diff '$_R_REF^' '$_R_REF' > ~/gstack-local-edits.patch, then git apply -3 ~/gstack-local-edits.patch. Delete when done: git -C $_Q update-ref -d '$_R_REF'. https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#auto-update-rescued-edits" + _R_SHOWN=$_TODAY + fi + printf '%s %s %s\n' "$_R_REF" "$_R_UNM" "$_R_SHOWN" >> "$RESCUE_FILE.tmp.$$" + done < "$RESCUE_FILE" + if [ -s "$RESCUE_FILE.tmp.$$" ]; then mv -f "$RESCUE_FILE.tmp.$$" "$RESCUE_FILE"; else rm -f "$RESCUE_FILE.tmp.$$" "$RESCUE_FILE"; fi fi # ── Throttle: skip if checked recently ── @@ -257,25 +322,56 @@ fi # freshens the pidfile mtime every 5 minutes for as long as we still own # the lock (ownership re-checked each beat: if another updater reclaimed # and wrote its own pid, the loop exits instead of touching THEIR file). - ( while :; do sleep 300; [ "$(cat "$LOCK_DIR/pid" 2>/dev/null)" = "$MYPID" ] || exit 0; touch "$LOCK_DIR/pid" 2>/dev/null; done ) & - HB_PID=$! - # Clean up lock on exit — ownership-checked: after a TTL reclaim by another # updater, $LOCK_DIR belongs to the NEW holder, and an unconditional rm -rf # here would delete the live holder's lock (cascading reclaims). Remove the # lock ONLY while $LOCK_DIR/pid still contains MYPID; always stop the - # heartbeat. - trap 'kill "$HB_PID" 2>/dev/null; [ "$(cat "$LOCK_DIR/pid" 2>/dev/null)" = "$MYPID" ] && rm -rf "$LOCK_DIR" 2>/dev/null' EXIT + # heartbeat, including its sleeping child, and reap it before returning. + HB_PID="" + cleanup_session_update() { + # The job-table fallback covers a signal between fork and HB_PID=$!. + # This holder has no other background jobs at that point. + for heartbeat in ${HB_PID:-$(jobs -p)}; do + kill "$heartbeat" 2>/dev/null + wait "$heartbeat" 2>/dev/null + done + [ "$(cat "$LOCK_DIR/pid" 2>/dev/null)" = "$MYPID" ] && rm -rf "$LOCK_DIR" 2>/dev/null + } + trap cleanup_session_update EXIT + trap 'exit 129' HUP + trap 'exit 130' INT + trap 'exit 143' TERM + + ( + cleanup_heartbeat() { + # Only this heartbeat's own background sleep belongs to this job table. + # Listing jobs also covers interruption before $! could be recorded. + for sleeper in $(jobs -p); do + kill "$sleeper" 2>/dev/null + wait "$sleeper" 2>/dev/null + done + } + trap cleanup_heartbeat EXIT + trap 'exit 0' HUP INT TERM + while :; do + sleep 300 & + wait "$!" || exit 0 + [ "$(cat "$LOCK_DIR/pid" 2>/dev/null)" = "$MYPID" ] || exit 0 + touch "$LOCK_DIR/pid" 2>/dev/null + done + ) & + HB_PID=$! # ── Fetch, check the incoming release's Bun floor, then fast-forward (E1) ── OLD_HEAD=$(git -C "$GSTACK_DIR" rev-parse HEAD 2>/dev/null) UPDATE_URL=$(git -C "$GSTACK_DIR" remote get-url origin 2>/dev/null || echo "") UPDATE_HOST="${UPDATE_URL#*://}"; UPDATE_HOST="${UPDATE_HOST#*@}"; UPDATE_HOST="${UPDATE_HOST%%[/:]*}" - # --autostash: locally-patched TRACKED files are the NORM on installs, not - # the exception — skill-prefix mode rewrites frontmatter names and + # Locally-patched TRACKED files are the NORM on installs, not the + # exception — skill-prefix mode rewrites frontmatter names and # `gstack-config gbrain-refresh` renders brain blocks into SKILL.md. A bare # --ff-only refuses over those edits, so auto-upgrade wedged permanently - # (observed: 308 consecutive PULL_FAILED with the reason discarded, #2566). + # (observed: 308 consecutive PULL_FAILED with the reason discarded, #2566); + # bin/gstack-ff-update carries them over the release instead. # Capture stderr: the log must carry WHY a pull failed, never just the code. PULL_ERR_FILE=$(mktemp "${TMPDIR:-/tmp}/gstack-session-pull-XXXXXX" 2>/dev/null || echo "") GSTACK_HOME="$STATE_DIR" _receipted_git open session-update "${UPDATE_HOST:-unknown}" gstack-self-update-pull "auto_upgrade=true" \ @@ -283,7 +379,7 @@ fi PULL_EXIT=$? # The fast-forward targets the exact revision whose requirement was read. # Setup runs `bun` from this PATH, so that is the binary checked; a - # malformed version passes here because setup only warns on it. + # version it cannot read holds too (bun-version-unreadable, #3195). INCOMING="" HELD="" if [ "$PULL_EXIT" -eq 0 ]; then INCOMING=$(git -C "$GSTACK_DIR" rev-parse --verify '@{upstream}^{commit}' 2>"${PULL_ERR_FILE:-/dev/null}") || PULL_EXIT=1 @@ -294,9 +390,37 @@ fi if [ "$PULL_EXIT" -eq 0 ] && [ -z "$HELD" ] && [ "$INCOMING" != "$OLD_HEAD" ]; then HELD=$(hook_incoming_hold "$INCOMING") || HELD="" fi + if [ "$PULL_EXIT" -eq 0 ] && [ -z "$HELD" ] && [ -z "$PULL_ERR_FILE" ] && [ "$INCOMING" != "$OLD_HEAD" ]; then + # Recovering a conflicting autostash needs git's own messages; without a + # file to keep them in, hold rather than merge blind. + HELD="temp-file-unavailable: cannot create a temp file under ${TMPDIR:-/tmp}" + fi if [ "$PULL_EXIT" -eq 0 ] && [ -z "$HELD" ]; then - git -C "$GSTACK_DIR" merge --ff-only --autostash -q "$INCOMING" >/dev/null 2>"${PULL_ERR_FILE:-/dev/null}" - PULL_EXIT=$? + # An index that cannot be read is never "no conflicts". + if ! _UNMERGED=$(git -C "$GSTACK_DIR" ls-files -u 2>/dev/null); then + echo "cannot read the index of $GSTACK_DIR; nothing was moved" > "${PULL_ERR_FILE:-/dev/null}" + PULL_EXIT=1 + elif [ -n "$_UNMERGED" ]; then + # Someone's own merge, rebase or stash pop (or an older updater's + # conflicting autostash) left conflicts here. A background update never + # buries them; the banner gives the command that rescues them first. + HELD="unmerged-files: $(printf '%s\n' "$_UNMERGED" | cut -f2- | sort -u | wc -l | tr -d ' ') unmerged file(s) in the install (first: $(printf '%s\n' "$_UNMERGED" | head -1 | cut -f2-))" + else + # gstack-ff-update moves the tree to the revision the checks passed: + # conflicting edits are shelved in a rescue ref, never left as markers. + FF_OUT=$(bash "$FF_UPDATE" "$GSTACK_DIR" "$INCOMING" 2>/dev/null) + PULL_EXIT=$? + case "$PULL_EXIT:$FF_OUT" in + 0:rescued\ *) + set -- $FF_OUT + log_entry "AUTOSTASH_CONFLICT_RECOVERED tree_reset=1 kept_stash=${4##*/} kept_ref=$4 $5" + rescue_note "$4" "${5#unmerged=}" ;; + 0:*) ;; + 3:*) HELD=$FF_OUT PULL_EXIT=0 ;; + 75:*) log_entry "SKIP ff_busy $(ff_flatten "$FF_OUT" 200)"; rm -f "$PULL_ERR_FILE" 2>/dev/null; exit 0 ;; + *) printf '%s\n' "${FF_OUT:-bin/gstack-ff-update exited $PULL_EXIT}" > "${PULL_ERR_FILE:-/dev/null}" ;; + esac + fi fi NEW_HEAD=$(git -C "$GSTACK_DIR" rev-parse HEAD 2>/dev/null) @@ -312,6 +436,7 @@ fi case "$P_NEXT" in ''|*[!0-9]*) P_NEXT=0 ;; esac if [ -n "$HELD" ]; then + HELD=$(ff_flatten "$HELD" 300) log_entry "HELD $HELD incoming=$INCOMING" P_PULL="$HELD" pending_save @@ -319,29 +444,25 @@ fi exit 0 fi if [ "$PULL_EXIT" -ne 0 ]; then - PULL_REASON=$(head -c 300 "$PULL_ERR_FILE" 2>/dev/null | tr '\n' ' ' | tr -s ' ') + PULL_REASON=$(ff_flatten "$(head -c 300 "$PULL_ERR_FILE" 2>/dev/null)" 300 | tr -s ' ') log_entry "PULL_FAILED exit=$PULL_EXIT reason=${PULL_REASON:-unknown}" P_PULL="exit $PULL_EXIT: $(printf '%s' "${PULL_REASON:-no error output}" | head -c 160)" - pending_save - # Autostash pop conflict leaves the stash behind and the tree half-merged. - # The local patches are REGENERABLE (prefix renames, gbrain blocks), so - # recover to a clean upstream tree and re-render them below rather than - # leaving conflict markers in a live install. - if grep -qi "autostash" "$PULL_ERR_FILE" 2>/dev/null; then - git -C "$GSTACK_DIR" checkout -q -- . 2>/dev/null - git -C "$GSTACK_DIR" stash drop -q 2>/dev/null - log_entry "AUTOSTASH_CONFLICT_RECOVERED tree_reset=1" - _PREFIX_CFG=$("$GSTACK_DIR/bin/gstack-config" get skill_prefix 2>/dev/null || echo false) - "$GSTACK_DIR/bin/gstack-patch-names" "$GSTACK_DIR" "$_PREFIX_CFG" >/dev/null 2>&1 || true # best-effort: regenerable prefix rewrite - "$GSTACK_DIR/bin/gstack-config" gbrain-refresh >/dev/null 2>&1 || true # best-effort: regenerable gbrain blocks + if [ -n "$NEW_HEAD" ] && [ "$NEW_HEAD" != "$OLD_HEAD" ]; then + # The checkout moved but setup did not run. Owe it, or once the person + # resolves the conflict the next check sees nothing new and never sets + # up this revision. The unmerged-files check holds it until then. + [ -n "$P_FROM" ] || P_FROM=$(git -C "$GSTACK_DIR" show "$OLD_HEAD:VERSION" 2>/dev/null || echo "unknown") + P_STAGES="${P_STAGES:-setup}" P_NEXT=0 P_REASON="$P_PULL" + log_entry "PENDING stages=$P_STAGES after a moved checkout" fi + pending_save rm -f "$PULL_ERR_FILE" 2>/dev/null exit 0 fi rm -f "$PULL_ERR_FILE" 2>/dev/null # Re-render local patches over the fresh tree (both tools are idempotent - # no-ops when the feature is unconfigured); the autostash pop usually - # preserves them, but a clean re-render costs nothing and self-heals. + # no-ops when the feature is unconfigured); gstack-ff-update usually + # carries them, but a clean re-render costs nothing and self-heals. _PREFIX_CFG=$("$GSTACK_DIR/bin/gstack-config" get skill_prefix 2>/dev/null || echo false) "$GSTACK_DIR/bin/gstack-patch-names" "$GSTACK_DIR" "$_PREFIX_CFG" >/dev/null 2>&1 || true # best-effort: regenerable prefix rewrite "$GSTACK_DIR/bin/gstack-config" gbrain-refresh >/dev/null 2>&1 || true # best-effort: regenerable gbrain blocks diff --git a/bin/gstack-skill-end b/bin/gstack-skill-end index 797a268af..dc0d10587 100755 --- a/bin/gstack-skill-end +++ b/bin/gstack-skill-end @@ -54,15 +54,17 @@ case "$TEL_START" in ''|*[!0-9]*) TEL_START="$_TEL_END" ;; esac _TEL_DUR=$(( _TEL_END - TEL_START )) [ -n "$SESSION_ID" ] && rm -f "$_GH/analytics/.pending-$SESSION_ID" 2>/dev/null || true +# Record completion before the final drain, including its durable enqueue. +# The normal sync gates still decide publication: full may sync timelines, +# artifacts-only retains them locally, and unattended never enqueues or drains. +"$_BIN/gstack-timeline-log" '{"skill":"'"$SKILL_NAME"'","event":"completed","branch":"'"$(_b=$(git branch --show-current 2>/dev/null | tr -cd 'a-zA-Z0-9._/-'); echo "${_b:-unknown}")"'","outcome":"'"$OUTCOME"'","duration_s":"'"$_TEL_DUR"'","session":"'"$SESSION_ID"'"}' 2>/dev/null || true + # Skill-end artifacts sync (drain + push; formerly its own inline fence). if [ "$_SESSION_KIND" != "unattended" ]; then "$_BIN/gstack-brain-sync" --discover-new 2>/dev/null || true "$_BIN/gstack-brain-sync" --once 2>/dev/null || true fi -# Session timeline: local-only, never sent anywhere. -"$_BIN/gstack-timeline-log" '{"skill":"'"$SKILL_NAME"'","event":"completed","branch":"'"$(_b=$(git branch --show-current 2>/dev/null | tr -cd 'a-zA-Z0-9._/-'); echo "${_b:-unknown}")"'","outcome":"'"$OUTCOME"'","duration_s":"'"$_TEL_DUR"'","session":"'"$SESSION_ID"'"}' 2>/dev/null || true - # Local analytics (gated on telemetry setting). if [ "$_TEL" != "off" ]; then echo '{"skill":"'"$SKILL_NAME"'","duration_s":"'"$_TEL_DUR"'","outcome":"'"$OUTCOME"'","browse":"'"$USED_BROWSE"'","session":"'"$SESSION_ID"'","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'"}' >> "$_GH/analytics/skill-usage.jsonl" 2>/dev/null || true diff --git a/bin/gstack-timeline-log b/bin/gstack-timeline-log index 0f24f8a23..9bd6bc391 100755 --- a/bin/gstack-timeline-log +++ b/bin/gstack-timeline-log @@ -38,5 +38,8 @@ fi echo "$INPUT" >> "$GSTACK_HOME/projects/$SLUG/timeline.jsonl" -# gbrain-sync: enqueue for cross-machine sync (no-op if sync is off). -"$SCRIPT_DIR/gstack-brain-enqueue" "projects/$SLUG/timeline.jsonl" 2>/dev/null & +# gbrain-sync: enqueue for cross-machine sync (no-op if sync is off). Finish +# the enqueue before returning: gstack-skill-end drains right after this +# logger, and a backgrounded enqueue can let that drain miss the new event. +# The helper keeps its off/unattended gates and silent-failure behavior. +"$SCRIPT_DIR/gstack-brain-enqueue" "projects/$SLUG/timeline.jsonl" 2>/dev/null || true diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index 711fb6c03..338c6dd6b 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -27,6 +27,8 @@ import { ensureProjectProfile } from './chromium-profiles'; import { launchWithXProtectHeal } from './xprotect-heal'; import { readPidStartTime, shouldSpawnXvfb, spawnFreeXvfb, xvfbInstallHint, type XvfbHandle } from './xvfb'; import { withCdpSession } from './cdp-bridge'; +import { resultLine } from '../../lib/result-codes'; +import { sanitizeEcho } from '../../lib/sanitize-echo'; import type { MemorySnapshot, MemoryStructureStats, MemoryTabSnapshot, MemoryProcess } from './memory-snapshot'; /** @@ -406,6 +408,7 @@ export class BrowserManager { private xvfb: XvfbHandle | null = null; private displayAllocation: Promise | null = null; private handoffPending: Promise | null = null; + private activeTabRecovery: Promise | null = null; private closing = false; private handoffPrevious: { browser: Browser; pages: Map; tabSessions: Map; activeTabId: number } | null = null; @@ -1312,6 +1315,48 @@ export class BrowserManager { } // ─── Session Access ──────────────────────────────────────── + /** + * Restore an active tab when every page was closed outside the CLI (for + * example, the user closed the headed window). The page 'close' handler is + * synchronous and cannot open a replacement, which left activeTabId at 0 and + * every later command, including status and stop, failing with "No active + * page". closeTab() already opens a blank tab for the last-tab case; this + * covers the external path. Concurrent callers share one recovery. Returns + * true when a new tab had to be opened. + */ + async ensureActiveTab(): Promise { + if (this.tabSessions.has(this.activeTabId)) return false; + const remaining = [...this.tabSessions.keys()]; + if (remaining.length > 0) { + this.activeTabId = remaining[remaining.length - 1]!; + return false; + } + if (!this.context) throw new Error('Browser not launched'); + this.activeTabRecovery ??= this.newTab() + .then(() => {}) + .finally(() => { this.activeTabRecovery = null; }); + await this.activeTabRecovery; + return true; + } + + /** + * Called before every command runs (#3167). status, stop and restart never + * open a tab, so a daemon whose browser or context is gone can still report + * and be stopped. Any other command gets an active tab: a failed recovery is + * BROWSE_TAB_UNRECOVERABLE with the restart command, and a command that runs + * on a newly opened tab gets the returned one-line warning. + */ + async ensureCommandTab(command: string): Promise { + if (BrowserManager.NO_TAB_RECOVERY.has(command)) return undefined; + try { + if (!(await this.ensureActiveTab())) return undefined; + } catch (err: any) { + throw new Error(resultLine('browse', 'BROWSE_TAB_UNRECOVERABLE', sanitizeEcho(err?.message ?? String(err)))); + } + return '[browse] warning: every tab was closed outside browse, so this command ran on a new blank tab; open your page again with: browse goto '; + } + private static readonly NO_TAB_RECOVERY = new Set(['status', 'stop', 'restart']); + /** Get the TabSession for the active tab. */ getActiveSession(): TabSession { const session = this.tabSessions.get(this.activeTabId); diff --git a/browse/src/meta-commands.ts b/browse/src/meta-commands.ts index b59247504..8cc9e2e6c 100644 --- a/browse/src/meta-commands.ts +++ b/browse/src/meta-commands.ts @@ -258,9 +258,8 @@ export async function handleMetaCommand( tokenInfo?: TokenInfo | null, opts?: MetaCommandOpts, ): Promise { - // Per-tab operations use the active session; global operations use bm directly - const session = bm.getActiveSession(); - + // Per-tab operations read the active session where they run, so status, + // stop and restart still answer when every tab was closed (#3167). switch (command) { // ─── Tabs ────────────────────────────────────────── case 'tabs': { @@ -403,13 +402,12 @@ export async function handleMetaCommand( // ─── Server Control ──────────────────────────────── case 'status': { - const page = bm.getPage(); const tabs = bm.getTabCount(); const mode = bm.getConnectionMode(); return [ `Status: healthy`, `Mode: ${mode}`, - `URL: ${page.url()}`, + `URL: ${bm.getCurrentUrl()}`, `Tabs: ${tabs}`, `PID: ${process.pid}`, ].join('\n'); @@ -720,7 +718,7 @@ export async function handleMetaCommand( // ─── Snapshot ───────────────────────────────────── case 'snapshot': { const isScoped = tokenInfo && tokenInfo.clientId !== 'root'; - const snapshotResult = await handleSnapshot(args, session, { + const snapshotResult = await handleSnapshot(args, bm.getActiveSession(), { splitForScoped: !!isScoped, }); // Scoped tokens get split format (refs outside envelope); root gets basic wrapping @@ -737,6 +735,7 @@ export async function handleMetaCommand( } case 'resume': { + const session = bm.getActiveSession(); bm.resume(); // Re-snapshot to capture current page state after human interaction const isScoped2 = tokenInfo && tokenInfo.clientId !== 'root'; diff --git a/browse/src/server.ts b/browse/src/server.ts index 048eb37af..3bcaeab35 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -739,6 +739,7 @@ export const __testInternals__ = { // need shutdown to fire. Without this, the second test's shutdown // returns early at the `if (isShuttingDown) return;` guard. resetShutdownState: () => { isShuttingDown = false; }, + get browserManager() { return browserManager; }, handleCommandInternal, // tab-external-close.test.ts drives real dispatch }; // ─── Parent-Process Watchdog ──────────────────────────────────────── @@ -1120,7 +1121,7 @@ async function handleCommandInternalImpl( try { let result: string; - const session = browserManager.getActiveSession(); + const tabWarning = await browserManager.ensureCommandTab(command); // Per-request warnings collected during hidden-element detection, // surfaced into the envelope the LLM sees. Carries across the read @@ -1128,6 +1129,7 @@ async function handleCommandInternalImpl( let hiddenContentWarnings: string[] = []; if (READ_COMMANDS.has(command)) { + const session = browserManager.getActiveSession(); const isScoped = tokenInfo && tokenInfo.clientId !== 'root'; // Hidden-element / ARIA-injection detection for every scoped // DOM-reading channel (text, html, links, forms, accessibility, @@ -1162,6 +1164,7 @@ async function handleCommandInternalImpl( result = await handleReadCommand(command, args, session, browserManager); } } else if (WRITE_COMMANDS.has(command)) { + const session = browserManager.getActiveSession(); result = await browserManager.failIfNavigationBlocked(session.getPage(), handleWriteCommand(command, args, session, browserManager)); } else if (META_COMMANDS.has(command)) { // Pass chain depth + executeCommand callback so chain routes subcommands @@ -1280,7 +1283,7 @@ async function handleCommandInternalImpl( console.warn('[browse] Failed to restore tab after command:', restoreErr.message); } } - return { status: 200, result }; + return { status: 200, result: tabWarning ? `${tabWarning}\n${result}` : result }; } catch (err: any) { // Restore original active tab even on error if (savedTabId !== null) { diff --git a/browse/src/terminal-agent.ts b/browse/src/terminal-agent.ts index 7ce237a3e..430ff56f2 100644 --- a/browse/src/terminal-agent.ts +++ b/browse/src/terminal-agent.ts @@ -31,7 +31,7 @@ import { findAvailablePort } from './port-allocator'; import { extractPtyCookie } from './pty-session-cookie'; import { allowedExtensionOrigin } from './extension-id'; import { - createPtyLifecycle, disposePtyProcess, ptyCompletionReason, + createPtyLifecycle, disposePtyProcess, ptyCompletionReason, PTY_SHUTDOWN_GRACE_MS, type PtyCompletion, type PtyLifecycle, } from './terminal-pty-lifecycle'; @@ -85,6 +85,8 @@ const sessionsById = new Map(); // the listener loop. export interface PtySession { proc: any | null; // Bun.Subprocess once spawned + /** Spawn-time identity of the PTY's process group; outlives the leader so shutdown can reap HUP-immune descendants. */ + group?: PtyGroupIdentity | null; lifecycle?: PtyLifecycle | null; completion?: PtyCompletion; disposed?: boolean; @@ -237,6 +239,64 @@ export function buildReplayPayload(session: PtySession): Buffer { } const sessions = new WeakMap(); // ws -> session +// Strong ownership index for shutdown. sessions is intentionally weak and +// sessionsById excludes legacy grants, so neither can enumerate every PTY +// child when the terminal-agent is replaced or its owner disappears. +const activeSessions = new Set(); +// Set once shutdown starts: no new connection or PTY spawn during the grace. +let shuttingDown = false; +// Groups of disposed sessions whose delayed reap has not run yet; shutdown reaps them too. +const pendingGroupReaps = new Set(); + +/** + * A PTY child is its own session and process-group leader (pgid = sid = + * pid). That identity, with the leader's start time, stays checkable after + * the leader exits, so descendants that ignore SIGHUP can still be found. + */ +export interface PtyGroupIdentity { pgid: number; sid: number; startTime: number } +interface ProcStat { state: string; pgrp: number; session: number; startTime: number } + +/** Linux /proc//stat fields after the parenthesised comm; null when absent (or not Linux). */ +export function readProcStat(pid: number, procRoot = '/proc'): ProcStat | null { + let raw: string; + try { raw = fs.readFileSync(path.join(procRoot, String(pid), 'stat'), 'utf8'); } catch { return null; } + const f = raw.slice(raw.lastIndexOf(')') + 2).split(' '); + const stat = { state: f[0] ?? '', pgrp: Number(f[2]), session: Number(f[3]), startTime: Number(f[19]) }; + return Number.isSafeInteger(stat.pgrp) && Number.isSafeInteger(stat.session) && Number.isSafeInteger(stat.startTime) ? stat : null; +} + +/** The group identity of a freshly spawned PTY child, or null unless it provably leads its own session. */ +export function ptyGroupIdentity(pid: number, procRoot = '/proc'): PtyGroupIdentity | null { + const stat = readProcStat(pid, procRoot); + if (!stat || stat.pgrp !== pid || stat.session !== pid) return null; + return { pgid: pid, sid: pid, startTime: stat.startTime }; +} + +/** + * Live processes proven to belong to the group: same pgid and sid, started + * no earlier than its leader. A live process holding the leader's PID with + * another start time means the number was reused, so nothing is ours. + */ +export function ownedGroupMembers(id: PtyGroupIdentity, procRoot = '/proc'): number[] { + const leader = readProcStat(id.pgid, procRoot); + if (leader && leader.startTime !== id.startTime) return []; + let entries: string[]; + try { entries = fs.readdirSync(procRoot); } catch { return []; } + const members: number[] = []; + for (const name of entries) { + if (!/^\d+$/.test(name)) continue; + const stat = readProcStat(Number(name), procRoot); + if (stat && stat.state !== 'Z' && stat.pgrp === id.pgid && stat.session === id.sid && stat.startTime >= id.startTime) members.push(Number(name)); + } + return members; +} + +/** SIGKILL every proven member of the group; returns how many were signalled. */ +export function reapOwnedGroup(id: PtyGroupIdentity, procRoot = '/proc', kill: (pid: number, signal: string) => void = process.kill): number { + const members = ownedGroupMembers(id, procRoot); + for (const pid of members) { try { kill(pid, 'SIGKILL'); } catch {} } + return members.length; +} /** Find claude on PATH. */ function findClaude(): string | null { @@ -365,6 +425,8 @@ function spawnClaude(cols: number, rows: number, lifecycle: PtyLifecycle) { /** Cleanup a PTY session: SIGINT, then SIGKILL after 3s. */ function disposeSession(session: PtySession): void { + if (session.disposed) return; + activeSessions.delete(session); // Suppress callbacks caused by explicit close, and cancel any drain deadline. session.disposed = true; session.lifecycle?.dispose(); @@ -373,6 +435,12 @@ function disposeSession(session: PtySession): void { session.proc = null; session.spawned = false; disposePtyProcess(proc); + // Unlike the direct-child fallback, the group reap is not cancelled when + // the leader exits: a descendant that ignores SIGHUP outlives it. + const group = session.group; + if (!group) return; + pendingGroupReaps.add(group); + setTimeout(() => { pendingGroupReaps.delete(group); reapOwnedGroup(group); }, PTY_SHUTDOWN_GRACE_MS).unref?.(); } function sendPtyCompletion(session: PtySession): void { @@ -456,7 +524,7 @@ async function internalHandler( * surfaced the error to the client (or will via the next frame). */ function maybeSpawnPty(ws: any, session: PtySession): boolean { - if (session.disposed || session.completion) return false; + if (shuttingDown || session.disposed || session.completion) return false; if (session.spawned) return true; session.spawned = true; let leftover = Buffer.alloc(0); @@ -510,6 +578,7 @@ function maybeSpawnPty(ws: any, session: PtySession): boolean { return false; } session.proc = proc; + session.group = ptyGroupIdentity(proc.pid); proc.exited.then( (code: number) => lifecycle.exited(code, proc.signalCode ?? null), () => lifecycle.exited(null, proc.signalCode ?? null, true), @@ -534,6 +603,7 @@ function buildServer(port: number) { idleTimeout: 0, // PTY connections are long-lived; default idleTimeout would kill them fetch(req, server) { + if (shuttingDown) return new Response('shutting down', { status: 503 }); const url = new URL(req.url); // /internal/grant — loopback-only handshake from parent server. @@ -748,6 +818,7 @@ function buildServer(port: number) { detached: false, detachTimer: null, }; + activeSessions.add(session); session.pingInterval = setInterval(() => { try { ws.send(JSON.stringify({ type: 'ping', ts: Date.now() })); @@ -782,6 +853,7 @@ function buildServer(port: number) { detached: false, detachTimer: null, }; + activeSessions.add(session); sessions.set(ws, session); if (session.sessionId) sessionsById.set(session.sessionId, session); } @@ -1070,9 +1142,20 @@ async function main() { // Cleanup port file + agent record on exit. let cleaningUp = false; - const cleanup = () => { + const cleanup = async () => { if (cleaningUp) return; cleaningUp = true; + shuttingDown = true; + // disposePtyProcess sends SIGINT and schedules the direct child's SIGKILL + // fallback. Each PTY's process group, captured at spawn, is reaped after + // the same grace even when its leader exits first, so descendants that + // ignore SIGHUP do not outlive the agent. Awaiting here keeps both alive + // instead of process.exit(0) abandoning them. + const owned = [...activeSessions]; + const childExits = owned.map(session => session.proc?.exited).filter((exited): exited is Promise => !!exited); + for (const session of owned) disposeSession(session); + const groups = [...pendingGroupReaps]; + sessionsById.clear(); try { const releaseCleanup = acquireAgentStateLock(dir, 25); try { @@ -1083,10 +1166,16 @@ async function main() { } } finally { releaseCleanup(); } } catch {} + let childrenExited = childExits.length === 0; + void Promise.allSettled(childExits).then(() => { childrenExited = true; }); + const deadline = Date.now() + PTY_SHUTDOWN_GRACE_MS; + while (Date.now() < deadline && !(childrenExited && groups.every(group => ownedGroupMembers(group).length === 0))) await Bun.sleep(100); + for (const group of groups) reapOwnedGroup(group); + if (!childrenExited) await Promise.race([Promise.allSettled(childExits), Bun.sleep(250)]); process.exit(0); }; - process.on('SIGTERM', cleanup); - process.on('SIGINT', cleanup); + process.on('SIGTERM', () => { void cleanup(); }); + process.on('SIGINT', () => { void cleanup(); }); // The terminal agent is intentionally detached so it survives the short-lived // CLI launcher, but its real owner is the persistent browse server. If that @@ -1096,7 +1185,7 @@ async function main() { if (BROWSE_OWNER_PID > 0) { const ownerWatchdog = setInterval(() => { if (!BROWSE_OWNER_START_TIME || readAgentStartTime(BROWSE_OWNER_PID) !== BROWSE_OWNER_START_TIME - || readAgentRecord(dir)?.gen !== CURRENT_GEN) cleanup(); + || readAgentRecord(dir)?.gen !== CURRENT_GEN) void cleanup(); }, OWNER_WATCHDOG_MS); (ownerWatchdog as any)?.unref?.(); } diff --git a/browse/test/tab-external-close-dispatch.test.ts b/browse/test/tab-external-close-dispatch.test.ts new file mode 100644 index 000000000..62d9e61ef --- /dev/null +++ b/browse/test/tab-external-close-dispatch.test.ts @@ -0,0 +1,107 @@ +/** + * #3167 through the real command dispatch (server.ts handleCommandInternal on + * the module's own BrowserManager): after every tab is closed outside the CLI, + * status and stop answer without opening a tab, other commands run on a new + * tab and say so in one line, and a browser whose context is gone fails with + * BROWSE_TAB_UNRECOVERABLE and the restart command while status and stop keep + * working. Runs in a child process so `stop` and the module-level server state + * never touch this test process. + */ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +let dir = ''; +let steps: Record = {}; + +const SCRIPT = (root: string) => ` +import { __testInternals__ } from ${JSON.stringify(path.resolve(import.meta.dir, '../src/server.ts'))}; +import { startTestServer } from ${JSON.stringify(path.resolve(import.meta.dir, 'test-server.ts'))}; +const fixture = startTestServer(0); +const bm = __testInternals__.browserManager; +const run = (command, args = []) => __testInternals__.handleCommandInternal({ command, args }, null); +const closeEveryTab = async () => { + while (bm.getTabCount() > 0) { + const page = bm.getPage(); + const closed = new Promise(resolve => page.once('close', resolve)); + await page.close(); + await closed; + } +}; +const steps = {}; +const record = async (name, command, args) => { const r = await run(command, args); steps[name] = { status: r.status, result: r.result, tabs: bm.getTabCount() }; }; +await bm.launch(); +await record('goto-first', 'goto', [fixture.url + '/basic.html']); +await closeEveryTab(); +await record('status', 'status'); +await record('text', 'text'); +await record('text-again', 'text'); +await closeEveryTab(); +await record('goto', 'goto', [fixture.url + '/basic.html']); +await closeEveryTab(); +await record('stop', 'stop'); +await record('restart-check', 'tabs'); +await bm.context.close(); +await record('closed-text', 'text'); +await record('closed-status', 'status'); +await record('closed-stop', 'stop'); +await Bun.write(${JSON.stringify(path.join(root, 'steps.json'))}, JSON.stringify(steps)); +fixture.server.stop(true); +await Promise.race([bm.close(), Bun.sleep(3000)]); +process.exit(0); +`; + +beforeAll(() => { + dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'browse-external-close-'))); + fs.writeFileSync(path.join(dir, 'drive.ts'), SCRIPT(dir)); + const r = spawnSync(process.execPath, [path.join(dir, 'drive.ts')], { + encoding: 'utf8', timeout: 90_000, + env: { ...process.env, GSTACK_HOME: path.join(dir, 'state'), CHROMIUM_PROFILE: path.join(dir, 'profile'), + BROWSE_STATE_FILE: path.join(dir, 'state', 'browse.json'), BROWSE_PARENT_PID: '0' }, + }); + if (!fs.existsSync(path.join(dir, 'steps.json'))) throw new Error(`driver failed (${r.status}): ${r.stderr.slice(-2000)}`); + steps = JSON.parse(fs.readFileSync(path.join(dir, 'steps.json'), 'utf8')); +}, 120_000); +afterAll(() => { if (dir) fs.rmSync(dir, { recursive: true, force: true }); }); + +const WARNING = '[browse] warning: every tab was closed outside browse, so this command ran on a new blank tab; open your page again with: browse goto '; + +describe('external close of the last tab, through command dispatch (#3167)', () => { + test('status answers without opening a tab', () => { + expect(steps.status).toMatchObject({ status: 200, tabs: 0 }); + expect(steps.status.result).toContain('Status: healthy'); + expect(steps.status.result).toContain('Tabs: 0'); + }); + + test('a text read after recovery runs on a new tab and shows the warning once', () => { + expect(steps.text.status).toBe(200); + expect(steps.text.tabs).toBe(1); + expect(steps.text.result.split('\n')[0]).toBe(WARNING); + expect(steps['text-again'].status).toBe(200); + expect(steps['text-again'].result).not.toContain(WARNING); + }); + + test('goto after an external close navigates a new tab', () => { + expect(steps.goto).toMatchObject({ status: 200, tabs: 1 }); + expect(steps.goto.result).toStartWith(`${WARNING}\n`); + expect(steps.goto.result).toContain('basic.html'); + }); + + test('stop answers without opening a tab', () => { + expect(steps.stop).toMatchObject({ status: 200, result: 'Server stopped', tabs: 0 }); + }); + + test('a closed context fails with the named error and restart command; status and stop still answer', () => { + expect(steps['closed-text'].status).toBe(500); + const error = JSON.parse(steps['closed-text'].result).error as string; + expect(error).toContain('browse: every tab was closed outside browse and a new one could not be opened'); + expect(error).toContain('fix: run `browse restart`'); + expect(error).toContain('(BROWSE_TAB_UNRECOVERABLE)'); + expect(error).not.toContain('\n['); + expect(steps['closed-status']).toMatchObject({ status: 200, tabs: 0 }); + expect(steps['closed-status'].result).toContain('Status: healthy'); + expect(steps['closed-stop']).toMatchObject({ status: 200, result: 'Server stopped' }); + }); +}); diff --git a/browse/test/tab-external-close.test.ts b/browse/test/tab-external-close.test.ts new file mode 100644 index 000000000..d84b3fba4 --- /dev/null +++ b/browse/test/tab-external-close.test.ts @@ -0,0 +1,80 @@ +/** + * Regression (#3167): closing the last tab outside the CLI (for example, the + * user closing the headed Chromium window) must not wedge the daemon. + * + * The page 'close' handler removes the tab and leaves activeTabId at 0, so + * getActiveSession() threw "No active page" for every later command, + * including status and stop. closeTab() already opens a blank tab for the + * last-tab case; ensureActiveTab() restores one for the external path before + * each command runs. + */ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { BrowserManager } from '../src/browser-manager'; + +// Per-file Chromium profile, scoped via hooks (see commands.test.ts). +const ORIGINAL_CHROMIUM_PROFILE = process.env.CHROMIUM_PROFILE; +let profileDir: string | undefined; +let bm: BrowserManager; + +beforeAll(async () => { + profileDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-test-profile-')); + process.env.CHROMIUM_PROFILE = profileDir; + bm = new BrowserManager(); + await bm.launch(); +}); + +afterAll(async () => { + await bm?.close(); + if (ORIGINAL_CHROMIUM_PROFILE === undefined) delete process.env.CHROMIUM_PROFILE; + else process.env.CHROMIUM_PROFILE = ORIGINAL_CHROMIUM_PROFILE; + if (profileDir) { + try { fs.rmSync(profileDir, { recursive: true, force: true }); } catch {} + } +}); + +const closeEveryTabExternally = async () => { + // Close the Playwright pages directly, bypassing closeTab(), the way a + // user closing the window does. + while (bm.getTabCount() > 0) { + const page = bm.getPage(); + const closed = new Promise((resolve) => page.once('close', () => resolve())); + await page.close(); + await closed; + } +}; + +describe('external close of the last tab', () => { + test('leaves no active session until a tab is restored', async () => { + await bm.ensureActiveTab(); + await closeEveryTabExternally(); + + expect(bm.getTabCount()).toBe(0); + expect(() => bm.getActiveSession()).toThrow('No active page'); + + await bm.ensureActiveTab(); + + expect(bm.getTabCount()).toBe(1); + expect(bm.getActiveSession().page.isClosed()).toBe(false); + }); + + test('concurrent commands restore exactly one tab', async () => { + await closeEveryTabExternally(); + + await Promise.all([bm.ensureActiveTab(), bm.ensureActiveTab(), bm.ensureActiveTab()]); + + expect(bm.getTabCount()).toBe(1); + }); + + test('keeps the existing active tab when one is open', async () => { + await bm.ensureActiveTab(); + const before = bm.getActiveSession(); + + await bm.ensureActiveTab(); + + expect(bm.getActiveSession()).toBe(before); + expect(bm.getTabCount()).toBe(1); + }); +}); diff --git a/browse/test/terminal-agent-signal-cleanup.test.ts b/browse/test/terminal-agent-signal-cleanup.test.ts new file mode 100644 index 000000000..d7ecdeaa3 --- /dev/null +++ b/browse/test/terminal-agent-signal-cleanup.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, test } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { GSTACK_EXTENSION_ID } from '../src/extension-id'; +import { ownedGroupMembers, ptyGroupIdentity, reapOwnedGroup, type PtyGroupIdentity } from '../src/terminal-agent'; +import { PTY_SHUTDOWN_GRACE_MS } from '../src/terminal-pty-lifecycle'; + +const AGENT_SCRIPT = path.join(import.meta.dir, '../src/terminal-agent.ts'); +const EXT_ORIGIN = `chrome-extension://${GSTACK_EXTENSION_ID}`; +const linux = process.platform === 'linux'; +// The agent's graceful shutdown is its SIGTERM/SIGINT handler (terminal-agent.ts +// cleanup). On Windows, Bun's subprocess.kill('SIGTERM') is TerminateProcess, so +// that handler never runs, and the PTY fixture is a bash shebang script that +// CreateProcess cannot launch: the signal-driven tests are POSIX-scoped. +const posixSignals = process.platform !== 'win32'; + +function waitForFile(file: string, timeoutMs = 5000): string { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const value = fs.readFileSync(file, 'utf8').trim(); + if (value) return value; + } catch {} + Bun.sleepSync(25); + } + throw new Error(`timed out waiting for ${path.basename(file)}`); +} + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + } catch (err: any) { + return err?.code === 'EPERM'; + } + // An orphan that was killed can linger as a zombie until its reaper runs. + try { return !/^\d+ \(.*\) Z /s.test(fs.readFileSync(`/proc/${pid}/stat`, 'utf8')); } catch { return true; } +} + +/** Start a real terminal-agent whose PTY runs `script`, open one session, and return the pids the script recorded. */ +async function withAgent(script: string[], pidFiles: string[], body: (ctx: { agent: any; pids: number[]; stateDir: string }) => Promise) { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-term-signal-')); + const stateFile = path.join(stateDir, 'browse.json'); + const terminalCli = path.join(stateDir, 'terminal-cli'); + let agent: any; + let ws: WebSocket | null = null; + const pids: number[] = []; + try { + fs.writeFileSync(stateFile, JSON.stringify({ token: 'test-browse-token' })); + fs.writeFileSync(terminalCli, ['#!/bin/bash', 'shift 2', ...script, ''].join('\n')); + fs.chmodSync(terminalCli, 0o755); + agent = Bun.spawn([process.execPath, 'run', AGENT_SCRIPT], { + env: { + ...process.env, + BROWSE_STATE_FILE: stateFile, + BROWSE_SERVER_PORT: '0', + BROWSE_TERMINAL_BINARY: terminalCli, + GSTACK_STATE_ROOT: stateDir, + GSTACK_TEST_PID_DIR: stateDir, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const port = Number(waitForFile(path.join(stateDir, 'terminal-port'))); + const internalToken = waitForFile(path.join(stateDir, 'terminal-internal-token')); + const attachToken = 'signal-cleanup-token-at-least-seventeen'; + const grant = await fetch(`http://127.0.0.1:${port}/internal/grant`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${internalToken}` }, + body: JSON.stringify({ token: attachToken, sessionId: 'signal-cleanup-session' }), + }); + expect(grant.status).toBe(200); + ws = new WebSocket(`ws://127.0.0.1:${port}/ws`, { headers: { Origin: EXT_ORIGIN, Cookie: `gstack_pty=${attachToken}` } } as any); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('websocket did not open')), 5000); + ws!.addEventListener('open', () => { clearTimeout(timer); resolve(); }); + ws!.addEventListener('error', () => { clearTimeout(timer); reject(new Error('websocket failed')); }); + }); + ws.send(new Uint8Array([10])); + for (const name of pidFiles) pids.push(Number(waitForFile(path.join(stateDir, name)))); + await body({ agent, pids, stateDir }); + } finally { + try { ws?.close(); } catch {} + try { agent?.kill?.('SIGKILL'); } catch {} + for (const pid of pids) if (isAlive(pid)) { try { process.kill(pid, 'SIGKILL'); } catch {} } + try { fs.rmSync(stateDir, { recursive: true, force: true }); } catch {} + } +} + +async function terminateAndWait(agent: any): Promise { + const started = Date.now(); + agent.kill('SIGTERM'); + await Promise.race([ + agent.exited, + Bun.sleep(PTY_SHUTDOWN_GRACE_MS + 3000).then(() => { throw new Error('terminal-agent did not exit'); }), + ]); + return started; +} + +/** Poll until every pid is dead or the deadline passes. */ +async function allDeadBy(pids: number[], deadline: number): Promise { + while (Date.now() < deadline) { + if (pids.every(pid => !isAlive(pid))) return true; + await Bun.sleep(50); + } + return pids.every(pid => !isAlive(pid)); +} + +const HUP_IMMUNE_GRANDCHILD = `( trap '' INT TERM HUP; echo "$BASHPID" > "$GSTACK_TEST_PID_DIR/grandchild.pid"; while :; do sleep 1; done ) &`; + +describe('terminal-agent shutdown reaps its PTY process groups (#3147)', () => { + test.skipIf(!posixSignals)('SIGTERM reaps every active PTY child before terminal-agent exits', async () => { + await withAgent([ + 'echo "$$" > "$GSTACK_TEST_PID_DIR/pty-child.pid"', + "trap '' INT TERM HUP", + 'while :; do sleep 1; done', + ], ['pty-child.pid'], async ({ agent, pids }) => { + expect(isAlive(pids[0]!)).toBe(true); + await terminateAndWait(agent); + expect(isAlive(pids[0]!)).toBe(false); + }); + }, 15_000); + + test.skipIf(!linux)('a HUP-immune grandchild is dead within the grace period plus one second', async () => { + await withAgent([ + 'echo "$$" > "$GSTACK_TEST_PID_DIR/pty-child.pid"', + HUP_IMMUNE_GRANDCHILD, + "trap '' INT TERM HUP", + 'while :; do sleep 1; done', + ], ['pty-child.pid', 'grandchild.pid'], async ({ agent, pids }) => { + expect(pids.every(isAlive)).toBe(true); + const started = await terminateAndWait(agent); + expect(await allDeadBy(pids, started + PTY_SHUTDOWN_GRACE_MS + 1000)).toBe(true); + }); + }, 15_000); + + test.skipIf(!linux)('a descendant that survives its early-exiting leader is still reaped at shutdown', async () => { + await withAgent([ + HUP_IMMUNE_GRANDCHILD, + 'while [ ! -s "$GSTACK_TEST_PID_DIR/grandchild.pid" ]; do sleep 0.05; done', + 'echo "$$" > "$GSTACK_TEST_PID_DIR/pty-child.pid"', + 'exit 0', + ], ['pty-child.pid', 'grandchild.pid'], async ({ agent, pids }) => { + const [leader, grandchild] = pids as [number, number]; + expect(await allDeadBy([leader], Date.now() + 3000)).toBe(true); + expect(isAlive(grandchild)).toBe(true); + const started = await terminateAndWait(agent); + expect(await allDeadBy([grandchild], started + PTY_SHUTDOWN_GRACE_MS + 1000)).toBe(true); + }); + }, 20_000); + + test.skipIf(!linux)('a session that already completed before shutdown still has its group reaped', async () => { + await withAgent([ + HUP_IMMUNE_GRANDCHILD, + 'while [ ! -s "$GSTACK_TEST_PID_DIR/grandchild.pid" ]; do sleep 0.05; done', + 'exec 0<&- 1>&- 2>&-', + 'echo "$$" > "$GSTACK_TEST_PID_DIR/pty-child.pid"', + 'exit 0', + ], ['pty-child.pid', 'grandchild.pid'], async ({ agent, pids }) => { + const [leader, grandchild] = pids as [number, number]; + expect(await allDeadBy([leader], Date.now() + 3000)).toBe(true); + // Past the drain grace the session is complete and disposed; its delayed group reap is still pending. + await Bun.sleep(PTY_SHUTDOWN_GRACE_MS + 300); + expect(isAlive(grandchild)).toBe(true); + const started = await terminateAndWait(agent); + expect(await allDeadBy([grandchild], started + PTY_SHUTDOWN_GRACE_MS + 1000)).toBe(true); + }); + }, 25_000); +}); + +describe('PTY process-group ownership', () => { + /** A fake /proc: pid → [state, pgrp, session, starttime]. */ + function fakeProc(rows: Record): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-fake-proc-')); + for (const [pid, [state, pgrp, session, start]] of Object.entries(rows)) { + fs.mkdirSync(path.join(root, pid)); + const fields = [state, '1', String(pgrp), String(session), ...Array(15).fill('0'), String(start)]; + fs.writeFileSync(path.join(root, pid, 'stat'), `${pid} (a b) ${fields.join(' ')} 0 0\n`); + } + fs.mkdirSync(path.join(root, 'self')); + return root; + } + + test('identity requires a session and group leader', () => { + const proc = fakeProc({ 100: ['S', 100, 100, 5000], 200: ['S', 150, 150, 5000] }); + try { + expect(ptyGroupIdentity(100, proc)).toEqual({ pgid: 100, sid: 100, startTime: 5000 }); + expect(ptyGroupIdentity(200, proc)).toBeNull(); + expect(ptyGroupIdentity(300, proc)).toBeNull(); + } finally { fs.rmSync(proc, { recursive: true, force: true }); } + }); + + test('after the leader exits, only same-group same-session descendants are signalled; a foreign group never is', () => { + const id: PtyGroupIdentity = { pgid: 100, sid: 100, startTime: 5000 }; + const proc = fakeProc({ + 101: ['S', 100, 100, 5100], + 102: ['Z', 100, 100, 5200], + 103: ['S', 300, 300, 5300], + 104: ['S', 104, 100, 5400], + 105: ['S', 100, 999, 5500], + }); + const signalled: number[] = []; + try { + expect(reapOwnedGroup(id, proc, pid => { signalled.push(pid); })).toBe(1); + expect(signalled).toEqual([101]); + } finally { fs.rmSync(proc, { recursive: true, force: true }); } + }); + + test('a reused leader PID (another start time) means nothing is signalled', () => { + const id: PtyGroupIdentity = { pgid: 100, sid: 100, startTime: 5000 }; + const proc = fakeProc({ 100: ['S', 100, 100, 9000], 101: ['S', 100, 100, 9100] }); + const signalled: number[] = []; + try { + expect(ownedGroupMembers(id, proc)).toEqual([]); + expect(reapOwnedGroup(id, proc, pid => { signalled.push(pid); })).toBe(0); + expect(signalled).toEqual([]); + } finally { fs.rmSync(proc, { recursive: true, force: true }); } + }); +}); diff --git a/bun.lock b/bun.lock index c6398e812..db2f7ae8d 100644 --- a/bun.lock +++ b/bun.lock @@ -5,17 +5,18 @@ "": { "name": "gstack", "dependencies": { - "@huggingface/transformers": "^4.2.0", + "@huggingface/transformers": "^4.3.0", "@ngrok/ngrok": "^1.7.0", "cross-spawn": "^7.0.6", "diff": "^9.0.0", "html-to-docx": "1.8.0", "marked": "^18.0.11", "playwright": "^1.62.1", + "sharp": "^0.35.4", "socks": "^2.8.9", }, "devDependencies": { - "@anthropic-ai/claude-agent-sdk": "0.2.117", + "@anthropic-ai/claude-agent-sdk": "0.2.141", "@anthropic-ai/sdk": "^0.78.0", "@types/bun": "1.4.2", "prettier": "3.9.9", @@ -38,23 +39,23 @@ "sharp": "0.35.5", }, "packages": { - "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.117", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.117", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.117", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.117", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.117", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.117", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.117", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.117", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.117" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-pVBss1Vu0w87nKCBhWtjMggSgCh6GVUtdRmuE58ZvXv0E2q0JcnUCQHehmn92BAW0+VCwPY8q/k7uKWkgwz/gA=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.141", "", { "dependencies": { "@anthropic-ai/sdk": "^0.93.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.141", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.141", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.141", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.141", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.141", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.141", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.141", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.141" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-AIBacMWGcZIUcXlUoObqjwJ6pmJI3BayAqPAFXuvSq3DHJXdiuZVs7l/zTB5l3nRhRv5cqSrI2XbiDeHgZWizw=="], - "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.117", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZeC/Lz8XMKQ5w+GmjTziPR8bSSarBtNCJMkMAYRT9ekNmyXSWXEwGLENe5TDDmtpzNNzAB1mQNuIYoqTsqgV3w=="], + "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.141", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9HZ0ot6+FwOfQ1aeMqQLH4IJGMm/DcP08SysDxscVjBm6l2JjqleHohxi3zid0DurfGweqT+4x9GScJffwg55g=="], - "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.117", "", { "os": "darwin", "cpu": "x64" }, "sha512-DKyggGzzpDcr9S435xlpbpwkEYKZNbePSekug75tJclK8l4ddD9+M9BFgMiSUq9F1Zt53kUaRDihDu/cBKvkdQ=="], + "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.141", "", { "os": "darwin", "cpu": "x64" }, "sha512-4iAdarJaQ+2R58s6QJswZCzUdz2WQmL5lYG7Y+FLzWbRSROFfcH0QYpmOqSaPXd2KRQhIJwEacqecDZd/Q1XKQ=="], - "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.117", "", { "os": "linux", "cpu": "arm64" }, "sha512-jyHmyZQavpPOe3zxBRX3KbdOAJ8JwZ8m/wMr5bhHhhcstugm/vJx6IIs7D44VvFjk+8sqdvR2ZrliL8PUcJL0g=="], + "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.141", "", { "os": "linux", "cpu": "arm64" }, "sha512-Jdf0ZEwJzOP8sE6rPqdJN+SxMb0/L8sxJg4twCv/7S+Qzk0hJtls+wxSi+0Tjh6EEMaNxJqEGc7S3fx99Wi99Q=="], - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.117", "", { "os": "linux", "cpu": "arm64" }, "sha512-bJU5gEOmM4VCOn4h8vipOKgdhPATePQ23mMpvyVqtVyipWppHfOUfVkqXb+SrF/hfkNSMYxDuoKxbJ+MmKtGjg=="], + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.141", "", { "os": "linux", "cpu": "arm64" }, "sha512-6H1AJ/AVaWNnV22kubUPkOTRzZFH0+qP9k7WlhriHMN9gtgZcVAsITMddDeGjQsQJMCAdhXFd6sgi7TM1LdeOQ=="], - "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.2.117", "", { "os": "linux", "cpu": "x64" }, "sha512-Zb5PXKrDNbQ1dyNYwxZMNL+F2Dhgjh9f9B21wZUJqkhJL69hRJwJyxO42HiNmB2zGCaTxQTyjPhLdB/eQJo74Q=="], + "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.2.141", "", { "os": "linux", "cpu": "x64" }, "sha512-DVjp72f3HmrRYpbneWZZWIqkUht5kTZXS7wXGFiwzLz6eNYEgjjh+GcsnhIi8UOwZUtNiKUrjZnoP38ovFqV8A=="], - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.117", "", { "os": "linux", "cpu": "x64" }, "sha512-LIkKTAYZGugEVssAuWCPqlDWSqhVZAveNPNsfKLbuG1naIMCR04fUqil6i3d3mAAfk7FaS5D4IdHp45psi+GDw=="], + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.141", "", { "os": "linux", "cpu": "x64" }, "sha512-fTI1YuM4cxOa4nSgsyMAdB5ELizkWp+w5Ispo4JnnYtcczMAL4D9GBNjWPW0sUzKvjsJOUVim68SmWLWhUOpXQ=="], - "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.117", "", { "os": "win32", "cpu": "arm64" }, "sha512-uetggH3B83PiH0a9D/5MVXB5Hqnlr2DVajehwAP2x0Mt4DBd632ICnHpu6pnSP+vVkWgq3FgQlkHe91RfP+peA=="], + "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.141", "", { "os": "win32", "cpu": "arm64" }, "sha512-Wm10J6kfbufbPGFELokiJ/7Y5Oqug4Uag3HXFsV8g7TWCpaItx/oqVaJoiGptuAtXQB7xGLQVTuk082wER+Y5w=="], - "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.2.117", "", { "os": "win32", "cpu": "x64" }, "sha512-TT4KngAokDTJSvQ2mrAP6ZRkXj50OLj7Tb1zZA4CnkmrrEidgs4KrMx7er1ZwoivngIvCekV9+TbtC9giknr5w=="], + "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.2.141", "", { "os": "win32", "cpu": "x64" }, "sha512-IXuP29YJuWbR5Q6xOHrjFVGG54V2s1FC61UVNwEN5fpxL09MwPnbwtQL6fqgzt/U1MP7vWAwpXZriYAklkH/mg=="], "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.78.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-PzQhR715td/m1UaaN5hHXjYB8Gl2lF9UVhrrGrZeysiF6Rb74Wc9GCB8hzLdzmQtBd1qe89F9OptgB9Za1Ib5w=="], @@ -64,11 +65,11 @@ "@hono/node-server": ["@hono/node-server@2.1.1", "", { "peerDependencies": { "hono": "^4" } }, "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg=="], - "@huggingface/jinja": ["@huggingface/jinja@0.5.9", "", {}, "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw=="], + "@huggingface/jinja": ["@huggingface/jinja@0.5.10", "", {}, "sha512-SgS1D1bglQ94ceD4ZCL6eayUDy9uV1xuyk61OjgSxU5GJh7upqZCKII0JoTYMc6wWsg3JUgaPRX0ElQzXPk3Cw=="], - "@huggingface/tokenizers": ["@huggingface/tokenizers@0.1.3", "", {}, "sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA=="], + "@huggingface/tokenizers": ["@huggingface/tokenizers@0.2.0", "", {}, "sha512-LidMHe1FpcSYH4vcSjXooda34pC0M8m1gmDOv7SQi6Iv+ib1zX7J3sHBCC9/3FPCkn2k1b55FneVqMRxrr99pg=="], - "@huggingface/transformers": ["@huggingface/transformers@4.2.0", "", { "dependencies": { "@huggingface/jinja": "^0.5.6", "@huggingface/tokenizers": "^0.1.3", "onnxruntime-node": "1.24.3", "onnxruntime-web": "1.26.0-dev.20260416-b7804b056c", "sharp": "^0.34.5" } }, "sha512-8BRCoBMH0XsWaEIamuR0LrJGAfftgHAfb2Vrffy0VKlSAE/MnUJ5/h/zTfEP3fDIft+nk7TqB8xXEyABGitBjQ=="], + "@huggingface/transformers": ["@huggingface/transformers@4.3.0", "", { "dependencies": { "@huggingface/jinja": "^0.5.10", "@huggingface/tokenizers": "^0.2.0", "onnxruntime-node": "1.30.0", "onnxruntime-web": "1.31.0-dev.20260914-8d85527a0", "sharp": "^0.35.4" } }, "sha512-fL1A/WUZwouPrOlYxU5dzIwD2T5J781JiB2jDR8bFe5DwCj0Gfudq+NEXCMno49kQgajHA7xQkrRLJlqG1veEA=="], "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], @@ -234,8 +235,6 @@ "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], - "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], - "browser-split": ["browser-split@0.0.1", "", {}, "sha512-JhvgRb2ihQhsljNda3BI8/UcRHVzrVwo3Q+P8vDtSiyobXuFpuZ9mq+MbRGMnC22CjW3RrfXdg6j6ITX8M+7Ow=="], "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], @@ -274,8 +273,6 @@ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="], - "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], "dom-serializer": ["dom-serializer@0.2.2", "", { "dependencies": { "domelementtype": "^2.0.1", "entities": "^2.0.0" } }, "sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g=="], @@ -306,8 +303,6 @@ "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], - "es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="], - "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], @@ -346,7 +341,7 @@ "global": ["global@4.4.0", "", { "dependencies": { "min-document": "^2.19.0", "process": "^0.11.10" } }, "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w=="], - "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="], + "global-agent": ["global-agent@4.1.3", "", { "dependencies": { "globalthis": "^1.0.2", "matcher": "^4.0.0", "semver": "^7.3.5", "serialize-error": "^8.1.0" } }, "sha512-KUJEViiuFT3I97t+GYMikLPJS2Lfo/S2F+DQuBWzuzaMPnvt5yyZePzArx36fBzpGTxZjIpDbXLeySLgh+k76g=="], "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], @@ -408,8 +403,6 @@ "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], - "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], - "jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="], "lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="], @@ -420,7 +413,7 @@ "marked": ["marked@18.0.11", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw=="], - "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], + "matcher": ["matcher@4.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -454,11 +447,11 @@ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - "onnxruntime-common": ["onnxruntime-common@1.24.3", "", {}, "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA=="], + "onnxruntime-common": ["onnxruntime-common@1.30.0", "", {}, "sha512-7fdVWjAID1dVhH/G8qK3APARunV4VkBFoCQAP7qp4Wkab0mrorvmc+sqiT+mKXOzDqdjN5j+/Z9nb4gzNPWcyA=="], - "onnxruntime-node": ["onnxruntime-node@1.24.3", "", { "dependencies": { "adm-zip": "^0.5.16", "global-agent": "^3.0.0", "onnxruntime-common": "1.24.3" }, "os": [ "linux", "win32", "darwin", ] }, "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg=="], + "onnxruntime-node": ["onnxruntime-node@1.30.0", "", { "dependencies": { "adm-zip": "^0.6.0", "global-agent": "^4.1.3", "onnxruntime-common": "1.30.0" }, "os": [ "linux", "win32", "darwin", ] }, "sha512-twhs1C2C/BFkz1yc5OY0KIU2GUq6DURO7hD4bx5Q2Qy3nAMJwRXW8xU3NVczE29VA9lolLOYepoD8fjTGOfIqw=="], - "onnxruntime-web": ["onnxruntime-web@1.26.0-dev.20260416-b7804b056c", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.24.0-dev.20251116-b39e144322", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw=="], + "onnxruntime-web": ["onnxruntime-web@1.31.0-dev.20260914-8d85527a0", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.31.0-dev.20260911-2a43ec07e", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-Iy7rtadoBgxS/LLvDr3QW38DB1PNXRnr0GJMcL0TAt7c9qjgVQl83UlGCVyeAnK2InpmW8Uc3PL8XIuqtDeF6g=="], "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], @@ -500,8 +493,6 @@ "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], - "roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="], - "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], @@ -512,11 +503,9 @@ "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], - "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], - "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="], + "serialize-error": ["serialize-error@8.1.0", "", { "dependencies": { "type-fest": "^0.20.2" } }, "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ=="], "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], @@ -542,8 +531,6 @@ "socks": ["socks@2.8.9", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw=="], - "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], - "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "string-template": ["string-template@0.2.1", "", {}, "sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw=="], @@ -558,7 +545,7 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], + "type-fest": ["type-fest@0.20.2", "", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="], "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], @@ -598,7 +585,7 @@ "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], - "@anthropic-ai/claude-agent-sdk/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.81.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw=="], + "@anthropic-ai/claude-agent-sdk/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.93.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-q9vaSZQVFx6B/gPxetGYfLXSJD5v0sOmh0OpZDq7yCrTSA+Rscvrtyol7JJTW40wEpQB4U1B4JXzxQitbQ3CAA=="], "@oozcitak/infra/@oozcitak/util": ["@oozcitak/util@8.0.0", "", {}, "sha512-+9Hq6yuoq/3TRV/n/xcpydGBq2qN2/DEDMqNTG7rm95K6ZE2/YY/sPyx62+1n8QsE9O26e5M1URlXsk+AnN9Jw=="], @@ -620,7 +607,7 @@ "negotiator/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], - "onnxruntime-web/onnxruntime-common": ["onnxruntime-common@1.24.0-dev.20251116-b39e144322", "", {}, "sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw=="], + "onnxruntime-web/onnxruntime-common": ["onnxruntime-common@1.31.0-dev.20260911-2a43ec07e", "", {}, "sha512-gBuF6U32YErKIAt+yD7DeGBpjRxhJ1uJwako6ygjokSZULDU6Pd+KWComX8Tumk1hYVHRxjPj7mdMnD0ryAPOw=="], "send/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], diff --git a/docs/autoplan-guard-troubleshooting.md b/docs/autoplan-guard-troubleshooting.md index 39f7f7294..ff7970f5b 100644 --- a/docs/autoplan-guard-troubleshooting.md +++ b/docs/autoplan-guard-troubleshooting.md @@ -67,6 +67,11 @@ The journal root has a shape the guard does not know, usually because Claude Code changed its journal format. The guard reads the journal twice and allows only when both reads agree. `unrecognized_shape:cwd_spelling` means the journal's directory is the same folder as the project, spelled differently. +`unrecognized_shape:compaction_unreachable` means records after a Claude Code +compaction hang from a compact boundary whose logical parent loops back +instead of reaching the session root, in a layout gstack does not read yet +(Claude Code 2.1.300's preserved segment is read). Earlier gstack versions +dropped those records and denied the next phase with `close_incomplete`. Please report the detail and version. diff --git a/docs/designs/FIX_WAVE_2026_10_15.md b/docs/designs/FIX_WAVE_2026_10_15.md new file mode 100644 index 000000000..74bc99d8a --- /dev/null +++ b/docs/designs/FIX_WAVE_2026_10_15.md @@ -0,0 +1,207 @@ + +# gstack fix wave (October 15, 2026): open PRs and issues, ordered by severity + +## URGENT, outside this plan + +- `bin/gstack-upgrade:50` compares a `C:/…` path with a `/c/…` path and refuses on every Git Bash install; `bin/gstack-doctor-check.sh:372-374,401` recommends that command, so it is part of the Windows recovery path (PR 1, item 3). The file belongs to draft #3190 (`capy/next-wave`, thread jam_01M4YA0TAXXGQ01VVWMW71091K). Owner: the #3190 thread. This wave reports it there with the fix (compare `gstack_path_shell` of both sides, `bin/gstack-path-spelling.sh`) and does not edit the file. + +## Implementation plan + +### Context + +Base: origin/main `47d639a` (v1.91.101.0). Input: every open PR (25) and open issue (≈70) on garrytan/gstack as of 2026-10-15, triaged in six read-only lanes on a Capy cloud machine. Each lane reviewed external PR diffs line by line as untrusted input (scope, egress, credentials, dependencies, CI permissions, weakened tests), checked each issue's claim against main's code, and reproduced with focused tests where cheap. Evidence: `~/.capy/work/fixwave/triage-{A..F}.md`. This is plan revision 3. Revision 1 was reviewed by six voices (CEO, DX, Eng; Claude Opus 5.5 and GPT-6.1 Sol each): 60 findings, no Critical. Revision 2 folded them in and was reviewed again by the same six voices: 37 findings, no Critical, no user challenge, every revision-1 finding confirmed addressed. This revision folds in the revision-2 findings. The Review record lists what each phase raised and how it was handled. + +Owner request (Garry, 2026-10-15): "do a fix wave on gstack — plan, autoplan, eli5 for approval — go through all PRs and issues, figure out what to fix here in a wave, be circumspect on all code written by others, order by severity and priority." Garry merges gstack; this wave opens PRs and never merges them. + +**In-flight work this wave must not collide with.** Garry's draft #3190 (`capy/next-wave`, claims v1.91.102.0) changes 110 files. None of this wave's items are fixed by it (each lane diffed its files against `origin/capy/next-wave`). Files both touch: `setup` (items 12, 13, 16: disjoint hunks), `bin/gstack-bun-version.sh` (item 11: disjoint function; #3190 adds new `gstack_bun_status` callers in the `_gb_*` namespace), `lib/result-codes.ts` and `docs/troubleshooting.md` (append-only), `lib/pregate/checks/lanes.ts` (item 9: one-line conflict), and generated SKILL.md/goldens (items 24, 25). `lib/pregate/lane-selection.ts`, `lib/pregate/import-graph.ts`, `bin/gstack-session-update`, `setup.ps1`, `lib/redact-engine.ts`, `lib/cso/**`, `browse/**`, `lib/regen.ts`, `lib/restamp.ts`, `lib/gate-diff/**`, `bin/gstack-evidence`, `gstack-upgrade/SKILL.md.tmpl` are untouched by #3190 (`git diff --name-only origin/main...origin/capy/next-wave`). + +**Security review result.** No external PR in the wave has a red flag: no new egress, telemetry, credential handling, postinstall scripts, or CI permission changes; #3145's lockfile adds no new packages; dependabot #3175's five action SHAs match upstream tags. Two PRs are rejected on safety grounds: #2923 drops the Playwright `patchedDependencies` entry that keeps Windows `windowsHide` (reintroduces console-flash #2160/#1989), and #1796 writes a bearer token through `writeFileSync`+`chmod` (follows a planted symlink), hard-codes `~/.gstack` against the state-root chain, and sends the cached bearer to a device address without checking which app listens. + +**How external code is adopted.** Commits are cherry-picked onto the wave branch with original authorship; our changes land as separate commits on top; release churn (VERSION, CHANGELOG, package.json version, e.g. #3189's `1.91.102.0` commit `3d1caac`) is dropped. Fork CI is **not** approved (it would run untrusted code with this repo's runner); the cherry-picked commits run on the wave branch's own CI. Every adopted PR gets at least one reviewer-written behavioral test in addition to the author's own; where that is impossible the PR body says "author test, reviewed". After Garry's review pass (never before: reporters would be running unreviewed code), each source issue gets a comment with exact commands to try the branch, labelled by shell: `cd "" && git checkout -- 'SKILL.md' '*/SKILL.md' '*/sections/*.md' && git fetch origin && git checkout --detach FETCH_HEAD && ./setup`, and to go back: `cd "" && git checkout main && ./setup`; it says auto-update pauses while a detached revision is checked out. Confirmations within 48 hours are recorded in the PR body (the reporters have the PS 5.1, MSYS tar, OrbStack, zsh, Cursor-1.31 and Claude Code 2.1.300 environments CI lacks); they are evidence, not a merge gate. The source PR is closed after merge with a comment naming what changed and why; issue reporters and PR authors are credited in the CHANGELOG. + +Repo constraints: SKILL.md is generated from `.tmpl`; new logic in `lib/*.ts` behind thin `bin/` entrypoints; `test/module-size-ratchet.test.ts`; state paths through `lib/state-root.ts`; result codes in `lib/result-codes.ts` with `docs/troubleshooting.md` anchors (`test/troubleshooting-anchors.test.ts`); every test subprocess has a timeout (`test/spawnsync-timeout-tripwire.test.ts`, which three adopted PRs fail today: #3188, #3178, #3181); parity and context-budget ratchets for template edits; ship is stamp-at-merge PATCH. + +Thesis: **people stuck this week get unstuck, including the installs that are already stuck; community fixes land with their authors credited; nothing a stranger wrote reaches main without an independent test.** + +### Release promises (the finish line) + +- **P1. No user is stuck, including already-stuck installs.** An /autoplan run survives a Claude Code 2.1.300 compaction; a conflicting autostash never leaves conflict markers in a live install and never silently hides a user's edit; a Windows install held by the tar bug and a macOS zsh user get a verified recovery path; `setup.ps1` runs under Windows PowerShell 5.1; `/cso` Semgrep returns findings; `/cso` works with OrbStack; the browse daemon recovers when its last tab is closed outside the CLI. +- **P2. Windows bugs are proven on Windows,** in CI on `windows-latest` (PowerShell 5.1, real MSYS tar, Git for Windows), not only by Linux emulation. +- **P3. Pregate selects the right tests** for bats repos and Next.js/tsconfig-alias repos without dropping a required platform receipt, and runs on Windows instead of refusing with a misleading message. +- **P4. Every new failure state says what happened and the one command that fixes it,** with a troubleshooting entry under its exact error text and a CHANGELOG "If you…" line for each affected cohort. +- **P5. The tracker is honest:** fixed issues are closed with file:line evidence on a dated follow-up; each deferred item has a reason, an owner and a revisit date. + +### Delivery shape + +Seven PRs with stated dependencies, each a PATCH release with its own slot from `bin/gstack-next-version` at ship time, each running `bun run test` once on its frozen tree (on Ubicloud; this machine has 4 cores). PR 1a ships first and depends on nothing that has never run before. + +| PR | Contents | Base | Depends on | Blockers it can have | +|---|---|---|---|---| +| 1a. Recovery, cross-platform | items 1, 5, 6, 7, 11 | main | — | its own tests | +| 1b. Recovery, install and Windows | items 2, 3, 4, 12, 13, 16, W | PR 1a's branch (`gh stack`) | 1a (Step 4 of `/gstack-upgrade` is edited by items 11 and 2) | the first run of the Windows job | +| 2. Windows script invocation | item 10a, item 8's manifest-parser half | main | — | its own Windows job file | +| 3. Maintenance | item 8's import-graph half, items 15, 19–23 | main | — | #3145 classifier smoke (split out if unavailable) | +| 4. Prompt changes | items 17, 14 | main | — | the free spike; paid gate cases and judges priced and approved first | +| 5. After #3190 | items 9, 10b, 24, 25, the doctor rows (D), the Windows curation move | `capy/next-wave` | #3190 | #3190 | +| 6. Census repair | item 18 | main, or folded into PR 5 if the repair edits `ship/` | — | evidence read first | + +Sibling PRs share no file except the integrator-owned append-only set (`lib/result-codes.ts`, `docs/troubleshooting.md`, `test/fixtures/module-size-ratchet.json`, `CHANGELOG.md`, `package.json` version line). Coordinator exception, recorded in each PR body: lane-check reports those files as intersections; the exception covers exactly that named set, and after any sibling or #3190 merges, the next PR merges main, resolves the appends, and restamps. Every other intersection is a blocker. For #3190 the exception also covers `setup` (PR 1b's hunks) and `bin/gstack-bun-version.sh` (PR 1a), backed by a receipt bound to both SHAs: the merged tree is computed once per pair (both orders recorded, the tree hash is the same), and on that tree `gen:skill-docs` freshness and the focused tests for both PRs' touched files run once. Files #3190 also changes that this wave needs (`lib/pregate/context.ts`, `lib/pregate/index.ts`, `bin/gstack-doctor-check.sh`, `bin/gstack-doctor-components.sh`, `lib/doctor-components.ts`, `scripts/lib/windows-curation.ts`, `.github/workflows/free-tests.yml`) are edited only in PR 5. + +This thread registers dated follow-ups: 2026-10-22 (post the closes below) and 2026-10-29 (if #3190 has not landed, this thread rebuilds PR 5 on main, resolving the `lanes.ts`, `bin/gstack-pregate`, `context.ts`/`index.ts` and doctor hunks itself, and tells the #3190 thread). + +Lanes and file ownership (each lane on a fresh machine, its own branch, bisected commits, no VERSION/CHANGELOG/package.json-version edits): +- Lane A (PR 1a): `lib/claude-journal-records.ts`, `lib/redact-engine.ts` and the scanner capture path in `lib/cso/**` (`process.ts`, `docker.ts`, `watchdog.c`, the executor), `browse/src/browser-manager.ts`, `browse/src/server.ts`, `bin/gstack-bun-version.sh` (`_gstack_bun_below` only, no new `_gb_*` globals), new `lib/sanitize-echo.ts` + its bash twin, `gstack-upgrade/SKILL.md.tmpl` (Step 4's floor check only). +- Lane B (PR 1b, on top of A): `bin/gstack-session-update`, new `bin/gstack-ff-update`, `bin/gstack-config`, `bin/gstack-memorable`, new `bin/gstack-claude-root.sh`, `setup` (hook-root, Cursor and Playwright hunks; the only PR that edits `setup`), `setup.ps1`, `bin/gstack-browser-ensure`, `gstack-upgrade/SKILL.md.tmpl` (Step 4's pull), `.github/workflows/windows-setup-e2e.yml`, `test/setup-ps1.test.ts`. +- Lane C (PR 2): new `lib/script-invocation.ts`, `hosts/claude/hooks/spawn-bin.ts`, `lib/gbrain-exec.ts`, `lib/regen.ts`, `lib/gate-diff/git.ts`, `bin/gstack-gate-diff`, `lib/restamp.ts`, `bin/gstack-evidence`, `lib/pregate/checks/secrets.ts`, `lib/pregate/lane-selection.ts` (manifest parser and `bash -c`; the only PR that edits it), new `.github/workflows/windows-pregate-e2e.yml`. +- Lane D (PR 3): `lib/pregate/import-graph.ts`, `browse/src/terminal-agent.ts`, `bin/gstack-skill-end`, `bin/gstack-timeline-log`, `bin/gstack-gbrain-sync.ts`, test-only files for #3163/#3156, `.github/workflows/*` action SHAs (not the two Windows job files), `.github/dependabot.yml`, `bun.lock` and dependency lines of `package.json` (#3145), README/CLAUDE.md skill lists, the OSV ledger comment, `ios-qa/daemon/src/devicectl.ts` and the ios-qa how-to. +- Lane E (PR 4): `scripts/resolvers/outside-voice-steps.ts`, the three plan-skill templates' outside-voice sections, `bin/gstack-ceo-plan-find`, `plan-eng-review/SKILL.md.tmpl`. +- Lane F (PR 5): `lib/pregate/checks/lanes.ts`, `bin/gstack-pregate`, `lib/pregate/context.ts`, `lib/pregate/index.ts`, the doctor files, `scripts/lib/windows-curation.ts`, `scripts/gen-skill-docs.ts` resolvers, the size-guard test. +- Lane G (PR 6): the census case and whatever its evidence names. + +Every item lands with a test that fails on `47d639a` first (named per item). No existing assertion is weakened; existing pins that change are named per item as intentional contract changes. + +### PR 1a and PR 1b — Recovery (High severity; ship first) + +PR 1a holds items 1, 5, 6, 7 and 11; PR 1b holds items 2, 3, 4, 12, 13, 16 and W (items 13 and 16 move here so that `setup` has one owner). + +**1. /autoplan locks after a Claude Code 2.1.300 auto-compaction — adopt #3196 (fixes #3194).** After compaction the `preservedSegment` boundary makes a parent cycle, the phase guard denies every phase entry with `close_incomplete`, and the run cannot continue. #3196 (vilano93) teaches `lib/claude-journal-records.ts` the boundary. Verified: `test/autoplan-compaction-preserved-segment.test.ts` fails 2/7 on main, passes on the PR; CI green; two compactions hold. Added: when owned nodes are unreachable from the root after the walk **and** a `compact_boundary` record (its logical parent or preserved list) explains the unreachable component, map it to the guard's existing `unrecognized_shape` disposition (allow with a visible warning and a decision-log row) rather than a false `close_incomplete`; orphaned or not-yet-flushed components keep today's behaviour, so the fail-open surface does not widen beyond compaction. Listed under the PR's gate-integrity edits. Negative controls: the existing denial tests and captured fixtures (`autoplan-publication-guard`, `-hook`, `guard-reasons`, `autoplan-publication-boundary-361c.json`) still deny. Reviewer test: a journal with a ~50k-record history and three compactions finishes inside the hook's time budget (memoize `descends` per walk if it does not). + +**2. A conflicting autostash leaves conflict markers in the live install — adopt #3189 and #3191 through one shared bin.** On git 2.55, `merge --ff-only --autostash` with a conflicting stash exits 0, leaves markers in tracked files of the install, and setup runs over them. #3189 (BenjaminDSmithy) detects unmerged files, restores the checked revision, and keeps the stash. Verified: 8/10 of its tests fail on main. Take commits `069b80b` and `8dc0078`; drop `3d1caac`. +- The fast-forward-plus-recovery moves into a new bash bin `bin/gstack-ff-update `, the only code that moves an install's tree, called by `bin/gstack-session-update`, by `/gstack-upgrade` Step 4 and by the held-install recovery command (item 3). Step 4 never reimplements recovery in prose. +- Transaction ownership: the bin takes an install-scoped lock at `$(git -C rev-parse --git-common-dir)/gstack-ff.lock` (the updater's TTL and reap-by-mv logic) before any merge, `update-ref` or reset; callers keep their own locks. Inside the lock it captures the whole working tree as-is (tracked edits, unmerged and marker-laden files, and anything written after an earlier stash) into a rescue commit through a private index, then resets to the target only if the working-tree hash still equals the captured one; otherwise it refuses with `autostash-unrecoverable:` and changes nothing. The regenerable footprint (`SKILL.md`, `*/SKILL.md`, `*/sections/*.md`, rewritten by `gstack-patch-names` in prefix mode) is discarded first, as Step 4 does today. +- Rescue refs are named by the rescue commit id (`refs/gstack/rescue/`), created compare-and-create, never overwriting an unrelated ref. Tests: Step 4 racing a background updater on one install with two state roots; a barrier-controlled edit written between capture and reset (refused, edit kept); same-second recoveries; a pre-existing ref. +- A recovery that shelved edits is never silent: the session-start banner names the files and the rescue ref, says the edits conflicted with the update, and makes inspection the default (`git -C '' show --stat `); restoring is guided out of the live tree (`git -C '' diff > ~/gstack-local-edits.patch`), never a bare `stash apply` into the install. The line repeats at most daily until the ref is deleted; the doctor lists rescue refs with the delete command (PR 5, item D). Ordinary success stays quiet. Test the background-to-foreground handoff. +- #3191 (Jesssullivan) reaps the lock heartbeat and its `sleep` child on exit (5/5 of its tests fail on main); update the two static pins in `test/session-update-autostash.test.ts` it breaks (intentional pin change). +- The #3189 test shim pins the real git binary once (resolving through any PATH wrapper) and fails fast with a named reason when it cannot, so no environment note is needed. + +**3. Windows auto-update is held on every release — fix #3184, plus the recovery path for held installs.** `hook_incoming_hold` (`bin/gstack-session-update:100`) extracts the incoming revision with `git archive | tar`; MSYS tar cannot create the `connect-chrome` symlink (it precedes its target), the failure is fail-closed since 1.91.89.0, and the banner shows a stale "bun not found" instead of the hold. +- Fix: extraction without tar and without symlinks, both commands in one subshell with a private index: `( export GIT_INDEX_FILE=/index; git read-tree ; git -c core.symlinks=false checkout-index -a --prefix=/tree/ )` (symlinks become plain files containing the target path, which the hook check never reads). Tests: a valid installed hook against an invalid incoming hook causes the hold (proving the incoming bytes were checked); the real index is byte-identical afterwards. A tripwire test asserts that no hooked path or hook-imported module in the tree is a symlink, so a future symlinked hook fails loudly instead of reading as missing. +- The held reason carries the first 200 bytes of the failing command's stderr with newlines flattened and control characters stripped (as `:322` does for `PULL_REASON`). Test: stderr containing `\nstages=x` and an ESC sequence. +- Banner: the hold line prints before the stage line; each new hold reason (`unmerged-files:`, `autostash-unrecoverable:`, `bun-version-unreadable:`, `hook-check-unavailable:` with stderr) has its own banner case naming the exact command and a troubleshooting anchor, and no `stages=setup` fix prints while unmerged entries exist. Every path in a printed command is shell-quoted; tests execute the emitted commands against isolated installs whose paths contain a space and an apostrophe. +- Already-held installs: their old updater cannot deliver the fix, and the old `/gstack-upgrade` Step 4 still has the autostash hole, so recovery runs the **new** `gstack-ff-update` fetched from the new revision. Published forms, each labelled with where it runs: in Git Bash, `cd "" && git fetch origin main && git show FETCH_HEAD:bin/gstack-ff-update > "${TMPDIR:-/tmp}/gstack-ff-update" && bash "${TMPDIR:-/tmp}/gstack-ff-update" . FETCH_HEAD && ./setup`; in Windows PowerShell 5.1, the same script run through Git's `bash.exe` followed by `.\setup.ps1`; in Claude Code, ask it to run the Git Bash form. Each form handles clean, dirty-conflicting, already-unmerged and prefix-mode installs (the bin discards the footprint and rescues everything else). `bin/gstack-upgrade`'s Git Bash refusal is reported to #3190 (URGENT block). The forms go into the CHANGELOG "If you…" block, the troubleshooting entry and the #3184 closing comment. +- Test that fails first: `test/session-update-hook-hold.test.ts` with an incoming revision containing a symlink sorted before its target and a PATH `tar` shim emulating MSYS; main logs `HELD hook-check-unavailable`, fixed code fast-forwards. + +**4. `setup.ps1` fails under Windows PowerShell 5.1 — fix #3130.** PS 5.1's legacy native-argument passing strips the double quotes inside the `bash -c` string at `setup.ps1:59`. Reproduced with pwsh `$PSNativeCommandArgumentPassing='Legacy'`. Fix: a `-c` string with no double quotes, `set -f; IFS=; mapfile -d '''' -t argv < $1; rm -f -- $1; exec ./setup ${argv[@]}`; validated in Legacy and Standard modes with a space in TMPDIR and adversarial args (`$(touch PWNED)`, `it's`, `"quoted"`, `;echo injected`, backticks, `*`, `a\"b\`), none executed, all forwarded exactly. An empty-string argument is refused in PowerShell before dispatch (`setup.ps1: argument N is empty; setup takes no empty arguments`). Test that fails first: a behavioral case in `test/setup-ps1.test.ts` under Legacy mode (exit 2 on main); on win32 the same test runs `powershell.exe`; the pinned-source assertion changes (intentional). + +**W. Windows CI proves items 3 and 4 natively.** The existing `windows-setup-e2e` job gains `setup.ps1` under `powershell.exe` (5.1) from a checkout path containing a space, `setup.ps1` in its path filter, and the held-install journey: a local bare origin whose `main` is this PR's HEAD plus a synthetic later commit containing `connect-chrome`; a prefix-mode v1.91.101.0 install in three states (clean, dirty-conflicting, already-unmerged); `auto_upgrade=true` with the throttle cleared, polling the updater log. Assertions: (1) the v1.91.101.0 updater logs `HELD hook-check-unavailable` (so the test is not vacuous); (2) the PowerShell recovery form reaches HEAD with every local edit present in a rescue ref; (3) the new updater then logs `UPDATED` for the synthetic commit. The job asserts executed counts, not skips. PR 2 adds its own job file for pregate and restamp. + +**5. `/cso` Semgrep always reports `INVALID_OUTPUT` — fix #3185.** The scanner path splices redactions into raw JSON; `redactFindingSpans` (`lib/redact-engine.ts:577-583`) replaces a bare 10+ digit number (`"max_memory_bytes":1145427776`, drizzle `"when"` epochs) with an unquoted placeholder, `JSON.parse` fails, and the report is discarded. A delimiter-only rule is not enough (it misses `-4155551234` and can misfire inside strings). Fix: the scan operation's declared output format is carried through the real capture path (executor, Docker capture, process boundary) for scan stdout only; version probes and diagnostic stderr stay plain. For JSON-declared plans, the tokenizer runs over the concatenated stdout (not per interleaved run), tolerates multiple top-level values and NDJSON, is string- and escape-aware, and replaces each number token a merged span touches (`-?digits`, fraction, exponent) with the quoted placeholder, number by number; a span that crosses a token boundary or a structural character withholds the stream. Spans inside JSON strings keep today's in-string replacement; non-JSON streams are unchanged. Marker-only withholding and cross-stream release checks are kept. Tests that fail first: `JSON.parse(redact('{"a":4155551234}'))` succeeds with the value redacted; a spliced Semgrep report with `"time":{"max_memory_bytes":…}` returns `status: 'complete'`. Also: negative, exponent, `[4155551234,4155551235]`, multi-line NDJSON, whitespace, punctuation-inside-string and escaped-quote cases; the real version-then-scan capture sequence with stderr interleaved; the output is a fixpoint of the views check at `lib/cso/process.ts:120`; a non-JSON stream keeps the unquoted form (`test/cso-eval.test.ts:1029,1071`). Behavior change listed. + +**6. `/cso` cannot use OrbStack's Docker — adopt #3188 (fixes #3187).** `executable()` returns the realpath and OrbStack's multicall binary dispatches on argv0. #3188 (aegixx) spawns the trusted canonical path with `argv0: 'docker'`, including in `watchdog.c`. Verified with a fake multicall binary; its test fails on main. Change: the python3 `spawnSync` at `test/cso-docker-argv0.test.ts:109` gets a timeout. The trust check still runs on the realpath. + +**7. The browse daemon wedges when its last tab is closed outside the CLI — adopt #3179 with changes (fixes #3167).** Reproduced on main: after an external close every command, `status` and `stop` included, throws "No active page". #3179 (aftabkh4n) reopens a page on demand. Changes: `status` and `stop` bypass recovery (so a closed headed context can still be stopped); a failed recovery maps to a named error carrying the restart command; a command that ran on a recreated tab prints a one-line warning. Reviewer tests: `status`, `goto` and `stop` after an external close; the closed-context case; a text read after recovery shows the warning. + +**11. The Bun floor check fails open under zsh — fix #3195 (promoted: zsh is macOS's default shell, so `/gstack-upgrade` Step 4 accepts Bun 1.3.2 there).** `_gstack_bun_below` relies on word-splitting `$_gb_core`; zsh does not split, `[ -ne ]` errors, and every error path reads as "not below". Fix: compare the already-parsed `_gb_maj`/`_gb_min`/`_gb_pat` (copied between the two parses, no new `_gb_*` globals) with `$((10#…))`; a parse failure returns a distinct status that every caller treats as a hold and prints `BUN_VERSION_UNREADABLE` (the version output and the bun path, both passed through the one flatten-and-strip sanitizer every echoed external string uses, its own fix and anchor, never "bun upgrade"); Step 4 runs the check through `bash -c`. One version table (1.3.2, 1.3.3-canary.1, 1.3.3, 1.3.14, 1.4.2, 1.10.0, 01.04.02, 2.0.0, garbage) drives bash, zsh, `bash --posix` and the TypeScript twin's `below()`. Test that fails first: `zsh -c '. bin/gstack-bun-version.sh; gstack_bun_status 1.3.2'` expects `too-old` (skipped without zsh); a source assertion that Step 4 invokes bash. + +**12. Setup bakes an ephemeral `CLAUDE_CONFIG_DIR` into global hooks — adopt #3181 with changes (fixes #3180).** #3181 (Brit-juho) registers hooks at `$HOME` when the config dir links back to it. Changes: `bin/gstack-memorable` has a verbatim copy of the old block, so `gstack_canonical_claude_root` moves into one sourced helper `bin/gstack-claude-root.sh` used by both; `resolve()`'s `spawnSync` gets a timeout (it fails the tripwire today); the PR's source-slicing test is rewritten to source the helper (intentional test change). Tests that fail first: `test/setup-hook-canonical-paths.test.ts` "per-session config dir linking back", plus a memorable case. + +**13. Cursor loads duplicate skills from 1.31-era unprefixed folders — adopt #3152 (fixes #3133; ships in PR 1b).** #3152 (szsunyuan) removes unprefixed Cursor folders proven generated and keeps user-owned ones. Its test fails on main, passes on the branch; CI green. Reviewer test: a user folder whose SKILL.md is a symlink to somewhere outside any gstack checkout is kept. Setup prints which folders it removed. + +**16. Missing `bunx` is reported as a network failure — fix #3131 (ships in PR 1b).** A winget Bun ships no `bunx`. Fix: `bun x` through setup's existing bun resolver and in `bin/gstack-browser-ensure`; rc 127 prints ` x playwright install chromium exited 127 (command not found); install Bun from bun.sh (winget Bun ships no bunx), or skip with ./setup --no-browser (Git Bash) or .\setup.ps1 --no-browser (PowerShell)`. Test that fails first: `test/setup-playwright-best-effort.test.ts` with a winget-shaped Bun (bun present, no bunx); the existing `bunx()` stub is renamed (intentional: the block under test changes). + +### PR 2 — Windows script invocation (High) + +**10a. Windows pregate and restamp refuse because Bun cannot spawn bash scripts — fix #3197 on main's call sites.** `bin/gstack-wtree` is spawned directly at `lib/regen.ts:133`, `lib/gate-diff/git.ts:84`, `lib/restamp.ts:512`, `bin/gstack-evidence:200`; `gstack-slug` at `restamp.ts:591`; `gstack-redact` at `lib/pregate/checks/secrets.ts:38`. gstack already has three conflicting Windows bash resolvers (`hosts/claude/hooks/spawn-bin.ts:27` falls back to a bare PATH `bash`, the WSL trap `lib/gbrain-exec.ts:211` warns about; `lib/gbrain-exec.ts:180`; `setup.ps1:31`). +- `lib/script-invocation.ts` becomes the only TypeScript resolver, used by hooks' `runBin`, `gbrain-exec`, regen, restamp, evidence, secrets and lane-selection's `bash -c`. Order: `GSTACK_GIT_BASH` (canonical, the name `setup.ps1` already reads), `GSTACK_BASH` as a deprecated alias at the same precedence in both `setup.ps1` and TypeScript; then bash under `git --exec-path` (resolves through scoop-style shims, per-user and portable Git); then the known install paths; then a PATH `bash` only when it reports MSYS/MINGW (System32 and WindowsApps `bash.exe`, i.e. WSL, rejected); else an actionable refusal that lists each candidate tried with its rejection reason and names `GSTACK_GIT_BASH`. The same text is used by the gbrain "no bash found" line and `setup.ps1`. Hooks' hot path stays spawn-free (`Bun.which` plus path arithmetic) with the resolved bash cached in the state root keyed by `git.exe` path and mtime; the Windows job times a hook invocation. Bun-shebang bins run as `process.execPath