mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
Merge main into fix/pregate-recognise-bats-3161
Main's selection-manifest parser now classifies each path-shaped token with the same isTestFile check, so recognising .bats in TEST_FILE_RE covers the manifest case too; take main's lane-selection.ts and drop the now-redundant manifest regex change from this branch.
This commit is contained in:
commit
b09476bebe
201 files changed
+11172
-1958
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+13
-13
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -2,6 +2,7 @@ name: Version Gate
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'VERSION'
|
||||
- 'CHANGELOG.md'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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:NAME -->.
|
||||
form() { awk -v m="<!-- form:$1 -->" '$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//'<branch>'/$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
|
||||
+10
-7
@@ -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]]
|
||||
|
||||
+132
@@ -1,5 +1,137 @@
|
||||
# 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 <url>`); `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 '<install>' show --stat '<ref>'`), 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 <b>, not <current>): <path>`) 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.
|
||||
The health report tells the truth.**
|
||||
|
||||
Six of 46 main runs last week needed the free suite's serial retry pass to go green. Five test files caused it, and three shared one cause: since git 2.29, every `git commit` starts a background `git maintenance run` that creates and deletes `.git/objects/maintenance.lock` after the commit has returned, so a test that lists, watches or deletes `.git` right afterwards sees a file that is there one moment and gone the next. Those fixtures now turn auto-maintenance off, and the free runner does it for every test child as a backstop. The other two flakes were a fixed-count poll and a smoke test that called live GitHub. `gstack-timeline-read` now refuses filters it cannot apply instead of returning everything.
|
||||
|
||||
### The numbers that matter
|
||||
|
||||
Measured with the repo's own reproducer (`scripts/ship-measure.ts free`, flaky retry off), before on 47d639a and after on this branch. Commands and per-file counts are in the PR body.
|
||||
|
||||
| Measurement | Before | After |
|
||||
|---|---|---|
|
||||
| The five ledger files, 16-vCPU Ubicloud VM (`bun run test:ubicloud --diagnostic`) | 197/200 reruns clean | 600/600 |
|
||||
| The three git fixtures under commit load, local runner env | 37/200 clean | 200/200 |
|
||||
| Next-version smoke test, live GitHub calls | one `gh api` per open PR, one after another (27 PRs at 1.2 s: 32.6 s, past its 30 s limit) | none (runs in about 60 ms) |
|
||||
| Weekly health: unseeded free test files | 164 (enforced check failing) | 0 |
|
||||
| Weekly health: Windows wall population | dispatch recordings on main ("n/a (0 runs)" on Oct 12) | 78 successful PR runs, 9.2 min p50 (7 days to 2026-10-15) |
|
||||
|
||||
The headline number is the first row: three of 200 clean-tree reruns failed before, none of 600 after.
|
||||
|
||||
### What this means for you
|
||||
|
||||
When the free suite goes red now, it is far more likely to be your change. Read the failure, not the retry ledger.
|
||||
|
||||
### Itemized changes
|
||||
|
||||
#### Fixed
|
||||
|
||||
- **If you run `gstack-timeline-read --since` or `--branch` without a value,** you get exit 2 and `--since must be a relative time like "7 days ago" (got no value); accepted: ...` (or `--branch must be a branch name (got flag '--limit')`) instead of a crash.
|
||||
- **If you pass `--since yesterday` or `--since "2 weeks"`,** you get exit 2 naming what it got, instead of your whole unfiltered history. Accepted forms: `7 days ago` (minute, hour, day, week, month), `7d` / `24h` / `2w`, and ISO dates `2026-10-01` or `2026-10-01T10:00:00Z`.
|
||||
- **If you pass an argument `gstack-timeline-read` does not know,** it exits 2 with the usage line instead of ignoring it. `--help` prints the usage line.
|
||||
|
||||
#### Changed
|
||||
|
||||
- **If you read the weekly test-health report,** "Free-suite and Windows wall" now measures successful Windows runs on pull requests, says so (`windows-free-tests (successful PR runs) 9.2 min (78 runs)`), and prints `no successful PR runs in window` when there are none. It used to average the dispatch-only duration recordings on main.
|
||||
- **If the health report says the duration seed is stale,** its fix line now names what the refresh needs (`UBICLOUD_API_KEY`, then `bun run test:ubicloud --record-durations`, which copies the seed back into your checkout) and tells contributors without the key to leave it to a maintainer. It no longer offers a CI recording, which does not exist.
|
||||
|
||||
#### For contributors
|
||||
|
||||
- The free runner appends `maintenance.auto=false` and `gc.auto=0` to every shard child's `GIT_CONFIG_*` pairs (`test/test-free-shards-git-maintenance.test.ts`). Fixtures that build their own git environment set the keys themselves; `docs/TESTING_INTERNALS.md#flake-ledger` has the rule and a recipe for reproducing a ledger entry from a CI run's `free-plan` artifact.
|
||||
- The zombie publication-lock test synchronizes on a PID line its Python parent prints after `os.waitid(..., WNOWAIT)`, and the next-version smoke test runs the real CLI against a fake `gh` that logs and checks every call.
|
||||
- `scripts/free-test-durations.json` is refreshed from a Ubicloud recording of this tree.
|
||||
- No community contributions in this release.
|
||||
|
||||
## [1.91.101.0] - 2026-10-13
|
||||
|
||||
**The field wave's residual gaps are closed.** v1.91.96.0 through v1.91.100.0 shipped the install-honesty and unattended-`/autoplan` wave (`docs/designs/GSTACK_FIELD_WAVE_2026_10_12.md`); running that release on a Capy machine left six gaps, each reproduced here with a test that failed first.
|
||||
|
||||
### What this means for you
|
||||
|
||||
- **`all` no longer overrides a recommendation to change the plan.** `gstack-autoplan answer <rev> all` resolves only the items the reviewers recommend keeping; an item recommended `b` stays pending (`GATE_PENDING: <ids> unanswered …`) with the hint `all does not answer <id> (recommended b changes the plan); reply <id>a or <id>b yourself`. `all except <id>b` still answers everything in one line.
|
||||
- **Every gate answer names who gave it.** `answer` takes `--by <human|agent:<id>>`; an unattended run refuses an answer without it (`ANSWER_BY_REQUIRED`, exit 3), and `decisions.jsonl` records `answered_by` so a review record can tell the owner's call from a delegate's.
|
||||
- **Later reviewers see the earlier consensus.** The DX and Design prompts carry the CEO consensus (and each later phase every closed predecessor's), the way Eng already did, so a reopened CEO decision reaches every phase that depends on it.
|
||||
- **An upgrade never moves a checkout under a live session.** Each `gstack-skill-start` touches an install-scoped lease (`bin/gstack-lease`, `<git dir>/gstack-leases/<harness pid>`, pruned after 120 minutes; the `/autoplan` driver renews it on every mutating command). `gstack-upgrade` refuses with `UPGRADE_BUSY` (exit 3) while a lease whose pid is alive and outside its own process chain exists; `gstack-lease list` shows them and `GSTACK_UPGRADE_FORCE=1` overrides.
|
||||
- **Capy sessions stay at the tip, not at the floor.** `GSTACK_MIN_REVISION` is the floor below which the trailer fails and the frozen bootstrapper fast-forwards. Above it, `bin/gstack-capy-session` fast-forwards any clean, unpinned checkout behind origin at install-mode session start (`SELF_UPGRADE: done <old> -> <new>`, then installs from the new revision); a refusal prints `SELF_UPGRADE: skipped (<reason>)` and the session continues on the installed revision. `--check` never moves the checkout and reports `gstack-capy-session: behind origin/<branch>: … fix: …`. `GSTACK_CAPY_SELF_UPGRADE=off` disables only this fast-forward; `GSTACK_CAPY_AUTO_UPGRADE=0` disables every one.
|
||||
- **gstack's own tools run on the Bun that launched them.** `lib/autoplan-prompts.ts`, `lib/restamp.ts` and `bin/gstack-gbrain-sync.ts` spawn `process.execPath` instead of whatever `bun` the PATH resolves, so a Capy machine whose PATH lacks `~/.bun/bin` no longer fails mid-workflow (`lib/autoplan-admission.ts` keeps probing the PATH Bun on purpose; that probe is the point).
|
||||
|
||||
- **`/ship` and `/review` load the QA methods as three awaited steps.** The exploratory preflight is now an ordered list (entrypoint, then `scope.md`, then each listed method, each in its own response) and names the smoke guard start, every capture and every plan check as probes that wait for them; the entrypoint alone completes nothing.
|
||||
|
||||
### For contributors
|
||||
|
||||
- `test/autoplan-residuals.test.ts` pins the `all` rule, the carried consensus, the `process.execPath` rule under `lib/` and `bin/*.ts`, and `--by`; `test/gstack-lease.test.ts` covers the lease; `test/gstack-upgrade-bin.test.ts` and `test/capy-skill-wrapper.test.ts` cover `UPGRADE_BUSY` and `SELF_UPGRADE:`.
|
||||
- `bin/gstack-harness-pid.sh` is the one harness-pid derivation, sourced by `bin/gstack-skill-start` and `bin/gstack-lease`.
|
||||
- The test preload and `test/helpers/hermetic-env.ts` set a per-run `GSTACK_LEASE_DIR`, so no test leases the developer's checkout.
|
||||
|
||||
## [1.91.100.0] - 2026-10-13
|
||||
|
||||
**A gbrain LongMemEval call plan now prices in full.** gbrain's `eval longmemeval --call-plan` (gbrain#6638) writes a `GSTACK_EVAL_CALLS` file for every lane of the run. `gstack-eval-plan price-gate` verified its binding but stopped at `EVAL_PRICE_MISSING` with the total unknown: it had no row for the judge (`gpt-4o`) or the reranker (`rerank-2.5`), and it did not recognize the dated Claude id gbrain resolves (`claude-haiku-4-5-20251001`). On the gbrain fixture plan (`--mode tokenmax --judge`) all seven lanes now price, for a total of $0.447.
|
||||
|
||||
### What this means for you
|
||||
|
||||
- **Dated Claude snapshots bill at their alias's price.** `claude-<name>-YYYYMMDD`, with or without a provider prefix, resolves to the `claude-<name>` row. Dated OpenAI snapshots are not folded, because some (`gpt-4o-2024-05-13`) have their own price.
|
||||
- **New rows, sourced and dated in `lib/pricing.ts`:** `gpt-4o` ($2.50 in, $10 out, $1.25 cached per million tokens) and the Voyage rerankers `rerank-2.5` ($0.05) and `rerank-2.5-lite` ($0.02 per million processed tokens: query tokens × documents, plus document tokens).
|
||||
|
||||
### For contributors
|
||||
|
||||
- `test/pricing-eval-plan.test.ts` checks every model a gbrain call plan names. It failed before the change.
|
||||
|
||||
## [1.91.99.0] - 2026-10-13
|
||||
|
||||
**A Bun pin mismatch on Capy now names the one command that fixes it, and the skill says to run it.** After gbrain pinned Bun 1.4.3 in CI, a session-start check on a gbrain checkout ended `gstack: fail pins` with a fix line that offered two choices and no command, and the skill's repair step only covered `revision` and `runtime`, so an agent following the skill left the machine failing. The same bootstrapper run without `--check` already installs the pinned Bun (a verified 1.4.3 in about 4 s) and ends `gstack: ok`.
|
||||
|
||||
@@ -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/<timestamp>/<skill>/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.
|
||||
@@ -88,9 +88,6 @@ Left open by the office-hours tier fix (#2801, #879, #1049, #1958, #1723, #1651
|
||||
follows the carried `SESSION_TIER` through Phase 6 (first run gets the
|
||||
introduction, second run asks about the earlier assignment). No office-hours
|
||||
quality judge exists either. **Effort:** M. **Priority:** P2.
|
||||
- **`gstack-timeline-read --since`/`--branch` without a value** — both still
|
||||
read an unbound `$2` under `set -u` and crash with exit 1, the class #1723
|
||||
fixed for `--limit`. **Effort:** S. **Priority:** P3.
|
||||
|
||||
### P3: Oct 8 fix wave, safety hooks follow-ups (filed 2026-10-08)
|
||||
|
||||
@@ -5394,3 +5391,65 @@ skills were told is fenced.
|
||||
Guard both writes. Effort S.
|
||||
|
||||
**Priority:** P3. **Depends on:** none.
|
||||
|
||||
### P1–P2: fix wave 2 follow-ups (filed 2026-10-15)
|
||||
|
||||
From the free-suite health wave (flaky-pass fixes, #3154 seed refresh). Owner
|
||||
Garry for each; revisit 2026-10-29 unless a date says otherwise.
|
||||
|
||||
- **`gstack-next-version` fetches claimed slots one PR at a time** —
|
||||
`GH_API_CONCURRENCY = 10` (bin/gstack-next-version:90), but its async workers
|
||||
call `spawnSync`, so the per-PR `gh api` calls run serially: 8 stubbed PRs at
|
||||
0.5 s each took 4160 ms instead of about 500 ms (4108 ms on a rerun), and 27
|
||||
open PRs at about 1.2 s each pass the 30 s that callers allow, which is how the
|
||||
old live smoke test failed. Every `/ship` slot allocation pays this. Fix:
|
||||
async spawn with bounded overlap, verified by a delayed fake `gh`. Changing the
|
||||
CLI selects 108 paid cases and 24 judges, so it ships as its own priced PR on
|
||||
Garry's call. **Effort:** S. **Priority:** P1.
|
||||
- **Standing Linux duration-seed recorder** — the seed refresh in this wave
|
||||
keeps CEO-10 green for days, not weeks: main gains about 15 free test files a
|
||||
day against a limit of 5 unseeded, so the weekly health run goes red again
|
||||
until a recorder lands. Design: a scheduled `free-durations-record.yml` that
|
||||
records on the standard runner and updates one seed PR before the Monday
|
||||
health run, or the `free-plan` job consuming the latest recording artifact once
|
||||
#3190 (owner of `free-tests.yml`) lands. Agree the design with the owner of
|
||||
`free-tests.yml` before 2026-10-29. **Effort:** M. **Priority:** P1.
|
||||
- **PR full-fallback and recorded spend (audit next step)** — 2026-10-15:
|
||||
full-fallback 45.9% of PR runs, $73.52 recorded PR-lane spend per merged PR,
|
||||
push-to-verdict p50 11.4 min (target 3–5 min). The pre-change spend baseline
|
||||
was never recorded, so the 50% reduction verdict is unavailable
|
||||
(docs/test-audit-2026-10.md, "Post-merge follow-up"). Next step, from the
|
||||
audit's selection replay: touchfile mappings for `bin/`, `lib/`, `hosts/` and
|
||||
`make-pdf/`, so a branch touching them stops selecting every case. Candidate
|
||||
headline for the next wave. **Effort:** M. **Priority:** P1.
|
||||
- **Free flaky-pass target follow-ups (2026-10-22 and 2026-10-29)** — read
|
||||
main's `flake-ledger-*` artifacts (`bun run test:health --since-days 7`) and
|
||||
check the free-suite flaky-pass rate is under 5% of main runs; a file still in
|
||||
the ledger gets a named cause or a new entry here. **Effort:** S. **Priority:** P1.
|
||||
- **`createQAFunctionalFixture` sets git auto-maintenance off itself** — its
|
||||
repos inherit the free runner's backstop keys in CI, but a direct `bun test`
|
||||
run still races `.git/objects/maintenance.lock` during observer setup.
|
||||
The helper selects 22 paid cases, so this is a priced follow-up.
|
||||
**Effort:** S. **Priority:** P2.
|
||||
- **`test:ubicloud --diagnostic` cannot give clean counts for two ledger
|
||||
shards** — `ship-measure free` reruns set a private `HOME` under `/tmp` and
|
||||
`GSTACK_STATE_ROOT`, so `browse/test/path-validation.test.ts` (its upload
|
||||
fixture builds an "outside" directory under `os.userInfo().homedir`, which
|
||||
Bun takes from `HOME`) and `browse/test/pair-agent-tunnel-eval.test.ts` (its
|
||||
denial log follows the inherited state root, not its fake `HOME`) fail 30/30
|
||||
in diagnostic mode while passing in the normal suite. Ledger shards that hold
|
||||
them can never report `passed = n`. **Effort:** S. **Priority:** P2.
|
||||
- **`test/plan-seed-submission.test.ts` reads its fake CLI's events after a
|
||||
fixed 50 ms sleep** — "actual PTY launcher carries placeholder styling into
|
||||
owned seed submission: xterm-256color" sends `/plan-eng-review\r`, sleeps
|
||||
50 ms and expects the `slash` event already in `events.jsonl`; under the
|
||||
16-worker duration recording on 2026-10-15 the event had not landed
|
||||
(`['paste', 'enter', 'end_turn']`). Wait for the CLI's own write instead of a
|
||||
timer (docs/TESTING_INTERNALS.md#flake-ledger). **Effort:** S. **Priority:** P2.
|
||||
- **Split the free files the 2026-10-15 seed put over 60 s** —
|
||||
`test/setup-install-registry.test.ts` (82 s), `test/host-renders-prune.test.ts`
|
||||
(64 s) and `test/setup-codex-scope.test.ts` (60.4 s) are allowlisted with this
|
||||
todo in `scripts/free-test-seed-allowlist.json`; `test/brain-sync.test.ts`
|
||||
(57 s) and `test/setup-codex-scope-namespaces.test.ts` (57 s) are next. Split
|
||||
each by describe block, rerun `bun run test:ubicloud --record-durations` and
|
||||
delete the entries. **Effort:** S each. **Priority:** P2.
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.91.99.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,
|
||||
|
||||
+6
-3
@@ -29,9 +29,12 @@ line yourself; gstack never writes the plan.
|
||||
2. Submit each voice exactly once (`BOUND:`). Both voices in closes the phase (`PHASE_CLOSED:`).
|
||||
3. Repeat `next` until `status=gate_pending` (`GATE: gate_rev=<n>`); render it with
|
||||
`gstack-gate render <dir>/gate.json`. You never approve the gate for the owner: relay it.
|
||||
4. `answer` with the owner's reply. `APPLY: <id> <title> -> <change>` lines are accepted plan
|
||||
changes: edit the plan, then `revise` (the printed `NEXT:` names it). Until then `next`
|
||||
refuses with `PLAN_AMEND_PENDING` and `export` will not mark the run complete.
|
||||
4. `answer` with the owner's reply and `--by human` (the owner typed the tokens) or
|
||||
`--by agent:<id>` (you relayed or decided them); without it an unattended run refuses
|
||||
(`ANSWER_BY_REQUIRED`). `all` leaves every item recommended `b` pending: only the owner's
|
||||
own token changes their stated direction. `APPLY: <id> <title> -> <change>` lines are
|
||||
accepted plan changes: edit the plan, then `revise` (the printed `NEXT:` names it). Until
|
||||
then `next` refuses with `PLAN_AMEND_PENDING` and `export` will not mark the run complete.
|
||||
5. `REOPENED: <phases>` means loop `next` again (Eng last). `APPROVED:` means `export`.
|
||||
|
||||
### Commands
|
||||
|
||||
@@ -26,9 +26,12 @@ line yourself; gstack never writes the plan.
|
||||
2. Submit each voice exactly once (`BOUND:`). Both voices in closes the phase (`PHASE_CLOSED:`).
|
||||
3. Repeat `next` until `status=gate_pending` (`GATE: gate_rev=<n>`); render it with
|
||||
`gstack-gate render <dir>/gate.json`. You never approve the gate for the owner: relay it.
|
||||
4. `answer` with the owner's reply. `APPLY: <id> <title> -> <change>` lines are accepted plan
|
||||
changes: edit the plan, then `revise` (the printed `NEXT:` names it). Until then `next`
|
||||
refuses with `PLAN_AMEND_PENDING` and `export` will not mark the run complete.
|
||||
4. `answer` with the owner's reply and `--by human` (the owner typed the tokens) or
|
||||
`--by agent:<id>` (you relayed or decided them); without it an unattended run refuses
|
||||
(`ANSWER_BY_REQUIRED`). `all` leaves every item recommended `b` pending: only the owner's
|
||||
own token changes their stated direction. `APPLY: <id> <title> -> <change>` lines are
|
||||
accepted plan changes: edit the plan, then `revise` (the printed `NEXT:` names it). Until
|
||||
then `next` refuses with `PLAN_AMEND_PENDING` and `export` will not mark the run complete.
|
||||
5. `REOPENED: <phases>` means loop `next` again (Eng last). `APPROVED:` means `export`.
|
||||
|
||||
### Commands
|
||||
|
||||
+5
-4
@@ -26,7 +26,7 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { answerGate } from '../lib/autoplan-gate';
|
||||
import { admit } from '../lib/autoplan-admission';
|
||||
import { admit, touchInstallLease } from '../lib/autoplan-admission';
|
||||
import { exportRun } from '../lib/autoplan-export';
|
||||
import { migrateRun } from '../lib/autoplan-migrate';
|
||||
import { reviseRun } from '../lib/autoplan-revise';
|
||||
@@ -60,7 +60,7 @@ DRIVER: ${driverFile()}
|
||||
--incorporates discharges the named accepted changes (the plan must change); --withdraw (alone or with --plan) drops one
|
||||
run --out <dir> --plan <file> --native-model <id> --outside-model <id> [--runner codex-cli|api] [--native-runner api] ...
|
||||
next in a loop, only when a runner executes both voices here; --deadline cancels the children it started
|
||||
answer --out <dir> --gate-rev <rev> --reply "<all | d3b uc1a | all except d3b>" [--json]
|
||||
answer --out <dir> --gate-rev <rev> --reply "<all | d3b uc1a | all except d3b>" --by <human|agent:<id>> [--json]
|
||||
resume --out <dir> [--json] status --out <dir> [--json] export --out <dir> release --out <dir> --attempt <id>
|
||||
contract print lib/coordinator-contract.ts (the block every lane and reviewer prompt inherits word for word)
|
||||
|
||||
@@ -99,6 +99,7 @@ function nextLine(state: RunState, out: string): string {
|
||||
*/
|
||||
function mutating(out: string, json: boolean, fn: () => number): number {
|
||||
const admission = admit(process.env);
|
||||
touchInstallLease(process.env);
|
||||
return withRunLock(out, () => {
|
||||
const notices = [...admission.lines];
|
||||
const record = (state: RunState) => {
|
||||
@@ -245,12 +246,12 @@ async function main(argv: string[]): Promise<number> {
|
||||
}, { steal: true });
|
||||
}
|
||||
if (cmd === 'answer') {
|
||||
const rev = flag(rest, '--gate-rev'); const reply = flag(rest, '--reply');
|
||||
const rev = flag(rest, '--gate-rev'); const reply = flag(rest, '--reply'); const by = flag(rest, '--by');
|
||||
if (reply === undefined) return usage('answer needs --reply "<text>"');
|
||||
if (rev === undefined) { const s = readState(out); process.stderr.write(resultLine('gstack-autoplan answer', 'GATE_REV_STALE', `no --gate-rev given; current gate_rev=${s.gate_rev ?? 0}`) + '\n'); return EXIT.refused; }
|
||||
return mutating(out, json, () => {
|
||||
const state = readState(out);
|
||||
const r = answerGate(state, Number(rev), reply);
|
||||
const r = answerGate(state, Number(rev), reply, by);
|
||||
writeState(state);
|
||||
const lines = [...r.lines];
|
||||
if (r.outcome === 'reopened') lines.push(nextLine(state, out));
|
||||
|
||||
@@ -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
|
||||
) </dev/null >&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=<seconds> 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=<seconds> 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
|
||||
|
||||
+35
-21
@@ -60,43 +60,57 @@ _gstack_bun_parse() {
|
||||
_gb_core="$((10#$_gb_maj)) $((10#$_gb_min)) $((10#$_gb_pat))"
|
||||
}
|
||||
|
||||
# _gstack_bun_below <version> <release> — 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 <version> <release> — 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 <version> [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 <git-dir> <rev> — the pre-advance check shared by
|
||||
# the auto-updater and /gstack-upgrade. Reads the floor from <rev>'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 <rev>, 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 <git-dir>). Otherwise it prints nothing and
|
||||
# returns 1 (no bun, or no floor in <rev>). 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"
|
||||
}
|
||||
|
||||
+56
-1
@@ -12,6 +12,20 @@
|
||||
# without anyone recopying the Drive skill; that includes the stale-copy owner
|
||||
# action, read from capy/support.env through gstack_support_env_get.
|
||||
#
|
||||
# Always current when online: the bootstrapper moves a checkout only when it
|
||||
# is below GSTACK_MIN_REVISION (the floor). Above the floor, this half keeps
|
||||
# the checkout at the tip of origin's default branch: in install mode, when
|
||||
# the bootstrapper's FETCH_HEAD is ahead of HEAD, it runs
|
||||
# bin/gstack-upgrade --ff-only-no-reinstall (every refusal of gstack-upgrade
|
||||
# applies: dirty, diverged, pinned, Bun hold, a live workflow lease) and on
|
||||
# success re-execs the NEW checkout's copy of this script with the same
|
||||
# arguments (GSTACK_CAPY_SELF_UPGRADED=1 stops a second attempt) before the
|
||||
# installer runs. Lines: `SELF_UPGRADE: done <old> -> <new>` | `skipped (<reason>)`
|
||||
# | `none (current)` | `disabled (<the variable: GSTACK_CAPY_SELF_UPGRADE=off or
|
||||
# GSTACK_CAPY_AUTO_UPGRADE=0>)`.
|
||||
# --check never moves the checkout; behind origin it prints
|
||||
# `gstack-capy-session: behind origin/<branch>: <installed> -> <remote> (<n> commits); fix: ...`.
|
||||
#
|
||||
# Usage: gstack-capy-session [--check] --root <checkout> [gstack-capy-install arguments]
|
||||
# --check certify only (bin/gstack-capy-install --check: the doctor with the
|
||||
# launch PATH and the remote minimum, the verdict, the launch line)
|
||||
@@ -19,8 +33,14 @@
|
||||
# The last line is the `gstack:` trailer; the exit status is the installer's.
|
||||
set -u
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
CHECK=0 SKILL=""
|
||||
prev=""
|
||||
for a in "$@"; do [ "$prev" = --root ] && ROOT="$a"; prev="$a"; done
|
||||
for a in "$@"; do
|
||||
[ "$prev" = --root ] && ROOT="$a"
|
||||
case "$prev" in --for|--only) SKILL="$a" ;; esac
|
||||
[ "$a" = --check ] && CHECK=1
|
||||
prev="$a"
|
||||
done
|
||||
INSTALLER="$ROOT/bin/gstack-capy-install"
|
||||
if [ ! -x "$INSTALLER" ]; then
|
||||
echo "gstack: fail installer — $INSTALLER is missing; fix: bash ${GSTACK_CAPY_WRAPPER_PATH:-<skill>/scripts/install.sh} (re-clones or fast-forwards)"
|
||||
@@ -33,4 +53,39 @@ MIN_WRAPPER=$(gstack_support_env_get "$(gstack_source_root "$ROOT/bin")" GSTACK_
|
||||
if [ -n "$MIN_WRAPPER" ] && [ "${GSTACK_CAPY_WRAPPER_REV:-0}" -lt "$MIN_WRAPPER" ] 2>/dev/null; then
|
||||
echo "gstack-capy-skill: owner action — Drive copy is wrapper ${GSTACK_CAPY_WRAPPER_REV:-0}, minimum $MIN_WRAPPER; copy $ROOT/capy/skill/gstack/ (CAPY_WRAPPER_STALE)"
|
||||
fi
|
||||
|
||||
# Behind origin? Only when the bootstrapper fetched (a known remote minimum
|
||||
# means FETCH_HEAD is origin's default branch tip) and that tip is not already
|
||||
# in HEAD.
|
||||
behind() {
|
||||
[ -n "${GSTACK_CAPY_REMOTE_MIN:-}" ] && [ "$GSTACK_CAPY_REMOTE_MIN" != unknown ] || return 1
|
||||
git -C "$ROOT" rev-parse -q --verify FETCH_HEAD >/dev/null 2>&1 || return 1
|
||||
! git -C "$ROOT" merge-base --is-ancestor FETCH_HEAD HEAD 2>/dev/null
|
||||
}
|
||||
disabled() { echo "SELF_UPGRADE: disabled ($1)"; } # VAR — the switch that turned the fast-forward off
|
||||
if [ "$CHECK" -eq 1 ]; then
|
||||
if behind; then
|
||||
BRANCH=$(git -C "$ROOT" symbolic-ref -q --short HEAD 2>/dev/null || echo HEAD)
|
||||
echo "gstack-capy-session: behind origin/$BRANCH: $(head -1 "$ROOT/VERSION" 2>/dev/null) -> $(git -C "$ROOT" show FETCH_HEAD:VERSION 2>/dev/null | head -1) ($(git -C "$ROOT" rev-list --count HEAD..FETCH_HEAD 2>/dev/null || echo '?') commits); fix: bash ${GSTACK_CAPY_WRAPPER_PATH:-<skill>/scripts/install.sh}${SKILL:+ --for $SKILL} (fast-forwards)"
|
||||
fi
|
||||
elif [ "${GSTACK_CAPY_SELF_UPGRADED:-}" != 1 ]; then
|
||||
if [ "${GSTACK_CAPY_SELF_UPGRADE:-}" = off ]; then disabled GSTACK_CAPY_SELF_UPGRADE
|
||||
elif [ "${GSTACK_CAPY_AUTO_UPGRADE:-1}" = 0 ]; then disabled GSTACK_CAPY_AUTO_UPGRADE
|
||||
elif [ -z "${GSTACK_CAPY_REMOTE_MIN:-}" ] || [ "$GSTACK_CAPY_REMOTE_MIN" = unknown ]; then echo "SELF_UPGRADE: none (remote minimum unknown)"
|
||||
elif ! behind; then echo "SELF_UPGRADE: none (current)"
|
||||
elif [ ! -x "$ROOT/bin/gstack-upgrade" ]; then echo "SELF_UPGRADE: skipped ($ROOT/bin/gstack-upgrade is missing)"
|
||||
else
|
||||
OLD=$(head -1 "$ROOT/VERSION" 2>/dev/null)
|
||||
UP_OUT=$("$ROOT/bin/gstack-upgrade" --ff-only-no-reinstall 2>&1); UP_RC=$?
|
||||
[ -n "$UP_OUT" ] && printf '%s\n' "$UP_OUT"
|
||||
if [ "$UP_RC" -eq 0 ]; then
|
||||
echo "SELF_UPGRADE: done $OLD -> $(head -1 "$ROOT/VERSION" 2>/dev/null)"
|
||||
export GSTACK_CAPY_UPGRADED_FROM="$OLD" GSTACK_CAPY_SELF_UPGRADED=1
|
||||
exec "$ROOT/bin/gstack-capy-session" "$@"
|
||||
fi
|
||||
REASON=$(printf '%s\n' "$UP_OUT" | sed -n 's/^gstack-upgrade: \(.*\); nothing was changed; fix: /\1; fix: /p' | tail -1)
|
||||
[ -n "$REASON" ] || REASON=$(printf '%s\n' "$UP_OUT" | sed -n 's/^gstack-upgrade: //p' | tail -1)
|
||||
echo "SELF_UPGRADE: skipped (${REASON:-gstack-upgrade exit $UP_RC})"
|
||||
fi
|
||||
fi
|
||||
exec "$INSTALLER" "$@"
|
||||
Executable
+50
@@ -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 <slug> <branch>
|
||||
#
|
||||
# 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 <b>, not <branch>): <path>".
|
||||
#
|
||||
# /plan-ceo-review saves each plan as
|
||||
# $GSTACK_STATE_ROOT/projects/<slug>/ceo-plans/<YYYY-MM-DD>-<feature>.md with a
|
||||
# "Branch: <branch> | Mode: ..." header line. Precedence:
|
||||
# 1. newest plan whose Branch line names <branch>, 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 <slug> <branch>" >&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
|
||||
@@ -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"
|
||||
}
|
||||
+5
-6
@@ -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
|
||||
|
||||
@@ -105,6 +105,7 @@ gstack_result_anchor() {
|
||||
REVIEW_STATUS_MISMATCH) printf '%s' 'review-status-mismatch' ;;
|
||||
GATE_REV_STALE) printf '%s' 'gate-rev-stale' ;;
|
||||
GATE_REPLY_UNPARSED) printf '%s' 'gate-reply-unparsed' ;;
|
||||
ANSWER_BY_REQUIRED) printf '%s' 'answer-by-required' ;;
|
||||
COMPONENT_MISSING) printf '%s' 'doctor-component-missing' ;;
|
||||
RUNTIME_BELOW_MINIMUM) printf '%s' 'doctor-runtime-below-minimum' ;;
|
||||
PROJECT_PIN_MISMATCH) printf '%s' 'doctor-project-pin-mismatch' ;;
|
||||
@@ -112,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' ;;
|
||||
@@ -186,10 +188,16 @@ gstack_result_anchor() {
|
||||
GSTACK_VERSION_CHANGED) printf '%s' 'gstack-version-changed' ;;
|
||||
ARTIFACT_GATE_UNSETTLED) printf '%s' 'artifact-gate-unsettled' ;;
|
||||
UPGRADE_REFUSED) printf '%s' 'upgrade-refused' ;;
|
||||
UPGRADE_BUSY) printf '%s' 'upgrade-busy' ;;
|
||||
UPGRADE_FETCH_FAILED) printf '%s' 'upgrade-fetch-failed' ;;
|
||||
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
|
||||
}
|
||||
+12
-10
@@ -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<number> {
|
||||
// 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<number> {
|
||||
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.
|
||||
|
||||
Executable
+317
@@ -0,0 +1,317 @@
|
||||
#!/usr/bin/env bash
|
||||
# gstack-ff-update <dir> <rev> — fast-forward the gstack install at <dir> to
|
||||
# <rev> 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 (<git-common-dir>/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/<oid>: tree = the working tree as captured, first
|
||||
# parent = the pre-update HEAD (so `diff <ref>^ <ref>` is only the
|
||||
# person's delta), second parent = the staged state. It is stash-shaped,
|
||||
# so `git stash show -p <oid>` reads it too.
|
||||
# 4. The tree moves with a two-way `read-tree -m -u <captured> <result>` 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 <head> | ok <old> <new> | rescued <old> <new> <ref> unmerged=<0|1>
|
||||
# refusals (stdout): <reason>: <detail>, 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 <max> 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 <install dir> <revision>"
|
||||
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 <index> — 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 <a> <b> <fileprefix> — changed paths between two trees, split into
|
||||
# <fileprefix>.user and <fileprefix>.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 <from> <to> <pathsfile> — index-info lines giving <to>'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 <<EOF
|
||||
$UNMERGED
|
||||
EOF
|
||||
|
||||
ZERO=$(g hash-object --stdin < /dev/null | tr '0-9a-f' '0')
|
||||
# lsentry <tree> <path> — "<mode> <oid>" of <path> in <tree>, 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 <path> — the index-info line that carries <path> 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 <this>^ <this> > 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
|
||||
@@ -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 <sha>] [--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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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<StageResult> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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" : "";
|
||||
@@ -1608,7 +1627,7 @@ function runMemoryIngest(args: CliArgs, previous: SyncState, consent: Transcript
|
||||
process.env.GSTACK_SYNC_MEMORY_TIMEOUT_MS,
|
||||
"GSTACK_SYNC_MEMORY_TIMEOUT_MS",
|
||||
);
|
||||
const result = spawnSync("bun", ingestArgs, {
|
||||
const result = spawnSync(process.execPath, ingestArgs, {
|
||||
encoding: "utf-8",
|
||||
timeout: memoryTimeoutMs,
|
||||
env: childEnv,
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# shellcheck shell=bash
|
||||
# gstack-harness-pid.sh — the harness pid behind a gstack command (sourced,
|
||||
# never executed). One derivation for every writer that keys per-session
|
||||
# state on it: bin/gstack-skill-start (the state-root session marker) and
|
||||
# bin/gstack-lease (the install-scoped workflow lease), so an updater that
|
||||
# reads leases sees the same pid the session wrote. Bash 3.2 plus ps.
|
||||
#
|
||||
# gstack_harness_pid START — walk up from START (the calling shell) past
|
||||
# shell wrappers and print the first non-shell ancestor. Claude Code runs a
|
||||
# tool call under a per-call `bash -c`, whose parent is the harness; a host
|
||||
# that execs the command directly is already the parent. Without a usable ps
|
||||
# (some Windows shells) START stands. At most 8 hops; pid 0 and 1 stop.
|
||||
gstack_harness_pid() {
|
||||
_hp_pid="$1"
|
||||
_hp_hops=0
|
||||
while [ "$_hp_hops" -lt 8 ]; do
|
||||
_hp_comm=$(ps -o comm= -p "$_hp_pid" 2>/dev/null | head -1) || break
|
||||
_hp_comm=${_hp_comm##*/}; _hp_comm=${_hp_comm#-}
|
||||
case "$_hp_comm" in bash|sh|zsh|dash|ksh|mksh|ash|fish|busybox) ;; *) break ;; esac
|
||||
_hp_up=$(ps -o ppid= -p "$_hp_pid" 2>/dev/null | tr -d ' ')
|
||||
case "$_hp_up" in ''|*[!0-9]*|0|1) break ;; esac
|
||||
_hp_pid="$_hp_up"
|
||||
_hp_hops=$((_hp_hops + 1))
|
||||
done
|
||||
printf '%s\n' "$_hp_pid"
|
||||
}
|
||||
Executable
+96
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env bash
|
||||
# gstack-lease — the workflow lease on a shared gstack installation. Several
|
||||
# agents (eight Capy subagents, say) share one checkout; a session-start
|
||||
# fast-forward must not move it under a session mid-workflow. The state-root
|
||||
# session markers (bin/gstack-skill-start) differ per agent, so an updater
|
||||
# cannot see them; leases live in the installation's own git dir, beside
|
||||
# gstack-upgrade.pending, where every session and updater of this checkout
|
||||
# shares them.
|
||||
#
|
||||
# Usage:
|
||||
# gstack-lease touch [--root <checkout>] [--pid <p> | --from <pid>]
|
||||
# write <git dir>/gstack-leases/<pid>; pid defaults to the harness pid
|
||||
# behind this call (bin/gstack-harness-pid.sh, the same derivation the
|
||||
# session marker uses), or behind --from (a caller whose own parent is
|
||||
# short-lived passes its parent pid); leases older than 120 min are pruned
|
||||
# gstack-lease live [--root <checkout>] [--exclude-ancestry-of <pid>]
|
||||
# print `<pid> <age>m` per lease whose pid is alive and is neither the
|
||||
# given pid nor one of its ancestors (the caller's own session); exit 0
|
||||
# when any live lease exists, 1 when none
|
||||
# gstack-lease list [--root <checkout>]
|
||||
# print `<pid> <age>m alive|dead` for every lease
|
||||
# <checkout> defaults to the source checkout behind this bin (an installed
|
||||
# runtime root's .source-path, else ../). A root that is not a git checkout
|
||||
# exits 2 and writes nothing. GSTACK_LEASE_DIR overrides the lease directory
|
||||
# (hermetic tests; a dev checkout's own tests must not lease it). Bash 3.2
|
||||
# plus git, ps, find.
|
||||
set -u
|
||||
BIN="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
# shellcheck source=bin/gstack-install-verdict.sh
|
||||
. "$BIN/gstack-install-verdict.sh" 2>/dev/null || gstack_source_root() { (cd "$1/.." && pwd -P); }
|
||||
# shellcheck source=bin/gstack-harness-pid.sh
|
||||
. "$BIN/gstack-harness-pid.sh" 2>/dev/null || gstack_harness_pid() { printf '%s\n' "$1"; }
|
||||
|
||||
VERB="${1:-}"; [ $# -gt 0 ] && shift
|
||||
ROOT="" PID="" EXCLUDE="" FROM=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--root) ROOT="${2:-}"; shift ;;
|
||||
--pid) PID="${2:-}"; shift ;;
|
||||
--from) FROM="${2:-}"; shift ;;
|
||||
--exclude-ancestry-of) EXCLUDE="${2:-}"; shift ;;
|
||||
*) echo "gstack-lease: unknown argument '$1'" >&2; exit 2 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
case "$VERB" in
|
||||
touch|live|list) ;;
|
||||
-h|--help|'') sed -n '2,24p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "gstack-lease: unknown verb '$VERB'" >&2; exit 2 ;;
|
||||
esac
|
||||
[ -n "$ROOT" ] || ROOT=$(gstack_source_root "$BIN")
|
||||
GIT_DIR_ABS=$(git -C "$ROOT" rev-parse --absolute-git-dir 2>/dev/null) \
|
||||
|| { echo "gstack-lease: $ROOT is not a git checkout" >&2; exit 2; }
|
||||
DIR="${GSTACK_LEASE_DIR:-$GIT_DIR_ABS/gstack-leases}"
|
||||
|
||||
alive() { kill -0 "$1" 2>/dev/null || ps -p "$1" >/dev/null 2>&1; }
|
||||
age_min() { # FILE — minutes since its mtime
|
||||
_m=$(stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo 0)
|
||||
echo $(( ( $(date +%s) - _m ) / 60 ))
|
||||
}
|
||||
|
||||
case "$VERB" in
|
||||
touch)
|
||||
[ -n "$PID" ] || PID=$(gstack_harness_pid "${FROM:-$PPID}")
|
||||
mkdir -p "$DIR" || exit 2
|
||||
: > "$DIR/$PID" || exit 2
|
||||
find "$DIR" -mmin +120 -type f -exec rm -f {} + 2>/dev/null
|
||||
exit 0 ;;
|
||||
list)
|
||||
for f in "$DIR"/*; do
|
||||
[ -f "$f" ] || continue
|
||||
p=${f##*/}
|
||||
if alive "$p"; then echo "$p $(age_min "$f")m alive"; else echo "$p $(age_min "$f")m dead"; fi
|
||||
done
|
||||
exit 0 ;;
|
||||
esac
|
||||
|
||||
# live: the caller's own chain (the pid and its ancestors, up to 64 hops) never counts.
|
||||
ANCESTRY=" "
|
||||
p="$EXCLUDE"; hops=0
|
||||
while [ -n "$p" ] && [ "$hops" -lt 64 ]; do
|
||||
case "$p" in ''|*[!0-9]*|0|1) break ;; esac
|
||||
ANCESTRY="$ANCESTRY$p "
|
||||
p=$(ps -o ppid= -p "$p" 2>/dev/null | tr -d ' ')
|
||||
hops=$((hops + 1))
|
||||
done
|
||||
FOUND=1
|
||||
for f in "$DIR"/*; do
|
||||
[ -f "$f" ] || continue
|
||||
p=${f##*/}
|
||||
case "$ANCESTRY" in *" $p "*) continue ;; esac
|
||||
alive "$p" || continue
|
||||
echo "$p $(age_min "$f")m"
|
||||
FOUND=0
|
||||
done
|
||||
exit $FOUND
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
+170
-49
@@ -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/<oid>, 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 <max> 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 <checker> <tree> — sets _hr_out to the checker's report on
|
||||
# <tree> 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 <hook>` or
|
||||
@@ -85,8 +100,12 @@ hook_report() {
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
# hook_incoming_hold <rev> — parse-check the hooks of <rev> (extracted with
|
||||
# git archive) before the live checkout moves. The incoming revision's own
|
||||
# hook_incoming_hold <rev> — parse-check the hooks of <rev> before the live
|
||||
# checkout moves. <rev> 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 <ref> <unmerged> — 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
|
||||
|
||||
@@ -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
|
||||
|
||||
+10
-15
@@ -52,22 +52,14 @@ while [ $# -gt 0 ]; do
|
||||
done
|
||||
[ -n "$SKILL_NAME" ] || SKILL_NAME="unknown"
|
||||
|
||||
# Harness pid: walk up from the calling shell past shell wrappers. Claude
|
||||
# Code runs this under a per-call `bash -c`, whose parent is the harness; a
|
||||
# host that execs the command directly is already the parent. Without a
|
||||
# usable ps (some Windows shells) the immediate parent stands.
|
||||
# Harness pid: walk up from the calling shell past shell wrappers
|
||||
# (bin/gstack-harness-pid.sh, shared with bin/gstack-lease so the install
|
||||
# lease and the session marker name the same pid). Without the helper the
|
||||
# immediate parent stands.
|
||||
# shellcheck source=bin/gstack-harness-pid.sh
|
||||
. "$(dirname "$0")/gstack-harness-pid.sh" 2>/dev/null || gstack_harness_pid() { printf '%s\n' "$1"; }
|
||||
if [ -z "$PARENT_PID" ]; then
|
||||
PARENT_PID="$PPID"
|
||||
_hops=0
|
||||
while [ "$_hops" -lt 8 ]; do
|
||||
_comm=$(ps -o comm= -p "$PARENT_PID" 2>/dev/null | head -1) || break
|
||||
_comm=${_comm##*/}; _comm=${_comm#-}
|
||||
case "$_comm" in bash|sh|zsh|dash|ksh|mksh|ash|fish|busybox) ;; *) break ;; esac
|
||||
_up=$(ps -o ppid= -p "$PARENT_PID" 2>/dev/null | tr -d ' ')
|
||||
case "$_up" in ''|*[!0-9]*|0|1) break ;; esac
|
||||
PARENT_PID="$_up"
|
||||
_hops=$((_hops + 1))
|
||||
done
|
||||
PARENT_PID=$(gstack_harness_pid "$PPID")
|
||||
fi
|
||||
|
||||
_SCRIPT_DIR=$(cd "$(dirname "$0")" 2>/dev/null && pwd)
|
||||
@@ -127,6 +119,9 @@ fi
|
||||
mkdir -p "$_GH/sessions" 2>/dev/null || true
|
||||
touch "$_GH/sessions/$PARENT_PID" 2>/dev/null || true
|
||||
find "$_GH/sessions" -mmin +120 -type f -exec rm {} + 2>/dev/null || true
|
||||
# The install-scoped twin (bin/gstack-lease): what bin/gstack-upgrade reads
|
||||
# before moving a checkout other sessions share. Fail-open.
|
||||
"$_BIN/gstack-lease" touch --pid "$PARENT_PID" >/dev/null 2>&1 || true
|
||||
_SESSIONS=$(find "$_GH/sessions" -mmin -120 -type f 2>/dev/null | wc -l | tr -d ' ')
|
||||
|
||||
_PROACTIVE=$("$_BIN/gstack-config" get proactive 2>/dev/null || echo "true")
|
||||
|
||||
@@ -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
|
||||
+69
-20
@@ -1,26 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
# gstack-timeline-read — read and format project timeline
|
||||
# Usage: gstack-timeline-read [--since "7 days ago"] [--limit N] [--branch NAME]
|
||||
# Usage: gstack-timeline-read [--since "7 days ago"|7d|YYYY-MM-DD] [--limit N] [--branch NAME]
|
||||
#
|
||||
# Session timeline: local-only, never sent anywhere.
|
||||
# Reads ~/.gstack/projects/$SLUG/timeline.jsonl, filters, formats.
|
||||
# Exit 0 silently if no timeline file exists or nothing matches. Missing bun
|
||||
# exits 127 and a failed read exits non-zero with its stderr (#2790). A missing
|
||||
# or non-positive-integer --limit exits 2 before any read (#1723).
|
||||
# or non-positive-integer --limit, a missing or unparseable --since, a missing
|
||||
# --branch, or an unknown argument exits 2 before any read (#1723); --help
|
||||
# prints the usage line and exits 0.
|
||||
set -euo pipefail
|
||||
USAGE='Usage: gstack-timeline-read [--since "7 days ago"|7d|YYYY-MM-DD] [--limit N] [--branch NAME]'
|
||||
SINCE_FORMS='accepted: "<N> minute|hour|day|week|month[s] ago", "<N>h|d|w", or an ISO date YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ'
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
|
||||
. "$(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
|
||||
GSTACK_HOME="$_gstack_sr_root"
|
||||
|
||||
SINCE=""
|
||||
SINCE_MS=0
|
||||
LIMIT=20
|
||||
BRANCH=""
|
||||
|
||||
missing_value() { # remaining-arg-count next-arg -> why the flag has no usable value, or nothing
|
||||
if [[ $1 -lt 2 ]]; then echo "got no value"
|
||||
elif [[ -z "$2" ]]; then echo "got an empty value"
|
||||
elif [[ "$2" == --* ]]; then echo "got flag '$2'"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
days_from_civil() { # year month day (UTC) -> days since 1970-01-01
|
||||
local y=$((10#$1)) m=$((10#$2)) d=$((10#$3))
|
||||
[[ $m -le 2 ]] && y=$((y - 1))
|
||||
local era=$((y / 400)) yoe doy mp
|
||||
yoe=$((y - era * 400)); mp=$(((m + 9) % 12)); doy=$(((153 * mp + 2) / 5 + d - 1))
|
||||
echo $((era * 146097 + yoe * 365 + yoe / 4 - yoe / 100 + doy - 719468))
|
||||
}
|
||||
|
||||
since_cutoff_ms() { # one --since value -> cutoff epoch ms, or nothing when unparseable
|
||||
local now n unit seconds lower
|
||||
now=$(date -u +%s)
|
||||
lower=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')
|
||||
if [[ "$lower" =~ ^([1-9][0-9]{0,5})[[:space:]]*(minute|hour|day|week|month)s?[[:space:]]+ago$ ]]; then
|
||||
n=${BASH_REMATCH[1]}; unit=${BASH_REMATCH[2]}
|
||||
elif [[ "$lower" =~ ^([1-9][0-9]{0,5})([hdw])$ ]]; then
|
||||
n=${BASH_REMATCH[1]}
|
||||
case ${BASH_REMATCH[2]} in h) unit=hour ;; d) unit=day ;; w) unit=week ;; esac
|
||||
elif [[ "$1" =~ ^([0-9]{4})-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])(T([01][0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9])Z)?$ ]]; then
|
||||
seconds=$(( $(days_from_civil "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}") * 86400 ))
|
||||
[[ -n "${BASH_REMATCH[4]}" ]] && seconds=$((seconds + 10#${BASH_REMATCH[5]} * 3600 + 10#${BASH_REMATCH[6]} * 60 + 10#${BASH_REMATCH[7]}))
|
||||
echo $((seconds * 1000)); return 0
|
||||
else
|
||||
return 0
|
||||
fi
|
||||
case $unit in minute) seconds=60 ;; hour) seconds=3600 ;; day) seconds=86400 ;; week) seconds=604800 ;; month) seconds=2592000 ;; esac
|
||||
echo $(((now - n * seconds) * 1000))
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--since) SINCE="$2"; shift 2 ;;
|
||||
--help|-h) echo "$USAGE"; exit 0 ;;
|
||||
--since)
|
||||
problem=$(missing_value $# "${2-}")
|
||||
if [[ -n "$problem" ]]; then
|
||||
echo "gstack-timeline-read: --since must be a relative time like \"7 days ago\" ($problem); $SINCE_FORMS" >&2
|
||||
exit 2
|
||||
fi
|
||||
SINCE_MS=$(since_cutoff_ms "$2")
|
||||
if [[ -z "$SINCE_MS" ]]; then
|
||||
echo "gstack-timeline-read: --since must be a relative time like \"7 days ago\" (got '$2'); $SINCE_FORMS" >&2
|
||||
exit 2
|
||||
fi
|
||||
shift 2 ;;
|
||||
--limit)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "gstack-timeline-read: --limit must be a positive integer (got no value); example: --limit 20" >&2
|
||||
@@ -31,8 +83,17 @@ while [[ $# -gt 0 ]]; do
|
||||
exit 2
|
||||
fi
|
||||
LIMIT="$2"; shift 2 ;;
|
||||
--branch) BRANCH="$2"; shift 2 ;;
|
||||
*) shift ;;
|
||||
--branch)
|
||||
problem=$(missing_value $# "${2-}")
|
||||
if [[ -n "$problem" ]]; then
|
||||
echo "gstack-timeline-read: --branch must be a branch name ($problem)" >&2
|
||||
exit 2
|
||||
fi
|
||||
BRANCH="$2"; shift 2 ;;
|
||||
*)
|
||||
echo "gstack-timeline-read: unknown argument '$1'" >&2
|
||||
echo "$USAGE" >&2
|
||||
exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
@@ -47,23 +108,11 @@ command -v bun >/dev/null 2>&1 || {
|
||||
exit 127
|
||||
}
|
||||
|
||||
cat "$TIMELINE_FILE" | GSTACK_TIMELINE_SINCE="$SINCE" GSTACK_TIMELINE_BRANCH="$BRANCH" GSTACK_TIMELINE_LIMIT="$LIMIT" bun -e "
|
||||
cat "$TIMELINE_FILE" | GSTACK_TIMELINE_SINCE_MS="$SINCE_MS" GSTACK_TIMELINE_BRANCH="$BRANCH" GSTACK_TIMELINE_LIMIT="$LIMIT" bun -e "
|
||||
const lines = (await Bun.stdin.text()).trim().split('\n').filter(Boolean);
|
||||
const since = process.env.GSTACK_TIMELINE_SINCE || '';
|
||||
const branch = process.env.GSTACK_TIMELINE_BRANCH || '';
|
||||
const limit = Number(process.env.GSTACK_TIMELINE_LIMIT);
|
||||
|
||||
let sinceMs = 0;
|
||||
if (since) {
|
||||
// Parse relative time like '7 days ago'
|
||||
const match = since.match(/(\d+)\s*(day|hour|minute|week|month)s?\s*ago/i);
|
||||
if (match) {
|
||||
const n = parseInt(match[1]);
|
||||
const unit = match[2].toLowerCase();
|
||||
const ms = { minute: 60000, hour: 3600000, day: 86400000, week: 604800000, month: 2592000000 };
|
||||
sinceMs = Date.now() - n * (ms[unit] || 86400000);
|
||||
}
|
||||
}
|
||||
const sinceMs = Number(process.env.GSTACK_TIMELINE_SINCE_MS);
|
||||
|
||||
const entries = [];
|
||||
for (const line of lines) {
|
||||
|
||||
+25
-4
@@ -10,24 +10,32 @@
|
||||
# gstack-upgrade fast-forward, then reinstall: bin/gstack-capy-install on a
|
||||
# Capy install (the marker it writes in the git dir),
|
||||
# else ./setup --refresh-registered (setup runs migrations)
|
||||
# gstack-upgrade --ff-only-no-reinstall
|
||||
# fast-forward with every refusal below, print the upgraded
|
||||
# line and exit 0 without reinstalling (for a caller about
|
||||
# to run the installer itself: bin/gstack-capy-session)
|
||||
#
|
||||
# Refuses with exit 3 and changes nothing (UPGRADE_REFUSED) when the directory
|
||||
# is not a gstack checkout, HEAD is detached or not on the remote default
|
||||
# branch (a pin), any tracked byte is dirty, a local commit diverged, the
|
||||
# incoming release's Bun floor holds, or another updater holds the setup
|
||||
# lock; never stashes, never resets. An unreachable remote is
|
||||
# UPGRADE_FETCH_FAILED (exit 3). Prints `gstack-upgrade: upgraded <old> -> <new>`
|
||||
# before reinstalling; a failed reinstall exits 1 and the next run completes it.
|
||||
# UPGRADE_FETCH_FAILED (exit 3). While another live session holds a workflow
|
||||
# lease on this installation (bin/gstack-lease live) it refuses with
|
||||
# UPGRADE_BUSY (exit 3); GSTACK_UPGRADE_FORCE=1 overrides. Prints
|
||||
# `gstack-upgrade: upgraded <old> -> <new>` before reinstalling; a failed
|
||||
# reinstall exits 1 and the next run completes it.
|
||||
# Bash 3.2 plus git; the fetch is bounded by `timeout` when present.
|
||||
set -u
|
||||
BASH_COMPAT=50
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
CHECK=0
|
||||
CHECK=0 REINSTALL=1
|
||||
case "${1:-}" in
|
||||
--check) CHECK=1 ;;
|
||||
--ff-only-no-reinstall) REINSTALL=0 ;;
|
||||
'') ;;
|
||||
-h|--help) sed -n '2,21p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
-h|--help) sed -n '2,28p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "gstack-upgrade: unknown argument '$1'" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
@@ -88,6 +96,18 @@ if ! git -C "$ROOT" merge-base --is-ancestor HEAD FETCH_HEAD 2>/dev/null; then
|
||||
git -C "$ROOT" merge-base --is-ancestor HEAD FETCH_HEAD 2>/dev/null \
|
||||
|| refuse "diverged: $(git -C "$ROOT" rev-list --count FETCH_HEAD..HEAD 2>/dev/null || echo '?') local commits are not on origin/$DEFAULT" "git -C $ROOT log --oneline FETCH_HEAD..HEAD shows them; move them to another branch, then re-run"
|
||||
fi
|
||||
# Workflow leases (bin/gstack-lease): sessions sharing this installation that
|
||||
# are alive and not this process's own chain. Moving the checkout under them
|
||||
# is what the lease exists to prevent; the caller's own session never counts.
|
||||
if [ "${GSTACK_UPGRADE_FORCE:-}" != 1 ] && [ -x "$ROOT/bin/gstack-lease" ] \
|
||||
&& BUSY=$("$ROOT/bin/gstack-lease" live --root "$ROOT" --exclude-ancestry-of $$ 2>/dev/null); then
|
||||
HOLDERS=""
|
||||
while read -r _pid _age; do HOLDERS="$HOLDERS${HOLDERS:+, }pid $_pid ($_age)"; done <<EOF_BUSY
|
||||
$BUSY
|
||||
EOF_BUSY
|
||||
echo "gstack-upgrade: live gstack sessions hold this installation: $HOLDERS; nothing was changed; fix: wait for those sessions to finish, or re-run with GSTACK_UPGRADE_FORCE=1 when you know they are idle (UPGRADE_BUSY)" >&2
|
||||
exit 3
|
||||
fi
|
||||
# shellcheck source=bin/gstack-bun-version.sh
|
||||
. "$ROOT/bin/gstack-bun-version.sh" 2>/dev/null && HOLD=$(gstack_bun_incoming_hold "$ROOT" "$INCOMING") \
|
||||
&& refuse "the incoming release holds: $HOLD" "install Bun $(git -C "$ROOT" show "$INCOMING:bin/gstack-bun-version.sh" 2>/dev/null | sed -n 's/^GSTACK_BUN_TESTED="\(.*\)"/\1/p') (bin/gstack-capy-install on Capy), then re-run"
|
||||
@@ -106,4 +126,5 @@ NEW=$(head -1 "$ROOT/VERSION")
|
||||
echo "gstack-upgrade: upgraded $INSTALLED -> $NEW"
|
||||
rm -rf "$LOCK_DIR"; trap - EXIT
|
||||
|
||||
[ "$REINSTALL" -eq 1 ] || exit 0
|
||||
reinstall "$INSTALLED"
|
||||
@@ -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<void> | null = null;
|
||||
private handoffPending: Promise<string> | null = null;
|
||||
private activeTabRecovery: Promise<void> | null = null;
|
||||
private closing = false;
|
||||
private handoffPrevious: { browser: Browser; pages: Map<number, Page>; tabSessions: Map<number, TabSession>; 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<boolean> {
|
||||
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<string | undefined> {
|
||||
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 <url>';
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -258,9 +258,8 @@ export async function handleMetaCommand(
|
||||
tokenInfo?: TokenInfo | null,
|
||||
opts?: MetaCommandOpts,
|
||||
): Promise<string> {
|
||||
// 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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string, PtySession>();
|
||||
// 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<any, PtySession>(); // 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<PtySession>();
|
||||
// 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<PtyGroupIdentity>();
|
||||
|
||||
/**
|
||||
* 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/<pid>/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<T>(
|
||||
* 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<number> => !!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?.();
|
||||
}
|
||||
|
||||
@@ -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<string, { status: number; result: string; tabs: number }> = {};
|
||||
|
||||
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 <url>';
|
||||
|
||||
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' });
|
||||
});
|
||||
});
|
||||
@@ -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<void>((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);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,8 @@ import { spawnSync } from 'node:child_process';
|
||||
import { acquireAgentStateLock, agentRecordPath, readAgentStartTime, writeAgentRecord, type AgentRecord } from '../src/terminal-agent-control';
|
||||
|
||||
const roots: string[] = [];
|
||||
const children: ReturnType<typeof Bun.spawn>[] = [];
|
||||
const children: Bun.Subprocess[] = [];
|
||||
let unfinished: (() => Promise<Error>) | undefined;
|
||||
const directory = () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'publication-lock-'));
|
||||
roots.push(root);
|
||||
@@ -31,6 +32,8 @@ async function fixture(dead = true) {
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
if (unfinished) console.error((await unfinished()).message);
|
||||
unfinished = undefined;
|
||||
for (const child of children.splice(0)) {
|
||||
if (child.exitCode === null) try { child.kill('SIGKILL'); } catch {}
|
||||
await child.exited;
|
||||
@@ -51,25 +54,36 @@ describe('owned terminal-agent publication lock recovery', () => {
|
||||
|
||||
test.skipIf(process.platform !== 'linux')('a dead zombie with the exact recorded birth cannot retain its publication lock', async () => {
|
||||
const root = directory();
|
||||
const ready = path.join(root, 'zombie-pid');
|
||||
const python = [
|
||||
'import os,time',
|
||||
'pid=os.fork()',
|
||||
'if pid==0: os._exit(0)',
|
||||
`with open(${JSON.stringify(ready)},'w') as f: f.write(str(pid))`,
|
||||
'os.waitid(os.P_PID,pid,os.WEXITED|os.WNOWAIT)',
|
||||
'print(pid,flush=True)',
|
||||
'time.sleep(30)',
|
||||
].join('\n');
|
||||
const parent = Bun.spawn(['python3', '-c', python], { stdio: ['ignore', 'ignore', 'ignore'] });
|
||||
const parent = Bun.spawn(['python3', '-c', python], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
children.push(parent);
|
||||
for (let n = 0; n < 300 && !fs.existsSync(ready); n++) await Bun.sleep(10);
|
||||
expect(fs.existsSync(ready)).toBe(true);
|
||||
const pid = Number(fs.readFileSync(ready, 'utf8'));
|
||||
let state = '';
|
||||
for (let n = 0; n < 300; n++) {
|
||||
state = fs.readFileSync(`/proc/${pid}/stat`, 'utf8').match(/^\d+ \(.*\) ([A-Z])/u)?.[1] || '';
|
||||
if (state === 'Z') break;
|
||||
await Bun.sleep(10);
|
||||
const failure = async (reason: string) => {
|
||||
if (parent.exitCode === null) parent.kill('SIGKILL');
|
||||
const status = await parent.exited;
|
||||
const stderr = await new Response(parent.stderr).text();
|
||||
return new Error(`${reason}; zombie parent exit status ${status}; stderr tail: ${stderr.slice(-500) || '(empty)'}`);
|
||||
};
|
||||
unfinished = () => failure('zombie parent never reported its exited child');
|
||||
const reader = parent.stdout.getReader();
|
||||
let line = '';
|
||||
while (!line.includes('\n')) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) throw await failure(`zombie parent closed stdout before a full PID line (got ${JSON.stringify(line)})`);
|
||||
line += new TextDecoder().decode(chunk.value);
|
||||
}
|
||||
reader.releaseLock();
|
||||
unfinished = undefined;
|
||||
const reported = line.slice(0, line.indexOf('\n'));
|
||||
if (!/^[1-9]\d*$/.test(reported)) throw await failure(`zombie parent reported a malformed PID ${JSON.stringify(reported)}`);
|
||||
const pid = Number(reported);
|
||||
const state = fs.readFileSync(`/proc/${pid}/stat`, 'utf8').match(/^\d+ \(.*\) ([A-Z])/u)?.[1] || '';
|
||||
expect(state).toBe('Z');
|
||||
expect(() => process.kill(pid, 0)).not.toThrow();
|
||||
const record: AgentRecord = { pid, gen: 'zombie-generation', startedAt: Date.now(),
|
||||
|
||||
@@ -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<void>) {
|
||||
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<void>((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<number> {
|
||||
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<boolean> {
|
||||
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<number, [string, number, number, number]>): 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 }); }
|
||||
});
|
||||
});
|
||||
@@ -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=="],
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ description: Install and use gstack in Capy and Claude Code. Load at session sta
|
||||
Resolve `scripts/install.sh` relative to this SKILL.md, not the working repository. It is a frozen bootstrapper: it prints `gstack-capy-skill: wrapper <n> (<path>)` first, learns the supported minimum from gstack's remote, and hands over to the checkout's own wrapper (`bin/gstack-capy-session`).
|
||||
|
||||
1. At session start run `bash scripts/install.sh --check`, adding `--for <skill>` when you know the workflow (`--for autoplan`, `--for review`, `--for qa`). It prints one `PASS|FAIL|SKIP <component>` row per component, a `GSTACK_LAUNCH:` line, and exactly one trailer as the last line: `gstack: ok <revision> for=<skill>` (exit 0) or `gstack: fail <ids> …` (exit 1). Read the exit status and grep `^gstack: ` directly; never pipe the check through `tail`, `head` or `grep` alone. `--check` never moves the checkout.
|
||||
2. If the trailer names `revision`, `runtime` or `pins` (`gstack: fail revision …`, `gstack: fail runtime,pins`, `gstack: fail pins`) and a checkout exists, run the same command without `--check` (900-second timeout). For `pins` it installs the project's pinned Bun when the pin names a verified version (about 4 s), or stops with `RUNTIME_PIN_CONFLICT` naming the pin to raise. A clean, unpinned checkout below the supported minimum is fast-forwarded (`gstack-capy-skill: upgraded <old> -> <new>`), reinstalled and rechecked in one locked step; about 1 s when nothing changed. A pinned (`--revision`, detached), dirty or diverged checkout is never moved: the trailer names the cause and the exact commands. `GSTACK_CAPY_AUTO_UPGRADE=0` disables the fast-forward.
|
||||
2. If the trailer names `revision`, `runtime` or `pins` (`gstack: fail revision …`, `gstack: fail runtime,pins`, `gstack: fail pins`) and a checkout exists, or the output carries a `gstack-capy-session: behind origin/<branch>: …` line, run the same command without `--check` (900-second timeout). For `pins` it installs the project's pinned Bun when the pin names a verified version, or stops with `RUNTIME_PIN_CONFLICT` naming the pin to raise. Any clean, unpinned checkout behind origin is fast-forwarded in install mode (below the `GSTACK_MIN_REVISION` floor by the bootstrapper, `gstack-capy-skill: upgraded <old> -> <new>`; above it by the checkout, `SELF_UPGRADE: done <old> -> <new>`), reinstalled and rechecked in one locked step. A pinned (`--revision`, detached), dirty or diverged checkout, or one another live session holds a lease on (`UPGRADE_BUSY`), is never moved: below the floor the trailer names the cause and the exact commands; above it the run prints `SELF_UPGRADE: skipped (<reason>)` and continues on the installed revision. `GSTACK_CAPY_AUTO_UPGRADE=0` disables every fast-forward, `GSTACK_CAPY_SELF_UPGRADE=off` only the above-floor one.
|
||||
3. If the trailer is `gstack: fail not-installed …` and this session runs no gstack workflow, record that line and continue; nothing is installed. Install right before the first workflow with `bash scripts/install.sh --for <skill>` (about 30 s cold: clone, the SHA-256-verified Bun 1.4.2, both host profiles without Chromium).
|
||||
4. Any other `FAIL` row carries a `fix:` command and a reason code with a troubleshooting anchor; run the fix, then `--check` again. `SKIP codex-cli (optional; …)`, `SKIP browser (lazy; …)` and `SKIP pins (planning-only; …)` are not failures.
|
||||
|
||||
|
||||
+7
-3
@@ -3,9 +3,13 @@
|
||||
# so a checkout too old to know today's rules learns them from the remote.
|
||||
# Parsed, never sourced: KEY=<dotted number> lines only.
|
||||
#
|
||||
# GSTACK_MIN_REVISION: the oldest revision whose installer, trailer and fix
|
||||
# lines are honest. A checkout below it is fast-forwarded at session start
|
||||
# (install mode; --check only reports). Raise it only for install-honesty or
|
||||
# GSTACK_MIN_REVISION: the floor. The oldest revision whose installer, trailer
|
||||
# and fix lines are honest: below it the trailer fails (REVISION_BELOW_MINIMUM)
|
||||
# and the bootstrapper fast-forwards a clean, unpinned checkout at install-mode
|
||||
# session start (--check only reports). It is not the upgrade trigger: any
|
||||
# checkout behind origin's default branch is fast-forwarded at install-mode
|
||||
# session start by the checkout's own bin/gstack-capy-session (SELF_UPGRADE:
|
||||
# lines; refusals skip and continue). Raise it only for install-honesty or
|
||||
# security fixes, at stamp time, to the VERSION being released
|
||||
# (docs/capy-install.md "Raising the supported minimum"); it never exceeds VERSION.
|
||||
GSTACK_MIN_REVISION=1.91.96.0
|
||||
|
||||
@@ -1133,15 +1133,44 @@ Free-suite flake rules:
|
||||
daemon without `CI=true` launches Chromium sandboxed.
|
||||
- Handshakes with a runner wait for the runner's own observable effect (its
|
||||
progress line, a coalesced read count), not for a fixture side file or a timer.
|
||||
- Fixtures that commit and then list, watch or remove a repository's `.git`
|
||||
turn git auto-maintenance off themselves (`maintenance.auto=false` and
|
||||
`gc.auto=0` in the repo config, or in the explicit git env they build). Since
|
||||
git 2.29, `git commit` starts a detached `git maintenance run --auto` that
|
||||
creates and deletes `.git/objects/maintenance.lock` after the commit has
|
||||
returned; a listing counts it, a watcher's `lstat` misses it, and Bun's
|
||||
`rmSync(..., { recursive: true, force: true })` returns without an error while
|
||||
leaving the directory behind. `runFreeShard` appends the same two keys to
|
||||
every shard child's `GIT_CONFIG_*` pairs as a backstop, so it covers fixtures
|
||||
that inherit the environment, and `bun run scripts/ship-measure.ts free
|
||||
--backend local` measures with that environment; a fixture with its own
|
||||
replacement env never sees the backstop.
|
||||
|
||||
To reproduce a ledger entry, take the shard's exact file list from the CI run's
|
||||
`free-plan` artifact, then rerun it with the flaky retry off on a CI-mirrored VM:
|
||||
|
||||
```bash
|
||||
gh run download <run-id> -R garrytan/gstack -n free-plan -D /tmp/free-plan
|
||||
files=$(jq -r --argjson s <shard> '.shards[] | select(.shard == $s) | .files | join(",")' /tmp/free-plan/free-plan.json)
|
||||
bun run test:ubicloud --diagnostic --files "$files" --reruns 30
|
||||
```
|
||||
|
||||
The ledger entry names the shard (`flake-ledger-<shard>`). Read the counts from
|
||||
the run's `free-measurement.json` (`completed`, `passed`, `capHit`): with a
|
||||
before-fix failure rate p, a fix needs at least 3/p clean completed reruns of
|
||||
the same command, so zero failures would have under a 5% chance if nothing had
|
||||
changed. Cut or unstarted reruns are incomplete, not passes.
|
||||
|
||||
### Free suite duration seed
|
||||
|
||||
`scripts/free-test-durations.json` drives shard packing; files missing from it
|
||||
pack at the seed's 99th percentile. Acceptable seeds come from
|
||||
pack at the seed's 99th percentile. Acceptable seeds come only from
|
||||
`bun run test:ubicloud --record-durations` (each file timed in its own child on a
|
||||
VM with the CI lane's environment) or a CI recording. A 4-core laptop or
|
||||
small-sandbox recording is not acceptable: browser and display tests skip or slow
|
||||
down there, so the seed misstates them.
|
||||
VM with the CI lane's environment; it needs `UBICLOUD_API_KEY` and copies the
|
||||
refreshed seed back into the checkout). A 4-core laptop or small-sandbox
|
||||
recording is not acceptable: browser and display tests skip or slow down there,
|
||||
so the seed misstates them. Contributors without `UBICLOUD_API_KEY` leave the
|
||||
refresh to a maintainer.
|
||||
|
||||
- `--ci-plan` warns in the job summary, with the refresh command, when more than 5
|
||||
files are unseeded; it never fails a PR. `--ci-verify` lists shards that ran
|
||||
|
||||
@@ -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.
|
||||
|
||||
<a id="journal-lag"></a>
|
||||
|
||||
+30
-6
@@ -5,7 +5,7 @@ Three layers, each owned by gstack:
|
||||
| Layer | Where | Job |
|
||||
|---|---|---|
|
||||
| Bootstrapper | `capy/skill/gstack/scripts/install.sh` (copied once into the owner's Drive skill folder; frozen) | clone when absent; fetch origin's default branch and read the supported minimum from `FETCH_HEAD:capy/support.env`; in install mode fast-forward a clean, unpinned checkout below it; exec the full wrapper |
|
||||
| Full wrapper | `bin/gstack-capy-session` (moves with the checkout) | map the session start onto the installer: `--check` certifies, otherwise install or repair |
|
||||
| Full wrapper | `bin/gstack-capy-session` (moves with the checkout) | map the session start onto the installer: `--check` certifies (and reports a checkout behind origin), otherwise fast-forward a checkout behind origin (`bin/gstack-upgrade --ff-only-no-reinstall`), then install or repair |
|
||||
| Installer | `bin/gstack-capy-install` | Bun, both host profiles, the doctor, the install verdict, the launch line, the trailer |
|
||||
|
||||
The vendored skill (`capy/skill/gstack/SKILL.md`) tells a Capy agent how to use
|
||||
@@ -36,9 +36,23 @@ git clone --single-branch --depth 1 https://github.com/garrytan/gstack.git ~/.lo
|
||||
checkout is never moved; the trailer names the cause and the exact commands
|
||||
([REVISION_BELOW_MINIMUM](troubleshooting.md#revision-below-minimum)).
|
||||
`GSTACK_CAPY_AUTO_UPGRADE=0` disables the fast-forward.
|
||||
3. When the fetch fails (offline), the bootstrapper says `remote minimum
|
||||
3. The minimum is the floor, not the trigger. Above it, a checkout behind
|
||||
origin's default branch still moves at install-mode session start: the
|
||||
full wrapper runs `bin/gstack-upgrade --ff-only-no-reinstall` (every
|
||||
refusal of `gstack-upgrade` applies), prints `SELF_UPGRADE: done <old> ->
|
||||
<new>` and re-execs the new checkout's wrapper, which installs from the
|
||||
new revision (`GSTACK_CAPY_UPGRADED_FROM` set, so setup is never skipped).
|
||||
A refusal prints `SELF_UPGRADE: skipped (<reason>)` and the session
|
||||
continues on the installed revision; the trailer stays the doctor's.
|
||||
`SELF_UPGRADE: none (current)` when nothing is ahead;
|
||||
`GSTACK_CAPY_SELF_UPGRADE=off` (or `GSTACK_CAPY_AUTO_UPGRADE=0`) prints
|
||||
`SELF_UPGRADE: disabled (<variable>)`. `--check` never moves the checkout;
|
||||
behind origin it prints `gstack-capy-session: behind origin/<branch>:
|
||||
<installed> -> <remote> (<n> commits); fix: bash <skill>/scripts/install.sh
|
||||
[--for <skill>] (fast-forwards)` and the agent runs that fix.
|
||||
4. When the fetch fails (offline), the bootstrapper says `remote minimum
|
||||
unknown (fetch failed)` and checks the local install only; it never claims
|
||||
the minimum.
|
||||
the minimum, and nothing is fast-forwarded.
|
||||
|
||||
The upgrade target is always the tip of the default branch: every commit there
|
||||
is a stamped release that passed CI. Recovery runs forward, never back: an
|
||||
@@ -55,6 +69,14 @@ and reinstalls (`bin/gstack-capy-install` on a Capy install, else `./setup
|
||||
[UPGRADE_REFUSED](troubleshooting.md#upgrade-refused) and never stashes or
|
||||
resets.
|
||||
|
||||
Several agents sharing one checkout (eight Capy subagents on a machine) hold
|
||||
workflow leases on it: every `bin/gstack-skill-start` touches
|
||||
`<git dir>/gstack-leases/<harness pid>` (`bin/gstack-lease touch`; pruned after
|
||||
120 minutes). `bin/gstack-upgrade`, and so the session-start self-upgrade,
|
||||
refuses with [UPGRADE_BUSY](troubleshooting.md#upgrade-busy) while a lease
|
||||
whose pid is alive and outside its own process chain exists;
|
||||
`bin/gstack-lease list` shows them and `GSTACK_UPGRADE_FORCE=1` overrides.
|
||||
|
||||
## What the installer does
|
||||
|
||||
1. Reuses the checkout it lives in (or clones into `--root <dir>`), under the
|
||||
@@ -194,7 +216,7 @@ git clone --single-branch --depth 1 https://github.com/garrytan/gstack.git ~/.lo
|
||||
|
||||
and snapshots capture `$HOME` (the checkout, `~/.bun/bin`, both host
|
||||
profiles). Each session then pays only the `--check`. A baked revision still
|
||||
moves forward at session start when it falls below the supported minimum, and
|
||||
moves forward at install-mode session start whenever it is behind origin, and
|
||||
the baked `~/.bun/bin` reaches workflows through the `GSTACK_LAUNCH` line. The
|
||||
project Setup is shared by every thread in the project, so changing it is the
|
||||
project's decision.
|
||||
@@ -203,8 +225,10 @@ project's decision.
|
||||
|
||||
`capy/support.env` holds `GSTACK_MIN_REVISION` and `GSTACK_CAPY_MIN_WRAPPER`.
|
||||
Raise `GSTACK_MIN_REVISION` only for install-honesty or security fixes, at
|
||||
stamp time, to the VERSION being released: every machine below it is
|
||||
fast-forwarded at its next session start. It never exceeds VERSION
|
||||
stamp time, to the VERSION being released: it is the floor below which the
|
||||
trailer fails and the bootstrapper itself fast-forwards (a checkout above it
|
||||
that is behind origin is fast-forwarded by the checkout's own wrapper, so the
|
||||
floor is not what keeps machines current). It never exceeds VERSION
|
||||
(test/capy-skill-wrapper.test.ts). Raise `GSTACK_CAPY_MIN_WRAPPER` together with
|
||||
`GSTACK_CAPY_WRAPPER` in the bootstrapper when the bootstrapper itself
|
||||
changes; older Drive copies keep working and print an owner action
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
# gstack fix wave (October 12, 2026): install honesty, unattended autoplan on Capy, plan-to-gate, queue-aware shipping
|
||||
|
||||
## URGENT, outside this plan
|
||||
|
||||
- `fixwave-run-20261012204516-ceo-native-6` [High]: D16 scans outgoing diffs; sealed eval transcripts and a quoted sealed answer are **already committed** in gbrain's public repository, and nothing in this wave rewrites history. Owner: the gbrain repo owner (Garry). Options: scrub history and force-push the affected refs, or accept the disclosure in writing. This wave only stops the next one.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
### Context
|
||||
|
||||
Base: origin/main `70fab3e` (v1.91.94.0). Input: field evidence from the GBRA threads, which ran gstack on Capy cloud machines during gbrain fix waves 15–16 (2026-10-10..12, gstack 1.91.22–1.91.94), plus reproductions on this machine (a Capy cloud box, Ubuntu 24.04, Bun 1.3.14 at `/usr/local/bin/bun`, no Codex CLI harness). Plan revision 3: revision 1 was reviewed by six voices (CEO, DX, Eng; Claude and GPT each), every gate item was answered `b` (take the reviewers' change), revision 2 folded those in and was reviewed again by six voices; this revision folds in the revision-2 findings (eight gate items plus the in-item fixes). The Review record lists what each phase accepted at each revision.
|
||||
|
||||
Reproduced here before writing a line (2026-10-12, this machine):
|
||||
|
||||
- **A1/A2.** `~/.local/share/gstack` sat at 1.91.69.0, 25 releases behind. The drive skill's `install.sh --check` printed `gstack: fail installer — checkout … predates gstack-doctor --check (installed 1.91.69.0); fix: … or run /home/user/.local/share/gstack/bin/gstack-upgrade to move it` and exited 1. `bin/gstack-upgrade` does not exist at any revision (`git ls-tree origin/main bin/ | grep -c gstack-upgrade` → 0); `gstack-upgrade/` is a Claude-only skill directory (`lib/doctor-components.ts:117`, `CLAUDE_ONLY`). The doctor's own fix text names it too (`bin/gstack-doctor-check.sh:338`: `…, or run /gstack-upgrade`). Rerunning the installer with a sidecar root installed 1.91.94.0 in 32 s `[measured: this machine 2026-10-12]` but ended `gstack: fail claude`, because `./setup` left `~/.claude/skills/gstack` pointing at the stale checkout. Only `git merge --ff-only origin/main` in the stale checkout plus `./setup` recovered the machine, exactly the field report.
|
||||
- **A3.** After that recovery the installer printed `gstack: ok 1.91.94.0 for=autoplan` and exited 0. The very next `install.sh --check` in a fresh shell printed `FAIL runtime — bun 1.3.14 at /usr/local/bin/bun is below the supported minimum 1.4.2` and `FAIL pins`, trailer `gstack: fail runtime,pins`, exit 1; with `PATH=$HOME/.bun/bin:$PATH` the same check printed `gstack: ok`. The installer exports the verified Bun only inside its own shell (`bin/gstack-capy-install:108`) and the `GSTACK_LAUNCH` line it prints (`:145`) carries no `PATH`. The runtime FAIL row's `fix:` says to reinstall Bun although `~/.bun/bin/bun` is already 1.4.2 (`lib/result-codes.ts:126`). `lib/autoplan-prompts.ts:33` and `lib/restamp.ts:177,314` spawn `bun` by PATH name.
|
||||
- **B5.** `lib/autoplan-gate.ts:107-120` `finishGate` writes decision rows whose ids come from `lib/gate-list.ts:148-150` as `${list.run}-${item.id}`; `gate_rev` is a separate column, so a second gate revision re-emits the same ids and `gstack-artifact validate` reports `ARTIFACT_DUPLICATE_ID`. (`finishGate` already retains earlier revisions' rows; the ids are what collide.) Reproduced by this run itself: after gate rev 2, `gstack-artifact validate run.json` reports 9 `ARTIFACT_DUPLICATE_ID` errors (`d1`..`d8`, `p1` at `decisions.jsonl:19-27`), so the run cannot validate until B5 ships.
|
||||
- **B6/B8.** `bin/gstack-autoplan --help`: no `revise` subcommand. Reopened phases snapshot the run's `plan-active.md` (`lib/autoplan-run.ts:119`), so a reopen does review a revised plan when that file is edited; the field reruns reviewed the old plan because the source document was edited instead, and nothing told the agent which file the run reads. The snapshot tool (`bin/gstack-autoplan-snapshot.ts:90`) requires exactly one `## Implementation plan` followed by one `## Review record` (`lib/autoplan-export.ts:54-57` is tolerant); `init` already scaffolds a heading-less plan (`:415`), and the error names neither the missing heading nor a fix. After a `b` answer, `answer` prints `NEXT: next` (`bin/gstack-autoplan:184`) and `next` snapshots and dispatches before anyone is told to revise, which is how the field reruns reviewed the old plan.
|
||||
- **B9.** `lib/autoplan-gate.ts:81-83`: every user-challenge item is built with `recommended: 'a'` = "keep your direction" even when both voices recommend the change. Live example: this plan's own gate rev 1 rendered 17 items as "keep your direction (recommended)" while the reviewers argued the opposite on every one. `lib/gate-list.ts:138` resolves `all` to `recommended`, and `answer` reopens on `how === 'override'`, not on the option chosen.
|
||||
- **B10.** `lib/autoplan-prompts.ts:23-27`: UI scope is two or more hits of `component|screen|form|button|modal|layout|dashboard|sidebar|nav|dialog` anywhere in the plan text, Review record included; `detectScope` (`:39-46`) records `source: 'flags'` and nothing persists flags across runs. `openPhase` passes prior consensus only to Eng (`lib/autoplan-run.ts:121`), so DX never sees the CEO consensus (this run: both rev-2 DX prompts contained zero consensus blocks). `bin/gstack-autoplan` never passes through `gstack-skill-start` or the doctor; this run's `run.json` carries no `gstack_revision`. Gate answers record `chosen`, `answered_at` and `reply` but not who answered (`lib/gate-list.ts:155`).
|
||||
- **D15/D16.** `lib/ship-policy.ts:34,58` has `release_tool` (one command run inside `gstack-restamp`'s staging worktree after the stamp) but no way for a repo whose own script is what CI checks to own the whole stamp; `bin/gstack-pregate --help` has no stamp or sealed check.
|
||||
|
||||
Items not reproducible on this machine are taken from the GBRA threads as stated and marked `[field]`: A4 (52 s install on automation machines), B7 (gate churn across three revisions), B11 (rules claimed "written first", unreceipted numbers), C13 (lanes passing `bun run verify` but missing manifest rows, pinned contracts, stale goldens), C14 ($723 unpriced gate, hidden synopsis builds), D15 (gbrain's `release-restamp.ts`, `since:` rows, migration v240 collision), D16 (transcripts and a quoted sealed answer pushed to a public repo).
|
||||
|
||||
**What already exists (reused, not rebuilt).** `bin/gstack-session-update:270-300` upgrades the checkout in-session: receipted fetch, `gstack_bun_incoming_hold` (`bin/gstack-bun-version.sh:82`), hook hold, `merge --ff-only --autostash` (a bare `--ff-only` wedged permanently on patched tracked files, #2566), setup, migrations. `gstack-doctor --check --revision <ref>` and `REVISION_UNMET` exist (`bin/gstack-doctor:55`, `bin/gstack-doctor-check.sh:338`). The installer already exits non-zero on every skipped-Bun path. `gstack-autoplan submit --result` binds a host-subagent markdown result by receipt and fence (this plan's own run closed all six attempts through it). `lib/tree-receipt.ts` (`buildReceipt`, `classifyFile:69`) already classifies stamp-only hunks, `since:` rows, changelog headings and gate reuse, printed by `bin/gstack-tree-receipt`. `scripts/test-paid-shards.ts --list` prints a free paid-gate plan (human-readable; no `--json`). `docs/unattended.md:135-243` is the Capy driver loop. `lib/claude-code-migration.ts:159` spawns through `process.execPath`. `bin/gstack-redact-prepush:604` already parses pre-push stdin refs for the managed pre-push wrapper (`bin/gstack-redact`), single-guard, spawning `bun` by name. `test/lane-coordination.test.ts:117` is the byte-pin pattern. `setup:309-327` resolves Bun with `command -v bun` and accepts 1.3.14 as merely untested.
|
||||
|
||||
What worked and must not regress (each gets a pinned test if it lacks one): `install.sh --check` FAIL rows with exact `fix:` lines; `gstack-autoplan next` phase order plus the snapshot hash on every attempt; accepted-obligation blocks that replace earlier ones; exact-token gate answers with `REOPENED:` reruns; `PLAN_REALITY … unreceipted=` flags; the pluggable outside voice on another model family.
|
||||
|
||||
Repo constraints: SKILL.md is generated from `.tmpl`; new logic in `lib/*.ts` behind thin `bin/` entrypoints, every module registered in `test/fixtures/module-size-ratchet.json` (800 lines, 150 per function); state paths through `lib/state-root.ts`; result codes appended to `lib/result-codes.ts` with `docs/troubleshooting.md` anchors; every new outbound call wrapped in `_receipted_git` / `lib/egress-receipt.ts` (`test/egress-receipt-wiring.test.ts` fails on an unreceipted sink); ship is on `stamp-at-merge` with `bump: patch`, so every PR opens with a tentative base+PATCH stamp and the restamp runs when it is next. Zero-headroom skills: autoplan union 1.149, review 1.296, ship 1.645, qa ~9 B; prose added there is replaced byte for byte or printed by a bin.
|
||||
|
||||
### Release promises (the finish line)
|
||||
|
||||
- **P1. A stale machine cannot run a workflow silently.** At session start, when the receipted fetch shows `origin/main` moved, the checkout fast-forwards itself (with autostash, never a reset) and refreshes setup, reruns `--check`, and refuses to run a workflow while the trailer is `fail`; below the contract floor it refuses even offline, above it an offline machine runs with a visible stale note. The Capy autoplan driver itself runs the doctor on its first `next` and refuses a `fail` trailer. A 1.91.12 checkout reaches current from one `install.sh --check`, on every machine a thread uses, and a subagent's first report carries its `gstack:` trailer.
|
||||
- **P2. The installer and the check agree.** `gstack-capy-install` never prints `gstack: ok` on a machine where the next fresh-shell `--check` fails; `GSTACK_LAUNCH` carries the verified Bun on `PATH`; lib code never spawns `bun` by name.
|
||||
- **P3. A three-revision unattended autoplan run validates and closes.** Rev-scoped decision ids, `submit --result --extract` and `--outcome` for phases run as Capy subagents, `revise --plan v2` in the same run before any rerun can dispatch, reruns that dedupe only explicitly linked repeats, user-challenge defaults that follow mechanically agreeing voices without letting `all` flip the owner's direction, answers with provenance, scope from changed files, every phase fed its predecessors' consensus with transitive invalidation, and a compact Capy driver doc.
|
||||
- **P4. Plan obligations reach the mechanical gates before a lane reports done,** and every paid gate a plan names is priced from a declared adapter's structured plan before approval; a gate without an adapter is an owner-answerable `GATE_UNPRICED` item, never silently accepted. Release one ships the gstack adapter only; other runners' manifests report `not configured`.
|
||||
- **P5. Stamp-at-merge delegates to the repo's own script where that is what CI checks,** with the stamp-only proof coming from the tree receipt, migration-collision detection, and a sealed-output guard that runs before push and inside the pre-push hook.
|
||||
|
||||
### Delivery shape
|
||||
|
||||
Three PRs in a dependency chain, not four independent lanes (gate rev 1, d1/d4): every lane appends to `lib/result-codes.ts`, `docs/troubleshooting.md`, `test/fixtures/module-size-ratchet.json` and `scripts/free-test-durations.json`; A and B share `lib/coordinator-contract.ts` and `lib/headless-artifacts.ts`; C13, D15 and D16 share `bin/gstack-pregate` and `lib/pregate/`. Each PR opens with a tentative base+PATCH stamp, is registered with the merge queue before it opens, merges `origin/main` in (never rebases) when the predecessor lands, and ships with a failing test first on the simulated Capy host.
|
||||
|
||||
| PR | Items | Base | Owns (no other PR edits these) |
|
||||
|---|---|---|---|
|
||||
| A | A1–A4, host simulator | main | `bin/gstack-upgrade`, `bin/gstack-upgrade-lib.sh`, `bin/gstack-session-update` pull stage, `bin/gstack-capy-install`, `bin/gstack-doctor-check.sh`, `scripts/capy/**`, `test/helpers/capy-host-sim.ts` |
|
||||
| B | B5–B12, the trailer rule in `lib/coordinator-contract.ts` and `lib/headless-artifacts.ts` | A's branch | `lib/autoplan-*.ts`, `lib/gate-list.ts`, `lib/plan-reality.ts`, `bin/gstack-autoplan*`, `autoplan/sections/**`, `docs/unattended.md` |
|
||||
| C | C13, C14, D15, D16 (one pregate PR) | A's branch (merged after B) | `bin/gstack-pregate`, `lib/pregate/**`, `lib/ship-policy.ts`, `lib/restamp.ts`, `lib/tree-receipt.ts`, `bin/gstack-eval-plan`, `scripts/test-paid-shards.ts` (`--list --json`), `bin/gstack-redact*`, `lib/prepush-refs.ts`, `docs/pregate.md`; **after B lands**: `lib/plan-reality.ts` (GATE_PRICE clause), `lib/headless-artifacts.ts:41,145` (tasks v3), `lib/autoplan-export.ts:31` (checks), `lib/coordinator-contract.ts:22` (lane-done and sealed sentences) |
|
||||
|
||||
Stacked stamps: each successor's tentative stamp is computed from `origin/main`, not the predecessor's branch; when a successor merges `origin/main` after its predecessor squash-lands, it takes main's `VERSION` and changelog heading and runs `gstack-restamp --next --base main` to re-mint rather than hand-resolving; `classifyFile` treats a merge commit whose second parent is already-gated `origin/main` as `gate-reuse: eligible`, so the chain costs B one regate and C two at most (test: a restamp fixture with a squash-merged predecessor resolves without a manual conflict and prints `STAMP_ONLY: yes`). Shared append-only files get pre-assigned blocks so merges are mechanical: result codes and troubleshooting anchors are listed per PR below; each PR adds its own module-size-ratchet rows and durations rows under its own file names; CHANGELOG sections stay separate.
|
||||
|
||||
**Result codes, per PR** (each with a `docs/troubleshooting.md` anchor before any line prints; every failure line ends `(<CODE>; <anchor>)` like the doctor rows). A: `UPGRADE_NOT_FAST_FORWARD`, `UPGRADE_BUSY`, `UPGRADE_FETCH_FAILED`, `UPGRADE_PINNED_ROOT`, `UPGRADE_TARGET_UNAVAILABLE`, `GSTACK_STALE_REFUSED`, `RUNTIME_OFF_PATH`. B: `PLAN_SHAPE_PARTIAL`, `PLAN_SHAPE_AMBIGUOUS`, `RESULT_FINDINGS_MISSING`, `REVISE_OBLIGATIONS_DROPPED`, `REVISE_ATTEMPTS_OPEN`, `REVISE_SCOPE_CHANGED`, `PLAN_UNCHANGED_SINCE_GATE`, `PLAN_REALITY_INCOMPLETE`. C: `MANIFEST_ROW_MISSING`, `GATE_OVER_CAP`, `GATE_UNPRICED`, `STAMP_NOT_ONLY`, `STAMP_EVIDENCE_MISSING`, `MIGRATION_COLLISION`, `MIGRATION_CENSUS_INCOMPLETE`, `SEALED_CONTENT_REFUSED`, `SEALED_RESULT_MISSING`.
|
||||
|
||||
**The simulated Capy host** (`test/helpers/capy-host-sim.ts`, lands in PR A, used by every PR). A scratch root with `usr/local/bin/bun` as a shim that reports `1.3.14` (and otherwise execs the real Bun), no `codex` on PATH, isolated `HOME`, `GIT_CONFIG_GLOBAL=/dev/null`, `GIT_ALLOW_PROTOCOL=file`, `http_proxy=http://127.0.0.1:9`. Subprocess environments are built from an allowlist (never inherited), every state/install/runtime/temp root is set explicitly, credentials are stripped, and a test plants sentinel files outside the root and asserts they are unchanged after install, upgrade and cleanup, including through an escaping symlink. The stale checkout is **synthesised**, because this checkout, CI's (`.github/workflows/quality-gate.yml:40`) and the installer's own clone (`bin/gstack-capy-install:65`) are all shallow and tagless: a local bare origin with two commits (stale = minimal tree with `VERSION 1.91.12` and no `bin/gstack-doctor-check.sh`; current = the install fixture's source files), cloned `--depth 1 --single-branch` like the installer; divergence is one extra local commit. One Docker smoke (`.github/docker/Dockerfile.capy-sim`, Ubuntu 24.04 + Bun 1.3.14 at `/usr/local/bin`) runs the same scenarios in a `workflow_dispatch` lane; it is a `requires-remote` obligation for PR A in the ship receipt, its evidence label declared outside `stamp_sensitive_gates` (`lib/ship-policy.ts:48`) so the tree receipt's `gate-reuse: eligible` carries it across a stamp-only restamp; only a successful job receipt bound to the gated SHA, container digest and selection clears it (a green unit-only rerun cannot erase it). Sim fixtures that run the real `./setup` cost about 30 s each `[estimated]`, so the suite shares one upgraded checkout per file where the scenario allows.
|
||||
|
||||
### PR A: install and upgrade honesty
|
||||
|
||||
**A1. `bin/gstack-upgrade` exists and is the one upgrade path.** Bash, not Bun (it must run on a checkout whose Bun it is about to verify). `bin/gstack-session-update`'s pull stage is extracted into a sourced `bin/gstack-upgrade-lib.sh` (receipted fetch labelled `gstack-upgrade-fetch`, `gstack_bun_incoming_hold`, hook hold, `merge --ff-only --autostash`, setup, migrations, pending-stage file) and both callers use it. Locking protects the **installation**, not a state root: workflow leases live under the installation (`<install>/.gstack-leases/<pid>`, written by `gstack-skill-start` and `gstack-autoplan next`, so eight subagents with distinct state roots are all visible); `gstack-upgrade` takes the installation flock (the installer's `.gstack-capy-install.lock`), refuses `UPGRADE_BUSY` (exit 3) while a lease whose pid is alive and outside the caller's process ancestry exists, and after acquiring the lock re-reads HEAD against `origin/main` so a second concurrent caller prints `UPGRADE: none (current)`. It refuses only local commits or a non-ancestor upstream with `UPGRADE_NOT_FAST_FORWARD` and the exact `git` commands; on an autostash conflict it keeps the stash and the original bytes and refuses rather than dropping anything (test: genuine user edits conflict → bytes and stash survive; a generated-overlay edit fast-forwards). `--to <tag|sha>` distinguishes a tag (`git fetch origin tag <t>`) from a commit (`git fetch origin <sha>`), verifies the commit (`UPGRADE_TARGET_UNAVAILABLE` otherwise), applies the same fast-forward rule, and records the pin in the state root; a `CLAUDE_PLUGIN_DATA`-managed root prints `claude plugin update gstack` and exits 3 (`UPGRADE_PINNED_ROOT`) without touching the tree. Prints `UPGRADE: <old> -> <new> ff=yes migrations=<n> setup=ok` then the doctor trailer, all on stdout. The doctor's `REVISION_UNMET` fix text names this bin; the `gstack-upgrade` Claude skill's Step 4/4.75 delegates to it and its `git reset --hard` divergence fallback is removed (listed under `## Behavior changes` in PR A; the fallback was reachable only when local commits existed, which the rule forbids). Decision rule, written first and stated here: the upgrade path never discards local commits, dirty files or a stash; when it cannot fast-forward it prints the exact commands and stops. Tests: `upgrade-refuses-divergence-keeps-bytes` (including diverged-with-clean-tree, two concurrent `--check` calls on one stale sim producing exactly one `UPGRADE:` line and two `gstack: ok` trailers, no upgrade while a live lease exists, and a dead-pid lease not blocking).
|
||||
|
||||
**A2. The authority on "stale" lives outside the checkout; the first hop has an owner; the drive carries no logic.** The drive skill's `install.sh` becomes a ~20-line **bootstrap** with no version number, bin name or flag in it (pinned by a test over the in-repo copy `scripts/capy/bootstrap.sh`): clone if absent; `git fetch origin main` wrapped in `_receipted_git` when the checkout's `bin/gstack-egress-lib.sh` exists, else a plain fetch printing `receipt: unavailable (pre-upgrade bootstrap)` on stdout (the one exemption named in `test/egress-receipt-wiring.test.ts`); then `git show origin/main:scripts/capy/install.sh > $tmp && bash $tmp --check "$@"`, falling back to the checkout's own `scripts/capy/install.sh` only when the fetch fails. All logic is in-repo in `scripts/capy/install.sh`, tested in the sim; the drive `SKILL.md` is generated from `scripts/capy/SKILL.md.tmpl` by `gen:skill-docs`, listed as a release output, with a test that every `bin/<name>` and flag the skill names exists. The in-repo wrapper's `--check` sources the fetched tree's `bin/gstack-bun-version.sh` and, when `$BUN_INSTALL/bin/bun` reports `gstack_bun_status ok`, prepends its directory to PATH **before** the merge, `./setup --refresh-registered` and the exec of `bin/gstack-doctor --check` (otherwise the upgrade ends `fail runtime` under the 1.3.14 Bun first on PATH; `setup:309` resolves Bun with `command -v bun`). It then applies one ordered policy, stated here and in the wrapper: (1) below `CONTRACT_FLOOR_REVISION` refuse, online or offline (`GSTACK_STALE_REFUSED`, exit 3, trailer `fail revision`); (2) a recorded `--to` pin skips with `SELF_UPGRADE: skipped (pinned <ref>)` and the stale note; (3) the off switch (`GSTACK_SELF_UPGRADE=off` or `auto_upgrade=false` via the checkout's `gstack-config`, `bin/gstack-session-update:153`) skips with `SELF_UPGRADE: disabled (<source>)`; (4) otherwise fetch under a deadline (`--fetch-timeout`, default 20 s; a timeout prints `UPGRADE_FETCH_FAILED (timeout <s>)`, distinct from a connection refusal) and, when `origin/main` moved, fast-forward and refresh setup unconditionally through `bin/gstack-upgrade` (G8 option (c): always current when online, so there is no version zoo and no knob to forget; the cost is setup time at session start on release days, bounded because A4 defers the full install to first workflow use); when the fetch fails above the floor, run with `gstack: ok <rev> (stale; upgrade unavailable: UPGRADE_FETCH_FAILED)`. `CONTRACT_FLOOR_REVISION` lives in `lib/doctor-components.ts`, is read by bash from the generated `bin/gstack-doctor-components.sh`, and is bumped deliberately when the CLI or artifact contract changes. Every trailer and `UPGRADE:`/`SELF_UPGRADE:` line goes to stdout; a test pins the wrapper's result-code anchors against `lib/result-codes.ts` as `bin/gstack-capy-install:50` does. In `gstack-skill-start` the upgrade is a mechanical pre-step printing `SELF_UPGRADE: none|done <old>-><new>|skipped …|disabled …|refused <code>`, and the unattended sentence at `:616` becomes "the tool already applied any required upgrade; never run an upgrade yourself". `gstack-skill-start` prints `GSTACK_TRAILER: gstack: ok <rev>` as the first line of every workflow so a subagent's first report carries it; `lib/coordinator-contract.ts` gains the sentence and the handoff-format order gains the trailer, and `gstack-artifact validate` requires the trailer line in an unattended `review-record.md` only when `run.json.gstack_revision` is at or above this release (reference fixtures keep validating). Session-check latency: the in-checkout check measured 0.38 s `[measured: this machine 2026-10-12]`; the fetch adds network time bounded by the deadline. Fixtures: `install-stale-1-91-12-self-upgrades` (synthesised stale origin → current from one bootstrap `--check`, runtime row PASS and trailer `gstack: ok` with the 1.3.14 shim first on the parent PATH and a supported Bun planted in `$BUN_INSTALL`); the 2×2×2×2 matrix floor × pinned × switch × offline, each cell asserting the `SELF_UPGRADE:` line and the trailer, the hanging-fetch cell using a listener that accepts and never answers; a diverged checkout refuses with the commands and an untouched tree.
|
||||
|
||||
**A3. One Bun, agreed on by installer, check and launch.** The runtime row stays honest to `command -v bun` (gate rev 1, d2/d6). When `$BUN_INSTALL/bin/bun` is already the supported version (read from `bin/gstack-bun-version.sh`, never hardcoded) and only PATH is wrong, the row reads `verified bun <v> at ~/.bun/bin/bun is not on PATH` with fix `source <state root>/launch.env before each gstack command (or prepend the GSTACK_LAUNCH line)` (`RUNTIME_OFF_PATH`; `RUNTIME_BELOW_MINIMUM.fix` gains the PATH clause). `gstack-doctor --check` writes `<state root>/launch.env` (plain `KEY=value` lines, no shell syntax, `PATH_PREPEND=<abs dir>`) and prints `GSTACK_LAUNCH_FILE: <path>` plus the display line `GSTACK_LAUNCH: PATH="<dir>:$PATH" GSTACK_SESSION_KIND=…` with every literal shell-quoted; `--json` carries `path_prepend` and the assignments as a map (paths with spaces, quotes and `$` round-trip; nothing is `eval`ed and the file is parsed, not sourced). The installer's final `--check` runs under `env -i PATH=<prepend>:<system PATH> HOME=<home>` plus the launch assignments, so `ok` means the next fresh shell says `ok`; a host with only 1.3.14 and no network ends `gstack: fail runtime` from both (the installer's existing non-zero skip exits are pinned by this negative test, not re-implemented). `lib/autoplan-prompts.ts:33` and `lib/restamp.ts:177,314` spawn `process.execPath`; `test/bun-spawn-ratchet.test.ts` greps `lib/**/*.ts`, `bin/*.ts` and generated hook templates for a bare `bun` spawn with a reasoned exemption list. Fixture: `install-ok-then-check-ok` records `Bun.version` from the snapshot tool inside the sim and asserts the supported version with the 1.3.14 shim first on the parent's PATH.
|
||||
|
||||
**A4. No 52 s install on machines that never run a workflow.** `[field]` The bootstrap's session-start step is `--check` only; the install runs at first workflow use from the wrapper (`install.sh --for <skill>`), printing `INSTALL: lazy (first workflow use) <seconds>s`. `docs/capy-install.md` gains "Baking gstack into a Capy snapshot" (what the Setup runs, what `--check` must print after boot, why `~/.bun/bin` must be on the Setup's PATH) and states the relation between the self-upgrade and `auto_upgrade`. Tier 2: `gstack-capy-install --prefetch`.
|
||||
|
||||
### PR B: unattended `/autoplan` on Capy
|
||||
|
||||
**B5. Rev-scoped decision ids; attempt-qualified references.** `lib/gate-list.ts:148` emits `${run}-g<gate_rev>-<item>` through one revision-aware reference constructor shared by decisions, `tasks.jsonl` `blocked_by` links and `applyReply` (which today derives the item with `row.id.slice(run.length + 1)`); both id shapes parse; `label` stays the primary key. Finding ids keep their shape (`lib/headless-artifacts.ts:127`), every canonical finding row and `tasks.findings` ref carries `attempt` (`${run}-a<n>`), and every cross-revision reference (`superseded_by`, the `DEDUPED:` line, the reopen journal) is attempt-qualified `<attempt>:<id>`; `gstack-artifact validate` checks each referenced attempt exists in the journal. Fixture: `autoplan-3-rev-validates` with v1 and v2 rows in one `decisions.jsonl`, a rev-2 finding reusing a rev-1 id, actionable findings, tasks, a partial answer and a stale-answer replay; zero `ARTIFACT_DUPLICATE_ID`.
|
||||
|
||||
**B6. `submit --result` closes a run whose phases ran as Capy subagents; failures have a terminal outcome.** No `--record` and no `--attempt new` (gate rev 1 d7/d12; gate rev 2 d5: `next` already mints the attempt and reserves against the cap at `lib/autoplan-run.ts:168-172`, and this run closed twelve attempts through `next`/`submit`). `submit --result` gains `--extract` with the parsed grammar `### F<n> [<Severity>] <title> — <file>:<line>`; a heading without an anchor refuses `RESULT_FINDINGS_MISSING` naming it and printing the example, or with `--anchor-optional` the row carries `anchor: none` and `DEDUPED:` prints `by=title`. `submit --outcome failed|unavailable --attempt <id>` is the terminal-outcome command for a host subagent that never produced a result (`markOutside` is reachable only from `run` today, `bin/gstack-autoplan:141`); terminal transitions are centralised across `submit`, `next` and `resume`: ordinary `next` never redispatches failed or unavailable work, usage settles once, a native failure leaves the phase `incomplete`, and outside-only degradation stays explicit. Every `--json` path returns one versioned JSON object, refusals included. The recovery triad (`resume` → `submit --attempt <id>`; `next --redispatch <attempt>`; `submit --outcome`) is one table in the driver doc. Fixture: each failure before and after its sibling result, asserting stable attempts, reservations and coverage under repeated `next`/`resume`.
|
||||
|
||||
**B7. Reruns dedupe only explicit repeats; nothing is suppressed silently.** `[field]` A reopened phase's prompt on `gate_rev > 1` carries the previous gate's answered items with their attempt-qualified ids. `lib/autoplan-gate.ts` dedupes a rerun challenge only when the finding links a prior item explicitly (`supersedes: <attempt>:<id>`) and its issue and resolution are compatible with that item's and the consumed inputs are unchanged; location and title similarity only **propose** a match (`DEDUPE_CANDIDATE:` line), never apply one. Packaging and other reversible choices render as `kind: auto` items with `basis: packaging` (visible, defaultable, never reopening a phase) rather than dropped (gate rev 1, d8). Fixture: a rerun whose only new challenge is packaging produces one auto item and no reopen; a paraphrased repeat with a valid prior-item link is deduped; two distinct defects at one answered line are both kept.
|
||||
|
||||
**B8. `revise --plan <v2>` in the same run, before any rerun can dispatch; transitive invalidation.** After a content-changing answer, `answer` prints `REVISE: gstack-autoplan revise --out <dir> --plan <v2> (before next)` and `EDIT: <plan-active path>` ahead of `NEXT:`, exposes the same next action in `--json`, and `next` refuses `PLAN_UNCHANGED_SINCE_GATE` (exit 3) when the Implementation-plan hash equals the one snapshotted at the answered gate revision unless `--same-plan` is passed and journaled as a deviation (`bin/gstack-autoplan:184`). `revise` replaces only the Implementation plan bytes of `plan-active.md`, refuses a v2 carrying `## Review record` or an empty body (`PLAN_SHAPE_AMBIGUOUS`), refuses while any attempt is `dispatched`/`execution_unknown` (`REVISE_ATTEMPTS_OPEN`), runs the snapshot tool's obligation check and refuses `REVISE_OBLIGATIONS_DROPPED` with the block names unless `--drop-accepted <ids>`, re-runs `detectScope` over the new bytes and prints `SCOPE: ui <old>-><new> (source=…)`, refusing `REVISE_SCOPE_CHANGED` when the phase order would change unless `--phase-order <list>` is passed (the added phase opens before Eng with every closed predecessor's consensus; `required_phases` is rewritten and journaled), then performs a journaled generation transition: `plan_revisions[]` in `run.json` (generation, both hashes, dropped ids, time), hash-named immutable archives of bound files, `restore.r<rev>.md`, an atomic manifest write, and idempotent `resume` across a crash after any step (one `failAfter` test, as `lib/restamp.ts` does). Every phase records the identities of its consumed inputs (plan snapshot hash plus each predecessor consensus hash); `revise`, `answer` and a changed consensus invalidate the transitive consumers of what changed (a CEO change reopens DX, design and Eng, not only Eng), recompute the phase order, and invalidate the previous gate approval; the reopen line states the dependent reruns and their estimate (gate rev 2, d6). The `PLAN_SHAPE` error at `bin/gstack-autoplan-snapshot.ts:90` gets result code `PLAN_SHAPE_PARTIAL` naming the missing or duplicated heading and the one-line fix; no `scaffold` subcommand (init already scaffolds). Fixtures: the 3-rev run revises with a v2 containing a Review record (refused), with a clean v2 (asserting `review-record.md` still carries rev-1 consensus blocks), with an early `next` before `revise` (refused, zero attempts dispatched); a four-phase CEO-change regression, an Eng-only control, and a CLI-to-TSX revision that inserts design.
|
||||
|
||||
**B9. User-challenge defaults follow mechanically agreeing voices; `all` never flips the owner's direction; answers compose and carry provenance.** In `buildGate` (`lib/autoplan-gate.ts:81-83`) the agreement rule is mechanical: a `confirmed` reconciliation row whose two findings are both `user_challenge` **and** whose resolutions share token overlap at or above the reconciler's half-words heuristic (`lib/autoplan-reconcile.ts:116`) renders `recommended: b, basis: both voices agree` with the reconciled change in the option text; a `confirmed` row with differing resolutions renders `recommended: a, basis: both challenge; resolutions differ` with both resolutions printed; one voice → `recommended: a, basis: one voice; the plan as reviewed stands`; an `agreement: true|false` field on the ReconciliationOverride schema (`:130`) overrides the heuristic. Items recommended `b` are excluded from `all` (`answerable_by_all: false` in `gate.json`) and stay `gate_pending` until answered by token; `GATE_PENDING` prints the exact tokens per pending item and `all does not answer items recommended b` (`:141`); reopen keys on the chosen option changing reviewed content, not on `how`. Incremental answers compose: persisted choices for the current revision merge with new explicit tokens, only unanswered eligible items default, and pending approval plus the reopen set derive from the complete effective decision set, reopening once (`uc1b` then `p1b`; `all` then individual tokens). `answer --by <human|agent:<id>>` is required in unattended runs; `answered_by` lands on every DecisionRow and in the review-record gate line, and `gstack-owner-brief` flags a revision whose user-challenge `b` answers were all agent-given as `agent-accepted; owner has not decided`. Fixture: `gate-defaults-follow-agreement` (the 15-item field gate; opposite fixes at one location → `a` with the basis line; a paraphrased same fix → `b`; `all` leaves agreed items pending; `d1b` reopens; composed answers reopen once).
|
||||
|
||||
**B10. Scope from changed files and rendered surfaces, flags sticky, every phase informed.** `Scope.ui` becomes `yes|no|unknown` (`lib/autoplan-prompts.ts:47` `phaseOrder` and the run-log rendering follow). `detectScope` (`:39`) runs over the Implementation plan bytes only: `ui=yes` when the plan names files under rendered-surface globs (default `**/*.tsx`, `**/*.vue`, `**/*.svelte`, `**/*.css`, `**/views/**`; `**/templates/**` is not a default and gstack ships its own `.gstack/autoplan.json`, with `init`/schema, excluding `qa/templates` and friends) or a fenced `surface:` block lists a screen; `ui=unknown` when no paths match but two or more prose UI terms appear (design runs light, never skipped silently); one prose hit is a `hint:` line and nothing more. Flags persist in `run.json.scope.flags` with provenance and apply to reopened phases (`source=flags (sticky from rev 1)`); `revise` may replace or reset them. `openPhase` passes every closed predecessor's consensus to DX and design, not only to Eng (`lib/autoplan-run.ts:121`), and refuses `CONSENSUS_MISSING` for a required predecessor. Fixtures: a plan with "doctor", "status", "description" and a Review record containing "dashboard" twice → `ui=no`; `qa/templates/x.md` → `ui=no`; `app/components/Foo.tsx` → `ui=yes`; prose-only "modal" and "sidebar" → `ui=unknown`; the DX prompt file contains the CEO consensus block.
|
||||
|
||||
**B11. PLAN_REALITY requires the rule text and receipts for decision-driving figures, before dispatch.** `lib/plan-reality.ts`: the `binding decisions` row fails when a plan claims a rule was written first but no rule text appears in the Implementation plan. The preserved `unreceipted=<row ids>` metric is unchanged; a new `figures_unreceipted=<n>` (with the first three excerpts and lines) scopes to unit-bearing figures (`$`, s/ms/min, %, KB/MB, calls, lines) inside the Implementation plan, accepts the existing tags `[measured: <src>]` / `[estimated]` / `[re-measure before gate]` (`lib/plan-reality.ts:67`) plus `[target]` and `[fixture]` for caps and fixture parameters, requires a receipt to be a `file:line` match on the same line (a bare sha is not a receipt), and ignores versions, exit codes, item ids and `g<n>`/`a<n>`/`uc<n>` tokens. The plan-bytes scan runs at `init` and the first `next` as a pre-dispatch refusal (`PLAN_REALITY_INCOMPLETE`, exit 3) with `--allow-unreceipted` recorded as a deviation; review-text rows still run at phase close as a persisted, input-bound validation boundary, without redispatching completed reviewers. Fixture: the scanner over this plan's Implementation plan pins an enumerated unreceipted set (zero once caps are tagged); the $723 arm list fails, `$723` with a sha on the line still fails; `32 s [measured: this machine 2026-10-12]` passes; `exit 3` and `v1.91.94.0` are ignored.
|
||||
|
||||
**B12. A compact Capy driver doc, one template, outside the parity union.** `[field]` `autoplan/sections/capy-driver.md.tmpl` renders to **`docs/capy-driver.md`** as a whole-file output registered in `.gstack/generated.json` (the parity harness unions every `sections/*.md`, `test/helpers/parity-harness.ts:78-82`, and the autoplan union sits at 117,121/101,979 = 1.1485 against the 1.1490 cap, ~50 bytes of headroom `[measured: Eng rev 2]`); `docs/unattended.md` links it (no fenced-region mechanism, which `gen-skill-docs` lacks). Content: phase order, artifact contract, the `next`/`submit`/`answer`/`revise` loop, the gate reply grammar, the recovery triad table, the RESULT FORMAT, no hook or guard prose; ≤ 200 lines `[target]`. The drive skill reads that path. A test pins the line cap, that no hook token (`PreToolUse`, `Stop hook`, `guard:`) appears, and that `bun test test/parity-suite.test.ts` shows the autoplan ratio unchanged. `review-record.md` links each bound record by name and sha256 instead of inlining it (byte cap pinned in the 3-rev fixture).
|
||||
|
||||
**B13. The Capy driver is honest about the checkout it runs on.** The first `gstack-autoplan next` of a run invokes `gstack-doctor --check --for autoplan` (through `process.execPath` and the bin path, receipted), writes `gstack_revision` and the `gstack:` trailer into `run.json` and the review-record run log, prints `GSTACK_TRAILER:` as the first line of its output, writes the installation lease (A1), and refuses `GSTACK_STALE_REFUSED` (exit 3) when the trailer is `fail`; the `gstack-skill-start` path stays for Claude Code. Fixture: a stale checkout driven by `gstack-autoplan next` in the sim refuses rather than runs.
|
||||
|
||||
### PR C: pregate (plan to gate, queue-aware shipping)
|
||||
|
||||
One flag grammar for every check: `gstack-pregate run [--only <checks>] --range <a>..<b>` (`--staged` as sugar; `--range a..b` implies pin files from `a` unless `--base` is given; the table header prints `POLICY_FROM: <ref>` and `TIER: unit (test tier)` so check tiers and lane platform tiers are not confused); `--only` stays literal; `install-hook` becomes `hook install|status|remove`. The pre-push ref parsing (zero remote sha → merge-base with the remote default; deletes skipped) is extracted from `bin/gstack-redact-prepush:604` into `lib/prepush-refs.ts` used by both guards; the managed pre-push wrapper becomes a guard list so `hook install|status|remove` adds or removes one guard while `pre-push.local` and the other guard survive, and the wrapper embeds the absolute Bun path (`process.execPath` at install time, `command -v bun` fallback) instead of `bun`. `bin/gstack-redact*` and `lib/prepush-refs.ts` are in C's owner column.
|
||||
|
||||
**C13. Lane-done runs the pre-gate on changed files.** `[field]` `gstack-pregate run --tier unit --range origin/<base>..HEAD --allow-repo-commands` is the lane-done preflight: stage 1, `lanes` restricted to the unit tier, `regen` (no duplicate `goldens` check), and the new `manifests` check; the receipt lists every promised preflight row, and a stale-generated-output fixture whose lane and manifest checks pass still fails on `regen`. `manifests` is the **gstack adapter** in release one (gate rev 2, d8): it imports `unseededFreeFiles` (`scripts/test-free-shards.ts:396`) and `TREE_MUTATING` (`:243`) and fails `MANIFEST_ROW_MISSING` with the exact row to add; an ordinary new test needs only its durations row, a renamed exclusive test its serial row, a platform-excluded test neither; a repository without a declared runner adapter reports `manifests: not configured` (never `pass`), and P4 is narrowed accordingly, with the adapter contract (census, durations, exclusivity, platform data) documented in `docs/pregate.md`. Receipts carry purpose, selection, required inventory and unexecuted obligations, so a green unit-only rerun cannot overwrite a publication receipt or clear a remote obligation. After B lands: `tasks.jsonl` gains `checks: string[]` (schema v3, `lib/headless-artifacts.ts:41,145`; `acceptance` stays prose), `lib/autoplan-export.ts:31` renders them, and `lib/coordinator-contract.ts:22` gains the exact sentence "before reporting done, run `gstack-pregate run --tier unit --range origin/<base>..HEAD --allow-repo-commands` and paste its table after `GSTACK_RESULT`". Fixtures: `pregate-lane-done-manifest-row` (adds an unseeded test file and renames a `TREE_MUTATING` key; both FAIL rows name the row to add); a fixture with a different exclusive-test map and one without duration manifests both report `not configured`.
|
||||
|
||||
**C14. Paid gates are priced from a declared adapter's structured plan before approval.** `[field]` `gstack-eval-plan dry-run --gate <cmd>` (under `--allow-repo-commands`) accepts only declared adapters emitting a complete structured plan; PR C ships the first one: `scripts/test-paid-shards.ts --list --json` (steps, calls, model, max tokens, trial multiplicity, preflight work, excluded charges, hidden-step rule), and the synopsis builders gain the same `--list --json`. It prices Σ(calls × model) through `lib/pricing.ts` and the ledger's reservation rule, persists a structured quote bound to adapter, resolved command, consumed-input hashes, models, limits and pricing revision, and prints `GATE_PRICE: <cmd> calls=<n> est=$<x> cap=$<y> evidence=list|smoke preconditions=<list|none>`. `--smoke 2` spends two real questions only with explicit consent and reserves at dispatch. A hidden step over the cap is `GATE_OVER_CAP` (exit 1) naming it; a quote whose inputs changed is invalid at spend time; an unknown model is refused. **Consequence stated (gate rev 2, d2):** a paid gate in a repository without a declared adapter is `GATE_UNPRICED`; in release one that renders as an owner-answerable gate item (`accept unpriced <cmd> under cap $x` / `block`) rather than an unconditional blocker, so the wave does not freeze gbrain shipping on day one; the gbrain synopsis-build adapter (the $723 field case) is a named deliverable with an owner outside this plan, and the adapter contract is published in `docs/pregate.md` so a repo can comply in one PR. After B lands, the plan `numbers` row requires a `GATE_PRICE:` line for every paid gate the plan names, printing `GATE_PRICE: unavailable (adapter not installed)` without blocking while the repo has no adapter. No `GSTACK_EVAL_DRY_RUN`. Fixtures: `gate-price-hidden-synopsis` exercises the real `--list --json` planner with a stubbed paid boundary (100-call plan under cap 10 → `GATE_OVER_CAP` naming the hidden step); changed-input refusal; an undeclared command → `GATE_UNPRICED` item.
|
||||
|
||||
**D15. Ship policy lets the repo's script own the stamp; the tree receipt proves stamp-only; failures never pass.** `[field]` `release_tool` stays the one key and gains `release_tool_owns: 'post-stamp' | 'whole-stamp'` (default `post-stamp`); `GSTACK_NEW_VERSION`, `GSTACK_OLD_VERSION` and `GSTACK_BASE_SHA` are exported in both modes (`lib/restamp.ts:310`). Whole-stamp skips the version/changelog/`stamp_paths` stages and keeps the journal, the allowed-output set, pre/postimage and `MERGE_CONDITION`; every post-command output is diffed against an immutable pre-command tree, index or HEAD mutation by the tool is refused, and the authoritative version, declared mirrors and changelog heading are validated independently afterwards (exit-zero no-op at the old version fails; already-at-target is a legitimate no-op). `--dry-run` prints `STAGES: version=skipped(release_tool owns) …`. A pregate `stamp` check (`run --only stamp --range <gated-head>..HEAD --evidence <dir>|--gate-ahead <record>`) calls `buildReceipt` with an explicit evidence source and prints `STAMP_ONLY: yes|no` and `REGATE: needed|not needed <lanes>` from the receipt (`STAMP_NOT_ONLY` on `no`; `STAMP_EVIDENCE_MISSING` with the recovery when the bundle is absent); stamp-only equality and per-lane evidence reuse are separate rows. `classifyFile` (`lib/tree-receipt.ts:69`) gains one `migration-number` rule backed by raw tree-entry comparison, so mode and object-type changes are refused rather than read as empty hunks, and a failed or overflowed `git diff`, enumeration or blob read propagates as `incomplete`/non-reusable instead of an empty (passing) comparison (`lib/tree-receipt.ts:140`). `MIGRATION_COLLISION` compares candidate ids against the target base's current migrations plus a complete, paginated open-PR census (`gh pr list --json number,files` pages past 100 files and 30 PRs, bound to head SHAs, receipted as `pregate-migration-collision`), rechecks at the serialised merge position, and refuses `MIGRATION_CENSUS_INCOMPLETE` (never `pass`) on a failed page or without `gh`; semantic renumbering requires a regate. Fixtures: extend the tree-receipt tests with a gbrain-shaped fixture (`release-restamp.ts`, `since:` rows, two PRs naming v240, a collision in PR 31, an open-to-merged predecessor, different filenames sharing an id, a chmod-only change on a stamp path, a failed diff that may not print `STAMP_ONLY: yes`).
|
||||
|
||||
**D16. Sealed-output guard before push and in the hook, bound to the destination.** `[field]` Sealed rules live under `checks.sealed` in `.gstack/pregate.json` (`public`, `deny` rules for transcript paths, answer-text fences, seed files and machine-local prefixes, each with narrow per-rule `except` globs so documented placeholders pass while a sealed answer in the same file still fails; `allow` globs; `max_bytes`, default 2 MB `[target]`), reusing its schema, containment check and `gstack-pregate init`; an unconfigured check reports `not configured` and the ship receipt treats that as not-required, never passed. The rule grammar is anchored literals, globs and a fixed set of named detectors, or a regex compiled under a per-rule time budget that yields `SEALED: incomplete <file> rule=<name> (timeout)`; the sealed check reads its policy from head without `--allow-repo-commands` since it executes no command. `run --only sealed --range <a>..<b> | --staged` applies path denies to every outgoing blob before decoding, inspects actual bytes independent of diff attributes (NUL-bearing, `-diff`, UTF-16 content included), returns `incomplete` for non-exempt uninspectable content, and reports over-cap files as `SEALED: incomplete <file> (<bytes>)` with the fix line (`add "<path>" to allow, or raise max_bytes`); allow-globbed files print `SEALED: skipped <file> rule=allow`. Receipts are bound to the receiving remote, the outgoing refs and object ids, the policy hash and the scanner version; the scanned history excludes only commits established to exist at that destination, so a push to a new or unknown destination scans the full reachable history or is `incomplete`; staged-only evidence never authorises publication, and a receipt is stale once leaking history is added even when the final tree is identical. Refusals print `SEALED: <ref> <commit>:<blob> <path>:<line> rule=<name> status=current|historical (SEALED_CONTENT_REFUSED)` with no sealed snippet, and already-published history routes to the owner with non-destructive inspection guidance rather than an automatic force-push. `hook` mode reads the pre-push stdin refs through `lib/prepush-refs.ts`. `/ship` runs `--range origin/<base>..HEAD` before `git push`; `gstack-ship-receipt write` refuses `SEALED_RESULT_MISSING` without a current result when the check is configured. After B lands, `lib/coordinator-contract.ts` gains the sentence every lane inherits: "Before any push, run `gstack-pregate run --only sealed --range origin/<base>..HEAD`; a lane never pushes without a current `SEALED:` pass or `not configured` result, and pastes that line in its handoff after `GSTACK_RESULT`" (the field leak was a lane's direct push, not `/ship`; G6 is judged against that path). Fixtures: `pregate-sealed-refuses-transcript` drives the hook through stdin for a new branch carrying a per-cell transcript, a quoted sealed answer, a 20 MB `[fixture]` generated file and a `public: false` negative; a private origin with denied base-history content pushed to an empty public destination is refused; a pathological rule (`(a+)+$`) over a 1 MB `[fixture]` blob completes as `incomplete`; a clean receipt followed by add-then-delete transcript commits with an identical final tree is stale.
|
||||
|
||||
### Not in this wave
|
||||
|
||||
Scrubbing gbrain's history (URGENT above, owner's call); the gbrain paid-gate adapter (named deliverable, owner outside this plan); runner adapters for `manifests` beyond gstack's; snapshot baking itself (documented, not automated); Windows paths for the simulated host; the `mirror` restamp tier; `/eval-plan` tier 3; CODEOWNERS-driven anything; a Capy-native hook mechanism (the driver doc says the guard is not enforced, as today); crash injection after every revise step beyond the one `failAfter` test; migration-identity adapters beyond the policy glob.
|
||||
|
||||
### Validation
|
||||
|
||||
Every item: a failing test first on the simulated Capy host, then the fix, then the test passes; never a loosened assertion. Cheap gates before paid: `bun run typecheck`, `typecheck:test`, `gen:skill-docs --host all` clean, `gstack-regen check`, parity suite, every ratchet (module-size, state-root, spawnsync, troubleshooting-anchors, headless-artifacts, free-fixtures, egress-receipt-wiring, bun-spawn, harness-version), `format:cso:check`, credential scan; `bun run test:quick`; `gstack-pregate run --tier unit --range` once C13 lands (dogfood). Paid: the autoplan/ship/review workflow judges on changed prompts only. Acceptance, each a named test: `install-stale-1-91-12-self-upgrades`, `install-ok-then-check-ok` (positive and the no-network negative), `upgrade-refuses-divergence-keeps-bytes`, `self-upgrade-policy-matrix`, `autoplan-3-rev-validates` (B5, B6, B8 together), `gate-defaults-follow-agreement` (B9), `scope-doctor-status-description-is-not-ui` (B10), `driver-refuses-stale-checkout` (B13), `pregate-lane-done-manifest-row` (C13), `gate-price-hidden-synopsis` (C14), `tree-receipt-gbrain-shape` (D15), `pregate-sealed-refuses-transcript` (D16). Integrated acceptance after review fixes, generation and release metadata: freeze the tree, `bun run test` once on it, PR A's Docker smoke receipt on the gated SHA (reused through the tree receipt on the stamped tree), then the selected judges. Done means: all twelve pass on the simulated host; a 3-rev unattended autoplan run validates; a synthesised 1.91.12 checkout upgrades itself to current at session start; and the drive skill is regenerated from `scripts/capy/SKILL.md.tmpl` with the bootstrap from `scripts/capy/bootstrap.sh` (no promise of a command that does not exist; `--check` at session start, lazy install, `GSTACK_LAUNCH_FILE`, the trailer in a subagent's first report).
|
||||
|
||||
### Effort
|
||||
|
||||
| PR | New bins / libs / fixtures | Est. LOC `[estimated]` | Notes |
|
||||
|---|---|---|---|
|
||||
| A | 2 / 0 / 6 | 1,100–1,500 | bash upgrade lib shared with session-update; installation leases; host sim with synthesised origin; bootstrap, in-repo wrapper, drive skill template and pin tests |
|
||||
| B | 0 / 3 / 8 | 1,800–2,400 | `lib/autoplan-gate.ts` (215 lines), `lib/autoplan-run.ts`, `lib/autoplan-prompts.ts`, `lib/gate-list.ts`, `lib/autoplan-reconcile.ts`; new `lib/autoplan-revise.ts`, `lib/decision-ref.ts`, `lib/phase-inputs.ts` |
|
||||
| C | 0 / 4 / 7 | 1,800–2,400 | `lib/pregate/checks/{manifests,stamp,sealed}.ts`, `lib/prepush-refs.ts`, `release_tool_owns`, `--list --json`, eval-plan `--gate`, one tree-receipt rule, guard-list hook |
|
||||
|
||||
### Gate decisions
|
||||
|
||||
Resolved at gate rev 1 (all 17 reviewer challenges answered `b`): G1 → dependency chain A → B → C with one owner per shared file; G2 → bash `bin/gstack-upgrade` sharing session-update's stages; G3 → (b) plus the PATH-bearing launch line; G5 → one template rendering the Capy driver doc. Resolved at gate rev 2 (owner answered `d1b d2b d3b d4a d5b d6b d7b d8b`): the drive carries a logic-free bootstrap; `GATE_UNPRICED` is owner-answerable in release one with the adapter contract published; G8 → option (c), always current when online, floor refuses, stale note offline; `--attempt new` is dropped (d4 moot); transitive invalidation of dependent phases; installation-scoped leases; `manifests` is the gstack adapter only. Still the owner's, each a draft direction that stands until decided:
|
||||
|
||||
- **G4. A4.** (a) **Recommended:** lazy install at first workflow use from the wrapper plus the snapshot-baking doc. (b) Snapshot-baking doc only. Draft direction stands until the owner decides.
|
||||
- **G6. D16 enforcement.** (a) **Recommended:** the coordinator contract makes every lane run the sealed check before any push, `/ship` runs it too and the receipt refuses without it; the pre-push hook is opt-in via `hook install`. (b) Hook only. Draft direction stands until the owner decides.
|
||||
- **G7. The simulated host.** (a) **Recommended:** hermetic simulation in the free suite plus one Docker smoke in a dispatch lane, cleared only by a matching job receipt reused through the tree receipt. (b) Docker only. Draft direction stands until the owner decides.
|
||||
|
||||
## Review record
|
||||
|
||||
### Gate rev 1 (run `fixwave-run-20261012204516`, snapshot `6f25ac67…`, order ceo, dx, eng; scope ui=no dx=yes source=flags)
|
||||
|
||||
CEO: native 18 findings (1 Critical, 8 High, 8 Medium, 1 Low), outside 14 (`new=14 native_only=18`). DX: native 15, outside 8. Eng: native 17 (5 High, 10 Medium, 2 Low), outside 12 (10 High, 2 Medium). Seventeen user challenges rendered as gate items d1–d17; the owner answered every one `b` and `p1b` (`REOPENED: ceo,dx,eng`); the Eng-stated reconciliations were adopted: `all` leaves agreed `uc` items pending and reopen keys on the chosen option (CEO native-5 + outside-6 against B9), and the single upgrade implementation is bash sharing session-update's stages (Eng native-1 over DX native-1's `lib/upgrade.ts`).
|
||||
|
||||
### Gate rev 2 (plan revision 2, snapshot `472342d2…`, same order and scope)
|
||||
|
||||
CEO: native 10 findings (4 High, 5 Medium, 1 Low), outside 4 (3 High, 1 Medium). DX: native 17 (4 High, 8 Medium, 5 Low), outside 7 (3 High, 4 Medium); the driver omitted the CEO consensus from both DX prompts (B10 defect, reproduced). Eng: native 22 (3 High, 10 Medium, 8 Low, 1 Informational), outside 9 (6 High, 3 Medium). Eight gate items d1–d8; the owner answered `d1b d2b d3b d4a d5b d6b d7b d8b p1b` (d4 moot once d5 dropped `--attempt new`). All non-challenge High and Medium findings were folded into revision 3 as the author's own decisions; they are listed in the accepted blocks below. The run itself reproduced B5: `gstack-artifact validate run.json` reports 9 `ARTIFACT_DUPLICATE_ID` errors after this revision.
|
||||
|
||||
<!-- autoplan-accepted:ceo -->
|
||||
- Lanes form a dependency chain A → B → C with one owner per shared file, pre-assigned result codes and anchors, and C's "after B lands" shared-file list; verification: the ownership table and no two PRs touching an owned file.
|
||||
- The staleness authority lives outside the checkout; the drive carries a logic-free bootstrap that fetches and executes the fetched tree's in-repo wrapper; policy order floor → pin → off switch → fetch-under-deadline → always current when online, stale note offline; verification: `install-stale-1-91-12-self-upgrades` and `self-upgrade-policy-matrix`.
|
||||
- The runtime row is honest to `command -v bun`; `GSTACK_LAUNCH` carries PATH and a parsed `launch.env`; the installer's final check runs in a fresh environment; verification: `install-ok-then-check-ok` and its negative.
|
||||
- `all` never flips the owner's stated direction; agreed items stay pending until answered by token; reopen keys on the chosen option; answers carry `answered_by`; verification: `gate-defaults-follow-agreement`.
|
||||
- No `--record` and no `--attempt new`; `submit --result` is extended with `--extract` and `--outcome`; packaging choices are visible auto items; dedupe requires an explicit prior-item link; verification: the 3-rev fixture.
|
||||
- Gate pricing accepts only declared adapters with complete structured plans; `GATE_UNPRICED` is owner-answerable in release one and the adapter contract is published; figures are classified and only decision-driving measurements need receipts; verification: `gate-price-hidden-synopsis` and the plan-reality fixtures.
|
||||
- The Capy driver runs the doctor on its first `next` and refuses a `fail` trailer; the coordinator contract makes every lane run the sealed check before any push; verification: `driver-refuses-stale-checkout` and the contract pin test.
|
||||
- Sealed scanning covers every newly reachable blob of the outgoing refs, bound to the destination; the Docker obligation is reused through the tree receipt; the existing disclosure is triaged separately (URGENT block).
|
||||
- The Claude upgrade skill delegates to `bin/gstack-upgrade` and loses its reset fallback; sealed rules live under `checks.sealed` in `pregate.json`.
|
||||
- Integrated acceptance: frozen tree, one final `bun run test`, PR A's Docker receipt on the gated SHA, selected judges.
|
||||
<!-- /autoplan-accepted:ceo -->
|
||||
|
||||
<!-- autoplan-accepted:dx -->
|
||||
- `scripts/capy/bootstrap.sh`, `scripts/capy/install.sh` and `scripts/capy/SKILL.md.tmpl` are in-repo with pin tests; the wrapper owns the first hop and lazy install, prints every trailer on stdout, and pins its anchors; `gstack-skill-start` prints `SELF_UPGRADE:` and its unattended prose matches; a `--to` pin skips rather than refuses and one off switch exists.
|
||||
- Every failure line carries a result code and anchor; the per-PR code lists above are the enumeration.
|
||||
- The runtime FAIL row names the off-PATH verified Bun with the `launch.env` fix; `gstack-doctor --check` prints `GSTACK_LAUNCH_FILE:` itself; JSON carries `path_prepend` without shell syntax.
|
||||
- `answer` prints `REVISE:` before `NEXT:` and `next` refuses `PLAN_UNCHANGED_SINCE_GATE`; `GATE_PENDING` prints exact tokens; incremental answers compose; `--extract` has a heading grammar; the plan-side numbers scan runs before dispatch and keeps `unreceipted=<row ids>`.
|
||||
- `release_tool` stays the one key with `release_tool_owns`; one `--range` grammar with `POLICY_FROM`/`TIER` headers; `hook install|status|remove` composed through the guard-list wrapper; `autoplan.json` has init and schema; the sealed cap has a stated default, a `skipped` verdict and a fix line; the stacked-stamp regate count is stated.
|
||||
- One Capy driver template renders `docs/capy-driver.md`; every later phase receives predecessor consensus; `--json` returns one versioned object on every path; a host-subagent failure has a terminal outcome command; revise refuses while attempts are open and can replace or reset scope flags.
|
||||
<!-- /autoplan-accepted:dx -->
|
||||
|
||||
<!-- autoplan-accepted:eng -->
|
||||
- `bin/gstack-upgrade` is bash over a sourced lib extracted from session-update; conflict recovery preserves bytes and stash; `--to` distinguishes tags from commits and verifies the target; the busy predicate is a live installation lease outside the caller's ancestry plus the installer flock; the wrapper prepends the supported Bun before merge, setup and check.
|
||||
- The stale checkout is synthesised from a local bare origin cloned depth-1 under `GIT_ALLOW_PROTOCOL=file` and a dead proxy; simulator environments are allowlisted and sentinel-tested; fixtures share one upgraded checkout where the scenario allows.
|
||||
- lib spawns and generated hooks use `process.execPath`; `test/bun-spawn-ratchet.test.ts` guards it.
|
||||
- Finding rows carry `attempt`; cross-revision references are attempt-qualified; decision references go through one revision-aware constructor; `Scope.ui` is `yes|no|unknown` and revise re-detects scope with `REVISE_SCOPE_CHANGED`.
|
||||
- `revise` replaces Implementation bytes only, checks obligations, performs a journaled generation transition with idempotent resume, and invalidates transitive consumers of changed inputs; `PLAN_SHAPE_PARTIAL` replaces the scaffold idea.
|
||||
- The B9 agreement rule is mechanical (both `user_challenge` and resolution token overlap, or an `agreement` override).
|
||||
- The numbers row scopes to unit-bearing figures with `[measured]`/`[estimated]`/`[target]`/`[fixture]` tags and `file:line` receipts; reality and price checks run as a persisted validation boundary.
|
||||
- `manifests` imports the shard runner's own inventories as the gstack adapter and reports `not configured` elsewhere; `goldens` is dropped for the existing `regen`; tasks gain `checks[]`; receipts are versioned by purpose and selection; the Docker obligation clears only on a matching job receipt.
|
||||
- `--gate` consumes the new `--list --json` plans; quotes are input-bound; no `GSTACK_EVAL_DRY_RUN`.
|
||||
- The stamp-only proof is the tree receipt plus one `migration-number` rule with raw tree-entry comparison; failed comparisons are `incomplete`, never stamp-only; the stamp check takes an explicit evidence source; delegated stamps are diffed against an immutable pre-tree with independent postconditions; the migration census is paginated, SHA-bound and refuses when incomplete.
|
||||
- The sealed check inspects every outgoing blob's bytes before decoding, restricts its rule grammar or budgets regex time, reads policy without `--allow-repo-commands`, binds receipts to destination, refs, objects, policy and scanner version, and prints object identities without snippets; the hook shares `lib/prepush-refs.ts`.
|
||||
- Egress sites are receipted under named labels with the single bootstrap exemption; `review-record.md` links bound records by sha; the bootstrap is fail-open on the egress lib exactly like session-update.
|
||||
<!-- /autoplan-accepted:eng -->
|
||||
@@ -0,0 +1,207 @@
|
||||
<!-- gstack-autoplan-scope: --no-ui --developer-tool -->
|
||||
# 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 "<install>" && git checkout -- 'SKILL.md' '*/SKILL.md' '*/sections/*.md' && git fetch origin <branch> && git checkout --detach FETCH_HEAD && ./setup`, and to go back: `cd "<install>" && 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 <dir> <rev>`, 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 <dir> 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/<oid>`), 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 '<dir>' show --stat <ref>`); restoring is guided out of the live tree (`git -C '<dir>' diff <target> <ref> > ~/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=<tmp>/index; git read-tree <incoming>; git -c core.symlinks=false checkout-index -a --prefix=<tmp>/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 "<install>" && 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 `<resolved bun> 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 <script>`, falling back to `Bun.which('bun')` when the executable is not Bun (compiled binaries); the tripwire stops compiled entries importing the module unguarded. One fixture table (override, alias, scoop shim, per-user Git, portable Git, WSL rejection, missing) drives both the TypeScript resolver and `setup.ps1` (pwsh on Linux CI with injected paths). Behavior change listed: a non-MSYS PATH bash is no longer used.
|
||||
- One `wtreeFingerprint({cwd, timeoutMs})` that always sets `GIT_OPTIONAL_LOCKS=0`, accepts 40–64 hex (so SHA-256 repos get a fingerprint in `gstack-evidence`), and returns `{tree}|{error}`, distinguishing a launch/discovery failure from a helper exit. Consumers migrated in this PR, each keeping its fail direction with an error-path test: `bin/gstack-gate-diff:157`, `lib/regen.ts:147`, `lib/restamp.ts:512`, `bin/gstack-evidence:200`, plus artifact tree-string fields and the before-check, after-check, publication and no-scratch regen paths (sync and async). The pregate consumers (`lib/pregate/context.ts:66,97`, `lib/pregate/index.ts:120`) are files #3190 changes, so they move in PR 5 (item 10b) through a compatibility wrapper this PR keeps.
|
||||
- Tests that fail first: an invocation unit test with injected `platform:'win32'` and `exists` (per-user Git, portable Git, override, WSL rejection, missing bash refusal); a source tripwire that no `lib/`, `bin/*.ts` or `hosts/**` file spawns a shebang bin by path or calls `spawnSync('bash'`; a new `windows-pregate-e2e.yml` job runs `gstack-restamp --dry-run` and the regen/evidence paths under pinned Bun and Git for Windows (`gstack-pregate run` joins it in PR 5). Moving pregate tests back into the Windows curation is a PR 5 edit (`scripts/lib/windows-curation.ts` is a #3190 file).
|
||||
|
||||
### PR 3 — Maintenance (Medium and Low)
|
||||
|
||||
**8. Pregate misses tests in Next.js route groups and tsconfig path aliases — adopt #3176 with changes (import-graph half in PR 3; manifest-parser half in PR 2, which owns `lane-selection.ts`).** #3176 (vocodestudios): `test/pregate-import-graph-paths.test.ts` 2 fail on main → 4 pass. Changes: aliases resolve exact match first, then the most specific wildcard, then target fallback; when no single target is established, union the plausible dependencies (the graph's "uncertainty selects more tests, never fewer" contract); relative `extends` followed to a bounded depth (the limit named in no-lane explain output); trailing commas dropped inside the tokenizer, not by a global regex. `lib/pregate/lane-selection.ts:42`'s manifest parser keeps framework paths intact (today `app/(shop)/[id]/page.test.ts` becomes `page.test.ts`, which can erase a platform lane's receipt obligation at `:91-94` and fall back to a local run at `:100`). Reviewer tests: overlapping aliases with both targets present in both key orders; exact vs wildcard; a callback-level fixture where a bracketed route test in a platform-only lane keeps its remote receipt obligation. `PREGATE_NO_LANE` explain output prints the unresolvable specifiers seen in test files, with the hint to declare dependencies or report the resolver gap.
|
||||
|
||||
**15. Terminal-agent shutdown can orphan HUP-immune PTY descendants — adopt #3148 with changes (fixes #3147).** On main the PTY hangup already kills children that honour SIGHUP; the PR's test fails on main only because it checks the instant the agent exits. Changes: record a spawn-time ownership identity for the PTY session/process group that stays valid after the leader exits; group escalation is not cancelled when the direct child exits; after the grace period the proven-owned group is terminated, so long-lived HUP-immune descendants are reaped, not just the direct child; spawn with `process.execPath`; refuse new connections during the shutdown grace. Reviewer tests: a HUP-immune grandchild through an actual terminal-agent shutdown is dead after the grace period plus one second; an early-exiting leader with a surviving descendant; a foreign group and a reused PID are never signalled.
|
||||
|
||||
**19. skill-end pushes before writing its own completion event — fix #3198 (Kkkkkuro's patch).** Move the completion `gstack-timeline-log` call above the final drain; `gstack-timeline-log` waits for its enqueue (one config read and a spool write; exits early when sync is off or unattended). Every consent, secret-scan and queue-lock gate is kept. Fix the stale "local-only, never sent anywhere" comment. Test from the issue (2/7 → 7/7).
|
||||
|
||||
**20. `gstack-config` root-path prefix compare and unquoted recovery commands — adopt #3186.** Its test fails 2 on main. Reviewer test executes the printed recovery command against a root path containing a space. The bash 3.2 case is [unverified] on Linux.
|
||||
|
||||
**21. gbrain-sync appends a pin line git already ignores — adopt #3178 with changes.** Changes: test `spawnSync` timeouts (lines 89, 100); the check is `git -c core.excludesFile= check-ignore -v` and accepts only a tracked `.gitignore` as the matching source (not global excludes, not `.git/info/exclude`); fixtures isolate `HOME` and `XDG_CONFIG_HOME`.
|
||||
|
||||
**22. Test hygiene — adopt #3163 (fixes #3162) and #3156 (fixes #2967).** #3163: ship-measure CLI tests get a private HOME (reproduced: `bun run test` leaves `ship-measure-cli-*` dirs in the real `~/.gstack/projects`). #3156: fixture git ignores inherited hooks, using `core.hooksPath=/dev/null` instead of `NUL` (Git for Windows rejects `NUL`); its load-only explanation is unproven, the isolation is right regardless.
|
||||
|
||||
**23. Dependencies and small docs.** Adopt dependabot #3175 and update the upload-artifact SHA pin at `test/model-policy-workflow.test.ts:542` (intentional: the pin tracks the workflow). #3145 (sonukapoor, declares `sharp`, clears two transitive advisories): adopted only with a non-skipping, isolated classifier model-load and inference smoke on the changed tree that asserts non-degraded output; if that smoke cannot run, only the `sharp` declaration lands and the inference-runtime bump is deferred. Close #2923; `.github/dependabot.yml` ignores only `semver-minor` and `semver-major` updates for `playwright`/`playwright-core` (patch and security updates still flow), and the 1.64 migration (re-key the windowsHide patch, the Dia/cookie qualification pins, the arm64 artifact name) is filed with owner Garry and revisit date 2026-11-15. Defer #3174 (agent-sdk 0.3: 12 new test type errors, breaks `overlay-sdk-cancel-eof` cancellation 2/2) with the same owner and date. #3169: drop the stale skill lists in README and CLAUDE.md, keep the `/browse` rule; a free test fails if any skill list present differs from the `*/SKILL.md` names; the matching doctor warning for pasted skill lists ships in PR 5 (item D). #2753: reword the OSV image-size ledger (2.0.3 fixes it; html-to-docx pins `^1.0.0`), keep its 2026-11-30 expiry. #1796: reimplement only `GSTACK_IOS_LAUNCH_ENV` in both launch paths: a JSON object of string to string; invalid JSON, non-string values, or keys colliding with the debug bridge's own variables refuse with a result code instead of launching without it; documented with an example in the ios-qa how-to; credit Bmathews721, close the PR.
|
||||
|
||||
### PR 4 — Prompt changes (Medium; eval-gated)
|
||||
|
||||
**17. The Claude outside-voice fallback depends on the removed TaskOutput tool — fix #3182.** Claude Code 2.1.277 removed TaskOutput; the native fallback in plan reviews (`codexPlanBoundedWait`, `scripts/resolvers/outside-voice-steps.ts:466`) preflights it, so users without the Codex CLI never get a second voice. Reading the background output file is not an option (it is the raw transcript, which the skill rejects).
|
||||
- Step 1, a free spike on Claude Code ≥2.1.277: record which completion and cancellation primitives exist (background completion notification and its subagent-result header, foreground Agent result, TaskStop) and measure native reviewer durations from captured runs.
|
||||
- Step 2: if the host can enforce a deadline (background dispatch, completion notification, stop on deadline), implement that, keeping "only an identifiable complete final report counts" (accounting for the 2.1.277 subagent-result header) and "no late-result credit". If it cannot, the item stops and returns to Garry as a decision: an honestly unbounded foreground reviewer, or "outside voice unavailable" with its cause. No unenforced prompt-only bound ships.
|
||||
- The unavailable line names the missing capability (Codex CLI missing or unauthenticated; Plan agent type not offered) and the action, in the output and the persisted result.
|
||||
- Pins that change (intentional): `test/run-in-background-guidance.test.ts:189-193,272-286`, `test/outside-voice-preflight.test.ts`, `test/plan-review-cases.test.ts`, `test/skill-ceo-section-ordering.test.ts`. Free check first: rendered review sections do not require TaskOutput. Then its selected judge and the affected paid outside-voice case, priced through `/eval-plan` and approved before spend.
|
||||
|
||||
**14. plan-eng-review ignores the CEO plan — adopt #3159 with changes (fixes #280).** #3159 (szsunyuan) adds `bin/gstack-ceo-plan-find` and reads the CEO plan in the Design Doc Check; CI green. It edits a plan-skill template, which selects paid gate cases and a judge, so it lives in this eval-gated PR. Changes: the finder's stdout stays a bare path or empty (the API `$DESIGN`-style callers rely on); provenance prints as one separate human line, `CEO plan found (from branch <b>, not <current>): <path>`, only for the cross-branch fallback; the skill lets an interactive user skip a plan from another branch, and an unattended run uses it with the printed disclosure (tested). The design-doc finder's matching disclosure is deferred (it changes the shared discovery block in four skills plus spec-review). The PR raises the plan-eng-review prompt-size ceiling from 1.2170 to 1.2196 [measured: triage-B, #3159 diff]; that is an owner decision (gate item); if declined, the template is trimmed to fit. Pins that change are named in the PR; its selected gate cases and judge are priced with item 17's.
|
||||
|
||||
### PR 5 — After #3190
|
||||
|
||||
**9. Pregate never recognises `.bats` tests — adopt #3177 (fixes #3161).** In bats repos `lanes` always fails. #3177 (harjothkhara): CI green, its bats tests pass; one-line conflict with #3190 in `lanes.ts`, rebased. Reviewer test: a bats file in a nested directory selected end to end.
|
||||
|
||||
**10b. The rest of #3197.** #3190's new `gstack-config` spawn routes through `lib/script-invocation.ts`; `bin/gstack-pregate:120` stops reporting a wtree failure as "origin/<base> is not a resolvable commit": the pregate consumers (`lib/pregate/context.ts:66,97`, `lib/pregate/index.ts:120`) migrate to item 10a's `wtreeFingerprint`; a launch/discovery failure is the new result code `WTREE_UNAVAILABLE`, whose detail lists each bash candidate tried with its rejection reason and whose fix names `GSTACK_GIT_BASH` (quoted example); a helper exit keeps a cause-specific message (for example a TMPDIR repair). Under `--json`, every context refusal (unavailable base, `WTREE_UNAVAILABLE`) is one envelope on stdout (`ok:false`, `status:'refused'`, `code`, `detail`, `fix`, `docs`) with exit 3; `GSTACK_RESULT` goes to stderr under `--json`, documented in `--help` and the troubleshooting entry. `gstack-pregate run` joins the `windows-pregate-e2e` job, and the pregate tests excluded from the Windows curation for the "shebang via CreateProcess" reason move back where they now pass. Tests: a wtree failure does not print "is not a resolvable commit" and ends with `(WTREE_UNAVAILABLE)`; a helper-exit failure keeps its own fix; both refusals through the CLI with `--json` parse as JSON with one discriminator shared with the success object.
|
||||
|
||||
**D. Doctor rows.** `gstack-doctor` lists `refs/gstack/rescue/*` with dates and the inspect and delete commands (item 2); warns when a gstack block in `~/.claude/CLAUDE.md` or a project CLAUDE.md enumerates skills and prints the short replacement (#3169); prints the resolved Windows bash and the variable that chose it (item 10a); and its Windows fix line names the held-install recovery forms of item 3 instead of `bin/gstack-upgrade` until #3190's thread fixes the path-spelling refusal.
|
||||
|
||||
**24. Generated SKILL.md size guard — #3199.** A free test fails when any generated SKILL.md for any host exceeds 100,000 chars, printing host, skill, actual and limit, and the fix. Current offenders (Claude `design-review` 136.8k [measured: triage-D], eight Hermes skills) are allowlisted at their current size, so growth fails and shrinkage requires lowering the pin. The preamble hoist is deferred (owner Garry, revisit 2026-11-15).
|
||||
|
||||
**25. Plan-mode section on hosts that have no plan mode — #3168.** The resolver honours the existing, unread `capabilities.planMode` host setting so planMode:false hosts (Hermes) omit the plan-mode section; goldens regenerate after #3190's.
|
||||
|
||||
### PR 6 — Census repair
|
||||
|
||||
**18. The weekly census has one red case and one headroom breach — #3153.** Its own PR so that a decision on item 17 cannot hold it; folded into PR 5 instead if the repair edits `ship/` (a #3190 area). The periodic tiers were green on Oct 12; the red is the gate-census case `ship-exploratory-late-input` probing before reading its sections (the same failure as the Oct 7 capture) and `review-army-simplification` over its headroom cap. Read the Oct 12 evidence and the free Oct 19 census first; a repair to `ship/` prose runs `gstack-lane-check` against #3190's `ship/` templates and sections before editing. Paid reruns only for the affected cases, priced and approved first, never retried to change a verdict.
|
||||
|
||||
### Not in this wave (reason, owner, revisit)
|
||||
|
||||
- **#3193** Dart/Flutter pregate lanes (High for Flutter users): ≈830 lines, conflicts with #3190 in 5 files, mixes in a trust-model change (user-owned pregate policy plus a `local` key that loosens the unrun-Windows-lane guard). Ask the author to rebase onto #3190 and split into recognition + import graph, run groups, and the policy change. Owner: Garry (policy decision); revisit when the split lands.
|
||||
- **#3192** HydraDB (feature): depends on unmerged gbrain#6695 by the same author; changes every gbrain user's search path. Owner: Garry; revisit when gbrain#6695 merges.
|
||||
- **#3129** drop "When to invoke": owner decision, rebuild after #3190. Owner: Garry; 2026-10-29.
|
||||
- **#3132** ios-qa session resume (Garry's draft): real-iPhone verification. Owner: Garry.
|
||||
- **#2595/#2124** Windows code signing: needs a signing account. Owner: Garry; 2026-11-15. #2124 closes as a duplicate of #2595.
|
||||
- **Host drift canary:** extend the daily canary with a tool-inventory assertion (tools and subagent types gstack prompts require) and a compaction journal-shape capture, since TaskOutput's removal (2.1.277) and the compaction shape (2.1.300) are one recurring class. Owner: Garry; 2026-10-29.
|
||||
- **Design-doc finder cross-branch disclosure** (the second half of item 14): changes the shared discovery block in four skills plus spec-review. Owner: Garry; 2026-11-15.
|
||||
- **#340** wireframe persistence: needs a downstream consumer in plan review (an eval-gated prompt change). Owner: Garry; 2026-11-15.
|
||||
- **#2993, #3183, #3091, #2120, #542, #1319, #2623, #3157, #3158, #3150, #356, #1060, #1048, #2118, #1791, #1651, #2378, #1022, #954, #661, #2920, #2754:** product calls, eval-gated prompt changes, deprecated install modes, external owners, or needs-info. Each gets a comment with its reason; owner Garry unless the triage report names an external owner (#2920 → gbrain); revisit 2026-11-15.
|
||||
- **Closes, posted by a dated follow-up this thread registers for 2026-10-22, each with its file:line evidence:** #2709, #931, #407, #886, #1957, #2756 (also drops TODOS.md:47/:985), #2365 (refresh the past "sunsets Sept 4" wording), and, once their confirmation windows end that day, #2637, #2295, #2151, #1775, #1674, #1731, #1967, #494, #528, #2847, #1076.
|
||||
|
||||
### Upgrade notes and docs (every PR)
|
||||
|
||||
Every printed or published command says where it runs (Claude Code, Git Bash, or Windows PowerShell 5.1) and quotes its paths; PowerShell users get a form that works in PowerShell (no `&&`, `.\setup.ps1` rather than the extensionless script), exercised under `powershell.exe` in item W. Each user-visible item adds or extends a `docs/troubleshooting.md` entry headed by its exact error text (#3184 hold, #3130 EOF, #3131 exit 127, #3185 `INVALID_OUTPUT`, #3187 argv0, #3167 "No active page", `BUN_VERSION_UNREADABLE`, the rescue-ref banner, `WTREE_UNAVAILABLE`): meaning, one copy-paste fix, "fixed in vX" (`test/troubleshooting-anchors.test.ts`). The CHANGELOG has an "If you…" block, one line per cohort (Windows-held: the recovery command; local edits in the install: the rescue ref; Cursor: which folders are removed; hooks: the new registration root; /cso: the quoted placeholder), mirrored in each PR's `## Behavior changes`.
|
||||
|
||||
### Verification
|
||||
|
||||
Per lane: the failing-first tests above; `bun run typecheck`; `typecheck:test` ratchet; `test/spawnsync-timeout-tripwire.test.ts`; `test/module-size-ratchet.test.ts`; template edits run `gen:skill-docs`, `test/parity-suite.test.ts` and the context-budget ratchet; `gstack-pregate run --allow-repo-commands`. Integrator per PR: the SHA-bound merge receipt with #3190 (one merged tree per pair, freshness and focused tests run once), `bun run test` once on the frozen tree on Ubicloud, secret scan, CHANGELOG with contributor credit, the native Windows jobs green for PRs 1b and 2. Paid spend only for PR 4 (items 14 and 17) and PR 6 (item 18's affected cases), each priced through `/eval-plan` and approved before it starts.
|
||||
|
||||
## Review record
|
||||
|
||||
Revision 1 (snapshot `eed7849f`) was reviewed by CEO, DX and Eng, each with Claude Opus 5.5 (native) and GPT-6.1 Sol (outside); 60 findings (CEO 16+6, DX 15+5, Eng 13+5), no Critical. Every finding was accepted into revision 2 except where noted:
|
||||
|
||||
- CEO: delivery split into five independently acceptable PRs with recovery first (native F1, F4; outside F6); recovery path for already-stuck installs and the `bin/gstack-upgrade` URGENT handoff (native F2, outside F1, confirmed); item 1 maps to the existing `unrecognized_shape` (native F3); native Windows CI coverage, item W (native F5, the one user challenge; outside F3); host-drift canary recorded as a dated deferral (native F6); item 17 needs an enforced bound or an owner decision (native F7, outside F5, confirmed); dependabot ignore scoped to minor/major (native F8); #3197's main sites move to PR 2 on main (native F9); item 18 premise corrected and owned (native F10); owners and dates for deferrals and a dated close follow-up (native F11); tar-free extraction plus symlink tripwire (native F12); reviewer-written tests for adopted PRs (native F13); item 11 promoted (native F14); quoting limited to JSON streams (native F15); no fork CI approval (native F16); manifest-parser receipt preservation (outside F2); classifier smoke or split for #3145 (outside F4).
|
||||
- DX: banner cases and anchors per new hold reason (native F1); visible rescue-ref notice (native F2, outside F2); troubleshooting entries and the CHANGELOG "If you…" block (native F3, F4); one finder output format (native F5); `BUN_VERSION_UNREADABLE` (native F6); actionable exit-127 text (native F7); git shim pinned instead of an environment note (native F8); reporter confirmation before merge (native F9); size-guard output and ratchet (native F10); empty-argument refusal in setup.ps1 (native F11); doctor warning for pasted skill lists (native F12); unavailable line names cause and action (native F13); `WTREE_UNAVAILABLE` fix clause (native F14); unresolved specifiers in no-lane output (native F15); git.exe-relative bash discovery (outside F1); JSON refusal envelope (outside F3); quoted recovery commands executed in tests (outside F4); #340 deferred until it has a consumer (outside F5, resolved by deferral instead of adding a prompt change).
|
||||
- Eng: one bash resolver (native F1); shared `gstack-ff-update` bin (native F2); fingerprint consumer inventory (native F3, outside F5); #3181 tripwire and helper test (native F4); complete-token JSON quoting (native F5, outside F2); literal-safe extraction, superseded by tar-free extraction (native F6); flattened stderr (native F7); status/stop bypass recovery (native F8); merged-tree checks in the receipt (native F9); `.git/info/exclude` excluded (native F10); `extends` and tokenizer comma handling (native F11); long-journal performance test (native F12); one version table (native F13); stash-oid rescue refs (outside F1); alias precedence (outside F3); process-group reaping (outside F4).
|
||||
|
||||
Revision 2 (snapshot `d0628720`) was reviewed by the same six voices; 37 findings (CEO 10+3, DX 9+4, Eng 8+3), no Critical, no user challenge. All accepted into revision 3:
|
||||
|
||||
- CEO: PRs 1a/1b split, real dependencies stated, `setup` and `lane-selection.ts` each given one PR, PR 2 gets its own Windows job file, the coordinator exception covers exactly the append-only set with merge-and-restamp after each sibling merge (native F1, F4; outside F3); #3190-shared files the revision added (doctor, curation, `free-tests.yml`, pregate `context.ts`/`index.ts`) moved to PR 5 (native F2; outside F3); held-install recovery runs the new `gstack-ff-update` fetched from the new revision and covers clean, dirty, unmerged and prefix-mode installs (native F3; outside F1); scoop-safe bash discovery via `git --exec-path` and MSYS-verified PATH bash (native F5); reporter trial after review, exact detached commands, 48-hour evidence not a gate (native F6); item 18 its own PR (native F7); `spawn-bin.ts` path (native F8); Oct 29 follow-up registered (native F9); whole-stdout JSON tokenizing (native F10); private index bound to both extraction commands (outside F2).
|
||||
- DX: commands labelled by shell with a PowerShell form (native F1); `WTREE_UNAVAILABLE` lists candidates and names `GSTACK_GIT_BASH`, separate from helper-exit failures (native F2; outside F2); one canonical override name (native F3); finder stdout stays a bare path, design-doc half deferred (native F4; outside F3); rescue refs repeat daily and are listed by the doctor (native F5); `ok:false` envelope and `GSTACK_RESULT` location (native F6); `GSTACK_IOS_LAUNCH_ENV` format and refusal (native F7); one echo sanitizer (native F8); unattended default for a cross-branch CEO plan (native F9); safe inspect-first rescue guidance (outside F1); item 14 moved to the eval-gated PR (outside F4).
|
||||
- Eng: install-scoped lock in `gstack-ff-update` (native F1; outside F1); working-tree capture and compare before reset protects late writes (outside F1); `unrecognized_shape` only for compaction-explained components with negative controls (native F2); spawn-free cached hook resolution (native F3); a non-vacuous item W harness (native F4); token-level, NDJSON-tolerant JSON quoting that withholds on cross-token spans (native F5); `Bun.which` fallback for compiled binaries (native F6); one merged tree per pair (native F7); one bash-discovery fixture table for TypeScript and `setup.ps1` (native F8); PTY group ownership survives leader exit (outside F2); scan-stdout format propagated through the real capture path (outside F3).
|
||||
|
||||
|
||||
## Build corrections from the revision-3 review (binding on every lane)
|
||||
|
||||
Revision 3 (snapshot `5d243037`) was reviewed by the same six voices: 23 findings (CEO 6+3, DX 6+1, Eng 5+2), one High, no Critical, no user challenge; every revision-2 finding was confirmed resolved. None changes scope or direction, so they are applied as lane tasks rather than another review round:
|
||||
|
||||
1. **Rescue export must not reverse upstream fixes (DX native F1, High; DX outside F1).** The rescue commit's parent is the pre-update HEAD (its real baseline); the banner prints `git -C '<dir>' diff <ref>^ <ref> > ~/gstack-local-edits.patch` (the user's delta only) and suggests `git apply -3` in a scratch checkout. Already-unmerged captures are labelled "needs manual resolution". Test: the patch touches only user-edited paths, and an unrelated upstream fix survives restoring the user's edit.
|
||||
2. **The original index is preserved too (Eng outside F2).** The rescue keeps the staged state as a second parent or ref, separate from the working tree, without touching the real index while building it. Tests: staged-only changes; differing staged and unstaged versions of one file.
|
||||
3. **The lock path is absolute, with a heartbeat (Eng native F1, outside F1).** `git -C <dir> rev-parse --path-format=absolute --git-common-dir`; the 300 s heartbeat with #3191's reaping, or reclaim only when the holder's PID is dead. Tests: two actors in different working directories and state roots, including a linked worktree; no lock dir appears in the user's project; a holder that outlasts the TTL.
|
||||
4. **Move with a two-way read-tree, not reset (Eng native F3).** `git read-tree -m -u <captured> <target>` on the private index, so git refuses to overwrite modified or untracked files; the refusal maps to `autostash-unrecoverable:`.
|
||||
5. **Explicit rescue identity (Eng native F4).** Rescue commits use scoped `GIT_AUTHOR_*`/`GIT_COMMITTER_*` (`gstack rescue <gstack@localhost>`); tested with `HOME`/`XDG_CONFIG_HOME` isolated and `GIT_CONFIG_NOSYSTEM=1`.
|
||||
6. **`gstack-ff-update` is self-contained (CEO native F3; CEO outside F2).** No sourcing; lock and sanitize logic inlined and pinned to the shared twins by a test. The recovery forms fetch and run it inside a uniquely created private temp directory (`mktemp -d`, checked, cleaned up), never a fixed `$TMPDIR` path. Free Linux test: copy the bin outside any checkout and recover a v1.91.101.0 install; a pre-existing symlink and concurrent invocations never clobber or share a script.
|
||||
7. **Literal PowerShell 5.1 forms (DX native F2; CEO native F4).** Recovery, trial and go-back each get a literal PowerShell form: locate `bash.exe` from `git --exec-path`, a `-c` string with no double quotes (item 4's pattern), all redirection inside bash, finishing with `.\setup.ps1`. The go-back command (both shells) discards the regenerable footprint first. Item W runs each form under `powershell.exe` on a prefix-mode fixture.
|
||||
8. **Item W ordering (Eng native F2).** The bare origin's `main` is PR HEAD for assertions 1 and 2 (`connect-chrome` already reproduces the hold); the synthetic commit (a symlink sorted before its target) is pushed only after recovery, then `UPDATED` is asserted for it.
|
||||
9. **How to set the bash override (DX native F3, F5).** Shell-labelled persistent recipes: a PowerShell user environment variable plus a Claude Code restart, or the `env` block of `~/.claude/settings.json` (a Git Bash export reaches only that shell). The override bypasses the cache and is never cached. `GSTACK_GIT_BASH` wins when both names are set; a warning prints once when they differ (row added to the shared fixture table).
|
||||
10. **Hook bash cache hygiene (Eng native F5).** Written by temp file plus rename; the cached path is stat-checked before use and re-resolved if missing.
|
||||
11. **Banner carries the delete command (DX native F6).** `git -C '<dir>' update-ref -d <ref>`, shell-quoted and executed in the banner tests.
|
||||
12. **Result codes and cohorts (DX native F4).** Each new refusal gets a result code and a troubleshooting entry under its exact text: the shared "no bash found" refusal, the `GSTACK_IOS_LAUNCH_ENV` refusal (`IOS_LAUNCH_ENV_INVALID`), `setup.ps1`'s empty-argument refusal, item 7's browse recovery error, item 17's unavailable line. CHANGELOG "If you…" adds zsh users now held on an old Bun, users of a non-MSYS PATH bash, and `GSTACK_BASH` users.
|
||||
13. **Ownership holes (CEO native F1; CEO outside F3).** `bin/gstack-config` moves from Lane B to Lane D (item 20). #3175 lands in PR 3 without its `free-tests.yml` hunk, re-bumped in PR 5. `test/helpers/carve-guards.ts`, `test/fixtures/context-budget.json` and `test/setup-playwright-best-effort.test.ts` join the integrator-owned set resolved at restamp, and are listed among the #3190 overlaps; tests and ratchet adapters are part of each lane's ownership inventory.
|
||||
14. **PR 5's real dependencies (CEO native F2).** PR 5 depends on PR 1b and PR 2 as well as #3190; it is built on `capy/next-wave` after main (with 1b and 2) is merged into it, or on main after #3190 merges. The Oct 29 rebuild has the same precondition.
|
||||
15. **Paid gates for every PR (CEO outside F1).** Each PR runs the no-spend gate and judge selection; any selected paid check, including in PRs 1a and 1b, is priced through `/eval-plan` and approved before dispatch; reused evidence is bound to unchanged consumed inputs.
|
||||
16. **Item 25's hosts (CEO native F6).** Every planMode:false host (hermes, codex, agy, openclaw, and define-host's default) is listed in item 25 and in PR 5's Behavior changes.
|
||||
17. **The split is an owner decision (CEO native F5).** `.gstack/ship-policy.json` sets `default_pr_count=1` and `split_requires_owner=true`. Seven PRs means seven PATCH slots, seven frozen-tree `bun run test` runs on Ubicloud, and a restamp after each merge. Recommended for recovery speed; presented to Garry with the gate.
|
||||
@@ -128,8 +128,18 @@ Set these env vars to target a specific device or bundle:
|
||||
GSTACK_IOS_TARGET_UDID=248C3A58-B843-5BDB-8F5D-89ADB7D7BF6A
|
||||
GSTACK_IOS_TARGET_BUNDLE_ID=com.yourorg.yourapp
|
||||
GSTACK_IOS_DAEMON_PORT=9099 # loopback listener port; default 9099
|
||||
GSTACK_IOS_LAUNCH_ENV='{"DEBUG_BRIDGE":"1"}' # optional: env for the app when the daemon launches it
|
||||
```
|
||||
|
||||
`GSTACK_IOS_LAUNCH_ENV` is for apps whose debug bridge (or anything else) is
|
||||
gated on an environment variable. It is a JSON object of string to string, and
|
||||
the daemon passes it to `xcrun devicectl device process launch
|
||||
--environment-variables` on every launch it performs, including the one forced
|
||||
relaunch below. It only takes effect when the daemon launches the app; an app
|
||||
that is already running keeps the environment it started with. Invalid JSON, a
|
||||
non-string value, or a `GSTACK_` key (reserved for the debug bridge) refuses the
|
||||
launch with `IOS_LAUNCH_ENV_INVALID` instead of starting the app without it.
|
||||
|
||||
If `GSTACK_IOS_TARGET_UDID` is unset, the daemon picks the best paired,
|
||||
available iPhone or iPad, preferring a wired device. When two devices tie (an
|
||||
iPhone and an iPad both on USB), it refuses to guess: the bootstrap error lists
|
||||
@@ -230,6 +240,7 @@ Before you ship to TestFlight or the App Store, run `/ios-clean`. It removes the
|
||||
| `Developer Mode` toggle missing from Settings → Privacy | Connect the device to Xcode → Window → Devices and Simulators once, or try any `devicectl device install` against it. iOS will surface the toggle after the first attempt. |
|
||||
| `xcrun devicectl device copy from` returns ERROR 7000 | The source path is wrong — boot token lives at `tmp/gstack-ios-qa.token` inside the app's data container (NSTemporaryDirectory), not at the path's root. |
|
||||
| Bootstrap fails with `multiple_devices` | More than one iPhone/iPad is connected. Run the printed `export GSTACK_IOS_TARGET_UDID=<udid>` and restart the daemon. |
|
||||
| Launch refused with `(IOS_LAUNCH_ENV_INVALID)` | `GSTACK_IOS_LAUNCH_ENV` is not a JSON object of string to string, or sets a `GSTACK_` key. Fix or unset it and restart the daemon; see [troubleshooting](troubleshooting.md#ios-launch-env-invalid). |
|
||||
| `boot_token_unavailable ... could not write tmp/gstack-ios-qa.token` | The app could not write its boot-token file. Fix the app's `tmp/` directory and relaunch the app. |
|
||||
| A SwiftUI `DragGesture` or `Canvas` ignores taps and swipes on iOS 26 | Synthesized in-process touches do not reach SwiftUI gesture recognizers on iOS 26, and `/swipe` only scrolls a `UIScrollView`. See "Known limits" in the `/ios-qa` skill for the input-routing workaround. |
|
||||
| `/healthz` returns 200 but `/tap` returns ok:true with no UI change | The phone is paired but the StateServer port may have changed across launches. Re-resolve the CoreDevice IPv6 (`dscacheutil -q host -a name '<DeviceName>.coredevice.local'`). |
|
||||
|
||||
@@ -274,3 +274,34 @@ The 4 remaining shouting pins are enum or marker tokens (`PLAN MODE EXCEPTION
|
||||
ALWAYS RUN`, `[CRITICAL]`, `ALWAYS-ON` CLI output), not emphasis. Most remaining
|
||||
sentence pins sit in QA caller, detector-helper and workflow files that other
|
||||
work in flight owns.
|
||||
|
||||
## Post-merge follow-up (2026-10-15)
|
||||
|
||||
The "post-merge audit metrics" TODO, from a no-spend run of
|
||||
`bun run test:health --since-days 7` (window 2026-10-08T22:14Z to
|
||||
2026-10-15T22:14Z). The 2026-10-12 figures are the weekly health run in issue
|
||||
#3154 (run 38234556073), kept as dated history.
|
||||
|
||||
| Metric | 2026-10-12 | 2026-10-15 | Target | Verdict |
|
||||
|---|---:|---:|---|---|
|
||||
| PR full-fallback rate | 45.0% (58/129 runs) | 45.9% (39/85 runs) | none numeric; the replay above names the cause | open: branches touching `bin/`, `lib/`, `hosts/` or `make-pdf/` still select every case |
|
||||
| Recorded PR-lane spend per merged PR | $75.09 (46 merged) | $73.52 (33 merged) | down at least 50% from the pre-change baseline | unavailable: baseline not recorded (below) |
|
||||
| Cancelled slice-minutes per week | 1,939 (44 runs) | 1,545 (33 runs) | none numeric | reported |
|
||||
| PR push-to-verdict | p50 12.0 min, p95 1,160.7 min (122 runs) | p50 11.4 min, p95 107.9 min (80 runs) | 3–5 minutes for typical PR checks (CONTRIBUTING.md) | missed |
|
||||
| Free-suite flaky-pass rate | 13.0% (6/46 main runs) | 6.1% (2/33 main runs; `terminal-agent-publication-lock` 2/33, over the 5% per-file check) | under 5% of main runs | missed; fix wave 2 repairs the five ledger files |
|
||||
|
||||
**Spend baseline.** The pre-change window was recomputed with the exported
|
||||
`collectHealth` at `now = 2026-10-04T00:00Z`, seven days, through a client that
|
||||
drops runs created and PRs merged after that instant (`listWorkflowRuns` has no
|
||||
upper bound of its own). The window holds 12 merged PRs but no
|
||||
`trial-outcomes-pr-*` artifacts and no paid-plan manifest with `prCoverage`:
|
||||
the recorder that feeds this metric shipped with the audit, so the PR lanes
|
||||
before it wrote nothing it can read (their other artifacts are retained until
|
||||
2026-11-02 and later). Baseline not recorded; verdict unavailable. The P1 in
|
||||
TODOS.md is opened on the absolute figures.
|
||||
|
||||
**Census reliability (not yet due).** Due after the fourth scheduled census
|
||||
after the audit merge (2026-10-26). Interim: periodic 2.00 expected failed
|
||||
trials per run (6/538 over 3 runs; target at most 1.0), gate census 0.33 (1/308
|
||||
over 3 runs; target at most 0.5). The TODO keeps its due date; the census
|
||||
repair stays with #3153 and #3200.
|
||||
+435
-9
@@ -795,6 +795,30 @@ auto-updater running its first update cannot make this check.)
|
||||
**Fix.** `bun upgrade`; the next session start resumes the update. To update
|
||||
now: `bun upgrade && cd <gstack checkout> && git pull --ff-only && ./setup`.
|
||||
|
||||
<a id="bun-version-unreadable"></a>
|
||||
### `bun-version-unreadable: could not read the version of Bun at <path> (bun --version printed '<output>'); gstack <version> needs <floor> or newer (BUN_VERSION_UNREADABLE)`
|
||||
|
||||
**Meaning.** Before moving the checkout, the auto-updater and `/gstack-upgrade`
|
||||
compare the Bun that setup would run with the incoming release's security
|
||||
floor. `bun --version` printed something that is not a version (shown, with
|
||||
control characters removed), so the comparison could not run and the update was
|
||||
held: nothing was installed or changed. `/gstack-upgrade` prints the same text
|
||||
after `BUN_VERSION_UNREADABLE:`. Earlier releases read an unreadable version,
|
||||
and every version under zsh, as "not below the floor" and went ahead (#3195).
|
||||
|
||||
**Fix.** Reinstall Bun; `bun upgrade` may not run on a broken install. In Git
|
||||
Bash or a macOS/Linux terminal:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
bun --version
|
||||
```
|
||||
|
||||
**Expected result.** `bun --version` prints a version such as `1.4.2`, and the
|
||||
next session start (or `/gstack-upgrade`) updates.
|
||||
|
||||
**Fixed in.** The first release after v1.91.101.0 (#3195).
|
||||
|
||||
<a id="auto-update-incomplete"></a>
|
||||
### `gstack auto-update: setup did not finish (<reason>); installed skills may be out of date. gstack retries automatically. Fix now: cd <dir> && ./setup`
|
||||
|
||||
@@ -895,17 +919,160 @@ cannot be checked before the pull; setup then refuses the broken hook (see the
|
||||
entry above).
|
||||
|
||||
<a id="auto-update-hook-check-unavailable"></a>
|
||||
### `gstack auto-update: update held (hook-check-unavailable: <reason>); nothing was installed or changed, and your current hooks keep running. ... Fix now: cd <dir> && git pull --ff-only --autostash && ./setup ...`
|
||||
### `gstack auto-update: update held (hook-check-unavailable: <reason>); nothing was installed or changed, and your current hooks keep running. ... Fix now (in a terminal; Git Bash on Windows): cd '<dir>' && git fetch origin && bash bin/gstack-ff-update . '@{upstream}' && ./setup ...`
|
||||
|
||||
**Meaning.** Neither the incoming release's hook checker nor your installed
|
||||
one gave a verified report on the incoming hooks (a crash, no output, an
|
||||
unreadable report, or no Bun to bundle the TypeScript hooks), so the
|
||||
auto-updater cannot tell whether moving your checkout would break Claude Code
|
||||
tool calls. It left your checkout, skills and hooks at the current revision.
|
||||
The `<reason>` names what each checker did.
|
||||
unreadable report, or no Bun to bundle the TypeScript hooks), or the incoming
|
||||
revision could not be extracted for the check (`cannot extract the incoming
|
||||
revision: <git's error>`), so the auto-updater cannot tell whether moving your
|
||||
checkout would break Claude Code tool calls. It left your checkout, skills and
|
||||
hooks at the current revision. The `<reason>` names what each checker did.
|
||||
|
||||
**Fix.** Run the printed command. Setup checks every hook it registers and
|
||||
says what is wrong (for example that Bun is missing).
|
||||
**Fix.** Run the printed command. `bin/gstack-ff-update` moves the checkout
|
||||
without losing a local edit (see
|
||||
[shelved edits](#auto-update-rescued-edits)), and setup checks every hook it
|
||||
registers and says what is wrong (for example that Bun is missing).
|
||||
|
||||
On Windows, if the reason is `git archive of the incoming revision failed`,
|
||||
your updater predates the fix for #3184: see
|
||||
[the held-install recovery](#auto-update-held-install-recovery).
|
||||
|
||||
<a id="auto-update-held-install-recovery"></a>
|
||||
### `HELD hook-check-unavailable: git archive of the incoming revision failed` / `gstack auto-update: update held (hook-check-unavailable: git archive of the incoming revision failed) ...` / `gstack auto-update: setup did not finish (bun not found on PATH) ...` on Windows
|
||||
|
||||
**Meaning.** From 1.91.89.0 to the release that fixed #3184, the auto-updater
|
||||
extracted each incoming release with `git archive | tar` before checking its
|
||||
hooks. Git for Windows' MSYS `tar` cannot create the `connect-chrome` symlink
|
||||
because it comes before its target in the archive, so every release was held
|
||||
(`~/.gstack/analytics/session-update.log` shows the `HELD` line), and a stale
|
||||
`setup did not finish (bun not found on PATH)` line could hide the hold. Your
|
||||
installed updater cannot deliver its own fix, and the old `/gstack-upgrade`
|
||||
can leave conflict markers when a local edit conflicts. The recovery fetches
|
||||
the new `bin/gstack-ff-update` from the new revision into a private temp
|
||||
directory and runs it. It works on a clean install, an install with local
|
||||
edits that conflict, an install that already has unmerged files, and a
|
||||
prefix-mode install: regenerable `SKILL.md` and `sections/*.md` edits are
|
||||
re-rendered by setup, and every other edit is kept in a rescue ref (the
|
||||
command prints it; see [shelved edits](#auto-update-rescued-edits)).
|
||||
|
||||
**Fix, in Git Bash** (use your install directory if it is not
|
||||
`~/.claude/skills/gstack`):
|
||||
|
||||
<!-- form:recovery-gitbash -->
|
||||
```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
|
||||
```
|
||||
|
||||
**Fix, in Windows PowerShell 5.1** (it runs the same script through Git's
|
||||
`bash.exe`, then `.\setup.ps1`):
|
||||
|
||||
<!-- form:recovery-powershell -->
|
||||
```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 }
|
||||
```
|
||||
|
||||
**In Claude Code**, ask it to run the Git Bash form above.
|
||||
|
||||
**Expected result.** The script prints `ok <old> <new>` or
|
||||
`rescued <old> <new> refs/gstack/rescue/<oid> unmerged=<0|1>`, setup
|
||||
finishes with exit 0, the next session start prints no held line, and later
|
||||
updates install on their own.
|
||||
|
||||
**Trying an unreleased fix, and going back.** A fix branch can be tried
|
||||
before it ships; auto-update pauses while a detached revision is checked out.
|
||||
Replace `<branch>` with the branch named in the issue.
|
||||
|
||||
<!-- form:trial-gitbash -->
|
||||
```bash
|
||||
cd ~/.claude/skills/gstack && git checkout -- 'SKILL.md' '*/SKILL.md' '*/sections/*.md' && git fetch origin <branch> && git checkout --detach FETCH_HEAD && ./setup
|
||||
```
|
||||
|
||||
<!-- form:trial-powershell -->
|
||||
```powershell
|
||||
Set-Location -LiteralPath "$HOME\.claude\skills\gstack" -ErrorAction Stop; $gb = Join-Path (git --exec-path) '..\..\..\bin\bash.exe'; & $gb -c 'set -f; git checkout -- SKILL.md */SKILL.md */sections/*.md && git fetch origin <branch> && git checkout --detach FETCH_HEAD'; if ($LASTEXITCODE -eq 0) { .\setup.ps1 }
|
||||
```
|
||||
|
||||
To go back to the released version (this also resumes auto-update):
|
||||
|
||||
<!-- form:goback-gitbash -->
|
||||
```bash
|
||||
cd ~/.claude/skills/gstack && git checkout -- 'SKILL.md' '*/SKILL.md' '*/sections/*.md' && git checkout main && ./setup
|
||||
```
|
||||
|
||||
<!-- form:goback-powershell -->
|
||||
```powershell
|
||||
Set-Location -LiteralPath "$HOME\.claude\skills\gstack" -ErrorAction Stop; $gb = Join-Path (git --exec-path) '..\..\..\bin\bash.exe'; & $gb -c 'set -f; git checkout -- SKILL.md */SKILL.md */sections/*.md && git checkout main'; if ($LASTEXITCODE -eq 0) { .\setup.ps1 }
|
||||
```
|
||||
|
||||
<a id="auto-update-unmerged-files"></a>
|
||||
### `gstack auto-update: update held (unmerged-files: <n> unmerged file(s) in the install (first: <path>)); an earlier merge, rebase or stash pop left conflicts in the install, and an update would bury them. Fix now (...): cd '<dir>' && git fetch origin && bash bin/gstack-ff-update . '@{upstream}' && ./setup ...`
|
||||
|
||||
**Meaning.** Your gstack checkout has unmerged files: conflict markers from a
|
||||
merge, rebase or `git stash pop` of your own, or from an auto-updater older
|
||||
than the fix for this (its `merge --autostash` left markers in place when a
|
||||
local edit conflicted). The background updater never moves a checkout with
|
||||
unmerged files, and it prints no "setup did not finish" fix while they exist,
|
||||
because setup would run over the markers.
|
||||
|
||||
**Fix.** Run the printed command. It saves everything first, the unmerged
|
||||
files included, in a rescue ref labelled "needs manual resolution", then
|
||||
moves the checkout to the release and runs setup. Read the rescue with the
|
||||
commands under [shelved edits](#auto-update-rescued-edits). To resolve the
|
||||
conflict yourself instead, finish or abort your merge (`git -C '<dir>' merge
|
||||
--abort`, `git -C '<dir>' checkout -- <file>`); gstack retries at the next
|
||||
check.
|
||||
|
||||
<a id="auto-update-autostash-unrecoverable"></a>
|
||||
### `gstack auto-update: update held (autostash-unrecoverable: <reason>); your local edits are where they were and nothing was installed. Look first: git -C '<dir>' status --short. Fix now (...)` / `autostash-unrecoverable: <reason>`
|
||||
|
||||
**Meaning.** `bin/gstack-ff-update` refused to move the checkout and changed
|
||||
nothing. The `<reason>` says why: an untracked file is where the release
|
||||
puts a tracked one (`git refused to update ...: would be overwritten`), a
|
||||
file changed while the update ran, another git command held the index lock
|
||||
(`index.lock exists`), or a rescue ref of that name already names another
|
||||
commit.
|
||||
|
||||
**Fix.** Run the `status` command the line prints, move the listed untracked
|
||||
file out of the way (or wait for the other git command to finish), then run
|
||||
the printed `Fix now` command. Nothing needs restoring: your edits are still
|
||||
in the working tree.
|
||||
|
||||
<a id="auto-update-rescued-edits"></a>
|
||||
### `gstack auto-update: your local edits in the install (<files>) conflicted with an update, so they were saved in refs/gstack/rescue/<oid> and the install runs the update. ...` / `rescued <old> <new> refs/gstack/rescue/<oid> unmerged=<0|1>`
|
||||
|
||||
**Meaning.** You had edited tracked files in your gstack checkout, and the
|
||||
release changed the same lines (or you had staged changes or unmerged files).
|
||||
Instead of leaving conflict markers in the checkout Claude Code runs hooks
|
||||
from, `bin/gstack-ff-update` saved your edits as one commit,
|
||||
`refs/gstack/rescue/<oid>`, and moved the checkout to the release. The
|
||||
rescue's first parent is the revision you had, so it holds only your changes;
|
||||
its second parent holds what you had staged. Edits that did not conflict stay
|
||||
in place, and regenerable `SKILL.md` and `sections/*.md` edits (skill prefix,
|
||||
gbrain blocks) are re-rendered by setup instead of being rescued. The
|
||||
session-start line repeats once a day until you delete the ref.
|
||||
|
||||
**Fix.** Inspect first:
|
||||
|
||||
```bash
|
||||
git -C ~/.claude/skills/gstack show --stat 'refs/gstack/rescue/<oid>'
|
||||
```
|
||||
|
||||
To keep your edits, export only your changes and apply them in a scratch
|
||||
checkout (never with `stash apply` in the install):
|
||||
|
||||
```bash
|
||||
git -C ~/.claude/skills/gstack diff 'refs/gstack/rescue/<oid>^' 'refs/gstack/rescue/<oid>' > ~/gstack-local-edits.patch
|
||||
git clone https://github.com/garrytan/gstack.git ~/gstack-scratch && cd ~/gstack-scratch && git apply -3 ~/gstack-local-edits.patch
|
||||
```
|
||||
|
||||
A rescue labelled "needs manual resolution" holds files that were already
|
||||
unmerged: their content can contain conflict markers to resolve. Delete the
|
||||
ref when you are done (this also stops the daily line):
|
||||
|
||||
```bash
|
||||
git -C ~/.claude/skills/gstack update-ref -d 'refs/gstack/rescue/<oid>'
|
||||
```
|
||||
|
||||
<a id="setup-runtime-root-contains-checkout"></a>
|
||||
### `gstack setup: refusing to replace <root> for <host>: this runtime directory contains the source checkout setup runs from (<checkout>), so replacing it would delete the checkout. Nothing was changed.`
|
||||
@@ -1041,6 +1208,39 @@ or too large. Each message names the cap that applied:
|
||||
holds the code you want audited and no symlinks. Add your file count or size to
|
||||
[#2993](https://github.com/garrytan/gstack/issues/2993).
|
||||
|
||||
<a id="cso-scanner-invalid-output"></a>
|
||||
### `INVALID_OUTPUT: Scanner report is malformed, unsupported, or exceeds structural limits.` (Semgrep on a real repository)
|
||||
|
||||
**Meaning.** Before the first release after v1.91.101.0, /cso redacted a
|
||||
scanner's JSON report by splicing an unquoted `<REDACTED-...>` marker over any
|
||||
bare number that looked like a phone number or card number, such as Semgrep's
|
||||
`"max_memory_bytes":1145427776` or an epoch timestamp. The report then failed to
|
||||
parse and the scanner was recorded as not assessed (#3185). Now a redacted
|
||||
number becomes the quoted string `"<REDACTED-...>"` and the report parses. A
|
||||
redaction that would cross JSON tokens still withholds the payload
|
||||
(`REDACTION_FAILED`).
|
||||
|
||||
**Fix.** Update gstack (`/gstack-upgrade`), then run /cso again. If this code
|
||||
still appears, the scanner itself emitted malformed JSON; report the scanner
|
||||
and version at https://github.com/garrytan/gstack/issues.
|
||||
|
||||
**Fixed in.** The first release after v1.91.101.0 (#3185).
|
||||
|
||||
<a id="cso-orbstack-argv0"></a>
|
||||
### `unsupported argv0 "docker-tools"` (OrbStack's `docker` under /cso)
|
||||
|
||||
**Meaning.** /cso runs the canonical path of `docker` (symlinks resolved), and
|
||||
OrbStack's `docker` is a symlink to one multicall binary, `docker-tools`, that
|
||||
decides what to be from the name it was started as. Started under its real name
|
||||
it refused, so Docker isolation and every scanner were reported missing
|
||||
(#3187). /cso now starts the same verified binary with the name `docker`,
|
||||
including in its cleanup watchdog.
|
||||
|
||||
**Fix.** Update gstack (`/gstack-upgrade`), then run `gstack-doctor`; its `cso`
|
||||
row should pass with OrbStack running.
|
||||
|
||||
**Fixed in.** The first release after v1.91.101.0 (#3187).
|
||||
|
||||
<a id="conductor-auq-hook-removed"></a>
|
||||
### `removed the AskUserQuestion preference hook: it breaks Conductor's native AskUserQuestion (#2207). ...`
|
||||
|
||||
@@ -1156,7 +1356,7 @@ the project whose pins apply.
|
||||
|
||||
**Meaning.** The first browser skill run on a machine installed without
|
||||
Chromium (`SKIP browser (lazy; gstack-browser-ensure installs on first use)`)
|
||||
tried to install it through `bunx playwright install chromium` under setup's
|
||||
tried to install it through `bun x playwright install chromium` under setup's
|
||||
lock, or Chromium is installed but does not launch.
|
||||
|
||||
**Fix.** Read the installer output above: offline or proxied machines need
|
||||
@@ -1193,6 +1393,26 @@ listed changes, check out the default branch, move local commits to another
|
||||
branch), then re-run `bin/gstack-upgrade`. A reinstall that failed after the
|
||||
fast-forward is completed by the next run (`reinstall pending from <version>`).
|
||||
|
||||
<a id="upgrade-busy"></a>
|
||||
### `gstack-upgrade: live gstack sessions hold this installation: pid <p> (<age>m), ...; nothing was changed; fix: ... (UPGRADE_BUSY)`
|
||||
|
||||
**Meaning.** Several agents share this checkout (Capy subagents on one
|
||||
machine, say). Each session start writes a workflow lease,
|
||||
`<git dir>/gstack-leases/<harness pid>` (`bin/gstack-lease touch`, from
|
||||
`bin/gstack-skill-start`), and `bin/gstack-upgrade` refused before changing
|
||||
anything because a lease whose pid is alive and not in its own process
|
||||
ancestry exists: fast-forwarding would change gstack under a session
|
||||
mid-workflow. Leases older than 120 minutes are pruned; a lease whose
|
||||
process has exited never blocks. The upgrader's own session (the pid chain it
|
||||
runs under) never counts.
|
||||
|
||||
**Fix.** Wait for those sessions to finish (`bin/gstack-lease list` shows
|
||||
every lease with its age and `alive|dead`), then re-run `bin/gstack-upgrade`.
|
||||
When you know they are idle, `GSTACK_UPGRADE_FORCE=1 bin/gstack-upgrade`
|
||||
proceeds. At a Capy session start the same refusal appears as
|
||||
`SELF_UPGRADE: skipped (live gstack sessions hold this installation: ...)`
|
||||
and the session continues on the installed revision.
|
||||
|
||||
<a id="upgrade-fetch-failed"></a>
|
||||
### `gstack-upgrade: could not reach origin (...) (UPGRADE_FETCH_FAILED)`
|
||||
|
||||
@@ -1363,6 +1583,32 @@ killed.
|
||||
**Fix.** After `browse stop`, check for a leftover browser with
|
||||
`ps aux | grep -i chrom` and end it with `kill <pid>`.
|
||||
|
||||
<a id="browse-no-active-page"></a>
|
||||
### `No active page. Use "browse goto <url>" first.` (after the last tab was closed outside browse)
|
||||
|
||||
**Meaning.** When every tab was closed outside browse (the headed window's tabs
|
||||
closed by hand, or a page that closed itself), earlier releases failed every
|
||||
later command this way, `status` and `stop` included, until the daemon was
|
||||
killed (#3167). Now `status` and `stop` answer without opening a tab, and any
|
||||
other command opens one new blank tab first and prints
|
||||
`[browse] warning: every tab was closed outside browse, so this command ran on a new blank tab; open your page again with: browse goto <url>`.
|
||||
|
||||
**Fix.** `browse goto <url>` to open your page again.
|
||||
|
||||
**Fixed in.** The first release after v1.91.101.0 (#3167).
|
||||
|
||||
<a id="browse-tab-unrecoverable"></a>
|
||||
### ``browse: every tab was closed outside browse and a new one could not be opened, because the browser or its context is gone (<cause>); fix: run `browse restart` ... (BROWSE_TAB_UNRECOVERABLE)``
|
||||
|
||||
**Meaning.** Every tab was closed outside browse and the browser, or its
|
||||
context, is gone too, so browse could not open a tab to run the command.
|
||||
`browse status` and `browse stop` still answer.
|
||||
|
||||
**Fix.** `browse restart` (`$B restart` inside a gstack skill), then run the
|
||||
command again.
|
||||
|
||||
**Fixed in.** The first release after v1.91.101.0 (#3167).
|
||||
|
||||
---
|
||||
|
||||
## Windows
|
||||
@@ -1402,6 +1648,112 @@ Windows allows the binaries, re-run `./setup`; the message goes away.
|
||||
**Expected result.** setup prints no "Windows blocked" line, and
|
||||
`gstack-doctor` shows `ok` for `browse bundle` and `binaries`.
|
||||
|
||||
<a id="setup-ps1-unexpected-eof"></a>
|
||||
### `setup: -c: line 1: unexpected EOF while looking for matching '"'` / `setup: -c: line 2: syntax error: unexpected end of file` from `.\setup.ps1`
|
||||
|
||||
**Meaning.** Windows PowerShell 5.1 (`powershell.exe`, the default PowerShell
|
||||
on Windows) passes arguments to native programs the legacy way and strips
|
||||
the double quotes inside them. Until the release that fixed #3130,
|
||||
`setup.ps1` handed Git Bash a `bash -c` script with double quotes in it, so
|
||||
bash read a broken script and nothing was installed. PowerShell 7.3 and newer
|
||||
were not affected.
|
||||
|
||||
**Fix.** Update gstack (in Git Bash: `cd ~/.claude/skills/gstack && git pull
|
||||
--ff-only`), then run `.\setup.ps1` again: its script no longer holds a
|
||||
double quote. Until then, run `./setup` from Git Bash, or `.\setup.ps1` from
|
||||
PowerShell 7 (`pwsh`).
|
||||
|
||||
<a id="setup-ps1-empty-argument"></a>
|
||||
### `setup.ps1: argument N is empty; setup takes no empty arguments. Nothing was installed or changed. (SETUP_PS1_EMPTY_ARGUMENT; ...)`
|
||||
|
||||
**Meaning.** One of the arguments you gave `.\setup.ps1` is an empty string
|
||||
(for example `''` or an unset variable such as `$hostName`). setup takes no
|
||||
empty arguments, and the way `setup.ps1` hands arguments to Git Bash under
|
||||
Windows PowerShell 5.1 cannot carry one, so it refuses before running
|
||||
anything.
|
||||
|
||||
**Fix.** Drop the empty argument, or give it a value:
|
||||
|
||||
```powershell
|
||||
.\setup.ps1 --host codex
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<a id="chromium-install-exit-127"></a>
|
||||
### `Chromium bootstrap: chromium-install — <bun> 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)`
|
||||
|
||||
**Meaning.** setup (or `bin/gstack-browser-ensure`, as `BROWSER_UNAVAILABLE
|
||||
<bun> x playwright install chromium exited 127 (command not found)`) could
|
||||
not run the Playwright installer: the shell found no command to run. Until
|
||||
the release that fixed #3131, setup called `bunx`, which a Bun installed with
|
||||
winget does not ship, and reported the failure as `exited 127 (offline,
|
||||
proxy, or blocked download?)`. It now runs `bun x` through the Bun it
|
||||
resolved, so exit 127 means that Bun itself, or a command it starts, is
|
||||
missing. Skills that do not need a browser are installed either way.
|
||||
|
||||
**Fix.** Install Bun from https://bun.sh (it ships `bun` and `bunx`), then
|
||||
run setup again. To install without the browser for now, in Git Bash:
|
||||
|
||||
```bash
|
||||
./setup --no-browser
|
||||
```
|
||||
|
||||
or in Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
.\setup.ps1 --no-browser
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<a id="bash-unavailable"></a>
|
||||
### `gstack: no Git Bash found to run gstack's bash scripts on Windows (tried ...); fix: ... (BASH_UNAVAILABLE)`
|
||||
|
||||
**Meaning.** Windows cannot run a shebang script, so gstack hands its bash
|
||||
scripts (`gstack-wtree`, `gstack-slug`, the hooks' bins, `bash -c` commands in
|
||||
regen and pregate selection manifests) to Git Bash. It looks in this order and
|
||||
the message lists every candidate it tried with the reason it was rejected:
|
||||
|
||||
1. `GSTACK_GIT_BASH` (or the older name `GSTACK_BASH`; when both are set and
|
||||
differ, `GSTACK_GIT_BASH` wins and a one-line warning says so). A value
|
||||
that does not exist is refused, not skipped.
|
||||
2. bash next to `git --exec-path` (finds scoop, per-user and portable Git).
|
||||
3. `C:\Program Files\Git`, `C:\Program Files (x86)\Git`,
|
||||
`%LOCALAPPDATA%\Programs\Git` and scoop's `git\current`.
|
||||
4. A `bash` on PATH, only when `uname -s` reports MSYS or MINGW. WSL's
|
||||
`bash.exe` in `System32` or `WindowsApps` is never used: it reads
|
||||
`C:\...` paths as Linux paths. A PATH bash that is neither (Cygwin, for
|
||||
example) is no longer used either.
|
||||
|
||||
Before #3197 these commands spawned the scripts directly, so `gstack-restamp`
|
||||
refused with `cannot fingerprint the working tree ()` and `gstack-gate-diff`
|
||||
and `gstack-evidence` silently dropped the fingerprint.
|
||||
|
||||
**Fix.** Install Git for Windows (`winget install --id Git.Git -e` in
|
||||
PowerShell), or point gstack at your bash.exe for good. An `export` in Git Bash
|
||||
reaches only that shell, so set it where Claude Code reads it:
|
||||
|
||||
- Windows PowerShell 5.1 or PowerShell 7, then restart Claude Code:
|
||||
|
||||
```powershell
|
||||
[Environment]::SetEnvironmentVariable('GSTACK_GIT_BASH', 'C:\Program Files\Git\bin\bash.exe', 'User')
|
||||
```
|
||||
|
||||
- Or the `env` block of `~/.claude/settings.json`:
|
||||
|
||||
```json
|
||||
{ "env": { "GSTACK_GIT_BASH": "C:/Program Files/Git/bin/bash.exe" } }
|
||||
```
|
||||
|
||||
The hooks cache the bash they found in `<state root>/cache/windows-bash.json`
|
||||
(keyed by `git.exe`'s path and modification time, rechecked before use); the
|
||||
override is never cached.
|
||||
|
||||
**Expected result.** `gstack-restamp --dry-run` prints its diff and
|
||||
`gstack-gate-diff` records a candidate ending in a tree id instead of
|
||||
`+nowtree`.
|
||||
|
||||
---
|
||||
|
||||
## Memory and gbrain
|
||||
@@ -1838,6 +2190,20 @@ applied; the unparsed tokens are listed.
|
||||
`all except <tokens>`. A bare `yes` or `no` is ambiguous across multi-option
|
||||
items and is never guessed.
|
||||
|
||||
<a id="answer-by-required"></a>
|
||||
### `ANSWER_BY_REQUIRED`
|
||||
|
||||
**Meaning.** `gstack-autoplan answer` ran in an unattended run without
|
||||
`--by`, or with a value that is neither `human` nor `agent:<id>`. The
|
||||
artifacts must say who answered a gate: an owner typing tokens and an agent
|
||||
relaying or deciding them look identical in `decisions.jsonl` otherwise.
|
||||
Nothing was applied.
|
||||
|
||||
**Fix.** Re-run with `--by human` when the owner typed the tokens, or
|
||||
`--by agent:<thread or id>` when an agent relays or decides. The value is
|
||||
recorded as `answered_by` on every decision row the reply answers and in the
|
||||
review record's gate line.
|
||||
|
||||
## Queue-aware ship: policy, restamp, tree receipt, ship receipt
|
||||
|
||||
`gstack-ship-policy`, `gstack-restamp`, `gstack-tree-receipt`,
|
||||
@@ -1957,6 +2323,12 @@ legitimate generated output, or stop the tool from writing it.
|
||||
the journal recorded (someone edited it after the stage ran). Overwriting it
|
||||
would lose that edit, so the restamp stopped.
|
||||
|
||||
`cannot fingerprint the working tree (<detail>); nothing written` is the same
|
||||
code before anything was staged: `gstack-wtree exited <n>: <reason>` carries
|
||||
the helper's own repair (for example a writable `TMPDIR`), and
|
||||
`gstack-wtree could not start: ...` is the Windows bash lookup failing (see
|
||||
[`BASH_UNAVAILABLE`](#bash-unavailable)).
|
||||
|
||||
**Fix.** Read `.gstack/tmp/restamp-journal.json`, reconcile the named file by
|
||||
hand, then rerun `gstack-restamp` (idempotent: a complete stamp is a no-op).
|
||||
|
||||
@@ -2326,7 +2698,12 @@ string literals, the `bin/<name>` → `test/<name>*.test.ts` convention or a
|
||||
|
||||
**Fix.** Add a test that names the file, or declare its lane (an explicit `[]`
|
||||
for files with no behaviour, such as docs) in `.gstack/pregate.json`. `--explain`
|
||||
prints every mapping and why.
|
||||
prints every mapping and why. For a file with no lane it also prints the import
|
||||
specifiers in test files that resolved to no repo file (for example a
|
||||
`tsconfig.json` `paths` alias such as `@/lib/x` whose target is missing) and any
|
||||
`extends` hop that was not followed (only relative `extends`, at most 4 hops). If
|
||||
a test does cover the file, declare it under `dependencies`; if the resolver
|
||||
should have found it, report the specifier as a resolver gap.
|
||||
|
||||
<a id="pregate-lane-failed"></a>
|
||||
### `PREGATE_LANE_FAILED`
|
||||
@@ -2705,3 +3082,52 @@ writes that state; it comes from a hand edit or an older runner.
|
||||
|
||||
**Fix.** Answer the gate with `gstack-autoplan answer` until no approval is
|
||||
pending, and `revise --incorporates` or `--withdraw` each pending amendment.
|
||||
|
||||
<a id="ios-launch-env-invalid"></a>
|
||||
### `ios-qa launch: GSTACK_IOS_LAUNCH_ENV ... so the app was not launched (<detail>); fix: ... (IOS_LAUNCH_ENV_INVALID)`
|
||||
|
||||
**Meaning.** `gstack-ios-qa-daemon` forwards `GSTACK_IOS_LAUNCH_ENV` to
|
||||
`xcrun devicectl device process launch --environment-variables` whenever it
|
||||
launches the app under test. The value must be a JSON object whose keys are
|
||||
environment variable names and whose values are strings, and it may not set a
|
||||
`GSTACK_` variable (that namespace belongs to the debug bridge). Anything else
|
||||
(invalid JSON, an array, a number value, a `GSTACK_` key) refuses the launch,
|
||||
because launching without the variables an env-gated debug bridge needs would
|
||||
look like a working app with no bridge. The detail names the key and its type,
|
||||
never a value. Added in the release after v1.91.101.0.
|
||||
|
||||
**Fix.** In the shell that starts the daemon (macOS Terminal or Claude Code on
|
||||
the Mac with the device):
|
||||
|
||||
```bash
|
||||
export GSTACK_IOS_LAUNCH_ENV='{"DEBUG_BRIDGE":"1"}'
|
||||
```
|
||||
|
||||
or `unset GSTACK_IOS_LAUNCH_ENV`, then restart `gstack-ios-qa-daemon`.
|
||||
|
||||
<a id="outside-voice-unavailable"></a>
|
||||
### `Outside voice unavailable: <cause>. Fix: <fix>. Continuing to ... (OUTSIDE_VOICE_UNAVAILABLE)`
|
||||
|
||||
**Meaning.** `/plan-ceo-review`, `/plan-eng-review` or `/plan-devex-review` ran
|
||||
without a second opinion. The outside provider (Codex, or Claude Code under the
|
||||
Codex host) did not run or failed, and the native fallback could not supply a
|
||||
bounded review either. That fallback dispatches the built-in Plan subagent in
|
||||
the background next to a five-minute background timer, and stops whichever is
|
||||
still running when the other ends. The cause names which part was missing:
|
||||
`Plan agent type not offered`, `TaskStop not offered`, `background tasks
|
||||
unavailable`, `no complete report within five minutes`, or a failed status.
|
||||
The review-log row carries the same cause as `unavailable_reason`. Claude Code
|
||||
2.1.277 removed the TaskOutput tool the fallback used before, so on older
|
||||
gstack every native fallback on current Claude Code ended here.
|
||||
|
||||
**What is kept.** The native plan review itself is complete. The row is
|
||||
recorded with `status: unavailable`, never as a clean review; a late or partial
|
||||
reviewer result is not counted.
|
||||
|
||||
**Fix.** Repair the provider the line names: install it, or run `codex login`
|
||||
(in your terminal or Git Bash). For the native fallback, run Claude Code 2.1.277
|
||||
or later with background tasks enabled (`CLAUDE_CODE_DISABLE_BACKGROUND_TASKS`
|
||||
unset, not in bare mode). Then re-run the review.
|
||||
|
||||
**Expected result.** An `OUTSIDE VOICE (...)` report, or `CODEX SAYS (plan
|
||||
review — outside voice)` when the provider runs.
|
||||
+16
-6
@@ -186,22 +186,32 @@ the plan.
|
||||
`PHASE_NOT_AWAITING`, exit 3). Both voices in closes the phase: the
|
||||
reconciliation (`confirmed` / `disagree` / `new` per outside finding, with
|
||||
disagreement and resolution kept separate) is written to
|
||||
`<phase>-consensus.{json,md}`, and the Eng prompt refuses to open without
|
||||
every closed prior phase's consensus (`CONSENSUS_MISSING`).
|
||||
`<phase>-consensus.{json,md}`, and every later phase's prompts (DX, design
|
||||
and Eng alike) carry each closed predecessor's consensus as data; a phase
|
||||
refuses to open without one (`CONSENSUS_MISSING`).
|
||||
3. **Loop `next` until `status=gate_pending`.** Eng always runs last. The gate
|
||||
(`gate.json`, `gate_rev`) lists user challenges as approval items,
|
||||
disagreements as auto items and `p1` plan approval; nothing is approved.
|
||||
4. **Answer the gate** with the reply grammar and the revision you read:
|
||||
```bash
|
||||
$AP answer --out <dir> --gate-rev 1 --reply "all" # every recommendation; agreeing plan changes print APPLY:
|
||||
$AP answer --out <dir> --gate-rev 1 --reply "all as-is" # APPROVED: keeps every direction, no new paid phase
|
||||
$AP answer --out <dir> --gate-rev 1 --reply "all except d1b" # APPLY: <id> ...; REOPENED: ceo,eng; NEXT: gstack-autoplan revise ...
|
||||
$AP answer --out <dir> --gate-rev 1 --reply "all" --by human # every recommendation the owner may delegate; agreeing plan changes stay pending
|
||||
$AP answer --out <dir> --gate-rev 1 --reply "all as-is" --by human # APPROVED: keeps every direction, no new paid phase
|
||||
$AP answer --out <dir> --gate-rev 1 --reply "all except d1b" --by human # APPLY: <id> ...; REOPENED: ceo,eng; NEXT: gstack-autoplan revise ...
|
||||
```
|
||||
`--by human` means the owner typed the tokens; `--by agent:<thread or id>`
|
||||
means an agent relayed or decided them. An unattended run refuses an answer
|
||||
without it (`ANSWER_BY_REQUIRED`, exit 3) and records the value as
|
||||
`answered_by` on every decision row and in the review record's gate line,
|
||||
so a gate an agent closed is distinguishable from one the owner closed.
|
||||
A user challenge both voices raised on a confirmed finding with the same
|
||||
`changes` value is recommended as `b`, and option b shows both voices'
|
||||
remedies (`recommended: b (both voices; remedies shown)`); a `--reconcile`
|
||||
record naming one shared `proposal` shows that instead. Disagreeing or
|
||||
missing remedies keep `a`. Every accepted plan change prints
|
||||
missing remedies keep `a`. `all` never answers an item recommended `b`:
|
||||
that would change the owner's stated direction on their behalf, so those
|
||||
items stay pending (`GATE_PENDING: uc1 unanswered (reply with uc1a|uc1b; all
|
||||
does not answer items recommended b)`) until their own token arrives, and
|
||||
the rendered gate names them under the reply grammar. Every accepted plan change prints
|
||||
`APPLY: <decision id> <title> -> <chosen text>` and becomes a pending
|
||||
amendment: `next` refuses with `PLAN_AMEND_PENDING` and `export` refuses a
|
||||
`complete` run until `revise --incorporates` names it (see below).
|
||||
|
||||
+29
-16
@@ -135,26 +135,26 @@ Use the install type and directory detected in Step 2:
|
||||
|
||||
**For git installs** (global-git, local-git):
|
||||
|
||||
Fast-forward first — the same policy the session-update auto-upgrade
|
||||
uses. `--autostash` carries local edits over the pull; render-footprint dirt
|
||||
is discarded first because it is regenerable and poisons stashes:
|
||||
Fast-forward first, through `bin/gstack-ff-update`, the same code the
|
||||
session-update auto-upgrade uses: local edits that merge cleanly ride over the
|
||||
update, and an edit that conflicts (or a staged change, or an unmerged file)
|
||||
is saved in a rescue ref instead of being left as conflict markers:
|
||||
```bash
|
||||
cd -- "${INSTALL_DIR:?INSTALL_DIR is not set: re-run Step 2 and substitute the printed path}" || exit 1
|
||||
{ [ -f VERSION ] && [ -f setup ] && [ -f bin/gstack-config ] && _p=$(git rev-parse --show-prefix 2>/dev/null) && [ -z "$_p" ] && [ "$(git rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; } || { echo "ERROR: $INSTALL_DIR is not a gstack checkout; nothing was changed. Re-run Step 2." >&2; exit 1; }
|
||||
# Discard render-footprint dirt: older gbrain-enabled installs rendered
|
||||
# generated SKILL.md / sections files IN PLACE. They are regenerable (setup
|
||||
# re-renders to ~/.gstack/render), so discarding is lossless.
|
||||
git checkout -- 'SKILL.md' '*/SKILL.md' '*/sections/*.md' 2>/dev/null || true
|
||||
git fetch origin
|
||||
# Same pre-advance check as the auto-updater: the incoming release's Bun floor.
|
||||
HOLD=$(. bin/gstack-bun-version.sh 2>/dev/null && gstack_bun_incoming_hold . origin/main) && { echo "BUN_TOO_OLD: $HOLD; nothing was changed" >&2; exit 1; }
|
||||
# Same pre-advance check as the auto-updater: the incoming release's Bun floor,
|
||||
# judged by bash whatever this tool shell is (zsh does not word-split).
|
||||
HOLD=$(bash -c '. bin/gstack-bun-version.sh && gstack_bun_incoming_hold . origin/main' 2>/dev/null) && { case "$HOLD" in bun-version-unreadable*) echo "BUN_VERSION_UNREADABLE: $HOLD; nothing was changed" >&2 ;; *) echo "BUN_TOO_OLD: $HOLD; nothing was changed" >&2 ;; esac; exit 1; }
|
||||
PRE_UPGRADE_COMMIT=$(git rev-parse HEAD)
|
||||
echo "PRE_UPGRADE_COMMIT=$PRE_UPGRADE_COMMIT"
|
||||
if git pull --ff-only --autostash origin main; then
|
||||
if ./setup --refresh-registered; then echo "FF_OK"; else echo "SETUP_FAILED: git update succeeded; stop and inspect setup output (previous commit: $PRE_UPGRADE_COMMIT)" >&2; exit 1; fi
|
||||
else
|
||||
echo "FF_REFUSED"
|
||||
fi
|
||||
FF=$(bash bin/gstack-ff-update . origin/main); FF_RC=$?
|
||||
echo "$FF"
|
||||
case "$FF_RC" in
|
||||
0) if ./setup --refresh-registered; then echo "FF_OK"; else echo "SETUP_FAILED: git update succeeded; stop and inspect setup output (previous commit: $PRE_UPGRADE_COMMIT)" >&2; exit 1; fi ;;
|
||||
4) echo "FF_REFUSED" ;;
|
||||
*) echo "FF_STOPPED: $FF" >&2; exit 1 ;;
|
||||
esac
|
||||
```
|
||||
|
||||
Setup's `--refresh-registered` refreshes every install this checkout
|
||||
@@ -165,9 +165,20 @@ its retry command and never say every host was refreshed. A `skipped` row
|
||||
belongs to another checkout or project; pass on its command, do not run it.
|
||||
|
||||
If the output ends with `FF_OK`, the upgrade is done — skip the fallback
|
||||
below entirely.
|
||||
below entirely. When the line before it reads
|
||||
`rescued <old> <new> refs/gstack/rescue/<oid> unmerged=<0|1>`, tell the user
|
||||
their conflicting local edits were saved there and relay the `Inspect`,
|
||||
`Export` and `Delete` commands the script printed (export into a scratch
|
||||
checkout with `git apply -3`, never `stash apply` in the install;
|
||||
`unmerged=1` means the rescue needs manual resolution).
|
||||
On `FF_STOPPED`, STOP and relay the reason: nothing was changed.
|
||||
`ff-busy` means another gstack update is running (retry in a minute);
|
||||
`autostash-unrecoverable` names what is in the way (an untracked file the
|
||||
release adds, or another git command); `index-unreadable` means the checkout's
|
||||
index is damaged.
|
||||
On `BUN_TOO_OLD`, STOP: tell the user to run `bun upgrade`, then /gstack-upgrade again.
|
||||
On `SETUP_FAILED`, STOP; keep user changes and report the recovery commit. There is no `.bak` on the git path. Do not enter the divergence fallback merely because setup failed. Enter it only on `FF_REFUSED`, after inspecting the pull error; network/auth failures stop for repair, not reset.
|
||||
On `BUN_VERSION_UNREADABLE`, STOP: `bun --version` did not print a version gstack can read, so `bun upgrade` may not run either; tell the user to reinstall Bun from https://bun.sh (`curl -fsSL https://bun.sh/install | bash` in Git Bash or a macOS/Linux terminal), check that `bun --version` prints a version such as 1.4.2, then run /gstack-upgrade again (docs/troubleshooting.md#bun-version-unreadable).
|
||||
On `SETUP_FAILED`, STOP; keep user changes and report the recovery commit. There is no `.bak` on the git path. Do not enter the divergence fallback merely because setup failed. Enter it only on `FF_REFUSED` (local commits or a diverged history); network/auth failures stop for repair, not reset.
|
||||
|
||||
**Fallback (ff-only refused — local commits or divergence).** `git reset
|
||||
--hard` DESTROYS things: a clean tree with unpushed local commits still loses
|
||||
@@ -187,6 +198,8 @@ those commits. Gate it:
|
||||
```bash
|
||||
cd -- "${INSTALL_DIR:?INSTALL_DIR is not set: re-run Step 2 and substitute the printed path}" || exit 1
|
||||
{ [ -f VERSION ] && [ -f setup ] && [ -f bin/gstack-config ] && _p=$(git rev-parse --show-prefix 2>/dev/null) && [ -z "$_p" ] && [ "$(git rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; } || { echo "ERROR: $INSTALL_DIR is not a gstack checkout; nothing was changed. Re-run Step 2." >&2; exit 1; }
|
||||
# Regenerable render-footprint dirt (setup re-renders it) never goes into the stash.
|
||||
git checkout -- 'SKILL.md' '*/SKILL.md' '*/sections/*.md' 2>/dev/null || true
|
||||
STASH_OUTPUT=$(git stash 2>&1)
|
||||
git reset --hard origin/main
|
||||
./setup --refresh-registered
|
||||
|
||||
@@ -132,26 +132,26 @@ Use the install type and directory detected in Step 2:
|
||||
|
||||
**For git installs** (global-git, local-git):
|
||||
|
||||
Fast-forward first — the same policy the session-update auto-upgrade
|
||||
uses. `--autostash` carries local edits over the pull; render-footprint dirt
|
||||
is discarded first because it is regenerable and poisons stashes:
|
||||
Fast-forward first, through `bin/gstack-ff-update`, the same code the
|
||||
session-update auto-upgrade uses: local edits that merge cleanly ride over the
|
||||
update, and an edit that conflicts (or a staged change, or an unmerged file)
|
||||
is saved in a rescue ref instead of being left as conflict markers:
|
||||
```bash
|
||||
cd -- "${INSTALL_DIR:?INSTALL_DIR is not set: re-run Step 2 and substitute the printed path}" || exit 1
|
||||
{ [ -f VERSION ] && [ -f setup ] && [ -f bin/gstack-config ] && _p=$(git rev-parse --show-prefix 2>/dev/null) && [ -z "$_p" ] && [ "$(git rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; } || { echo "ERROR: $INSTALL_DIR is not a gstack checkout; nothing was changed. Re-run Step 2." >&2; exit 1; }
|
||||
# Discard render-footprint dirt: older gbrain-enabled installs rendered
|
||||
# generated SKILL.md / sections files IN PLACE. They are regenerable (setup
|
||||
# re-renders to ~/.gstack/render), so discarding is lossless.
|
||||
git checkout -- 'SKILL.md' '*/SKILL.md' '*/sections/*.md' 2>/dev/null || true
|
||||
git fetch origin
|
||||
# Same pre-advance check as the auto-updater: the incoming release's Bun floor.
|
||||
HOLD=$(. bin/gstack-bun-version.sh 2>/dev/null && gstack_bun_incoming_hold . origin/main) && { echo "BUN_TOO_OLD: $HOLD; nothing was changed" >&2; exit 1; }
|
||||
# Same pre-advance check as the auto-updater: the incoming release's Bun floor,
|
||||
# judged by bash whatever this tool shell is (zsh does not word-split).
|
||||
HOLD=$(bash -c '. bin/gstack-bun-version.sh && gstack_bun_incoming_hold . origin/main' 2>/dev/null) && { case "$HOLD" in bun-version-unreadable*) echo "BUN_VERSION_UNREADABLE: $HOLD; nothing was changed" >&2 ;; *) echo "BUN_TOO_OLD: $HOLD; nothing was changed" >&2 ;; esac; exit 1; }
|
||||
PRE_UPGRADE_COMMIT=$(git rev-parse HEAD)
|
||||
echo "PRE_UPGRADE_COMMIT=$PRE_UPGRADE_COMMIT"
|
||||
if git pull --ff-only --autostash origin main; then
|
||||
if {{SETUP_COMMAND}} --refresh-registered; then echo "FF_OK"; else echo "SETUP_FAILED: git update succeeded; stop and inspect setup output (previous commit: $PRE_UPGRADE_COMMIT)" >&2; exit 1; fi
|
||||
else
|
||||
echo "FF_REFUSED"
|
||||
fi
|
||||
FF=$(bash bin/gstack-ff-update . origin/main); FF_RC=$?
|
||||
echo "$FF"
|
||||
case "$FF_RC" in
|
||||
0) if {{SETUP_COMMAND}} --refresh-registered; then echo "FF_OK"; else echo "SETUP_FAILED: git update succeeded; stop and inspect setup output (previous commit: $PRE_UPGRADE_COMMIT)" >&2; exit 1; fi ;;
|
||||
4) echo "FF_REFUSED" ;;
|
||||
*) echo "FF_STOPPED: $FF" >&2; exit 1 ;;
|
||||
esac
|
||||
```
|
||||
|
||||
Setup's `--refresh-registered` refreshes every install this checkout
|
||||
@@ -162,9 +162,20 @@ its retry command and never say every host was refreshed. A `skipped` row
|
||||
belongs to another checkout or project; pass on its command, do not run it.
|
||||
|
||||
If the output ends with `FF_OK`, the upgrade is done — skip the fallback
|
||||
below entirely.
|
||||
below entirely. When the line before it reads
|
||||
`rescued <old> <new> refs/gstack/rescue/<oid> unmerged=<0|1>`, tell the user
|
||||
their conflicting local edits were saved there and relay the `Inspect`,
|
||||
`Export` and `Delete` commands the script printed (export into a scratch
|
||||
checkout with `git apply -3`, never `stash apply` in the install;
|
||||
`unmerged=1` means the rescue needs manual resolution).
|
||||
On `FF_STOPPED`, STOP and relay the reason: nothing was changed.
|
||||
`ff-busy` means another gstack update is running (retry in a minute);
|
||||
`autostash-unrecoverable` names what is in the way (an untracked file the
|
||||
release adds, or another git command); `index-unreadable` means the checkout's
|
||||
index is damaged.
|
||||
On `BUN_TOO_OLD`, STOP: tell the user to run `bun upgrade`, then /gstack-upgrade again.
|
||||
On `SETUP_FAILED`, STOP; keep user changes and report the recovery commit. There is no `.bak` on the git path. Do not enter the divergence fallback merely because setup failed. Enter it only on `FF_REFUSED`, after inspecting the pull error; network/auth failures stop for repair, not reset.
|
||||
On `BUN_VERSION_UNREADABLE`, STOP: `bun --version` did not print a version gstack can read, so `bun upgrade` may not run either; tell the user to reinstall Bun from https://bun.sh (`curl -fsSL https://bun.sh/install | bash` in Git Bash or a macOS/Linux terminal), check that `bun --version` prints a version such as 1.4.2, then run /gstack-upgrade again (docs/troubleshooting.md#bun-version-unreadable).
|
||||
On `SETUP_FAILED`, STOP; keep user changes and report the recovery commit. There is no `.bak` on the git path. Do not enter the divergence fallback merely because setup failed. Enter it only on `FF_REFUSED` (local commits or a diverged history); network/auth failures stop for repair, not reset.
|
||||
|
||||
**Fallback (ff-only refused — local commits or divergence).** `git reset
|
||||
--hard` DESTROYS things: a clean tree with unpushed local commits still loses
|
||||
@@ -184,6 +195,8 @@ those commits. Gate it:
|
||||
```bash
|
||||
cd -- "${INSTALL_DIR:?INSTALL_DIR is not set: re-run Step 2 and substitute the printed path}" || exit 1
|
||||
{ [ -f VERSION ] && [ -f setup ] && [ -f bin/gstack-config ] && _p=$(git rev-parse --show-prefix 2>/dev/null) && [ -z "$_p" ] && [ "$(git rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; } || { echo "ERROR: $INSTALL_DIR is not a gstack checkout; nothing was changed. Re-run Step 2." >&2; exit 1; }
|
||||
# Regenerable render-footprint dirt (setup re-renders it) never goes into the stash.
|
||||
git checkout -- 'SKILL.md' '*/SKILL.md' '*/sections/*.md' 2>/dev/null || true
|
||||
STASH_OUTPUT=$(git stash 2>&1)
|
||||
git reset --hard origin/main
|
||||
{{SETUP_COMMAND}} --refresh-registered
|
||||
|
||||
@@ -6,26 +6,18 @@
|
||||
* 1. `new URL(import.meta.url).pathname` yields `/C:/Users/...`; path.resolve
|
||||
* then rebases it onto the drive root as `C:\C:\Users\...`. fileURLToPath
|
||||
* is the correct conversion. (ENOENT before the bin ever ran.)
|
||||
* 2. `bin/gstack-*` are extensionless bash scripts. Windows has no shebang
|
||||
* support, so they must be handed to bash explicitly.
|
||||
* 2. `bin/gstack-*` are extensionless shebang scripts. Windows has no shebang
|
||||
* support, so lib/script-invocation.ts hands them to Git Bash (or Bun).
|
||||
*
|
||||
* Also home to runExternal: the contained runner for EXTERNAL executables
|
||||
* (third-party binaries a hook hands data to; see its doc comment). Unlike
|
||||
* runBin it refuses win32, because its guarantee is process-group containment.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { spawn, spawnSync, type SpawnSyncOptions } from 'child_process';
|
||||
|
||||
// Forward slashes on purpose: Bun's spawnSync on Windows returns ENOENT for a
|
||||
// backslash exe path containing spaces.
|
||||
const GIT_BASH = 'C:/Program Files/Git/bin/bash.exe';
|
||||
|
||||
/** bash Windows itself can execute — env override, Git Bash, then PATH. */
|
||||
function bashExe(): string {
|
||||
return process.env.GSTACK_BASH || (fs.existsSync(GIT_BASH) ? GIT_BASH : 'bash');
|
||||
}
|
||||
import { spawn, type SpawnSyncOptions } from 'child_process';
|
||||
import { resolveWindowsBashCached, runScriptSync } from '../../../lib/script-invocation';
|
||||
import { resolveStateRoot } from '../../../lib/state-root';
|
||||
|
||||
/** gstack install root. This file lives at hosts/claude/hooks/. */
|
||||
export function repoRoot(): string {
|
||||
@@ -38,12 +30,15 @@ export function binPath(name: string): string {
|
||||
return path.join(repoRoot(), 'bin', name);
|
||||
}
|
||||
|
||||
/** Resolve `name` under bin/ and run it, via bash on Windows. */
|
||||
/**
|
||||
* Resolve `name` under bin/ and run it through lib/script-invocation.ts. On
|
||||
* Windows the Git Bash resolution is cached in the state root (keyed by the
|
||||
* git.exe path and mtime), so a hook spawns nothing extra on the hot path.
|
||||
*/
|
||||
export function runBin(name: string, args: string[], opts: SpawnSyncOptions) {
|
||||
const bin = binPath(name);
|
||||
return process.platform === 'win32'
|
||||
? spawnSync(bashExe(), [bin, ...args], opts)
|
||||
: spawnSync(bin, args, opts);
|
||||
return runScriptSync(binPath(name), args, opts, {
|
||||
resolve: () => resolveWindowsBashCached(path.join(resolveStateRoot(), 'cache', 'windows-bash.json')),
|
||||
});
|
||||
}
|
||||
|
||||
/** Kept tail of the child's stderr, for the caller's error log. */
|
||||
|
||||
@@ -8,6 +8,7 @@ import { spawnSync, type SpawnSyncReturns } from 'child_process';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { resultLine } from '../../../lib/result-codes';
|
||||
|
||||
export interface DeviceEntry {
|
||||
identifier: string;
|
||||
@@ -267,17 +268,53 @@ export function isAppRunning(
|
||||
}
|
||||
}
|
||||
|
||||
/** The debug bridge's own variable namespace; a launch environment may not set it. */
|
||||
const BRIDGE_ENV_PREFIX = 'GSTACK_';
|
||||
|
||||
/**
|
||||
* Parse GSTACK_IOS_LAUNCH_ENV: a JSON object of string to string (for
|
||||
* example `{"DEBUG_BRIDGE":"1"}`), or unset. A refusal names keys and types,
|
||||
* never values, which may be credentials.
|
||||
*/
|
||||
export function parseLaunchEnv(raw: string | undefined): { ok: true; env: Record<string, string> | null } | { ok: false; detail: string } {
|
||||
if (raw === undefined || raw.trim() === '') return { ok: true, env: null };
|
||||
let value: unknown;
|
||||
try { value = JSON.parse(raw); } catch { return { ok: false, detail: 'GSTACK_IOS_LAUNCH_ENV is not valid JSON' }; }
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return { ok: false, detail: 'GSTACK_IOS_LAUNCH_ENV must be a JSON object of string to string' };
|
||||
for (const [key, v] of Object.entries(value)) {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return { ok: false, detail: `${JSON.stringify(key)} is not an environment variable name` };
|
||||
if (typeof v !== 'string') return { ok: false, detail: `${key} is a ${v === null ? 'null' : typeof v}, not a string` };
|
||||
if (key.startsWith(BRIDGE_ENV_PREFIX)) return { ok: false, detail: `${key} collides with the debug bridge's reserved ${BRIDGE_ENV_PREFIX} variables` };
|
||||
}
|
||||
return { ok: true, env: Object.keys(value).length ? value as Record<string, string> : null };
|
||||
}
|
||||
|
||||
/**
|
||||
* The `--environment-variables` argv for a devicectl launch, from
|
||||
* GSTACK_IOS_LAUNCH_ENV (empty when unset), or the IOS_LAUNCH_ENV_INVALID
|
||||
* line that must stop the launch. Every launch path uses it, so the app is
|
||||
* never started without the variables its debug bridge may be gated on.
|
||||
*/
|
||||
export function launchEnvArgs(raw = process.env.GSTACK_IOS_LAUNCH_ENV): { ok: true; args: string[] } | { ok: false; error: string } {
|
||||
const launchEnv = parseLaunchEnv(raw);
|
||||
if (!launchEnv.ok) return { ok: false, error: resultLine('ios-qa launch', 'IOS_LAUNCH_ENV_INVALID', launchEnv.detail) };
|
||||
return { ok: true, args: launchEnv.env ? ['--environment-variables', JSON.stringify(launchEnv.env)] : [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch an app on the device. Returns true on success, false otherwise.
|
||||
* Locked-device errors (the iPhone needs to be unlocked first) are surfaced
|
||||
* through the error string.
|
||||
* through the error string. GSTACK_IOS_LAUNCH_ENV is forwarded (see
|
||||
* launchEnvArgs); an invalid value refuses before any launch.
|
||||
*/
|
||||
export function launchApp(
|
||||
udid: string,
|
||||
bundleId: string,
|
||||
spawn: SpawnImpl = defaultSpawn,
|
||||
): { ok: boolean; error?: string } {
|
||||
const r = spawn('xcrun', ['devicectl', 'device', 'process', 'launch', '--device', udid, bundleId]);
|
||||
const env = launchEnvArgs();
|
||||
if (!env.ok) return { ok: false, error: env.error };
|
||||
const r = spawn('xcrun', ['devicectl', 'device', 'process', 'launch', '--device', udid, ...env.args, bundleId]);
|
||||
if (r.status === 0) return { ok: true };
|
||||
const err = (r.stderr?.toString() ?? '') + (r.stdout?.toString() ?? '');
|
||||
if (err.includes('was not, or could not be, unlocked')) {
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
getDeviceTunnelIPv6FromDevicectl,
|
||||
isAppRunning,
|
||||
launchApp,
|
||||
launchEnvArgs,
|
||||
copyFileFromAppContainer,
|
||||
type DeviceEntry,
|
||||
type SpawnImpl,
|
||||
@@ -177,10 +178,13 @@ function relaunchApp(
|
||||
bundleId: string,
|
||||
spawn: SpawnImpl = defaultSpawn,
|
||||
): { ok: true } | { ok: false; error: 'device_locked' | 'launch_failed'; detail?: string } {
|
||||
const env = launchEnvArgs();
|
||||
if (!env.ok) return { ok: false, error: 'launch_failed', detail: env.error };
|
||||
const r = spawn('xcrun', [
|
||||
'devicectl', 'device', 'process', 'launch',
|
||||
'--device', udid,
|
||||
'--terminate-existing',
|
||||
...env.args,
|
||||
bundleId,
|
||||
]);
|
||||
if (r.status === 0) return { ok: true };
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// GSTACK_IOS_LAUNCH_ENV (split from #1796, credit @Bmathews721): a JSON object
|
||||
// of string to string forwarded to `devicectl device process launch
|
||||
// --environment-variables`. An invalid value refuses the launch with
|
||||
// IOS_LAUNCH_ENV_INVALID instead of launching the app without it.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import type { SpawnSyncReturns } from 'child_process';
|
||||
import { writeFileSync } from 'fs';
|
||||
import { launchApp, launchEnvArgs, parseLaunchEnv, type SpawnImpl } from '../src/devicectl';
|
||||
import { bootstrapTunnel } from '../src/tunnel-bootstrap';
|
||||
|
||||
function recorder(status = 0): { spawn: SpawnImpl; calls: string[][] } {
|
||||
const calls: string[][] = [];
|
||||
const spawn: SpawnImpl = (cmd, args) => {
|
||||
calls.push([cmd, ...args]);
|
||||
return { status, stdout: Buffer.from(''), stderr: Buffer.from(''), pid: 1, output: [], signal: null } as unknown as SpawnSyncReturns<Buffer>;
|
||||
};
|
||||
return { spawn, calls };
|
||||
}
|
||||
|
||||
let saved: string | undefined;
|
||||
beforeEach(() => { saved = process.env.GSTACK_IOS_LAUNCH_ENV; delete process.env.GSTACK_IOS_LAUNCH_ENV; });
|
||||
afterEach(() => { if (saved === undefined) delete process.env.GSTACK_IOS_LAUNCH_ENV; else process.env.GSTACK_IOS_LAUNCH_ENV = saved; });
|
||||
|
||||
describe('GSTACK_IOS_LAUNCH_ENV', () => {
|
||||
test('unset or empty leaves the launch argv unchanged', () => {
|
||||
for (const value of [undefined, '', ' ', '{}']) {
|
||||
if (value === undefined) delete process.env.GSTACK_IOS_LAUNCH_ENV; else process.env.GSTACK_IOS_LAUNCH_ENV = value;
|
||||
const { spawn, calls } = recorder();
|
||||
expect(launchApp('UDID', 'com.test', spawn)).toEqual({ ok: true });
|
||||
expect(calls).toEqual([['xcrun', 'devicectl', 'device', 'process', 'launch', '--device', 'UDID', 'com.test']]);
|
||||
}
|
||||
});
|
||||
|
||||
test('a JSON object is forwarded before the bundle id', () => {
|
||||
process.env.GSTACK_IOS_LAUNCH_ENV = '{"DEBUG_BRIDGE":"1","API_BASE":"http://localhost:8080"}';
|
||||
const launch = recorder();
|
||||
expect(launchApp('UDID', 'com.test', launch.spawn)).toEqual({ ok: true });
|
||||
expect(launch.calls[0]).toEqual(['xcrun', 'devicectl', 'device', 'process', 'launch', '--device', 'UDID',
|
||||
'--environment-variables', '{"DEBUG_BRIDGE":"1","API_BASE":"http://localhost:8080"}', 'com.test']);
|
||||
expect(launchEnvArgs()).toEqual({ ok: true, args: ['--environment-variables', '{"DEBUG_BRIDGE":"1","API_BASE":"http://localhost:8080"}'] });
|
||||
expect(launchEnvArgs('')).toEqual({ ok: true, args: [] });
|
||||
});
|
||||
|
||||
test('invalid JSON, a non-object, a non-string value, a bad name or a bridge-reserved key refuses without launching', () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
['{"A":', 'is not valid JSON'],
|
||||
['["A=1"]', 'must be a JSON object of string to string'],
|
||||
['null', 'must be a JSON object of string to string'],
|
||||
['{"PORT":8080}', 'PORT is a number, not a string'],
|
||||
['{"BAD-NAME":"x"}', '"BAD-NAME" is not an environment variable name'],
|
||||
['{"GSTACK_IOS_QA_PORT":"1"}', 'GSTACK_IOS_QA_PORT collides with the debug bridge\'s reserved GSTACK_ variables'],
|
||||
];
|
||||
for (const [value, detail] of cases) {
|
||||
process.env.GSTACK_IOS_LAUNCH_ENV = value;
|
||||
const { spawn, calls } = recorder();
|
||||
const r = launchApp('UDID', 'com.test', spawn);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.error).toContain(detail);
|
||||
expect(r.error).toEndWith('(IOS_LAUNCH_ENV_INVALID)');
|
||||
expect(calls).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
test('a refusal never echoes a value', () => {
|
||||
process.env.GSTACK_IOS_LAUNCH_ENV = '{"TOKEN":"sk-secret-value","N":1}';
|
||||
const r = launchApp('UDID', 'com.test', recorder().spawn);
|
||||
expect(r.error).not.toContain('sk-secret-value');
|
||||
expect(parseLaunchEnv('{"TOKEN": "sk-secret-value"')).toEqual({ ok: false, detail: expect.not.stringContaining('sk-secret-value') });
|
||||
});
|
||||
|
||||
/** A running app whose boot token an earlier daemon consumed: bootstrap must force one relaunch. */
|
||||
function consumedTokenDevice() {
|
||||
const calls: string[][] = [];
|
||||
let copies = 0;
|
||||
const out = (args: string[], flag: string, body: string) => { const i = args.indexOf(flag); if (i !== -1) writeFileSync(args[i + 1]!, body); };
|
||||
const spawn: SpawnImpl = (cmd, args) => {
|
||||
calls.push([cmd, ...args]);
|
||||
const joined = args.join(' ');
|
||||
let status = 0;
|
||||
if (joined.includes('list devices')) out(args, '--json-output', JSON.stringify({ result: { devices: [{ identifier: 'UDID', connectionProperties: { tunnelState: 'connected', pairingState: 'paired' }, deviceProperties: { name: 'Test' }, hardwareProperties: { productType: 'iPhone18,2' } }] } }));
|
||||
else if (joined.includes('info processes')) out(args, '--json-output', JSON.stringify({ result: { runningProcesses: [{ executable: 'file:///var/containers/Bundle/Application/X/com.test.app/com.test' }] } }));
|
||||
else if (joined.includes('info details')) out(args, '--json-output', JSON.stringify({ result: { connectionProperties: { tunnelIPAddress: 'fd99::beef' } } }));
|
||||
else if (joined.includes('copy from')) { copies++; if (copies === 1) status = 1; else out(args, '--destination', 'FRESH\n'); }
|
||||
return { status, stdout: Buffer.from(''), stderr: Buffer.from(''), pid: 1, output: [], signal: null } as unknown as SpawnSyncReturns<Buffer>;
|
||||
};
|
||||
const fetchImpl = (async (url: string | URL, init?: RequestInit) => {
|
||||
if (String(url).endsWith('/healthz')) return new Response(JSON.stringify({ bundle_id: 'com.test' }), { status: 200 });
|
||||
const auth = (init?.headers as Record<string, string> | undefined)?.Authorization;
|
||||
return new Response(auth === 'Bearer FRESH' ? '{"ok":true}' : 'no', { status: auth === 'Bearer FRESH' ? 200 : 401 });
|
||||
}) as typeof fetch;
|
||||
return { calls, spawn, fetchImpl, launches: () => calls.filter(c => c.includes('launch')) };
|
||||
}
|
||||
|
||||
test('the forced relaunch carries the launch environment too', async () => {
|
||||
process.env.GSTACK_IOS_LAUNCH_ENV = '{"DEBUG_BRIDGE":"1"}';
|
||||
const device = consumedTokenDevice();
|
||||
const r = await bootstrapTunnel({ bundleId: 'com.test', spawnImpl: device.spawn, fetchImpl: device.fetchImpl, startupTimeoutMs: 1_000 });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(device.launches()).toEqual([['xcrun', 'devicectl', 'device', 'process', 'launch', '--device', 'UDID', '--terminate-existing', '--environment-variables', '{"DEBUG_BRIDGE":"1"}', 'com.test']]);
|
||||
});
|
||||
|
||||
test('an invalid launch environment refuses the forced relaunch without terminating the app', async () => {
|
||||
process.env.GSTACK_IOS_LAUNCH_ENV = '{"PORT":1}';
|
||||
const device = consumedTokenDevice();
|
||||
const r = await bootstrapTunnel({ bundleId: 'com.test', spawnImpl: device.spawn, fetchImpl: device.fetchImpl, startupTimeoutMs: 1_000 });
|
||||
expect(r).toMatchObject({ ok: false, error: 'launch_failed' });
|
||||
expect(r.ok ? '' : r.detail).toEndWith('(IOS_LAUNCH_ENV_INVALID)');
|
||||
expect(device.launches()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@
|
||||
* hosts' ambient spawned sessions (a warning line), are not refused.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { RunError } from './autoplan-state';
|
||||
import { checkInstallAdmission } from './install-verdict';
|
||||
@@ -17,6 +18,18 @@ const ROOT = path.resolve(import.meta.dir, '..');
|
||||
|
||||
export interface Admission { lines: string[]; deviation?: string }
|
||||
|
||||
/**
|
||||
* A workflow lease on the shared installation (bin/gstack-lease): the upgrader
|
||||
* refuses UPGRADE_BUSY while a live lease it does not own exists, so a
|
||||
* session-start fast-forward cannot move the checkout under a running
|
||||
* autoplan driver. Fail-open: a missing or failing bin never blocks the run.
|
||||
*/
|
||||
export function touchInstallLease(env: NodeJS.ProcessEnv = process.env): void {
|
||||
const bin = path.join(ROOT, 'bin', 'gstack-lease');
|
||||
if (process.platform === 'win32' || !fs.existsSync(bin)) return;
|
||||
try { spawnSync(bin, ['touch', '--root', ROOT, '--from', String(process.ppid)], { encoding: 'utf8', timeout: 10_000, env, stdio: 'ignore' }); } catch { /* fail-open */ }
|
||||
}
|
||||
|
||||
/** Throws RunError (exit 3) unless the session may write; an override or a warning returns the line to print. */
|
||||
export function admit(env: NodeJS.ProcessEnv = process.env): Admission {
|
||||
const bunVersion = spawnSync('bun', ['--version'], { encoding: 'utf8', timeout: 30_000, env }).stdout?.trim() || '0.0.0';
|
||||
|
||||
@@ -81,7 +81,7 @@ function runLog(state: RunState, findings: number, tasks: number, decisions: { t
|
||||
}
|
||||
for (const h of state.history) lines.push(`Reopened ${h.phase} at gate_rev ${h.gate_rev}: ${h.reason} (${h.at})`);
|
||||
if (state.history.length) lines.push('');
|
||||
lines.push(`Gate: ${state.gate_rev ? `gate_rev ${state.gate_rev}, ${decisions.total} decisions (${decisions.pending} pending)${state.gate && typeof (state.gate as any).reply === 'string' ? `; answered "${(state.gate as any).reply}"` : ''}` : 'not reached'}. Findings: ${findings}. Tasks: ${tasks}.`);
|
||||
lines.push(`Gate: ${state.gate_rev ? `gate_rev ${state.gate_rev}, ${decisions.total} decisions (${decisions.pending} pending)${state.gate && typeof (state.gate as any).reply === 'string' ? `; answered "${(state.gate as any).reply}"${typeof (state.gate as any).answered_by === 'string' ? ` answered by ${(state.gate as any).answered_by}` : ''}` : ''}` : 'not reached'}. Findings: ${findings}. Tasks: ${tasks}.`);
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import { consensusRecord, reconcile, renderConsensus, type ConsensusRecord, type
|
||||
import type { Phase } from './autoplan-prompts';
|
||||
import { FILES, RunError, journal as journalRow, now, readDecisions, voiceRows, writeDecisions, type RunState, type VoiceState } from './autoplan-state';
|
||||
import { phaseClose } from './autoplan-timing';
|
||||
import { applyReply, decisionId, decisionRows, keepDirection, parseReply, validateGateList, type GateChoice, type GateItem, type GateList, type GateOption } from './gate-list';
|
||||
import { ANSWERED_BY_RE, applyReply, decisionId, decisionRows, keepDirection, parseReply, pendingHint, validateGateList, type GateChoice, type GateItem, type GateList, type GateOption } from './gate-list';
|
||||
import type { FindingRow } from './headless-artifacts';
|
||||
import { readJsonl } from './headless-artifacts';
|
||||
import { closeDraftDirectionsFile, loadBriefInputs } from './owner-brief';
|
||||
@@ -189,9 +189,11 @@ export interface AnswerResult { state: RunState; applied: boolean; pending: stri
|
||||
* prints `APPLY:` and becomes a pending amendment (`next` refuses until
|
||||
* `revise --incorporates` or `--withdraw`).
|
||||
*/
|
||||
export function answerGate(state: RunState, gateRev: number, reply: string): AnswerResult {
|
||||
export function answerGate(state: RunState, gateRev: number, reply: string, answeredBy?: string): AnswerResult {
|
||||
const gateFile = path.join(state.out, 'gate.json');
|
||||
if (state.status !== 'gate_pending' || !fs.existsSync(gateFile)) throw new RunError('PHASE_NOT_AWAITING', `run is ${state.status}, not gate_pending`, 3);
|
||||
if (answeredBy !== undefined && !ANSWERED_BY_RE.test(answeredBy)) throw new RunError('ANSWER_BY_REQUIRED', `--by ${JSON.stringify(answeredBy)} is neither human nor agent:<id>`, 3);
|
||||
if (answeredBy === undefined && state.session_kind === 'unattended') throw new RunError('ANSWER_BY_REQUIRED', 'an unattended run records who answered; pass --by human or --by agent:<id>', 3);
|
||||
const list = JSON.parse(fs.readFileSync(gateFile, 'utf8')) as GateList;
|
||||
const rows = readDecisions(state.out);
|
||||
const prior = Object.fromEntries(rows.filter(r => r.gate_rev === list.gate_rev && r.chosen != null && r.status !== 'pending' && r.label).map(r => [r.label!.toLowerCase(), r.chosen!]));
|
||||
@@ -203,7 +205,7 @@ export function answerGate(state: RunState, gateRev: number, reply: string): Ans
|
||||
const plan = parsed.choices.find(c => c.id === 'p1');
|
||||
const lines: string[] = [];
|
||||
const answeredAt = now();
|
||||
const record = () => writeDecisions(state.out, applyReply(rows, parsed, answeredAt).map(r => {
|
||||
const record = () => writeDecisions(state.out, applyReply(rows, parsed, answeredAt, answeredBy).map(r => {
|
||||
const c = r.gate_rev === list.gate_rev && r.chosen && !r.chosen_text ? itemOf((r.label ?? '').toLowerCase())?.options.find(o => o.key === r.chosen) : undefined;
|
||||
return c ? { ...r, chosen_text: c.text } : r;
|
||||
}));
|
||||
@@ -212,7 +214,7 @@ export function answerGate(state: RunState, gateRev: number, reply: string): Ans
|
||||
record();
|
||||
const pending = parsed.pending.length ? parsed.pending : ['p1'];
|
||||
state.gate = { ...(state.gate ?? {}), last_reply: reply, pending };
|
||||
lines.push(`GATE_PENDING: ${pending.join(', ')} unanswered (reply with p1a..p1e or all)`);
|
||||
lines.push(`GATE_PENDING: ${pending.join(', ')} unanswered (${pendingHint(list, pending)})`);
|
||||
return { state, applied: true, pending, reopened: [], outcome: 'gate_pending', lines };
|
||||
}
|
||||
const proceeds = plan.chosen !== 'c' && plan.chosen !== 'e';
|
||||
@@ -227,8 +229,8 @@ export function answerGate(state: RunState, gateRev: number, reply: string): Ans
|
||||
if (phase) { reopenSet.add(phase); if (state.phase_order.includes('eng')) reopenSet.add('eng'); }
|
||||
}
|
||||
record();
|
||||
state.gate = { ...(state.gate ?? {}), answered_at: answeredAt, reply, gate_rev: gateRev, plan: plan.chosen, overrides: tokens(overrides) };
|
||||
journalRow(state.out, { event: 'answered', gate_rev: gateRev, reply, plan: plan.chosen, overrides: tokens(overrides), changes: tokens(changing), reopen: [...reopenSet] });
|
||||
state.gate = { ...(state.gate ?? {}), answered_at: answeredAt, reply, gate_rev: gateRev, plan: plan.chosen, overrides: tokens(overrides), ...(answeredBy ? { answered_by: answeredBy } : {}) };
|
||||
journalRow(state.out, { event: 'answered', gate_rev: gateRev, reply, plan: plan.chosen, overrides: tokens(overrides), changes: tokens(changing), reopen: [...reopenSet], ...(answeredBy ? { answered_by: answeredBy } : {}) });
|
||||
if (plan.chosen === 'c') {
|
||||
lines.push(`GATE_PENDING: interrogate recorded; the gate stays at gate_rev=${gateRev}`);
|
||||
return { state, applied: true, pending: ['p1'], reopened: [], outcome: 'gate_pending', lines };
|
||||
|
||||
@@ -39,7 +39,7 @@ export interface Scope {
|
||||
}
|
||||
|
||||
export function snapshotTool(args: string[], cwd: string): any {
|
||||
const r = spawnSync('bun', [SNAPSHOT_TOOL, ...args], { cwd, encoding: 'utf8', timeout: 120_000, env: process.env });
|
||||
const r = spawnSync(process.execPath, [SNAPSHOT_TOOL, ...args], { cwd, encoding: 'utf8', timeout: 120_000, env: process.env });
|
||||
if (r.status !== 0) throw new Error(`gstack-autoplan-snapshot ${args[0]}: ${(r.stderr || r.stdout).trim()}`);
|
||||
return JSON.parse(r.stdout);
|
||||
}
|
||||
|
||||
+9
-7
@@ -145,14 +145,16 @@ function openPhase(state: RunState, phase: Phase, cwd: string): void {
|
||||
if (ps.snapshot) return;
|
||||
const methodology = snapshotTool(['methodology', phase, reviewSkillFile(phase), state.restore], cwd);
|
||||
const snap = snapshotTool(['create', phase, state.active_plan, state.restore, methodology.methodologyPath], cwd);
|
||||
// Every later phase reviews with its closed predecessors' consensus as data
|
||||
// (not only Eng): a DX or design reviewer who never saw the CEO consensus
|
||||
// re-raises what the CEO phase already reconciled (field gate rev 2).
|
||||
const prior: Array<{ phase: string; text: string }> = [];
|
||||
if (phase === 'eng') {
|
||||
for (const p of state.phase_order) {
|
||||
if (p === 'eng' || !state.phases[p]?.closed_at) continue;
|
||||
const file = path.join(state.out, `${p}-consensus.md`);
|
||||
if (!fs.existsSync(file)) throw new RunError('CONSENSUS_MISSING', `${p} closed without ${p}-consensus.md`, 3);
|
||||
prior.push({ phase: p, text: fs.readFileSync(file, 'utf8') });
|
||||
}
|
||||
for (const p of state.phase_order) {
|
||||
if (p === phase) break;
|
||||
if (!state.phases[p]?.closed_at) continue;
|
||||
const file = path.join(state.out, `${p}-consensus.md`);
|
||||
if (!fs.existsSync(file)) throw new RunError('CONSENSUS_MISSING', `${p} closed without ${p}-consensus.md`, 3);
|
||||
prior.push({ phase: p, text: fs.readFileSync(file, 'utf8') });
|
||||
}
|
||||
const sections = sectionLoadFor(phase, state.scope, state.session_kind === 'unattended' || state.light ? 'light' : 'full');
|
||||
const decided = (state.gate_rev ?? 0) >= 1 ? readDecisions(state.out).filter(r => r.chosen != null && r.status !== 'pending')
|
||||
|
||||
@@ -236,6 +236,8 @@ export interface RecordMeta {
|
||||
subtype?: string;
|
||||
attachmentType?: string;
|
||||
version?: string;
|
||||
/** A compact boundary's preserved message uuids, ending with its logical parent (Claude Code 2.1.300). */
|
||||
preserved?: readonly string[];
|
||||
}
|
||||
|
||||
/** Claude writes each SessionStart hook result as a message-less attachment chained above the first user turn. */
|
||||
@@ -243,6 +245,22 @@ const sessionStartRecord = (r: Record<string, any>): boolean => r.type === 'atta
|
||||
object(r.attachment) && r.attachment.hookEvent === 'SessionStart' &&
|
||||
typeof r.attachment.type === 'string' && /^hook_[a-z_]{1,48}$/.test(r.attachment.type);
|
||||
|
||||
const PRESERVED_MAX = 1024;
|
||||
/**
|
||||
* Claude Code 2.1.300 keeps a compaction's last records in place and lists them in
|
||||
* compactMetadata.preservedMessages, ending with the segment's tail, which is also the
|
||||
* boundary's logicalParentUuid. Only such a list of native uuids is read.
|
||||
*/
|
||||
function preservedUuids(r: Record<string, any>): readonly string[] | undefined {
|
||||
if (r.type !== 'system' || r.subtype !== 'compact_boundary' || r.parentUuid !== null || !nativeUuid(r.logicalParentUuid)) return;
|
||||
const meta = r.compactMetadata;
|
||||
if (!object(meta) || !object(meta.preservedSegment) || !object(meta.preservedMessages)) return;
|
||||
const uuids = meta.preservedMessages.uuids;
|
||||
if (meta.preservedSegment.tailUuid !== r.logicalParentUuid || !Array.isArray(uuids) ||
|
||||
uuids.length > PRESERVED_MAX || uuids.at(-1) !== r.logicalParentUuid || !uuids.every(nativeUuid)) return;
|
||||
return uuids;
|
||||
}
|
||||
|
||||
/** Record type names only, never content: `attachment:hook_success`, `system:compact_boundary`. */
|
||||
export const shapeOf = (m: RecordMeta): string => [m.type, m.subtype, m.attachmentType]
|
||||
.filter(x => typeof x === 'string' && x).map(x => x!.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 40)).join(':') || 'untyped';
|
||||
@@ -270,6 +288,7 @@ export function recordMeta(r: Record<string, any>, index: number): RecordMeta {
|
||||
type: typeof r.type === 'string' ? r.type : undefined, subtype: typeof r.subtype === 'string' ? r.subtype : undefined,
|
||||
attachmentType: object(r.attachment) && typeof r.attachment.type === 'string' ? r.attachment.type : undefined,
|
||||
version: indexedVersion(r),
|
||||
preserved: preservedUuids(r),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -294,13 +313,32 @@ export function ownedCausalOrder(metas: readonly RecordMeta[], cwd: string): Own
|
||||
}
|
||||
const byId = { get: (id: string) => { const m = byUuid.get(id); return m?.node ? m : undefined; } };
|
||||
if (byId.get(first.uuid ?? '') !== first) return { reason: excludedOf(first), shape: [shapeOf(first)] };
|
||||
// Claude Code 2.1.300 writes a compaction's preserved tail after the boundary, under the
|
||||
// compact summary, so the boundary's logical parent descends from the boundary itself.
|
||||
// Such a boundary continues from the last preserved record that is a node of this file
|
||||
// and not its own descendant. With none, the cycle stays and supplies no ownership.
|
||||
const descends = (id: string | undefined, from: RecordMeta): boolean => {
|
||||
for (const visited = new Set<RecordMeta>(); id;) {
|
||||
const m = byId.get(id);
|
||||
if (!m || visited.has(m)) return false;
|
||||
if (m === from) return true;
|
||||
visited.add(m); id = m.parent;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const resumed = new Map<RecordMeta, string>();
|
||||
for (const m of nodes) if (m.preserved && descends(m.parent, m)) {
|
||||
const anchor = m.preserved.slice(0, -1).findLast(id => byId.get(id) !== undefined && !descends(id, m));
|
||||
if (anchor) resumed.set(m, anchor);
|
||||
}
|
||||
const parentOf = (m: RecordMeta) => resumed.get(m) ?? m.parent;
|
||||
// Anchor through the first observed conversation node, never an unrelated
|
||||
// later root. An unflushed ancestor supplies no ownership yet.
|
||||
const chain: RecordMeta[] = [first], seen = new Set<RecordMeta>(chain);
|
||||
const shape = () => chain.map(shapeOf).reverse().slice(0, 32);
|
||||
let genesis = false;
|
||||
for (let at = first; ;) {
|
||||
const id = at.parent;
|
||||
const id = parentOf(at);
|
||||
if (!id) break;
|
||||
const next = byId.get(id);
|
||||
if (!next) {
|
||||
@@ -320,9 +358,9 @@ export function ownedCausalOrder(metas: readonly RecordMeta[], cwd: string): Own
|
||||
const top = chain.toReversed(), root = top[0]!;
|
||||
const turn = top.findIndex(n => n.message);
|
||||
const children = new Map<string, RecordMeta[]>();
|
||||
for (const node of nodes) if (node.parent) {
|
||||
const list = children.get(node.parent) ?? []; list.push(node); children.set(node.parent, list);
|
||||
}
|
||||
for (const node of nodes) { const parent = parentOf(node); if (parent) {
|
||||
const list = children.get(parent) ?? []; list.push(node); children.set(parent, list);
|
||||
} }
|
||||
// Uniqueness over every root candidate: a null-parent user, a SessionStart
|
||||
// chain head carrying a conversation, and the resumed-genesis boundary.
|
||||
const carriesTurn = (head: RecordMeta): boolean => {
|
||||
@@ -370,5 +408,36 @@ export function ownedCausalOrder(metas: readonly RecordMeta[], cwd: string): Own
|
||||
order.push(node.index);
|
||||
for (const child of children.get(node.uuid!) ?? []) offer(child.index);
|
||||
}
|
||||
const boundary = compactionCycle(nodes, new Set(order), byId, parentOf);
|
||||
if (boundary) return { reason: 'unrecognized_shape:compaction_unreachable', shape: [shapeOf(boundary), ...shape()] };
|
||||
return { order, root: root.uuid };
|
||||
}
|
||||
|
||||
/**
|
||||
* An owned node the walk never reached is silently dropped, which the guard reads
|
||||
* as an unread close packet. When that node's ancestry ends in a cycle through a
|
||||
* compact boundary's logical parent, the cause is a compaction shape this reader
|
||||
* does not know, so it is reported as one. An orphan whose ancestor is missing
|
||||
* (not yet flushed) or another root keeps being dropped. Each node is walked once.
|
||||
*/
|
||||
function compactionCycle(nodes: readonly RecordMeta[], reached: ReadonlySet<number>,
|
||||
byId: { get(id: string): RecordMeta | undefined }, parentOf: (m: RecordMeta) => string | undefined): RecordMeta | undefined {
|
||||
const verdict = new Map<RecordMeta, RecordMeta | null>();
|
||||
const isBoundary = (m: RecordMeta) => m.type === 'system' && m.subtype === 'compact_boundary' && m.parentNull && m.parent !== undefined;
|
||||
for (const start of nodes) {
|
||||
if (reached.has(start.index) || verdict.has(start)) continue;
|
||||
const path: RecordMeta[] = [], onPath = new Map<RecordMeta, number>();
|
||||
let result: RecordMeta | null = null;
|
||||
for (let at: RecordMeta | undefined = start; at;) {
|
||||
if (verdict.has(at)) { result = verdict.get(at)!; break; }
|
||||
const loop = onPath.get(at);
|
||||
if (loop !== undefined) { result = path.slice(loop).find(isBoundary) ?? null; break; }
|
||||
onPath.set(at, path.length); path.push(at);
|
||||
const id = parentOf(at);
|
||||
at = id ? byId.get(id) : undefined;
|
||||
}
|
||||
for (const m of path) verdict.set(m, result);
|
||||
if (result) return result;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
+27
-18
@@ -59,6 +59,17 @@ function dockerTimeout(deadline: number | undefined, maximum: number): number {
|
||||
if (remaining <= 0) throw new CsoError('DEADLINE', 'Docker operation reached its aggregate deadline');
|
||||
return Math.max(1, Math.min(maximum, remaining));
|
||||
}
|
||||
// `executable('docker')` is the canonical path, which for OrbStack is the
|
||||
// multicall `docker-tools` binary. It dispatches on argv[0] and exits 127 with
|
||||
// `unsupported argv0 "docker-tools"` unless it is told it was invoked as docker.
|
||||
export const DOCKER_ARGV0 = 'docker';
|
||||
export function runDocker(
|
||||
file: string,
|
||||
args: string[],
|
||||
opts: Omit<Parameters<typeof runProcess>[2], 'argv0'>,
|
||||
): ReturnType<typeof runProcess> {
|
||||
return runProcess(file, args, { ...opts, argv0: DOCKER_ARGV0 });
|
||||
}
|
||||
function deadlineExpired(deadline: number | undefined): boolean {
|
||||
return deadline !== undefined && Date.now() >= deadline;
|
||||
}
|
||||
@@ -84,7 +95,7 @@ export async function dockerEndpoint(
|
||||
};
|
||||
let context = env.DOCKER_CONTEXT;
|
||||
if (!context) {
|
||||
const shown = await runProcess(docker, ['context', 'show'], {
|
||||
const shown = await runDocker(docker, ['context', 'show'], {
|
||||
cwd: home,
|
||||
env: inspectEnv,
|
||||
raw: true,
|
||||
@@ -102,7 +113,7 @@ export async function dockerEndpoint(
|
||||
}
|
||||
if (!/^[A-Za-z0-9_.-]{1,100}$/.test(context))
|
||||
throw new CsoError('ISOLATION_FAILED', 'Invalid Docker context name');
|
||||
const result = await runProcess(
|
||||
const result = await runDocker(
|
||||
docker,
|
||||
['context', 'inspect', context, '--format', '{{json .Endpoints.docker.Host}}'],
|
||||
{ cwd: home, env: inspectEnv, raw: true, timeoutMs: dockerTimeout(deadline, 5000), maxBytes: 8192 },
|
||||
@@ -210,7 +221,7 @@ export async function dockerProbe(
|
||||
): Promise<{ version: string; security: string[] }> {
|
||||
assertEndpoint(endpoint);
|
||||
const config = secureDirectory(join(home, 'docker-config'));
|
||||
const r = await runProcess(endpoint.executable, ['info', '--format', '{{json .}}'], {
|
||||
const r = await runDocker(endpoint.executable, ['info', '--format', '{{json .}}'], {
|
||||
cwd: home,
|
||||
env: dockerEnvironment(endpoint, config),
|
||||
raw: true,
|
||||
@@ -260,17 +271,13 @@ export async function dockerExactImagePresent(
|
||||
assertExactCatalogImage(image);
|
||||
assertEndpoint(endpoint);
|
||||
const config = secureDirectory(join(home, 'docker-config'));
|
||||
const result = await runProcess(
|
||||
endpoint.executable,
|
||||
['image', 'inspect', '--format', '{{json .}}', image],
|
||||
{
|
||||
cwd: home,
|
||||
env: dockerEnvironment(endpoint, config),
|
||||
raw: true,
|
||||
timeoutMs: dockerTimeout(deadline, 5_000),
|
||||
maxBytes: 128 * 1024,
|
||||
},
|
||||
);
|
||||
const result = await runDocker(endpoint.executable, ['image', 'inspect', '--format', '{{json .}}', image], {
|
||||
cwd: home,
|
||||
env: dockerEnvironment(endpoint, config),
|
||||
raw: true,
|
||||
timeoutMs: dockerTimeout(deadline, 5_000),
|
||||
maxBytes: 128 * 1024,
|
||||
});
|
||||
assertEndpoint(endpoint);
|
||||
if (deadlineExpired(deadline) || result.timedOut)
|
||||
throw new CsoError('DEADLINE', 'Exact image inspection reached its bounded image-provisioning deadline');
|
||||
@@ -304,7 +311,7 @@ export async function dockerPullExactCatalogImage(
|
||||
assertExactCatalogImage(image);
|
||||
assertEndpoint(endpoint);
|
||||
const config = secureDirectory(join(home, 'docker-config'));
|
||||
const result = await runProcess(endpoint.executable, ['pull', '--quiet', '--platform', platform, image], {
|
||||
const result = await runDocker(endpoint.executable, ['pull', '--quiet', '--platform', platform, image], {
|
||||
cwd: home,
|
||||
env: dockerEnvironment(endpoint, config),
|
||||
timeoutMs: dockerTimeout(deadline, 300_000),
|
||||
@@ -555,7 +562,7 @@ export class DockerGroup {
|
||||
private async docker(args: string[], max = 128 * 1024) {
|
||||
assertEndpoint(this.endpoint);
|
||||
const remaining = Math.max(1, Math.min(300_000, this.deadline - Date.now()));
|
||||
const r = await runProcess(this.endpoint.executable, args, {
|
||||
const r = await runDocker(this.endpoint.executable, args, {
|
||||
cwd: this.dir,
|
||||
env: dockerEnvironment(this.endpoint, this.config),
|
||||
raw: true,
|
||||
@@ -930,6 +937,7 @@ export class DockerGroup {
|
||||
workdir?: string;
|
||||
env?: Record<string, string>;
|
||||
redaction?: 'withhold' | 'splice';
|
||||
stdoutFormat?: 'json';
|
||||
preparationCommand?: true;
|
||||
} = {},
|
||||
): Promise<{ code: number; stdout: string; stderr: string }> {
|
||||
@@ -948,12 +956,13 @@ export class DockerGroup {
|
||||
args.push('--env', `${key}=${value}`);
|
||||
}
|
||||
args.push(id, ...command);
|
||||
const r = await runProcess(this.endpoint.executable, args, {
|
||||
const r = await runDocker(this.endpoint.executable, args, {
|
||||
cwd: this.dir,
|
||||
env: dockerEnvironment(this.endpoint, this.config),
|
||||
timeoutMs: remaining,
|
||||
maxBytes: this.remainingOutput,
|
||||
redaction: options.redaction,
|
||||
stdoutFormat: options.stdoutFormat,
|
||||
preparationCommand: options.preparationCommand,
|
||||
});
|
||||
this.remainingOutput = Math.max(0, this.remainingOutput - r.capturedBytes);
|
||||
@@ -975,7 +984,7 @@ export class DockerGroup {
|
||||
if (this.remainingOutput <= 0)
|
||||
throw new CsoError('REDACTION_FAILED', 'Reproduction-group output budget is exhausted');
|
||||
const remaining = Math.max(1, Math.min(300_000, this.deadline - Date.now()));
|
||||
const r = await runProcess(this.endpoint.executable, ['start', '--attach', id], {
|
||||
const r = await runDocker(this.endpoint.executable, ['start', '--attach', id], {
|
||||
cwd: this.dir,
|
||||
env: dockerEnvironment(this.endpoint, this.config),
|
||||
timeoutMs: remaining,
|
||||
|
||||
+51
-4
@@ -13,7 +13,7 @@ import {
|
||||
statSync,
|
||||
} from 'node:fs';
|
||||
import { basename, dirname, join, isAbsolute, delimiter, resolve } from 'node:path';
|
||||
import { redactFindingSpans } from '../redact-engine';
|
||||
import { redactFindingSpans, redactJsonFindingSpans } from '../redact-engine';
|
||||
import { CsoError, MAX_OUTPUT } from './contracts';
|
||||
import { windowsDockerUnavailable } from './windows-docker';
|
||||
|
||||
@@ -84,12 +84,15 @@ export function childEnvironment(home: string): Record<string, string> {
|
||||
* released only when every view a reader could assemble from it (each channel,
|
||||
* both concatenation orders, and the chronological interleaving) is free of
|
||||
* findings; a span crossing a channel boundary or one that cannot be located
|
||||
* therefore still withholds both channels.
|
||||
* therefore still withholds both channels. A JSON-declared stdout is redacted
|
||||
* as one concatenated stream with redactJsonFindingSpans, so it stays
|
||||
* parseable, and every view is then checked the same way.
|
||||
*/
|
||||
function spliceRedaction(
|
||||
ordered: Buffer[],
|
||||
streams: Array<Buffer[]>,
|
||||
out: Buffer[],
|
||||
stdoutFormat?: 'json',
|
||||
): { stdout: string; stderr: string } | null {
|
||||
const runs: Array<{ stdout: boolean; text: string }> = [];
|
||||
for (let i = 0; i < ordered.length;) {
|
||||
@@ -101,9 +104,14 @@ function spliceRedaction(
|
||||
runs.push({ stdout: streams[i] === out, text });
|
||||
i = j;
|
||||
}
|
||||
const json =
|
||||
stdoutFormat === 'json'
|
||||
? jsonStdoutRuns(runs.filter((run) => run.stdout).map((run) => run.text))
|
||||
: undefined;
|
||||
if (json === null) return null;
|
||||
const released: Array<{ stdout: boolean; text: string }> = [];
|
||||
for (const run of runs) {
|
||||
const text = redactFindingSpans(run.text, { maxBytes: MAX_OUTPUT });
|
||||
const text = json && run.stdout ? json.shift()! : redactFindingSpans(run.text, { maxBytes: MAX_OUTPUT });
|
||||
if (text === null) return null;
|
||||
released.push({ stdout: run.stdout, text });
|
||||
}
|
||||
@@ -120,6 +128,32 @@ function spliceRedaction(
|
||||
if (views.some((view) => redactFindingSpans(view, { maxBytes: 4 * MAX_OUTPUT }) !== view)) return null;
|
||||
return { stdout, stderr };
|
||||
}
|
||||
/**
|
||||
* Each stdout run's share of the JSON-redacted concatenation. A replacement
|
||||
* that straddles a run boundary (a number split by interleaved stderr) goes
|
||||
* whole to the run where it starts, so no share holds part of a marker.
|
||||
*/
|
||||
function jsonStdoutRuns(texts: string[]): string[] | null {
|
||||
const redacted = redactJsonFindingSpans(texts.join(''), { maxBytes: MAX_OUTPUT });
|
||||
if (!redacted) return null;
|
||||
const { body, edits } = redacted;
|
||||
const shares: string[] = [];
|
||||
let raw = 0,
|
||||
cut = 0,
|
||||
shift = 0,
|
||||
from = 0,
|
||||
next = 0;
|
||||
for (const text of texts) {
|
||||
raw += text.length;
|
||||
cut = Math.max(cut, raw);
|
||||
cut = edits.find((edit) => edit.start < cut && cut < edit.end)?.end ?? cut;
|
||||
for (; next < edits.length && edits[next].end <= cut; next++)
|
||||
shift += edits[next].text.length - (edits[next].end - edits[next].start);
|
||||
shares.push(body.slice(from, cut + shift));
|
||||
from = cut + shift;
|
||||
}
|
||||
return shares;
|
||||
}
|
||||
export function redact(value: string): string {
|
||||
// Scan the complete bounded stream, including across write/chunk boundaries.
|
||||
const output = redactFindingSpans(value, { maxBytes: MAX_OUTPUT });
|
||||
@@ -538,8 +572,16 @@ export async function runProcess(
|
||||
raw?: boolean; // Only for inert Git framing or private helper/Docker control JSON that is validated before use. Never print or persist raw results.
|
||||
/** `splice` replaces each located sensitive span with a marker instead of withholding both channels. */
|
||||
redaction?: 'withhold' | 'splice';
|
||||
/** With `splice`: stdout is a declared JSON report (scan output only), redacted so it stays parseable. */
|
||||
stdoutFormat?: 'json';
|
||||
/** Allows PREPARATION_COMMAND_TIMEOUT_MS; only preparation dependency commands set it. */
|
||||
preparationCommand?: true;
|
||||
/**
|
||||
* The name the child sees as argv[0]. `executable()` returns the canonical
|
||||
* path, and a multicall binary (OrbStack's docker -> docker-tools) dispatches
|
||||
* on argv[0], so callers pass the command name they resolved.
|
||||
*/
|
||||
argv0?: string;
|
||||
},
|
||||
): Promise<ProcessResult> {
|
||||
if (!isAbsolute(file) || !isAbsolute(opts.cwd) || !existsSync(opts.cwd))
|
||||
@@ -549,11 +591,14 @@ export async function runProcess(
|
||||
);
|
||||
if (!args.every((a) => typeof a === 'string' && !a.includes('\0')))
|
||||
throw new CsoError('INVALID_ARGUMENT', 'Invalid child argument');
|
||||
if (opts.argv0 !== undefined && !/^[a-zA-Z0-9._-]+$/.test(opts.argv0))
|
||||
throw new CsoError('INVALID_ARGUMENT', 'Invalid child argv0');
|
||||
const hardened = hardenGit(file, args);
|
||||
args = hardened.args;
|
||||
const cap = Math.min(opts.maxBytes ?? MAX_OUTPUT, opts.raw ? RAW_OUTPUT_LIMIT : MAX_OUTPUT);
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(file, args, {
|
||||
argv0: opts.argv0,
|
||||
cwd: opts.cwd,
|
||||
env: opts.env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
@@ -620,7 +665,9 @@ export async function runProcess(
|
||||
reverse = stderr + stdout,
|
||||
chronological = Buffer.concat(ordered).toString('utf8');
|
||||
const spliced =
|
||||
opts.redaction === 'splice' && !truncated ? spliceRedaction(ordered, streams, out) : null;
|
||||
opts.redaction === 'splice' && !truncated
|
||||
? spliceRedaction(ordered, streams, out, opts.stdoutFormat)
|
||||
: null;
|
||||
if (spliced) {
|
||||
resolve({ code: code ?? -1, ...spliced, timedOut, truncated, capturedBytes: bytes });
|
||||
return;
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
} from './scanner-catalog';
|
||||
import {
|
||||
ScannerExecution,
|
||||
ScannerFormat,
|
||||
ScannerGap,
|
||||
ScannerId,
|
||||
ScannerOutcome,
|
||||
@@ -689,6 +690,16 @@ export async function prepareDockerScannerApplication(
|
||||
}
|
||||
}
|
||||
|
||||
/** How each declared report format reaches the parser: every scanner format today is a JSON document. */
|
||||
const REPORT_STDOUT: Record<ScannerFormat, 'json'> = {
|
||||
'gitleaks-json': 'json',
|
||||
'osv-json': 'json',
|
||||
'semgrep-json': 'json',
|
||||
sarif: 'json',
|
||||
'trivy-json': 'json',
|
||||
'schemathesis-json': 'json',
|
||||
};
|
||||
|
||||
/** No arbitrary runner configuration crosses this boundary; images and paths came from the catalog. */
|
||||
export async function createDockerScannerRunner(context: ScannerRunnerContext): Promise<ScannerRunner> {
|
||||
const { input, plan, profile, runtime, application, deadline } = context;
|
||||
@@ -727,12 +738,13 @@ export async function createDockerScannerRunner(context: ScannerRunnerContext):
|
||||
});
|
||||
let scanner = await createScanner();
|
||||
await group.start(scanner);
|
||||
const capture = async (command: string[]): Promise<ScannerExecution> => {
|
||||
const capture = async (command: string[], report?: ScannerFormat): Promise<ScannerExecution> => {
|
||||
if (!scanner) throw new CsoError('ISOLATION_FAILED', 'Scanner container is unavailable');
|
||||
const result = await group!.execCapture(scanner, command, {
|
||||
workdir: '/work',
|
||||
env: plan.env,
|
||||
redaction: 'splice',
|
||||
stdoutFormat: report && REPORT_STDOUT[report],
|
||||
});
|
||||
return { stdout: result.stdout, stderr: result.stderr, exitCode: result.code };
|
||||
};
|
||||
@@ -850,9 +862,12 @@ export async function createDockerScannerRunner(context: ScannerRunnerContext):
|
||||
await group!.start(scanner);
|
||||
}
|
||||
}
|
||||
const execution = await capture([profile.executable, ...plan.args]);
|
||||
const execution = await capture(
|
||||
[profile.executable, ...plan.args],
|
||||
plan.outputPath ? undefined : plan.format,
|
||||
);
|
||||
if (plan.outputPath) {
|
||||
const report = await capture(['/bin/cat', plan.outputPath]);
|
||||
const report = await capture(['/bin/cat', plan.outputPath], plan.format);
|
||||
if (report.exitCode !== 0)
|
||||
throw new CsoError('PREREQUISITE', 'Scanner did not produce its required bounded report file');
|
||||
return {
|
||||
|
||||
+4
-3
@@ -54,6 +54,7 @@ static int wait_bounded(pid_t child,int *status,int seconds){
|
||||
for(int i=0;i<10;i++){pid_t r=waitpid(child,status,WNOHANG);if(r==child)return -1;if(r<0&&errno!=EINTR)return -1;while(nanosleep(&delay,&delay)&&errno==EINTR){}delay.tv_sec=0;delay.tv_nsec=100000000;}
|
||||
return -1;
|
||||
}
|
||||
/* argv[0] is "docker", not the canonical path: OrbStack's docker is a multicall binary that dispatches on argv[0]. */
|
||||
static int resource_present(const char *run_dir,const char *docker,const char *endpoint,const char *id){
|
||||
char output[4096];if(snprintf(output,sizeof output,"%s/watchdog.ps.%ld",run_dir,(long)getpid())>=(int)sizeof output)return -1;
|
||||
int fd=open(output,O_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW,0600);if(fd<0)return -1;
|
||||
@@ -63,7 +64,7 @@ static int resource_present(const char *run_dir,const char *docker,const char *e
|
||||
close(fd);
|
||||
int nullfd=open("/dev/null",O_WRONLY);if(nullfd>=0){(void)dup2(nullfd,STDERR_FILENO);close(nullfd);}
|
||||
char filter[80];if(snprintf(filter,sizeof filter,"id=%s",id)>=(int)sizeof filter)_exit(127);
|
||||
char *const argv[]={(char*)docker,"--host",(char*)endpoint,"ps","--all","--quiet","--no-trunc","--filter",filter,NULL};
|
||||
char *const argv[]={"docker","--host",(char*)endpoint,"ps","--all","--quiet","--no-trunc","--filter",filter,NULL};
|
||||
char *const envp[]={"PATH=/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin",NULL};execve(docker,argv,envp);_exit(127);
|
||||
}
|
||||
close(fd);int status=0;if(wait_bounded(child,&status,10)!=0){unlink(output);return -1;}
|
||||
@@ -74,7 +75,7 @@ static int resource_present(const char *run_dir,const char *docker,const char *e
|
||||
static int remove_container(const char *docker, const char *endpoint, const char *id) {
|
||||
pid_t child=fork(); if(child<0)return -1;
|
||||
if(child==0){
|
||||
char *const argv[]={(char*)docker,"--host",(char*)endpoint,"rm","--force","--volumes",(char*)id,NULL};
|
||||
char *const argv[]={"docker","--host",(char*)endpoint,"rm","--force","--volumes",(char*)id,NULL};
|
||||
char *const envp[]={"PATH=/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin",NULL};
|
||||
execve(docker,argv,envp); _exit(127);
|
||||
}
|
||||
@@ -84,7 +85,7 @@ static int remove_container(const char *docker, const char *endpoint, const char
|
||||
static int cleanup_label(const char *run_dir,const char *docker,const char *endpoint,const char *label){
|
||||
char output[4096],filter[160];if(snprintf(output,sizeof output,"%s/watchdog.labels.%ld",run_dir,(long)getpid())>=(int)sizeof output||snprintf(filter,sizeof filter,"label=com.gstack.cso.run=%s",label)>=(int)sizeof filter)return -1;
|
||||
int fd=open(output,O_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW,0600);if(fd<0)return -1;pid_t child=fork();if(child<0){close(fd);unlink(output);return -1;}
|
||||
if(child==0){if(dup2(fd,STDOUT_FILENO)<0)_exit(127);close(fd);int nullfd=open("/dev/null",O_WRONLY);if(nullfd>=0){(void)dup2(nullfd,STDERR_FILENO);close(nullfd);}char *const argv[]={(char*)docker,"--host",(char*)endpoint,"ps","--all","--quiet","--no-trunc","--filter",filter,NULL};char *const envp[]={"PATH=/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin",NULL};execve(docker,argv,envp);_exit(127);}
|
||||
if(child==0){if(dup2(fd,STDOUT_FILENO)<0)_exit(127);close(fd);int nullfd=open("/dev/null",O_WRONLY);if(nullfd>=0){(void)dup2(nullfd,STDERR_FILENO);close(nullfd);}char *const argv[]={"docker","--host",(char*)endpoint,"ps","--all","--quiet","--no-trunc","--filter",filter,NULL};char *const envp[]={"PATH=/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin",NULL};execve(docker,argv,envp);_exit(127);}
|
||||
close(fd);int status=0;if(wait_bounded(child,&status,10)!=0||!WIFEXITED(status)||WEXITSTATUS(status)!=0){unlink(output);return -1;}
|
||||
FILE *f=fopen(output,"r");if(!f){unlink(output);return -1;}char line[256];int failed=0;while(fgets(line,sizeof line,f)){line[strcspn(line,"\r\n")]=0;if(!is_id(line)||remove_container(docker,endpoint,line)!=0)failed=1;}fclose(f);unlink(output);return failed?-1:0;
|
||||
}
|
||||
|
||||
@@ -78,17 +78,6 @@ export function headSha(cwd: string): string | null {
|
||||
return gitText(['rev-parse', '--verify', '-q', 'HEAD^{commit}'], cwd);
|
||||
}
|
||||
|
||||
/** Working-tree content fingerprint from the sibling gstack-wtree helper; null when unavailable. */
|
||||
export function wtreeFingerprint(binDir: string, cwd: string): string | null {
|
||||
try {
|
||||
const r = spawnSync(path.join(binDir, 'gstack-wtree'), [], { cwd, encoding: 'utf-8', timeout: 60_000, env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' } });
|
||||
const out = (r.stdout || '').trim();
|
||||
return r.status === 0 && /^[0-9a-f]{40,64}$/.test(out) ? out : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** C-style unquote of a git path (`"a\\tb"`), including octal UTF-8 bytes. */
|
||||
export function unquoteGitPath(p: string): string {
|
||||
if (!(p.startsWith('"') && p.endsWith('"') && p.length >= 2)) return p;
|
||||
|
||||
+25
-5
@@ -68,9 +68,25 @@ export function validateGateList(list: unknown): string[] {
|
||||
return problems;
|
||||
}
|
||||
|
||||
/** The `all` resolution: every item takes its recommendation; shown under the list so the reply shape is explicit. */
|
||||
/**
|
||||
* An approval item whose recommended option changes the plan (both voices
|
||||
* recommend the change) is answered only by its own token: `all` must never
|
||||
* flip the owner's stated direction on their behalf (field gate rev 1, d3).
|
||||
*/
|
||||
export function heldFromAll(item: GateItem): boolean {
|
||||
return item.kind === 'approval' && item.options.some(o => o.key === item.recommended && o.changes_plan === true);
|
||||
}
|
||||
|
||||
/** The `all` resolution: every item takes its recommendation, except the ones `heldFromAll` leaves to the owner; shown under the list so the reply shape is explicit. */
|
||||
export function resolveAll(list: GateList): Record<string, string> {
|
||||
return Object.fromEntries(list.items.map(i => [i.id, i.recommended]));
|
||||
return Object.fromEntries(list.items.filter(i => !heldFromAll(i)).map(i => [i.id, i.recommended]));
|
||||
}
|
||||
|
||||
/** The `GATE_PENDING` hint: exact tokens per pending item, and the `all` rule when any pending item is held from it. */
|
||||
export function pendingHint(list: GateList, pending: string[]): string {
|
||||
const items = pending.map(id => list.items.find(i => i.id === id)).filter((i): i is GateItem => !!i);
|
||||
const tokens = items.map(i => i.options.map(o => `${i.id}${o.key}`).join('|')).join(' ');
|
||||
return `reply with ${tokens || 'p1a..p1e'}${items.some(heldFromAll) ? '; all does not answer items recommended b' : ' or all'}`;
|
||||
}
|
||||
|
||||
export function renderGate(list: GateList, page = 1, pageSize = PAGE_SIZE): string {
|
||||
@@ -92,6 +108,7 @@ export function renderGate(list: GateList, page = 1, pageSize = PAGE_SIZE): stri
|
||||
if (pages > 1) lines.push(`Ids are stable across pages; render page N with \`gstack-gate render <file> --page N\`.`);
|
||||
lines.push('Reply with exactly one of: `all` · `<id><option>` tokens, e.g. `' + exampleTokens(list) + '` · `all except <tokens>` · `all as-is` (keep every direction).');
|
||||
lines.push('Items marked auto take their recommendation if unanswered; approval items stay pending until answered. Bare yes/no is not accepted.');
|
||||
for (const held of list.items.filter(heldFromAll)) lines.push(`\`all\` does not answer ${held.id} (recommended ${held.recommended} changes the plan); reply ${held.options.map(o => `${held.id}${o.key}`).join(' or ')} yourself.`);
|
||||
lines.push(`Reply binds to gate_rev=${list.gate_rev}. \`all\` resolves to: ${JSON.stringify({ gate_rev: list.gate_rev, choices: resolveAll(list) })}`);
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
@@ -156,7 +173,7 @@ export function parseReply(reply: string, list: GateList, gateRev?: number, prio
|
||||
if (chosen !== undefined) choices.push({ id: item.id, chosen, kind: item.kind, how: how(item, chosen) });
|
||||
else if (earlier !== undefined && item.options.some(o => o.key === earlier)) choices.push({ id: item.id, chosen: earlier, kind: item.kind, how: how(item, earlier), prior: true });
|
||||
else if (asIs) { const keep = keepDirection(item); choices.push({ id: item.id, chosen: keep, kind: item.kind, how: how(item, keep) }); }
|
||||
else if (all) choices.push({ id: item.id, chosen: item.recommended, kind: item.kind, how: 'recommended' });
|
||||
else if (all && !heldFromAll(item)) choices.push({ id: item.id, chosen: item.recommended, kind: item.kind, how: 'recommended' });
|
||||
else if (item.kind === 'auto') choices.push({ id: item.id, chosen: item.recommended, kind: item.kind, how: 'auto_default' });
|
||||
else pending.push(item.id);
|
||||
}
|
||||
@@ -185,14 +202,17 @@ export function decisionRows(list: GateList, extra: Partial<DecisionRow> = {}):
|
||||
}));
|
||||
}
|
||||
|
||||
export function applyReply(rows: DecisionRow[], parsed: ParsedReply, answeredAt: string): DecisionRow[] {
|
||||
/** `--by` values: the owner typing tokens, or an agent relaying or deciding them. */
|
||||
export const ANSWERED_BY_RE = /^(human|agent:[A-Za-z0-9_.:-]+)$/;
|
||||
|
||||
export function applyReply(rows: DecisionRow[], parsed: ParsedReply, answeredAt: string, answeredBy?: string): DecisionRow[] {
|
||||
if (!parsed.valid) return rows;
|
||||
const byId = new Map(parsed.choices.map(c => [c.id, c]));
|
||||
return rows.map(row => {
|
||||
const choice = byId.get(row.label?.toLowerCase() ?? '') ?? byId.get(row.id.slice(row.run.length + 1).replace(/^r\d+-/, ''));
|
||||
if (!choice || choice.prior || row.gate_rev !== parsed.gate_rev) return row;
|
||||
return {
|
||||
...row, chosen: choice.chosen, answered_at: answeredAt, reply: parsed.reply,
|
||||
...row, chosen: choice.chosen, answered_at: answeredAt, reply: parsed.reply, ...(answeredBy ? { answered_by: answeredBy } : {}),
|
||||
status: choice.how === 'override' ? 'overridden' : 'approved',
|
||||
};
|
||||
});
|
||||
|
||||
+9
-38
@@ -37,6 +37,7 @@ import { existsSync, readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { homedir, tmpdir } from "os";
|
||||
import { spawnSync, spawn, execFileSync, type SpawnSyncReturns, type ChildProcess, type SpawnOptions } from "child_process";
|
||||
import { resolveWindowsBash, type BashProbe } from "./script-invocation";
|
||||
|
||||
interface GbrainConfig {
|
||||
database_url?: string;
|
||||
@@ -176,13 +177,6 @@ export function isExecTimeout(err: unknown): boolean {
|
||||
return e?.killed === true || e?.signal === "SIGTERM" || e?.code === "ETIMEDOUT";
|
||||
}
|
||||
|
||||
/** Where Git for Windows puts bash, most-specific first. */
|
||||
const WINDOWS_BASH_CANDIDATES = [
|
||||
"C:\\Program Files\\Git\\bin\\bash.exe",
|
||||
"C:\\Program Files\\Git\\usr\\bin\\bash.exe",
|
||||
"C:\\Program Files (x86)\\Git\\bin\\bash.exe",
|
||||
];
|
||||
|
||||
export interface ScriptInvocation {
|
||||
cmd: string;
|
||||
argv: string[];
|
||||
@@ -202,38 +196,15 @@ export interface ScriptInvocation {
|
||||
* failing on **every** run on Windows. The two cases look identical and are not:
|
||||
* a `.cmd` shim needs a shell, a shebang script needs an interpreter.
|
||||
*
|
||||
* The consequence was quiet rather than loud. `artifacts_sync_mode` defaults to
|
||||
* pushing curated artifacts to git, so a Windows user's learnings accumulated in
|
||||
* `~/.gstack` and were never committed, while `/sync-gbrain` printed one red
|
||||
* line among four green ones.
|
||||
*
|
||||
* Git for Windows' bash is preferred over a bare `bash` on PATH because
|
||||
* WindowsApps ships a `bash.exe` that is the WSL launcher; if it wins PATH
|
||||
* order it interprets `C:\...` as a Linux path and the script never sees the
|
||||
* repo. `GSTACK_BASH` overrides everything for unusual installs.
|
||||
*
|
||||
* Returns `null` when no bash can be found, so the caller can say so plainly
|
||||
* instead of surfacing a spawn error nobody can act on.
|
||||
* The bash comes from lib/script-invocation.ts, the one Windows resolver
|
||||
* (GSTACK_GIT_BASH, then Git next to `git --exec-path`, the install paths, an
|
||||
* MSYS PATH bash; WSL's launcher never). Returns `null` when no bash can be
|
||||
* found; `noBashMessage()` there is the text to show.
|
||||
*/
|
||||
export function bashScriptInvocation(
|
||||
scriptPath: string,
|
||||
args: string[],
|
||||
opts: { platform?: string; exists?: (p: string) => boolean; env?: NodeJS.ProcessEnv } = {},
|
||||
): ScriptInvocation | null {
|
||||
const platform = opts.platform ?? process.platform;
|
||||
if (platform !== "win32") return { cmd: scriptPath, argv: args, shell: false };
|
||||
|
||||
const exists = opts.exists ?? existsSync;
|
||||
const env = opts.env ?? process.env;
|
||||
|
||||
const override = env.GSTACK_BASH?.trim();
|
||||
const candidates = [...(override ? [override] : []), ...WINDOWS_BASH_CANDIDATES];
|
||||
const bash = candidates.find((p) => exists(p));
|
||||
if (!bash) return null;
|
||||
|
||||
// Forward slashes: bash treats backslashes as escapes, so a Windows path
|
||||
// passed verbatim loses its separators.
|
||||
return { cmd: bash, argv: [scriptPath.replace(/\\/g, "/"), ...args], shell: false };
|
||||
export function bashScriptInvocation(scriptPath: string, args: string[], opts: BashProbe = {}): ScriptInvocation | null {
|
||||
if ((opts.platform ?? process.platform) !== "win32") return { cmd: scriptPath, argv: args, shell: false };
|
||||
const res = resolveWindowsBash(opts);
|
||||
return res.ok ? { cmd: res.bash, argv: [scriptPath.replace(/\\/g, "/"), ...args], shell: false } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -35,7 +35,8 @@ export interface DecisionRow {
|
||||
schema_version: 1; run: string; id: string; title: string; options: string[]; recommended: string | null;
|
||||
kind: DecisionKind; status: DecisionStatus; gate_rev: number; label?: string; phase?: string;
|
||||
default_if_unanswered?: string | null; chosen?: string | null; answered_at?: string; reply?: string;
|
||||
source?: string; cost?: string;
|
||||
/** Who answered: `human` or `agent:<id>` (required in unattended runs). */
|
||||
answered_by?: string; source?: string; cost?: string;
|
||||
/** Why the recommendation (gate items built by gstack-autoplan), what a user challenge changes, and the carry-forward identity. */
|
||||
recommended_basis?: string; changes?: string; origin?: string; carried_from?: string; chosen_text?: string;
|
||||
}
|
||||
|
||||
+1
-1
@@ -90,7 +90,7 @@ export function renderBrief(inputs: BriefInputs, o: { page?: number; summary?: s
|
||||
'',
|
||||
renderDecisions(inputs, page).trimEnd(),
|
||||
'',
|
||||
`Full review: ${record}${recordNote}. Answer with \`gstack-autoplan answer --out <dir> --gate-rev ${inputs.gate.gate_rev} --reply "<all | d3b uc1a | all except d3b>"\`.`,
|
||||
`Full review: ${record}${recordNote}. Answer with \`gstack-autoplan answer --out <dir> --gate-rev ${inputs.gate.gate_rev} --reply "<all | d3b uc1a | all except d3b>" --by human\`.`,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { runScriptSync } from '../../script-invocation';
|
||||
import { baseInputs, sha256 } from '../context';
|
||||
import type { CheckDef, CheckResult, PregateContext } from '../types';
|
||||
|
||||
@@ -35,7 +36,7 @@ function run(ctx: PregateContext): CheckResult {
|
||||
const file = path.join(tmp, 'added.txt');
|
||||
try {
|
||||
fs.writeFileSync(file, text, { mode: 0o600 });
|
||||
const r = spawnSync(path.join(BIN_DIR, 'gstack-redact'), ['--from-file', file, '--json', '--max-bytes', String(64 * 1024 * 1024)], { cwd: ctx.repoRoot, encoding: 'utf8', timeout: Math.min(ctx.timeoutMs, 300_000), maxBuffer: 64 * 1024 * 1024, env: ctx.env });
|
||||
const r = runScriptSync(path.join(BIN_DIR, 'gstack-redact'), ['--from-file', file, '--json', '--max-bytes', String(64 * 1024 * 1024)], { cwd: ctx.repoRoot, encoding: 'utf8', timeout: Math.min(ctx.timeoutMs, 300_000), maxBuffer: 64 * 1024 * 1024, env: ctx.env });
|
||||
if (r.error || r.status === null) return { id: 'secrets', stage: 'preflight', status: 'incomplete', inputs, code: 'PREGATE_INCOMPLETE', detail: `gstack-redact did not finish (${r.error?.message ?? 'killed'})`, fix: 'rerun; raise --timeout for very large diffs' };
|
||||
let findings: Array<{ id?: string; description?: string; severity?: string; category?: string; line?: number }> = [];
|
||||
try { findings = JSON.parse(r.stdout).findings ?? []; } catch { /* non-JSON output below */ }
|
||||
|
||||
+172
-23
@@ -28,10 +28,17 @@ export interface Graph {
|
||||
tests: Set<string>;
|
||||
/** dependency → dependents with the kind of edge. */
|
||||
reverse: Map<string, Edge[]>;
|
||||
/** Test file → import specifiers that looked like repo paths but resolved nowhere (for the no-lane explanation). */
|
||||
unresolved?: Map<string, string[]>;
|
||||
/** tsconfig/jsconfig `extends` hops that were not followed (for the no-lane explanation). */
|
||||
aliasNotes?: string[];
|
||||
}
|
||||
|
||||
const scanner = new Bun.Transpiler({ loader: 'tsx' });
|
||||
const QUOTED = /['"`]([A-Za-z0-9_@][A-Za-z0-9_./@-]*)['"`]/g;
|
||||
// `( ) [ ] +` appear in framework route paths: Next.js route groups `(app)`,
|
||||
// dynamic segments `[id]` / `[[...slug]]`, SvelteKit `+page.svelte`. A literal
|
||||
// that matches more text only gains an edge when it resolves to a repo file.
|
||||
const QUOTED = /['"`]([A-Za-z0-9_@][A-Za-z0-9_./@()[\]+-]*)['"`]/g;
|
||||
const JOIN = /\bjoin\(([^()]*)\)/g;
|
||||
const RESOLVE_EXT = ['', '.ts', '.tsx', '.js', '.mjs', '.cjs', '/index.ts', '/index.js'];
|
||||
|
||||
@@ -44,50 +51,176 @@ export function listRepoFiles(repoRoot: string): string[] {
|
||||
|
||||
function normalize(p: string): string { return path.posix.normalize(p.replace(/\\/g, '/')).replace(/^\.\//, ''); }
|
||||
|
||||
/** A module specifier or literal → the repo file it names, or a directory → `dir/**`, or null. */
|
||||
function resolveRef(files: Set<string>, dirs: Set<string>, fromFile: string, ref: string, literal = false): string | null {
|
||||
if (!ref || ref.startsWith('node:') || ref.startsWith('bun:') || ref.startsWith('@') && !ref.includes('/')) return null;
|
||||
const candidates: string[] = [];
|
||||
if (ref.startsWith('.')) candidates.push(normalize(path.posix.join(path.posix.dirname(fromFile), ref)));
|
||||
else candidates.push(normalize(ref));
|
||||
for (const c of candidates) {
|
||||
if (c.startsWith('../') || c === '..' || c === '.' || c === '') continue;
|
||||
for (const ext of RESOLVE_EXT) if (files.has(c + ext)) return c + ext;
|
||||
// A directory literal needs two segments (`test/fixtures/x`): a bare word is a name, not a path.
|
||||
if (dirs.has(c) && (!literal || c.includes('/'))) return `${c}/**`;
|
||||
// A bare bin name in a test (spawned through PATH or a helper's bin dir) names bin/<name>.
|
||||
if (literal && !c.includes('/') && files.has(`bin/${c}`)) return `bin/${c}`;
|
||||
/** One tsconfig/jsconfig `compilerOptions.paths` entry: `@/*` → [`./*`], already joined to its base. */
|
||||
export interface PathAlias { prefix: string; suffix: string; wildcard: boolean; targets: string[] }
|
||||
/** The effective `paths` of the root config, its `baseUrl` (repo-relative, or null) and what could not be read. */
|
||||
export interface PathAliases { aliases: PathAlias[]; baseUrl: string | null; notes: string[] }
|
||||
|
||||
/** How many relative `extends` hops are followed from the root tsconfig/jsconfig. */
|
||||
export const MAX_TSCONFIG_EXTENDS = 4;
|
||||
|
||||
/** JSONC → JSON: drop comments, and a comma whose next token closes an object or array, outside strings. */
|
||||
function stripJsonc(text: string): string {
|
||||
const skipTrivia = (from: number): number => {
|
||||
let j = from;
|
||||
while (j < text.length) {
|
||||
if (/\s/.test(text[j]!)) { j++; continue; }
|
||||
if (text.startsWith('//', j)) { const e = text.indexOf('\n', j); j = e < 0 ? text.length : e; continue; }
|
||||
if (text.startsWith('/*', j)) { const e = text.indexOf('*/', j + 2); j = e < 0 ? text.length : e + 2; continue; }
|
||||
break;
|
||||
}
|
||||
return j;
|
||||
};
|
||||
let out = '';
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text[i]!;
|
||||
if (c === '"') { const end = /"(?:[^"\\]|\\.)*"/y; end.lastIndex = i; const m = end.exec(text); const s = m ? m[0] : text.slice(i); out += s; i += s.length - 1; continue; }
|
||||
if (c === '/' && text[i + 1] === '/') { while (i < text.length && text[i] !== '\n') i++; out += '\n'; continue; }
|
||||
if (c === '/' && text[i + 1] === '*') { const e = text.indexOf('*/', i + 2); i = e < 0 ? text.length : e + 1; continue; }
|
||||
if (c === ',' && (text[skipTrivia(i + 1)] === '}' || text[skipTrivia(i + 1)] === ']')) continue;
|
||||
out += c;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface Located<T> { value: T; dir: string }
|
||||
interface ConfigOptions { baseUrl?: Located<string>; paths?: Located<Record<string, unknown>> }
|
||||
|
||||
/** `baseUrl` and `paths` of one config after its relative `extends` chain (later entries and the config itself override). */
|
||||
function effectiveOptions(repoRoot: string, rel: string, depth: number, seen: Set<string>, notes: string[]): ConfigOptions | null {
|
||||
let json: { extends?: unknown; compilerOptions?: { baseUrl?: unknown; paths?: unknown } };
|
||||
try { json = JSON.parse(stripJsonc(fs.readFileSync(path.join(repoRoot, rel), 'utf8'))) ?? {}; } catch { return null; }
|
||||
const dir = path.posix.dirname(rel);
|
||||
let options: ConfigOptions = {};
|
||||
const parents = typeof json.extends === 'string' ? [json.extends] : Array.isArray(json.extends) ? json.extends.filter((e): e is string => typeof e === 'string') : [];
|
||||
for (const parent of parents) {
|
||||
if (!parent.startsWith('./') && !parent.startsWith('../')) { notes.push(`${rel} extends ${parent}: not a relative path, not followed`); continue; }
|
||||
if (depth >= MAX_TSCONFIG_EXTENDS) { notes.push(`${rel} extends ${parent}: not followed (tsconfig extends depth limit ${MAX_TSCONFIG_EXTENDS})`); continue; }
|
||||
let target = normalize(path.posix.join(dir, parent));
|
||||
if (!target.endsWith('.json') && !fs.existsSync(path.join(repoRoot, target))) target += '.json';
|
||||
if (target.startsWith('../') || seen.has(target)) { notes.push(`${rel} extends ${parent}: outside the repo or a cycle, not followed`); continue; }
|
||||
const inherited = effectiveOptions(repoRoot, target, depth + 1, new Set([...seen, target]), notes);
|
||||
if (inherited) options = { ...options, ...inherited };
|
||||
else notes.push(`${rel} extends ${parent}: unreadable`);
|
||||
}
|
||||
const own = json.compilerOptions ?? {};
|
||||
if (typeof own.baseUrl === 'string') options.baseUrl = { value: own.baseUrl, dir };
|
||||
if (own.paths && typeof own.paths === 'object') options.paths = { value: own.paths as Record<string, unknown>, dir };
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* The root tsconfig.json (else jsconfig.json) `paths`, relative to the repo,
|
||||
* following relative `extends` up to MAX_TSCONFIG_EXTENDS hops. Targets join
|
||||
* `baseUrl` when one is set, else the directory of the config declaring
|
||||
* `paths`. What is not followed is recorded in `notes` for the no-lane
|
||||
* explanation; a missing alias leaves no edge (so `no lane`), never a wrong one.
|
||||
*/
|
||||
export function readPathAliases(repoRoot: string): PathAliases {
|
||||
for (const name of ['tsconfig.json', 'jsconfig.json']) {
|
||||
const notes: string[] = [];
|
||||
const options = effectiveOptions(repoRoot, name, 0, new Set([name]), notes);
|
||||
if (!options) continue;
|
||||
const baseUrl = options.baseUrl ? normalize(path.posix.join(options.baseUrl.dir, options.baseUrl.value)) : null;
|
||||
const base = baseUrl ?? options.paths?.dir ?? '.';
|
||||
const aliases: PathAlias[] = [];
|
||||
for (const [pattern, targets] of Object.entries(options.paths?.value ?? {})) {
|
||||
if (!Array.isArray(targets)) continue;
|
||||
const star = pattern.indexOf('*');
|
||||
const resolved = targets.filter((t): t is string => typeof t === 'string').map(t => normalize(path.posix.join(base, t)));
|
||||
aliases.push(star < 0 ? { prefix: pattern, suffix: '', wildcard: false, targets: resolved } : { prefix: pattern.slice(0, star), suffix: pattern.slice(star + 1), wildcard: true, targets: resolved });
|
||||
}
|
||||
return { aliases, baseUrl, notes };
|
||||
}
|
||||
return { aliases: [], baseUrl: null, notes: [] };
|
||||
}
|
||||
|
||||
const NO_ALIASES: PathAliases = { aliases: [], baseUrl: null, notes: [] };
|
||||
|
||||
/**
|
||||
* TypeScript's pattern choice: an exact pattern, else the matching wildcard
|
||||
* with the longest prefix. `chosen` is that pattern's expanded targets in
|
||||
* order; `others` are the other matching patterns' targets.
|
||||
*/
|
||||
function matchAliases(aliases: readonly PathAlias[], ref: string): { chosen: string[] | null; others: string[] } {
|
||||
const expand = (a: PathAlias) => a.wildcard ? a.targets.map(t => normalize(t.replace('*', ref.slice(a.prefix.length, ref.length - a.suffix.length)))) : a.targets;
|
||||
const matching = aliases.filter(a => a.wildcard ? ref.startsWith(a.prefix) && ref.endsWith(a.suffix) && ref.length >= a.prefix.length + a.suffix.length : ref === a.prefix);
|
||||
if (matching.length === 0) return { chosen: null, others: [] };
|
||||
const best = matching.find(a => !a.wildcard) ?? matching.reduce((a, b) => (b.prefix.length > a.prefix.length ? b : a));
|
||||
return { chosen: expand(best), others: matching.filter(a => a !== best).flatMap(expand) };
|
||||
}
|
||||
|
||||
/** One candidate repo path → the file it names (trying extensions), `dir/**`, a bare bin, or null. */
|
||||
function resolveCandidate(files: Set<string>, dirs: Set<string>, c: string, literal: boolean): string | null {
|
||||
if (c.startsWith('../') || c === '..' || c === '.' || c === '') return null;
|
||||
for (const ext of RESOLVE_EXT) if (files.has(c + ext)) return c + ext;
|
||||
// A directory literal needs two segments (`test/fixtures/x`): a bare word is a name, not a path.
|
||||
if (dirs.has(c) && (!literal || c.includes('/'))) return `${c}/**`;
|
||||
// A bare bin name in a test (spawned through PATH or a helper's bin dir) names bin/<name>.
|
||||
if (literal && !c.includes('/') && files.has(`bin/${c}`)) return `bin/${c}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A module specifier or literal → the repo files it names. A relative ref
|
||||
* and an alias whose chosen pattern has a target that exists name one file
|
||||
* (targets tried in order). Otherwise no single target is established, so
|
||||
* every plausible reading (other matching patterns, `baseUrl`, the repo
|
||||
* root) contributes: uncertainty selects more tests, never fewer.
|
||||
*/
|
||||
function resolveRefs(files: Set<string>, dirs: Set<string>, fromFile: string, ref: string, literal = false, config: PathAliases = NO_ALIASES): string[] {
|
||||
if (!ref || ref.startsWith('node:') || ref.startsWith('bun:')) return [];
|
||||
if (ref.startsWith('.')) { const t = resolveCandidate(files, dirs, normalize(path.posix.join(path.posix.dirname(fromFile), ref)), literal); return t ? [t] : []; }
|
||||
const { chosen, others } = matchAliases(config.aliases, ref);
|
||||
for (const c of chosen ?? []) { const t = resolveCandidate(files, dirs, c, literal); if (t) return [t]; }
|
||||
if (!chosen && others.length === 0 && ref.startsWith('@') && !ref.includes('/')) return [];
|
||||
const plausible = [...others, ...(config.baseUrl ? [normalize(path.posix.join(config.baseUrl, ref))] : []), normalize(ref)];
|
||||
return [...new Set(plausible.map(c => resolveCandidate(files, dirs, c, literal)).filter((t): t is string => t !== null))];
|
||||
}
|
||||
|
||||
/** A specifier worth naming when it resolves nowhere: relative, aliased, or alias-shaped (`@/`, `~`, `#`); never a package. */
|
||||
function pathLike(config: PathAliases, ref: string): boolean {
|
||||
if (ref.startsWith('.') || /^(?:@\/|~|#)/.test(ref)) return true;
|
||||
const { chosen, others } = matchAliases(config.aliases, ref);
|
||||
return chosen !== null || others.length > 0;
|
||||
}
|
||||
|
||||
function addEdge(graph: Graph, dependency: string, dependent: string, via: Edge['via']): void {
|
||||
if (dependency === dependent) return;
|
||||
const list = graph.reverse.get(dependency) ?? [];
|
||||
if (!list.some(e => e.from === dependent && e.via === via)) { list.push({ from: dependent, via }); graph.reverse.set(dependency, list); }
|
||||
}
|
||||
|
||||
/** Every dependency reference one source file makes: imports, requires, quoted repo paths and join() segment runs. */
|
||||
export function referencesIn(files: Set<string>, dirs: Set<string>, rel: string, source: string): Array<{ target: string; via: Edge['via'] }> {
|
||||
/**
|
||||
* Every dependency reference one source file makes: imports, requires, quoted
|
||||
* repo paths and join() segment runs. Import specifiers that resolve nowhere
|
||||
* and look like repo paths go to `unresolved` when given.
|
||||
*/
|
||||
export function referencesIn(files: Set<string>, dirs: Set<string>, rel: string, source: string, config: PathAliases = NO_ALIASES, unresolved?: string[]): Array<{ target: string; via: Edge['via'] }> {
|
||||
const out: Array<{ target: string; via: Edge['via'] }> = [];
|
||||
const importOf = (spec: string) => {
|
||||
const targets = resolveRefs(files, dirs, rel, spec, false, config);
|
||||
for (const target of targets) out.push({ target, via: 'import' });
|
||||
if (targets.length === 0 && unresolved && pathLike(config, spec) && !unresolved.includes(spec)) unresolved.push(spec);
|
||||
};
|
||||
if (/\.[cm]?[jt]sx?$/.test(rel)) {
|
||||
let imports: Array<{ path: string }> = [];
|
||||
try { imports = scanner.scanImports(source); } catch { imports = []; }
|
||||
for (const { path: spec } of imports) { const t = resolveRef(files, dirs, rel, spec); if (t) out.push({ target: t, via: 'import' }); }
|
||||
for (const { path: spec } of imports) importOf(spec);
|
||||
// The transpiler erases type-only imports; a types module still binds its importers' contracts.
|
||||
for (const m of source.matchAll(/^\s*(?:import|export)\s+type\b[^;'"]*?\bfrom\s+['"]([^'"]+)['"]/gm)) { const t = resolveRef(files, dirs, rel, m[1]!); if (t) out.push({ target: t, via: 'import' }); }
|
||||
for (const m of source.matchAll(/^\s*(?:import|export)\s+type\b[^;'"]*?\bfrom\s+['"]([^'"]+)['"]/gm)) importOf(m[1]!);
|
||||
}
|
||||
// Literals carry dependencies only from the test tree and from bin/ and shell
|
||||
// sources (a test spawns a bin; a bash bin calls a sibling). Prose and
|
||||
// manifests name everything and would make every file a hub.
|
||||
if (!scansLiterals(rel)) return out;
|
||||
for (const m of source.matchAll(QUOTED)) { const t = resolveRef(files, dirs, rel, m[1]!, true); if (t) out.push({ target: t, via: 'literal' }); }
|
||||
for (const m of source.matchAll(QUOTED)) for (const target of resolveRefs(files, dirs, rel, m[1]!, true, config)) out.push({ target, via: 'literal' });
|
||||
for (const m of source.matchAll(JOIN)) {
|
||||
const segments = [...m[1]!.matchAll(/['"`]([^'"`]+)['"`]/g)].map(s => s[1]!);
|
||||
if (segments.length < 2) continue;
|
||||
const joined = segments.join('/');
|
||||
const t = resolveRef(files, dirs, rel, joined, true) ?? resolveRef(files, dirs, rel, './' + joined, true);
|
||||
if (t) out.push({ target: t, via: 'literal' });
|
||||
const direct = resolveRefs(files, dirs, rel, joined, true);
|
||||
for (const target of direct.length ? direct : resolveRefs(files, dirs, rel, './' + joined, true)) out.push({ target, via: 'literal' });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -109,13 +242,16 @@ export function buildGraph(repoRoot: string, opts: BuildOptions = {}): Graph {
|
||||
const files = new Set([...all, ...(opts.extraFiles ?? [])]);
|
||||
const dirs = new Set<string>();
|
||||
for (const f of all) { let d = path.posix.dirname(f); while (d && d !== '.') { dirs.add(d); d = path.posix.dirname(d); } }
|
||||
const graph: Graph = { files, tests: new Set(all.filter(isTestFile)), reverse: new Map() };
|
||||
const config = readPathAliases(repoRoot);
|
||||
const graph: Graph = { files, tests: new Set(all.filter(isTestFile)), reverse: new Map(), unresolved: new Map(), aliasNotes: config.notes };
|
||||
const maxBytes = opts.maxBytes ?? 2 * 1024 * 1024;
|
||||
for (const rel of all) {
|
||||
if (!SOURCE_EXT.test(rel) && !rel.startsWith('bin/')) continue;
|
||||
let source: string;
|
||||
try { const st = fs.statSync(path.join(repoRoot, rel)); if (!st.isFile() || st.size > maxBytes) continue; source = fs.readFileSync(path.join(repoRoot, rel), 'utf8'); } catch { continue; }
|
||||
for (const { target, via } of referencesIn(files, dirs, rel, source)) addEdge(graph, target, rel, via);
|
||||
const unresolved: string[] = [];
|
||||
for (const { target, via } of referencesIn(files, dirs, rel, source, config, graph.tests.has(rel) ? unresolved : undefined)) addEdge(graph, target, rel, via);
|
||||
if (unresolved.length) graph.unresolved!.set(rel, unresolved);
|
||||
}
|
||||
for (const test of graph.tests) {
|
||||
const m = /^(?:[\w.-]+\/)*?test\/([\w-]+)\.(?:test|spec)\./.exec(test);
|
||||
@@ -161,5 +297,18 @@ export function testsFor(graph: Graph, file: string, declarations: Record<string
|
||||
if (graph.tests.has(file)) { tests.add(file); provenance.push(`${file} (itself a test)`); }
|
||||
const declaredNone = tests.size === 0 && Object.entries(declarations).some(([glob, list]) => list.length === 0 && matchesAny([glob], file));
|
||||
if (declaredNone) provenance.push(`${file}: declared no lane needed (.gstack/pregate.json dependencies)`);
|
||||
if (tests.size === 0 && !declaredNone) provenance.push(...noLaneExplanation(graph, file));
|
||||
return { file, tests: [...tests].sort(), provenance, declaredNone };
|
||||
}
|
||||
|
||||
/** Why the graph may have missed a file's tests: unresolved repo-path specifiers in test files and unfollowed `extends`. */
|
||||
function noLaneExplanation(graph: Graph, file: string): string[] {
|
||||
const seen = [...(graph.unresolved ?? new Map<string, string[]>())].sort(([a], [b]) => a.localeCompare(b));
|
||||
const notes = graph.aliasNotes ?? [];
|
||||
if (seen.length === 0 && notes.length === 0) return [];
|
||||
const lines = notes.map(n => `${file}: path aliases may be incomplete: ${n}`);
|
||||
const shown = seen.slice(0, 10).map(([test, specs]) => `${test}: ${specs.slice(0, 5).map(s => `'${s}'`).join(' ')}${specs.length > 5 ? ' …' : ''}`);
|
||||
if (shown.length) lines.push(`${file}: no lane; specifiers in test files that resolve to no repo file: ${shown.join('; ')}${seen.length > 10 ? `; +${seen.length - 10} more test file(s)` : ''}`);
|
||||
lines.push(`${file}: if a test covers it, declare the file under dependencies in .gstack/pregate.json, or report the resolver gap`);
|
||||
return lines;
|
||||
}
|
||||
@@ -7,8 +7,8 @@
|
||||
* runner-owned selection manifest `.gstack/pregate.json` declares for a
|
||||
* planner-computed matrix. Every uncertainty widens the selection.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { GENERATED_PATH } from '../regen';
|
||||
import { runBashSync } from '../script-invocation';
|
||||
import { matchesAny } from '../ship-policy';
|
||||
import { PREGATE_CONFIG_PATH } from './config';
|
||||
import { buildGraph, isTestFile, listRepoFiles, testsFor, type Graph } from './import-graph';
|
||||
@@ -31,15 +31,33 @@ export interface Selection {
|
||||
provenance: string[];
|
||||
}
|
||||
|
||||
/** One path-shaped token: framework route segments (`(group)`, `[id]`, `[...slug]`, `@slot`, `$param`, `+page`) stay whole. */
|
||||
const PATH_TOKEN = /[\w.@()[\]$+~-]+(?:\/[\w.@()[\]$+~-]+)*/g;
|
||||
|
||||
/** Test files a selection manifest printed, in order: every path-shaped token the import graph calls a test file. */
|
||||
export function manifestTestPaths(output: string): string[] {
|
||||
const found = new Set<string>();
|
||||
for (const line of output.split('\n')) {
|
||||
for (const [token] of line.matchAll(PATH_TOKEN)) {
|
||||
const bare = token.replace(/^\.\//, '');
|
||||
const trimmed = bare.replace(/^[([]+/, '').replace(/[)\].,]+$/, '');
|
||||
const hit = [bare, trimmed].find(isTestFile);
|
||||
if (hit) found.add(hit);
|
||||
}
|
||||
}
|
||||
return [...found];
|
||||
}
|
||||
|
||||
/** Resolve one lane's test-file set: literal paths, then the declared selection manifest, else `?` when its matrix is an expression. */
|
||||
export function laneFileSet(ctx: PregateContext, lane: Lane): string[] | '?' | 'all' {
|
||||
const files = new Set(lane.testFiles);
|
||||
const manifest = ctx.config.selection[lane.id];
|
||||
if (manifest) {
|
||||
if (!ctx.allowRepoCommands) { ctx.explain.push(`lane ${lane.id}: selection manifest \`${manifest}\` not run (pass --allow-repo-commands); treated as unknown`); return files.size ? [...files] : '?'; }
|
||||
const r = spawnSync('bash', ['-c', `set -o pipefail; ${manifest}`], { cwd: ctx.repoRoot, encoding: 'utf8', timeout: Math.min(ctx.timeoutMs, 300_000), maxBuffer: 64 * 1024 * 1024, env: ctx.env });
|
||||
const r = runBashSync(`set -o pipefail; ${manifest}`, { cwd: ctx.repoRoot, encoding: 'utf8', timeout: Math.min(ctx.timeoutMs, 300_000), maxBuffer: 64 * 1024 * 1024, env: ctx.env });
|
||||
if (r.error && r.status === null && r.signal === null) { ctx.explain.push(`lane ${lane.id}: selection manifest could not start (${r.error.message}); treated as unknown`); return '?'; }
|
||||
if (r.status !== 0) { ctx.explain.push(`lane ${lane.id}: selection manifest exited ${r.status}; treated as unknown`); return '?'; }
|
||||
for (const line of (r.stdout ?? '').split('\n')) { const m = /((?:[\w.-]+\/)*[\w.-]+\.bats(?![\w./-])|(?:[\w.-]+\/)*[\w.-]+\.(?:test|spec)\.[cm]?[jt]sx?|(?:[\w.-]+\/)*test_[\w.-]+\.py)/.exec(line.trim()); if (m) files.add(m[1]!); }
|
||||
for (const f of manifestTestPaths(r.stdout ?? '')) files.add(f);
|
||||
ctx.explain.push(`lane ${lane.id}: ${files.size} files from manifest \`${manifest}\``);
|
||||
return [...files];
|
||||
}
|
||||
|
||||
+13
-3
@@ -21,7 +21,7 @@
|
||||
* - Anthropic: https://www.anthropic.com/pricing#api
|
||||
* - OpenAI: https://openai.com/api/pricing/
|
||||
* - Google AI: https://ai.google.dev/gemini-api/docs/pricing
|
||||
* - Voyage AI (embeddings): https://docs.voyageai.com/docs/pricing
|
||||
* - Voyage AI (embeddings, rerankers): https://docs.voyageai.com/docs/pricing
|
||||
*/
|
||||
|
||||
export interface ModelPricing {
|
||||
@@ -59,6 +59,9 @@ export const PRICING: Record<string, ModelPricing> = {
|
||||
// https://developers.openai.com/api/docs/models/gpt-6.1-sol (standard tier, up to 272K input)
|
||||
'gpt-6.1-sol': { input_per_mtok: 2.00, output_per_mtok: 10.00, cached_input_per_mtok: 0.10, as_of: '2026-10' },
|
||||
'gpt-5.4': { input_per_mtok: 2.50, output_per_mtok: 10.00, as_of: '2026-04' },
|
||||
// https://developers.openai.com/api/docs/models/gpt-4o (fetched 2026-10-13; gbrain's LongMemEval judge, the official scorer's model;
|
||||
// the alias only: the gpt-4o-2024-05-13 snapshot has its own price)
|
||||
'gpt-4o': { input_per_mtok: 2.50, output_per_mtok: 10.00, cached_input_per_mtok: 1.25, as_of: '2026-10' },
|
||||
'gpt-5.4-mini': { input_per_mtok: 0.60, output_per_mtok: 2.40, as_of: '2026-04' },
|
||||
'o3': { input_per_mtok: 15.00, output_per_mtok: 60.00, as_of: '2026-04' },
|
||||
'o4-mini': { input_per_mtok: 1.10, output_per_mtok: 4.40, as_of: '2026-04' },
|
||||
@@ -89,6 +92,9 @@ export const EMBEDDING_PRICING: Record<string, ModelPricing> = {
|
||||
'voyage-code-3': { input_per_mtok: 0.18, output_per_mtok: 0, as_of: '2026-10' },
|
||||
'voyage-3-large': { input_per_mtok: 0.18, output_per_mtok: 0, as_of: '2026-10' },
|
||||
'voyage-multimodal-3': { input_per_mtok: 0.12, output_per_mtok: 0, as_of: '2026-10' }, // text tokens; pixels excluded
|
||||
// https://docs.voyageai.com/docs/pricing (fetched 2026-10-13; rerankers bill query tokens x documents plus document tokens)
|
||||
'rerank-2.5': { input_per_mtok: 0.05, output_per_mtok: 0, as_of: '2026-10' },
|
||||
'rerank-2.5-lite': { input_per_mtok: 0.02, output_per_mtok: 0, as_of: '2026-10' },
|
||||
// https://developers.openai.com/api/docs/pricing (fetched 2026-10-12)
|
||||
'text-embedding-3-large': { input_per_mtok: 0.13, output_per_mtok: 0, as_of: '2026-10' },
|
||||
'text-embedding-3-small': { input_per_mtok: 0.02, output_per_mtok: 0, as_of: '2026-10' },
|
||||
@@ -98,12 +104,16 @@ export const EMBEDDING_PRICING: Record<string, ModelPricing> = {
|
||||
|
||||
/**
|
||||
* A provider-prefixed id (`openai/gpt-6-astra`, `anthropic/claude-fable-5-1`,
|
||||
* or gbrain's `voyage:voyage-4`) resolves to its table row.
|
||||
* or gbrain's `voyage:voyage-4`) resolves to its table row, and so does a
|
||||
* dated Claude snapshot (`claude-haiku-4-5-20251001`, Anthropic's `-YYYYMMDD`
|
||||
* form), which bills at its alias's price. Dated OpenAI snapshots are not
|
||||
* folded: some are priced differently from their alias.
|
||||
*/
|
||||
export function pricingRow(model: string | undefined): ModelPricing | undefined {
|
||||
if (!model) return undefined;
|
||||
const bare = model.replace(/^[a-z-]+[/:]/, '');
|
||||
return PRICING[model] ?? PRICING[bare] ?? EMBEDDING_PRICING[model] ?? EMBEDDING_PRICING[bare];
|
||||
const undated = /^claude-/.test(bare) ? bare.replace(/-\d{8}$/, '') : bare;
|
||||
return PRICING[model] ?? PRICING[bare] ?? PRICING[undated] ?? EMBEDDING_PRICING[model] ?? EMBEDDING_PRICING[bare];
|
||||
}
|
||||
|
||||
export function hasPricing(model: string | undefined): boolean {
|
||||
|
||||
+111
-6
@@ -553,6 +553,76 @@ const MARKER_ONLY_PATTERN_IDS = new Set(["pem.private_key", "gcp.service_account
|
||||
* structure-preserving path.)
|
||||
*/
|
||||
export function redactFindingSpans(input: string, opts: ScanOptions = {}): string | null {
|
||||
const merged = mergedFindingSpans(input, opts);
|
||||
return merged && spliceEdits(input, merged.map((m) => ({ start: m.start, end: m.end, text: marker(m.ids) })));
|
||||
}
|
||||
|
||||
/** One replacement on the original text: [start, end) becomes `text`. */
|
||||
export interface RedactionEdit {
|
||||
start: number;
|
||||
end: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* redactFindingSpans for a stream declared as JSON (a scanner report: one
|
||||
* document, several top-level values, or NDJSON), keeping it parseable. A span
|
||||
* inside a string keeps the in-string marker, widened to whole escape
|
||||
* sequences; a span inside a number replaces that whole number token with the
|
||||
* quoted marker, so `{"max_memory_bytes":1145427776}` stays valid JSON
|
||||
* (#3185). A span that touches more than one token, a structural character,
|
||||
* whitespace, a literal or a string's quotes returns null (withhold). A
|
||||
* stream that does not tokenize as JSON gets the plain splice. Returns the
|
||||
* edits (sorted, disjoint, on the original offsets) so a caller can map
|
||||
* interleaved runs onto the result.
|
||||
*/
|
||||
export function redactJsonFindingSpans(input: string, opts: ScanOptions = {}): { body: string; edits: RedactionEdit[] } | null {
|
||||
const merged = mergedFindingSpans(input, opts);
|
||||
if (!merged) return null;
|
||||
const tokens = jsonTokens(input);
|
||||
const spans: Array<{ start: number; end: number; ids: string[]; quote: boolean }> = [];
|
||||
for (const m of merged) {
|
||||
if (!tokens) {
|
||||
spans.push({ ...m, quote: false });
|
||||
continue;
|
||||
}
|
||||
let lo = 0, hi = tokens.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (tokens[mid].end <= m.start) lo = mid + 1; else hi = mid;
|
||||
}
|
||||
const t = tokens[lo];
|
||||
if (!t || t.start > m.start || t.end < m.end) return null;
|
||||
let span: { start: number; end: number; quote: boolean };
|
||||
if (t.kind === "number") span = { start: t.start, end: t.end, quote: true };
|
||||
else if (t.kind === "string" && m.start > t.start && m.end < t.end) {
|
||||
span = { start: m.start, end: m.end, quote: false };
|
||||
for (const [s, e] of t.escapes) {
|
||||
if (s < span.start && span.start < e) span.start = s;
|
||||
if (s < span.end && span.end < e) span.end = e;
|
||||
}
|
||||
} else return null;
|
||||
const last = spans[spans.length - 1];
|
||||
if (last && span.start < last.end) {
|
||||
last.end = Math.max(last.end, span.end);
|
||||
for (const id of m.ids) if (!last.ids.includes(id)) last.ids.push(id);
|
||||
} else spans.push({ ...span, ids: [...m.ids] });
|
||||
}
|
||||
const edits = spans.map((x) => ({ start: x.start, end: x.end, text: x.quote ? `"${marker(x.ids)}"` : marker(x.ids) }));
|
||||
return { body: spliceEdits(input, edits), edits };
|
||||
}
|
||||
|
||||
const marker = (ids: string[]) => `<REDACTED-${ids.join("+")}>`;
|
||||
|
||||
function spliceEdits(input: string, edits: RedactionEdit[]): string {
|
||||
// Right-to-left so earlier offsets remain valid after splicing.
|
||||
let body = input;
|
||||
for (let i = edits.length - 1; i >= 0; i--) body = body.slice(0, edits[i].start) + edits[i].text + body.slice(edits[i].end);
|
||||
return body;
|
||||
}
|
||||
|
||||
/** Every finding's span, coalesced; null when one cannot be located or is marker-only. */
|
||||
function mergedFindingSpans(input: string, opts: ScanOptions): Array<{ start: number; end: number; ids: string[] }> | null {
|
||||
const spans = new Map<Finding, OriginalSpan>();
|
||||
const { findings } = scanInternal(input, opts, spans);
|
||||
if (findings.some((f) => MARKER_ONLY_PATTERN_IDS.has(f.id))) return null;
|
||||
@@ -573,14 +643,49 @@ export function redactFindingSpans(input: string, opts: ScanOptions = {}): strin
|
||||
merged.push({ start: t.start, end: t.end, ids: [t.f.id] });
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
// Right-to-left so earlier offsets remain valid after splicing.
|
||||
let body = input;
|
||||
for (let i = merged.length - 1; i >= 0; i--) {
|
||||
const m = merged[i];
|
||||
body = body.slice(0, m.start) + `<REDACTED-${m.ids.join("+")}>` + body.slice(m.end);
|
||||
type JsonToken = { kind: "string" | "number" | "other"; start: number; end: number; escapes: Array<[number, number]> };
|
||||
const JSON_NUMBER = /-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
|
||||
const JSON_LITERAL = /true|false|null/y;
|
||||
|
||||
/** Lexical JSON tokens of one or more top-level values (NDJSON included); null when the text is not JSON. */
|
||||
function jsonTokens(text: string): JsonToken[] | null {
|
||||
const tokens: JsonToken[] = [];
|
||||
for (let i = 0; i < text.length;) {
|
||||
const c = text[i];
|
||||
if (c === " " || c === "\t" || c === "\n" || c === "\r") { i++; continue; }
|
||||
if ("{}[]:,".includes(c)) { tokens.push({ kind: "other", start: i, end: i + 1, escapes: [] }); i++; continue; }
|
||||
if (c === '"') {
|
||||
const escapes: Array<[number, number]> = [];
|
||||
let j = i + 1;
|
||||
for (;;) {
|
||||
if (j >= text.length) return null;
|
||||
if (text[j] === '"') break;
|
||||
if (text[j] === "\\") {
|
||||
const end = j + (text[j + 1] === "u" ? 6 : 2);
|
||||
if (end > text.length) return null;
|
||||
escapes.push([j, end]);
|
||||
j = end;
|
||||
} else j++;
|
||||
}
|
||||
tokens.push({ kind: "string", start: i, end: j + 1, escapes });
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
let matched = false;
|
||||
for (const [kind, pattern] of [["number", JSON_NUMBER], ["other", JSON_LITERAL]] as const) {
|
||||
pattern.lastIndex = i;
|
||||
if (!pattern.test(text)) continue;
|
||||
tokens.push({ kind, start: i, end: pattern.lastIndex, escapes: [] });
|
||||
i = pattern.lastIndex;
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
if (!matched) return null;
|
||||
}
|
||||
return body;
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function inStructuralToken(body: string, start: number, end: number): boolean {
|
||||
|
||||
+16
-9
@@ -21,11 +21,11 @@ import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { type ResultCodeName } from './result-codes';
|
||||
import { runBashSync, wtreeFingerprint as fingerprintTree } from './script-invocation';
|
||||
import { containedRelative, globToRegExp, matchesAny } from './ship-policy';
|
||||
|
||||
export const GENERATED_PATH = '.gstack/generated.json';
|
||||
export const GENERATED_SCHEMA = 'https://github.com/garrytan/gstack/docs/pregate.md#generated-v1';
|
||||
const BIN_DIR = path.dirname(Bun.fileURLToPath(import.meta.url)).replace(/\/lib$/, '/bin');
|
||||
|
||||
export interface RegenEntry {
|
||||
/** Stable id used in `regen` results and `--only <id>`. */
|
||||
@@ -128,11 +128,15 @@ export function selectEntries(registry: Registry, touched?: readonly string[]):
|
||||
return registry.entries.filter(e => e.inputs.length === 0 || touched.some(t => matchesAny(e.inputs, t) || matchesAny(e.outputs, t)));
|
||||
}
|
||||
|
||||
/** The working-tree content fingerprint (bin/gstack-wtree): tracked and untracked non-ignored files. */
|
||||
/**
|
||||
* Compatibility wrapper for the pregate consumers (lib/pregate/context.ts,
|
||||
* lib/pregate/index.ts), files #3190 changes: they move to
|
||||
* lib/script-invocation.ts's `{tree}|{error}` form in item 10b, which then
|
||||
* deletes this.
|
||||
*/
|
||||
export function wtreeFingerprint(repoRoot: string): string | null {
|
||||
const r = spawnSync(path.join(BIN_DIR, 'gstack-wtree'), [], { cwd: repoRoot, encoding: 'utf8', timeout: 60_000 });
|
||||
const out = (r.stdout ?? '').trim();
|
||||
return r.status === 0 && /^[0-9a-f]{40,64}$/.test(out) ? out : null;
|
||||
const r = fingerprintTree({ cwd: repoRoot });
|
||||
return 'tree' in r ? r.tree : null;
|
||||
}
|
||||
|
||||
export interface Scratch { dir: string; wtree: string; remove: () => void }
|
||||
@@ -144,8 +148,9 @@ export interface Scratch { dir: string; wtree: string; remove: () => void }
|
||||
* Error naming the step.
|
||||
*/
|
||||
export function scratchWorktree(repoRoot: string, prefix = 'gstack-regen-'): Scratch {
|
||||
const wtree = wtreeFingerprint(repoRoot);
|
||||
if (!wtree) throw new Error('cannot fingerprint the working tree (gstack-wtree failed)');
|
||||
const fp = fingerprintTree({ cwd: repoRoot });
|
||||
if ('error' in fp) throw new Error(`cannot fingerprint the working tree (${fp.error})`);
|
||||
const wtree = fp.tree;
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
const add = git(repoRoot, ['worktree', 'add', '--detach', '-q', dir, 'HEAD']);
|
||||
@@ -196,7 +201,7 @@ function diffSnapshots(a: Map<string, string>, b: Map<string, string>): string[]
|
||||
}
|
||||
|
||||
function runCommand(cwd: string, command: string, env: NodeJS.ProcessEnv, timeoutMs: number): { exit: number | null; stderr: string } {
|
||||
const r = spawnSync('bash', ['-c', `set -o pipefail; ${command}`], { cwd, encoding: 'utf8', timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024, env: { ...env, GSTACK_REGEN_SCRATCH: cwd } });
|
||||
const r = runBashSync(`set -o pipefail; ${command}`, { cwd, encoding: 'utf8', timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024, env: { ...env, GSTACK_REGEN_SCRATCH: cwd } });
|
||||
return { exit: r.status, stderr: ((r.stderr ?? '') + (r.error ? `\n${r.error.message}` : '')).trim().slice(-4000) };
|
||||
}
|
||||
|
||||
@@ -242,7 +247,9 @@ export function runRegen(o: RunRegenOptions): { results: EntryResult[]; wtree: s
|
||||
} else results.push({ id: e.id, command: e.command, status: changed.length ? 'written' : 'fresh', changed, outside, exit: 0, ms, fix: fixFor(e) });
|
||||
}
|
||||
} finally { scratch?.remove(); }
|
||||
return { results, wtree: scratch?.wtree ?? wtreeFingerprint(o.repoRoot) };
|
||||
if (scratch) return { results, wtree: scratch.wtree };
|
||||
const fp = fingerprintTree({ cwd: o.repoRoot });
|
||||
return { results, wtree: 'tree' in fp ? fp.tree : null };
|
||||
}
|
||||
|
||||
/** One line per entry, the way `gstack-regen` and the pre-gate print them. */
|
||||
|
||||
+7
-6
@@ -26,6 +26,7 @@ import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { checkChangelog, mergeChangelogs, parseChangelog, reheadChangelog } from './changelog-check';
|
||||
import { type ResultCodeName } from './result-codes';
|
||||
import { runScriptSync, wtreeFingerprint } from './script-invocation';
|
||||
import {
|
||||
SHIP_POLICY_PATH, VERSION_SLOT, assertInsideRoot, declaredCommands, isUnavailable, loadPolicyFromBase, loadPolicyFromHead, matchesAny,
|
||||
type LoadedPolicy, type QueueMode, type ShipPolicy,
|
||||
@@ -177,7 +178,7 @@ function planVersions(o: RestampOptions, loaded: LoadedPolicy, policyLabel: stri
|
||||
newVersion = bumped(atBase.version, level);
|
||||
claims = 'ignored (stamp-at-merge)';
|
||||
} else {
|
||||
const r = spawnSync('bun', [path.join(BIN_DIR, 'gstack-next-version'), '--base', o.base, '--bump', level, '--current-version', atBase.version], { cwd: o.repoRoot, encoding: 'utf8', timeout: 120_000, env: o.env ?? process.env });
|
||||
const r = spawnSync(process.execPath, [path.join(BIN_DIR, 'gstack-next-version'), '--base', o.base, '--bump', level, '--current-version', atBase.version], { cwd: o.repoRoot, encoding: 'utf8', timeout: 120_000, env: o.env ?? process.env });
|
||||
let q: any = null;
|
||||
try { q = JSON.parse(r.stdout ?? ''); } catch {}
|
||||
const usable = r.status === 0 && q && typeof q.version === 'string' && parseVersion(q.version) && (q.offline === false || q.fallback === 'git');
|
||||
@@ -344,7 +345,7 @@ function runStages(o: RestampOptions, plan: Plan, staging: string, since: string
|
||||
const v = stage('version');
|
||||
if (command) { v.status = 'skipped'; v.detail = 'restamp_command owns the version'; }
|
||||
else {
|
||||
const w = spawnSync('bun', [path.join(BIN_DIR, 'gstack-version-bump'), 'write', '--version', plan.newVersion], { cwd: staging, encoding: 'utf8', timeout: 120_000, env });
|
||||
const w = spawnSync(process.execPath, [path.join(BIN_DIR, 'gstack-version-bump'), 'write', '--version', plan.newVersion], { cwd: staging, encoding: 'utf8', timeout: 120_000, env });
|
||||
if (w.status !== 0) { v.status = 'failed'; v.detail = (w.stderr ?? '').trim().slice(0, 300); throw new Refusal('RESTAMP_VERSION_SOURCE', `gstack-version-bump write failed: ${v.detail}; nothing written`); }
|
||||
v.status = 'done'; v.detail = `wrote ${plan.newVersion}`;
|
||||
}
|
||||
@@ -509,9 +510,9 @@ export function restamp(o: RestampOptions): RestampResult {
|
||||
if (dirty.length) throw new Refusal('RESTAMP_CONFLICT', `restamp_command needs a clean HEAD, index and worktree (it runs on a clean checkout of HEAD); commit or remove: ${dirty.slice(0, 10).join(', ')}${dirty.length > 10 ? ' …' : ''}; nothing written`);
|
||||
}
|
||||
|
||||
const wt = spawnSync(path.join(BIN_DIR, 'gstack-wtree'), [], { cwd: o.repoRoot, encoding: 'utf8', timeout: 60_000 });
|
||||
const wtree = (wt.stdout ?? '').trim();
|
||||
if (wt.status !== 0 || !/^[0-9a-f]{40,64}$/.test(wtree)) throw new Refusal('RESTAMP_CONFLICT', `cannot fingerprint the working tree (${(wt.stderr ?? '').trim()}); nothing written`);
|
||||
const fp = wtreeFingerprint({ cwd: o.repoRoot });
|
||||
if ('error' in fp) throw new Refusal('RESTAMP_CONFLICT', `cannot fingerprint the working tree (${fp.error}); nothing written`);
|
||||
const wtree = fp.tree;
|
||||
staging = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-restamp-staging-'));
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
const add = git(o.repoRoot, ['worktree', 'add', '--detach', '-q', staging, 'HEAD']);
|
||||
@@ -588,7 +589,7 @@ function gateAhead(o: RestampOptions, plan: Plan, stages: StageRecord[], common:
|
||||
if (!commit.ok) { removeWorktree(o.repoRoot, dir); throw new Refusal('RESTAMP_CONFLICT', `cannot commit the gate-ahead stamp: ${commit.err}`); }
|
||||
const gatedHead = git(dir, ['rev-parse', 'HEAD']).out;
|
||||
const gatedTree = git(dir, ['rev-parse', 'HEAD^{tree}']).out;
|
||||
const slug = spawnSync(path.join(BIN_DIR, 'gstack-slug'), [], { cwd: o.repoRoot, encoding: 'utf8', timeout: 30_000 });
|
||||
const slug = runScriptSync(path.join(BIN_DIR, 'gstack-slug'), [], { cwd: o.repoRoot, encoding: 'utf8', timeout: 30_000 });
|
||||
const branchKey = /^BRANCH=(.+)$/m.exec(slug.stdout ?? '')?.[1]?.trim() ?? git(o.repoRoot, ['branch', '--show-current']).out;
|
||||
const record: GateAheadRecord = {
|
||||
schema_version: 1, pr: o.pr ?? null, predecessor_pr: pred.pr, predecessor_head: pred.head, branch_head: branchHead, gated_head: gatedHead,
|
||||
|
||||
@@ -114,6 +114,11 @@ export const RESULT_CODES = {
|
||||
summary: 'part of the reply matched no gate item or option, so nothing was applied',
|
||||
fix: 'reply with `all`, `all as-is`, `<id><option>` tokens (for example `d3b uc1a`), or `all except <tokens>`; bare yes/no is ambiguous',
|
||||
},
|
||||
ANSWER_BY_REQUIRED: {
|
||||
anchor: 'answer-by-required',
|
||||
summary: 'an unattended gate answer named no valid answerer, so nothing was applied',
|
||||
fix: 'pass `--by human` when the owner typed the tokens, or `--by agent:<thread or id>` when an agent relays or decides; the value lands on every decision row as answered_by',
|
||||
},
|
||||
// PR A (multi-agent wave): gstack-doctor --check, gstack-capy-install, gstack-browser-ensure.
|
||||
COMPONENT_MISSING: {
|
||||
anchor: 'doctor-component-missing',
|
||||
@@ -150,6 +155,11 @@ export const RESULT_CODES = {
|
||||
summary: 'a tool the installer needs before ./setup (git, curl, node, jq) is missing, or the Bun install recipe failed its checksum',
|
||||
fix: 'install the named tool, then re-run bin/gstack-capy-install',
|
||||
},
|
||||
SETUP_PS1_EMPTY_ARGUMENT: {
|
||||
anchor: 'setup-ps1-empty-argument',
|
||||
summary: 'an argument given to .\\setup.ps1 is an empty string, which its Git Bash hand-off cannot carry, so nothing ran',
|
||||
fix: 'drop the empty argument or give it a value, then run .\\setup.ps1 again',
|
||||
},
|
||||
// C-stamp (plan C1, C2, C3, C5, C7): ship policy, restamp, tree receipt, ship receipt.
|
||||
POLICY_SOURCE_UNAVAILABLE: {
|
||||
anchor: 'policy-source-unavailable',
|
||||
@@ -527,6 +537,11 @@ export const RESULT_CODES = {
|
||||
summary: 'bin/gstack-upgrade changed nothing: the checkout is dirty, diverged, pinned (detached or off the remote default branch), not a gstack checkout, or the incoming release holds',
|
||||
fix: 'resolve the named cause (commit or move the listed changes, check out the default branch), then re-run bin/gstack-upgrade; it never stashes or resets',
|
||||
},
|
||||
UPGRADE_BUSY: {
|
||||
anchor: 'upgrade-busy',
|
||||
summary: 'bin/gstack-upgrade changed nothing: another live session holds a workflow lease on this installation (bin/gstack-lease live), so moving the checkout would change gstack under it',
|
||||
fix: 'wait for those sessions to finish (bin/gstack-lease list names each pid and age), then re-run bin/gstack-upgrade; GSTACK_UPGRADE_FORCE=1 overrides when you know they are idle',
|
||||
},
|
||||
UPGRADE_FETCH_FAILED: {
|
||||
anchor: 'upgrade-fetch-failed',
|
||||
summary: 'bin/gstack-upgrade could not fetch the remote default branch, so it cannot tell whether the checkout is current',
|
||||
@@ -547,6 +562,35 @@ export const RESULT_CODES = {
|
||||
summary: 'a Capy session (GSTACK_CAPY=1) has no passing install verdict for this checkout, HEAD and workflow, so the workflow refused to start',
|
||||
fix: 'run the Capy skill installer with --for <workflow> on this machine and launch with the GSTACK_LAUNCH line it prints',
|
||||
},
|
||||
// Fix wave 2026-10-15, PR 1a (items 7 and 11).
|
||||
BROWSE_TAB_UNRECOVERABLE: {
|
||||
anchor: 'browse-tab-unrecoverable',
|
||||
summary: 'every tab was closed outside browse and a new one could not be opened, because the browser or its context is gone',
|
||||
fix: 'run `browse restart` (`$B restart` in a gstack skill); `browse status` and `browse stop` keep working',
|
||||
},
|
||||
BUN_VERSION_UNREADABLE: {
|
||||
anchor: 'bun-version-unreadable',
|
||||
summary: 'bun --version did not print a version gstack can compare with the incoming release\'s Bun floor, so the update was held and nothing was changed',
|
||||
fix: 'reinstall Bun from https://bun.sh (bun upgrade may not run on a broken install), check that bun --version prints a version such as 1.4.2, then update again',
|
||||
},
|
||||
// #3197: the one Windows bash resolver (lib/script-invocation.ts) found no usable Git Bash.
|
||||
BASH_UNAVAILABLE: {
|
||||
anchor: 'bash-unavailable',
|
||||
summary: 'no Git Bash found to run gstack\'s bash scripts on Windows',
|
||||
fix: 'install Git for Windows (winget install --id Git.Git -e), or set GSTACK_GIT_BASH to its bash.exe for good: in PowerShell, [Environment]::SetEnvironmentVariable(\'GSTACK_GIT_BASH\', \'C:\\Program Files\\Git\\bin\\bash.exe\', \'User\') and restart Claude Code, or add it to the "env" block of ~/.claude/settings.json',
|
||||
},
|
||||
// Fix wave 2026-10-15, PR 3 (item 23, #1796).
|
||||
IOS_LAUNCH_ENV_INVALID: {
|
||||
anchor: 'ios-launch-env-invalid',
|
||||
summary: 'GSTACK_IOS_LAUNCH_ENV is set but is not a JSON object of string to string, or sets a GSTACK_ variable the debug bridge reserves, so the app was not launched',
|
||||
fix: 'set it to a JSON object such as GSTACK_IOS_LAUNCH_ENV=\'{"DEBUG_BRIDGE":"1"}\' (string values, no GSTACK_ keys), or unset it, then restart gstack-ios-qa-daemon',
|
||||
},
|
||||
// Fix wave 2026-10-15, PR 4 (item 17, #3182).
|
||||
OUTSIDE_VOICE_UNAVAILABLE: {
|
||||
anchor: 'outside-voice-unavailable',
|
||||
summary: 'a plan review ran without its second opinion: the outside provider did not run or failed, and the bounded native Plan reviewer was not offered or did not finish within five minutes',
|
||||
fix: 'repair the provider the line names (install it, or log in with `codex login`); for the native fallback use Claude Code 2.1.277 or later with background tasks enabled (`CLAUDE_CODE_DISABLE_BACKGROUND_TASKS` unset), then re-run the review',
|
||||
},
|
||||
} as const satisfies Record<string, ResultCode>;
|
||||
|
||||
export type ResultCodeName = keyof typeof RESULT_CODES;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 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. Carriage returns, newlines and tabs become spaces, every other ASCII
|
||||
* control byte (ESC included) and DEL is removed, runs of spaces collapse, and
|
||||
* the result is at most ECHO_LIMIT bytes of valid UTF-8 with no leading or
|
||||
* trailing space. bin/gstack-sanitize-echo.sh is the Bash twin;
|
||||
* test/sanitize-echo.test.ts runs both on one table.
|
||||
*/
|
||||
export const ECHO_LIMIT = 200;
|
||||
|
||||
const SPACE = 0x20;
|
||||
|
||||
export function sanitizeEcho(text: string): string {
|
||||
const out: number[] = [];
|
||||
for (const byte of Buffer.from(text, 'utf8')) {
|
||||
const b = byte === 0x0d || byte === 0x0a || byte === 0x09 ? SPACE : byte;
|
||||
if (b < SPACE || b === 0x7f) continue;
|
||||
if (b === SPACE && (out.length === 0 || out[out.length - 1] === SPACE)) continue;
|
||||
out.push(b);
|
||||
}
|
||||
let bytes = out.slice(0, ECHO_LIMIT);
|
||||
const lead = (b: number | undefined, min: number) => b !== undefined && b >= min;
|
||||
const cont = (b: number | undefined) => b !== undefined && b >= 0x80 && b <= 0xbf;
|
||||
const n = bytes.length;
|
||||
if (lead(bytes[n - 3], 0xf0) && cont(bytes[n - 2]) && cont(bytes[n - 1])) bytes = bytes.slice(0, -3);
|
||||
else if (lead(bytes[n - 2], 0xe0) && cont(bytes[n - 1])) bytes = bytes.slice(0, -2);
|
||||
else if (lead(bytes[n - 1], 0xc0)) bytes = bytes.slice(0, -1);
|
||||
if (bytes[bytes.length - 1] === SPACE) bytes = bytes.slice(0, -1);
|
||||
return Buffer.from(bytes).toString('utf8');
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* script-invocation — the one way gstack's TypeScript runs its own shebang
|
||||
* bins and `bash -c` strings (#3197).
|
||||
*
|
||||
* Windows has no shebangs: Bun's spawnSync cannot execute an extensionless
|
||||
* `#!/usr/bin/env bash` script (`bin/gstack-wtree`, `gstack-slug`) or a
|
||||
* `#!/usr/bin/env bun` one (`gstack-redact`), and `shell: true` does not help
|
||||
* (cmd.exe has no idea what a shebang is). So:
|
||||
* - bun-shebang bins run as `<bun> <script>` on every platform, `<bun>`
|
||||
* being the running Bun (process.execPath) or, inside a compiled binary,
|
||||
* the `bun` on PATH;
|
||||
* - bash-shebang bins and `bash -c` strings exec directly on POSIX and go
|
||||
* through the resolved Git Bash on Windows.
|
||||
*
|
||||
* Windows bash resolution, in order (one fixture table,
|
||||
* test/fixtures/windows-bash-resolution.json, pins it):
|
||||
* 1. GSTACK_GIT_BASH (canonical; the name setup.ps1 reads), GSTACK_BASH as
|
||||
* a deprecated alias at the same precedence. GSTACK_GIT_BASH wins when
|
||||
* both are set; a warning prints once when they differ. An override that
|
||||
* does not exist is refused, never silently skipped.
|
||||
* 2. bash next to `git --exec-path` (resolves through scoop-style shims,
|
||||
* per-user and portable Git).
|
||||
* 3. The known install paths.
|
||||
* 4. A PATH `bash` only when `uname -s` reports MSYS or MINGW. System32 and
|
||||
* WindowsApps `bash.exe` are WSL launchers and are rejected unprobed.
|
||||
* 5. Otherwise a refusal listing each candidate with its rejection reason
|
||||
* and naming GSTACK_GIT_BASH (BASH_UNAVAILABLE).
|
||||
* Paths are forward-slashed: Bun's spawnSync on Windows returns ENOENT for a
|
||||
* backslash exe path containing spaces, and bash reads backslashes as escapes.
|
||||
*/
|
||||
import { spawnSync, type SpawnSyncOptions, type SpawnSyncOptionsWithStringEncoding, type SpawnSyncReturns } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { resultLine } from './result-codes';
|
||||
|
||||
export const BIN_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'bin');
|
||||
|
||||
export interface BashProbe {
|
||||
platform?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
exists?: (p: string) => boolean;
|
||||
/** `git --exec-path`; the default asks git only on a real Windows host. */
|
||||
gitExecPath?: () => string | null;
|
||||
which?: (cmd: string) => string | null;
|
||||
/** `uname -s` as the candidate bash reports it. */
|
||||
uname?: (bash: string) => string | null;
|
||||
}
|
||||
|
||||
export interface BashCandidate { path: string; source: string; rejected: string | null }
|
||||
|
||||
export type BashResolution =
|
||||
| { ok: true; bash: string; source: string; tried: BashCandidate[]; warning?: string }
|
||||
| { ok: false; tried: BashCandidate[]; message: string; warning?: string };
|
||||
|
||||
const slash = (p: string) => p.replace(/\\/g, '/');
|
||||
const WSL_LAUNCHER = /\/(?:windows\/(?:system32|sysnative)|microsoft\/windowsapps)\//i;
|
||||
const GIT_ROOT_FROM_EXEC_PATH = /^(.*)\/(?:mingw64|mingw32|clangarm64|clang64|ucrt64|usr\/lib)\/(?:libexec\/)?git-core$/i;
|
||||
|
||||
function knownInstalls(env: NodeJS.ProcessEnv): string[] {
|
||||
return [
|
||||
'C:/Program Files/Git/bin/bash.exe',
|
||||
'C:/Program Files/Git/usr/bin/bash.exe',
|
||||
'C:/Program Files (x86)/Git/bin/bash.exe',
|
||||
...(env.LOCALAPPDATA ? [`${slash(env.LOCALAPPDATA)}/Programs/Git/bin/bash.exe`] : []),
|
||||
...(env.USERPROFILE ? [`${slash(env.USERPROFILE)}/scoop/apps/git/current/bin/bash.exe`] : []),
|
||||
];
|
||||
}
|
||||
|
||||
function defaultGitExecPath(): string | null {
|
||||
const r = spawnSync('git', ['--exec-path'], { encoding: 'utf8', timeout: 10_000, windowsHide: true });
|
||||
return r.status === 0 ? r.stdout.trim() || null : null;
|
||||
}
|
||||
|
||||
function defaultUname(bash: string): string | null {
|
||||
const r = spawnSync(bash, ['-c', 'uname -s'], { encoding: 'utf8', timeout: 10_000, windowsHide: true });
|
||||
return r.status === 0 ? r.stdout.trim() || null : null;
|
||||
}
|
||||
|
||||
/** The refusal text shared by every consumer (scripts, gbrain sync, the doctor). */
|
||||
export function noBashMessage(tried: readonly BashCandidate[]): string {
|
||||
const list = tried.map(c => `${c.source} ${c.path}: ${c.rejected}`).join('; ');
|
||||
return resultLine('gstack', 'BASH_UNAVAILABLE', `tried ${list}`);
|
||||
}
|
||||
|
||||
export function resolveWindowsBash(o: BashProbe = {}): BashResolution {
|
||||
const env = o.env ?? process.env;
|
||||
const exists = o.exists ?? fs.existsSync;
|
||||
const live = process.platform === 'win32';
|
||||
const tried: BashCandidate[] = [];
|
||||
const canonical = env.GSTACK_GIT_BASH?.trim();
|
||||
const alias = env.GSTACK_BASH?.trim();
|
||||
const warning = canonical && alias && slash(canonical) !== slash(alias)
|
||||
? `gstack: GSTACK_GIT_BASH (${canonical}) and the deprecated GSTACK_BASH (${alias}) differ; using GSTACK_GIT_BASH`
|
||||
: undefined;
|
||||
const found = (bash: string, source: string): BashResolution => {
|
||||
tried.push({ path: bash, source, rejected: null });
|
||||
return { ok: true, bash, source, tried, warning };
|
||||
};
|
||||
const refuse = (): BashResolution => ({ ok: false, tried, message: noBashMessage(tried), warning });
|
||||
|
||||
const override = canonical || alias;
|
||||
if (override) {
|
||||
const source = canonical ? 'GSTACK_GIT_BASH' : 'GSTACK_BASH';
|
||||
if (exists(slash(override))) return found(slash(override), source);
|
||||
tried.push({ path: slash(override), source, rejected: 'does not exist' });
|
||||
return refuse();
|
||||
}
|
||||
tried.push({ path: '(unset)', source: 'GSTACK_GIT_BASH', rejected: 'not set' });
|
||||
|
||||
const seen = new Set<string>();
|
||||
const execPath = (o.gitExecPath ?? (live ? defaultGitExecPath : () => null))();
|
||||
const root = execPath ? GIT_ROOT_FROM_EXEC_PATH.exec(slash(execPath).replace(/\/+$/, ''))?.[1] : undefined;
|
||||
if (!execPath) tried.push({ path: '(git)', source: 'git --exec-path', rejected: 'git not found or failed' });
|
||||
else if (!root) tried.push({ path: slash(execPath), source: 'git --exec-path', rejected: 'not a Git for Windows or MSYS2 layout' });
|
||||
else for (const bash of [`${root}/bin/bash.exe`, `${root}/usr/bin/bash.exe`]) {
|
||||
seen.add(bash.toLowerCase());
|
||||
if (exists(bash)) return found(bash, 'git --exec-path');
|
||||
tried.push({ path: bash, source: 'git --exec-path', rejected: 'not found' });
|
||||
}
|
||||
|
||||
for (const bash of knownInstalls(env)) {
|
||||
if (seen.has(bash.toLowerCase())) continue;
|
||||
if (exists(bash)) return found(bash, 'install path');
|
||||
tried.push({ path: bash, source: 'install path', rejected: 'not found' });
|
||||
}
|
||||
|
||||
const onPath = (o.which ?? (live ? (c: string) => Bun.which(c) : () => null))('bash');
|
||||
if (!onPath) { tried.push({ path: 'bash', source: 'PATH', rejected: 'not on PATH' }); return refuse(); }
|
||||
const bash = slash(onPath);
|
||||
if (WSL_LAUNCHER.test(bash)) { tried.push({ path: bash, source: 'PATH', rejected: 'WSL launcher, not Git Bash' }); return refuse(); }
|
||||
const kind = (o.uname ?? defaultUname)(bash);
|
||||
if (kind && /^(?:MSYS|MINGW)/i.test(kind)) return found(bash, 'PATH');
|
||||
tried.push({ path: bash, source: 'PATH', rejected: `uname -s reports ${kind ?? 'nothing'}, not MSYS/MINGW` });
|
||||
return refuse();
|
||||
}
|
||||
|
||||
export interface CachedProbe extends BashProbe {
|
||||
stat?: (p: string) => { mtimeMs: number } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The hooks' resolver: spawn-free on the hot path. The override is never
|
||||
* cached; otherwise the last resolution is reused while `git` on PATH keeps
|
||||
* its path and mtime and the cached bash still exists. Written by temp file
|
||||
* plus rename; only successes are cached.
|
||||
*/
|
||||
export function resolveWindowsBashCached(cacheFile: string, o: CachedProbe = {}): BashResolution {
|
||||
const env = o.env ?? process.env;
|
||||
if (env.GSTACK_GIT_BASH?.trim() || env.GSTACK_BASH?.trim()) return resolveWindowsBash(o);
|
||||
const exists = o.exists ?? fs.existsSync;
|
||||
const stat = o.stat ?? ((p: string) => { try { return fs.statSync(p); } catch { return null; } });
|
||||
const git = (o.which ?? (process.platform === 'win32' ? (c: string) => Bun.which(c) : () => null))('git');
|
||||
const key = git ? `${slash(git)}|${stat(git)?.mtimeMs ?? 'missing'}` : 'no-git';
|
||||
try {
|
||||
const cached = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
|
||||
if (cached?.key === key && typeof cached.bash === 'string' && exists(cached.bash)) {
|
||||
return { ok: true, bash: cached.bash, source: `${cached.source} (cached)`, tried: [{ path: cached.bash, source: `${cached.source} (cached)`, rejected: null }] };
|
||||
}
|
||||
} catch { /* no usable cache: resolve below */ }
|
||||
const res = resolveWindowsBash(o);
|
||||
if (res.ok) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(cacheFile), { recursive: true });
|
||||
const tmp = `${cacheFile}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify({ key, bash: res.bash, source: res.source }) + '\n');
|
||||
fs.renameSync(tmp, cacheFile);
|
||||
} catch { /* an unwritable cache only costs the next resolution */ }
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
export interface InvocationOptions extends BashProbe {
|
||||
/** Supplies the Windows bash resolution (the hooks pass their cached resolver). */
|
||||
resolve?: () => BashResolution;
|
||||
/** The Bun that runs bun-shebang bins. */
|
||||
bunExe?: string;
|
||||
}
|
||||
|
||||
export type Invocation = { ok: true; cmd: string; argv: string[] } | { ok: false; error: string };
|
||||
|
||||
/** The running Bun, or the `bun` on PATH when this process is a compiled binary. */
|
||||
export function bunExecutable(): string {
|
||||
const exe = process.execPath;
|
||||
return /^bun(?:-debug)?(?:\.exe)?$/i.test(path.basename(exe)) ? exe : (Bun.which('bun') ?? 'bun');
|
||||
}
|
||||
|
||||
function readShebang(script: string): string {
|
||||
try {
|
||||
const fd = fs.openSync(script, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(128);
|
||||
const n = fs.readSync(fd, buf, 0, 128, 0);
|
||||
return buf.subarray(0, n).toString('utf8').split('\n')[0] ?? '';
|
||||
} finally { fs.closeSync(fd); }
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
const warned = new Set<string>();
|
||||
function windowsBash(o: InvocationOptions): { ok: true; bash: string } | { ok: false; error: string } {
|
||||
const res = o.resolve ? o.resolve() : resolveWindowsBash(o);
|
||||
if (res.warning && !warned.has(res.warning)) { warned.add(res.warning); process.stderr.write(res.warning + '\n'); }
|
||||
return res.ok ? { ok: true, bash: res.bash } : { ok: false, error: res.message };
|
||||
}
|
||||
|
||||
/** How to run a gstack shebang bin on this platform. */
|
||||
export function scriptInvocation(script: string, args: readonly string[], o: InvocationOptions = {}): Invocation {
|
||||
if (/^#!.*\bbun\b/.test(readShebang(script))) return { ok: true, cmd: o.bunExe ?? bunExecutable(), argv: [script, ...args] };
|
||||
if ((o.platform ?? process.platform) !== 'win32') return { ok: true, cmd: script, argv: [...args] };
|
||||
const bash = windowsBash(o);
|
||||
return bash.ok ? { ok: true, cmd: bash.bash, argv: [slash(script), ...args] } : bash;
|
||||
}
|
||||
|
||||
/** How to run `bash -c <command>` on this platform. */
|
||||
export function bashCommandInvocation(command: string, o: InvocationOptions = {}): Invocation {
|
||||
if ((o.platform ?? process.platform) !== 'win32') return { ok: true, cmd: 'bash', argv: ['-c', command] };
|
||||
const bash = windowsBash(o);
|
||||
return bash.ok ? { ok: true, cmd: bash.bash, argv: ['-c', command] } : bash;
|
||||
}
|
||||
|
||||
/** A spawnSync-shaped failure for an invocation that could not be built, so callers keep one error path. */
|
||||
function unlaunched(error: string, encoding: unknown): SpawnSyncReturns<string | Buffer> {
|
||||
const empty = encoding && encoding !== 'buffer' ? '' : Buffer.alloc(0);
|
||||
return { pid: 0, output: [null, empty, empty], stdout: empty, stderr: empty, status: null, signal: null, error: Object.assign(new Error(error), { code: 'BASH_UNAVAILABLE' }) };
|
||||
}
|
||||
|
||||
export function runScriptSync(script: string, args: readonly string[], opts: SpawnSyncOptionsWithStringEncoding, o?: InvocationOptions): SpawnSyncReturns<string>;
|
||||
export function runScriptSync(script: string, args: readonly string[], opts: SpawnSyncOptions, o?: InvocationOptions): SpawnSyncReturns<string | Buffer>;
|
||||
export function runScriptSync(script: string, args: readonly string[], opts: SpawnSyncOptions, o: InvocationOptions = {}): SpawnSyncReturns<string | Buffer> {
|
||||
const inv = scriptInvocation(script, args, o);
|
||||
return inv.ok ? spawnSync(inv.cmd, inv.argv, opts) : unlaunched(inv.error, opts.encoding);
|
||||
}
|
||||
|
||||
export function runBashSync(command: string, opts: SpawnSyncOptionsWithStringEncoding, o?: InvocationOptions): SpawnSyncReturns<string>;
|
||||
export function runBashSync(command: string, opts: SpawnSyncOptions, o?: InvocationOptions): SpawnSyncReturns<string | Buffer>;
|
||||
export function runBashSync(command: string, opts: SpawnSyncOptions, o: InvocationOptions = {}): SpawnSyncReturns<string | Buffer> {
|
||||
const inv = bashCommandInvocation(command, o);
|
||||
return inv.ok ? spawnSync(inv.cmd, inv.argv, opts) : unlaunched(inv.error, opts.encoding);
|
||||
}
|
||||
|
||||
export type WtreeResult = { tree: string } | { error: string; kind: 'launch' | 'exit' };
|
||||
|
||||
/**
|
||||
* The working-tree content fingerprint (bin/gstack-wtree): tracked and
|
||||
* untracked non-ignored files, as a 40- or 64-hex git tree id (SHA-1 or
|
||||
* SHA-256 repositories). `launch` means gstack-wtree never ran (no bash, a
|
||||
* spawn error); `exit` means it ran and failed (its stderr says why).
|
||||
*/
|
||||
export function wtreeFingerprint(o: { cwd: string; timeoutMs?: number; env?: NodeJS.ProcessEnv; invocation?: InvocationOptions }): WtreeResult {
|
||||
const r = runScriptSync(path.join(BIN_DIR, 'gstack-wtree'), [], {
|
||||
cwd: o.cwd, encoding: 'utf8', timeout: o.timeoutMs ?? 60_000, windowsHide: true,
|
||||
env: { ...(o.env ?? process.env), GIT_OPTIONAL_LOCKS: '0' },
|
||||
}, o.invocation);
|
||||
const out = (r.stdout ?? '').trim();
|
||||
if (r.status === 0 && /^[0-9a-f]{40,64}$/.test(out)) return { tree: out };
|
||||
if (r.error && r.status === null && r.signal === null) return { kind: 'launch', error: `gstack-wtree could not start: ${r.error.message}` };
|
||||
const why = (r.stderr ?? '').trim().split('\n').pop() || (r.signal ? `killed by ${r.signal}` : r.status === 0 ? `unexpected output ${JSON.stringify(out.slice(0, 80))}` : 'no stderr');
|
||||
return { kind: 'exit', error: `gstack-wtree exited ${r.status ?? r.signal}: ${why}` };
|
||||
}
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.91.99",
|
||||
"version": "1.91.106",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -68,13 +68,14 @@
|
||||
"eval:bg:release": "bun run scripts/eval-bg.ts release"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"engines": {
|
||||
@@ -91,7 +92,7 @@
|
||||
"devtools"
|
||||
],
|
||||
"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",
|
||||
|
||||
@@ -520,54 +520,53 @@ Other preflight failures retain their printed diagnosis, including harness misma
|
||||
These failures do not block the review; they use the bounded fallback below.
|
||||
|
||||
Enter only when **Outcome routing** selects fallback; do not restart the outside
|
||||
invocation after its failure. A native result never counts as outside coverage.
|
||||
invocation after its failure.
|
||||
Immediately before dispatch, recheck whether reviews are enabled. If the mode is
|
||||
`CODEX_MODE: disabled`, return to **Record the disabled outcome** without
|
||||
dispatching. Otherwise continue with the same prepared prompt.
|
||||
|
||||
|
||||
**Bounded outside-voice wait — one five-minute wait plus dispatch/cancellation overhead:**
|
||||
**Bounded outside-voice wait — one five-minute host timer:**
|
||||
|
||||
Before dispatch, verify TaskOutput and TaskStop in this session's tool definitions,
|
||||
and Plan in Agent's declared subagent types. Do not launch a task to test availability.
|
||||
If any capability is missing or undeclared, take the unavailable path below.
|
||||
Before dispatch, check this session's tools for TaskStop, Bash's `run_in_background`
|
||||
and Plan among Agent's subagent types. Do not launch a task to test availability;
|
||||
if any is missing, take the unavailable path without launching.
|
||||
Use Plan, which denies native Edit, Write and NotebookEdit tools. Do not set a model
|
||||
override; keep the inherited model. This is not a filesystem sandbox: the review-only
|
||||
prompt also forbids mutations through other tools. The subagent has fresh context
|
||||
but is the same harness; model identity stays unknown unless the runtime reports it.
|
||||
A native result never supplies outside coverage.
|
||||
prompt also forbids mutations through other tools. A native result never supplies
|
||||
outside coverage. This is the one exception to foreground dispatch here.
|
||||
Execute the four steps once:
|
||||
|
||||
This is the single bounded-wait exception to foreground dispatch for this outside
|
||||
voice. Execute the four steps once:
|
||||
|
||||
1. Dispatch via the Agent tool with `subagent_type: "Plan"` and
|
||||
`run_in_background: true`. Subagent prompt: same plan review prompt as above.
|
||||
Keep the returned `agentId`; do not guess an ID or launch a second task.
|
||||
If dispatch fails without an ID, take the unavailable path without guessing one.
|
||||
2. Immediately call TaskOutput with that exact ID as `task_id`, `block: true`,
|
||||
and `timeout: 300000`. Make one wait only; do not poll or renew the budget.
|
||||
3. Check TaskOutput's outer fields: `<retrieval_status>` must be `success`,
|
||||
`<task_id>` must match, `<task_type>` must be `local_agent`, `<status>`
|
||||
must be `completed`, `<output>` must be nonempty, and there must be no outer
|
||||
`<error>`. Accept findings only if that output is an identifiable complete
|
||||
1. Start the timer: Bash `command: "sleep 300"`, `run_in_background: true`,
|
||||
`timeout: 300000`. Dispatch via the Agent tool with `subagent_type: "Plan"`
|
||||
and `run_in_background: true` where offered, with the same plan review prompt.
|
||||
Keep the returned agent ID; do not guess an ID or launch a second task.
|
||||
2. End your turn and wait for task notifications. Make one wait only: do not poll,
|
||||
sleep, Read either output file or renew the timer.
|
||||
3. Only the agent's `<task-notification>` arriving before the timer's counts:
|
||||
`<task-id>` must match the agent ID, `<status>` must be `completed`, `<summary>`
|
||||
must say it finished ("is waiting on its own background work": keep waiting; a
|
||||
turn limit: partial) and `<result>` must be nonempty; a "[Subagent hand-back]"
|
||||
header only frames it. Accept findings only if it is an identifiable complete
|
||||
final reviewer report. Reject raw or in-progress transcripts; do not extract
|
||||
finding fragments from them. Terminal status or warning markers alone do not
|
||||
establish report completeness. If any check fails or the report cannot be identified, follow step 4. Otherwise present it under an `OUTSIDE VOICE (Claude subagent):`
|
||||
establish report completeness. If any check fails or the report cannot be identified, follow step 4. Otherwise call TaskStop with the timer's ID, present it under an `OUTSIDE VOICE (Claude subagent):`
|
||||
header, then continue to **Integrate reviewer findings**.
|
||||
4. On any noncompletion (timeout, error, missing/mismatched result, failed/killed
|
||||
status, raw transcript or empty report), call TaskStop with the same ID as
|
||||
`task_id`. TaskOutput timeout does not stop the agent. Record the stop result;
|
||||
if cancellation fails, say cancellation is unconfirmed. If TaskStop reports the
|
||||
task already completed after the timeout, still give no late-result credit.
|
||||
4. Otherwise (timer first, no ID, failed or stopped status, partial or empty result,
|
||||
or Agent returns a report directly: no enforced deadline, so no credit), call
|
||||
TaskStop with the same ID as the agent, and call TaskStop with the timer's ID if it runs.
|
||||
Record each stop result; if one fails, cancellation is unconfirmed.
|
||||
For a completion after the timer or TaskStop, still give no late-result credit.
|
||||
|
||||
**Unavailable path:** "Outside voice unavailable. Continuing to planning decisions and Approval readiness."
|
||||
**Unavailable path:** "Outside voice unavailable: <cause>. Fix: <fix>. Continuing to planning decisions and Approval readiness. (OUTSIDE_VOICE_UNAVAILABLE)"
|
||||
The cause adds to the provider's diagnosis: Plan agent type not offered, TaskStop not offered,
|
||||
background tasks unavailable (fix: Claude Code 2.1.277+, background tasks on), no complete report within five minutes, or a failed status.
|
||||
Do not retry with a general-purpose agent. Report missing outside-voice coverage.
|
||||
Ignore partial or late results for critique, agreement, clean status or coverage.
|
||||
Skip Integrate reviewer findings and Cross-model tension. Persist an unavailable result using the command below
|
||||
with STATUS = "unavailable", SOURCE = "none", OUTSIDE_STATUS = "unavailable";
|
||||
then continue directly to the remaining planning decisions and Approval readiness. The storage policy still applies.
|
||||
Do not record a clean review when no reviewer completed within the accepted wait.
|
||||
|
||||
with STATUS = "unavailable", SOURCE = "none", OUTSIDE_STATUS = "unavailable", adding `"unavailable_reason":"<cause>"`;
|
||||
then continue directly to the remaining planning decisions and Approval readiness.
|
||||
Do not record a clean review when no reviewer completed.
|
||||
|
||||
|
||||
**Integrate reviewer findings:**
|
||||
|
||||
Loaded 100 of 201 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user