v1.93.41.0 stamp: merge main e4e9913398 into #3729 (land)

This commit is contained in:
garrytan committed 2026-11-03 03:15:42 +00:00
commit ba062b524b
907 files changed
+60764 -10749

No files matched your search

+30
View File
@@ -60,6 +60,21 @@ RUN curl --retry 5 --retry-delay 5 --retry-connrefused -fsSL \
&& rm -rf /var/lib/apt/lists/* \
&& git --no-lazy-fetch --version
# Partial clone: the eval jobs check out with `filter: blob:none` and read
# older files through on-demand blob fetches (.github/scripts/history-read.sh).
# Prove this Git makes a blobless clone and fetches a missing blob, so the image
# fails here instead of in a slice's history read.
RUN set -e; d=$(mktemp -d); \
git init -q -b main "$d/src"; echo one > "$d/src/f"; git -C "$d/src" add f; \
git -C "$d/src" -c user.name=ci -c user.email=ci@localhost commit -qm one; \
echo two > "$d/src/f"; git -C "$d/src" -c user.name=ci -c user.email=ci@localhost commit -qam two; \
git -C "$d/src" config uploadpack.allowFilter true; \
git clone -q --filter=blob:none --no-local "file://$d/src" "$d/clone"; \
test "$(git -C "$d/clone" config remote.origin.promisor)" = true; \
if GIT_NO_LAZY_FETCH=1 git -C "$d/clone" cat-file -e HEAD~1:f 2>/dev/null; then echo "blob present: not a partial clone" >&2; exit 1; fi; \
test "$(git -C "$d/clone" show HEAD~1:f)" = one; \
rm -rf "$d"; echo "partial clone: ok ($(git --version))"
RUN python3 -m venv /tmp/gstack-ci-venv \
&& /tmp/gstack-ci-venv/bin/python -m pip --version \
&& rm -rf /tmp/gstack-ci-venv
@@ -178,6 +193,21 @@ RUN for i in 1 2 3; do \
&& fc-cache -f \
&& rm -rf /var/lib/apt/lists/*
# ffmpeg (with ffprobe): /design-motion's checks and exports shell out to it
# (lib/motion-encode.ts), and the marathon design-motion-showcase case runs
# that flow end to end. The image had none, so every trial stopped at
# FFMPEG_MISSING (#3687). The build fails if the packaged ffmpeg ever lacks a
# filter or encoder the checks and exports use.
RUN for i in 1 2 3; do \
apt-get update && apt-get install -y --no-install-recommends ffmpeg && break || \
(echo "ffmpeg install retry $i/3"; sleep 10); \
done \
&& rm -rf /var/lib/apt/lists/* \
&& command -v ffprobe \
&& filters="$(ffmpeg -hide_banner -filters)" && encoders="$(ffmpeg -hide_banner -encoders)" \
&& for f in psnr gblur palettegen paletteuse signalstats tile; do echo "$filters" | grep -qw "$f" || { echo "ERROR: ffmpeg lacks the $f filter" >&2; exit 1; }; done \
&& for e in libx264 gif; do echo "$encoders" | grep -qw "$e" || { echo "ERROR: ffmpeg lacks the $e encoder" >&2; exit 1; }; done
# Pre-install dependencies (cached layer — only rebuilds when package.json or
# bun.lock changes). Copy BOTH so install is deterministic and matches local
# resolution. Without bun.lock here, bun install resolved transitive deps
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Read one file at a commit in a CI checkout that may be a blobless clone
# (actions/checkout `filter: blob:none`), where git fetches an old file's
# contents on demand and that fetch can fail. lib/history-read.ts is the
# TypeScript twin.
#
# Usage: history-read.sh <ref> <path> [--absent <text>]
#
# A read that `git show` answers is the file. When it fails, existence comes
# from the ref's tree (`git ls-tree --full-tree`, local data only): a path the
# tree does not list is absent, so this prints <text> (default: nothing) and
# exits 0. A listed path is retried, 3 tries in all in a partial clone (only a
# fetch can recover) and none otherwise, waiting HISTORY_READ_BACKOFF_SECONDS
# (default 2) times the attempt between tries. Anything else (a ref that does
# not resolve, a fetch that keeps failing) exits 1 with HISTORY_READ_FAILED
# naming the ref, the path and the git version, and never reads as absent.
# docs/troubleshooting.md#history-read-failed
set -euo pipefail
if [ "$#" -ne 2 ] && { [ "$#" -ne 4 ] || [ "$3" != --absent ]; }; then
echo "usage: history-read.sh <ref> <path> [--absent <text>]" >&2
exit 2
fi
ref=$1 file=$2 absent=${4:-}
out=$(mktemp) err=$(mktemp)
trap 'rm -f "$out" "$err"' EXIT
fail() {
echo "::error::cannot read ${file} at ${ref}. $1 ($(git --version)). Fix: see docs/troubleshooting.md#history-read-failed (HISTORY_READ_FAILED)" >&2
exit 1
}
if git show "${ref}:${file}" >"$out" 2>"$err"; then cat "$out"; exit 0; fi
listed=$(git ls-tree --full-tree "$ref" -- "$file" 2>"$err") || fail "git ls-tree failed: $(head -n 1 "$err")"
if [ -z "$listed" ]; then
printf '%s' "$absent"
exit 0
fi
tries=1
if git config --get-regexp '^(remote\..+\.promisor|extensions\.partialclone)$' | grep -qvi ' false$'; then tries=3; fi
try=1
while [ "$try" -lt "$tries" ]; do
sleep "$(( try * ${HISTORY_READ_BACKOFF_SECONDS:-2} ))"
if git show "${ref}:${file}" >"$out" 2>"$err"; then cat "$out"; exit 0; fi
try=$(( try + 1 ))
done
fail "git show failed ${tries} time(s): $(head -n 1 "$err")"
+11 -1
View File
@@ -49,7 +49,17 @@ jobs:
- name: Read the train policy (main)
id: policy
run: |
train=$(git show "${GITHUB_SHA}:.gstack/ship-policy.json" 2>/dev/null | jq -c 'if (.train | type) == "object" then .train else null end' 2>/dev/null || true)
ref="$GITHUB_SHA" file=.gstack/ship-policy.json policy=null
read_failed() { echo "::error::cannot read ${file} at ${ref}. $1 ($(git --version)). Fix: see docs/troubleshooting.md#history-read-failed (HISTORY_READ_FAILED)" >&2; exit 1; }
listed=$(git ls-tree --full-tree "$ref" -- "$file") || read_failed "git ls-tree failed"
if [ -n "$listed" ]; then
for try in 1 2 3; do
policy=$(git show "${ref}:${file}") && break
if [ "$try" = 3 ]; then read_failed "git show failed 3 times"; fi
sleep $((try * 2))
done
fi
train=$(jq -c 'if (.train | type) == "object" then .train else null end' <<<"$policy") || { echo "::error::${GITHUB_SHA}:.gstack/ship-policy.json is not valid JSON; fix the policy file" >&2; exit 1; }
if [ -z "$train" ] || [ "$train" = null ]; then
echo "train=false" >> "$GITHUB_OUTPUT"
echo "- Backstop inert: main's .gstack/ship-policy.json declares no train block; per-PR gates cover main" >> "$GITHUB_STEP_SUMMARY"
+20 -3
View File
@@ -179,6 +179,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
filter: blob:none
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
@@ -193,9 +194,19 @@ jobs:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
base="${BASE_SHA:-origin/${DEFAULT_BRANCH}}"
train=$(git show "${base}:.gstack/ship-policy.json" 2>/dev/null | jq -c 'if (.train | type) == "object" then .train else null end' 2>/dev/null || true)
artifact=$(jq -r '.record_artifact // ""' <<<"${train:-null}" 2>/dev/null || true)
check=$(jq -r '.full_gate_check // ""' <<<"${train:-null}" 2>/dev/null || true)
ref="$base" file=.gstack/ship-policy.json policy=null
read_failed() { echo "::error::cannot read ${file} at ${ref}. $1 ($(git --version)). Fix: see docs/troubleshooting.md#history-read-failed (HISTORY_READ_FAILED)" >&2; exit 1; }
listed=$(git ls-tree --full-tree "$ref" -- "$file") || read_failed "git ls-tree failed"
if [ -n "$listed" ]; then
for try in 1 2 3; do
policy=$(git show "${ref}:${file}") && break
if [ "$try" = 3 ]; then read_failed "git show failed 3 times"; fi
sleep $((try * 2))
done
fi
train=$(jq -c 'if (.train | type) == "object" then .train else null end' <<<"$policy") || { echo "::error::${base}:.gstack/ship-policy.json is not valid JSON; fix the policy file" >&2; exit 1; }
artifact=$(jq -r '.record_artifact // ""' <<<"${train:-null}")
check=$(jq -r '.full_gate_check // ""' <<<"${train:-null}")
if [ -z "$train" ] || [ "$train" = null ]; then
echo "train=false" >> "$GITHUB_OUTPUT"
echo "- Train policy: none at ${base}; this run routes as before" >> "$GITHUB_STEP_SUMMARY"
@@ -470,6 +481,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
filter: blob:none
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
@@ -528,6 +540,7 @@ jobs:
# Preserve full history for merge-base diff selection. Moving the
# planner off the eval image must not change its selection inputs.
fetch-depth: 0
filter: blob:none
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
@@ -670,6 +683,7 @@ jobs:
# shards on the lane's first live run ("ambiguous argument
# 'main...HEAD'"). The manifest still governs WHICH shards run.
fetch-depth: 0
filter: blob:none
persist-credentials: false
- name: Fix bun temp
@@ -903,6 +917,7 @@ jobs:
with:
ref: ${{ inputs.screen_merge_sha }}
fetch-depth: 0
filter: blob:none
persist-credentials: false
- name: Fix bun temp
@@ -1029,6 +1044,7 @@ jobs:
with:
ref: ${{ fromJSON(needs.route.outputs.bisect_refs)[matrix.slice] }}
fetch-depth: 0
filter: blob:none
persist-credentials: false
- name: Verify the prefix checkout
@@ -1173,6 +1189,7 @@ jobs:
# history of its prefix commits T1..Tk for the train record.
ref: ${{ inputs.screen_merge_sha }}
fetch-depth: ${{ needs.plan-slices.outputs.mode == 'train' && '0' || '1' }}
filter: blob:none
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
+5 -2
View File
@@ -71,6 +71,9 @@ jobs:
windows:
uses: ./.github/workflows/windows-free-tests.yml
permissions:
contents: read
actions: read
with:
record_durations: true
@@ -161,7 +164,7 @@ jobs:
- Files that failed while recording (durations kept; this PR's own free checks decide): $failed_line
- Seed growth ratchet (60 s ceiling, shrink-only allowlist): $ratchet_line
Data only. This run dispatched Free Tests (the required free-tests check), Windows Free Tests and Quality gate on this branch; those are this PR's checks. The pull_request runs wait as "action required" because a token-opened PR never starts them without approval. Stamp and merge per docs/ship-policy.md; the stamp push runs the full PR checks.
Data only. This run dispatched Free Tests (the required free-tests check), Windows Smoke and Quality gate on this branch; those are this PR's checks. The pull_request runs wait as "action required" because a token-opened PR never starts them without approval. Stamp and merge per docs/ship-policy.md; the stamp push runs the full PR checks.
EOF
if [ -n "$number" ]; then
gh pr edit "$number" --body-file pr-body.md
@@ -169,5 +172,5 @@ jobs:
gh pr create --base main --head "$branch" --title "test(seed): refresh the free-suite duration seeds" --body-file pr-body.md
fi
gh workflow run free-tests.yml --repo "$GITHUB_REPOSITORY" --ref "$branch"
gh workflow run windows-free-tests.yml --repo "$GITHUB_REPOSITORY" --ref "$branch"
gh workflow run windows-smoke.yml --repo "$GITHUB_REPOSITORY" --ref "$branch"
gh workflow run quality-gate.yml --repo "$GITHUB_REPOSITORY" --ref "$branch"
+140 -14
View File
@@ -14,6 +14,15 @@ name: Free Tests
# is permanent false comfort.
#
# Local `bun run test` still uses the existing bounded process pool.
#
# One full suite per tree (GSTA-156, docs/pregate.md#ci-reuse). The aggregate
# uploads a `tested-tree` record (tree.json) only after strict verification
# passes. A push to main first runs `reuse`: when the merged pull request's
# head has the pushed tree and a green pull_request or workflow_dispatch run of
# this workflow verifies (lib/run-evidence.ts), every suite job is skipped and
# the aggregate links that run instead. Any other answer, a reuse failure
# included, runs every job as before. Pull request and dispatch runs never
# consume; reused runs never upload a record, so they are never reused.
on:
pull_request:
@@ -32,13 +41,27 @@ on:
description: 'Runner-sizing experiments only: shard count, 1-40 (empty keeps FREE_SUITE_SHARDS)'
type: string
default: ''
free_jobs:
description: 'Runner-sizing experiments only: sub-shards per CI shard (--shard-jobs), 1-8 (empty keeps FREE_SUITE_JOBS)'
type: string
default: ''
# The one source of the free-suite runner and shard count. pull_request and
# push runs never see workflow_dispatch inputs, so free-plan resolves these
# (or a dispatch override) into outputs that free-suite reads.
# The one source of the free-suite runner, shard count and sub-shards per
# shard. pull_request and push runs never see workflow_dispatch inputs, so
# free-plan resolves these (or a dispatch override) into outputs and the plan
# that free-suite reads. FREE_SUITE_JOBS is k in `--ci-plan --shard-jobs k`:
# each shard runs min(k, its parallel files) sandboxed sub-shards side by side
# (scripts/lib/shard-jobs.ts); the exclusive host-state shard stays one child.
# FREE_SUITE_SETUP_SECONDS is a free-suite job's time outside the "Run free
# suite" step. --ci-plan adds it to each shard's runner wall and flaky-retry
# wall to emit that shard's job timeout (#3850), so GitHub never kills a job
# before the runner can name a hung file. Measured over 110 free-suite jobs in
# PR runs 41467261350..41478988224: p50 60 s, p90 80 s, max 142 s.
env:
FREE_SUITE_RUNNER: ubicloud-standard-2
FREE_SUITE_SHARDS: '20'
FREE_SUITE_RUNNER: ubicloud-standard-8
FREE_SUITE_SHARDS: '10'
FREE_SUITE_JOBS: '4'
FREE_SUITE_SETUP_SECONDS: '300'
# Keyed on the PR number, not head_ref: a bare branch name carries no fork
# prefix, so same-name branches from two forks would share one group and a
@@ -54,7 +77,40 @@ permissions:
contents: read
jobs:
# Main pushes only, and the one job with actions: read (it reads this
# repository's runs and their tested-tree artifacts). Every suite job below
# runs unless this job succeeded and reused a run; a skipped or failed reuse
# job runs them all. Their conditions start with !cancelled() because a
# skipped needed job would otherwise skip its dependents on pull_request and
# dispatch runs, where reuse never runs.
reuse:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: read
actions: read
outputs:
reused: ${{ steps.find.outputs.reused }}
run_id: ${{ steps.find.outputs.run_id }}
run_url: ${{ steps.find.outputs.run_url }}
tree: ${{ steps.find.outputs.tree }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.4.2
- id: find
name: Find a green pull request or dispatch run of this exact tree
env:
GH_TOKEN: ${{ github.token }}
run: bun run scripts/ci-tree-reuse.ts find --workflow free-tests.yml --profile ci-reuse/free-tests
free-plan:
needs: reuse
if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
runs-on: ubuntu-24.04
timeout-minutes: 5
outputs:
@@ -65,8 +121,10 @@ jobs:
with:
persist-credentials: false
# Full history: the seed ratchet compares the allowlist with its
# merge-base copy, which a depth-1 checkout cannot read.
# merge-base copy, which a depth-1 checkout cannot read. Blobless:
# that copy is fetched on demand, and a failed read fails the ratchet.
fetch-depth: 0
filter: blob:none
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.4.2
@@ -76,10 +134,12 @@ jobs:
env:
RUNNER_OVERRIDE: ${{ inputs.free_runner }}
SHARDS_OVERRIDE: ${{ inputs.free_shards }}
JOBS_OVERRIDE: ${{ inputs.free_jobs }}
run: |
set -euo pipefail
runner="${RUNNER_OVERRIDE:-$FREE_SUITE_RUNNER}"
shards="${SHARDS_OVERRIDE:-$FREE_SUITE_SHARDS}"
jobs="${JOBS_OVERRIDE:-$FREE_SUITE_JOBS}"
case "$runner" in
ubicloud-standard-2|ubicloud-standard-4|ubicloud-standard-8) ;;
*) echo "::error::free_runner '$runner' is not ubicloud-standard-2, -4 or -8"; exit 1 ;;
@@ -88,10 +148,14 @@ jobs:
''|*[!0-9]*) echo "::error::free_shards '$shards' is not a whole number"; exit 1 ;;
esac
if [ "$shards" -lt 1 ] || [ "$shards" -gt 40 ]; then echo "::error::free_shards $shards is outside 1-40"; exit 1; fi
if [ "$runner" != "$FREE_SUITE_RUNNER" ] || [ "$shards" != "$FREE_SUITE_SHARDS" ]; then
echo "- Runner-sizing experiment: $shards shards on $runner (defaults $FREE_SUITE_SHARDS on $FREE_SUITE_RUNNER); not a PR or train Free Tests result" >> "$GITHUB_STEP_SUMMARY"
case "$jobs" in
''|*[!0-9]*) echo "::error::free_jobs '$jobs' is not a whole number"; exit 1 ;;
esac
if [ "$jobs" -lt 1 ] || [ "$jobs" -gt 8 ]; then echo "::error::free_jobs $jobs is outside 1-8"; exit 1; fi
if [ "$runner" != "$FREE_SUITE_RUNNER" ] || [ "$shards" != "$FREE_SUITE_SHARDS" ] || [ "$jobs" != "$FREE_SUITE_JOBS" ]; then
echo "- Runner-sizing experiment: $shards shards on $runner at $jobs sub-shards each (defaults $FREE_SUITE_SHARDS on $FREE_SUITE_RUNNER at $FREE_SUITE_JOBS); not a PR or train Free Tests result" >> "$GITHUB_STEP_SUMMARY"
fi
matrix=$(bun run scripts/test-free-shards.ts --ci-plan "$RUNNER_TEMP/free-plan.json" --shards "$shards")
matrix=$(bun run scripts/test-free-shards.ts --ci-plan "$RUNNER_TEMP/free-plan.json" --shards "$shards" --shard-jobs "$jobs" --job-setup-seconds "$FREE_SUITE_SETUP_SECONDS")
echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
echo "runner=$runner" >> "$GITHUB_OUTPUT"
- name: Seed growth ratchet (60 s ceiling, shrink-only allowlist)
@@ -104,6 +168,8 @@ jobs:
if-no-files-found: error
cso-macos-launcher:
needs: reuse
if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
runs-on: macos-latest
timeout-minutes: 15
steps:
@@ -140,8 +206,11 @@ jobs:
# code that compares a realpath with an unresolved path passes on Linux's
# /tmp and fails here (#2982). TMPDIR is deliberately left at the runner's
# default, and the job fails if that layout is not symlinked. It also sources
# the Codex probe from zsh, macOS's default shell (#3024; no paid call).
# the Codex probe from zsh, macOS's default shell (#3024; no paid call), and
# runs ubi-runner under macOS's bsdtar and one-true-awk (#3680, #3679).
macos-named-regressions:
needs: reuse
if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
runs-on: macos-latest
timeout-minutes: 20
steps:
@@ -181,11 +250,13 @@ jobs:
resolved="$(realpath "$tmp")"
echo "temp root: $tmp -> $resolved"
test "$resolved" != "${tmp%/}"
files=(test/gstack-design-detect.test.ts browse/test/chromium-profile-isolation.test.ts test/cso-git-hardening.test.ts test/plan-count-file-permission.test.ts test/spec-quality-gate-secret-sink.test.ts test/gstack-memory-ingest.test.ts test/regression-issue3024-zsh-sourced-helpers.test.ts test/codex-probe-subcommands.test.ts browse/test/proxy-env-daemon.test.ts browse/test/proxy-direct-launch-args.test.ts)
files=(test/gstack-design-detect.test.ts browse/test/chromium-profile-isolation.test.ts test/cso-git-hardening.test.ts test/plan-count-file-permission.test.ts test/spec-quality-gate-secret-sink.test.ts test/gstack-memory-ingest.test.ts test/regression-issue3024-zsh-sourced-helpers.test.ts test/codex-probe-subcommands.test.ts browse/test/proxy-env-daemon.test.ts browse/test/proxy-direct-launch-args.test.ts test/regression-issue3680-ubicloud-bsd-userland.test.ts)
for file in "${files[@]}"; do test -f "$file"; done
bun test "${files[@]}"
cso-windows-launcher:
needs: reuse
if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
runs-on: windows-latest
timeout-minutes: 15
steps:
@@ -214,6 +285,8 @@ jobs:
retention-days: 14
cso-docker-integration:
needs: reuse
if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
@@ -244,6 +317,8 @@ jobs:
DOCKER_HOST: unix:///var/run/docker.sock
typecheck:
needs: reuse
if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
@@ -267,6 +342,8 @@ jobs:
# the quick tier also runs on it. Its pin equals GSTACK_BUN_DEFAULT;
# test/bun-version-drift.test.ts checks this job exists and agrees.
quick-bun-default:
needs: reuse
if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
runs-on: ubicloud-standard-8
timeout-minutes: 30
steps:
@@ -363,9 +440,12 @@ jobs:
run: xvfb-run -a bun run test:quick
free-suite:
needs: free-plan
name: free-suite (${{ matrix.shard }})
needs: [free-plan, reuse]
if: ${{ !cancelled() && needs.free-plan.result == 'success' && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
runs-on: ${{ needs.free-plan.outputs.runner }}
timeout-minutes: 20
# Per shard, from the plan: runner wall + flaky retry + setup (#3850).
timeout-minutes: ${{ matrix.timeout_minutes }}
strategy:
fail-fast: false
max-parallel: 20
@@ -588,13 +668,38 @@ jobs:
# Branch protection already requires the `free-tests` context. Keep that
# stable name as an always-running aggregate so every platform-specific CSO
# gate is merge-blocking without a separate branch-protection migration.
# Its first step picks one of two exclusive paths before any download: a
# verified reuse (main pushes only) links the reused run and passes; anything
# else takes the strict path below unchanged, which alone uploads tested-tree.
free-tests:
if: always()
needs: [free-suite, typecheck, quick-bun-default, cso-macos-launcher, macos-named-regressions, cso-windows-launcher, cso-docker-integration]
needs: [reuse, free-suite, typecheck, quick-bun-default, cso-macos-launcher, macos-named-regressions, cso-windows-launcher, cso-docker-integration]
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Take the reuse path only for a verified main-push reuse
id: route
env:
REUSE_RESULT: ${{ needs.reuse.result }}
REUSED: ${{ needs.reuse.outputs.reused }}
REUSE_RUN_ID: ${{ needs.reuse.outputs.run_id }}
REUSE_RUN_URL: ${{ needs.reuse.outputs.run_url }}
REUSE_TREE: ${{ needs.reuse.outputs.tree }}
run: |
set -eu
if [ "$REUSE_RESULT" != success ] || [ "$REUSED" != true ]; then
echo 'reused=false' >> "$GITHUB_OUTPUT"
exit 0
fi
test "$GITHUB_EVENT_NAME" = push
test "$GITHUB_REF" = refs/heads/main
case "$REUSE_RUN_ID" in ''|*[!0-9]*) echo "::error::the reuse job named no run id"; exit 1 ;; esac
case "$REUSE_TREE" in ''|*[!0-9a-f]*) echo "::error::the reuse job named no tree"; exit 1 ;; esac
test "$REUSE_RUN_URL" = "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$REUSE_RUN_ID"
echo "Reused $REUSE_RUN_URL, which passed on tree $REUSE_TREE; this push ran no suite of its own." >> "$GITHUB_STEP_SUMMARY"
echo 'reused=true' >> "$GITHUB_OUTPUT"
- name: Require the free suite, typecheck, the quick tier on the default Bun, macOS regressions, and every CSO platform gate
if: steps.route.outputs.reused != 'true'
env:
FREE_SUITE_RESULT: ${{ needs.free-suite.result }}
TYPECHECK_RESULT: ${{ needs.typecheck.result }}
@@ -613,16 +718,20 @@ jobs:
test "$CSO_WINDOWS_RESULT" = success
test "$CSO_DOCKER_RESULT" = success
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
if: steps.route.outputs.reused != 'true'
with:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
if: steps.route.outputs.reused != 'true'
with:
bun-version: 1.4.2
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
if: steps.route.outputs.reused != 'true'
with:
name: free-plan
path: ${{ runner.temp }}
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
if: steps.route.outputs.reused != 'true'
with:
pattern: free-result-*
merge-multiple: true
@@ -631,10 +740,27 @@ jobs:
# flake-ledger.jsonl). A green run is where a retried flake hides, so the
# summary runs before strict verification.
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
if: steps.route.outputs.reused != 'true'
with:
pattern: flake-ledger-*
path: ${{ runner.temp }}/flake-ledgers
- name: Summarize flaky passes absorbed by the serial retry
if: steps.route.outputs.reused != 'true'
run: bun run scripts/test-health-report.ts flake-summary "$RUNNER_TEMP/flake-ledgers" >> "$GITHUB_STEP_SUMMARY"
- name: Require exact coverage and complete strict results
if: steps.route.outputs.reused != 'true'
run: bun run scripts/test-free-shards.ts --ci-verify "$RUNNER_TEMP/free-plan.json" --results "$RUNNER_TEMP/free-results"
# Only after strict verification passed, so a red or partial run never
# leaves a record; the reuse path never reaches here.
- name: Record the tested tree
if: steps.route.outputs.reused != 'true'
env:
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: bun run scripts/ci-tree-reuse.ts record --plan "$RUNNER_TEMP/free-plan.json" --results "$RUNNER_TEMP/free-results" --os Linux,macOS,Windows --out "$RUNNER_TEMP/tested-tree/tree.json"
- name: Upload the tested tree
if: steps.route.outputs.reused != 'true'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: tested-tree
path: ${{ runner.temp }}/tested-tree/tree.json
if-no-files-found: error
+9 -2
View File
@@ -23,6 +23,9 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
# Blobless: full history with file contents fetched on demand; the
# reads below fail loudly when a fetch fails (.github/scripts/history-read.sh).
filter: blob:none
ref: ${{ github.event.pull_request.head.sha }}
# The head's release_tool runs inside the gate (lib/version-gate-train.ts);
# no token may sit in the checkout it stages. The base fetch below is public.
@@ -49,8 +52,12 @@ jobs:
set -euo pipefail
PR_VERSION=$(cat VERSION | tr -d '[:space:]')
BASE_REF="${{ github.event.pull_request.base.ref }}"
git fetch origin "$BASE_REF" --quiet || true
BASE_VERSION=$(git show "origin/$BASE_REF:VERSION" 2>/dev/null | tr -d '[:space:]' || echo "0.0.0.0")
for try in 1 2 3; do
git fetch origin "$BASE_REF" --quiet && break
if [ "$try" = 3 ]; then echo "::error::git fetch origin $BASE_REF failed 3 times ($(git --version)). Fix: see docs/troubleshooting.md#history-read-failed (HISTORY_READ_FAILED)"; exit 1; fi
sleep $((try * 2))
done
BASE_VERSION=$(bash .version-gate/.github/scripts/history-read.sh "origin/$BASE_REF" VERSION --absent 0.0.0.0 | tr -d '[:space:]')
{
echo "pr_version=$PR_VERSION"
echo "base_version=$BASE_VERSION"
+115 -23
View File
@@ -1,9 +1,15 @@
name: Windows Free Tests
# The Windows-safe subset of the free suite on GitHub's free `windows-latest`
# runners. scripts/test-free-shards.ts --windows-only is the single source of
# truth for which files run: POSIX-bound tests (bin/ shebang spawns, raw /tmp
# paths, /bin/sh, mode bitmasks, the Bun-driven browse server) are excluded by
# Nightly, not a PR gate. Windows has minimal support (docs/support-tiers.md):
# the required Windows check is windows-smoke.yml (./setup plus the core
# skills). This workflow runs the curated Windows-safe subset of the free suite
# on main every night and on workflow_dispatch, and never blocks a pull
# request. A red nightly run gets a fix only when it shows an install, startup
# or core-workflow break; other Windows-only failures close as won't fix.
#
# scripts/test-free-shards.ts --windows-only is the single source of truth for
# which files run: POSIX-bound tests (bin/ shebang spawns, raw /tmp paths,
# /bin/sh, mode bitmasks, the Bun-driven browse server) are excluded by
# pattern there, with explicit force-include and known-incompatible lists.
# Every run prints the curated count and top exclusion reasons in the job
# summary so a shrinking subset is visible.
@@ -19,21 +25,27 @@ name: Windows Free Tests
#
# Native browser qualification campaigns (Windows cookie extraction, launch
# diagnostics) are dispatch-only in native-qualification.yml.
#
# One full suite per tree (GSTA-156, docs/pregate.md#ci-reuse), as in
# free-tests.yml: the aggregate uploads `tested-tree` only after strict
# verification passes, and a push to main whose merged pull request already
# ran this workflow green on the same tree reuses that run. Pull request,
# dispatch and duration-recording runs never consume.
on:
pull_request:
branches: [main]
# Also on main pushes, as free-tests.yml does: trains dispatch only the
# Linux free workflow, so without this no Windows run ever sees a train's
# combined tree or main after a merge.
push:
branches: [main]
# 09:17 UTC, about 2 AM Pacific, on the default branch (main).
schedule:
- cron: '17 9 * * *'
workflow_dispatch:
inputs:
record_durations:
description: Record per-file Windows durations (artifact) instead of running the lane
type: boolean
default: false
windows_jobs:
description: 'Sub-shard experiments only: sub-shards per Windows shard (--shard-jobs), 1-4 (empty keeps WINDOWS_FREE_JOBS). Never cite such a run as a PR result.'
type: string
default: ''
# free-durations-record.yml calls the recording path weekly.
workflow_call:
inputs:
@@ -41,6 +53,11 @@ on:
type: boolean
default: false
# k in `--ci-plan --shard-jobs k`: each Windows shard runs min(k, its parallel
# files) sandboxed sub-shards side by side (scripts/lib/shard-jobs.ts).
env:
WINDOWS_FREE_JOBS: '1'
concurrency:
group: windows-free-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
@@ -50,8 +67,36 @@ permissions:
contents: read
jobs:
# Main pushes only, and the one job with actions: read; see free-tests.yml.
# A skipped or failed reuse job runs every job below as before.
reuse:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: read
actions: read
outputs:
reused: ${{ steps.find.outputs.reused }}
run_id: ${{ steps.find.outputs.run_id }}
run_url: ${{ steps.find.outputs.run_url }}
tree: ${{ steps.find.outputs.tree }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.4.2
- id: find
name: Find a green pull request or dispatch run of this exact tree
env:
GH_TOKEN: ${{ github.token }}
run: bun run scripts/ci-tree-reuse.ts find --workflow windows-free-tests.yml --profile ci-reuse/windows-free-tests
windows-plan:
if: ${{ !inputs.record_durations }}
needs: reuse
if: ${{ !cancelled() && !inputs.record_durations && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
runs-on: ubuntu-24.04
timeout-minutes: 5
outputs:
@@ -69,8 +114,18 @@ jobs:
# 4-vCPU Windows runner, so the lane lands near 4 min with setup.
env:
GSTACK_FREE_TEST_DURATIONS: scripts/free-test-durations-windows.json
JOBS_OVERRIDE: ${{ inputs.windows_jobs }}
run: |
matrix=$(bun run scripts/test-free-shards.ts --windows-only --ci-plan "$RUNNER_TEMP/windows-plan.json" --shards 6)
set -euo pipefail
jobs="${JOBS_OVERRIDE:-$WINDOWS_FREE_JOBS}"
case "$jobs" in
''|*[!0-9]*) echo "::error::windows_jobs '$jobs' is not a whole number"; exit 1 ;;
esac
if [ "$jobs" -lt 1 ] || [ "$jobs" -gt 4 ]; then echo "::error::windows_jobs $jobs is outside 1-4"; exit 1; fi
if [ "$jobs" != "$WINDOWS_FREE_JOBS" ]; then
echo "- Sub-shard experiment: $jobs sub-shards per Windows shard (default $WINDOWS_FREE_JOBS); not a PR Windows Free Tests result" >> "$GITHUB_STEP_SUMMARY"
fi
matrix=$(bun run scripts/test-free-shards.ts --windows-only --ci-plan "$RUNNER_TEMP/windows-plan.json" --shards 6 --shard-jobs "$jobs")
echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
@@ -79,7 +134,10 @@ jobs:
if-no-files-found: error
windows-free-shard:
needs: windows-plan
# gstack-pregate reads a job keyed on a dispatch input as manual, never a
# PR obligation; this lane runs only nightly or by hand.
needs: [windows-plan, reuse]
if: ${{ !cancelled() && !inputs.record_durations && needs.windows-plan.result == 'success' && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
runs-on: windows-latest
timeout-minutes: 15
strategy:
@@ -104,14 +162,8 @@ jobs:
name: windows-plan
path: ${{ runner.temp }}
# bun's install cache lives under ~/.bun/install/cache on every platform.
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: ~/.bun/install/cache
key: windows-bun-${{ hashFiles('bun.lock') }}
# A lockfile bump starts from the previous cache instead of cold.
restore-keys: |
windows-bun-
# No bun install cache: restoring the ~450 MB entry took 33-42 s per
# shard, a cold install 16-17 s (GSTA-203 PR 1 A/B, 3 runs per arm).
- name: Configure git identity (required by tests that init temp repos)
run: |
@@ -184,11 +236,33 @@ jobs:
windows-free-tests:
if: ${{ always() && !inputs.record_durations }}
needs: [windows-plan, windows-free-shard]
needs: [reuse, windows-plan, windows-free-shard]
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Take the reuse path only for a verified main-push reuse
id: route
env:
REUSE_RESULT: ${{ needs.reuse.result }}
REUSED: ${{ needs.reuse.outputs.reused }}
REUSE_RUN_ID: ${{ needs.reuse.outputs.run_id }}
REUSE_RUN_URL: ${{ needs.reuse.outputs.run_url }}
REUSE_TREE: ${{ needs.reuse.outputs.tree }}
run: |
set -eu
if [ "$REUSE_RESULT" != success ] || [ "$REUSED" != true ]; then
echo 'reused=false' >> "$GITHUB_OUTPUT"
exit 0
fi
test "$GITHUB_EVENT_NAME" = push
test "$GITHUB_REF" = refs/heads/main
case "$REUSE_RUN_ID" in ''|*[!0-9]*) echo "::error::the reuse job named no run id"; exit 1 ;; esac
case "$REUSE_TREE" in ''|*[!0-9a-f]*) echo "::error::the reuse job named no tree"; exit 1 ;; esac
test "$REUSE_RUN_URL" = "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$REUSE_RUN_ID"
echo "Reused $REUSE_RUN_URL, which passed on tree $REUSE_TREE; this push ran no suite of its own." >> "$GITHUB_STEP_SUMMARY"
echo 'reused=true' >> "$GITHUB_OUTPUT"
- name: Require the plan and every Windows shard
if: steps.route.outputs.reused != 'true'
env:
PLAN_RESULT: ${{ needs.windows-plan.result }}
SHARD_RESULT: ${{ needs.windows-free-shard.result }}
@@ -197,22 +271,40 @@ jobs:
test "$PLAN_RESULT" = success
test "$SHARD_RESULT" = success
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
if: steps.route.outputs.reused != 'true'
with:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
if: steps.route.outputs.reused != 'true'
with:
bun-version: 1.4.2
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
if: steps.route.outputs.reused != 'true'
with:
name: windows-plan
path: ${{ runner.temp }}
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
if: steps.route.outputs.reused != 'true'
with:
pattern: windows-result-*
merge-multiple: true
path: ${{ runner.temp }}/windows-results
- name: Require exact coverage and complete strict results
if: steps.route.outputs.reused != 'true'
run: bun run scripts/test-free-shards.ts --windows-only --ci-verify "$RUNNER_TEMP/windows-plan.json" --results "$RUNNER_TEMP/windows-results"
# Only after strict verification passed; the reuse path never reaches here.
- name: Record the tested tree
if: steps.route.outputs.reused != 'true'
env:
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: bun run scripts/ci-tree-reuse.ts record --plan "$RUNNER_TEMP/windows-plan.json" --results "$RUNNER_TEMP/windows-results" --os Windows --out "$RUNNER_TEMP/tested-tree/tree.json"
- name: Upload the tested tree
if: steps.route.outputs.reused != 'true'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: tested-tree
path: ${{ runner.temp }}/tested-tree/tree.json
if-no-files-found: error
windows-record-durations:
if: ${{ inputs.record_durations }}
+2 -10
View File
@@ -92,16 +92,8 @@ jobs:
with:
bun-version: 1.4.2
# Same lockfile-keyed install cache as windows-free-tests.yml (install
# was 45s of a 64s job, all network).
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: ~/.bun/install/cache
key: windows-bun-${{ hashFiles('bun.lock') }}
# A lockfile bump starts from the previous cache instead of cold
# (restore alone costs ~43s; without this a bump pays it for nothing).
restore-keys: |
windows-bun-
# No bun install cache, as in windows-free-tests.yml: a cold install is
# faster on windows-latest than restoring the ~450 MB cache entry.
- name: Configure git identity
run: |
+170
View File
@@ -0,0 +1,170 @@
name: Windows Smoke
# The required Windows check under minimal support (docs/support-tiers.md):
# with a default Git for Windows install, ./setup installs and the core skills
# start. One windows-latest job, under 5 minutes:
# 1. ./setup --host claude into an isolated HOME under Git Bash, with
# gstack's own browser (Playwright Chromium).
# 2. The core skills start: gstack-skill-start for /ship, /review,
# /qa-only, /investigate and /browse answers SKILL_START_PROTO: 1.
# 3. /browse on gstack's own browser: the installed browse runs a piped
# chain flow against a local page from an unrelated directory.
# 4. The fastest Windows-safe free tests for those paths (list below).
# The full curated Windows suite is windows-free-tests.yml: nightly on main
# and on dispatch, never a PR gate.
#
# No bun install cache: a cold install took 16-17 s on windows-latest, a
# restore of the ~450 MB cache 33-42 s (GSTA-203 PR 1 A/B, 3 runs per arm).
on:
pull_request:
branches: [main]
push:
branches: [main]
workflow_dispatch:
concurrency:
group: windows-smoke-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
permissions:
contents: read
jobs:
windows-smoke:
runs-on: windows-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.4.2
- name: Configure git identity (required by tests that init temp repos)
run: |
git config --global user.email "windows-smoke@gstack.test"
git config --global user.name "Windows Smoke"
git config --global init.defaultBranch main
shell: bash
- name: Install dependencies
run: bun install --frozen-lockfile
shell: bash
- name: ./setup installs into an isolated HOME
run: |
set -euo pipefail
SMOKE_HOME="$RUNNER_TEMP/smoke-home"
mkdir -p "$SMOKE_HOME"
HOME="$SMOKE_HOME" USERPROFILE="$(cygpath -w "$SMOKE_HOME")" ./setup --host claude
test -x "$SMOKE_HOME/.claude/skills/gstack/bin/gstack-skill-start" || (echo "MISSING: installed gstack-skill-start" && exit 1)
shell: bash
- name: The core skills start
run: |
set -euo pipefail
SMOKE_HOME="$RUNNER_TEMP/smoke-home"
USERPROFILE="$(cygpath -w "$SMOKE_HOME")"
export HOME="$SMOKE_HOME" USERPROFILE
for skill in ship review qa-only investigate browse; do
set +e
OUT=$(timeout 120 "$SMOKE_HOME/.claude/skills/gstack/bin/gstack-skill-start" --skill "$skill" --model claude 2>&1 < /dev/null)
CODE=$?
set -e
echo "::group::$skill"; echo "$OUT"; echo "::endgroup::"
test "$CODE" -eq 0 || (echo "/$skill: gstack-skill-start exited $CODE" && exit 1)
grep -q '^SKILL_START_PROTO: 1' <<< "$OUT" || (echo "/$skill: no SKILL_START_PROTO: 1 line" && exit 1)
done
shell: bash
- name: /browse on gstack's own browser runs a piped chain flow from an unrelated directory
run: |
set -euo pipefail
SMOKE_HOME="$RUNNER_TEMP/smoke-home"
USERPROFILE="$(cygpath -w "$SMOKE_HOME")"
export HOME="$SMOKE_HOME" USERPROFILE
BROWSE="$SMOKE_HOME/.claude/skills/gstack/browse/dist/browse.exe"
[ -f "$BROWSE" ] || BROWSE="$SMOKE_HOME/.claude/skills/gstack/browse/dist/browse"
test -f "$BROWSE" || (echo "MISSING: installed browse" && exit 1)
bun -e 'Bun.serve({ hostname: "127.0.0.1", port: 47124, fetch: () => new Response("<title>gstack smoke</title><h1>gstack-windows-smoke-ok</h1>", { headers: { "content-type": "text/html" } }) }); setInterval(() => {}, 1 << 30);' &
PAGE_PID=$!
trap 'kill "$PAGE_PID" 2>/dev/null || true; "$BROWSE" stop >/dev/null 2>&1 || true' EXIT
mkdir -p "$RUNNER_TEMP/unrelated-dir" && cd "$RUNNER_TEMP/unrelated-dir"
set +e
OUT=$(printf '%s\n' '[["goto","http://127.0.0.1:47124/"],["text"]]' | "$BROWSE" chain 2>&1)
CODE=$?
set -e
echo "$OUT"
if [ "$CODE" -ne 0 ]; then
find "$RUNNER_TEMP/unrelated-dir" "$SMOKE_HOME" -maxdepth 4 \( -name 'browse-daemon.log' -o -name 'browse-startup-error.log' \) -print -exec tail -n 60 {} \; 2>/dev/null || true
echo "browse chain exited $CODE" && exit 1
fi
grep -q 'gstack-windows-smoke-ok' <<< "$OUT" || (echo "chain output lacks the page text: the flow did not run" && exit 1)
shell: bash
- name: Fastest Windows-safe free tests for install and the core skills
# Windows-measured seconds from scripts/free-test-durations-windows.json.
# setup-windows-fallback (0.1): setup's _link_or_copy copies, never links, on Windows
# setup-staged-copy (0.6): a copy install is staged and swapped in only when complete
# setup-windows-rerun-refresh (1.7): re-running ./setup refreshes copied skills (#2444)
# setup-skill-copies (0.1): copied SKILL.md hashes and the backup of an edited copy
# setup-bun-cmd-and-pipe-bugs (0.4): setup's bun.cmd and pipe handling
# gstack-launch-probe (0.1): setup's Smart App Control launch probe (#2595)
# state-root-parity (2.1): every skill start resolves one state root in TS and bash
# bun-isolation-launch (1.1): Bun entries ignore .env and bunfig under Git Bash (#3415)
# gstack-lease-git-bash (3.7): the skill-start lease under Git Bash (#3330)
# gstack-wtree-windows-paths (1.3): /review and /ship record a start on C:/ paths (#3101)
# ship-hook-windows-paths (2.3): /ship's pre-push guard in a Windows worktree (#2805)
# qa-only-browser-probe (2.4): /qa-only's browser probe fixture
# find-browse (0.1): /browse resolves its binary (.exe)
# cli-chain-stdin (0.3): /browse chain reads its flow from stdin on Windows (#3039)
# windows-spawn-hide (0.1): /browse's spawns hide their console window (#1835)
env:
GSTACK_FREE_RETRY_FLAKY: '1'
GSTACK_FLAKE_LEDGER: ${{ runner.temp }}/flake-ledger.jsonl
TEMP: ${{ runner.temp }}
TMP: ${{ runner.temp }}
run: |
set -euo pipefail
cat > "$RUNNER_TEMP/windows-smoke-files.txt" <<'LIST'
test/setup-windows-fallback.test.ts
test/setup-staged-copy.test.ts
test/setup-windows-rerun-refresh.test.ts
test/setup-skill-copies.test.ts
test/setup-bun-cmd-and-pipe-bugs.test.ts
test/gstack-launch-probe.test.ts
test/state-root-parity.test.ts
test/bun-isolation-launch.test.ts
test/gstack-lease-git-bash.test.ts
test/gstack-wtree-windows-paths.test.ts
test/ship-hook-windows-paths.test.ts
test/qa-only-browser-probe.test.ts
browse/test/find-browse.test.ts
browse/test/cli-chain-stdin.test.ts
browse/test/windows-spawn-hide.test.ts
LIST
bun run scripts/test-free-shards.ts --files-from "$RUNNER_TEMP/windows-smoke-files.txt"
shell: bash
- name: Upload full test logs
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: windows-smoke-logs
path: |
.context/free-test-logs/gstack-free-test-*.log
${{ runner.temp }}/gstack-free-test-*.log
include-hidden-files: true
if-no-files-found: ignore
- name: Upload flake ledger
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: flake-ledger-windows-smoke
path: ${{ runner.temp }}/flake-ledger.jsonl
if-no-files-found: ignore
retention-days: 90
+3
View File
@@ -1,5 +1,7 @@
.env
node_modules/
node_modules.gstack-migrate/
node_modules.gstack-migrate.lock/
dist/
browse/dist/
design/dist/
@@ -40,6 +42,7 @@ lib/cso/images/gstack-cso-preparation
.cursor/
.copilot/
.agy/
.gemini-cli/
.openclaw/
.hermes/
.gbrain/
+11 -3
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://github.com/garrytan/gstack/docs/pregate.md#pregate-v1",
"runner": "bun test {files}",
"runner": "bun test --timeout=30000 {files}",
"remote_gate": "bash scripts/ubicloud/pregate-remote.sh",
"dependencies": {
".github/workflows/**": ["test/pregate-selection.test.ts", "test/free-tests-workflow-wiring.test.ts"],
@@ -16,8 +16,16 @@
"supabase/**": []
},
"selection": {
"free-tests.yml/free-suite": "bun run scripts/test-free-shards.ts --list",
"windows-free-tests.yml/windows-free-shard": "bun run scripts/test-free-shards.ts --windows-only --list"
"free-tests.yml/free-suite": "bun run scripts/test-free-shards.ts --list"
},
"run_profiles": {
"ci-reuse/free-tests": { "workflow": "free-tests.yml", "events": ["pull_request", "workflow_dispatch"], "jobs": ["free-plan", "typecheck", "quick-bun-default", "cso-macos-launcher", "macos-named-regressions", "cso-windows-launcher", "cso-docker-integration", "free-tests"], "artifact": "tested-tree", "os": "Linux" },
"ci-reuse/windows-free-tests": { "workflow": "windows-free-tests.yml", "events": ["pull_request", "workflow_dispatch"], "jobs": ["windows-plan", "windows-free-tests"], "artifact": "tested-tree", "os": "Windows" }
},
"remote_gate_runs": {
"profiles": ["ci-reuse/free-tests"],
"inventory": "bun --no-env-file --config=/dev/null run scripts/test-free-shards.ts --list | sed -n 's/^ //p'",
"skipped": "bun --no-env-file --config=/dev/null -e 'import { isPaidTestFile } from \"./test/helpers/paid-test-set.ts\"; import * as fs from \"node:fs\"; for (const f of fs.readFileSync(process.env.PREGATE_FILES_FILE, \"utf8\").split(/\\r?\\n/)) if (f && isPaidTestFile(f)) console.log(f);'"
},
"warn": [],
"strays": { "allow": ["modules/loadouts.json", "modules/loadouts.schema.json"] }
+2 -2
View File
@@ -13,7 +13,7 @@
"stamp_sensitive_gates": ["tests"],
"pr_count": { "default_pr_count": 1, "split_requires_owner": true },
"maintainers": ["garrytan"],
"train": { "gate_workflow": "evals.yml", "free_workflow": "free-tests.yml", "full_gate_check": "gstack/full-gate", "record_artifact": "gstack-train-record", "max_size": 3, "fill_hours": 1, "screen_selection": "gstack-paid", "trial_reservation_usd": 1.5 },
"changelog_fragments": { "dir": "changelog.d", "required_checks": ["free-tests", "windows-free-tests", "Check VERSION is not stale vs queue", "quality", "check-freshness", "actionlint"] },
"train": { "gate_workflow": "evals.yml", "free_workflow": "free-tests.yml", "full_gate_check": "gstack/full-gate", "record_artifact": "gstack-train-record", "max_size": 5, "fill_hours": 1, "screen_selection": "gstack-paid", "trial_reservation_usd": 1.5 },
"changelog_fragments": { "dir": "changelog.d", "required_checks": ["free-tests", "windows-smoke", "Check VERSION is not stale vs queue", "quality", "check-freshness", "actionlint"] },
"min_gstack_version": "1.91.340.0"
}
+8 -3
View File
@@ -281,10 +281,15 @@ Agents poll `eval:bg:*` logs for the `### gstack-detach EXIT=<code> ###` sentine
## Platform support
Support tiers (full: macOS, Linux; minimal: Windows, Gemini models) are in
[docs/support-tiers.md](docs/support-tiers.md).
- **macOS** + **Linux**: full test suite supported.
- **Windows**: the curated Windows-safe subset runs in the `windows-free-tests`
CI workflow across six `windows-latest` jobs, packed by Windows-measured
durations. Setup script (`./setup`) requires Git Bash or
- **Windows**: minimal support; [docs/support-tiers.md](docs/support-tiers.md)
says what is promised. Every PR must pass the `windows-smoke` check (`./setup`,
the core skills start, the fastest Windows-safe tests); the curated
Windows-safe subset runs nightly on main in `windows-free-tests` and never
blocks a PR. Setup script (`./setup`) requires Git Bash or
MSYS today; native PowerShell support is a future expansion. The `bin/gstack-paths`
helper resolves state roots through `CLAUDE_PLUGIN_DATA` / `GSTACK_HOME` so plugin
installs work on every platform.
+44 -3
View File
@@ -99,7 +99,7 @@ through CDP before `goto`, as the cookbook does), and no `file://` navigation.
| `/scrape` | Prototype the extraction with `aside repl`, hand back the table, list, or prices as structured data. Read-only. |
| `/devex-review` | Walk the real onboarding flow and time it, carrying the cookbook inline. |
| `/ship`, `/spec`, `/land-and-deploy`, `/setup-deploy`, `/office-hours` | Third-party web actions (vendor dashboards, API keys, webhooks) offered as an Aside drive across your real sessions, with the one-question consent gate for anything mutating. |
| `/plan-ceo-review`, `/plan-eng-review`, `/plan-devex-review`, `/design-consultation`, `/review`, `/investigate`, `/cso`, `/office-hours` | Web research runs through `aside exec` in your real browser (`{{ASIDE_RESEARCH}}`), one read-only request per question, answers treated as untrusted content. No Aside → the same queries go to the host's WebSearch tool; no WebSearch either → "Search unavailable" once and the skill proceeds on in-distribution knowledge. |
| `/plan-ceo-review`, `/plan-eng-review`, `/plan-devex-review`, `/design-consultation`, `/review`, `/cso`, `/office-hours` | Web research runs through `aside exec` in your real browser (`{{ASIDE_RESEARCH}}`), one read-only request per question, answers treated as untrusted content. No Aside → the same queries go to the host's WebSearch tool; no WebSearch either → "Search unavailable" once and the skill proceeds on in-distribution knowledge. |
### Local-HTML rendering
@@ -619,7 +619,7 @@ Storage stays intact by default. With explicit approval on a Chromium target, `-
| Command | Description |
|---------|-------------|
| `tabs` | List open tabs |
| `tabs` | List open tabs, with each owned tab's owner (a pair-agent client or `session:<name>`) |
| `tab <id>` | Switch to tab |
| `newtab [url] [--json]` | Open new tab; `--json` returns `{tabId, url}` for programmatic use |
| `closetab [id]` | Close tab |
@@ -634,6 +634,27 @@ in a long script, `status`, `tabs`, `tab <id>`, `newtab`, `closetab`,
not responding. A command that needs the stuck tab times out after 30s and
names it, so you can switch away or close it without `--force-restart`.
**Sessions keep parallel agents on their own tabs (#1961).** A call from a
known agent harness (Claude Code, Codex and others) gets a session named after
that agent process; its first command opens the session's own tab and later
commands stay there without moving the shared active tab. Name one with
`--session <name>` or `BROWSE_SESSION=<name>`; `BROWSE_SESSION=` turns it off.
`--tab-id` and `BROWSE_TAB` win over a session and no longer move the active
tab either. Sessions isolate tabs, not cookies or storage, and only the local
root token can name one.
### Phone emulation + touch
This is Chromium emulating a phone on one tab, not Safari; a real device still goes through `/ios-qa`.
| Command | Description |
|---------|-------------|
| `device [<name> [--landscape] \| off \| --list]` | Emulate a phone on the current tab only (size, pixel ratio, touch, `pointer: coarse`, user agent) from Playwright's devices table; other tabs stay desktop. `off` restores; `viewport` and `responsive` refuse while a device is active |
| `tap <@ref\|sel\|x y> [--hold <ms>]` | Touch and release at an element's center or at x y. Needs `device` first |
| `swipe <@ref\|sel\|x y> <up\|down\|left\|right> [--distance <px>] [--ms <n>]` | One-finger swipe (default 300px over 300ms). Needs `device` first |
| `pinch <@ref\|sel\|x y> <scale>` | Two-finger pinch; scale above 1 zooms in, below 1 zooms out. Needs `device` first |
| `touch [down\|move <finger 0-9> <x> <y> \| up <finger> \| cancel]` | Hold, move and lift numbered fingers for multi-finger flows; no action lists held fingers. Needs `device` first |
### Extraction
| Command | Description |
@@ -1515,6 +1536,19 @@ capacity each), flushed to disk asynchronously via `Bun.write()`:
- Network: `.gstack/browse-network.log`
- Dialog: `.gstack/browse-dialog.log`
The console, network, dialog and startup-error logs start empty at each daemon
start and can grow while one daemon runs; `browse-daemon.log` and the
`browse-audit.jsonl` command audit build up across restarts, and codex scratch
files collect in `.gstack/tmp/`.
Run `gstack-clean` in the repository to see what it would reclaim, then
`gstack-clean --apply` to do it: it cuts these logs to their last 256 KB
once they pass 1 MB, removes `tmp/codex-*` files older than 7 days, and
trims the audit log only when you pass `--audit-days N`. It never touches
`browse.json`, `session-state.json`, `chromium-profile/`, `browser-skills/`
or `browse-states/`, and it skips the browse files while the daemon runs.
Deleting the whole `.gstack/` folder instead also deletes your cookies and
browser skills.
The `console`, `network`, and `dialog` commands read from the in-memory
buffers (not disk) so capture is real-time even when disk is slow.
@@ -1732,6 +1766,9 @@ the global `~/.gstack/browser-skills/foo/` only inside project-a.
| `BROWSE_HEADLESS_SKIP` | 0 | Skip Chromium launch entirely (test harness only) |
| `BROWSE_TUNNEL` | 0 | Activate the dual-listener tunnel architecture (requires `NGROK_AUTHTOKEN`) |
| `BROWSE_TUNNEL_LOCAL_ONLY` | 0 | Test-only — bind both listeners locally without ngrok |
| `BROWSE_SESSION` | unset (agent harness calls get `agent-<pid>`) | CLI side: this caller's session name; empty or `off` turns sessions off |
| `BROWSE_SESSION_IDLE_MINUTES` | 120 | Daemon side: close (headless) or release (headed) a session's tabs after this many idle minutes; 0 keeps them |
| `BROWSE_MAX_SESSIONS` | 16 | Daemon side: most sessions at once; the least recently used idle one is closed to make room |
| `CHROMIUM_PROFILE` | unset | Explicit headed Chromium profile directory (used by gbrowser's gbd per-workspace); honored by headed launch and profile-lock cleanup, not used by headless sessions |
| `GSTACK_DISABLE_GPU` | unset | On macOS, headless Chromium starts with a GPU-taming flag set (it stops runaway GPU-process spin) that also disables WebGL: `getContext('webgl')`/`'webgl2'` return `null`, so WebGL pages (three.js, Mapbox GL, sigma.js) show their fallback. Set `GSTACK_DISABLE_GPU=off` to enable WebGL (`off` is the only recognized value). |
| `GSTACK_BROWSE_MAX_HTML_BYTES` | 52428800 (50MB) | `load-html` size cap |
@@ -1776,7 +1813,11 @@ browse/
│ ├── cdp-inspector.ts # $B inspect — persistent CDP session per page
│ ├── activity.ts # ActivityEntry, CircularBuffer, SSE subscribers, privacy filtering
│ ├── buffers.ts # Console/network/dialog circular buffers (O(1) ring)
│ ├── tab-session.ts # Per-tab session state (load-html replay, ref map scope)
│ ├── tab-session.ts # Per-tab session state (load-html replay, ref map scope, device profile, held fingers)
│ ├── session-name.ts # CLI: which session a call belongs to (--session, BROWSE_SESSION, agent harness pid)
│ ├── session-tabs.ts # Daemon: per-session and per-tab-id request scopes, idle sweep, session cap
│ ├── device-emulation.ts # $B device: per-tab phone emulation over the tab's own CDP session
│ ├── touch-commands.ts # $B tap/swipe/pinch/touch via Input.dispatchTouchEvent
│ ├── token-registry.ts # Mint/validate/revoke for root + setup keys + scoped tokens
│ ├── sse-session-cookie.ts # 30-min HttpOnly cookie for /activity/stream + /inspector/events
│ ├── pty-session-cookie.ts # Separate scope: live Claude PTY auth
+1477
View File
File diff suppressed because it is too large. Load diff
+16 -12
View File
@@ -129,7 +129,7 @@ and commit both; never edit the generated file
(`test/design-checklist-sync.test.ts` fails on drift).
Generation uses each host's `defaultModel` (`claude` for most hosts, `gpt`
for Codex, `gemini` for Antigravity's `agy`) unless `--model` is explicit. Codex installs additionally read the
for Codex, `gemini` for Antigravity's `agy` and Gemini CLI) unless `--model` is explicit. Codex installs additionally read the
top-level model from `${CODEX_HOME:-~/.codex}/config.toml`; rerun
`./setup --host codex` after changing that model. Note: `bun run build` and a
bare `gen:skill-docs --host codex` render the host default (gpt) — if your
@@ -374,18 +374,22 @@ is not a reason to choose MINOR. Choose **MINOR (X.Y+1.0.0)** or **MAJOR
("ridiculous"), such as an incompatible public-interface change or a genuinely
new product-scale release. Make that judgment without another approval question.
Use `bin/gstack-next-version` to check the live release queue before publishing.
If a slot is claimed, advance to the next available version at the chosen bump
level and use `bin/gstack-version-bump` to synchronize release metadata. A higher
base version does not itself require a MINOR bump. Keep the PR ready for Garry to
merge; autonomous version decisions do not authorize merging, deploying or
skipping required validation.
gstack's policy sets `changelog_fragments` (#3654), so a branch never writes a
version and never checks `bin/gstack-next-version` for a slot. The merge
train's stamp (`train land`) assigns base + PATCH at merge. To ask for more,
open the branch's fragment with `bump: minor` (or `major`) front matter. A
higher base version does not itself require a MINOR bump. Keep the PR READY for
the merge train; autonomous version decisions do not authorize merging,
deploying or skipping required validation.
**VERSION and CHANGELOG are branch-scoped.** Each shipping branch gets its own
bump and one entry, written at `/ship` time, that describes what THIS branch
adds versus main, for users, with no branch-internal versions or development
narrative. VERSION stays 4-digit; package.json carries the 3-digit npm
translation owned by `bin/gstack-version-bump`, so never hand-edit it. Read
**The CHANGELOG entry is a branch fragment.** Each shipping branch writes one
entry at `/ship` time as `changelog.d/<branch-slug>.md` (entry text only, no
`## [x]` heading) that describes what THIS branch adds versus main, for users,
with no branch-internal versions or development narrative. A branch never edits
VERSION, package.json or a released CHANGELOG.md section: the merge stamp folds
the fragment into CHANGELOG.md under its version, keeps VERSION 4-digit and
writes package.json's 3-digit npm translation through `bin/gstack-version-bump`
([changelog fragments](docs/ship-policy.md#changelog-fragments)). Read
[docs/CHANGELOG_STYLE.md](docs/CHANGELOG_STYLE.md) BEFORE writing an entry: it
holds the versioning invariant, the merge-main checks, what stays out and the
entry format. Always credit community contributions with
+10 -9
View File
@@ -14,7 +14,7 @@ bun install # install dependencies
bin/dev-setup # activate dev mode
```
`./setup` on its own installs production dependencies only. `bun install` (and `bin/dev-setup`, which runs it) adds the dev toolchain the tests and evals need: the Claude Agent SDK, TypeScript and Prettier. Once they are installed, a later `./setup` runs the full install too, so they stay at `bun.lock`'s versions.
`./setup` on its own installs production dependencies only. `bin/dev-setup` adds the dev toolchain the tests and evals need (the Claude Agent SDK, TypeScript and Prettier) and marks the clone as a contributor's with `git config gstack.contributor true`. In a marked clone a later `./setup` runs the full install too, so the toolchain stays at `bun.lock`'s versions. In an unmarked clone `./setup` moves back to production dependencies, which is how installs made before v1.91.231.0 drop the toolchain they never needed. If you ran plain `bun install` instead of `bin/dev-setup`, run `git config gstack.contributor true` once, or set `GSTACK_CONTRIBUTOR=1` for a single run.
> **Full clone vs shallow.** The README's user-facing install uses `--depth 1` for speed. As a contributor, use a full clone (no `--depth` flag) — you'll need history for `git log`, `git blame`, `git bisect`, and reviewing PRs against earlier versions. If you already have a `--depth 1` clone from following the README, promote it to a full clone with `git fetch --unshallow`.
@@ -73,8 +73,8 @@ project where you actually felt the pain.
**What CI does on a fork PR.** GitHub never gives a fork PR this repository's
secrets, so CI runs everything that needs none: the required `free-tests` check
(the Linux free suite, typecheck, the macOS and Windows gates), Windows Free
Tests, Skill Docs Freshness, Workflow Lint, Version Gate, Quality gate, the
(the Linux free suite, typecheck, the macOS and Windows gates), Windows
Smoke, Skill Docs Freshness, Workflow Lint, Version Gate, Quality gate, the
make-pdf gate, and any path-triggered gates your change touches. E2E Evals
still builds the CI image from your `Dockerfile.ci` without publishing it, and
skips its paid eval jobs, which need provider API keys. That skip is expected
@@ -192,7 +192,7 @@ the new defaults.
| Run full free acceptance before publishing | `bun run test` | Free, a few minutes | Bun 1.4.2 |
| Run the full free suite from a small machine | `bun run test:ubicloud` | Free suite on a billed 16-vCPU VM, about 5 minutes | `UBICLOUD_API_KEY`, `UBI_OWNER` |
| Clear pregate's test tier on a machine under 8 cores ([remote gate](docs/pregate.md#remote-gate)) | `gstack-pregate --stage tests --remote --spend-cap 2 --timeout 2700 --allow-repo-commands` (same command keyed or keyless) | Keyed: one billed 16-vCPU VM, about 7 minutes and $0.06 for the whole free tier, worst case $0.38; keyless: free, local parallel shards, practical for small selections only (98 files took 103 s on 4 cores) | Keyed: `UBICLOUD_API_KEY`, `UBI_OWNER`; keyless: nothing |
| Run the curated Windows-safe subset | `bun run test:windows` (CI: six `windows-latest` jobs) | Free | Windows, Git Bash |
| Run the curated Windows-safe subset | `bun run test:windows` (CI: six `windows-latest` jobs, nightly on main; PRs run the shorter `windows-smoke` check) | Free | Windows, Git Bash |
| Refresh the Windows duration seed | `gh workflow run windows-free-tests.yml --ref <branch> -f record_durations=true`, then commit the `free-test-durations-windows` artifact | Free CI runners | Pushed branch, `gh` with workflow rights |
| Run a native Windows qualification campaign | `gh workflow run native-qualification.yml --ref <branch> -f mode=<mode>` ([modes](docs/TESTING_INTERNALS.md#windows-free-lane)) | Free CI runners | Pushed branch, `gh` with workflow rights |
| Preview which paid cases my diff selects | `bun run eval:select` (PR profile; `--profile full` for the plain touchfile selection) | Free | — |
@@ -206,6 +206,7 @@ the new defaults.
| Validate periodic cases plus the weekly gate census on a branch | `gh workflow run evals-periodic.yml --ref <branch> -f include_gate_census=true` | CI runners + API spend | Pushed branch, `gh` with workflow rights |
| Run the opt-in ML, gitleaks and Swift checks | `gh workflow run platform-qualification.yml --ref <branch>` | Free CI runners | Pushed branch, `gh` with workflow rights |
| Render one module loadout as an eval arm ([loadouts](docs/loadouts.md)) | `bin/gstack-modules render --host claude --modules full,-section:<skill>/<id> --out-dir <dir>` (`--loadout full` for the control) | Free, about a second per host | Bun 1.4.2 |
| See which loadout a model gets, or publish it as Capy would ([loadouts](docs/loadouts.md#how-gstack-picks-a-loadout)) | `bin/gstack-modules resolve --model <id>`; `bin/gstack-modules install --model <id> --full-root ~/.claude/skills --store <dir>` | Free, under a second | Bun 1.4.2 |
| Look at past local eval runs | `bun run eval:list` / `eval:compare` / `eval:summary` | Free | Local eval history |
| See a case's pass rate across recent weekly runs | `bun run eval:pass-rates --case <case-id>` | Free | `gh` with repo read access |
| Measure one red case alone against the ship bar (MEETS 9/10) | `bun run scripts/ship-measure.ts measure --case <case-id> --round baseline` ([bar](docs/TESTING_INTERNALS.md#ship-measure)) | API spend for 10 trials (12 for behavior); asks above $2/trial, stops at $25/case | Same as paid coverage |
@@ -431,7 +432,7 @@ eval files, and misses the strict classifier. No API keys needed.
- **Catalog budget** (`test/catalog-budget.test.ts`) — Caps the aggregate discovery surface: the sum of every skill's frontmatter `name` + `description` (what every host loads at discovery, every session) must stay under 1,423 token-equivalents, with a 260-byte per-skill cap. Counting goes through the shared census in `test/helpers/skill-census.ts` (physical files vs authored skills vs registry entries — three deliberately different counts). Adding a skill? The failure message carries the re-measure + ratchet protocol.
- **Context-budget ratchet** (`test/context-budget-ratchet.test.ts`) — CI ceilings on the two token ledgers the catalog budget doesn't cover: the always-on full-frontmatter aggregate and each skill's per-invocation eager tokens (SKILL.md + forced-read references), graded against `test/fixtures/context-budget.json` via `lib/context-bill.ts`. New skills fail until they have a ceiling; ceilings for removed skills must be pruned. Legitimate growth or a landed reduction: re-run `bun test/helpers/capture-context-budget.ts` and commit the refreshed fixture in the same commit, so the change is a visible decision in the diff.
- **Skill-to-bin drift ratchet** (`test/skill-bin-drift.test.ts`) — `scripts/skill-bin-drift.ts` statically reads every gstack bin call in skill templates, carved sections, references and the Claude-host renders (it never executes a bin) and fails with `DRIFT_UNKNOWN_BIN` or `DRIFT_UNKNOWN_FLAG` at file:line when a skill names a bin missing from `bin/` or a flag that bin's parser does not accept. Known misses live in `test/fixtures/skill-bin-drift-allowlist.json`, which only shrinks: a new finding fails, and so does an entry that no longer reproduces. Check one skill with `bun run scripts/skill-bin-drift.ts --skill <name>`; bins with permissive parsers or no usage text are reported, never failed.
- **Dependency security regressions** (`test/dependency-security.test.ts`) — Run `bun test test/dependency-security.test.ts` to check the resolved `sharp` and `adm-zip` version floors, load Sharp, verify ordinary ZIP extraction, and reject extraction through destination-file and destination-directory symlinks. The symlink cases skip Windows. These checks complement the OSV scan; they do not change its existing exceptions.
- **Dependency security regressions** (`test/dependency-security.test.ts`) — Run `bun test test/dependency-security.test.ts` to check the resolved `sharp`, `adm-zip`, `qs` and `@hono/node-server` version floors, load Sharp, verify ordinary ZIP extraction, and reject extraction through destination-file and destination-directory symlinks. The symlink cases skip Windows. These checks complement the OSV scan; they do not change its existing exceptions.
### Tier 2: E2E via `claude -p`
@@ -736,11 +737,11 @@ worth the review overhead.
## Multi-host development
gstack generates SKILL.md files for 10 hosts from one set of `.tmpl` templates.
gstack generates SKILL.md files for 13 hosts from one set of `.tmpl` templates.
Each host is a typed config in `hosts/*.ts`. The generator reads these configs
to produce host-appropriate output (different frontmatter, paths, tool names).
**Supported hosts:** Claude (primary), Codex, Factory, Kiro, OpenCode, Slate, Cursor, OpenClaw, Hermes, GBrain.
**Supported hosts:** Claude (primary), Codex, Factory, Kiro, OpenCode, Slate, Cursor, OpenClaw, Hermes, GBrain, Copilot, Antigravity, Gemini CLI.
### Generating for all hosts
@@ -749,7 +750,7 @@ to produce host-appropriate output (different frontmatter, paths, tool names).
bun run gen:skill-docs # Claude (default)
bun run gen:skill-docs --host codex # Codex
bun run gen:skill-docs --host opencode # OpenCode
bun run gen:skill-docs --host all # All 10 hosts
bun run gen:skill-docs --host all # All 13 hosts
# Or use build, which does all hosts + compiles binaries
bun run build
@@ -1037,4 +1038,4 @@ When you're happy with your skill edits:
/ship
```
This runs tests, reviews the diff, triages Greptile comments (with 2-tier escalation), manages TODOS.md, bumps the version, and opens a PR. See `ship/SKILL.md` for the full workflow.
This runs tests, reviews the diff, triages the comments of every review bot you configured (Greptile's with 2-tier escalation), manages TODOS.md, bumps the version, and opens a PR. See `ship/SKILL.md` for the full workflow.
+6 -3
View File
@@ -77,12 +77,13 @@ installs and passes the conformance tests but has no certification run yet;
| Kiro | experimental | `--host kiro` → `~/.kiro/skills/gstack-*/` | ask for `gstack-office-hours` | advisory, not blocked | Codex CLI, signed in | `./setup --host kiro` |
| GitHub Copilot CLI | experimental | `--host copilot` → `~/.copilot/skills/gstack-*/` | `/gstack-office-hours` | advisory, not blocked | Codex CLI, signed in | `./setup --host copilot` |
| Antigravity CLI | experimental | `--host agy` (or `--host antigravity`) → `~/.gemini/antigravity-cli/skills/gstack-*/` | `/gstack-office-hours` | advisory, not blocked | Codex CLI, signed in | `./setup --host agy` |
| Gemini CLI | experimental | `--host gemini` (or `--host gemini-cli`) → `~/.gemini/skills/gstack-*/` | ask for `gstack-office-hours` | advisory, not blocked | Codex CLI, signed in | `./setup --host gemini` |
| Slate | instruction-only | `--host slate` (points at the Claude install; Slate reads `.claude/skills`) | `/office-hours` via the Claude install | advisory, not blocked | — | `./setup --host claude` |
| OpenClaw | experimental | `--host openclaw` (five task skills and one `gstack` router skill in OpenClaw's skills folder; automatic when setup runs inside OpenClaw; ACP route in [docs/OPENCLAW.md](docs/OPENCLAW.md)) | ask for the task; the agent opens the matching task skill or the `gstack` router | advisory, not blocked | — | `./setup --host openclaw` |
| Hermes | experimental | `--host hermes` (every skill in `$HERMES_HOME/skills/gstack`; automatic when setup runs inside Hermes) | ask for the task, or `/gstack-review` | advisory, not blocked | — | `./setup --host hermes` |
| GBrain (mod) | instruction-only | `--host gbrain` (brain-aware variants ship from the GBrain repo) | — | advisory, not blocked | — | — |
Per-host notes (agent harnesses, Copilot, Antigravity, OpenClaw and ClawHub, the Codex behavioral profile and model tiers, outside-review providers, the design binary's models) are in [docs/hosts.md](docs/hosts.md). Any rules-reading agent (Zed, Amp, Jules) can use the 2KB [agents digest](agents-digest/gstack-AGENTS.md) with no install. Adding a host: [docs/ADDING_A_HOST.md](docs/ADDING_A_HOST.md).
Per-host notes (agent harnesses, Copilot, Antigravity, Gemini CLI, OpenClaw and ClawHub, the Codex behavioral profile and model tiers, outside-review providers, the design binary's models) are in [docs/hosts.md](docs/hosts.md). Any rules-reading agent (Zed, Amp, Jules) can use the 2KB [agents digest](agents-digest/gstack-AGENTS.md) with no install. Adding a host: [docs/ADDING_A_HOST.md](docs/ADDING_A_HOST.md).
## See it work
@@ -167,7 +168,7 @@ Each skill feeds into the next. `/office-hours` writes a design doc that `/plan-
| `/eval-plan` | **Eval Planner** | Preregister a paid eval before the first priced call: bars, arms, decoys and stop rules, a $0 stub-model dry run over the real inputs, and a three-item priced pilot whose extrapolation and spend table gate the approval. |
| `/document-release` | **Technical Writer** | Audit changed behavior against project docs on every ship, before final verification and publication. Also runs standalone. Shows updated, reviewed/current or blocked docs and any remaining gaps. |
| `/document-generate` | **Documentation Author** | Generate missing docs from scratch using the Diataxis framework. Researches the codebase first, then writes reference / how-to / tutorial / explanation docs that actually match the code. Invokable standalone or chained from `/document-release` when the coverage map finds gaps. Learn more: [tutorial](docs/tutorial-document-generate.md) • [how-to](docs/howto-document-a-shipped-feature.md) • [why Diataxis](docs/explanation-diataxis-in-gstack.md). |
| `/retro` | **Eng Manager** | Team-aware weekly retro. Per-person breakdowns, shipping streaks, test health trends, growth opportunities. `/retro global` runs across all your projects and AI tools (Claude Code, Codex, Gemini). |
| `/retro` | **Eng Manager** | Team-aware weekly retro. Per-person breakdowns, shipping streaks, test health trends, growth opportunities. `/retro global` runs across all your projects and AI tools (Claude Code, Codex, Gemini, Kimi Code, OMP, Antigravity). |
| `/browse` | **QA Engineer** | Give the agent eyes. Drives your [Aside](https://aside.com) browser first — your real sessions, real clicks, real screenshots — through deterministic `aside repl` scripts. No Aside? It falls back to gstack's own Chromium: real clicks, ~100ms per command, and `/open-gstack-browser` shows it headed with sidebar, anti-bot stealth, and auto model routing. Every other browser skill stands on it. |
| `/scrape` | **Data Extractor** | Pull structured data off a web page — tables, lists, prices — in your Aside browser with the page's real logged-in state. On the fallback browser, `/skillify` turns the flow into a permanent browser-skill that runs in ~200ms next time. |
| `/setup-browser-cookies` | **Session Manager** | Copy selected cookies from Chrome, Chromium, Brave, Edge, Windows-only Opera and Opera GX, or macOS-only Comet and Arc into gstack's bundled browser. Choose your profile and domains; copying and sign-in verification are separate. Only needed on the fallback path — Aside already has your sessions. |
@@ -225,6 +226,8 @@ Other references: [docs/gbrain-sync.md](docs/gbrain-sync.md) (sync-specific guid
## Platform notes
macOS and Linux get full support. Windows and Gemini models get minimal support: install and the core skills work, and nothing more is promised. See [support tiers](docs/support-tiers.md).
**macOS:** everything runs. With the [Aside](https://aside.com) browser (macOS 15+) open and signed in, the browser skills, `/make-pdf` and `/diagram` use it and your real sessions; otherwise they use the bundled browser `./setup` builds. gstack never installs Aside.
**Linux:** everything runs on the bundled browser; Aside is macOS-only, so the readiness probe says `NEEDS_ASIDE: Linux` once and the skills use gstack's own Chromium. If Chromium installs but cannot launch because the host blocks unprivileged user namespaces (Ubuntu 24.04+ AppArmor default), set `GSTACK_CHROMIUM_NO_SANDBOX=1`.
@@ -241,7 +244,7 @@ On Windows without Developer Mode (MSYS2 / Git Bash), `setup` falls back to file
| Doc | What it covers |
|-----|---------------|
| [Skill Deep Dives](docs/skills.md) | Philosophy, examples, and workflow for every skill (includes Greptile integration) |
| [Skill Deep Dives](docs/skills.md) | Philosophy, examples, and workflow for every skill (includes review-bot triage for Greptile, CodeRabbit and other bots) |
| [The recommended harness](docs/recommended-harness.md) | gstack + Capy from zero to the first merged train: install, the Captain and its Drive, trains, the daily flaky pass, the twice-weekly release, the factory scorecard, what is Capy-specific, known Capy limits |
| [gstack + Capy: the software factory](capy/README.md) | What we built with Capy Captains and gstack, how the pieces fit, the measured before/after, tips, and how Garry uses it |
| [Why gstack](docs/why-gstack.md) | The story, the productivity numbers and their caveats, Karpathy's failure modes, parallel sprints |
+33 -13
View File
@@ -2,6 +2,22 @@
## NEXT PRIORITY
### P3: screen verdict reuse lookback (filed 2026-11-02, GST-1027)
- **Screen reuse sees only the newest 50 evals.yml dispatches.** `listGateRuns` (`scripts/recover-receipts.ts`) lists dispatched screens as one unfiltered page (`event=workflow_dispatch`, `per_page=50`), shared with every PR's screens and every train. At week-B traffic (220 dispatches from 2026-10-25 to 11-02, about 27 a day) that is under two days, and nothing says when a PR's earlier screen fell off the page: `gate-reuse find` prints "no earlier executed gate on this PR bound a verdict" and the full screen runs. No miss yet: the longest gap among the week-B re-screens was 44 dispatches (#3607). Page the listing until runs are older than the PR's oldest candidate head (or filter by `created>=`), and print a truncation line when the last page is full. A missed run only costs a screen, never a wrong verdict. **Effort:** S. **Priority:** P3.
### P3: gstack-clean follow-ups (filed 2026-10-31, GSTA-226 E2, #2627)
- **Codex templates delete their own scratch.** The `/codex` review, challenge and consult blocks leave `codex-err-*`, `codex-resp-*`, `codex-out-*` and `codex-prompt-*` mktemp files under `TMP_ROOT` (which falls back to `.gstack/tmp`) and the `codex-prompt.*` and `codex-focus.*` free-text files in `.gstack/tmp`; `gstack-clean` removes them after 7 days. Delete each file at the end of its block instead. Skill prose for every user, so it needs its own review. **Effort:** S. **Priority:** P3.
- **Codex scratch lands at the git toplevel.** `bin/gstack-paths` falls back to the relative `.gstack/tmp` when `TMPDIR` and `TMP` are unset, so a /codex run from a subfolder leaves scratch in `<subfolder>/.gstack/tmp`, where `gstack-clean` does not look. Make the fallback `$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp`. **Effort:** S. **Priority:** P3.
- **`gstack-clean` reclaims `browse-daemon.log.1`.** The browse CLI renames `browse-daemon.log` to `.1` past 10 MB and nothing ever removes the copy. Add it to the browse group (remove when older than an hour and the daemon is not running). **Effort:** S. **Priority:** P3.
- **Browse hints `gstack-clean` when `.gstack/tmp` is large.** Print one line at daemon start when `.gstack/tmp` or the browse logs pass a size threshold, naming `gstack-clean`. **Effort:** S. **Priority:** P3.
### P2/P3: Gemini design provider follow-ups (filed 2026-10-31, from the #990 build)
- **Run the live Gemini check once a key exists** — every Gemini request and response shape in `design/src/provider.ts` follows Google's docs and stubbed fixtures; nobody has sent one to the live API. `GEMINI_API_KEY=... bun run design/scripts/live-gemini-check.ts` (about $0.04) settles the image body, the thinking-image pick and the OpenAI-compatible vision endpoint. A JPEG answer is refused today. **Effort:** S. **Priority:** P2.
- **`$D check` reports a failed vision call as `pass: true, status: "skipped"`** — kept as on main for both providers (DX review F2 wanted a non-zero exit and `pass: null`). That changes OpenAI users' behavior too, so it needs its own decision. **Effort:** S. **Priority:** P3.
### P3: GST-421 and GST-412 follow-ups (filed 2026-10-31, from the PR B /ship review)
- **Check the Approvals table's own cells.** `--check-prose` skips the whole rendered Approvals block, including the Asked, Covers and Still pending cells the lane types. Narrow the skip to the Exact words column. **Priority:** P3.
@@ -12,6 +28,12 @@
- **Derive `carry.covers` from depth.** A carried receipt's `covers` is the fixed `CARRY_COVERS` list (lib/ship-receipt.ts); derive it from the recorded `review_depth` so a light review's carry does not claim the checks a full review ran (plan item 5, deferred at /ship). **Effort:** S. **Priority:** P1.
- **Bound the queue's recompute time.** `gstack-ship-queue` recomputes each receipt's needed depth at READY and at train build; add a test that a five-PR train recomputes within a fixed budget, so a large diff cannot stall the train. **Effort:** S. **Priority:** P1.
### P2/P3: Windows smoke follow-ups (filed 2026-11-01, from the GSTA-203 PR 1 /ship review)
- **Branches cut before windows-smoke.yml still owe the old Windows lane (P2)** — pregate reads lanes from the branch's own workflows, so such a branch keeps an uncleared `windows-free-tests.yml/windows-free-shard` obligation, and `ready --dispatch-ci` skips `windows-smoke.yml` there. The lane clears it by passing `windows-free-tests.yml` to `--dispatch-ci` (the old cost) or with a main-merge grant. Since GSTA-203 PR 6, `gstack-pregate clear --run-url` refuses a `windows-smoke.yml` run for that lane (`workflow_mismatch`), so the wrong-workflow clear is blocked. Have the skip note name that route, or dispatch the old workflow in smoke's place. The skip also treats a branch that deletes or renames `windows-smoke.yml` as one that predates it; check the merge base too. **Effort:** S. **Priority:** P2.
- **Someone owns the nightly (P2)** — nothing reads the scheduled `windows-free-tests.yml` result or its flake ledgers. Add a test-health-report metric for scheduled runs on main, or have the daily flaky pass read the latest nightly. **Effort:** S. **Priority:** P2.
- **Smoke /browse step waits for its page server (P3)** — `windows-smoke.yml` starts `Bun.serve` in the background and calls `browse chain goto` right away; poll the port first. **Effort:** XS. **Priority:** P3.
- **Smoke seed ceiling vs the weekly reseed (P3)** — `test/windows-native-workflows.test.ts` holds each smoke file to 5 s in the Windows seed, which `free-durations-record.yml` rewrites weekly; one slow recording turns the seed PR red. Report the overrun there or re-pick files at reseed. **Effort:** S. **Priority:** P3.
### P2/P3: pregate head-policy follow-ups (filed 2026-10-31, from the GST-419/GST-420 /ship review)
- **`finish` fallback drops head-policy lanes (P2)** — `lib/ship-release.ts` reruns pregate on the stamped tree under the base policy, so after a version bump a lane the branch added under `--policy-from head` is gone and `finish` reports `PREGATE: fail (rerun on the stamped tree)`. GSTA-194 owns that file and has the note. **Effort:** S. **Priority:** P2.
- **The 3600 s preflight and `finish` wrapper deadline (P3)** — it caps the whole pregate run, so several runners that each fit `timeout_s` can still be cut off together. Derive the wrapper deadline from `timeout_s` and the lane count, or document the cap. **Effort:** S. **Priority:** P3.
@@ -34,8 +56,6 @@ The 158 older open issues were re-checked against main 5cb5e1c (v1.91.83.0). Of
- #1048: "smallest clear diff" in `plan-eng-review/SKILL.md.tmpl` and `plan-ceo-review/SKILL.md.tmpl` has no schema-design carve-out, so it biases reviews toward JSON blobs. Add one tradeoff bullet, with a paid before/after case. **Effort:** S. **Priority:** P3.
- #1791: every plan-eng-review run writes a fresh `tasks-eng-review-<ts>.jsonl` with no branch in the name and no reconciliation (`scripts/resolvers/tasks-section.ts`). Put the branch in the name and supersede earlier sets. **Effort:** M. **Priority:** P3.
- #1957: /autoplan Phase 0 prints no matched-terms line for UI scope and keeps UI and DX terms in two places (`bin/gstack-autoplan-snapshot.ts`, `lib/autoplan-prompts.ts`). One shared terms module, and a `borderline` flag in the printed line. **Effort:** M. **Priority:** P3.
- **Review**
- #356: `/review` has no repo-convention check, so a `.bat` added to a bash-and-Make repo passes. Add it to `review/specialists/maintainability.md`. **Effort:** S. **Priority:** P3.
- **Office hours and design**
- #340: the approved sketch stays in a purgeable `mktemp` dir and no downstream design skill reads it (`scripts/resolvers/design.ts`). Copy it next to the design doc and have plan-design-review and design-review read it. **Effort:** S. **Priority:** P3.
- **Questions**
@@ -67,7 +87,7 @@ The 158 older open issues were re-checked against main 5cb5e1c (v1.91.83.0). Of
Each item was decided in the Wave 9 plan and parked with its reason. Credit the reporter or contributor when it is picked up.
- **Fold `triggers:` into skill descriptions (#2118)** — `scripts/gen-skill-docs.ts` folds only `voice-triggers:`, and the trimmed description keeps one sentence, so natural-language triggers never reach the listing. Parked because it conflicts with the catalog-trim design and would regenerate every skill. Mechanism when picked up: a `when_to_use:` frontmatter key for the Claude host, built from `triggers:`, deduped, and kept inside the catalog byte budget (Claude Code's 1,536-character combined listing limit); hosts that allowlist name and description get a bounded "Use when…" sentence or nothing. **Effort:** M. **Priority:** P2.
- **Plan-tune `{skill}-{slug}` ids never reach a signal (#3442, split from #2623)** — the label-to-key capture (#3394), `contributing_events` and the per-day bound (#3397) closed the rest of #2623. Remaining: `scripts/resolvers/question-tuning.ts` lets skills use `{skill}-{slug}` ids, which have no registry entry and so no `signal_key`; those answers never move a dimension. Acceptance: `{skill}-{slug}` ids get a hinted fallback to a registered signal key. Needs a design call on the key source. Credit @v639dragoon. **Effort:** M. **Priority:** P2.
- **Plan-tune qid marker follow-ups (#3442 review notes, filed 2026-11-02)**: `QID_MARKER_RE` in `scripts/qid-marker.ts` drops the whole marker when the `;signal=` tail is malformed (an underscore, a space, an empty key). Match the id first and drop only a bad hint. `question-preference-hook.ts` still looks up the raw marker id, while the log hook lowercases it. Use `parseQidMarker(...).id` in both hooks. Add hinted-marker cases to `test/question-preference-hook.test.ts`. **Effort:** S. **Priority:** P3.
- **Stateless, instruction-only host mode (#3061, @m-check1B)** — hosted multi-tenant runtimes have no install, state root or helper binaries, but skill bodies still read and write `$GSTACK_STATE_ROOT`, call `bin/gstack-*` and log telemetry. Deferred to the new-hosts theme below. Requirements kept on record: tenant-owned state (a `capabilities.state: 'none'` host renders host-provided hooks instead of shell steps), named artifact hooks (`ARTIFACT_SAVE`/`ARTIFACT_FIND` per kind, `WEB_RESEARCH`, `SECRET_SCAN`, `USER_PREF`) mapped the way the gbrain resolvers already are, a generator check that fails a stateless render containing `~/.gstack`, `$GSTACK_`, `bin/gstack-*` or `--event-type`, and the `claude` host's output byte-identical. The contributor offered an office-hours overlay plus the generator check as a first PR. **Effort:** L. **Priority:** P3.
- **Legacy unprefixed Cursor skill folders (#3084)** — 1.31 Cursor installs created `~/.cursor/skills/<skill>/SKILL.md` links; 1.91 links `gstack-*` folders and `_prune_stale_generated` in `setup` only looks at `gstack-*` names, so Cursor can load two skills with the same name. `setup` already removes old unprefixed Claude entries; Cursor needs the same owned-link cleanup (remove only links that point into a gstack checkout). Split from #3084's data-loss fix, which Wave 9 Lane A0 ships. **Effort:** S. **Priority:** P3.
- **`/retro global` says what a "session" is (#1315, third problem)** — `bin/gstack-global-discover.ts` counts transcript files and rollouts, so cron- or agent-driven repos show hundreds of "sessions" with no interactive work. Annotate the retro output that sessions are tool invocations or files, not distinct human working sessions. Problems 1 and 2 are fixed (v1.91.66.0 and Wave 9 Lane F). **Effort:** S. **Priority:** P3.
@@ -75,16 +95,16 @@ Each item was decided in the Wave 9 plan and parked with its reason. Credit the
### P3: feature themes from the Wave 9 triage (filed 2026-10-11)
The 73 open feature requests (75 with #3061 and #3122, which the themes below also name), grouped by theme with counted demand (issues in the theme). Each issue stays open under the `parked` label with a link to its theme here (the rule is in docs/triage.md); the three largest cross-cutting themes (new hosts, provider pluralism, install footprint) also have tracking issues. Pick a theme up as a whole and credit every linked reporter.
The 73 open feature requests (74 with #3061, which the themes below also name), grouped by theme with counted demand (issues in the theme). Each issue stays open under the `parked` label with a link to its theme here (the rule is in docs/triage.md); the three largest cross-cutting themes (new hosts, provider pluralism, install footprint) also have tracking issues. Pick a theme up as a whole and credit every linked reporter.
- **New hosts (8 issues, plus #3061 above and 9 PRs parked on Oct 8)** — Kimi CLI (#1072), Gemini CLI (#1247), Pi Agent (#1288), Claude Desktop Cowork (#1345), Letta Code (#2117), team mode for non-Claude hosts (#890), `/retro global` discovery of Antigravity (#1977) and Kimi Code / OMP sessions (#2917). Waits for the host-config work; see "Parked contributor ideas (Oct 8 triage)" for the parked host PRs. **Priority:** P3.
- **Provider pluralism (7 issues, plus #3122)** — Gemini CLI as an outside-voice backend (#619), non-OpenAI image providers in `design` (#990), Codex-native `image_gen` in the `design` binary's own generate, evolve and prototype calls (#2174; design-shotgun itself already uses `$imagegen` on Codex since #2175), GPT-6 Sol/Luna tiers in Codex call routing (#2943), a `/grok` review port (#2947), Codex review token usage (#2948), Codex refresh-token recovery docs (#1542), and a configurable PR review bot instead of hard-coded Greptile in /review, /ship and /retro (#3122, folded here by decision D4). One provider seam serves all of them. **Priority:** P3.
- **New hosts (7 issues, plus #3061 above and 9 PRs parked on Oct 8)** — Kimi CLI (#1072), Gemini CLI (#1247), Pi Agent (#1288), Claude Desktop Cowork (#1345), Letta Code (#2117), team mode for non-Claude hosts (#890), `/retro global` repo attribution for Antigravity sessions (#1977: they are counted now, but the working directory sits in an undocumented blob). Kimi Code and OMP discovery (#2917) shipped with the scanner registry in `bin/gstack-global-discover.ts`. Waits for the host-config work; see "Parked contributor ideas (Oct 8 triage)" for the parked host PRs. **Priority:** P3.
- **Provider pluralism (7 issues)** — Gemini CLI as an outside-voice backend (#619), more image providers in `design` beyond OpenAI and Gemini (Gemini shipped for #990), Codex-native `image_gen` in the `design` binary's own generate, evolve and prototype calls (#2174; design-shotgun itself already uses `$imagegen` on Codex since #2175), GPT-6 Sol/Luna tiers in Codex call routing (#2943), a `/grok` review port (#2947), Codex review token usage (#2948), and Codex refresh-token recovery docs (#1542). One provider seam serves all of them. **Priority:** P3.
- **Install footprint and layout (7 issues)** — skills.sh distribution (#113), `~/.gstack` as the global install path (#753), a repo-local install that keeps state in the project (#1235), five compiled binaries each bundling Bun (#1245), Windows junctions instead of copies (#1877), the 1.1 GB `~/.claude/skills` footprint (#2199), and a skill-only mode (#2528). Decision D6: setup keeps the staged copy on Windows, because it works today and links change what every cleanup deletes; #1943 (@JonasFocus, junctions) and #2129 (@TTmo123, symlinks with the privilege check) are closed with credit and their designs stay here as the starting point. See also "install footprint follow-ups from the October fix wave". **Priority:** P3.
- **New skills (12 issues)** — GUI kanban for plan reviews (#247), journey maps (#729), UI-doc review (#851), assumption verification `/avl` (#1058), `/a11y-check` (#1104), `/env-check` (#1106), native plan writing (#1170), `/plan-experiment` and `/review-experiment` (#1195), `/plan-status` (#1343), language-specific reviewers (#1753), `/benchmark-mobile` (#1754), session start/end skills (#2088). **Priority:** P3.
- **Ship, review and safety (9 issues)** — `/review` shadowing the native command (#422), a silent-deploy-failures checklist section (#2089), Conductor TODO proposals and a /ship block on unresolved ones (#2090), a light-ship mode (#2188), auditing an issue before filing it (#2642), maintainer-gated CI versus failures in landing-report (#2766), Flutter pubspec versions with build metadata (#2833), a deterministic fact-consistency check for document skills (#2887), a dual-engine /careful guard (#2888). **Priority:** P3.
- **Planning reviews (8 issues)** — marketplace-aware skill recommendations (#419), founder-signal reasoning in the YC recommendation (#513), grounded verification of paths, math and flags in spec reviews (#973), a substrate-neutral existing-capability check (#1685), `/autoplan --tier` (#1955), autoplan workspace safeguards (#1959), splitting one-way architecture forks from two-way defects (#2490), an evidence ledger for "is demand real?" (#2350). **Priority:** P3.
- **State root and project identity (6 issues)** — repo-local portable context artifacts (#1161), design-doc archive and prune (#1536), umbrella directories over several repos (#1923), reclaiming per-repo `.gstack` state (#2627), nested repos declaring themselves projects (#2649), references and disposition on decision events (#2678). **Priority:** P3.
- **Browser and mobile QA (6 issues)** — a remote browser via `BROWSER_WS_ENDPOINT` (#974), per-session tab isolation (#1961), an opt-in User-Agent suffix and default headers (#2459), skipping the Aside probe on Linux and Windows (#2864), activity-ring copies on caught-up reads (#2973), touch input and mobile Safari (#3058). **Priority:** P3.
- **Browser and mobile QA (5 issues)** — a remote browser via `BROWSER_WS_ENDPOINT` (#974), an opt-in User-Agent suffix and default headers (#2459), skipping the Aside probe on Linux and Windows (#2864), activity-ring copies on caught-up reads (#2973), and mobile Safari, the half of #3058 that Chromium touch emulation (GSTA-227) did not cover. Per-session tabs (#1961) shipped in GSTA-227. **Priority:** P3.
- **Prompt cost and skill shape (4 issues)** — a response-header template (#1376), caching preamble output within a session (#1572), the Fable 5 prompting-guide audit (#1994), per-skill benefit in `gstack-context-bill` (#2889). **Priority:** P3.
- **Customization and localization (3 issues)** — locale-aware skills (#481), a tutorial on new customization dimensions (#489), a `SKILL.local.md` overlay (#1759). **Priority:** P3.
- **Retro (2 issues)** — a team-scoped multi-repo retro (#402), ADR and issue awareness in the narrative (#2284). **Priority:** P3.
@@ -5532,12 +5552,12 @@ Garry for each; revisit 2026-10-29 unless a date says otherwise.
(`['paste', 'enter', 'end_turn']`). Wait for the CLI's own write instead of a
timer (docs/TESTING_INTERNALS.md#flake-ledger). **Effort:** S. **Priority:** P2.
- **Split the free files the weekly seed puts over 60 s** —
`test/setup-install-registry.test.ts` (84 s), `test/brain-sync.test.ts` (89 s)
and `test/setup-codex-scope.test.ts` (64 s) are allowlisted with this todo in
`scripts/free-test-seed-allowlist.json` (2026-10-19 recording);
`test/host-renders-prune.test.ts` (57 s) and
`test/setup-codex-scope-namespaces.test.ts` are next. Split
each by describe block, rerun `bun run test:ubicloud --record-durations` and
`test/setup-install-registry.test.ts` (117 s), `test/brain-sync.test.ts` (91 s)
and `test/setup-codex-scope.test.ts` (77 s) are allowlisted with this todo in
`scripts/free-test-seed-allowlist.json` (2026-10-31 recording, run 41229924108).
`test/host-renders-prune.test.ts`, `test/setup-codex-scope-namespaces.test.ts`
and seven other files the recordings put over 60 s were split in
GSTA-151. Split each remaining file by describe block, rerun `bun run test:ubicloud --record-durations` and
delete the entries. **Effort:** S each. **Priority:** P2.
## /write-skill follow-ups (filed via /autoplan on GSTA-133, 2026-10-27)
+2
View File
@@ -74,6 +74,8 @@ If you accept the local PGLite, you end up in **split-engine mode**:
- **Brain/context queries** (`mcp__gbrain__search`, `mcp__gbrain__query`, `mcp__gbrain__get_page`) route to the remote MCP. Plans, retros, learnings, cross-machine memory — all on the shared server.
- **Code queries** (`gbrain code-def`, `code-refs`, `code-callers`, `code-callees`, `gbrain search` for code) route to the local PGLite via the `.gbrain-source` pin in each worktree. Indexed locally, fast, never leaves the machine.
A `gbrain serve --http` on this machine's loopback address (`localhost`, `127.0.0.1`, `::1`) over this machine's own Postgres brain is not Path 4. That is gbrain's recommended setup for local agents on a Postgres brain (`gbrain bootstrap harness`), and gstack treats it as a local brain: transcripts go through `gbrain import` here. gstack decides this from the URL and the local gbrain config alone, and only a config with a Postgres `database_url` counts. A loopback URL next to a local PGLite stays Path 4, so the split-engine setup above keeps working when you reach the remote brain through an SSH tunnel or port forward on a loopback port.
The two engines are independent. Wiping the local PGLite doesn't touch the remote brain; rotating the remote MCP bearer doesn't affect local code search. This is also the right configuration if your remote brain admin can't (or shouldn't) index every developer's checkout — local code stays local.
## MCP registration for Claude Code
+1 -1
View File
@@ -1 +1 @@
1.91.378.0
1.93.41.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.378.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.93.41.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,
+12 -12
View File
@@ -20,7 +20,7 @@ The first `next` prints `SCOPE: ui=… (terms; visual; files) dx=… (detected)
TODOS*, docs/** but not docs/*-policy.md, or other markdown outside a skill dir) runs CEO on the native voice only, then Eng. UI needs a rendered file path or a UI term next
to a visual noun in the Implementation plan. To keep flags for later runs on the same plan, add
the `SCOPE_PIN:` marker it prints (`<!-- gstack-autoplan-scope: … -->`) as the plan's first
line yourself; gstack never writes the plan.
line yourself; gstack never writes the plan. **Kinds (R65), first `next` only.** `--kind fix` reviews nothing: it prints `AUTOPLAN: not needed (bug fix; R65)`, a `PROOF:` line and `GSTACK_RESULT: skill=autoplan status=skipped run=-`, writes nothing and needs no `--out` or `--plan`; the PR still carries the bug's proof, a repro on current main or a failing test written first. `--kind feature`, the default for an unattended run on Capy (`GSTACK_CAPY=1`) that names no kind, is the lean run. Its `phase_order` is `eng` alone with one native reviewer, whose prompt merges the CEO and Eng lenses (Design or DX only when scope detects them), always asks "Any outside terms, licences or rights involved?" and shows the draft code. The draft is `git diff` from the merge base with `--base <ref>` (default `origin/HEAD`), untracked files included and the run directory left out, written to the snapshot as `draft.diff` and hashed; with no code yet it says `DRAFT: none (<why>)`. The first `next` prints `LEAN:` and each opened phase `DRAFT:`; the reviewer's line after INPUT must be `DRAFT: <sha256>` or `DRAFT: none`, or submit refuses it (`RESULT_RECEIPT_MISSING`). No outside voice, `--max-rounds 1`, no carry check and one gate revision: `revise --incorporates` applies the accepted changes without re-review and approves. `--full` brings back every phase and both voices; off Capy, and in an interactive session, the default stays full.
## The loop
@@ -57,28 +57,28 @@ line yourself; gstack never writes the plan.
### Commands
- `gstack-autoplan next --out <dir> --plan <plan.md> [scope flags] [--light] [--spend-cap <usd>] [--max-rounds <n>] [--scaffold]`
first call: init the run, print PHASE:, SCOPE:, DRIVER: and one ATTEMPT: per voice; --max-rounds caps review rounds per phase (default 2 unattended), and an unattended Capy run reserves 5.00 per host attempt unless --estimate-usd <n> says otherwise, so --spend-cap binds.
- `gstack-autoplan next --out <dir> --plan <plan.md> [--kind feature|--full] [--base <ref>] [scope flags] [--light] [--spend-cap <usd>] [--max-rounds <n>] [--estimate-usd <n>] [--scaffold] [--ceo-only]`
first call: init the run, print PHASE:, SCOPE:, DRIVER: and one ATTEMPT: per voice; --kind feature (the default for an unattended Capy run) is the lean run above and prints LEAN: and DRAFT:, --full keeps every phase and voice, and `next --kind fix` alone (no --out or --plan) reviews nothing and ends `status=skipped run=-`; --max-rounds caps review rounds per phase (default 2 unattended); a run with --spend-cap, in any session kind (or any unattended Capy run), reserves 5.00 per voice for each attempt, so the cap binds; --estimate-usd <n> sets that reservation and the run keeps it for later phases; --ceo-only (command line only, never a scope marker) reviews CEO alone and ends `status=reviewed` with `<dir>/ceo-review.md` (`REVIEWED:`): no gate, and the gate verbs refuse it (`CEO_ONLY_NO_GATE`).
- `gstack-autoplan run --out <dir> --plan <plan.md> --native-model <id> --outside-model <id> --native-runner api --runner api [--max-tokens <n>] [--spend-cap <usd>]`
hosts without subagents: one command calls both models directly (ANTHROPIC_API_KEY, OPENAI_API_KEY) and loops next/submit to the gate; a response cut off at the output limit is reported truncated, so raise --max-tokens (default 16384 for both providers; OpenAI counts reasoning tokens toward it); each api call reserves its priced worst case (a model with no price reserves the per-voice default under a cap), a native call that went out but bound no result settles at its usage, or its full reservation when the usage is unknown, and a failed outside call stays counted at its reservation.
- `gstack-autoplan next --out <dir> [--redispatch <attempt>]`
later calls: open the next phase or reprint open attempts; never redispatches on its own.
- `gstack-autoplan submit --out <dir> --phase <p> --voice native|outside --record <file.md> --model <id> [--attempt <id>]`
bind a free-form review record (canonical fence, one findings table or list, or `No findings.`).
- `gstack-autoplan submit ... --record <file.md> --attest "<reason>" --attempt <id>`
bind a record with no INPUT: line as receipt: attested (a wrong INPUT: always refuses).
- `gstack-autoplan submit --out <dir> --phase <p> --voice native|outside --result <file> --model <id> --attempt <id>`
bind a canonical result (the RESULT FORMAT below).
- `gstack-autoplan submit --out <dir> --phase <p> --voice native|outside --result <file> --model <id> --attempt <id> [--usage-usd <n>] [--reconcile <jsonl>]`
bind a canonical result (the RESULT FORMAT below); `--usage-usd` settles the attempt at what the reviewer actually cost (else it stays priced unknown at its reservation); on the submit that closes the phase, `--reconcile` takes your own pairing of findings both voices raised, one `{"outside_id","native_id","disposition","resolution"}` row each, over the structural one.
- `gstack-autoplan submit --out <dir> --phase <p> --voice outside --unavailable|--failed "<reason>"`
close the phase native-only when no outside voice can run.
- `gstack-autoplan answer --out <dir> --gate-rev <n> --reply "<reply>" --by human|agent:<id> [--owner-reply <text>|-] [--rec-hash <h>]`
answer the gate with the reply grammar below; an agent answer stores the owner's words verbatim.
- `gstack-autoplan answer --out <dir> --gate-rev <n> --reply "<reply>" --by human|agent:<id> [--owner-reply <text>|-] [--words-by agent:<id>] [--rec-hash <h>]`
answer the gate with the reply grammar below; an agent answer stores the owner's words verbatim, and passes --words-by agent:<id> whenever the words are its own (the default is owner).
- `gstack-autoplan recommend --out <dir> --gate-rev <n> --item <id> --option <k> --basis "<why>"`
change one recommendation before relay; then render and relay again (a `rec` needs the new `--rec-hash`).
- `gstack-autoplan revise --out <dir> --plan <plan v2> --incorporates <decision ids>`
bind the amended plan; reopens every phase whose reviewed plan changed, Eng last.
- `gstack-autoplan revise --out <dir> --settle <decision id|d4>`
discharge a packaging or ordering change with no plan edit; reopens nothing.
- `gstack-autoplan revise --out <dir> --withdraw <decision id>`
drop a pending amendment instead of applying it.
- `gstack-autoplan revise --out <dir> --settle <decision id|d4> | --withdraw <decision id>`
--settle discharges a packaging or ordering change with no plan edit and reopens nothing; --withdraw drops a pending amendment instead of applying it.
- `gstack-autoplan close --out <dir> --phase <p> --deferred "<reason>" --by human|agent:<id>`
close a phase accepted-with-findings: its open findings go to tasks.jsonl, and Critical, urgent or user-challenge findings become gate items; also the recovery from `STOP: reason=budget_exhausted`.
- `gstack-autoplan budget --out <dir> [--spend-cap <usd>] [--max-rounds <n>] --by human|agent:<id>`
@@ -111,7 +111,7 @@ A reply binds to the `gate_rev` you read (`--gate-rev`). Bare `yes`/`no` is unpa
- `rec`: every item takes its recommendation, plan changes included (each prints APPLY:); `all` and `do it` are aliases.
- `as-is`: keep every direction; no plan change.
- `d3b uc1a`: one <id><option> token per item you set; unanswered auto items take their default.
- `d3b uc1a`: one <id><option> token per item you set; unanswered auto items take their default (a finding item keeps the plan as written; `p1b` applies it).
- `rec except d3b`: recommendations, with the listed overrides.
## Run directory
+1 -1
View File
@@ -17,7 +17,7 @@ The first `next` prints `SCOPE: ui=… (terms; visual; files) dx=… (detected)
TODOS*, docs/** but not docs/*-policy.md, or other markdown outside a skill dir) runs CEO on the native voice only, then Eng. UI needs a rendered file path or a UI term next
to a visual noun in the Implementation plan. To keep flags for later runs on the same plan, add
the `SCOPE_PIN:` marker it prints (`<!-- gstack-autoplan-scope: … -->`) as the plan's first
line yourself; gstack never writes the plan.
line yourself; gstack never writes the plan. **Kinds (R65), first `next` only.** `--kind fix` reviews nothing: it prints `AUTOPLAN: not needed (bug fix; R65)`, a `PROOF:` line and `GSTACK_RESULT: skill=autoplan status=skipped run=-`, writes nothing and needs no `--out` or `--plan`; the PR still carries the bug's proof, a repro on current main or a failing test written first. `--kind feature`, the default for an unattended run on Capy (`GSTACK_CAPY=1`) that names no kind, is the lean run. Its `phase_order` is `eng` alone with one native reviewer, whose prompt merges the CEO and Eng lenses (Design or DX only when scope detects them), always asks "Any outside terms, licences or rights involved?" and shows the draft code. The draft is `git diff` from the merge base with `--base <ref>` (default `origin/HEAD`), untracked files included and the run directory left out, written to the snapshot as `draft.diff` and hashed; with no code yet it says `DRAFT: none (<why>)`. The first `next` prints `LEAN:` and each opened phase `DRAFT:`; the reviewer's line after INPUT must be `DRAFT: <sha256>` or `DRAFT: none`, or submit refuses it (`RESULT_RECEIPT_MISSING`). No outside voice, `--max-rounds 1`, no carry check and one gate revision: `revise --incorporates` applies the accepted changes without re-review and approves. `--full` brings back every phase and both voices; off Capy, and in an interactive session, the default stays full.
## The loop
+3 -2
View File
@@ -67,9 +67,9 @@ If `SKILL_PREFIX` is `true` use `/gstack-*` names; paths stay `~/.claude/skills/
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>"`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check); its `--signal-keys` prints an optional signal-key hint. `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker, the PreToolUse hook treats AskUserQuestion as observed-only and never auto-decides.
**Embed the question_id as a marker in every asked brief**, ad hoc IDs included, with one ID for check, marker and log. Include `<gstack-qid:{question_id}>` once in the question text itself, not only a command or log. On prose paths, use the explicit reply line. Without the marker the hook never auto-decides.
**Embed the option recommendation via the `(recommended)` label suffix** on exactly one option per AUQ. The PreToolUse hook parses it first, falls back to "Recommendation: X" prose, and refuses when ambiguous (two labels = refuse).
@@ -269,6 +269,7 @@ sections. Read a section in full before doing its step; do not work from memory.
| closing a review phase, after its reviews finish and before announcing completion or loading the next phase (read afresh at each exit) | `sections/phase-close.md` |
| presenting the Final Approval Gate (Phase 4) — the aggregator computes $AGGREGATED_TASKS that the gate message substitutes | `sections/tasks-aggregator.md` |
| the preamble echoed `SESSION_KIND: unattended` (read before Phase 1 and at the Phase 4 gate) | `sections/unattended.md` |
| the invocation names `--ceo-only` (read before any preamble instruction block) | `sections/ceo-only.md` |
---
+1 -3
View File
@@ -8,9 +8,7 @@ description: |
taste decisions (close approaches, borderline scope, outside-review disagreements) at a final
approval gate. One command, fully reviewed plan out.
Use when asked to "auto review", "autoplan", "run all reviews", "review this plan
automatically", or "make the decisions for me".
Proactively suggest when the user has a plan file and wants to run the full review
gauntlet without answering 15-30 intermediate questions. (gstack)
automatically", or "make the decisions for me". (gstack)
codex_default_prompt: "Use $gstack-autoplan to run the CEO, design, DX and eng reviews over this plan in one pass, decide the routine questions itself, and stop at a single approval gate."
voice-triggers:
- "auto plan"
+8 -2
View File
@@ -17,6 +17,10 @@ type Event = ClaudeParentPublicEvent;
type Use = NativePublicToolEvent & { order: number };
/** Claude Code's reply to a Read of an unchanged file it already returned in full. */
export const DEDUP_REPLY = 'Wasted call — file unchanged since your last Read. Refer to that earlier tool_result instead.';
/** Claude Code appends exactly this one line to a Bash result when the command's `cd` left the session cwd (#3651). */
const SHELL_CWD_RESET = /\nShell cwd was reset to [^\n]+$/;
/** A Bash result's JSON, without that one host footer; anything else must still be strict JSON. */
export function bashJson(text: string): any { return JSON.parse(text.replace(SHELL_CWD_RESET, '')); }
/** The longest Read input or Bash command the index keeps; a literal init is far shorter. */
const INDEX_INPUT_MAX = 4096;
@@ -69,11 +73,13 @@ function select(tests: GuardJournalTests, currentToolUseId: string) {
const replies = (resultsOf.get(init.use.toolUseId) ?? []).flatMap(r => r.events)
.filter(e => e.kind === 'result' && e.toolUseId === init.use.toolUseId && e.order > init.use.order);
let reply: unknown;
try { reply = replies.length === 1 ? JSON.parse(textResult(replies[0]!) ?? 'null') : undefined; } catch { reply = undefined; }
try { reply = replies.length === 1 ? bashJson(textResult(replies[0]!) ?? 'null') : undefined; } catch { reply = undefined; }
// A failed or unreadable init binds nothing and is skipped, as invocation() does.
if (!object(reply)) continue;
// A reused:true init keeps the earlier binding, as invocation() does.
if (object(reply) && reply.reused === true && bound && bound.activePlan === reply.activePlan &&
bound.restorePath === reply.restorePath) continue;
chainStart = init.record; bound = object(reply) ? reply : undefined;
chainStart = init.record; bound = reply;
}
let start = 0;
for (let i = chainStart ?? records.length - 1; i >= 0; i--)
+12 -9
View File
@@ -10,7 +10,7 @@ import { extractImplementationPlan, checkPhaseImplementation, acceptedBlocks, ca
import { autoplanPhaseCompletions } from '../../lib/autoplan-phase-publication';
import { nativePathSpelling, ownedNativePath, sameNativePath, type ClaudeParentPublicEvent } from '../../lib/claude-journal-records';
import { ownedRecordLimit, ownedRetainedLimit, type JournalPrefix, type OwnedRead, type OwnedReadMeasure } from '../../lib/claude-owned-journal';
import { readGuardJournal, textResult, DEDUP_REPLY } from './guard-journal';
import { bashJson, readGuardJournal, textResult, DEDUP_REPLY } from './guard-journal';
import { REASONS, reasonCode, reasonText, type Detail, type ReasonCode } from './guard-reasons';
import { logGuardDecision } from './guard-log';
import { approveOwnedRead, renderSectionBase, userRenderRoot } from './owned-read';
@@ -227,7 +227,7 @@ function checkpointResult(result: Event, tools: Tools, init: Invocation): { phas
if (use?.name !== 'Bash' || use.order >= result.order) return;
const text = textResult(result);
if (text === undefined) return;
const output = JSON.parse(text);
const output = bashJson(text);
if (!object(output) || !['ceo', 'design', 'dx', 'eng'].includes(output.phase) ||
!ownPath(output.snapshotPath) || typeof output.nativePrompt !== 'string' || !object(output.baselineEdits)) return;
const { nativePrompt, baselineEdits, ...identity } = output;
@@ -260,6 +260,7 @@ function initArguments(command: unknown, root: string): string[] | undefined {
const INIT_MISMATCH_CAUSE = 'Snapshot init returned paths that do not match the canonical form of its own command arguments';
const INIT_FAILED_CAUSE = 'The most recent snapshot init call failed; re-run the same init command with the same three paths';
const INIT_UNPARSEABLE_CAUSE = 'The most recent snapshot init printed something other than its JSON result; re-run the same init command with the same three paths, without a leading cd';
/** Diagnostic only, never binds: a Bash call that ran snapshot init in a shape the guard cannot bind (#3045). */
const UNBINDABLE_INIT = /(?:gstack-autoplan-snapshot|SNAPSHOT_TOOL|\$\{?[A-Z_]+\}?"?)\S*\s+init\s/;
@@ -268,8 +269,8 @@ function invocation(events: Event[], root: string): Invocation {
let bound: Invocation | undefined;
let chosen: Record<string, any> | undefined;
let unbindable = false;
// True while the most recent bindable init use errored (a later success clears it).
let lastErrored = false;
// The cause while the most recent bindable init use failed (a later success clears it).
let lastFailure: string | undefined;
const tools = byTool(events);
for (const use of events) {
if (use.kind !== 'use' || use.name !== 'Bash') continue;
@@ -278,10 +279,12 @@ function invocation(events: Event[], root: string): Invocation {
const results = tools.results(use.toolUseId).filter(x => x.order > use.order);
if (results.length !== 1) fail('init_mismatch');
// An errored init published nothing; a later correct init may still bind.
if (results[0]!.isError === true) { lastErrored = true; continue; }
if (results[0]!.isError === true) { lastFailure = INIT_FAILED_CAUSE; continue; }
const text = textResult(results[0]!);
if (text === undefined) fail('init_failed');
const result = JSON.parse(text);
if (text === undefined) { lastFailure = INIT_FAILED_CAUSE; continue; }
let result: any;
// Output that is not the init's JSON bound nothing; a later correct init may still bind.
try { result = bashJson(text); } catch { lastFailure = INIT_UNPARSEABLE_CAUSE; continue; }
// init reports canonical destinations (macOS /tmp is /private/tmp); canonicalize the argv the same way.
let active: string, restore: string;
try { active = canonicalDestination(args[1]!); restore = canonicalDestination(args[2]!); }
@@ -289,13 +292,13 @@ function invocation(events: Event[], root: string): Invocation {
if (!object(result) || !samePath(result.sourcePlan, fs.realpathSync(args[0]!)) || !samePath(result.activePlan, active) ||
!samePath(result.restorePath, restore) || typeof result.reused !== 'boolean' || !positive(result.originalBytes) ||
!/^[a-f0-9]{64}$/.test(result.originalSha256)) fail('init_mismatch', { cause: INIT_MISMATCH_CAUSE });
lastErrored = false;
lastFailure = undefined;
if (result.reused && bound && bound.activePlan === result.activePlan && bound.restorePath === result.restorePath) continue;
chosen = result;
bound = { activePlan: result.activePlan, restorePath: result.restorePath,
originalSha256: result.originalSha256, start: results[0]!.order };
}
if (lastErrored) fail('init_failed', { cause: INIT_FAILED_CAUSE });
if (lastFailure) fail('init_failed', { cause: lastFailure });
if ((!chosen || !bound) && unbindable) fail('init_unbindable');
if (!chosen || !bound) fail('init_required');
const restore = read(bound.restorePath, true), active = read(bound.activePlan);
+41
View File
@@ -0,0 +1,41 @@
<!-- AUTO-GENERATED from ceo-only.md.tmpl — do not edit directly -->
<!-- Regenerate: bun run gen:skill-docs -->
## CEO-only run (`--ceo-only`)
This section governs the whole run and wins over the skeleton. The run reviews
the plan; it never approves it.
**Zero questions.** Never call AskUserQuestion (native or `mcp__*`) or
ExitPlanMode, never ask the user in prose, never wait for a reply, whatever
`SESSION_KIND` says. A preamble instruction block that would ask takes its
non-consenting default (off, not now, skip). Each decision takes its
`(recommended)` option, else the 6 principles; consent, destructive or
irreversible options take the conservative choice. Log each as a Decision Audit
Trail row.
**Scope.** Run Phases 0, 0.5 and 1 only; send "Phases 2, 2.5, 3 and 4 skipped
(CEO-only run)" and record it in ACTIVE_PLAN. With `--no-outside`, skip every
outside dispatch: `outside_status: disabled (--no-outside)`.
**Mode.** Instead of SELECTIVE EXPANSION, use plan-ceo-review 0E step 2's rule
(an explicit mode in the request wins), skip step 3, run
`~/.claude/skills/gstack/bin/gstack-ceo-mode-handoff "<mode>" --ceo-only --decisions "<rows or none>"`
and send its line verbatim.
**Never applied:** a user challenge (keep the direction; list it below).
**Skipped and logged `skipped (ceo-only)`:** office-hours and BENEFITS_FROM
offers, 0E's question, plan archive cleanup, Next Steps chaining and CEO plan
promotion; the row satisfies its checklist entry.
**State.** Write only in `$_AP`, ACTIVE_PLAN and `GSTACK_STATE_ROOT`
(the override; docs/state-root.md).
**Finish.** After Phase 1 closes, write `$_AP/ceo-review.md`: `# CEO-only
review: <plan title>`; a line with the mode, outside-voice status and "Not
approved"; then `## User challenges (direction kept)` (plan says, proposal,
why, cost if wrong), `## Scope added`, `## Conservative choices`,
`## Findings` (severity, section, fix, disposition) and `## Decision Audit
Trail` (copied), each "None." when empty. Send the CEO Completion Summary and
both paths, skip Completion's logs, approval and `/ship`, and end with:
`GSTACK_RESULT: skill=autoplan status=reviewed run=<absolute $_AP>`
+39
View File
@@ -0,0 +1,39 @@
## CEO-only run (`--ceo-only`)
This section governs the whole run and wins over the skeleton. The run reviews
the plan; it never approves it.
**Zero questions.** Never call AskUserQuestion (native or `mcp__*`) or
ExitPlanMode, never ask the user in prose, never wait for a reply, whatever
`SESSION_KIND` says. A preamble instruction block that would ask takes its
non-consenting default (off, not now, skip). Each decision takes its
`(recommended)` option, else the 6 principles; consent, destructive or
irreversible options take the conservative choice. Log each as a Decision Audit
Trail row.
**Scope.** Run Phases 0, 0.5 and 1 only; send "Phases 2, 2.5, 3 and 4 skipped
(CEO-only run)" and record it in ACTIVE_PLAN. With `--no-outside`, skip every
outside dispatch: `outside_status: disabled (--no-outside)`.
**Mode.** Instead of SELECTIVE EXPANSION, use plan-ceo-review 0E step 2's rule
(an explicit mode in the request wins), skip step 3, run
`~/.claude/skills/gstack/bin/gstack-ceo-mode-handoff "<mode>" --ceo-only --decisions "<rows or none>"`
and send its line verbatim.
**Never applied:** a user challenge (keep the direction; list it below).
**Skipped and logged `skipped (ceo-only)`:** office-hours and BENEFITS_FROM
offers, 0E's question, plan archive cleanup, Next Steps chaining and CEO plan
promotion; the row satisfies its checklist entry.
**State.** Write only in `$_AP`, ACTIVE_PLAN and `GSTACK_STATE_ROOT`
(the override; docs/state-root.md).
**Finish.** After Phase 1 closes, write `$_AP/ceo-review.md`: `# CEO-only
review: <plan title>`; a line with the mode, outside-voice status and "Not
approved"; then `## User challenges (direction kept)` (plan says, proposal,
why, cost if wrong), `## Scope added`, `## Conservative choices`,
`## Findings` (severity, section, fix, disposition) and `## Decision Audit
Trail` (copied), each "None." when empty. Send the CEO Completion Summary and
both paths, skip Completion's logs, approval and `/ship`, and end with:
`GSTACK_RESULT: skill=autoplan status=reviewed run=<absolute $_AP>`
+6
View File
@@ -45,6 +45,12 @@
"file": "unattended.md",
"title": "Unattended profile: parent-driven next/submit loop, gate answers, export, terminal line",
"trigger": "the preamble echoed `SESSION_KIND: unattended` (read before Phase 1 and at the Phase 4 gate)"
},
{
"id": "ceo-only",
"file": "ceo-only.md",
"title": "CEO-only run: zero questions, CEO phase only, review report, no gate",
"trigger": "the invocation names `--ceo-only` (read before any preamble instruction block)"
}
]
}
+76
View File
@@ -0,0 +1,76 @@
<!-- gstack autoplan shadow decider, prompt version 1. Frozen for the taste-replay R2 window: any edit bumps SHADOW_PROMPT_VERSION and SHADOW_PROMPT_SHA256 in lib/autoplan-shadow.ts. -->
# Predict the owner's answer at an autoplan approval gate
You are predicting how one specific person, the owner of this plan, will answer
the questions at the final approval gate of an autoplan review. You are not
deciding for him and nothing you write is shown to him. Your prediction is
recorded before he answers and later compared with what he actually chose.
Be a forecaster, not an advocate. A good prediction is one that turns out to
match his answer. Do not argue for what you think is best for the plan.
## What you have
- The plan's Implementation plan section, as reviewed.
- Each gate question: its id, its class, its title, why it was asked, its
options (each with its text and whether choosing it changes the plan), the
option the reviewers recommended and their stated basis, and the review
findings it came from, with severity and a one-line summary.
The reviewers' recommendation is one input among several. It is often right
and sometimes wrong. Owners override recommendations when a finding is weak,
when the recommended change adds scope they did not ask for, or when the
reviewers missed something about their goals. Weigh it; do not copy it.
## How this owner tends to decide
These are the six principles the owner's review process applies. Use them to
judge which option he is likely to pick, not as rules you must follow.
1. Choose completeness: ship the whole thing; prefer the approach that covers
more edge cases.
2. Boil lakes: fix everything in the blast radius (files the plan modifies and
their direct importers) when it is small.
3. Pragmatic: when two options fix the same thing, pick the cleaner one, fast.
4. DRY: reject what duplicates existing functionality; reuse what exists.
5. Explicit over clever: a short obvious fix beats a large abstraction.
6. Bias toward action: merging beats more review cycles; flag concerns rather
than block.
## For each question, predict
- action: what the owner would do with it.
- `decide`: he answers it now by picking one option.
- `send_back`: he sends the plan back for more work before answering.
- `hold`: he leaves it open or asks questions first.
- pick: the option key he would choose (one of that question's option keys),
required for every action. For `send_back` or `hold`, give the option he
would most likely settle on afterwards.
- confidence: a number from 0 to 1, the probability that he picks exactly that
option.
- cites: the finding ids from that question's findings that most shaped your
prediction (an empty list when none did). Cite only ids listed for that
question.
- reason: one or two sentences, at most 400 characters.
## The plan
{{PLAN}}
## The questions
{{QUESTIONS}}
## Output contract
Reply with exactly one fenced `json` block holding one JSON array and nothing
else. The array has exactly one object per question id listed below, in any
order, with no duplicates and no other ids:
{{IDS}}
Each object has exactly these fields:
```json
{"id": "<question id>", "action": "decide | send_back | hold", "pick": "<option key>", "confidence": 0.0, "cites": ["<finding id>"], "reason": "<at most 400 characters>"}
```
+6 -3
View File
@@ -22,9 +22,12 @@ if [ ! -f "$REPO_ROOT/.env" ]; then
fi
fi
# 2. Install dependencies, devDependencies included. Always run: a checkout
# first installed by ./setup has production dependencies only, and an
# up-to-date install is a no-op.
# 2. Install dependencies, devDependencies included, and mark this clone as a
# contributor's (#3447) so ./setup keeps the dev toolchain instead of moving it
# to the production-only install. Always run: a checkout first installed by
# ./setup has production dependencies only, and an up-to-date install is a no-op.
git -C "$REPO_ROOT" config --local gstack.contributor true 2>/dev/null \
|| echo "Warning: could not set git config gstack.contributor in $REPO_ROOT; export GSTACK_CONTRIBUTOR=1 before ./setup to keep the dev toolchain." >&2
echo "Installing dependencies..."
(cd "$REPO_ROOT" && bun install)
+4 -1
View File
@@ -22,6 +22,7 @@ import {
import { resultLine } from '../lib/result-codes';
import { resolveStateRoot } from '../lib/state-root';
import { validatePublic } from '../lib/autoplan-public';
import { validateShadow } from '../lib/autoplan-shadow-reader';
const HELP = `gstack-artifact — the unattended artifact contract (docs/unattended.md)
@@ -86,7 +87,9 @@ function main(argv: string[]): number {
else { printErrors(errors); process.stdout.write(errors.length ? `ARTIFACT_INVALID: ${errors.length} error(s)\n` : `ARTIFACT_VALID: ${as} ${path.basename(file)}\n`); }
return errors.length ? EXIT.fail : EXIT.ok;
}
const result = validateRun(file);
const run = validateRun(file);
const shadow = validateShadow(run.runDir);
const result = { ...run, valid: run.valid && shadow.length === 0, errors: [...run.errors, ...shadow] };
if (json) {
process.stdout.write(JSON.stringify({ valid: result.valid, run: result.manifest?.run ?? null, status: result.manifest?.status ?? null, run_dir: result.runDir, attested: result.attested ?? 0, errors: result.errors }) + '\n');
} else {
+198 -80
View File
@@ -2,15 +2,16 @@
/**
* gstack-autoplan — the unattended /autoplan runner as an explicit, durable state machine (plan B2).
*
* gstack-autoplan next --out <dir> [--plan <file>] [--plan-heading <text>] [--ui|--no-ui] [--developer-tool] [--agent-primary] [--docs-only]
* gstack-autoplan next --kind fix a bug fix: no plan review (R65), status=skipped, nothing written (run --kind fix too)
* gstack-autoplan next --out <dir> [--plan <file>] [--kind feature|--full] [--base <ref>] [--plan-heading <text>] [--ui|--no-ui] [--developer-tool] [--agent-primary] [--docs-only] [--ceo-only]
* [--no-outside] [--light] [--spend-cap <usd>] [--estimate-usd <n>] [--deadline <min>] [--redispatch <attempt>] [--json]
* gstack-autoplan submit --out <dir> --phase <p> --voice native|outside --result <file> | --record <file> [--attest <reason>]
* --model <id> [--attempt <id>] [--runner <r>] [--usage-usd <n>] [--reconcile <jsonl>] [--json]
* gstack-autoplan submit --out <dir> --phase <p> --voice outside --unavailable <reason> | --failed <reason> [--json]
* gstack-autoplan revise --out <dir> --plan <file> [--incorporates <id,...>] [--withdraw <id>] [--settle <id>] [--json] review a revised plan in this run
* gstack-autoplan run --out <dir> --plan <file> --native-model <id> --outside-model <id> [--runner codex-cli|api] [--native-runner api]
* [--deadline <min>] [--spend-cap <usd>] [--estimate-usd <n>] [--json] next in a loop; needs runners for both voices
* gstack-autoplan answer --out <dir> --gate-rev <rev> --reply "<grammar>" --by <human|agent:<id>> [--owner-reply <text>|-] [--rec-hash <h>] [--json]
* [--deadline <min>] [--spend-cap <usd>] [--estimate-usd <n>] [--max-tokens <n>] [--json] next in a loop; needs runners for both voices
* gstack-autoplan answer --out <dir> --gate-rev <rev> --reply "<grammar>" --by <human|agent:<id>> [--owner-reply <text>|-] [--words-by owner|agent:<id>] [--rec-hash <h>] [--json]
* gstack-autoplan recommend --out <dir> --gate-rev <n> --item <id> --option <k> --basis "<why>" [--by <who>]
* gstack-autoplan resume --out <dir> [--from <checkpoint>] [--json] take ownership; intent without a terminal record becomes EXECUTION_UNKNOWN
* gstack-autoplan status --out <dir> [--json] query: the manifest, no GSTACK_RESULT line
@@ -21,6 +22,7 @@
* gstack-autoplan budget --out <dir> [--spend-cap <usd>] [--max-rounds <n>] --by <human|agent:<id>> the owner's journaled cap change
* gstack-autoplan check --out <dir> --attempt <id> --result <file> --model <id> | --unavailable <reason> the delta check before a carry
* gstack-autoplan checkpoints --from <dir> list checkpoint generations, newest first, with hash ok|bad
* gstack-autoplan shadow --out <dir> | --runs <file> [--json] export answered gates beside their shadow predictions (read-only)
* gstack-autoplan contract print the coordinator contract every lane prompt inherits (plan E2)
*
* Logic: lib/autoplan-run.ts (next/submit/resume), lib/autoplan-gate.ts (close, gate, answer),
@@ -31,10 +33,11 @@
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { amendmentSteps, answerGate, closeDeferred, finishGate, recommendGate } from '../lib/autoplan-gate';
import { amendmentSteps, answerGate, closeDeferred, recommendGate } from '../lib/autoplan-gate';
import { checkCeoOnlyFlags, finishRun, refuseCeoOnly } from '../lib/autoplan-ceo-only';
import { gateCost, gateCostPayload, renderGateCost, type GateCost } from '../lib/autoplan-gate-cost';
import { attemptEstimate, changeBudget, escalateLine, liftStop } from '../lib/autoplan-budget';
import { DEFAULT_CARRY_CHECK_CAP_USD, carryLines, submitCarryCheck } from '../lib/autoplan-carry';
import { DEFAULT_CARRY_CHECK_CAP_USD, submitCarryCheck } from '../lib/autoplan-carry';
import { ANSWERED_BY_RE } from '../lib/gate-list';
import { checkpointLines, checkpointRun, restoreCheckpoint, type CheckpointMoment } from '../lib/autoplan-checkpoint';
import { admit, touchInstallLease } from '../lib/autoplan-admission';
@@ -43,23 +46,36 @@ import { capyRelayLine } from '../lib/captain-brief';
import { exportPublic } from '../lib/autoplan-public';
import { migrateRun } from '../lib/autoplan-migrate';
import { reviseRun } from '../lib/autoplan-revise';
import { pendingAmendments, type RunState } from '../lib/autoplan-state';
import { FILES, RunError, currentPhase, initRun, markOutside, nextStep, readState, resumeRun, statusLines, submitResult, withRunLock, writeState } from '../lib/autoplan-run';
import { FIX_LINES, KINDS, type AutoplanKind } from '../lib/autoplan-lean';
import { pendingAmendments, type RunState, type Voice } from '../lib/autoplan-state';
import { FILES, RunError, currentPhase, fitReservation, initRun, markOutside, markSent, nextStep, readState, resumeRun, retireAttempt, statusLines, submitResult, withRunLock, writeState, type NextOptions } from '../lib/autoplan-run';
import { driverFile, type Phase } from '../lib/autoplan-prompts';
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
import { createRunner, familyConflict, type RunnerId } from '../lib/outside-voice-runner';
import { DEFAULT_MAX_TOKENS, apiTimeoutMs } from '../lib/outside-voice-api';
import { estimateCostUsd, pricingRow } from '../lib/pricing';
import { resultLine } from '../lib/result-codes';
import { release } from '../lib/spend-ledger';
import { COORDINATOR_CONTRACT } from '../lib/coordinator-contract';
import { claimShadow, runShadow, shadowBeforeAnswer, shadowSwitch, type ShadowClaim, type ShadowOutcome } from '../lib/autoplan-shadow';
import { exportShadow, renderShadowExport } from '../lib/autoplan-shadow-reader';
const HELP = `gstack-autoplan — unattended /autoplan: next/submit loop, answer, resume (docs/unattended.md)
DRIVER: ${driverFile()}
next --out <dir> [--plan <file>] [--plan-heading <text>] [--scaffold] [--ui|--no-ui] [--developer-tool] [--agent-primary] [--no-dx] [--docs-only]
next --kind fix a bug fix needs no plan review (R65): prints AUTOPLAN: not needed and status=skipped, writes nothing (no --out or --plan);
the PR still needs the bug's proof (a repro on current main or a failing test written first)
next --out <dir> [--plan <file>] [--kind feature|--full] [--base <ref>] [--plan-heading <text>] [--scaffold] [--ui|--no-ui] [--developer-tool] [--agent-primary] [--no-dx] [--docs-only] [--ceo-only]
[--no-outside] [--light] [--spend-cap <usd>] [--max-rounds <n>] [--estimate-usd <n>] [--deadline <min>] [--redispatch <attempt>] [--json]
--max-rounds (first next): review rounds per phase (default 2 unattended); at the cap a phase closes accepted-with-findings
--kind feature (first next; the default for an unattended Capy run, GSTACK_CAPY=1): the lean run (R65): the eng phase alone with one
native reviewer whose brief merges the CEO and Eng lenses (Design/DX when scope detects them), reads the draft code (git diff of
--base, default origin/HEAD, hashed into the snapshot; DRAFT: none when there is no code yet) and always asks about outside terms;
no outside voice, one round, one gate revision (accepted changes apply without re-review), no carry check
--full (first next): every phase and both voices, as before R65; off Capy, or in an interactive session, the default stays full
--max-rounds (first next): review rounds per phase (default 2 unattended, 1 lean); at the cap a phase closes accepted-with-findings
--docs-only (first next; also detected when every Implementation-plan path is docs-class): CEO (native voice only) + Eng
--estimate-usd: reserved per new attempt; unattended Capy runs reserve a default for host attempts so --spend-cap binds
--ceo-only (first next, command line only): the CEO phase alone, no gate; ends status=reviewed with <out>/ceo-review.md, never approved
--estimate-usd: reserved per new attempt, kept for the rest of the run; with --spend-cap any run (or an unattended run on Capy) reserves a default so the cap binds
--scaffold (first next): a plan missing one of its two sections is reviewed from <out>/plan-source.md with the heading added
--light loads each phase's checklist plus the sections its scope needs (the unattended default); interactive kinds load everything
--no-carry-forward (first next): every later gate revision goes to the owner, even one that asks nothing new
@@ -80,9 +96,13 @@ DRIVER: ${driverFile()}
comes into scope, the edit applies without re-review (APPLIED:); --withdraw (alone or with --plan) drops one;
--settle records a packaging/ordering change that needs no plan edit and reopens nothing (ids: decision id or d4)
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
next in a loop, only when a runner executes both voices here (hosts without subagents: --native-runner api --runner api);
the full run unless --kind feature (the lean run) or --kind fix (no review, nothing written);
--deadline cancels the children it started; --max-tokens caps each api call's output (default 16384; OpenAI counts reasoning tokens in it), and a
response cut off at that limit is reported truncated; submit's result checks judge every completed response
answer --out <dir> --gate-rev <rev> --reply "<rec | rec except d3a | d3b uc1a | as-is>" --by <human|agent:<id>> [--json]
--by agent:<id> needs --owner-reply "<owner's words>" (- reads stdin; --owner-reply-file <path>), stored verbatim;
--words-by agent:<id> (the same agent) when those words are its own: recorded as its words, never the owner's, never carried;
--rec-hash <h> binds a rec to the render the owner saw (required once recommend ran); accepted changes leave amend_pending
recommend --out <dir> --gate-rev <n> --item <id> --option <k> --basis "<why>" [--by <who>]
set one item's recommendation before relay (provenance recorded, p1 follows); re-render and relay afterwards
@@ -93,6 +113,14 @@ DRIVER: ${driverFile()}
bind the one-reviewer delta check a CARRY_CHECK: line asked for: nothing at High or above carries the owner's
last answer (CARRIED:); otherwise, or when unavailable, the gate goes to the owner as before
checkpoints --from <dir> list checkpoint generations, newest first: run, gate_rev, time, hash ok|bad, current
shadow --out <dir> | --runs <file> [--json]
read-only export for taste-replay R2: one row per item of each answered gate revision, its shadow prediction
(recorded before the answer; status recorded, malformed, failed, missing, retrospective or off) beside the
owner's answer and how he gave it; counts by status, item class, owner_mode and ceo_eligible. An unanswered or
interrogated revision refuses SHADOW_BEFORE_ANSWER, a CEO-only run CEO_ONLY_NO_GATE. --runs: one run dir per line.
Shadow mode itself is off by default: gstack-config set autoplan_shadow on (or GSTACK_AUTOPLAN_SHADOW=1) at the
first next; each gate then gets one hidden model call (autoplan_shadow_model, default claude-opus-5-5) counted
against --spend-cap, and every command that prints a gate prints SHADOW: first
close --out <dir> --phase <p> --deferred "<reason>" --by <human|agent:<id>>
close the phase accepted-with-findings: open voices skipped (dispatched attempts settle unknown at their
reservation), open findings into tasks.jsonl; Critical, urgent and user-challenge findings become gate items
@@ -136,7 +164,7 @@ function nextLine(state: RunState, out: string): string {
* Admission, then under the run lock: bring an existing run forward (GSTACK_VERSION_CHANGED, legacy decision ids)
* and record an admission override as a deviation. Notices go to stdout before the command's own lines (stderr with --json).
*/
function mutating(out: string, json: boolean, fn: () => number): number {
function mutating(out: string, json: boolean, fn: () => number | Report): number | Report {
const admission = admit(process.env);
touchInstallLease(process.env);
return withRunLock(out, () => {
@@ -153,13 +181,48 @@ function mutating(out: string, json: boolean, fn: () => number): number {
notices.push(...migrated.lines);
}
if (notices.length) (json ? process.stderr : process.stdout).write(notices.join('\n') + '\n');
const code = fn();
const result = fn();
if (admission.deviation && fs.existsSync(path.join(out, FILES.run))) { const state = readState(out); if (record(state)) writeState(state); }
if (code === EXIT.ok) checkpointQuietly(out);
return code;
if (typeof result !== 'number') return claimed(out, result);
if (result === EXIT.ok) checkpointQuietly(out);
return result;
});
}
/**
* What a command that can leave a gate reports: the lines before the gate, the lines after it, and its JSON payload.
* `publish` puts the SHADOW line and the gate lines between them once the shadow has run (lib/autoplan-shadow.ts).
*/
interface Report { head: string[]; tail: string[]; payload: Record<string, unknown>; gate: boolean; claim?: ShadowClaim; claimLine?: string }
/** Still under the command's run lock: a report at a gate claims its shadow (the checkpoint waits for publish); any other checkpoints now. */
function claimed(out: string, r: Report): Report {
if (!r.gate) { checkpointQuietly(out); return r; }
try { const c = claimShadow(out); return { ...r, claim: c.claim, claimLine: c.line }; }
catch (e: any) { return { ...r, claimLine: `SHADOW: failed internal (${e?.message ?? e}); the gate stands` }; }
}
const shadowField = (o: ShadowOutcome | undefined, line: string | undefined) => (o ? { shadow: { status: o.status, ...(o.code ? { code: o.code } : {}), ...(o.model ? { model: o.model } : {}), ...(o.usd !== undefined ? { usd: o.usd } : {}) } } : line ? { shadow: { line } } : {});
/**
* The one gate-publication step every command that prints a gate shares (next, submit, close, resume, revise, run, check):
* after the locked step, run the claimed shadow with no lock held, then print SHADOW, the gate and its cost, then
* checkpoint, so a checkpoint taken at a gate holds its shadow record. A shadow failure never fails the command.
*/
async function publish(out: string, json: boolean, r: number | Report): Promise<number> {
if (typeof r === 'number') return r;
const outcome = r.claim ? await runShadow(out, r.claim) : undefined;
const line = outcome?.line ?? r.claimLine;
const state = r.gate ? readState(out) : undefined;
emit(json, [...r.head, ...(line ? [line] : []), ...(state ? gateLines(state, out) : []), ...r.tail], { ...r.payload, ...(state ? gateCostField(state, out) : {}), ...shadowField(outcome, line) });
if (r.gate) gateCheckpoint(out);
return EXIT.ok;
}
function gateCheckpoint(out: string): void {
try { withRunLock(out, () => checkpointQuietly(out), { waitMs: 30_000 }); }
catch (e: any) { process.stderr.write(`CHECKPOINT: skipped for ${out}: ${e instanceof RunError ? `${e.detail} (${e.code})` : e?.message ?? e}; the command's result stands\n`); }
}
/** Set by a command whose success is a checkpoint moment beyond rest: a bound voice, a phase transition, the first next. */
let moment: CheckpointMoment | undefined;
@@ -178,32 +241,32 @@ function emit(json: boolean, lines: string[], payload: Record<string, unknown>):
else process.stdout.write(lines.join('\n') + '\n');
}
function doNext(out: string, rest: string[], json: boolean): number {
function doNext(out: string, rest: string[], price?: NextOptions['price']): Report {
const cwd = process.cwd();
const initLines: string[] = [];
const fresh = !fs.existsSync(path.join(out, FILES.run));
if (!fresh) checkCeoOnlyFlags(rest, undefined, readState(out));
if (fresh) {
const plan = flag(rest, '--plan');
if (!plan) throw new RunError('RUN_NOT_INITIALIZED', `${out} has no run.json; pass --plan <file> on the first next`, 3);
const ceoOnly = checkCeoOnlyFlags(rest, path.resolve(plan), undefined, out);
const ui = rest.includes('--ui') ? true : rest.includes('--no-ui') ? false : undefined;
const maxRounds = num(rest, '--max-rounds');
if (maxRounds !== undefined && (!Number.isInteger(maxRounds) || maxRounds < 1)) throw new RunError('ARTIFACT_SCHEMA', '--max-rounds needs an integer >= 1', 1);
const carryCheckCapUsd = num(rest, '--carry-check-cap');
if (carryCheckCapUsd !== undefined && !(carryCheckCapUsd >= 0)) throw new RunError('ARTIFACT_SCHEMA', '--carry-check-cap needs a number >= 0', 1);
initRun({ plan, out, cwd, planHeading: flag(rest, '--plan-heading'), outsideEnabled: !rest.includes('--no-outside'), spendCap: num(rest, '--spend-cap') ?? null, maxRounds,
carryForward: !rest.includes('--no-carry-forward'), carryCheckCapUsd,
const shadow = shadowSwitch({ outsideEnabled: !rest.includes('--no-outside'), ceoOnly }, process.env);
if (shadow.line) initLines.push(shadow.line);
initRun({ plan, out, cwd, kind: flag(rest, '--kind') as AutoplanKind | undefined, full: rest.includes('--full'), base: flag(rest, '--base'), planHeading: flag(rest, '--plan-heading'), outsideEnabled: !rest.includes('--no-outside'), spendCap: num(rest, '--spend-cap') ?? null, maxRounds,
carryForward: !rest.includes('--no-carry-forward'), carryCheckCapUsd, ceoOnly, shadow: shadow.shadow,
deadlineMin: num(rest, '--deadline'), light: rest.includes('--light'), scaffold: rest.includes('--scaffold'), scope: { ui, developerTool: rest.includes('--developer-tool'), agentPrimary: rest.includes('--agent-primary'), dx: rest.includes('--no-dx') ? false : undefined, ...(rest.includes('--docs-only') ? { docsOnly: true } : {}) } }, initLines);
}
const redispatch = rest.flatMap((a, i) => (a === '--redispatch' && rest[i + 1] ? [rest[i + 1]!] : []));
const r = nextStep({ out, cwd, estimateUsd: num(rest, '--estimate-usd'), redispatch });
const r = nextStep({ out, cwd, estimateUsd: num(rest, '--estimate-usd'), redispatch, price });
moment = fresh ? 'init' : r.attempts.some(a => a.new) || r.lines.some(l => l.startsWith('PHASE_DEFERRED: ')) ? 'transition' : undefined;
const status = r.state.status;
const lines = [...initLines, ...r.lines];
if (status === 'gate_pending') lines.push(...gateLines(r.state, out));
if (status === 'running') lines.push(`NEXT: gstack-autoplan next --out ${out}`);
lines.push(resultLineFor(status, out));
emit(json, lines, { run: r.state.run, status, phase: r.phase ?? null, attempts: r.attempts, gate_rev: r.state.gate_rev ?? null, ...gateCostField(r.state, out), out });
return EXIT.ok;
return { head: [...initLines, ...r.lines], tail: [...(status === 'running' ? [`NEXT: gstack-autoplan next --out ${out}`] : []), resultLineFor(status, out)], gate: status === 'gate_pending',
payload: { run: r.state.run, status, phase: r.phase ?? null, attempts: r.attempts, gate_rev: r.state.gate_rev ?? null, out } };
}
/** The gate pointer, its cost so far and the price of `rec` (lib/autoplan-gate-cost.ts), plus the owner-facing render to relay under Capy (lib/captain-brief.ts). */
@@ -224,7 +287,7 @@ function gateCostField(state: RunState, out: string): Record<string, unknown> {
return cost ? { gate_cost: gateCostPayload(cost) } : {};
}
function doSubmit(out: string, rest: string[], json: boolean): number {
function doSubmit(out: string, rest: string[]): number | Report {
const phase = flag(rest, '--phase'); const voice = flag(rest, '--voice'); const result = flag(rest, '--result'); const record = flag(rest, '--record');
const model = flag(rest, '--model'); const attempt = flag(rest, '--attempt'); const unavailable = flag(rest, '--unavailable'); const failed = flag(rest, '--failed');
if (!phase || !voice) return usage('submit needs --phase and --voice');
@@ -236,27 +299,22 @@ function doSubmit(out: string, rest: string[], json: boolean): number {
const r = submitResult({ out, phase, voice, result: result ? path.resolve(result) : undefined, record: record ? path.resolve(record) : undefined, model, attempt, attest: flag(rest, '--attest'),
unavailable, failed, runner: flag(rest, '--runner'), usageUsd: num(rest, '--usage-usd'), reconcile: flag(rest, '--reconcile'), cwd: process.cwd() });
const status = r.state.status;
const lines = [...r.lines];
if (status === 'gate_pending') lines.push(...gateLines(r.state, out));
if (status === 'running') lines.push(`NEXT: gstack-autoplan next --out ${out}`);
lines.push(resultLineFor(status, out));
emit(json, lines, { run: r.state.run, status, phase, voice, attempt: r.state.phases[phase]?.[voice]?.attempt ?? attempt ?? null, closed: r.closed, findings: r.findings, gate_rev: r.state.gate_rev ?? null, ...gateCostField(r.state, out), out });
return EXIT.ok;
return { head: [...r.lines], tail: [...(status === 'running' ? [`NEXT: gstack-autoplan next --out ${out}`] : []), resultLineFor(status, out)], gate: status === 'gate_pending',
payload: { run: r.state.run, status, phase, voice, attempt: r.state.phases[phase]?.[voice]?.attempt ?? attempt ?? null, closed: r.closed, findings: r.findings, gate_rev: r.state.gate_rev ?? null, out } };
}
function doRevise(out: string, rest: string[], json: boolean): number {
function doRevise(out: string, rest: string[]): number | Report {
const plan = flag(rest, '--plan'); const incorporates = listFlag(rest, '--incorporates'); const withdraw = listFlag(rest, '--withdraw'); const settle = listFlag(rest, '--settle');
if (!plan && !withdraw.length && !settle.length) return usage('revise needs --plan <file> (and optionally --incorporates <id,...>), --withdraw <decision id> or --settle <id>');
if (!plan && incorporates.length) return usage('--incorporates binds accepted changes to a revised plan: pass --plan <file>');
refuseCeoOnly(readState(out), 'revise');
const r = reviseRun({ out, plan: plan ? path.resolve(plan) : undefined, incorporates, withdraw, settle, cwd: process.cwd() });
const lines = [...r.lines];
if (r.state.status === 'running' || r.state.status === 'amend_pending') lines.push(nextLine(r.state, out));
lines.push(resultLineFor(r.state.status, out));
emit(json, lines, { run: r.state.run, status: r.state.status, reopened: r.reopened, amendments: pendingAmendments(r.state).map(a => a.decision), gate_rev: r.state.gate_rev ?? null, out });
return EXIT.ok;
const tail = [...(r.state.status === 'running' || r.state.status === 'amend_pending' ? [nextLine(r.state, out)] : []), resultLineFor(r.state.status, out)];
return { head: [...r.lines], tail, gate: r.state.status === 'gate_pending',
payload: { run: r.state.run, status: r.state.status, reopened: r.reopened, amendments: pendingAmendments(r.state).map(a => a.decision), gate_rev: r.state.gate_rev ?? null, out } };
}
function doClose(out: string, rest: string[], json: boolean): number {
function doClose(out: string, rest: string[]): number | Report {
const phase = flag(rest, '--phase'); const reason = flag(rest, '--deferred'); const by = flag(rest, '--by');
if (!phase || !reason?.trim()) return usage('close needs --phase <p> --deferred "<reason>" --by <human|agent:<id>>');
if (by === undefined || !ANSWERED_BY_RE.test(by)) throw new RunError('ANSWER_BY_REQUIRED', `close records who deferred the phase: pass --by human or --by agent:<id> (got ${JSON.stringify(by ?? null)})`, 3);
@@ -268,25 +326,22 @@ function doClose(out: string, rest: string[], json: boolean): number {
const r = closeDeferred(state, phase as Phase, { reason: reason.trim(), by, cause: 'owner' });
moment = 'transition';
const lines = [...r.lines, ...(stopped ? liftStop(state, `${phase} closed deferred by ${by}`) : [])];
if (!currentPhase(state)) { finishGate(state); lines.push(...carryLines(state), ...(state.status === 'gate_pending' ? gateLines(state, out) : [])); }
if (!currentPhase(state)) lines.push(...finishRun(state));
else { state.status = 'running'; lines.push(`NEXT: gstack-autoplan next --out ${out}`); }
writeState(state);
lines.push(resultLineFor(state.status, out));
emit(json, lines, { run: state.run, status: state.status, phase, blockers: r.blockers, gate_rev: state.gate_rev ?? null, ...gateCostField(state, out), out });
return EXIT.ok;
return { head: lines, tail: [resultLineFor(state.status, out)], gate: state.status === 'gate_pending', payload: { run: state.run, status: state.status, phase, blockers: r.blockers, gate_rev: state.gate_rev ?? null, out } };
}
/** `check`: the delta reviewer's verdict before a carry (lib/autoplan-carry.ts). */
function doCheck(out: string, rest: string[], json: boolean): number {
function doCheck(out: string, rest: string[]): number | Report {
const unavailable = flag(rest, '--unavailable');
const result = flag(rest, '--result');
if (unavailable === undefined && (!result || !flag(rest, '--model'))) return usage('check needs --attempt <id> and --result <file> --model <id>, or --unavailable "<reason>"');
const r = submitCarryCheck({ out, attempt: flag(rest, '--attempt'), result: result ? path.resolve(result) : undefined, model: flag(rest, '--model'), unavailable, sameFamily: flag(rest, '--same-family') });
moment = 'transition';
const status = r.state.status;
const lines = [...r.lines, ...(status === 'gate_pending' ? gateLines(r.state, out) : []), ...(status === 'amend_pending' || status === 'running' ? [nextLine(r.state, out)] : []), resultLineFor(status, out)];
emit(json, lines, { run: r.state.run, status, gate_rev: r.state.gate_rev ?? null, carry: r.state.carry ?? null, out });
return EXIT.ok;
return { head: [...r.lines], tail: [...(status === 'amend_pending' || status === 'running' ? [nextLine(r.state, out)] : []), resultLineFor(status, out)], gate: status === 'gate_pending',
payload: { run: r.state.run, status, gate_rev: r.state.gate_rev ?? null, carry: r.state.carry ?? null, out } };
}
function doBudget(out: string, rest: string[], json: boolean): number {
@@ -297,7 +352,9 @@ function doBudget(out: string, rest: string[], json: boolean): number {
return EXIT.ok;
}
async function doRun(out: string, rest: string[], json: boolean): Promise<number> {
async function doRun(out: string, args: string[], json: boolean): Promise<number> {
// run pays for both voices, so it starts the full run unless --kind feature asks for the lean one (R65).
const rest = args.includes('--kind') ? args : [...args, '--full'];
const nativeModel = flag(rest, '--native-model'); const outsideModel = flag(rest, '--outside-model');
const outsideRunner = (flag(rest, '--runner') ?? 'codex-cli') as RunnerId; const nativeRunner = (flag(rest, '--native-runner') ?? 'api') as RunnerId;
if (!nativeModel || !outsideModel) return usage('run needs --native-model and --outside-model (a runner must execute both voices)');
@@ -305,43 +362,89 @@ async function doRun(out: string, rest: string[], json: boolean): Promise<number
if (!['codex-cli', 'api'].includes(outsideRunner)) return usage('--runner must be codex-cli or api for run (host-subagent cannot execute here; use next/submit)');
const conflict = familyConflict(outsideModel, nativeModel);
if (conflict) throw new RunError('MODEL_FAMILY_CONFLICT', conflict, 3);
const maxTokens = num(rest, '--max-tokens');
if (maxTokens !== undefined && (!Number.isInteger(maxTokens) || maxTokens <= 0)) return usage('--max-tokens needs a positive integer');
const deadline = num(rest, '--deadline');
const deadlineAt = deadline ? Date.now() + deadline * 60_000 : undefined;
const modelOf = (voice: Voice) => (voice === 'native' ? nativeModel : outsideModel);
const isApi = (voice: Voice) => (voice === 'native' ? nativeRunner : outsideRunner) === 'api';
// An api call reserves its worst case (prompt bytes / 3 input tokens plus the output limit it is sent) and settles at its usage.
const price = (voice: Voice, promptFile: string) => {
const row = isApi(voice) ? pricingRow(modelOf(voice)) : undefined;
return row ? +((fs.statSync(promptFile).size / 3 * row.input_per_mtok + (maxTokens ?? DEFAULT_MAX_TOKENS) * row.output_per_mtok) / 1e6).toFixed(6) : undefined;
};
const usdOf = (voice: Voice, used: { input_tokens?: number; output_tokens?: number } | undefined) =>
isApi(voice) && pricingRow(modelOf(voice)) && typeof used?.input_tokens === 'number' && typeof used?.output_tokens === 'number' ? estimateCostUsd({ input: used.input_tokens, output: used.output_tokens }, modelOf(voice)) : undefined;
// Every exit after a paid call went out settles its attempt: a failure, a cancel, a refused result, or a process that died
// mid-call (sent_at, found on the next run) settles at its usage, or its reservation when the usage is unknown. Only a call
// refused before any request left this machine bills nothing, so its attempt stays open for the rerun.
const NOT_SENT = new Set(['runner_unavailable', 'cli_missing', 'model_unselected']);
for (let guard = 0; guard < 40; guard++) {
const code = withRunLock(out, () => doNext(out, rest, true));
if (code !== EXIT.ok) return code;
const step = withRunLock(out, () => { const r = doNext(out, rest, price); return r.gate ? claimed(out, r) : r; });
const state = readState(out);
if (state.status !== 'awaiting_result') {
if (!json) process.stdout.write([...statusLines(state), ...(state.status === 'gate_pending' ? gateLines(state, out) : [])].join('\n') + '\n');
process.stdout.write(resultLineFor(state.status, out) + '\n'); return EXIT.ok;
const outcome = step.claim ? await runShadow(out, step.claim) : undefined;
const line = outcome?.line ?? step.claimLine;
const at = readState(out);
emit(true, [], { ...step.payload, ...gateCostField(at, out), ...shadowField(outcome, line) });
if (!json) process.stdout.write([...statusLines(at), ...(line ? [line] : []), ...(at.status === 'gate_pending' ? gateLines(at, out) : [])].join('\n') + '\n');
if (step.gate) gateCheckpoint(out);
process.stdout.write(resultLineFor(at.status, out) + '\n'); return EXIT.ok;
}
emit(true, [], step.payload);
const phase = state.phase_order.find(p => state.phases[p]?.snapshot && !state.phases[p]?.closed_at)!;
const lost = (['native', 'outside'] as const).flatMap(voice => (state.phases[phase]![voice]!.attempts ?? []).filter(a => a.state === 'dispatched' && a.sent_at).map(a => [voice, a.attempt] as const));
if (lost.length) { withRunLock(out, () => { for (const [voice, attempt] of lost) retireAttempt(out, phase as Phase, voice, attempt, 'unknown'); }); continue; }
for (const voice of ['native', 'outside'] as const) {
const v = state.phases[phase]![voice]!;
const open = v.attempts?.find(a => a.state === 'dispatched');
if (!open) continue;
const runnerId = voice === 'native' ? nativeRunner : outsideRunner;
const cost = price(voice, open.prompt);
if (cost !== undefined) withRunLock(out, () => fitReservation(out, phase as Phase, voice, open.attempt, cost));
const runner = createRunner(runnerId);
const remaining = deadlineAt ? deadlineAt - Date.now() : 300_000;
const remaining = deadlineAt ? deadlineAt - Date.now() : runnerId === 'api' ? apiTimeoutMs(maxTokens) : 300_000;
if (remaining <= 0) { withRunLock(out, () => { const s = readState(out); s.status = 'interrupted'; writeState(s); }); process.stdout.write(resultLineFor('interrupted', out) + '\n'); return EXIT.fail; }
const timer = deadlineAt ? setTimeout(() => runner.cancel(), remaining) : undefined;
const res = await runner.run({ promptFile: open.prompt, outFile: `${open.result}.raw`, model: voice === 'native' ? nativeModel : outsideModel, timeoutMs: Math.min(remaining, 600_000), cwd: process.cwd(), gate: 'review' });
withRunLock(out, () => markSent(out, phase as Phase, voice, open.attempt, true));
const res = await runner.run({ promptFile: open.prompt, outFile: `${open.result}.raw`, model: voice === 'native' ? nativeModel : outsideModel, timeoutMs: Math.min(remaining, runnerId === 'api' ? apiTimeoutMs(maxTokens) : 600_000), cwd: process.cwd(), gate: 'execution', maxTokens: runnerId === 'api' ? maxTokens ?? DEFAULT_MAX_TOKENS : maxTokens });
if (timer) clearTimeout(timer);
if (!json) process.stdout.write(`VOICE: ${phase} ${voice} ${res.status} runner=${res.runner} model=${res.model ?? 'unknown'}\n`);
if (res.status === 'cancelled') { withRunLock(out, () => { const s = readState(out); s.status = 'interrupted'; writeState(s); }); process.stdout.write(resultLineFor('interrupted', out) + '\n'); return EXIT.fail; }
const retire = () => { if (NOT_SENT.has(res.reason ?? '')) markSent(out, phase as Phase, voice, open.attempt, false); else retireAttempt(out, phase as Phase, voice, open.attempt, usdOf(voice, res.usage) ?? 'unknown'); };
if (res.status === 'cancelled') { withRunLock(out, () => { retire(); const s = readState(out); s.status = 'interrupted'; writeState(s); }); process.stdout.write(resultLineFor('interrupted', out) + '\n'); return EXIT.fail; }
if (res.status !== 'completed' && res.status !== 'unverified') {
if (voice === 'outside') { withRunLock(out, () => markOutside(out, phase as Phase, 'unavailable', `${res.runner} ${res.status}${res.detail ? `: ${res.detail}` : ''}`)); continue; }
process.stderr.write(`gstack-autoplan run: native voice ${res.status}${res.detail ? `: ${res.detail}` : ''}\n`);
withRunLock(out, () => { const s = readState(out); s.status = 'interrupted'; writeState(s); });
process.stderr.write(`gstack-autoplan run: native voice ${res.status}${res.reason ? `: ${res.reason}` : ''}${res.detail ? `: ${res.detail}` : ''}\n`);
withRunLock(out, () => { retire(); const s = readState(out); s.status = 'interrupted'; writeState(s); });
process.stdout.write(resultLineFor('interrupted', out) + '\n');
return EXIT.fail;
}
withRunLock(out, () => doSubmit(out, ['--phase', phase, '--voice', voice, '--result', `${open.result}.raw`, '--model', res.model ?? (voice === 'native' ? nativeModel : outsideModel), '--attempt', open.attempt, '--runner', res.runner], true));
const usd = usdOf(voice, res.usage);
const usage = usd === undefined ? [] : ['--usage-usd', String(usd)];
try {
const bound = withRunLock(out, () => doSubmit(out, ['--phase', phase, '--voice', voice, '--result', `${open.result}.raw`, '--model', res.model ?? modelOf(voice), '--attempt', open.attempt, '--runner', res.runner, ...usage]));
if (typeof bound !== 'number') emit(true, [], bound.payload);
}
catch (e) {
if (voice !== 'outside' || !(e instanceof RunError)) { withRunLock(out, () => { retire(); const s = readState(out); s.status = 'interrupted'; writeState(s); }); throw e; }
withRunLock(out, () => markOutside(out, phase as Phase, 'unavailable', `${res.runner} result refused: ${e.code}`));
}
}
}
return EXIT.fail;
}
/** `shadow`: the read-only export for taste-replay R2 (lib/autoplan-shadow-reader.ts). */
function doShadow(rest: string[], json: boolean): number {
const runs = flag(rest, '--runs'); const one = flag(rest, '--out');
if (!runs === !one) return usage('shadow needs --out <run dir> or --runs <file> (one run directory per line)');
const dirs = runs ? fs.readFileSync(runs, 'utf8').split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#')).map(d => path.resolve(d)) : [path.resolve(one!)];
const r = exportShadow(dirs);
for (const x of r.refused) process.stderr.write(resultLine('gstack-autoplan shadow', x.code, x.detail) + '\n');
if (json) process.stdout.write(JSON.stringify(r) + '\n'); else process.stdout.write(renderShadowExport(r).join('\n') + '\n');
return r.rows.length === 0 && r.refused.length ? EXIT.refused : EXIT.ok;
}
async function main(argv: string[]): Promise<number> {
const [cmd, ...rest] = argv;
const json = rest.includes('--json');
@@ -353,16 +456,27 @@ async function main(argv: string[]): Promise<number> {
try { process.stdout.write(checkpointLines(from).join('\n') + '\n'); return EXIT.ok; }
catch (e: any) { if (!(e instanceof RunError)) throw e; process.stderr.write(resultLine('gstack-autoplan checkpoints', e.code, e.detail) + '\n'); return EXIT.refused; }
}
if ((cmd === 'next' || cmd === 'run') && rest.includes('--kind')) {
const kind = flag(rest, '--kind');
if (!(KINDS as readonly string[]).includes(kind ?? '')) return usage(`--kind takes fix or feature (got ${JSON.stringify(kind ?? '')})`);
if (kind === 'fix') {
const live = flag(rest, '--out');
if (live && fs.existsSync(path.join(path.resolve(live), FILES.run))) return usage(`--kind fix starts no run, but ${live} already holds a run: continue it without --kind, or pass another --out`);
emit(json, FIX_LINES, { skill: 'autoplan', status: 'skipped', kind: 'fix', run: null, lines: FIX_LINES.slice(0, -1) });
return EXIT.ok;
}
}
const outFlag = flag(rest, '--out');
if (!outFlag) return usage(`${cmd} needs --out <dir>`);
const out = path.resolve(outFlag);
if (!outFlag && cmd !== 'shadow') return usage(`${cmd} needs --out <dir>`);
const out = path.resolve(outFlag ?? '.');
try {
if (cmd === 'next') return mutating(out, json, () => doNext(out, rest, json));
if (cmd === 'submit') return mutating(out, json, () => doSubmit(out, rest, json));
if (cmd === 'revise') return mutating(out, json, () => doRevise(out, rest, json));
if (cmd === 'close') return mutating(out, json, () => doClose(out, rest, json));
if (cmd === 'budget') return mutating(out, json, () => doBudget(out, rest, json));
if (cmd === 'check') return mutating(out, json, () => doCheck(out, rest, json));
if (cmd === 'shadow') return doShadow(rest, json);
if (cmd === 'next') return await publish(out, json, mutating(out, json, () => doNext(out, rest)));
if (cmd === 'submit') return await publish(out, json, mutating(out, json, () => doSubmit(out, rest)));
if (cmd === 'revise') return await publish(out, json, mutating(out, json, () => doRevise(out, rest)));
if (cmd === 'close') return await publish(out, json, mutating(out, json, () => doClose(out, rest)));
if (cmd === 'budget') return await publish(out, json, mutating(out, json, () => doBudget(out, rest, json)));
if (cmd === 'check') return await publish(out, json, mutating(out, json, () => doCheck(out, rest)));
if (cmd === 'run') { const admission = admit(process.env); if (admission.lines.length) process.stdout.write(admission.lines.join('\n') + '\n'); return await doRun(out, rest, json); }
if (cmd === 'status') {
const state = readState(out);
@@ -372,42 +486,46 @@ async function main(argv: string[]): Promise<number> {
if (cmd === 'resume') {
const from = flag(rest, '--from');
const restored = from !== undefined ? restoreCheckpoint(from, out) : [];
return withRunLock(out, () => {
return await publish(out, json, withRunLock(out, () => {
const r = resumeRun(out);
checkpointQuietly(out);
const lines = [...restored, ...r.lines, resultLineFor(r.state.status, out)];
emit(json, lines, { run: r.state.run, status: r.state.status, execution_unknown: r.unknown, rebound: r.rebound, out });
return EXIT.ok;
}, { steal: true });
return claimed(out, { head: [...restored, ...r.lines], tail: [resultLineFor(r.state.status, out)], gate: r.state.status === 'gate_pending',
payload: { run: r.state.run, status: r.state.status, execution_unknown: r.unknown, rebound: r.rebound, out } });
}, { steal: true }));
}
if (cmd === 'answer') {
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; }
if (rev === undefined) { const s = readState(out); refuseCeoOnly(s, 'answer'); 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; }
const replyFile = flag(rest, '--owner-reply-file'); const ownerFlag = flag(rest, '--owner-reply');
if (replyFile !== undefined && ownerFlag !== undefined) return usage('--owner-reply and --owner-reply-file are exclusive');
const wordsBy = flag(rest, '--words-by') ?? 'owner';
if (wordsBy !== 'owner' && (wordsBy !== by || !by.startsWith('agent:'))) return usage(`--words-by must be owner or the answering agent (--by ${by ?? '<unset>'}), got ${wordsBy}`);
const ownerReply = replyFile !== undefined ? fs.readFileSync(replyFile, 'utf8') : ownerFlag === '-' ? fs.readFileSync(0, 'utf8') : ownerFlag;
return mutating(out, json, () => {
const shadowLines = await shadowBeforeAnswer(out);
if (shadowLines.length) (json ? process.stderr : process.stdout).write(shadowLines.join('\n') + '\n');
return await publish(out, json, mutating(out, json, () => {
const state = readState(out);
const r = answerGate(state, Number(rev), reply, by, { recHash: flag(rest, '--rec-hash'), ownerReply });
refuseCeoOnly(state, 'answer');
const r = answerGate(state, Number(rev), reply, by, { recHash: flag(rest, '--rec-hash'), ownerReply, ...(wordsBy === 'owner' ? {} : { wordsBy }) });
writeState(state);
const lines = [...r.lines];
if (r.outcome === 'reopened' || r.outcome === 'amend_pending') lines.push(nextLine(state, out));
lines.push(resultLineFor(state.status, out));
emit(json, lines, { run: state.run, status: state.status, outcome: r.outcome, pending: r.pending, reopened: r.reopened, amendments: pendingAmendments(state).map(a => a.decision), gate_rev: state.gate_rev, out });
return EXIT.ok;
});
}));
}
if (cmd === 'recommend') {
const rev = flag(rest, '--gate-rev'); const item = flag(rest, '--item'); const option = flag(rest, '--option'); const basis = flag(rest, '--basis');
if (rev === undefined || !item || !option || !basis?.trim()) return usage('recommend needs --gate-rev <n> --item <id> --option <k> --basis "<why>"');
return mutating(out, json, () => {
return await publish(out, json, mutating(out, json, () => {
const state = readState(out);
refuseCeoOnly(state, 'recommend');
const lines = recommendGate(state, Number(rev), item.toLowerCase(), option.toLowerCase(), basis.trim(), flag(rest, '--by') ?? 'parent');
writeState(state);
emit(json, [...lines, resultLineFor(state.status, out)], { run: state.run, status: state.status, gate_rev: state.gate_rev, out });
return EXIT.ok;
});
}));
}
if (cmd === 'export' && flag(rest, '--public') !== undefined) {
return withRunLock(out, () => {
@@ -418,7 +536,7 @@ async function main(argv: string[]): Promise<number> {
});
}
if (cmd === 'export') {
return mutating(out, json, () => {
return await publish(out, json, mutating(out, json, () => {
const state = readState(out);
const pending = pendingAmendments(state).map(a => a.decision);
if (state.status === 'complete' && pending.length) throw new RunError('PLAN_AMEND_PENDING', `run says complete with accepted changes not in the plan: ${pending.join(', ')}`, 3);
@@ -427,7 +545,7 @@ async function main(argv: string[]): Promise<number> {
for (const w of r.warnings) process.stderr.write(`gstack-autoplan export: ${w}\n`);
emit(json, [...r.files.map(f => `EXPORTED: ${f}`), dispatchLine(state)], { run: state.run, files: r.files, warnings: r.warnings, dispatches: state.dispatches, est_input_tokens: state.est_input_tokens });
return EXIT.ok;
});
}));
}
if (cmd === 'release') {
const attempt = flag(rest, '--attempt');
+1 -1
View File
@@ -55,7 +55,7 @@ For each finding: what's wrong, severity (critical/high/medium), and the fix.`,
reviewing this plan. You have NOT seen any prior review. Evaluate:
1. Getting started: how many steps from zero to hello world? What's the TTHW?
2. API/CLI ergonomics: naming consistency, sensible defaults, progressive disclosure?
3. Error handling: Score every error path against the agent-ready error contract (https://github.com/garrytan/gstack/blob/main/docs/agent-ready-errors.md): what (operation and actual values), fix (a safe, authorized command or edit on the first or last line, or the precise thing a human must provide), recovery (retry, fix and retry, do X first, or ask a human; side effects none/partial/complete/unknown, reconcile before retrying an unknown outcome, a retry bound), code (stable, with a doc anchor), context (versions, config, env var names, the underlying cause; caller-safe only for remote services), trace (unexpected failures only: a compact trace or its file path locally, a correlation id remotely), redaction (no secret values anywhere), channel (the surface's native failure signal: non-zero exit and stderr for a CLI, status plus RFC 9457 body for an API, a typed error for an SDK, isError for an MCP tool). Usage and validation errors rejected before any work need only what, fix, code, redaction and channel; items apply per surface, N/A needs a reason, an item the plan never specifies for a path it introduces is a gap, and unknown is reserved for existing behavior you could not inspect. Report a leaked secret, a failure signalled as success, an unsafe fix, or a block on a terminal (a hang, or a no-terminal failure that names no non-interactive alternative such as --yes) as a Critical finding titled "CAP4:<path>:<item>" (caps the Error Messages score at 4/10); report a missing applicable fix, recovery, context or trace as a High finding titled "CAP6:<path>:<item>" (caps it at 6/10).
3. Error handling: Score every error path against the agent-ready error contract (https://github.com/garrytan/gstack/blob/main/docs/agent-ready-errors.md): what (operation and actual values), fix (a safe, authorized command or edit on the first or last line, or what a human must provide), recovery (retry, fix and retry, do X first, or ask a human; side effects none/partial/complete/unknown, reconcile before retrying an unknown outcome, a retry bound), code (stable, with a doc anchor), context (versions, config, env var names, the underlying cause; caller-safe only for remote services), trace (unexpected failures only: a compact trace or its file path locally, a correlation id remotely), redaction (no secret values anywhere), channel (the surface's native failure signal: non-zero exit and stderr for a CLI, status plus RFC 9457 body for an API, a typed error for an SDK, isError for an MCP tool). Usage and validation errors rejected before any work need only what, fix, code, redaction and channel; items apply per surface, N/A needs a reason, an item the plan never specifies for a path it introduces is a gap, and unknown is reserved for existing behavior you could not inspect. Report a leaked secret, a failure signalled as success, an unsafe fix, or a block on a terminal (a hang, or a no-terminal failure naming no non-interactive alternative such as --yes) as a Critical finding titled "CAP4:<path>:<item> <what fails>" (caps Error Messages at 4/10); report a missing applicable fix, recovery, context or trace as a High finding titled "CAP6:<path>:<item> <what fails>" (caps it at 6/10).
4. Documentation: copy-paste examples? Information architecture? Interactive elements?
5. Escape hatches: can developers override every opinionated default?
For each finding: what's wrong, severity (critical/high/medium), and the fix.`,
+128 -12
View File
@@ -38,11 +38,11 @@
# applies; `ok` and `idle` print none. <bin> is this script's absolute path
# and <N> the held count. Rows are read by test/gstack-skill-start.test.ts.
#
# held trigger: status=held: the secret scan held back flagged files and synced the rest
# line: ARTIFACTS_SYNC: attention: status=held held=<N>. The secret scan is holding back <N> file(s); everything else still syncs. See which files and how to fix them: <bin> --status
# fix: edit the file so the scan no longer matches, or <bin> --skip-file <path> (permanent; undo with <bin> --unskip-file <path>)
# held trigger: status=held: the secret scan or the JSON check held back flagged files and synced the rest
# line: ARTIFACTS_SYNC: attention: status=held held=<N>. Sync is holding back <N> file(s) that the secret scan flagged or that are not valid JSON; everything else still syncs. See which files and how to fix them: <bin> --status
# fix: edit the file so the scan no longer matches (or repair a file that is not valid JSON), or <bin> --skip-file <path> (permanent; undo with <bin> --unskip-file <path>)
# blocked trigger: status=blocked: a re-scan still flagged after the hold, so nothing was committed
# line: ARTIFACTS_SYNC: attention: status=blocked. The secret scan flagged a file it could not hold back on its own, so nothing is syncing. See the fix: <bin> --status
# line: ARTIFACTS_SYNC: attention: status=blocked. Sync committed nothing: a flagged file could not be held back on its own, or held files are part of an unfinished git merge. See the fix: <bin> --status
# fix: edit or skip the files named by <bin> --status, or clear the batch with <bin> --drop-queue --yes
# push_failed trigger: status=push_failed: a commit is saved locally but the push failed
# line: ARTIFACTS_SYNC: attention: status=push_failed. Synced files are committed locally but the push failed; it retries at skill start. See the cause: <bin> --status
@@ -144,7 +144,7 @@ remote_auth_hint() {
# Status file schema (one JSON object; readers: --status, --attention, doctor):
# status ok | idle | held | collision | blocked | push_failed | error
# (collision: files were held only for case-colliding paths)
# held_count files the secret scan held back this drain (hits + dependents)
# held_count files the secret scan or the JSON check held back this drain (hits + dependents)
# case_held queued files held because their path differs only by letter
# case from another path in the repo or the batch
# case_names the colliding path component names, comma-separated and
@@ -585,6 +585,60 @@ transcripts_consented() {
transcript_policy --check 2>/dev/null
}
# After a rejected push: merge origin/<branch> into HEAD (#3704). git refuses
# a merge that would overwrite uncommitted edits to tracked files, and that
# refusal used to repeat on every run, so local edits to the paths the merge
# changes are set aside first and put back after; other local edits stay in
# place, and only a stash entry this call created is put back. Paths are
# literal, never patterns. A failed merge is aborted, so no half-finished merge
# or conflict markers block the next commit; git's reason lands in MERGE_ERR.
# When putting an edit back conflicts with what the merge brought in, the
# merged version stays in the working tree, so a later sync never publishes
# the stale local side over the other machine's change, and the local edit
# stays in the kept stash entry, which the status names with the command
# that restores it.
MERGE_ERR=""
merge_origin_keeping_local_edits() {
local branch="$1" out rc=0 p stash_before="" stash_sha=""
local -a lit=(git --literal-pathspecs -C "$GSTACK_HOME") blocking=()
while IFS= read -r -d '' p; do
"${lit[@]}" diff --quiet --no-renames HEAD "origin/$branch" -- "$p" 2>/dev/null || blocking+=("$p")
done < <("${lit[@]}" diff -z --no-renames --name-only HEAD 2>/dev/null)
if [ "${#blocking[@]}" -gt 0 ]; then
stash_before=$("${lit[@]}" rev-parse -q --verify refs/stash 2>/dev/null)
if ! out=$("${lit[@]}" stash push -q -m "gstack-brain-sync: local edits before merging origin/$branch" -- "${blocking[@]}" 2>&1); then
MERGE_ERR="could not set local edits aside: $(printf '%s\n' "$out" | head -1)"
return 1
fi
stash_sha=$("${lit[@]}" rev-parse -q --verify refs/stash 2>/dev/null)
[ "$stash_sha" != "$stash_before" ] || stash_sha=""
fi
out=$("${lit[@]}" merge --no-edit "origin/$branch" 2>&1) || rc=$?
if [ "$rc" -ne 0 ]; then
MERGE_ERR=$(printf '%s\n' "$out" | grep -m1 -E '^(CONFLICT|error|fatal)' || printf '%s\n' "$out" | head -1)
"${lit[@]}" merge --abort >/dev/null 2>&1 || true
fi
[ -n "$stash_sha" ] || return "$rc"
out=$("${lit[@]}" stash pop -q 2>&1) && return "$rc"
local paths="" args="" failed=0
while IFS= read -r -d '' p; do
if "${lit[@]}" cat-file -e "HEAD:$p" 2>/dev/null; then
"${lit[@]}" checkout -q HEAD -- "$p" >/dev/null 2>&1 || failed=1
else
{ "${lit[@]}" rm -q --cached -- "$p" >/dev/null 2>&1 && rm -f -- "$GSTACK_HOME/$p"; } || failed=1
fi
paths="$paths ${p}"
args="$args $(printf '%q' "$p")"
done < <("${lit[@]}" diff -z --name-only --diff-filter=U 2>/dev/null)
"${lit[@]}" reset -q >/dev/null 2>&1 || failed=1
if [ -z "$paths" ] || [ "$failed" = 1 ]; then
MERGE_ERR="local edits could not be put back after merging origin/$branch ($(printf '%s\n' "$out" | head -1)); they are saved in git -C $GSTACK_HOME stash list"
return 1
fi
ST_NOTE="$ST_NOTE; local edits to$paths conflicted with the merged remote version, which is kept; the local edits are saved in stash $stash_sha (get them back: git -C $GSTACK_HOME --literal-pathspecs restore --source=$stash_sha --$args)"
return "$rc"
}
# Before every push: scan the unpublished range for transcript pages outside
# the current consent; when some exist, the helper rewrites gstack-brain-sync's
# own unpublished commits without them, or refuses (exit 3) when it cannot.
@@ -899,6 +953,8 @@ for rule, path in hits:
"rule": rule,
"dependents": deps,
"fixes": [
f"{path} is not valid JSON (often git conflict markers from a merge); repair it and the next sync re-checks it"
] if rule == "invalid-json" else [
f"edit {path} so the secret scan no longer matches ({rule}); the next sync re-scans it",
f"{self_bin} --skip-file {path} (permanently excludes it{also} from future syncs; undo with {self_bin} --unskip-file {path})",
],
@@ -928,6 +984,52 @@ for path, rows in ((unstage_out, unstage), (wait_out, waiting)):
PYEOF
}
# Append "invalid-json<TAB><path>" to the hits file $2 for every staged *.json
# path in the NUL-separated names file $1 whose index blob does not parse
# (#3763): a git merge can leave conflict markers in a synced JSON file, and
# committing it spreads an unreadable file to every machine. A path the
# secret scan already flagged is held for that reason alone. One
# `git cat-file --batch` reads the index blobs; a staged deletion has none.
append_invalid_json_hits() {
python3 - "$1" "$2" "$1.json" <<'PYEOF' || return 1
import sys
names_path, hits_path, out_path = sys.argv[1:4]
with open(names_path, "rb") as f:
names = [p for p in f.read().decode("utf-8", "surrogateescape").split("\0") if p.endswith(".json") and "\n" not in p]
with open(hits_path, encoding="utf-8", errors="surrogateescape") as f:
flagged = {l.rstrip("\r\n").split("\t", 1)[-1] for l in f if "\t" in l}
with open(out_path, "w", encoding="utf-8", errors="surrogateescape", newline="\n") as out:
for p in names:
if p not in flagged:
out.write(":" + p + "\n")
PYEOF
[ -s "$1.json" ] || return 0
git -C "$GSTACK_HOME" cat-file --batch < "$1.json" > "$1.json.blobs" 2>/dev/null || return 1
python3 - "$1.json" "$1.json.blobs" "$2" <<'PYEOF'
import json, sys
batch_path, blobs_path, hits_path = sys.argv[1:4]
with open(batch_path, encoding="utf-8", errors="surrogateescape") as f:
names = [l[1:].rstrip("\n") for l in f if l.startswith(":")]
with open(blobs_path, "rb") as f:
data = f.read()
pos = 0
with open(hits_path, "a", encoding="utf-8", errors="surrogateescape", newline="\n") as out:
for p in names:
nl = data.index(b"\n", pos)
header = data[pos:nl].split(b" ")
pos = nl + 1
if header[-1] == b"missing":
continue
size = int(header[2])
blob = data[pos:pos + size]
pos += size + 1
try:
json.loads(blob.decode("utf-8"))
except ValueError:
out.write("invalid-json\t" + p + "\n")
PYEOF
}
# Fail closed: unstage everything, keep the queue, status blocked.
block_drain() {
git -C "$GSTACK_HOME" reset -q HEAD -- . >/dev/null 2>&1 || true
@@ -940,7 +1042,9 @@ block_drain() {
# one scanner pass attributes findings to staged paths, plan_holds decides
# what is held or waits, those paths are unstaged and leave $1 (their records
# stay queued), then the whole remaining staged diff must re-scan clean.
# Sets ST_HELD and WAITING. Returns 1 after falling back to block_drain.
# Sets ST_HELD and WAITING. Returns 1 after falling back to block_drain, or
# when a path would be held during an unfinished merge (the merge commit
# would silently keep only this side of it).
hold_flagged_paths() {
local paths_file="$1" groups_file="$2" scan="$1.scan" rc=0 gate_rc=0 gate_out
WAITING=0
@@ -949,6 +1053,7 @@ hold_flagged_paths() {
git -C "$GSTACK_HOME" diff --cached --no-renames > "$scan.diff" 2>/dev/null || rc=1
git -C "$GSTACK_HOME" diff --cached --no-renames -z --name-only > "$scan.names" 2>/dev/null || rc=1
[ "$rc" -eq 0 ] && { secret_scan attribute "$scan.diff" "$scan.names" "$scan.hits" || rc=1; }
[ "$rc" -eq 0 ] && { append_invalid_json_hits "$scan.names" "$scan.hits" || rc=1; }
# A successful add need not stage the requested spelling or current bytes
# (case-insensitive indexes and assume-unchanged can both report success).
# Reuse the scanner's name snapshot; refusal preserves the index and queue.
@@ -969,6 +1074,12 @@ hold_flagged_paths() {
ST_HELD=$(cat "$scan.held")
WAITING=$(grep -c . "$scan.wait" 2>/dev/null || true)
WAITING=${WAITING:-0}
if [ -s "$scan.unstage" ] && git -C "$GSTACK_HOME" rev-parse -q --verify MERGE_HEAD >/dev/null 2>&1; then
ST_DRAINABLE=0
write_status "blocked" "a git merge in $GSTACK_HOME is unfinished and $(grep -c . "$scan.unstage") file(s) are held back or wait for their coupled group; committing the merge without them would drop the other side's changes. Repair the files held[] names (they stay queued and sync on the next run), or finish the merge yourself; --skip-file and --drop-queue do not clear this. Nothing was committed; the merge and the queue are kept"
echo "BRAIN_SYNC: blocked: unfinished merge with held files" >&2
return 1
fi
if [ -s "$scan.unstage" ]; then
if ! tr -d '\r' < "$scan.unstage" | git --literal-pathspecs -C "$GSTACK_HOME" reset -q HEAD --pathspec-from-file=- >/dev/null 2>&1; then
block_drain "git could not unstage the held files"
@@ -1293,11 +1404,13 @@ subcmd_once() {
hold_flagged_paths "$paths_file" "$groups_file" || exit 0
ST_DRAINABLE=$(( ADD_FAILED + WAITING ))
[ "$WAITING" -gt 0 ] && ST_NOTE="$ST_NOTE; $WAITING file(s) of a coupled group wait for a consistent generation (still queued)"
local held_n ok_code="ok"
local held_n json_n ok_code="ok"
held_n=$(printf '%s' "$ST_HELD" | grep -o '"rule"' | wc -l | tr -d ' ')
json_n=$(printf '%s' "$ST_HELD" | grep -o '"rule": "invalid-json"' | wc -l | tr -d ' ')
if [ "$held_n" -gt 0 ]; then
ok_code="held"
ST_NOTE="$ST_NOTE; the secret scan held back $held_n flagged file(s) (and any coupled files), NOT synced; see held[] for each file's fixes"
[ "$held_n" -gt "$json_n" ] && ST_NOTE="$ST_NOTE; the secret scan held back $(( held_n - json_n )) flagged file(s) (and any coupled files), NOT synced; see held[] for each file's fixes"
[ "$json_n" -gt 0 ] && ST_NOTE="$ST_NOTE; $json_n synced file(s) are not valid JSON (often git conflict markers) and were held back (and any coupled files), NOT synced and still queued; see held[] for each file's fixes"
echo "BRAIN_SYNC: held $held_n flagged file(s); see $SELF_BIN --status" >&2
elif [ "$ST_CASE_HELD" -gt 0 ]; then
ok_code="collision"
@@ -1379,7 +1492,7 @@ subcmd_once() {
bash -c 'git -C "$1" fetch origin 2>/dev/null' _ "$GSTACK_HOME"; then
local branch
branch=$(git -C "$GSTACK_HOME" rev-parse --abbrev-ref HEAD 2>/dev/null || echo main)
if git -C "$GSTACK_HOME" merge --no-edit "origin/$branch" >/dev/null 2>&1 && guard_unpublished_range; then
if merge_origin_keeping_local_edits "$branch" && guard_unpublished_range; then
validate_outgoing_paths || exit 0
if GSTACK_HOME="$GSTACK_HOME" _receipted_git closed brain-sync "$push_host" curated-memory-git-push "artifacts_sync_mode!=off" \
bash -c 'git -C "$1" push origin HEAD 2>/dev/null' _ "$GSTACK_HOME"; then
@@ -1391,7 +1504,10 @@ subcmd_once() {
fi
fi
# Commit exists locally; the run-start detector re-pushes it next time.
write_status "push_failed" "push failed: $(printf '%s' "$push_err" | head -1); commit retained locally, will retry next run"
local push_reason
push_reason=$(printf '%s\n' "$push_err" | grep -v -E '^(To |hint:)' | head -1)
[ -n "$push_reason" ] || push_reason=$(printf '%s\n' "$push_err" | head -1)
write_status "push_failed" "push failed: $push_reason${MERGE_ERR:+; merging origin failed: $MERGE_ERR}; commit retained locally, will retry next run"
finalize_queue "$snapshot_file" "$class_file" "$paths_file"
exit 0
}
@@ -1495,8 +1611,8 @@ subcmd_attention() {
case "$last_push" in $iso) ;; *) last_push="" ;; esac
case "$code" in
held) echo "ARTIFACTS_SYNC: attention: status=held held=$held. The secret scan is holding back $held file(s); everything else still syncs. See which files and how to fix them: $bin --status" ;;
blocked) echo "ARTIFACTS_SYNC: attention: status=blocked. The secret scan flagged a file it could not hold back on its own, so nothing is syncing. See the fix: $bin --status" ;;
held) echo "ARTIFACTS_SYNC: attention: status=held held=$held. Sync is holding back $held file(s) that the secret scan flagged or that are not valid JSON; everything else still syncs. See which files and how to fix them: $bin --status" ;;
blocked) echo "ARTIFACTS_SYNC: attention: status=blocked. Sync committed nothing: a flagged file could not be held back on its own, or held files are part of an unfinished git merge. See the fix: $bin --status" ;;
push_failed) echo "ARTIFACTS_SYNC: attention: status=push_failed. Synced files are committed locally but the push failed; it retries at skill start. See the cause: $bin --status" ;;
error) echo "ARTIFACTS_SYNC: attention: status=error. The last sync stopped before committing; the queue is kept and retried. See the cause: $bin --status" ;;
unknown) echo "ARTIFACTS_SYNC: attention: status=unknown. The sync status file is unreadable. See it: $bin --status" ;;
+59 -3
View File
@@ -5,7 +5,7 @@
# Usage:
# gstack-capy-install [--for <skill> | --only <skill> | --require <id,id>] [--with-browser]
# [--revision <ref>] [--project <path>] [--root <dir>]
# [--check] [--min-revision <v>|unknown]
# [--check] [--min-revision <v>|unknown] [--model <id>]
#
# Steps: clone or reuse the checkout under a lock (never reset, never pulled
# here; the Capy skill bootstrapper fast-forwards below-minimum checkouts and
@@ -42,6 +42,13 @@
# mode-0600 file beside the verdict; callers and their children `. <file>` it,
# never re-type it) and the doctor's trailer (`gstack: ok ...` or `gstack: fail ...`;
# grep that, never pipe through tail).
# --model <id>, else GSTACK_MODEL (GSTA-161, docs/loadouts.md), else the agent_model of the session's
# GSTACK_PREFERENCES_FILE as gstack-models resolve --role agent reads it (GST-982), names the model this session runs:
# bin/gstack-modules install picks its loadout, publishes a non-full one beside
# the full Claude install and prints LOADOUT and SKILL_ROOT lines before the
# launch lines; a non-full loadout gets its own launch file, keyed by loadout
# name and digest, that adds GSTACK_LOADOUT_SKILL_ROOT. The verdict, its key and its
# launch file are unchanged, and any failure serves full.
# Exit: 0 ok, 1 a component failed, 2 usage, 3 preflight stopped before ./setup.
set -u
BASH_COMPAT=50
@@ -50,7 +57,7 @@ SELF="$(cd "$(dirname "$0")/.." && pwd -P)"
ROOT="${GSTACK_CAPY_ROOT:-$SELF}"
REPO="${GSTACK_CAPY_REPO:-https://github.com/garrytan/gstack.git}"
DOCS="https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md"
FOR="" REQUIRE="" WITH_BROWSER=0 REVISION="" PROJECT="" CHECK_ONLY=0 MIN_REVISION="${GSTACK_CAPY_REMOTE_MIN:-}"
MODEL="" FOR="" REQUIRE="" WITH_BROWSER=0 REVISION="" PROJECT="" CHECK_ONLY=0 MIN_REVISION="${GSTACK_CAPY_REMOTE_MIN:-}"
usage() { sed -n '2,32p' "$0" | sed 's/^# \{0,1\}//'; }
need() { [ -n "${2:-}" ] || { echo "gstack-capy-install: $1 needs a value" >&2; usage >&2; exit 2; }; }
while [ $# -gt 0 ]; do
@@ -63,13 +70,17 @@ while [ $# -gt 0 ]; do
--root) need "$1" "${2:-}"; ROOT="$2"; shift ;;
--check) CHECK_ONLY=1 ;;
--min-revision) need "$1" "${2:-}"; MIN_REVISION="$2"; shift ;;
--model) need "$1" "${2:-}"; MODEL="$2"; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "gstack-capy-install: unknown argument '$1'" >&2; usage >&2; exit 2 ;;
esac
shift
done
MODEL_SOURCE=model-flag
[ -n "$MODEL" ] || { MODEL=${GSTACK_MODEL:-}; MODEL_SOURCE=GSTACK_MODEL; }
[ -n "$FOR" ] && [ -n "$REQUIRE" ] && { echo "gstack-capy-install: --for and --require are exclusive" >&2; exit 2; }
[ -z "$FOR" ] && [ -z "$REQUIRE" ] && REQUIRE="claude,codex,patch"
[ -z "$PROJECT" ] || { _given=$PROJECT; PROJECT=$(CDPATH= cd -- "$_given" 2>/dev/null && { git rev-parse --show-toplevel 2>/dev/null || pwd -P; }) || { echo "gstack-capy-install: --project takes a directory path; '$_given' is not a directory (pass the project checkout's path, not a repo slug)" >&2; exit 2; }; }
say() { echo "gstack-capy-install: $*"; }
stop() { # CODE MESSAGE FIX — print and exit 3 before ./setup
@@ -148,7 +159,7 @@ STORE=$(gstack_bun_store "$GSTACK_STATE_ROOT")
done
INSTALL_VERSION=$(gstack_bun_install_version) || stop BUN_INSTALL_VERSION_INVALID "GSTACK_BUN_INSTALL_VERSION=$INSTALL_VERSION is not a verified Bun at or above gstack's supported minimum $GSTACK_BUN_TESTED (verified archives: $GSTACK_BUN_VERIFIED)" "unset GSTACK_BUN_INSTALL_VERSION, or set it to one of: $GSTACK_BUN_VERIFIED"
[ -n "$PROJECT" ] || { PROJECT=$(cd "$ORIG_PWD" && git rev-parse --show-toplevel 2>/dev/null || true); [ "$(cd "${PROJECT:-/nonexistent}" 2>/dev/null && pwd -P)" = "$ROOT_REAL" ] && PROJECT=""; }
PROJECT_REAL=""; [ -n "$PROJECT" ] && PROJECT_REAL=$(cd "$PROJECT" 2>/dev/null && pwd -P)
PROJECT_REAL=""; [ -n "$PROJECT" ] && PROJECT_REAL=$(cd "$PROJECT" && pwd -P) && [ "$PROJECT_REAL" = "$ROOT_REAL" ] && PROJECT_REAL=""
VERDICT=$(gstack_install_verdict_path "$GSTACK_STATE_ROOT" "$ROOT_REAL" "$PROJECT_REAL")
REC_BUN="" REC_VER="" REC_RULE="" REC_BUN_INSTALL="" REC_FOR=""
if [ -f "$VERDICT" ]; then
@@ -712,9 +723,54 @@ fi
# leaves that verdict's file in place.
[ "$RC" -eq 0 ] || { [ "$PROFILE" -eq 1 ] && rm -rf "${VERDICT%.json}.profile" 2>/dev/null; verdict_kept || rm -f "$LAUNCH_FILE" 2>/dev/null; LAUNCH_FILE=""; }
record_verdict "$TRAILER" "$([ "$RC" -eq 0 ] && echo 1 || echo 0)"
LOADOUT_LINES=""
if [ "$RC" -eq 0 ] && [ -z "$MODEL" ] && [ -n "${GSTACK_PREFERENCES_FILE:-}" ] && [ -z "${GSTACK_EVAL_DIR:-}${EVALS_RUN_ID:-}" ] && [ "$PROFILE" -eq 0 ] && [ -n "$LAUNCH_FILE" ]; then
_ag_out=$(cd "$ROOT" && bin/gstack-models resolve --role agent 2>&1) && _ag_rc=0 || _ag_rc=$?
_ag_first=$(printf '%s\n' "$_ag_out" | head -1)
case "$_ag_first" in
*", this machine") MODEL_SOURCE=config ;;
*", the preferences file") MODEL_SOURCE=preferences ;;
esac
[ "$_ag_rc" -eq 0 ] || LOADOUT_LINES="LOADOUT_FALLBACK: agent_model unreadable: $(printf '%s' "$_ag_first" | tr -d '\000-\037' | cut -c1-200); serving full (docs/troubleshooting.md#loadout-fallback)"
[ "$MODEL_SOURCE" = GSTACK_MODEL ] || MODEL=$(printf '%s' "$_ag_first" | sed -n 's|^agent: [a-z0-9-]*/\([A-Za-z0-9._-]*\) .*|\1|p')
fi
if [ "$RC" -eq 0 ] && [ -n "$MODEL" ] && [ "$PROFILE" -eq 0 ] && [ -n "$LAUNCH_FILE" ]; then
_lo_full=$(. "$SELF/bin/gstack-claude-root.sh" && dirname "$(gstack_canonical_claude_root)")
if [ "$MODEL_SOURCE" = GSTACK_MODEL ]; then
_lo_out=$(cd "$ROOT" && GSTACK_MODEL=$MODEL bin/gstack-modules install --full-root "$_lo_full" 2>&1)
else
_lo_out=$(cd "$ROOT" && bin/gstack-modules install --model "$MODEL" --full-root "$_lo_full" 2>&1)
fi
_lo_line=$(printf '%s\n' "$_lo_out" | grep -m1 '^LOADOUT: ')
case "$MODEL_SOURCE" in preferences|config) _lo_line=$(printf '%s' "$_lo_line" | sed "s/ source=model-flag/ source=$MODEL_SOURCE/") ;; esac
_lo_root=$(printf '%s\n' "$_lo_out" | sed -n 's/^SKILL_ROOT: //p' | head -1)
_lo_notes=$(printf '%s\n' "$_lo_out" | grep -E '^(LOADOUT_INVALID_MODEL|LOADOUT_FALLBACK): ')
case "$MODEL_SOURCE" in preferences|config) _lo_notes="${_lo_notes:+$_lo_notes
}LOADOUT_ASSUMED: model=$MODEL is the owner's agent_model ($MODEL_SOURCE), not read from the session; a session on another model sets GSTACK_MODEL=<its id>" ;; esac
_lo_name=$(printf '%s' "$_lo_line" | sed -n 's/^LOADOUT: \([a-z0-9-]*\) .*/\1/p')
_lo_digest=$(printf '%s' "$_lo_line" | sed -n 's/.* digest=\([0-9a-f]\{12\}\) .*/\1/p')
if [ -n "$_lo_name" ] && [ "$_lo_name" != full ]; then
_lo_file="${VERDICT%.json}.loadout-$_lo_name-$_lo_digest.env"
[ -n "$_lo_digest" ] && [ -d "$_lo_root" ] \
&& _lo_tmp=$(umask 077 && mktemp "$_lo_file.tmp.XXXXXX") \
&& { cat "$LAUNCH_FILE" && echo "export GSTACK_LOADOUT_SKILL_ROOT=$(shq "$_lo_root")"; } > "$_lo_tmp" \
&& chmod 0600 "$_lo_tmp" && mv -f "$_lo_tmp" "$_lo_file" && LAUNCH_FILE=$_lo_file \
|| { rm -f "${_lo_tmp:-}" 2>/dev/null; _lo_notes="${_lo_notes:+$_lo_notes
}LOADOUT_FALLBACK: could not publish the $_lo_name launch file; serving full (docs/troubleshooting.md#loadout-fallback)"; _lo_name=""; }
fi
if [ -z "$_lo_name" ] || [ -z "$_lo_root" ]; then
[ -n "$_lo_line" ] || _lo_notes="${_lo_notes:+$_lo_notes
}LOADOUT_FALLBACK: $(printf '%s\n' "$_lo_out" | tail -1 | tr -d '\000-\037' | cut -c1-200); serving full (docs/troubleshooting.md#loadout-fallback)"
_lo_line="LOADOUT: full model=none source=$MODEL_SOURCE reason=render-failed" _lo_root=$_lo_full
fi
LOADOUT_LINES="${_lo_notes:+$_lo_notes
}$_lo_line
SKILL_ROOT: $_lo_root"
fi
[ "$CHECK_ONLY" -eq 1 ] || [ "$PROFILE" -eq 1 ] || write_shim
printf '%s\n' "$OUT" | grep -v '^gstack: '
say "total: $(( $(now) - T0 ))s"
[ -n "$LOADOUT_LINES" ] && printf '%s\n' "$LOADOUT_LINES"
echo "GSTACK_LAUNCH: ${LAUNCH_DIR:+PATH=$LAUNCH_DIR:\$PATH }GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT=$GSTACK_STATE_ROOT GSTACK_EPHEMERAL=${GSTACK_EPHEMERAL:-1} GSTACK_CAPY=1 GSTACK_INSTALL_VERDICT=$VERDICT"
[ -n "$LAUNCH_DIR" ] && echo "GSTACK_LAUNCH_PATH: $LAUNCH_DIR"
[ -n "$LAUNCH_FILE" ] && echo "GSTACK_LAUNCH_FILE: $LAUNCH_FILE"
+8 -2
View File
@@ -1,10 +1,11 @@
#!/usr/bin/env bash
# gstack-ceo-mode-handoff — print /plan-ceo-review's Step 0E mode handoff line.
#
# Usage: gstack-ceo-mode-handoff <MODE> [--auto] [--decisions "<rows>"]
# Usage: gstack-ceo-mode-handoff <MODE> [--auto | --ceo-only] [--decisions "<rows>"]
# MODE: HOLD SCOPE | SCOPE EXPANSION | SELECTIVE EXPANSION | SCOPE REDUCTION
# (underscores accepted for spaces)
# --auto the mode came from a saved AUTO_DECIDE preference
# --ceo-only the mode came from 0E's rule in an /autoplan --ceo-only run
# --decisions governing approved rows; defaults to "none"
#
# The handoff used to be chat-only, and the model often composed it in its
@@ -15,10 +16,12 @@ set -euo pipefail
mode="${1:-}"
[ "$#" -gt 0 ] && shift
auto=0
ceo_only=0
decisions="none"
while [ "$#" -gt 0 ]; do
case "$1" in
--auto) auto=1; shift ;;
--ceo-only) ceo_only=1; shift ;;
--decisions)
[ "$#" -ge 2 ] || { echo "gstack-ceo-mode-handoff: --decisions needs a value" >&2; exit 2; }
decisions="$2"; shift 2 ;;
@@ -31,11 +34,14 @@ case "$mode" in
"HOLD SCOPE"|"SCOPE EXPANSION"|"SELECTIVE EXPANSION"|"SCOPE REDUCTION") ;;
*) echo "gstack-ceo-mode-handoff: unknown mode: ${mode:-<empty>}" >&2; exit 2 ;;
esac
[ "$auto$ceo_only" != 11 ] || { echo "gstack-ceo-mode-handoff: --auto and --ceo-only conflict" >&2; exit 2; }
decisions="${decisions//$'\n'/ }"
decisions="${decisions%.}"
[ -n "${decisions// /}" ] || decisions="none"
if [ "$auto" = 1 ]; then
if [ "$ceo_only" = 1 ]; then
echo "Auto-selected review mode → $mode (CEO-only run: Step 0E's rule). Approved decisions: $decisions."
elif [ "$auto" = 1 ]; then
echo "Auto-decided review mode → $mode (your preference). Change with /plan-tune. Approved decisions: $decisions."
else
echo "Mode: $mode; approved decisions: $decisions."
+12 -2
View File
@@ -1,14 +1,16 @@
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* gstack-checks — the build-and-verify loop's checks for /verify. Logic: lib/checks.ts.
* gstack-checks — the build-and-verify loop's checks for /verify, and /health's tool detection. Logic: lib/checks.ts.
*/
import { spawnSync } from 'node:child_process';
import { cancelActive } from '../lib/pregate/supervise';
import { MAX_FAIL_LINES, TESTS, isTrusted, ledgerPath, load, nothingToRun, readLedger, recordTrust, runChecks, status, type Loaded } from '../lib/checks';
import { MAX_FAIL_LINES, TESTS, healthDimensions, isTrusted, ledgerPath, load, nothingToRun, readLedger, recordTrust, runChecks, status, type Loaded } from '../lib/checks';
const USAGE = `Usage: gstack-checks <detect|trust|run|status> [options]
detect list the checks run would execute and whether they are trusted (reads files only)
detect --for health /health's dimensions: DIMENSION <name> <command> (<source>), or DIMENSION <name> none
(<reason>); a CLAUDE.md Health Stack section wins (reads files and PATH only)
trust trust the current command set for this repo (checks, the pregate runner and its
selection manifest commands); any change to one of them needs trust again
run [--only a,b] [--allow-repo-commands] [--timeout <s>]
@@ -43,6 +45,14 @@ async function main(argv: string[]): Promise<number> {
const [verb, ...args] = argv;
if (!verb || verb === '--help' || verb === '-h') { process.stdout.write(USAGE); return verb ? 0 : 64; }
if (!['detect', 'trust', 'run', 'status'].includes(verb)) { process.stderr.write(USAGE); return 64; }
const mode = flag(args, '--for');
if (args.includes('--for')) {
if (verb !== 'detect' || mode !== 'health') { process.stderr.write(USAGE); return 64; }
const d = healthDimensions(process.cwd());
for (const c of d.checks) process.stdout.write(`DIMENSION ${c.dimension} ${c.command} (${c.source})\n`);
for (const m of d.missing) process.stdout.write(`DIMENSION ${m.dimension} none (${m.reason})\n`);
return 0;
}
const root = repoRoot();
if (!root) { process.stderr.write('gstack-checks: not inside a git repository\n'); return 3; }
const timeoutS = Number(flag(args, '--timeout') ?? 600);
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* gstack-clean — reclaim throwaway files in this repository's .gstack folder
* (#2627): old codex scratch in tmp/, oversized browse logs cut in place, and
* with --audit-days N old browse audit entries. Dry run unless --apply;
* `gstack-clean --help` lists the fixed thresholds. The rules live in
* lib/repo-state-clean.ts; error codes GSTACK_CLEAN_* are documented in
* docs/troubleshooting.md.
*/
import { main } from '../lib/repo-state-clean';
process.exit(main(process.argv.slice(2)));
+2 -2
View File
@@ -662,8 +662,8 @@ _gstack_codex_lock_acquire() {
if [ "$_ohost" = "$_host" ] && ! kill -0 "$_pid" 2>/dev/null; then _dead=1; fi
[ $((_now - _ts)) -ge "$_stale" ] && _dead=1
else
_ts=$(stat -c %Y "$_dir" 2>/dev/null || stat -f %m "$_dir" 2>/dev/null || echo 0)
case "$_ts" in ''|*[!0-9]*) _ts=0 ;; esac
_ts=$(stat -c %Y "$_dir" 2>/dev/null || stat -f %m "$_dir" 2>/dev/null || echo "$_now")
case "$_ts" in ''|*[!0-9]*) _ts="$_now" ;; esac
[ $((_now - _ts)) -ge 5 ] && _dead=1
fi
[ "$_dead" -eq 1 ] && _gstack_codex_lock_break "$_dir" "$_owner"
+2 -1
View File
@@ -138,7 +138,7 @@ for SESSION_FILE in "${SESSION_FILES[@]}"; do
const text = String(e.payload?.message || "");
if (!text) continue;
// Detect D-numbered brief or marker. Markers are sufficient on their own.
const markerMatch = text.match(/<gstack-qid:([a-z0-9-]{1,64})>/i);
const markerMatch = text.match(/<gstack-qid:([a-z0-9-]{1,64})(?:;signal=([a-z0-9-]{1,64}))?>/i);
const dMatch = text.match(/^D\d+[\.\d]*\s*[—\-]\s*(.+?)$/m);
if (!markerMatch && !dMatch) continue;
@@ -199,6 +199,7 @@ for SESSION_FILE in "${SESSION_FILES[@]}"; do
options_count: options.length || 1,
user_choice: userChoice.slice(0, 64),
...(recommended ? { recommended: recommended.slice(0, 64) } : {}),
...(markerMatch?.[2] ? { signal_key: markerMatch[2].toLowerCase() } : {}),
source,
session_id: sessionId,
// Use ts_nanos+ts shape from the event itself if available; else null.
+49 -9
View File
@@ -208,6 +208,14 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne
# # the same model; every reviewer keeps full.
# Docs: docs/model-policy.md
#
# harness_start_hook: # on | off. on: ./setup and gstack-harness add the
# # gstack-start plugin to OpenClaw and Hermes registrations
# # (a one-line note on every turn to run the start
# # command of a skill; edits openclaw.json or config.yaml via the
# # harness CLI). off: takes it out. Absent = each
# # registration keeps its saved choice (default off).
# # GSTACK_HARNESS_START_HOOK=1|0 overrides it. Docs: docs/hosts.md
#
# ─── Advanced ────────────────────────────────────────────────────────
# codex_reviews: enabled # Outside review (Codex or Claude Code by harness): sends
# # the review prompt and repository content to that model.
@@ -221,6 +229,17 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne
# # /codex and /claude-code still run when you ask by name.
# # An unreadable config counts as disabled.
# # Invalid values preserve the old value.
# autoplan_shadow: off # Shadow mode for the CEO-only decider (gstack-autoplan
# # driver runs): on gives every gate one hidden model
# # prediction, recorded before the owner answers, for
# # taste-replay R2. Sends the reviewed plan and the gate
# # to that model on your own API key, about $0.07 per
# # gate, counted against --spend-cap. Read at the first
# # next of each run only. GSTACK_AUTOPLAN_SHADOW=1|0
# # overrides.
# # Invalid values are rejected, the old value kept.
# autoplan_shadow_model: claude-opus-5-5 # The model the shadow decider runs on.
# # Docs: docs/unattended.md#shadow-mode
# browse_extension_id: # Forks and self-built extensions: the 32-letter (a-p)
# # Chrome extension ID the browse sidebar terminal accepts.
# # Empty = the gstack extension. "" clears it.
@@ -281,7 +300,9 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne
# # bot owns logins starting with its name and
# # checks mentioning it).
# pr_wait_advisory_bots: coderabbit # review bots gstack-pr-wait reports but
# # never waits for. Both lists: lowercase names,
# # never waits for. /review and /ship triage the
# # comments of every bot in both lists (e.g.
# # coderabbit,copilot). Both lists: lowercase names,
# # letters, digits, . _ - only; none empties a list.
# # Invalid values are REJECTED (value kept).
#
@@ -308,7 +329,10 @@ lookup_default() {
skill_prefix) echo "false" ;;
explain_level) echo "default" ;;
plan_mode_preamble) echo "on" ;; # on | off — off drops the plan-mode preamble on the next ./setup (#3168)
claude_loadout) echo "full" ;; # a modules/loadouts.json name — written by ./setup --loadout (docs/loadouts.md)
codex_reviews) echo "enabled" ;;
autoplan_shadow) echo "off" ;; # on | off — autoplan shadow mode, fixed at each run's first next (lib/autoplan-shadow.ts)
autoplan_shadow_model) echo "claude-opus-5-5" ;; # the shadow decider's model; needs a lib/pricing.ts row and its family's API key
design_detector) echo "auto" ;; # auto | off — impeccable engine pre-pass in the design skills
design_detector_install_prompted) echo "false" ;; # true once the user answered the one-time engine install offer with "never ask again"
gstack_contributor) echo "false" ;;
@@ -368,6 +392,7 @@ lookup_default() {
agent_model) echo "" ;; # empty = host (agents keep their own model); gstack-models resolve --role agent
codex_effort) echo "" ;; # empty = each gate's own effort; /codex --effort, then GSTACK_CODEX_EFFORT, win (gstack-codex-probe run-with-timeout --effort)
review_effort_tiers) echo "on" ;; # on | off — gstack-review-plan effort tiers (docs/model-policy.md#reasoning-effort)
harness_start_hook) echo "" ;; # on | off — empty = each harness registration keeps its saved choice (docs/hosts.md#start-hook)
ship_greptile_early) echo "true" ;; # true | false — /ship's early Greptile PR
pr_wait_gating_bots) echo "greptile,macroscope" ;; # review bots gstack-pr-wait --until ready waits for
pr_wait_advisory_bots) echo "coderabbit" ;; # review bots gstack-pr-wait reports but never waits for
@@ -781,6 +806,10 @@ $_gstack_legacy_root"
;;
esac
fi
if [ "$KEY" = "claude_loadout" ] && ! printf '%s' "$VALUE" | grep -Eq '^[a-z0-9-]{1,40}$'; then
echo "Warning: claude_loadout must be a loadout name such as lite or full. Using full." >&2
VALUE="full"
fi
if [ "$KEY" = "plan_mode_preamble" ] && [ "$VALUE" != "on" ] && [ "$VALUE" != "off" ]; then
echo "Warning: plan_mode_preamble '$VALUE' not recognized. Valid values: on, off. Using on." >&2
VALUE="on"
@@ -833,6 +862,10 @@ $_gstack_legacy_root"
echo "Error: review_effort_tiers '$VALUE' not recognized. Valid values: on, off. Existing value left unchanged." >&2
exit 1
fi
if [ "$KEY" = "harness_start_hook" ] && [ "$VALUE" != "on" ] && [ "$VALUE" != "off" ]; then
echo "Error: harness_start_hook '$VALUE' not recognized. Valid values: on, off. Existing value left unchanged. fix: gstack-config unset harness_start_hook keeps each registration's saved choice (docs/hosts.md#start-hook)" >&2
exit 1
fi
if [ "$KEY" = "codex_effort" ]; then
case "$VALUE" in
minimal|low|medium|high|xhigh|max|ultra) ;;
@@ -961,6 +994,12 @@ $_gstack_legacy_root"
echo "Error: memorable_recall '$VALUE' not recognized. Valid values: on, off. Existing value left unchanged." >&2
exit 1
fi
# autoplan_shadow is a CONSENT key: `on` sends each gate's plan to a model
# API. Reject like memorable_recall -- a typo must never flip it.
if [ "$KEY" = "autoplan_shadow" ] && [ "$VALUE" != "on" ] && [ "$VALUE" != "off" ]; then
echo "Error: autoplan_shadow '$VALUE' not recognized. Valid values: on, off. Existing value left unchanged." >&2
exit 1
fi
# browse_extension_id (D1) decides which Chrome extension may reach the
# browse daemon's token route and terminal socket. Reject anything that is
# not a Chrome extension ID; "" clears the override.
@@ -1092,15 +1131,15 @@ $_gstack_legacy_root"
echo "# ─── Active values (including defaults for unset keys) ───"
report_root_disagreement
for KEY in proactive routing_declined telemetry auto_upgrade update_check update_channel \
skill_prefix explain_level plan_mode_preamble \
codex_reviews gstack_contributor skip_eng_review workspace_root \
skill_prefix explain_level plan_mode_preamble claude_loadout \
codex_reviews autoplan_shadow autoplan_shadow_model gstack_contributor skip_eng_review workspace_root \
artifacts_sync_mode artifacts_sync_mode_prompted artifacts_sync_removals plan_tune_hooks \
timeline_stop_hook design_detector design_detector_install_prompted memorable_recall \
browse_extension_id ship_measure_rule_trials ship_measure_behavior_panels \
ship_measure_judge_outputs ship_measure_ask_per_trial_usd ship_measure_budget_usd \
ship_measure_max_rounds ship_measure_sweep_cases ship_measure_sweep_budget_usd ship_rerun_backend test_backend \
plan_review_tier implementation_tier model_frontier_claude model_frontier_openai \
model_smart_claude model_smart_openai codex_effort review_effort_tiers agent_model; do
model_smart_claude model_smart_openai codex_effort review_effort_tiers agent_model harness_start_hook; do
gstack_config_select "$KEY"
VALUE="$_gstack_cfg_value"
SOURCE="default"
@@ -1117,15 +1156,15 @@ $_gstack_legacy_root"
defaults)
echo "# gstack-config defaults"
for KEY in proactive routing_declined telemetry auto_upgrade update_check update_channel \
skill_prefix explain_level plan_mode_preamble \
codex_reviews gstack_contributor skip_eng_review workspace_root \
skill_prefix explain_level plan_mode_preamble claude_loadout \
codex_reviews autoplan_shadow autoplan_shadow_model gstack_contributor skip_eng_review workspace_root \
artifacts_sync_mode artifacts_sync_mode_prompted artifacts_sync_removals plan_tune_hooks \
timeline_stop_hook design_detector design_detector_install_prompted memorable_recall \
browse_extension_id ship_measure_rule_trials ship_measure_behavior_panels \
ship_measure_judge_outputs ship_measure_ask_per_trial_usd ship_measure_budget_usd \
ship_measure_max_rounds ship_measure_sweep_cases ship_measure_sweep_budget_usd ship_rerun_backend test_backend \
plan_review_tier implementation_tier model_frontier_claude model_frontier_openai \
model_smart_claude model_smart_openai codex_effort review_effort_tiers agent_model; do
model_smart_claude model_smart_openai codex_effort review_effort_tiers agent_model harness_start_hook; do
printf ' %-24s %s\n' "$KEY:" "$(lookup_default "$KEY")"
done
;;
@@ -1150,6 +1189,7 @@ $_gstack_legacy_root"
. "$SCRIPT_DIR/gstack-render-claude.sh" 2>/dev/null || { echo "$0: $SCRIPT_DIR/gstack-render-claude.sh is missing. fix: reinstall with ./setup or /gstack-upgrade" >&2; exit 1; }
_PLAN_MODE_ARG=""
[ "$("$0" get plan_mode_preamble 2>/dev/null || true)" != off ] || _PLAN_MODE_ARG="--plan-mode=off"
gstack_claude_loadout_arg "$0"
DETECT_BIN="$SCRIPT_DIR/gstack-gbrain-detect"
DETECTION_FILE="$STATE_DIR/gbrain-detection.json"
mkdir -p "$STATE_DIR"
@@ -1218,7 +1258,7 @@ $_gstack_legacy_root"
gstack_claude_overlay "$INSTALL_DIR" "$0"
RENDER_TMP="$RENDER_DIR.tmp.$$"
rm -rf "$RENDER_TMP"
if ( cd "$INSTALL_DIR" && bun run gen:skill-docs:user --host claude --out-dir "$RENDER_TMP" --link-root "$RENDER_DIR" --model "$_GSTACK_OVERLAY" ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"} >/dev/null 2>&1 ); then
if ( cd "$INSTALL_DIR" && bun run gen:skill-docs:user --host claude --out-dir "$RENDER_TMP" --link-root "$RENDER_DIR" --model "$_GSTACK_OVERLAY" ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"} ${_LOADOUT_ARG:+"$_LOADOUT_ARG"} >/dev/null 2>&1 ); then
gstack_render_lock "$RENDER_DIR"
_swap_in_render "$RENDER_DIR" "$RENDER_TMP"
gstack_render_unlock "$RENDER_DIR"
@@ -1244,7 +1284,7 @@ $_gstack_legacy_root"
# brain blocks, so removing gbrain keeps the chosen overlay.
INSTALL_DIR="$HOME/.claude/skills/gstack"
RENDER_DIR="${GSTACK_USER_RENDER_DIR:-$STATE_DIR/render/claude}"
if { [ -d "$RENDER_DIR" ] || "$0" has claude_overlay_model || [ -n "$_PLAN_MODE_ARG" ]; } \
if { [ -d "$RENDER_DIR" ] || "$0" has claude_overlay_model || [ -n "$_PLAN_MODE_ARG" ] || [ -n "$_LOADOUT_ARG" ]; } \
&& [ -d "$INSTALL_DIR" ] && [ ! -L "$INSTALL_DIR" ] && [ -f "$INSTALL_DIR/VERSION" ] && [ -f "$INSTALL_DIR/package.json" ]; then
if ! command -v bun >/dev/null 2>&1; then
echo "Skip: bun not on PATH — the Claude render at $RENDER_DIR was not refreshed. Install bun, then re-run 'gstack-config gbrain-refresh'."
+161 -35
View File
@@ -25,6 +25,10 @@
# `completed` events in projects/*/timeline.jsonl.
# Idempotent; backfilled rows count for tenure only.
#
# Writers (--derive, --log-session, --reconcile, --migrate) hold
# developer-profile.json.lock for their read-modify-write and exit 5 when
# another writer keeps it past the give-up window (#3437).
#
# Profile file: ~/.gstack/developer-profile.json (unified schema — see
# docs/designs/PLAN_TUNING_V0.md). Event file: ~/.gstack/projects/{SLUG}/
# question-events.jsonl.
@@ -37,6 +41,10 @@ ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
. "$(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"
# A relative state root is anchored to the starting directory once, here:
# do_derive changes directory, and the profile lock must name the same path
# when it is taken and when it is released.
case "$GSTACK_HOME" in /*|[A-Za-z]:[\\/]*) ;; *) GSTACK_HOME="$PWD/$GSTACK_HOME" ;; esac
# Windows git-bash: GSTACK_HOME resolves to an MSYS path (/c/Users/...), which
# Bun on Windows cannot open as a filesystem path (bites --derive). Normalize
# once, here, before PROFILE_FILE/LEGACY_FILE are derived from it below —
@@ -46,16 +54,107 @@ case "$(uname -s)" in
esac
PROFILE_FILE="$GSTACK_HOME/developer-profile.json"
LEGACY_FILE="$GSTACK_HOME/builder-profile.jsonl"
MIN_EVIDENCE=3
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null || true)"
SLUG="${SLUG:-unknown}"
CMD="${1:---read}"
shift || true
# -----------------------------------------------------------------------
# Profile lock (#3437). Every subcommand that rewrites the profile reads,
# changes and renames the whole file, and gstack-question-log starts a
# background --derive after every append, so writers overlap: a shared temp
# name made renames fail and an unlocked rewrite dropped a concurrent
# --log-session. Writers hold a mkdir lock (<profile>.lock/, whose owner file
# names the holder's pid and a random token; release is ownership-checked)
# from before their read until after their rename, and write a unique temp
# file. A lock whose holder process is gone is taken over at once; one older
# than 5 minutes is taken over even when its pid is alive, since no writer
# runs that long and the pid may have been reused. Takeovers are serialized by a second mkdir lock
# (<profile>.lock.takeover/) and re-read the owner under it, so two waiters
# cannot both take over or remove a lock a third writer just took. A lock or
# takeover directory older than 30 s with no owner belongs to a writer that
# died before writing one; cleanup removes a takeover lock this writer holds. A writer that cannot get the lock within the
# give-up window writes nothing and exits 5. Writers outside this binary
# (gstack-distill-apply, /plan-tune's declared edits) do not take it yet.
# -----------------------------------------------------------------------
LOCK_DIR="$PROFILE_FILE.lock"
TAKEOVER_DIR="$LOCK_DIR.takeover"
LOCK_TOKEN=""
HOLDS_TAKEOVER=""
PROFILE_TMP=""
cleanup() {
if [ -n "$PROFILE_TMP" ]; then rm -f "$PROFILE_TMP"; fi
if [ -n "$HOLDS_TAKEOVER" ]; then rmdir "$TAKEOVER_DIR" 2>/dev/null || true; fi
if [ -n "$LOCK_TOKEN" ] && [ "$(cat "$LOCK_DIR/owner" 2>/dev/null || true)" = "$LOCK_TOKEN" ]; then
rm -rf "$LOCK_DIR"
fi
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
dir_age_s() {
local mtime
# GNU stat first: BSD-style stat -f on Linux prints a filesystem block.
mtime=$(stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo "")
case "$mtime" in ""|*[!0-9]*) echo 0 ;; *) echo $(( $(date +%s) - mtime )) ;; esac
}
lock_abandoned() {
if [ -n "$1" ]; then
! kill -0 "${1%%-*}" 2>/dev/null || [ "$(dir_age_s "$LOCK_DIR")" -gt 300 ]
else
[ "$(dir_age_s "$LOCK_DIR")" -gt 30 ]
fi
}
acquire_lock() {
if [ -n "$LOCK_TOKEN" ]; then return 0; fi
mkdir -p "$GSTACK_HOME"
# GSTACK_PROFILE_LOCK_TIMEOUT_MS: test-only override for the give-up.
local give_up_ms="${GSTACK_PROFILE_LOCK_TIMEOUT_MS:-10000}" waited_ms=0 started=$SECONDS vanished=0 owner
local token="$$-$RANDOM$RANDOM"
until mkdir "$LOCK_DIR" 2>/dev/null; do
# mkdir failed with no lock to wait for: retry once in case a holder just
# released it, then report the directory instead of waiting out the window.
if [ ! -e "$LOCK_DIR" ]; then
if [ "$vanished" -eq 0 ]; then vanished=1; continue; fi
echo "gstack-developer-profile: cannot create $LOCK_DIR (is $GSTACK_HOME writable?), so nothing was written (exit 5)." >&2
exit 5
fi
vanished=0
owner=$(cat "$LOCK_DIR/owner" 2>/dev/null || true)
if [ -d "$LOCK_DIR" ] && lock_abandoned "$owner"; then
if [ -d "$TAKEOVER_DIR" ] && [ "$(dir_age_s "$TAKEOVER_DIR")" -gt 30 ]; then rmdir "$TAKEOVER_DIR" 2>/dev/null || true; fi
if mkdir "$TAKEOVER_DIR" 2>/dev/null; then
HOLDS_TAKEOVER=1
if [ "$(cat "$LOCK_DIR/owner" 2>/dev/null || true)" = "$owner" ] && lock_abandoned "$owner"; then
rm -rf "$LOCK_DIR" 2>/dev/null || true
fi
rmdir "$TAKEOVER_DIR" 2>/dev/null || true
HOLDS_TAKEOVER=""
if [ ! -e "$LOCK_DIR" ]; then continue; fi
fi
fi
if [ "$waited_ms" -ge "$give_up_ms" ] || [ $(( SECONDS - started )) -gt $(( give_up_ms / 1000 )) ]; then
echo "gstack-developer-profile: another writer held $LOCK_DIR for ${give_up_ms} ms, so nothing was written (exit 5)." >&2
exit 5
fi
sleep 0.05
waited_ms=$(( waited_ms + 50 ))
done
printf '%s\n' "$token" > "$LOCK_DIR/owner"
LOCK_TOKEN="$token"
}
# -----------------------------------------------------------------------
# Migration: builder-profile.jsonl → developer-profile.json
# -----------------------------------------------------------------------
do_migrate() {
acquire_lock
if [ ! -f "$LEGACY_FILE" ]; then
echo "MIGRATE: no legacy file to migrate"
return 0
@@ -68,9 +167,7 @@ do_migrate() {
fi
# Run migration in a temp file, then atomic rename.
local TMPOUT
TMPOUT=$(mktemp "$GSTACK_HOME/developer-profile.json.tmp.XXXXXX")
trap 'rm -f "$TMPOUT"' EXIT
PROFILE_TMP=$(mktemp "$GSTACK_HOME/developer-profile.json.tmp.XXXXXX")
cat "$LEGACY_FILE" | bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
const lines = (await Bun.stdin.text()).trim().split('\n').filter(Boolean);
@@ -113,11 +210,11 @@ do_migrate() {
schema_version: 1,
};
console.log(JSON.stringify(profile, null, 2));
" > "$TMPOUT"
" > "$PROFILE_TMP"
# Atomic rename.
mv "$TMPOUT" "$PROFILE_FILE"
trap - EXIT
mv "$PROFILE_TMP" "$PROFILE_FILE"
PROFILE_TMP=""
# gbrain-sync: enqueue the migrated file for cross-machine sync (no-op if off).
SCRIPT_DIR_E="$(cd "$(dirname "$0")" && pwd)"
@@ -205,13 +302,12 @@ do_log_session() {
*) echo "gstack-developer-profile: bun printed no verdict while validating the session; nothing was logged" >&2; return 1 ;;
esac
acquire_lock
ensure_profile
local TMPOUT
TMPOUT=$(mktemp "$GSTACK_HOME/developer-profile.json.tmp.XXXXXX")
trap 'rm -f "$TMPOUT"' EXIT
PROFILE_TMP=$(mktemp "$GSTACK_HOME/developer-profile.json.tmp.XXXXXX")
PROFILE_FILE_PATH="$PROFILE_FILE" RECORD_INPUT="$INPUT" TMPOUT_PATH="$TMPOUT" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
PROFILE_FILE_PATH="$PROFILE_FILE" RECORD_INPUT="$INPUT" TMPOUT_PATH="$PROFILE_TMP" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
const fs = require('fs');
const entry = JSON.parse(process.env.RECORD_INPUT);
if (!entry.ts) entry.ts = new Date().toISOString();
@@ -238,8 +334,8 @@ do_log_session() {
fs.writeFileSync(process.env.TMPOUT_PATH, JSON.stringify(profile, null, 2));
"
mv "$TMPOUT" "$PROFILE_FILE"
trap - EXIT
mv "$PROFILE_TMP" "$PROFILE_FILE"
PROFILE_TMP=""
"$SCRIPT_DIR/gstack-brain-enqueue" "developer-profile.json" 2>/dev/null &
}
@@ -252,13 +348,12 @@ do_log_session() {
# rewritten only when something was added.
# -----------------------------------------------------------------------
do_reconcile() {
acquire_lock
ensure_profile
local TMPOUT
TMPOUT=$(mktemp "$GSTACK_HOME/developer-profile.json.tmp.XXXXXX")
trap 'rm -f "$TMPOUT"' EXIT
PROFILE_TMP=$(mktemp "$GSTACK_HOME/developer-profile.json.tmp.XXXXXX")
PROFILE_FILE_PATH="$PROFILE_FILE" PROJECTS_DIR="$GSTACK_HOME/projects" TMPOUT_PATH="$TMPOUT" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
PROFILE_FILE_PATH="$PROFILE_FILE" PROJECTS_DIR="$GSTACK_HOME/projects" TMPOUT_PATH="$PROFILE_TMP" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
const fs = require('fs');
const path = require('path');
const profile = JSON.parse(fs.readFileSync(process.env.PROFILE_FILE_PATH, 'utf-8'));
@@ -313,13 +408,13 @@ do_reconcile() {
console.log('RECONCILE: ' + added + ' backfilled from ' + runs.length + ' office-hours run(s)');
"
if [ -s "$TMPOUT" ]; then
mv "$TMPOUT" "$PROFILE_FILE"
trap - EXIT
if [ -s "$PROFILE_TMP" ]; then
mv "$PROFILE_TMP" "$PROFILE_FILE"
PROFILE_TMP=""
"$SCRIPT_DIR/gstack-brain-enqueue" "developer-profile.json" 2>/dev/null &
else
rm -f "$TMPOUT"
trap - EXIT
rm -f "$PROFILE_TMP"
PROFILE_TMP=""
fi
}
@@ -394,6 +489,18 @@ do_read() {
# -----------------------------------------------------------------------
do_profile() {
ensure_profile
local PARSE_ERR
if ! PARSE_ERR=$(PROFILE_FILE_PATH="$PROFILE_FILE" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
const text = require('fs').readFileSync(process.env.PROFILE_FILE_PATH, 'utf-8');
try { JSON.parse(text); } catch (e) {
const markers = /^(<{7}|={7}|>{7})( |\$)/m.test(text) ? ' (it holds git conflict markers from a merge)' : '';
console.log(e.message + markers);
process.exit(1);
}
" 2>&1); then
echo "gstack-developer-profile: $PROFILE_FILE is not valid JSON: $PARSE_ERR; repair the file or restore it from git -C $GSTACK_HOME log -- developer-profile.json" >&2
return 1
fi
cat "$PROFILE_FILE"
}
@@ -406,14 +513,19 @@ do_gap() {
const p = JSON.parse(await Bun.stdin.text());
const declared = p.declared || {};
const inferred = (p.inferred && p.inferred.values) || {};
const contributing = p.inferred && p.inferred.contributing_events;
const dims = ['scope_appetite','risk_tolerance','detail_preference','autonomy','architecture_care'];
const gap = {};
const low_evidence = {};
for (const d of dims) {
if (declared[d] !== undefined && inferred[d] !== undefined) {
gap[d] = +(Math.abs(declared[d] - inferred[d])).toFixed(3);
if (declared[d] === undefined || inferred[d] === undefined) continue;
if (contributing && (contributing[d] || 0) < $MIN_EVIDENCE) {
low_evidence[d] = contributing[d] || 0;
continue;
}
gap[d] = +(Math.abs(declared[d] - inferred[d])).toFixed(3);
}
console.log(JSON.stringify({ declared, inferred, gap }, null, 2));
console.log(JSON.stringify({ declared, inferred, gap, low_evidence }, null, 2));
"
}
@@ -452,6 +564,7 @@ function accumulateSignals(lines, QUESTIONS, SIGNAL_MAP, dims) {
const days = new Set();
let count = 0;
let signalEvents = 0;
let hintedEvents = 0;
for (const line of lines) {
let e;
try { e = JSON.parse(line); } catch { continue; }
@@ -464,16 +577,18 @@ function accumulateSignals(lines, QUESTIONS, SIGNAL_MAP, dims) {
const day = when && !Number.isNaN(when.getTime()) ? when.toISOString().slice(0, 10) : null;
if (day) days.add(day);
const def = Object.hasOwn(QUESTIONS, e.question_id) ? QUESTIONS[e.question_id] : undefined;
const byChoice = def && def.signal_key && Object.hasOwn(SIGNAL_MAP, def.signal_key) ? SIGNAL_MAP[def.signal_key] : {};
const hinted = !def && typeof e.signal_key === "string" && Object.hasOwn(SIGNAL_MAP, e.signal_key);
const byChoice = def && def.signal_key && Object.hasOwn(SIGNAL_MAP, def.signal_key) ? SIGNAL_MAP[def.signal_key] : hinted ? SIGNAL_MAP[e.signal_key] : {};
const deltas = Object.hasOwn(byChoice, e.user_choice) ? byChoice[e.user_choice] : [];
if (!Array.isArray(deltas) || deltas.length === 0) continue;
signalEvents++;
if (hinted) hintedEvents++;
const key = day || "undated";
const sums = buckets.get(key) || {};
buckets.set(key, sums);
for (const d of deltas) {
sums[d.dim] = (sums[d.dim] || 0) + d.delta;
rows.push({ ts: e.ts, question_id: e.question_id, choice: e.user_choice, dim: d.dim, delta: d.delta });
rows.push({ ts: e.ts, question_id: e.question_id, choice: e.user_choice, dim: d.dim, delta: d.delta, hinted });
}
for (const dim of new Set(deltas.map((d) => d.dim))) contributing[dim] = (contributing[dim] ?? 0) + 1;
}
@@ -486,7 +601,7 @@ function accumulateSignals(lines, QUESTIONS, SIGNAL_MAP, dims) {
totals[dim] = (totals[dim] ?? 0) + bounded;
}
}
return { cap, totals, contributing, rows, capped, count, signalEvents, skills, qids, days };
return { cap, totals, contributing, rows, capped, count, signalEvents, hintedEvents, skills, qids, days };
}
'
@@ -494,6 +609,7 @@ function accumulateSignals(lines, QUESTIONS, SIGNAL_MAP, dims) {
# Derive: recompute inferred dimensions from question-events.jsonl
# -----------------------------------------------------------------------
do_derive() {
acquire_lock
ensure_profile
local EVENTS="$GSTACK_HOME/projects/$SLUG/question-log.jsonl"
local REGISTRY="$ROOT_DIR/scripts/question-registry.ts"
@@ -503,8 +619,9 @@ do_derive() {
exit 1
fi
PROFILE_TMP=$(mktemp "$GSTACK_HOME/developer-profile.json.tmp.XXXXXX")
cd "$ROOT_DIR"
PROFILE_FILE_PATH="$PROFILE_FILE" EVENTS_PATH="$EVENTS" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
PROFILE_FILE_PATH="$PROFILE_FILE" EVENTS_PATH="$EVENTS" TMPOUT_PATH="$PROFILE_TMP" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
import('./scripts/question-registry.ts').then(async (regmod) => {
const sigmod = await import('./scripts/psychographic-signals.ts');
const fs = require('fs');
@@ -521,7 +638,7 @@ do_derive() {
lines = fs.readFileSync(eventsPath, 'utf-8').trim().split('\n').filter(Boolean);
}
const { totals, contributing, count, signalEvents, skills, qids, days } =
const { totals, contributing, count, signalEvents, hintedEvents, skills, qids, days } =
accumulateSignals(lines, QUESTIONS, SIGNAL_MAP, ALL_DIMENSIONS);
const values = {};
@@ -539,6 +656,8 @@ do_derive() {
// evidence' from 'balanced evidence': both print 0.5, which is how
// five 0.5s sat beside sample_size 1286 (#2623).
contributing_events: contributing,
// Signal events that came from an unregistered id's signal hint (#3442).
hinted_events: hintedEvents,
diversity: {
skills_covered: skills.size,
question_ids_covered: qids.size,
@@ -549,6 +668,7 @@ do_derive() {
// Recompute gap.
const gap = {};
for (const d of Object.keys(values)) {
if ((contributing[d] || 0) < $MIN_EVIDENCE) continue;
if (profile.declared && profile.declared[d] !== undefined) {
gap[d] = +(Math.abs(profile.declared[d] - values[d])).toFixed(3);
}
@@ -556,12 +676,12 @@ do_derive() {
profile.gap = gap;
profile.derived_at = new Date().toISOString();
const tmp = profilePath + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(profile, null, 2));
fs.renameSync(tmp, profilePath);
fs.writeFileSync(process.env.TMPOUT_PATH, JSON.stringify(profile, null, 2));
console.log('DERIVE: ok — ' + count + ' events, ' + skills.size + ' skills, ' + qids.size + ' questions');
}).catch(err => { console.error('DERIVE:', err.message); process.exit(1); });
"
mv "$PROFILE_TMP" "$PROFILE_FILE"
PROFILE_TMP=""
}
# -----------------------------------------------------------------------
@@ -597,7 +717,7 @@ do_trace() {
} else {
console.log('TRACE: ' + rows.length + ' events for ' + target);
for (const r of rows) {
console.log(' ' + clean(r.ts).slice(0,19) + ' ' + clean(r.question_id) + ' → ' + clean(r.choice) + ' (' + signed(r.delta) + ')');
console.log(' ' + clean(r.ts).slice(0,19) + ' ' + clean(r.question_id) + ' → ' + clean(r.choice) + ' (' + signed(r.delta) + ')' + (r.hinted ? ' (hinted)' : ''));
}
for (const c of acc.capped.filter((c) => c.dim === target)) {
console.log(' day ' + clean(c.day) + ': ' + signed(c.sum, 3) + ' capped to ' + signed(c.bounded, 3));
@@ -619,9 +739,8 @@ do_check_mismatch() {
const declared = p.declared || {};
const inferred = (p.inferred && p.inferred.values) || {};
const sampleSize = (p.inferred && p.inferred.sample_size) || 0;
const diversity = (p.inferred && p.inferred.diversity) || {};
const contributing = p.inferred && p.inferred.contributing_events;
// Require enough data before reporting mismatch.
if (sampleSize < 10) {
console.log('MISMATCH: not enough data (' + sampleSize + ' events; need 10+)');
process.exit(0);
@@ -629,8 +748,14 @@ do_check_mismatch() {
const THRESHOLD = 0.3;
const flagged = [];
const thin = [];
for (const d of Object.keys(declared)) {
if (inferred[d] === undefined) continue;
const n = contributing ? (contributing[d] || 0) : undefined;
if (n !== undefined && n < $MIN_EVIDENCE) {
thin.push(' ' + d + ': ' + (n === 0 ? 'no observed evidence' : 'too little evidence (' + n + ' of $MIN_EVIDENCE events)'));
continue;
}
const gap = Math.abs(declared[d] - inferred[d]);
if (gap > THRESHOLD) {
flagged.push({ dim: d, declared: declared[d], inferred: inferred[d], gap: +gap.toFixed(3) });
@@ -645,6 +770,7 @@ do_check_mismatch() {
console.log(' ' + f.dim + ': declared ' + f.declared + ' vs inferred ' + f.inferred + ' (gap ' + f.gap + ')');
}
}
for (const line of thin) console.log(line);
"
}
+118 -16
View File
@@ -5,6 +5,9 @@
# Or: gstack-diff-scope <base> --head <sha> → the committed range <base>...<sha> only
# (no working tree, no untracked files): what a receipt writer and the
# merge queue compute for one commit, from any checkout (GSTA-147).
# Or: gstack-diff-scope <base> --head <sha> --merged → the same for a head GitHub
# already merged into the base (land resuming a true merge): the empty range
# is the answer, so the base-contains-head refusal below does not apply.
#
# Output contract (#2526 — all-false must be distinguishable from "we could
# not look" and from "nothing matched"):
@@ -15,7 +18,11 @@
# trips loudly instead of silently disabling reviewers)
# exit 2 base ref unresolvable → all false + SCOPE_ERROR=no_base
# (shallow CI checkout / missing fetch — a green here would mean
# "we could not look")
# "we could not look"; also no default base when origin/HEAD
# names the checked-out branch and nothing past it resolves)
# exit 2 base is HEAD or contains it → all false + SCOPE_ERROR=base_contains_head
# (the branch has commits its real base lacks, so it would be
# compared with itself, GST-774; the reason as a comment line)
# Every line is shell-safe for `source <(...)` consumers: assignments or
# `#`-comments only.
#
@@ -26,16 +33,71 @@
set -euo pipefail
# Detect the repo's default branch when no arg is given (#703-class
# platform-agnostic rule): origin/HEAD -> origin/main -> origin/master -> main.
_default_base() {
git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/origin/||' && return
git rev-parse --verify -q origin/main >/dev/null 2>&1 && { echo "main"; return; }
git rev-parse --verify -q origin/master >/dev/null 2>&1 && { echo "master"; return; }
echo "main"
}
BASE="${1:-$(_default_base)}"
# platform-agnostic rule, lib/default-base.ts): origin/HEAD unless it names the
# checked-out branch, its upstream or HEAD's commit (a PR flow's clone can point
# it at the PR branch, GST-511, GST-774) -> the PR's base (gh, 5 s) -> the
# remote's live HEAD (ls-remote, 5 s) -> origin/main -> origin/master -> main.
# An origin/HEAD skipped that way with nothing past it resolving leaves BASE
# empty, and that fails closed; so does a default-base.ts that cannot run.
GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac
_default_base_ts() { bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." 2>/dev/null && pwd)/lib/default-base.ts" "$@"; }
NO_DEFAULT=""
BASE="${1:-}"
if [ -z "$BASE" ]; then
_rc=0
_found=$(_default_base_ts default 2>/dev/null) || _rc=$?
case "$_rc" in
0) BASE="$_found" ;;
1) BASE="main" ;;
3) NO_DEFAULT="$_found" ;;
*) NO_DEFAULT="lib/default-base.ts could not run (exit $_rc); pass the base explicitly" ;;
esac
fi
HEAD_REF=""
MERGED_HEAD=false
if [ "${2:-}" = "--head" ]; then HEAD_REF="${3:-}"; fi
if [ -n "$HEAD_REF" ] && [ "${4:-}" = "--merged" ]; then MERGED_HEAD=true; fi
# A bare branch name reads origin's tracking branch, by its full ref, when that
# shares history with HEAD: /ship merges against origin/<base>, and a linked
# worktree or a Capy clone never moves the local branch, so a stale one widens
# the scope (#3650). A remote-qualified name is taken as given.
_NOT_BRANCH='^[0-9a-f]{7,64}$|[~^]|@\{|^[A-Z_]*HEAD$|^refs/'
_QUALIFIED=false
case "$BASE" in origin/*) _QUALIFIED=true ;; esac
for _remote in $(git remote 2>/dev/null); do
case "$BASE" in "$_remote"/*) _QUALIFIED=true ;; esac
done
if [[ ! "$BASE" =~ $_NOT_BRANCH ]] && [ "$_QUALIFIED" = false ] \
&& git rev-parse --verify -q "refs/remotes/origin/${BASE}^{commit}" >/dev/null 2>&1 \
&& git merge-base "refs/remotes/origin/${BASE}" "${HEAD_REF:-HEAD}" >/dev/null 2>&1; then
BASE="refs/remotes/origin/${BASE}"
fi
# The base as a commit (GST-884, mirroring lib/gate-diff/git.ts resolveBase from
# GST-883): a branch name reads refs/remotes/<base> (origin/main as given), then
# refs/remotes/origin/<base> that shares history with the head, then refs/heads/<base>
# that does, then another remote's twin that does, then refs/heads/<base> as it is,
# all by full ref name and in resolveBase's order, so a stale local main never widens
# the scope and a tag named main or origin/main cannot stand in for the branch.
# A sha, a relative ref (HEAD~1, main^2, @{u}) or a full refs/ name resolves as
# given. Prints nothing when nothing resolves.
_is_branch_name() {
case "$1" in refs/*|*~*|*^*|*@\{*|HEAD|*_HEAD) return 1 ;; esac
[[ ! "$1" =~ ^[0-9a-f]{7,64}$ ]]
}
_commit() { git rev-parse --verify -q "$1^{commit}" 2>/dev/null; }
_resolve_base() {
local ref="$1" head="$2" sha loc remote
if ! _is_branch_name "$ref"; then _commit "$ref"; return; fi
sha=$(_commit "refs/remotes/$ref") && { echo "$sha"; return; }
loc=$(_commit "refs/heads/$ref")
for remote in $(git remote 2>/dev/null | grep -x origin) ${loc:+:local} $(git remote 2>/dev/null | grep -vx origin); do
if [ "$remote" = :local ]; then sha=$loc; else sha=$(_commit "refs/remotes/$remote/$ref") || continue; fi
git merge-base "$sha" "$head" >/dev/null 2>&1 && { echo "$sha"; return; }
done
[ -n "$loc" ] && { echo "$loc"; return; }
_commit "$ref"
}
FRONTEND=false
BACKEND=false
@@ -53,6 +115,19 @@ AUTH=false
# the root-anchored globs alone (#3335).
TEST_PATH_RE='(^|/)(tests?|spec|__tests__)/|(^|/)tests?\.py$|(^|/)test_[^/]+\.py$|_test\.(go|py|rb|ts|js|exs|dart)$|\.(test|spec)\.[jt]sx?$|_spec\.rb$|Test\.(java|kt)$'
# Data files (lib/review-depth.ts DATA_PATH) outside the config locations its
# DATA_CONFIG and agent-prose rules keep code: .github/, config/, dotfiles,
# *.config.*, .gstack/. Case-insensitive, as DATA_PATH is.
DATA_FILE_RE='\.(json|jsonl|ndjson|csv|tsv|png|jpe?g|gif|webp|bmp|ico|avif)$'
DATA_CONFIG_RE='(^|/)\.github/|(^|/)config/|(^|/)\.[^/]+$|(^|/)[^/]+\.config\.[^/]+$|^\.gstack/'
_session_data() {
local r=1
shopt -s nocasematch
if [[ "$1" =~ $DATA_FILE_RE ]] && [[ ! "$1" =~ $DATA_CONFIG_RE ]]; then r=0; fi
shopt -u nocasematch
return "$r"
}
_print_flags() {
echo "SCOPE_FRONTEND=$FRONTEND"
echo "SCOPE_BACKEND=$BACKEND"
@@ -65,16 +140,40 @@ _print_flags() {
echo "SCOPE_AUTH=$AUTH"
}
if [ -z "$BASE" ]; then
_print_flags
echo "SCOPE_ERROR=no_base"
echo "# ${NO_DEFAULT}"
exit 2
fi
# Base reachability (#2526): a shallow CI checkout or an unfetched ref makes
# `git diff` return an empty list — all-false with exit 0, a green that means
# "we could not look". Distinguish it before diffing.
if ! git rev-parse --verify -q "${BASE}^{commit}" >/dev/null 2>&1; then
# "we could not look". Distinguish it before diffing. A branch name that does
# not resolve here reads its origin twin (_resolve_base; a PR checkout has no local main).
BASE_SHA=$(_resolve_base "$BASE" "${HEAD_REF:-HEAD}" || true)
if [ -z "$BASE_SHA" ]; then
_print_flags
echo "SCOPE_ERROR=no_base"
echo "# base ref '${BASE}' is not resolvable — shallow checkout or missing fetch. Run: git fetch origin ${BASE}"
exit 2
fi
# A base that is the head or already contains it reads the branch against
# itself, all false with exit 0 (GST-774). lib/default-base.ts decides whether
# that is the base branch itself or a fresh branch (today's output) or a
# refusal; it is asked only when git says the base contains the head.
if [ "$MERGED_HEAD" = false ] && git merge-base --is-ancestor "${HEAD_REF:-HEAD}" "${BASE_SHA}" >/dev/null 2>&1; then
_rc=0
_why=$(_default_base_ts check "$BASE" "${HEAD_REF:-HEAD}" gstack-diff-scope 2>/dev/null) || _rc=$?
if [ "$_rc" -ne 0 ]; then
_print_flags
echo "SCOPE_ERROR=base_contains_head"
echo "# ${_why:-lib/default-base.ts could not check the base (exit $_rc) (BASE_CONTAINS_HEAD)}"
exit 2
fi
fi
# Changed files, NUL-delimited (#2526 minor: `git diff --name-only` octal-quotes
# non-ASCII paths, and the trailing quote defeats extension globs; -z avoids it).
FILES_LIST=()
@@ -91,16 +190,16 @@ if [ -n "$HEAD_REF" ] || [ "${2:-}" = "--head" ]; then
echo "# --head '${HEAD_REF}' is not a commit in this repository"
exit 2
fi
if ! git merge-base "${BASE}" "${HEAD_REF}" >/dev/null 2>&1; then
if ! git merge-base "${BASE_SHA}" "${HEAD_REF}" >/dev/null 2>&1; then
_print_flags
echo "SCOPE_ERROR=no_merge_base"
echo "# '${BASE}' and '${HEAD_REF}' share no merge base"
exit 2
fi
_collect < <(git diff -z "${BASE}...${HEAD_REF}" --name-only 2>/dev/null)
_collect < <(git diff -z "${BASE_SHA}...${HEAD_REF}" --name-only 2>/dev/null)
else
# Committed diff vs base (merge-base form; two-dot fallback when no merge base).
_collect < <(git diff -z "${BASE}...HEAD" --name-only 2>/dev/null || git diff -z "${BASE}" --name-only 2>/dev/null || true)
_collect < <(git diff -z "${BASE_SHA}...HEAD" --name-only 2>/dev/null || git diff -z "${BASE_SHA}" --name-only 2>/dev/null || true)
# Working-tree changes (staged + unstaged). `git diff HEAD` fails on a repo
# with no commits; tolerated.
_collect < <(git diff -z HEAD --name-only 2>/dev/null || true)
@@ -190,9 +289,12 @@ for f in ${FILES_LIST[@]+"${FILES_LIST[@]}"}; do
*.graphql|*.gql|openapi.*|swagger.*) m_api=true ;;
esac
# Auth: authentication, authorization, sessions, permissions
# Auth: authentication, authorization, sessions, permissions. A data file
# named for sessions (recovered-sessions.jsonl, GST-911) holds rows, not
# session handling, so it is not auth; session config still is.
case "$f" in
*auth*|*session*|*jwt*|*oauth*|*permission*|*role*) m_auth=true ;;
*auth*|*jwt*|*oauth*|*permission*|*role*) m_auth=true ;;
*session*) _session_data "$f" || m_auth=true ;;
esac
# Backend: code that isn't a frontend component/view file. Includes ESM/CJS
+65 -6
View File
@@ -4,8 +4,9 @@
*
* Usage (run inside the repository):
* gstack-docs-candidate snapshot --out <candidate.json> --audit-id <id> --mode edit|read-only
* --base <rev> [--select <path>]... [--docs <path>]...
* --base <rev> [--select <path>]... [--docs <path>]... [--exception <docs-audit.json>]
* gstack-docs-candidate compare <candidate.json>
* gstack-docs-candidate attempt <child-output> --state <attempts.json>
* gstack-docs-candidate [snapshot|compare] --help
*
* snapshot saves, NUL-safely and without writing to the repository: base SHA,
@@ -15,15 +16,30 @@
* selected paths are the committed, staged, unstaged and untracked paths.
* --docs takes files or directories (directories expand to their tracked and
* untracked files). It prints a short summary; the JSON file is the record.
* --exception names the blocked .gstack/tmp/docs-audit.json an owner accepted
* (Step 14.5 Blocked recovery): the record then carries that exception, so
* Step 16 can tell an accepted risk from a skipped audit. A --base that is HEAD
* or already contains it while the branch has commits its real base lacks is
* refused (BASE_CONTAINS_HEAD, lib/default-base.ts): it would select nothing.
*
* compare re-reads the same state and prints one JSON object: whether HEAD or
* the index changed, which recorded paths' content changed or disappeared, and
* which paths are newly dirty or untracked. It never decides whether a change
* was permitted; the caller checks those paths against the child's report.
*
* attempt reads one audit child's output and says whether the launch counts
* against Step 14.5's attempt budget. A launch the child's install check refused
* (an `INSTALL: fail` row, its last GSTACK_RESULT line a document-release
* refusal, and no audit JSON) never audited, so it is not counted, once per
* invocation (--state, the invocation's record); a second refusal is counted
* and stops for the install fix (GST-391, gbrain #8084).
*/
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import { baseContainsHead } from '../lib/default-base';
import { resultLine } from '../lib/result-codes';
import { parseReceiptDocs, type ReceiptDocs } from '../lib/ship-receipt-docs';
function git(args: string[]): string {
const r = spawnSync('git', args, { encoding: 'utf8', env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' }, maxBuffer: 256 * 1024 * 1024 });
@@ -82,8 +98,19 @@ function one(args: string[], name: string): string {
return values[0]!;
}
const USAGE = `usage: gstack-docs-candidate snapshot --out <candidate.json> --audit-id <id> --mode edit|read-only --base <rev> [--select <path>]... [--docs <path>]...
gstack-docs-candidate compare <candidate.json>`;
const USAGE = `usage: gstack-docs-candidate snapshot --out <candidate.json> --audit-id <id> --mode edit|read-only --base <rev> [--select <path>]... [--docs <path>]... [--exception <docs-audit.json>]
gstack-docs-candidate compare <candidate.json>
gstack-docs-candidate attempt <child-output> --state <attempts.json>`;
/** The owner's accepted Step 14.5 exception from the blocked .gstack/tmp/docs-audit.json record, read by the receipt's own parser; throws when the record is not blocked or names no complete exception. */
function acceptedException(file: string): NonNullable<ReceiptDocs['exception']> & { scope: string } {
let audit: unknown;
try { audit = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (error) { throw new Error(`--exception ${file} is not readable JSON: ${(error as Error).message}`); }
const parsed = parseReceiptDocs(audit);
const problem = !parsed.ok ? parsed.message : parsed.docs.status !== 'blocked' ? `its status is ${parsed.docs.status}, not blocked` : !parsed.docs.exception ? 'it names no accepted exception' : null;
if (problem || !parsed.ok || !parsed.docs.exception) throw new Error(`--exception ${file}: ${problem}. Only an owner-accepted documentation risk writes this snapshot (ship/sections/documentation.md, Blocked recovery step 3)`);
return { ...parsed.docs.exception, scope: parsed.docs.scope };
}
/** Each field compare needs that the record lacks or types wrongly; empty for a valid record. */
function recordProblems(record: any): string[] {
@@ -98,13 +125,13 @@ function recordProblems(record: any): string[] {
function main(argv: string[]): void {
const [command, ...args] = argv;
const topHelp = (command === '--help' || command === '-h') && !args.length;
const commandHelp = (command === 'snapshot' || command === 'compare') && args.length === 1 && args[0] === '--help';
const commandHelp = (command === 'snapshot' || command === 'compare' || command === 'attempt') && args.length === 1 && args[0] === '--help';
if (topHelp || commandHelp) {
console.log(USAGE);
return;
}
if (command === 'snapshot') {
const known = new Set(['--out', '--audit-id', '--mode', '--base', '--select', '--docs']);
const known = new Set(['--out', '--audit-id', '--mode', '--base', '--select', '--docs', '--exception']);
for (let i = 0; i < args.length; i += 2) if (!known.has(args[i]!)) throw new Error(`unknown argument: ${args[i]}`);
const out = path.resolve(one(args, '--out'));
const top = fs.realpathSync(git(['rev-parse', '--show-toplevel']).trim());
@@ -112,6 +139,10 @@ function main(argv: string[]): void {
const mode = one(args, '--mode');
if (mode !== 'edit' && mode !== 'read-only') throw new Error('--mode must be edit or read-only');
const base = git(['rev-parse', '--verify', one(args, '--base') + '^{commit}']).trim();
const self = baseContainsHead(top, base);
if (self) throw new Error(resultLine('snapshot', 'BASE_CONTAINS_HEAD', self));
const exceptionFile = flag(args, '--exception')[0];
const exception = exceptionFile === undefined ? undefined : acceptedException(exceptionFile);
process.chdir(top);
const now = state(base);
const listed = nul(git(['ls-files', '-z', '--cached', '--others', '--exclude-standard']));
@@ -119,10 +150,11 @@ function main(argv: string[]): void {
: [...new Set([...now.committed, ...now.staged, ...now.unstaged, ...now.untracked])].sort();
const docs = expand(listed, flag(args, '--docs'));
const tracked = [...new Set([...selected, ...docs, ...now.staged, ...now.unstaged, ...now.untracked])].sort();
const record = { schema_version: 1, audit_id: one(args, '--audit-id'), mode, base, ...now, selected, docs, hashes: hashes(tracked) };
const record = { schema_version: 1, audit_id: one(args, '--audit-id'), mode, base, ...now, selected, docs, hashes: hashes(tracked), ...(exception ? { exception } : {}) };
fs.writeFileSync(out, JSON.stringify(record, null, 2) + '\n', { mode: 0o600 });
console.log(`CANDIDATE: ${out}`);
console.log(`base=${base} head=${now.head} branch=${now.branch || '(detached)'} mode=${mode}`);
if (exception) console.log(`EXCEPTION: accepted by ${exception.accepted_by} ${exception.date} via ${exception.via}`);
console.log(`committed=${now.committed.length} staged=${now.staged.length} unstaged=${now.unstaged.length} untracked=${now.untracked.length} selected=${selected.length} docs=${docs.length} hashed=${tracked.length}`);
for (const [label, list] of [['committed', now.committed], ['staged', now.staged], ['unstaged', now.unstaged], ['untracked', now.untracked], ['docs', docs]] as const) {
if (list.length) console.log(`${label}: ${list.map(p => JSON.stringify(p)).join(' ')}`);
@@ -160,6 +192,33 @@ function main(argv: string[]): void {
}));
return;
}
if (command === 'attempt') {
const INSTALL_FIX = 'run the Capy skill installer (scripts/install.sh --for document-release), then launch with its GSTACK_LAUNCH line';
const [file, ...rest] = args;
if (!file || file.startsWith('--')) throw new Error('attempt takes the child output file, then --state <attempts.json>');
const state = one(rest, '--state');
const lines = fs.readFileSync(file, 'utf8').split('\n').map(l => l.trim()).filter(Boolean);
const last = lines.filter(l => l.startsWith('GSTACK_RESULT: ')).pop();
const install = lines.find(l => /^INSTALL: fail\b/.test(l));
const audited = lines.some(l => { try { const v = JSON.parse(l); return !!v && typeof v === 'object' && 'schema_version' in v; } catch { return false; } });
if (!last || !/^GSTACK_RESULT: skill=document-release status=refused\b/.test(last) || !install || audited) { console.log('ATTEMPT: counted'); return; }
let seen = 0;
try { seen = Number(JSON.parse(fs.readFileSync(state, 'utf8')).not_counted) || 0; } catch { seen = fs.existsSync(state) ? 1 : 0; }
if (seen >= 1) {
console.log('ATTEMPT: counted (a second install refusal in this invocation)');
console.log(`STOP: ${INSTALL_FIX} and confirm its gstack: ok trailer before another launch`);
process.exit(3);
}
fs.mkdirSync(path.dirname(path.resolve(state)), { recursive: true });
try { fs.writeFileSync(state, JSON.stringify({ not_counted: 1 }) + '\n', { mode: 0o600, flag: 'wx' }); } catch (e) {
if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e;
console.log('ATTEMPT: counted (another launch in this invocation already used the allowance)');
console.log(`STOP: ${INSTALL_FIX} and confirm its gstack: ok trailer before another launch`);
process.exit(3);
}
console.log('ATTEMPT: not-counted (the audit child\'s install check refused document-release)');
return;
}
throw new Error(USAGE);
}
+15 -3
View File
@@ -275,7 +275,7 @@ check_codex_probe() {
}
check_sync() {
local mode file json status held drainable drain push queue=0 now f detail case_names fix="$BIN/gstack-brain-sync --status"
local mode file json json_held status held drainable drain push queue=0 now f detail case_names fix="$BIN/gstack-brain-sync --status"
cfg artifacts_sync_mode off; mode=$_cfg
if [ ! -d "$STATE_ROOT/.git" ]; then
row "not configured" "artifacts sync" "off; cross-machine artifacts sync is unavailable" "$BIN/gstack-artifacts-init"
@@ -285,6 +285,11 @@ check_sync() {
row "not configured" "artifacts sync" "artifacts_sync_mode off; cross-machine artifacts sync is unavailable" "$BIN/gstack-config set artifacts_sync_mode artifacts-only"
return
fi
for f in "$STATE_ROOT/developer-profile.json" "$STATE_ROOT"/projects/*/decisions.active.json; do
[ -f "$f" ] && grep -qE '^(<{7}|>{7})( |$)' "$f" 2>/dev/null || continue
row warn "artifacts sync" "${f#"$STATE_ROOT"/} holds git conflict markers and does not parse" "repair the file, or restore it from git -C $STATE_ROOT log -- ${f#"$STATE_ROOT"/}"
return
done
for f in "$STATE_ROOT/.brain-queue.d"/*.json; do [ -f "$f" ] && queue=$((queue + 1)); done
for f in "$STATE_ROOT/.brain-queue.jsonl" "$STATE_ROOT/.brain-queue.jsonl.migrating"; do
[ -f "$f" ] && queue=$((queue + $(wc -l < "$f" | tr -d ' ')))
@@ -306,8 +311,15 @@ check_sync() {
[ -n "$push" ] || push=$(epoch_of "$(head -1 "$STATE_ROOT/.brain-last-push" 2>/dev/null)")
now=$(date +%s)
case "$status" in
held) row warn "artifacts sync" "status held${held:+ ($held file(s) held by the secret scanner)}" "$fix"; return ;;
blocked) row warn "artifacts sync" "status blocked (a re-scan still flags staged content)" "$fix"; return ;;
held)
json_held=$(printf '%s' "$json" | grep -o '"rule": *"invalid-json"' | wc -l | tr -d ' ')
if [ "${json_held:-0}" -gt 0 ]; then
row warn "artifacts sync" "status held ($json_held synced file(s) are not valid JSON${held:+; $held held in all})" "$fix"
else
row warn "artifacts sync" "status held${held:+ ($held file(s) held by the secret scanner)}" "$fix"
fi
return ;;
blocked) row warn "artifacts sync" "status blocked (nothing was committed; --status names the cause)" "$fix"; return ;;
push_failed|error|unknown) row warn "artifacts sync" "status $status" "$fix"; return ;;
esac
if [ -n "$case_names" ]; then
+46 -1
View File
@@ -141,6 +141,7 @@ gstack_result_anchor() {
BROWSER_UNAVAILABLE) printf '%s' 'browser-ensure-failed' ;;
INSTALL_PREFLIGHT_FAILED) printf '%s' 'capy-install-preflight-failed' ;;
SETUP_PS1_EMPTY_ARGUMENT) printf '%s' 'setup-ps1-empty-argument' ;;
SETUP_DEPS_MIGRATE_FAILED) printf '%s' 'setup-deps-migrate-failed' ;;
POLICY_SOURCE_UNAVAILABLE) printf '%s' 'policy-source-unavailable' ;;
POLICY_INVALID) printf '%s' 'policy-invalid' ;;
POLICY_CONTAINMENT) printf '%s' 'policy-containment' ;;
@@ -232,7 +233,6 @@ gstack_result_anchor() {
BUN_RESOLUTION_DRIFT) printf '%s' 'bun-resolution-drift' ;;
BUN_INSTALL_VERSION_INVALID) printf '%s' 'bun-install-version-invalid' ;;
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' ;;
@@ -282,7 +282,21 @@ gstack_result_anchor() {
QUEUE_TRAIN_STAMP_MISMATCH) printf '%s' 'queue-train-stamp-mismatch' ;;
QUEUE_TRAIN_TREE_MISMATCH) printf '%s' 'queue-train-tree-mismatch' ;;
QUEUE_TRAIN_STALE) printf '%s' 'queue-train-stale' ;;
QUEUE_TRAIN_BASE_RED) printf '%s' 'queue-train-base-red' ;;
QUEUE_TRAIN_SPEND_CAP) printf '%s' 'queue-train-spend-cap' ;;
QUEUE_TRAIN_LAND_METHOD) printf '%s' 'queue-train-land-method' ;;
QUEUE_TRAIN_INIT_REFUSED) printf '%s' 'queue-train-init-refused' ;;
QUEUE_TRAIN_DOCTOR_FINDINGS) printf '%s' 'queue-train-doctor-findings' ;;
QUEUE_BASE_RED_NO_TRAIN) printf '%s' 'queue-base-red-no-train' ;;
SHIP_BASE_RED_FIXER) printf '%s' 'ship-base-red-fixer' ;;
QUEUE_TRAIN_FROZEN) printf '%s' 'queue-train-frozen' ;;
QUEUE_WAVE_CONFLICT) printf '%s' 'queue-wave-conflict' ;;
QUEUE_WAVE_MOVED) printf '%s' 'queue-wave-moved' ;;
QUEUE_WAVE_DUPLICATE) printf '%s' 'queue-wave-duplicate' ;;
QUEUE_WAVE_SIZE) printf '%s' 'queue-wave-size' ;;
QUEUE_WAVE_UNKNOWN) printf '%s' 'queue-wave-unknown' ;;
QUEUE_WAVE_DEPENDENT) printf '%s' 'queue-wave-dependent' ;;
QUEUE_WAVE_NOT_MERGED) printf '%s' 'queue-wave-not-merged' ;;
QUEUE_RECEIPT_MISSING) printf '%s' 'queue-receipt-missing' ;;
QUEUE_RECEIPT_STALE) printf '%s' 'queue-receipt-stale' ;;
QUEUE_RECEIPT_CONDITIONAL) printf '%s' 'queue-receipt-conditional' ;;
@@ -291,8 +305,10 @@ gstack_result_anchor() {
BASE_RED_REPRO_ERROR) printf '%s' 'base-red-repro-error' ;;
BASE_RED_PAUSE_EXPIRED) printf '%s' 'base-red-pause-expired' ;;
BASE_RED_WAIT_TIMEOUT) printf '%s' 'base-red-wait-timeout' ;;
BASE_CONTAINS_HEAD) printf '%s' 'base-contains-head' ;;
QUEUE_RECEIPT_DEPTH_RAISED) printf '%s' 'queue-receipt-depth-raised' ;;
QUEUE_RECEIPT_EXCEPTION_INVALID) printf '%s' 'queue-receipt-exception-invalid' ;;
QUEUE_RECEIPT_TARGETED_MISSING) printf '%s' 'queue-receipt-targeted-missing' ;;
QUEUE_SCREEN_STALE_REF) printf '%s' 'queue-screen-stale-ref' ;;
QUEUE_SCREEN_NOT_RUN) printf '%s' 'queue-screen-not-run' ;;
QUEUE_TRAIN_STACK_RED) printf '%s' 'queue-train-stack-red' ;;
@@ -311,23 +327,31 @@ gstack_result_anchor() {
REGEN_DEPS_MISSING) printf '%s' 'regen-deps-missing' ;;
REVIEW_SCOPE_INPUT) printf '%s' 'review-scope-input' ;;
REVIEW_SCOPE_RANGE) printf '%s' 'review-scope-range' ;;
REVIEW_SCOPE_LEDGER) printf '%s' 'review-scope-ledger' ;;
RECEIPT_CARRY_REFUSED) printf '%s' 'receipt-carry-refused' ;;
PROSE_LINT_FOUND) printf '%s' 'prose-lint-found' ;;
REVIEW_LOOP_EXCEPTION_INVALID) printf '%s' 'review-loop-exception-invalid' ;;
RECEIPT_CARRY_TREE_MISMATCH) printf '%s' 'receipt-carry-tree-mismatch' ;;
RECEIPT_CARRY_PRODUCT_CHANGED) printf '%s' 'receipt-carry-product-changed' ;;
RECEIPT_CARRY_SCOPED_REVIEW) printf '%s' 'receipt-carry-scoped-review' ;;
DOCS_AUDIT_BLOCKED) printf '%s' 'docs-audit-blocked' ;;
RECEIPT_PROSE_MISMATCH) printf '%s' 'receipt-prose-mismatch' ;;
RECEIPT_CARRY_PREGATE_STALE) printf '%s' 'receipt-carry-pregate-stale' ;;
RECEIPT_GIT_TOO_OLD) printf '%s' 'receipt-git-too-old' ;;
CHANGELOG_FRAGMENT_INVALID) printf '%s' 'changelog-fragment-invalid' ;;
QUEUE_LAND_LOCKED) printf '%s' 'queue-land-locked' ;;
QUEUE_TRAIN_UNSTAMPED) printf '%s' 'queue-train-unstamped' ;;
RESTAMP_NOT_STAGED) printf '%s' 'restamp-not-staged' ;;
RESTAMP_UNSTAGED_EDITS) printf '%s' 'restamp-unstaged-edits' ;;
QUEUE_LAND_CONFLICT) printf '%s' 'queue-land-conflict' ;;
QUEUE_LAND_DRIFT) printf '%s' 'queue-land-drift' ;;
QUEUE_LAND_BASE_BUSY) printf '%s' 'queue-land-base-busy' ;;
PR_WAIT_BLOCKED) printf '%s' 'pr-wait-blocked' ;;
PR_WAIT_REFUSED) printf '%s' 'pr-wait-refused' ;;
PREGATE_HEAD_POLICY_LOOSENS) printf '%s' 'pregate-head-policy-loosens' ;;
REVIEW_DIFF_TOO_LARGE) printf '%s' 'review-diff-too-large' ;;
REVIEW_DIFF_UNREADABLE) printf '%s' 'review-diff-unreadable' ;;
REVIEW_EXCLUDE_REFERENCE) printf '%s' 'review-exclude-reference' ;;
APPROVAL_RECORD_INVALID) printf '%s' 'approval-record-invalid' ;;
APPROVAL_RECORD_SECRET) printf '%s' 'approval-record-secret' ;;
APPROVAL_RECORD_NO_BRANCH) printf '%s' 'approval-record-no-branch' ;;
@@ -345,6 +369,7 @@ gstack_result_anchor() {
UPGRADE_SKIPPED) printf '%s' 'upgrade-skipped' ;;
BUDGET_CAP_REACHED) printf '%s' 'budget-cap-reached' ;;
BUDGET_READING_MISSING) printf '%s' 'budget-reading-missing' ;;
METER_SCOPE_PARENT) printf '%s' 'meter-scope-parent' ;;
BUDGET_READING_REJECTED) printf '%s' 'budget-reading-rejected' ;;
BUDGET_METER_UNAVAILABLE) printf '%s' 'budget-meter-unavailable' ;;
BUDGET_LEDGER_INVALID) printf '%s' 'budget-ledger-invalid' ;;
@@ -353,7 +378,27 @@ gstack_result_anchor() {
PREREG_INCOMPLETE) printf '%s' 'prereg-incomplete' ;;
PREREG_DIAGNOSTIC_UNTESTED) printf '%s' 'prereg-diagnostic-untested' ;;
PREREG_NO_GSTACK_ARM) printf '%s' 'prereg-no-gstack-arm' ;;
EVAL_REPORT_NO_GSTACK_ROW) printf '%s' 'eval-report-no-gstack-row' ;;
EVAL_ARM_DUPLICATE) printf '%s' 'eval-arm-duplicate' ;;
OFFICE_HOURS_PROBE_REFUSED) printf '%s' 'office-hours-probe-refused' ;;
TASKS_SUPERSEDE_REFUSED) printf '%s' 'tasks-supersede-refused' ;;
REALITY_RECEIPT_UNRESOLVED) printf '%s' 'reality-receipt-unresolved' ;;
CEO_ONLY_CONFLICT) printf '%s' 'ceo-only-conflict' ;;
CEO_ONLY_AFTER_INIT) printf '%s' 'ceo-only-after-init' ;;
CEO_ONLY_NO_GATE) printf '%s' 'ceo-only-no-gate' ;;
SHADOW_BEFORE_ANSWER) printf '%s' 'shadow-before-answer' ;;
BROWSE_TAB_UNRECOVERABLE) printf '%s' 'browse-tab-unrecoverable' ;;
BROWSE_SESSION_INVALID) printf '%s' 'browse-session-invalid' ;;
BROWSE_SESSION_ROOT_ONLY) printf '%s' 'browse-session-root-only' ;;
BROWSE_SESSION_TAB_RESET) printf '%s' 'browse-session-tab-reset' ;;
BROWSE_SESSION_LIMIT) printf '%s' 'browse-session-limit' ;;
BROWSE_DEVICE_UNKNOWN) printf '%s' 'browse-device-unknown' ;;
BROWSE_DEVICE_CDP_FAILED) printf '%s' 'browse-device-cdp-failed' ;;
BROWSE_TOUCH_NO_DEVICE) printf '%s' 'browse-touch-no-device' ;;
GSTACK_CLEAN_USAGE) printf '%s' 'gstack-clean-usage' ;;
GSTACK_CLEAN_TARGET_REFUSED) printf '%s' 'gstack-clean-target-refused' ;;
GSTACK_CLEAN_PARTIAL) printf '%s' 'gstack-clean-partial' ;;
GSTACK_CLEAN_INTERNAL) printf '%s' 'gstack-clean-internal' ;;
*) return 1 ;;
esac
}
+35 -11
View File
@@ -5,11 +5,12 @@
* gstack-eval-plan prereg --check <file> [--allow-repo-commands] [--json] | --init <file> [--title <t>]
* gstack-eval-plan arms [--arms <model,model>] [--policy-doc <file>] [--input-tokens N] [--output-tokens N] [--json]
* gstack-eval-plan dry-run --input <rows.jsonl> --arms <model,model> [--fields a,b] [--output-budget N]
* [--retry-prompt <file>] [--max-rows N] [--out <dir>] [--json]
* gstack-eval-plan pilot start --out <dir> --cap <usd> --arms <model[#loadout],...> --dry-run <dry-run.json> [--n 3]
* [--retry-prompt <file>] [--max-rows N] [--tier-question <why>] [--out <dir>] [--json]
* gstack-eval-plan pilot start --out <dir> --cap <usd> --arms <model[#loadout],...> --dry-run <dry-run.json> [--n 3] [--seat-metered <arm,...>]
* gstack-eval-plan pilot settle --out <dir> --attempt <id> --usd <n|unknown>
* gstack-eval-plan pilot release --out <dir> --attempt <id>
* gstack-eval-plan pilot report --out <dir> --total <items> [--workers W] [--json]
* gstack-eval-plan results --check <prereg> <results.md> [--json]
* gstack-eval-plan price-gate --gate-file <file> | --gate "<exact gate command>" [--plan-file <file>] [--base <branch>] [--cap-usd N]
* [--smoke-cap-usd N] [--allow-unpriced "<reason>"] [--out <dir>] [--json]
*
@@ -26,12 +27,13 @@
* price-gate lint every arm's model against the base branch's
* .gstack/eval-models.json (docs/eval-models.md; lib/eval-models.ts). Exit 0 ok, 1 fail (incomplete
* prereg, failed check, exceeded cap, unpriced or stale gate), 2 usage, 3 refused.
* Logic: lib/eval-plan.ts.
* Logic: lib/eval-plan.ts; `results --check` in lib/eval-plan-report.ts.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
import { armModel, checkPrereg, DEFAULT_TOKENS, PREREG_SECTIONS, dryRun, pilotReport, preregTemplate, priceGate, releasePilot, renderArms, renderDryRun, renderGatePrice, renderPilotReport, renderPreregDiagnostics, resolveArms, resolvePlanSource, runPreregDiagnostics, settlePilot, startPilot } from '../lib/eval-plan';
import { checkResults } from '../lib/eval-plan-report';
import { checkModelRules, EVAL_SCOPES, type EvalScope, type ModelRulesCheck } from '../lib/eval-models';
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
import { resultLine } from '../lib/result-codes';
@@ -41,18 +43,26 @@ import { SpendCapExceeded } from '../lib/spend-ledger';
const HELP = `gstack-eval-plan — preregistration, priced arms, $0 dry run, priced pilot (eval-plan/SKILL.md)
prereg --check <file> [--allow-repo-commands] [--json]
the five sections exist and are filled: Bars, Arms, Decoys, Stop rules, Held-out exposure;
the required sections exist and are filled: Bars, Arms, Comparison, Decoys, Stop rules,
Held-out exposure, and No-gstack arm when the Comparison says stack: loaded;
an optional Diagnostics section's summarizer runs on its synthetic fixture only with
--allow-repo-commands (else it is listed and exit 3) and every count must match
prereg --init <file> [--title <t>] write the template
arms [--arms m,m] [--policy-doc f] [--input-tokens N] [--output-tokens N] [--json]
arms from \`gstack-models resolve --role eval-arms\`, else the repo policy doc, each priced per item
dry-run --input rows.jsonl --arms m,m [--fields a,b] [--output-budget N] [--retry-prompt f] [--max-rows N] [--cap usd] [--out dir] [--json]
dry-run --input rows.jsonl --arms m,m [--fields a,b] [--output-budget N] [--retry-prompt f] [--max-rows N] [--cap usd] [--tier-question "<why>"] [--out dir] [--json]
stub model over the real rows; checklist: price table, output budget, retry prompt text,
and with --cap reservation-fits-cap (one call: largest row + output budget, priced)
pilot start --out dir --cap usd --arms m,m#no-gstack --dry-run dry-run.json [--n 3]
and with --cap reservation-fits-cap (one call: largest row + output budget, priced);
frontier: refuses a plan whose every arm is Haiku or Sonnet unless --tier-question
names what it asks about that tier, and re-costs the plan on frontier prices (R54)
pilot start --out dir --cap usd --arms m,m#no-gstack --dry-run dry-run.json [--n 3] [--seat-metered arm,...]
--seat-metered: arms on a subscription with no incremental charge; they reserve $0,
settle unknown, and sit outside the cap and the projection until one settles a charge
pilot settle --out dir --attempt id --usd <n|unknown> pilot release --out dir --attempt id
pilot report --out dir --total items [--workers W] [--json] spend table + extrapolation with a 95% band, then the approval ask
pilot report --out dir --total items [--workers W] [--json] spend table + per-arm extrapolation with a 95% band, then the approval ask
results --check <prereg> <results.md> [--json]
one table in the results file has a filled row for every Arms identity, each no-gstack arm
beside its variants; reads text only (docs/TESTING_INTERNALS.md#no-gstack-arm)
price-gate --gate-file <file> | --gate "<exact gate command>" [--plan-file f] [--base b] [--cap-usd N] [--smoke-cap-usd N] [--allow-unpriced "<reason>"] [--out dir] [--json]
price a paid gate from its GSTACK_EVAL_CALLS v1 call-plan file (--plan-file, else the base
policy's price_file at HEAD); checks the gate/head/input binding, executes nothing, writes a
@@ -129,6 +139,17 @@ function doPrereg(rest: string[]): number {
return EXIT.ok;
}
function doResults(rest: string[]): number {
const prereg = flag(rest, '--check');
const results = rest.filter(a => !a.startsWith('--') && a !== prereg)[0];
if (!prereg || !results || !fs.existsSync(prereg) || !fs.existsSync(results)) return usage('results needs --check <existing prereg> <existing results file>');
const r = checkResults(fs.readFileSync(prereg, 'utf8'), fs.readFileSync(results, 'utf8'));
if (rest.includes('--json')) process.stdout.write(JSON.stringify({ prereg, results, ...r }) + '\n');
else process.stdout.write(`RESULTS: ${r.ok ? 'ok' : 'incomplete'} no_gstack_rows=${r.no_gstack.filter(id => r.found.includes(id)).length}/${r.no_gstack.length} rows=${r.found.length}/${r.required.length} file=${results}\n`);
if (!r.ok) { process.stderr.write(resultLine('gstack-eval-plan results', 'EVAL_REPORT_NO_GSTACK_ROW', r.problems.join('; ')) + '\n'); return EXIT.fail; }
return EXIT.ok;
}
function doArms(rest: string[]): number {
const tokens = { input: num(rest, '--input-tokens', DEFAULT_TOKENS.input)!, output: num(rest, '--output-tokens', DEFAULT_TOKENS.output)! };
const scope = scopeOf(rest);
@@ -146,13 +167,15 @@ function doDryRun(rest: string[]): number {
if (!arms.length) return usage('dry-run needs --arms <model,model>');
const scope = scopeOf(rest);
const modelRules = lintModels(rest, arms.map(model => ({ model, scope, where: 'dry-run --arms' })), 'dry-run');
const r = dryRun({ input, arms, fields: csv(flag(rest, '--fields')), outputBudget: num(rest, '--output-budget'), retryPrompt: flag(rest, '--retry-prompt'), maxRows: num(rest, '--max-rows'), cap: num(rest, '--cap'), modelRules });
const tierQuestion = flag(rest, '--tier-question');
if (tierQuestion?.startsWith('--')) return usage('--tier-question needs the question the preregistration asks about that tier, not another flag');
const r = dryRun({ input, arms, fields: csv(flag(rest, '--fields')), outputBudget: num(rest, '--output-budget'), retryPrompt: flag(rest, '--retry-prompt'), maxRows: num(rest, '--max-rows'), cap: num(rest, '--cap'), modelRules, tierQuestion });
const out = flag(rest, '--out');
if (out) { fs.mkdirSync(out, { recursive: true }); fs.writeFileSync(path.join(out, 'dry-run.json'), JSON.stringify(r, null, 2) + '\n'); }
if (rest.includes('--json')) process.stdout.write(JSON.stringify(r) + '\n'); else process.stdout.write(renderDryRun(r));
if (out) process.stdout.write(`DRY_RUN_RECORD: ${path.join(out, 'dry-run.json')}\n`);
if (!r.pass) {
const why = [r.rows === 0 ? 'no rows' : '', r.bad_rows.length ? `${r.bad_rows.length} bad rows` : '', !r.checks.price_table.ok ? `price missing: ${r.checks.price_table.missing.join(',') || 'no arms'}` : '', !r.checks.output_budget.ok ? 'output budget below the reasoning floor' : '', !r.checks.retry_prompt.ok ? 'retry prompt missing' : '', r.checks.reservation_fits_cap && !r.checks.reservation_fits_cap.ok ? `one call exceeds --cap $${r.checks.reservation_fits_cap.cap}` : '', r.checks.model_rules && !r.checks.model_rules.ok ? 'model rules refused an arm (EVAL_MODEL lines above)' : ''].filter(Boolean).join('; ');
const why = [r.rows === 0 ? 'no rows' : '', r.bad_rows.length ? `${r.bad_rows.length} bad rows` : '', !r.checks.price_table.ok ? `price missing: ${r.checks.price_table.missing.join(',') || 'no arms'}` : '', !r.checks.output_budget.ok ? 'output budget below the reasoning floor' : '', !r.checks.retry_prompt.ok ? 'retry prompt missing' : '', r.checks.reservation_fits_cap && !r.checks.reservation_fits_cap.ok ? `one call exceeds --cap $${r.checks.reservation_fits_cap.cap}` : '', r.checks.model_rules && !r.checks.model_rules.ok ? 'model rules refused an arm (EVAL_MODEL lines above)' : '', !r.checks.frontier.ok ? 'every arm is Haiku or Sonnet (R54; CHECK: frontier above)' : ''].filter(Boolean).join('; ');
process.stderr.write(resultLine('gstack-eval-plan dry-run', r.checks.price_table.ok ? 'EVAL_DRY_RUN_FAILED' : 'EVAL_PRICE_MISSING', why) + '\n');
return EXIT.fail;
}
@@ -168,7 +191,7 @@ function doPilot(rest: string[]): number {
if (cap === undefined || !arms.length || !dry || !fs.existsSync(dry)) return usage('pilot start needs --cap <usd> --arms <m,m> --dry-run <existing dry-run.json>');
const rules = lintModels(args, arms.map(model => ({ model, scope: scopeOf(args), where: 'pilot start --arms' })), 'pilot start');
if (!rules.ok) return EXIT.fail;
const r = startPilot({ out, cap, n: num(args, '--n', 3)!, arms, dryRun: dry });
const r = startPilot({ out, cap, n: num(args, '--n', 3)!, arms, seatMetered: csv(flag(args, '--seat-metered')), dryRun: dry });
if (r.error && !r.record) { process.stderr.write(resultLine('gstack-eval-plan pilot start', r.error.code, r.error.message) + '\n'); return EXIT.refused; }
for (const a of r.record.attempts) process.stdout.write(`PILOT_ATTEMPT: ${a.attempt} model=${a.model} estimate=$${a.estimate_usd.toFixed(4)}\n`);
process.stdout.write(`PILOT: status=${r.record.status} reserved=${r.record.attempts.length} cap=$${cap.toFixed(2)} ledger=${path.join(out, 'spend.json')}\n`);
@@ -242,6 +265,7 @@ function main(argv: string[]): number {
if (cmd === 'dry-run') return doDryRun(rest);
if (cmd === 'pilot') return doPilot(rest);
if (cmd === 'price-gate') return doPriceGate(rest);
if (cmd === 'results') return doResults(rest);
return usage(`unknown command ${cmd}`);
} catch (e: any) {
if (e instanceof SpendCapExceeded) { process.stderr.write(resultLine(`gstack-eval-plan ${cmd}`, 'EVAL_BUDGET_EXHAUSTED', e.message) + '\n'); return EXIT.fail; }
+1 -1
View File
@@ -400,7 +400,7 @@ async function cmdRun(argv: string[]): Promise<number> {
try {
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" });
proc = Bun.spawn(spawnArgv, { stdin: "inherit", stdout: "pipe", stderr: "pipe", env: log ? { ...process.env, GSTACK_EVIDENCE_LOG: log.path } : process.env });
} catch (e: any) {
// Spawn failure (ENOENT on argv-direct form): record exit 127, propagate 127.
exitCode = 127;
+1 -1
View File
@@ -11,7 +11,7 @@
* gstack-factory-scorecard [--repo owner/name] [--base main] [--since-days 7]
* [--crew login,login] [--gate-workflow evals.yml] [--gate-event workflow_dispatch|any]
* [--free-workflow free-tests.yml] [--train-prefix train/] [--ready-marker <regex>]
* [--pass-marker <regex>] [--pass-cap 3] [--max-artifact-runs 60]
* [--pass-marker <regex>] [--pass-cap 2] [--max-artifact-runs 60]
* [--out-dir <dir>] [--csv <file>] [--json] [--cache-dir <dir>]
* [--fixtures <recorded gh responses.json>] [--now <iso>]
*
+47 -3
View File
@@ -36,7 +36,14 @@
# exit 3 autostash-unrecoverable: (edits kept where they are)
# exit 4 not-fast-forward:
# exit 5 index-unreadable:
# exit 6 tracked-by-project: / version-reverted: (#1022, the preflight)
# exit 75 ff-busy: (another update holds this install's lock)
#
# gstack-ff-update --preflight <dir> runs only the exit-6 checks (exit 0 or 6);
# bin/gstack-upgrade calls it before any fetch. A project that tracks the
# install's files as plain blobs puts old ones back on every checkout of its
# own, so VERSION freezes behind HEAD while updates seem to succeed; a
# submodule gitlink is the supported shape and passes.
# A rescue also prints the inspect, export and delete commands on stderr.
set +e
@@ -49,15 +56,51 @@ ff_flatten() {
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"; }
refuse() { printf '%s: %s\n' "$2" "$(ff_flatten "$3" "${4:-300}")"; exit "$1"; }
[ $# -eq 2 ] && [ -n "$1" ] && [ -n "$2" ] || refuse 2 usage "gstack-ff-update <install dir> <revision>"
DIR=$1 REV=$2
[ $# -eq 2 ] && [ -n "$1" ] && [ -n "$2" ] || refuse 2 usage "gstack-ff-update <install dir> <revision> | --preflight <install dir>"
PREFLIGHT=0
if [ "$1" = --preflight ]; then PREFLIGHT=1 DIR=$2 REV=HEAD; else DIR=$1 REV=$2; fi
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"
# ── Preflight (#1022) ──
# The repository enclosing the install's parent, probed with the caller's git
# environment cleared; any git error there means not tracked.
_PF=$(
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_OBJECT_DIRECTORY
cd -- "$DIR/.." 2>/dev/null || exit 0 # best-effort: no parent directory means no project tracks the install
pg() { git -c core.fsmonitor=false -c core.splitIndex=false "$@"; }
_top=$(pg rev-parse --show-toplevel 2>/dev/null) || exit 0
_rel="$(pg rev-parse --show-prefix 2>/dev/null)${DIR##*/}" || exit 0
_n=0
while IFS= read -r _l; do
case "$_l" in (''|'160000 '*) ;; (*) _n=$((_n + 1)) ;; esac
done <<EOF_PF
$(pg -C "$_top" --literal-pathspecs ls-files -s -- "$_rel" 2>/dev/null)
EOF_PF
[ "$_n" -gt 0 ] && printf '%s\n%s\n%s\n' "$_top" "$_rel" "$_n"
)
if [ -n "$_PF" ]; then
_top=$(printf '%s\n' "$_PF" | sed -n 1p) _rel=$(printf '%s\n' "$_PF" | sed -n 2p) _n=$(printf '%s\n' "$_PF" | sed -n 3p)
_ign=$(printf '%s' "$_rel" | sed 's/[][*?\\]/\\&/g')
refuse 6 tracked-by-project "$_top tracks $_n files of the gstack install at $_rel, so every checkout there puts old gstack files back (VERSION frozen, #1022); nothing was changed; fix: git -C $(ff_quote "$_top") --literal-pathspecs rm -r -q --cached -- $(ff_quote "$_rel"), add /$_ign/ to $(ff_quote "$_top/.gitignore") and commit both on every branch you check out (or vendor gstack as a git submodule), then git -C $(ff_quote "$DIR") stash push -m 'files reverted by the project' (keeps every edit) and re-run" 4000
fi
# A VERSION whose first line differs from HEAD's. Any unmerged file is left to
# the rescue below; the full update runs this inside the lock, where a VERSION
# already at the target's (an interrupted move) is finished, not held.
version_check() {
g cat-file -e HEAD:VERSION 2>/dev/null && [ -z "$(g ls-files -u 2>/dev/null)" ] || return 0
_have=$(head -1 VERSION 2>/dev/null | tr -d '\r') _want=$(g show HEAD:VERSION 2>/dev/null | head -1 | tr -d '\r')
[ "$_have" = "$_want" ] && return 0
[ -n "$1" ] && [ "$_have" = "$(g show "$1:VERSION" 2>/dev/null | head -1 | tr -d '\r')" ] && return 0
refuse 6 version-reverted "VERSION says ${_have:-nothing} but the checkout is at $_want: files changed outside git (an enclosing project's checkout puts old ones back, #1022, or a hand edit); nothing was changed; fix: git -C $(ff_quote "$DIR") stash push -m 'files reverted by the project' (keeps every edit), then re-run" 4000
}
[ "$PREFLIGHT" -eq 1 ] && { version_check; exit 0; }
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) \
@@ -128,6 +171,7 @@ case "$_beat" in ''|*[!0-9]*|0) _beat=300 ;; esac
HB_PID=$!
# ── Inside the lock ──
version_check "$TARGET"
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"
+40 -4
View File
@@ -9,11 +9,13 @@
import * as fs from 'fs';
import { DEFAULT_MAX_RERUNS, appendLedger, ledgerPath, renderResult, runFlaky, safeText, stepSummary, workflowEscape } from '../lib/flaky-run';
import { JunitError, readJunit, type JunitRunner } from '../lib/junit';
import { liveQuarantine, QuarantineError, quarantineMain, readRegistry } from '../lib/quarantine';
const USAGE = `Usage:
gstack-flaky run --junit <file.xml> [--runner playwright|bun|generic] [--max-reruns <n>]
[--rerun-scope command|test] [--ledger <file>] -- <test command...>
[--rerun-scope command|test] [--ledger <file>] [--base <ref>] -- <test command...>
gstack-flaky ids --junit <file> [--runner playwright|bun|generic]
gstack-flaky quarantine add|renew|release|check|report ... (gstack-flaky quarantine --help)
gstack-flaky --help
run: runs the command (an argv, no shell), reads the JUnit report it writes at
@@ -31,11 +33,18 @@ holds the first run's report afterwards; reruns' reports sit beside it as
<name>.rerun-<n>.xml. The command runs with PLAYWRIGHT_JUNIT_INCLUDE_RETRIES=1
so Playwright's own retries are reported.
Quarantine: tests listed in .gstack/quarantine.json (read from --base <ref> when
given, so PR CI passes its target branch) still run and never count as passes.
When every test an attempt failed is a live quarantined test, the whole command
may run again, up to 3 attempts; the run is green only on an attempt with the
runner's full green verdict, and a quarantined test that fails all 3 is a hard
break (red). A quarantined id missing from a run that included its file is red.
ids: prints the id of every test in a report (<file>::<project>::<title>).
Output: one FLAKY: line per test that failed at least once, FLAKY_RERUN:
scope=command (<why>) when the rerun repeated the whole command, then
FLAKY_RESULT: tests=<n> failed=<n> flaky=<n> reruns=<n> command_exit=<n> verdict=pass|fail ledger=<file|none>
FLAKY_RESULT: tests=<n> failed=<n> flaky=<n> quarantined=<n> reruns=<n> command_exit=<n> verdict=pass|fail ledger=<file|none>
Exit: 0 pass (flaky passes included), 1 a test failed, 2 usage,
3 no usable report, a crash or a killed runner (FLAKY_<CODE> and a fix: line on stderr).
`;
@@ -48,6 +57,10 @@ const FIX: Record<string, string> = {
FLAKY_COMMAND_KILLED: 'the runner was killed by a signal (a cancelled job, an OOM kill); nothing was rerun, rerun the job',
FLAKY_COMMAND_NOT_STARTED: 'check the command after -- (it runs without a shell; wrap it as bash -c \'<script> "$@"\' _ when it needs one)',
FLAKY_UNREPORTED_FAILURES: 'the runner counted failures its JUnit omits (a test file that failed to load, an error outside any test); fix that first, it is a real red',
FLAKY_QUARANTINE_INVALID: 'fix .gstack/quarantine.json: it must be {"schema":1,"entries":{...},"released":[...]} (gstack-flaky quarantine check names the problem)',
FLAKY_QUARANTINE_BASE: 'the base branch is not in this clone: fetch it before the run (in Actions, actions/checkout with fetch-depth: 0) and pass it as --base origin/<branch>',
FLAKY_QUARANTINE_REFUSED: 'read the reason above; docs/flaky.md#quarantine lists the rules',
FLAKY_QUARANTINE_USAGE: 'gstack-flaky quarantine --help',
};
function die(msg: string, code = 2): never {
@@ -60,8 +73,13 @@ function coded(e: JunitError): never {
process.exit(3);
}
function quarantineFailed(e: QuarantineError): never {
process.stderr.write(`gstack-flaky: ${e.message} (${e.code})\nfix: ${FIX[e.code] ?? 'see docs/flaky.md#quarantine'}\n`);
process.exit(e.exitCode);
}
function parse(argv: string[]) {
const o: { junit?: string; runner?: JunitRunner; maxReruns: number; rerunScope: 'command' | 'test'; ledger?: string; command: string[] } = { maxReruns: DEFAULT_MAX_RERUNS, rerunScope: 'command', command: [] };
const o: { junit?: string; runner?: JunitRunner; maxReruns: number; rerunScope: 'command' | 'test'; ledger?: string; base?: string; command: string[] } = { maxReruns: DEFAULT_MAX_RERUNS, rerunScope: 'command', command: [] };
for (let i = 0; i < argv.length; i++) {
const a = argv[i]!;
const val = () => { const v = argv[++i]; if (v === undefined) die(`${a} needs a value`); return v; };
@@ -80,6 +98,7 @@ function parse(argv: string[]) {
if (v !== 'command' && v !== 'test') die(`--rerun-scope must be command or test, got ${JSON.stringify(v)}`);
o.rerunScope = v;
} else if (a === '--ledger') o.ledger = val();
else if (a === '--base') o.base = val();
else die(`unknown option ${a}\n${USAGE}`);
}
return o;
@@ -88,6 +107,16 @@ function parse(argv: string[]) {
const [sub, ...rest] = process.argv.slice(2);
if (!sub || sub === '--help' || sub === '-h') { process.stdout.write(USAGE); process.exit(sub ? 0 : 2); }
if (sub === 'quarantine') {
try {
process.exit(quarantineMain(rest, { cwd: process.cwd(), out: s => process.stdout.write(s), now: new Date() }));
} catch (e) {
if (e instanceof QuarantineError) quarantineFailed(e);
if (e instanceof JunitError) coded(e);
throw e;
}
}
if (sub === 'ids') {
const o = parse(rest);
if (!o.junit) die('ids needs --junit <file>');
@@ -107,9 +136,15 @@ try { existing = fs.statSync(o.junit, { throwIfNoEntry: false }) && fs.readFileS
}
if (existing && existing.trim() && !/<testsuites?[\s>/]/.test(existing)) die(`--junit ${JSON.stringify(o.junit)} exists and is not a JUnit report; gstack-flaky deletes that path before every attempt, so it refuses to touch it`);
if (!o.command.length) die('run needs the test command after --');
let quarantined: Set<string>;
try {
const live = liveQuarantine(readRegistry(process.cwd(), o.base), new Date());
for (const n of live.notices) process.stdout.write(`${n}\n`);
quarantined = live.ids;
} catch (e) { if (e instanceof QuarantineError) quarantineFailed(e); throw e; }
let result;
try {
result = await runFlaky({ command: o.command, junit: o.junit, runner: o.runner, maxReruns: o.maxReruns, rerunScope: o.rerunScope });
result = await runFlaky({ command: o.command, junit: o.junit, runner: o.runner, maxReruns: o.maxReruns, rerunScope: o.rerunScope, quarantined });
} catch (e) { if (e instanceof JunitError) coded(e); throw e; }
const ledger = ledgerPath(o.ledger);
appendLedger(result.ledger, ledger, s => process.stderr.write(`gstack-flaky: ${s}\n`));
@@ -118,6 +153,7 @@ const summary = process.env.GITHUB_STEP_SUMMARY && stepSummary(result);
if (summary) { try { fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY!, summary); } catch { /* a summary is a convenience, never the verdict */ } }
if (process.env.GITHUB_ACTIONS === 'true') {
for (const f of result.outcomes.filter(x => x.status === 'flaky-pass')) process.stdout.write(`::warning title=flaky test::${workflowEscape(safeText(f.test.id))} passed on attempt ${f.attempts.length}\n`);
for (const f of result.outcomes.filter(x => x.status === 'hard-break')) process.stdout.write(`::error title=quarantined test hard break::${workflowEscape(safeText(f.test.id))} failed all ${f.attempts.length} attempts\n`);
}
if (result.code) {
process.stderr.write(`gstack-flaky: ${result.message} (${result.code})\nfix: ${FIX[result.code] ?? 'see docs/flaky.md'}\n`);
+27 -6
View File
@@ -19,9 +19,13 @@
* /review's review_binding and make /ship loop).
*
* Exit: 0 with or without hunks. 2 only when the base is unresolvable
* (`GATE_ERROR=no_base ref=<ref> fix=<git command>` as the only stdout line)
* or the tool itself failed (`GATE_ERROR=internal …`). Unmatched paths are
* counted, never an exit 2.
* (`GATE_ERROR=no_base ref=<ref> fix=<git command>` as the only stdout line;
* `ref=<remote>/HEAD target=<remote>/<branch>` when no base is given, that
* remote's HEAD names the checked-out branch and no other default resolves),
* when the base is HEAD or already contains it while the branch has commits
* its real base lacks (`GATE_ERROR=base_contains_head ref=<ref>`, the
* BASE_CONTAINS_HEAD line on stderr), or the tool itself failed
* (`GATE_ERROR=internal …`). Unmatched paths are counted, never an exit 2.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
@@ -31,6 +35,8 @@ import {
commitInfo, commitsInRange, defaultBase, diffCommit, diffWorkingTree, firstParent, headSha, mergeBase, noBaseFix, readClaudeMd, repoRoot,
resolveBase, resolvesToCommit, treePaths, untrackedAsAdded,
} from '../lib/gate-diff/git';
import { baseContainsHead, noDefaultBaseLine } from '../lib/default-base';
import { resultLine } from '../lib/result-codes';
import { renderDiagnostics, renderJsonl, renderJsonlBody, renderTable } from '../lib/gate-diff/render';
import { resolveStateRoot } from '../lib/state-root';
import { runScriptSync, wtreeFingerprint } from '../lib/script-invocation';
@@ -39,7 +45,8 @@ const BIN_DIR = path.dirname(Bun.fileURLToPath(import.meta.url));
const USAGE = `usage: gstack-gate-diff [base-ref] [--format table|jsonl] [--commit <sha>] [--help]
Lists every hunk in a test, CI, runner/lint-config, snapshot or golden path of the
candidate (merge-base of <base-ref> and HEAD -> working tree + untracked files),
candidate (merge-base of <base-ref> and HEAD -> working tree + untracked files;
a branch name resolves to origin/<base-ref>, the local ref only when no remote has it),
tags the gate edits it recognises (RH-1 RH-3 RH-4 RH-12 RH-13 RH-14 RH-15 RH-16),
and prints the read-level listing after one GATE_SUMMARY: line. --commit <sha>
scans that commit's first-parent delta instead of the working tree.
@@ -141,19 +148,33 @@ function main(argv: string[]): number {
candidate = sha.slice(0, 12);
specIsTestPath = specDirectoryHoldsSpecs(treePaths(root, sha));
} else {
const baseRef = parsed.base ?? defaultBase(root);
const found = parsed.base !== undefined ? { ref: parsed.base } : defaultBase(root);
if ('skipped' in found) {
const { remote, branch } = found.skipped;
console.error(`gstack-gate-diff: ${noDefaultBaseLine(found.skipped)}`);
process.stdout.write(`GATE_ERROR=no_base ref=${remote}/HEAD target=${remote}/${branch} fix=git remote set-head ${remote} <base>\n`);
return 2;
}
const baseRef = found.ref;
const head = headSha(root);
const base = resolveBase(baseRef, root, head ?? undefined);
if (!base || !head) {
process.stdout.write(`GATE_ERROR=no_base ref=${baseRef} fix=${noBaseFix(baseRef, root)}\n`);
return 2;
}
if (base.local) console.error(`gstack-gate-diff: ${base.local === 'unrelated' ? `local ${baseRef} shares no history with HEAD` : `no local branch ${baseRef}`}; base is ${base.ref}`);
if (base.local === 'fallback') console.error(`gstack-gate-diff: ${base.unrelatedRemote ? `${base.unrelatedRemote} shares no history with HEAD` : `no remote-tracking branch for ${baseRef}`}; base is the local ${baseRef}, which may lag its remote`);
else if (base.local) console.error(`gstack-gate-diff: ${{ unrelated: `local ${baseRef} shares no history with HEAD`, missing: `no local branch ${baseRef}`, behind: `local ${baseRef} is behind ${base.ref}`, ahead: `local ${baseRef} is ahead of ${base.ref}`, diverged: `local ${baseRef} has diverged from ${base.ref}` }[base.local]}; base is ${base.ref}`);
const mb = mergeBase(base.sha, head, root);
if (!mb) {
process.stdout.write(`GATE_ERROR=no_base ref=${baseRef} fix=${noBaseFix(baseRef, root)}\n`);
return 2;
}
const self = mb === head ? baseContainsHead(root, base.ref) : null;
if (self) {
console.error(resultLine('gstack-gate-diff', 'BASE_CONTAINS_HEAD', self));
process.stdout.write(`GATE_ERROR=base_contains_head ref=${baseRef}\n`);
return 2;
}
files = [...diffWorkingTree(mb, root), ...untrackedAsAdded(root, root)];
commits = commitsInRange(mb, head, root);
const fp = wtreeFingerprint({ cwd: root });
+29 -25
View File
@@ -45,6 +45,7 @@ import {
readGbrainVersion,
} from "../lib/gbrain-local-status";
import { gbrainConfigDir, isTransactionModePooler } from "../lib/gbrain-exec";
import { classifyGbrainMcpEntry, classifyGbrainMcpListLine, type GbrainMcpEntry } from "../lib/gbrain-mcp-locality";
import { resolveStateRoot } from "../lib/state-root";
const STATE_DIR = resolveStateRoot();
@@ -154,7 +155,19 @@ function detectBrainGit(): boolean {
// 1. `claude mcp get gbrain --json` — public CLI surface, structured output
// 2. `claude mcp list` text-grep — older claude versions without --json
// 3. `~/.claude.json` jq read — last resort if `claude` isn't on PATH
function detectMcpMode(): "local-stdio" | "remote-http" | "none" {
//
// #3539: an HTTP entry on a loopback host whose local gbrain config has an
// engine and no remote_mcp marker is a resident `gbrain serve --http` of this
// machine's own brain. It reports "local-stdio" (the local-engine mode every
// consumer already understands: memory ingest imports, sync-gbrain skips the
// remote trust-policy question) rather than a new value.
type McpMode = "local-stdio" | "remote-http";
function classify(entry: GbrainMcpEntry): McpMode | null {
const c = classifyGbrainMcpEntry(entry);
return c === "remote" ? "remote-http" : c === "local" ? "local-stdio" : null;
}
function detectMcpMode(): McpMode | "none" {
const claudeOnPath = tryExec("sh", ["-c", "command -v claude"], 1_000) !== null;
if (claudeOnPath) {
// Tier 1: `claude mcp get gbrain --json`
@@ -167,11 +180,8 @@ function detectMcpMode(): "local-stdio" | "remote-http" | "none" {
command?: string;
url?: string;
};
const mtype = parsed.type || parsed.transport || "";
if (mtype === "http" || mtype === "sse") return "remote-http";
if (mtype === "stdio") return "local-stdio";
if (parsed.url) return "remote-http";
if (parsed.command) return "local-stdio";
const c = classify(parsed);
if (c) return c;
} catch {
// fall through
}
@@ -179,34 +189,28 @@ function detectMcpMode(): "local-stdio" | "remote-http" | "none" {
// Tier 2: `claude mcp list` text-grep. Name-pattern generalized (#2051):
// a gbrain server registered as e.g. "gbrain-remote" or "gbrain_work"
// still counts. Anchored to the gbrain token so unrelated servers can't
// false-positive.
// false-positive. The exact `gbrain:` line wins over a variant, the same
// order as tier 3 (and memory-ingest, which reads mcpServers.gbrain):
// {gbrain-a: loopback, gbrain: remote} is remote-http.
const list = tryExec("claude", ["mcp", "list"], 3_000);
if (list) {
const line = list.split("\n").find((l) => /^gbrain([-_][\w-]*)?:/.test(l));
const lines = list.split("\n");
const line =
lines.find((l) => l.startsWith("gbrain:")) ?? lines.find((l) => /^gbrain[-_][\w-]*:/.test(l));
if (line) {
if (/\b(http|HTTP)\b/.test(line)) return "remote-http";
return "local-stdio";
// The list line carries the URL for HTTP and SSE servers
// ("gbrain: http://127.0.0.1:3131/mcp (HTTP) - ✓ Connected"), so the
// loopback rule (#3539) applies here too. The whole URL token is
// classified, never cut at ")"; an HTTP or SSE line without a usable
// URL keeps the old remote-http answer.
return classifyGbrainMcpListLine(line) === "local" ? "local-stdio" : "remote-http";
}
}
}
// Tier 3: read ~/.claude.json directly
interface McpServerEntry {
type?: string;
transport?: string;
command?: string;
url?: string;
}
const cj = tryReadJSON(CLAUDE_JSON) as
| { mcpServers?: Record<string, McpServerEntry> }
| { mcpServers?: Record<string, GbrainMcpEntry> }
| null;
const classify = (entry: McpServerEntry): "local-stdio" | "remote-http" | null => {
const mtype = entry.type || entry.transport || "";
if (mtype === "url" || mtype === "http" || mtype === "sse") return "remote-http";
if (mtype === "stdio") return "local-stdio";
if (entry.url) return "remote-http";
if (entry.command) return "local-stdio";
return null;
};
const servers = cj?.mcpServers || {};
const exact = servers["gbrain"];
if (exact) {
+167 -42
View File
@@ -10,7 +10,7 @@
*/
import { existsSync, readdirSync, statSync, readFileSync, openSync, readSync, closeSync } from "fs";
import { join, basename, dirname, resolve } from "path";
import { join, basename, dirname, resolve, isAbsolute } from "path";
import { spawnSync } from "child_process";
import { homedir } from "os";
import { canonicalRemote } from "../lib/remote-identity";
@@ -20,8 +20,11 @@ import { canonicalRemote } from "../lib/remote-identity";
type CodexOriginator = "cli" | "desktop" | "exec" | "claude_code" | "other";
type CodexOriginatorCounts = Record<CodexOriginator, number>;
type ToolId = "claude_code" | "codex" | "gemini" | "kimi_code" | "omp";
type ToolCounts = Record<ToolId, number>;
interface Session {
tool: "claude_code" | "codex" | "gemini";
tool: ToolId;
cwd: string;
codexOriginator?: CodexOriginator;
}
@@ -30,19 +33,24 @@ interface Repo {
name: string;
remote: string;
paths: string[];
sessions: { claude_code: number; codex: number; gemini: number };
sessions: ToolCounts;
codex_originators: CodexOriginatorCounts;
}
interface ToolSummary {
label: string;
total_sessions: number;
repos: number;
originators?: CodexOriginatorCounts;
/** Sessions counted without a recorded cwd: they count in total_sessions, like sessions outside any repo, but belong to no repo. */
unattributed?: true;
}
interface DiscoveryResult {
window: string;
start_date: string;
repos: Repo[];
tools: {
claude_code: { total_sessions: number; repos: number };
codex: { total_sessions: number; repos: number; originators: CodexOriginatorCounts };
gemini: { total_sessions: number; repos: number };
};
tools: Record<ToolId | "agy", ToolSummary>;
total_sessions: number;
total_repos: number;
}
@@ -521,6 +529,128 @@ function scanGemini(since: Date): Session[] {
return sessions;
}
/**
* Kimi Code: ~/.kimi-code/session_index.jsonl lists { sessionId, sessionDir, workDir };
* the session's state.json carries updatedAt (epoch ms). workDir is the absolute cwd.
*/
function scanKimiCode(since: Date): Session[] {
const root = join(homedir(), ".kimi-code");
let index: string;
try {
index = readFileSync(join(root, "session_index.jsonl"), { encoding: "utf-8" });
} catch {
return [];
}
const sessions: Session[] = [];
for (const line of index.split(/\r?\n/)) {
if (!line.trim()) continue;
let entry: any;
try {
entry = JSON.parse(line);
} catch {
continue;
}
if (typeof entry?.sessionDir !== "string" || !entry.sessionDir) continue;
const dir = isAbsolute(entry.sessionDir) ? entry.sessionDir : join(root, entry.sessionDir);
let state: any = {};
try {
state = JSON.parse(readFileSync(join(dir, "state.json"), { encoding: "utf-8" }));
} catch {}
let updated = typeof state?.updatedAt === "number" ? state.updatedAt : NaN;
if (Number.isNaN(updated)) {
try {
updated = statSync(dir).mtimeMs;
} catch {
continue;
}
}
if (updated < since.getTime()) continue;
const cwd = typeof entry.workDir === "string" && entry.workDir ? entry.workDir : typeof state?.cwd === "string" ? state.cwd : null;
if (cwd) sessions.push({ tool: "kimi_code", cwd });
}
return sessions;
}
/**
* OMP: one <timestamp>_<uuid>.jsonl per session under ~/.omp/agent/sessions/<workspace>/.
* The workspace directory name is a lossy encoding of the path, so the cwd comes from
* the session file itself. A session counts when its file was written inside the
* window, like the other scanners; ~/.omp/logs holds per-launch logs and is never read.
*/
function scanOmp(since: Date, stats: CwdScanStats): Session[] {
const sessionsDir = join(homedir(), ".omp", "agent", "sessions");
let workspaces: string[];
try {
workspaces = readdirSync(sessionsDir);
} catch {
return [];
}
const sessions: Session[] = [];
for (const workspace of workspaces) {
const dir = join(sessionsDir, workspace);
let files: string[];
try {
files = readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
} catch {
continue;
}
for (const file of files) {
const path = join(dir, file);
try {
if (statSync(path).mtime < since) continue;
} catch {
continue;
}
const cwd = extractCwdFromJsonl(path, stats);
if (cwd) sessions.push({ tool: "omp", cwd });
}
}
return sessions;
}
/**
* Antigravity CLI (agy) keeps each conversation as SQLite under
* ~/.gemini/antigravity-cli/conversations/*.db, with the cwd inside an undocumented
* blob (#1977). Sessions are counted by the later of the .db and its -wal file's
* mtime and are not attributed to a repo.
*/
function countAntigravity(since: Date): number {
const dir = join(homedir(), ".gemini", "antigravity-cli", "conversations");
let files: string[];
try {
files = readdirSync(dir).filter((f) => f.endsWith(".db"));
} catch {
return 0;
}
let count = 0;
for (const file of files) {
let latest = 0;
for (const path of [join(dir, file), join(dir, `${file}-wal`)]) {
try {
latest = Math.max(latest, statSync(path).mtimeMs);
} catch {}
}
if (latest >= since.getTime()) count++;
}
return count;
}
const SCANNERS: ReadonlyArray<{ id: ToolId; label: string; scan: (since: Date, stats: CwdScanStats) => Session[] }> = [
{ id: "claude_code", label: "CC", scan: scanClaudeCode },
{ id: "codex", label: "Codex", scan: (since) => scanCodex(since) },
{ id: "gemini", label: "Gemini", scan: (since) => scanGemini(since) },
{ id: "kimi_code", label: "Kimi Code", scan: (since) => scanKimiCode(since) },
{ id: "omp", label: "OMP", scan: scanOmp },
];
function emptyCounts(): ToolCounts {
return Object.fromEntries(SCANNERS.map((s) => [s.id, 0])) as ToolCounts;
}
function countTotal(counts: ToolCounts): number {
return SCANNERS.reduce((n, s) => n + counts[s.id], 0);
}
// ── Deduplication ──────────────────────────────────────────────────────────
async function resolveAndDeduplicate(sessions: Session[]): Promise<Repo[]> {
@@ -555,7 +685,7 @@ async function resolveAndDeduplicate(sessions: Session[]): Promise<Repo[]> {
}
}
const sessionCounts = { claude_code: 0, codex: 0, gemini: 0 };
const sessionCounts = emptyCounts();
for (const s of data.sessions) {
sessionCounts[s.tool]++;
}
@@ -570,11 +700,7 @@ async function resolveAndDeduplicate(sessions: Session[]): Promise<Repo[]> {
}
// Sort by total sessions descending
repos.sort(
(a, b) =>
b.sessions.claude_code + b.sessions.codex + b.sessions.gemini -
(a.sessions.claude_code + a.sessions.codex + a.sessions.gemini)
);
repos.sort((a, b) => countTotal(b.sessions) - countTotal(a.sessions));
return repos;
}
@@ -588,21 +714,19 @@ async function main() {
// Run all scanners
const cwdStats: CwdScanStats = { oversizedLines: 0 };
const ccSessions = scanClaudeCode(sinceDate, cwdStats);
const codexSessions = scanCodex(sinceDate);
const geminiSessions = scanGemini(sinceDate);
const allSessions = [...ccSessions, ...codexSessions, ...geminiSessions];
const byTool = SCANNERS.map((s) => ({ ...s, sessions: s.scan(sinceDate, cwdStats) }));
const agySessions = countAntigravity(sinceDate);
const allSessions = byTool.flatMap((t) => t.sessions);
const codexSessions = byTool.find((t) => t.id === "codex")!.sessions;
const toolCounts = byTool.map((t) => `${t.label}: ${t.sessions.length}`).join(", ");
// Summary to stderr
console.error(
`Discovered: ${ccSessions.length} CC sessions, ${codexSessions.length} Codex sessions, ${geminiSessions.length} Gemini sessions`
);
console.error(`Discovered: ${toolCounts}; Antigravity: ${agySessions} (repo unattributed)`);
if (cwdStats.oversizedLines > 0) {
console.error(
`Skipped ${cwdStats.oversizedLines} Claude Code JSONL line(s) over 1 MiB while reading session cwds; ` +
`a session whose cwd appears only after such lines counts with its project directory's other sessions.`,
`Skipped ${cwdStats.oversizedLines} session JSONL line(s) over 1 MiB while reading session cwds; ` +
`a Claude Code session whose cwd appears only after such lines counts with its project directory's other sessions.`,
);
}
@@ -611,23 +735,25 @@ async function main() {
console.error(`→ ${repos.length} unique repos`);
// Count per-tool repo counts
const ccRepos = new Set(repos.filter((r) => r.sessions.claude_code > 0).map((r) => r.remote)).size;
const codexRepos = new Set(repos.filter((r) => r.sessions.codex > 0).map((r) => r.remote)).size;
const geminiRepos = new Set(repos.filter((r) => r.sessions.gemini > 0).map((r) => r.remote)).size;
const codexOriginators = countCodexOriginators(codexSessions);
const tools = Object.fromEntries(
byTool.map((t) => [
t.id,
{
label: t.label,
total_sessions: t.sessions.length,
repos: repos.filter((r) => r.sessions[t.id] > 0).length,
...(t.id === "codex" ? { originators: codexOriginators } : {}),
},
]),
) as Record<ToolId, ToolSummary>;
const result: DiscoveryResult = {
window: since,
start_date: startDate,
repos,
tools: {
claude_code: { total_sessions: ccSessions.length, repos: ccRepos },
codex: { total_sessions: codexSessions.length, repos: codexRepos, originators: codexOriginators },
gemini: { total_sessions: geminiSessions.length, repos: geminiRepos },
},
total_sessions: allSessions.length,
tools: { ...tools, agy: { label: "Antigravity", total_sessions: agySessions, repos: 0, unattributed: true } },
total_sessions: allSessions.length + agySessions,
total_repos: repos.length,
};
@@ -636,17 +762,16 @@ async function main() {
} else {
// Summary format
console.log(`Window: ${since} (since ${startDate})`);
console.log(`Sessions: ${allSessions.length} total (CC: ${ccSessions.length}, Codex: ${codexSessions.length}, Gemini: ${geminiSessions.length})`);
console.log(`Sessions: ${allSessions.length + agySessions} total (${toolCounts}, Antigravity: ${agySessions})`);
if (codexSessions.length > 0) console.log(` Codex originators: ${formatCodexOriginators(codexOriginators)}`);
if (agySessions > 0) console.log(` Antigravity sessions are not attributed to a repo`);
console.log(`Repos: ${repos.length} unique`);
console.log("");
for (const repo of repos) {
const total = repo.sessions.claude_code + repo.sessions.codex + repo.sessions.gemini;
const tools = [];
if (repo.sessions.claude_code > 0) tools.push(`CC:${repo.sessions.claude_code}`);
if (repo.sessions.codex > 0) tools.push(`Codex:${repo.sessions.codex} (${formatCodexOriginators(repo.codex_originators)})`);
if (repo.sessions.gemini > 0) tools.push(`Gemini:${repo.sessions.gemini}`);
console.log(` ${repo.name} (${total} sessions) — ${tools.join(", ")}`);
const parts = byTool
.filter((t) => repo.sessions[t.id] > 0)
.map((t) => (t.id === "codex" ? `Codex:${repo.sessions.codex} (${formatCodexOriginators(repo.codex_originators)})` : `${t.label}:${repo.sessions[t.id]}`));
console.log(` ${repo.name} (${countTotal(repo.sessions)} sessions) — ${parts.join(", ")}`);
console.log(` Remote: ${repo.remote}`);
console.log(` Paths: ${repo.paths.join(", ")}`);
}
+24 -8
View File
@@ -8,10 +8,16 @@
*
* Usage:
* gstack-harness detect [--json]
* gstack-harness register <target> [--skills router|hybrid|all] [--skills-dir <dir>] [--context <file>] [--print] [--yes] [--no-registry]
* gstack-harness register <target> [--skills router|hybrid|all] [--skills-dir <dir>] [--context <file>] [--start-hook|--no-start-hook] [--print] [--yes] [--no-registry]
* --context <file> writes gstack's marked routing block into that file only
* (opt-in: setup, upgrades and refresh never add one, and a block you
* delete stays deleted); it prints the file and its scope before writing
* --start-hook (OpenClaw, Hermes; opt-in) adds the gstack-start plugin, which
* puts a one-line note on every user turn to run a gstack skill's start
* command, and turns it on through the harness's own commands (this edits
* openclaw.json or Hermes's config.yaml); --no-start-hook takes it out. The
* choice is saved; GSTACK_HARNESS_START_HOOK=1|0 or gstack-config
* harness_start_hook on|off make it too. Exit 4: skills registered, hook not active
* gstack-harness unregister <target|registration id> [--remote]
* gstack-harness status [--json] [--no-check]
* gstack-harness refresh
@@ -46,11 +52,18 @@ function receiptLines(r: RegisterResult): string[] {
'gstack-harness: ' + t.displayName + ': ' + r.skills + ' gstack skills ' + how + ' at ' + r.dest + ' (' + r.status + (r.written ? ', ' + r.written + ' files written' : '') + ')',
...(r.router ? [' router: ' + r.router + ' (register with --skills all to list every skill natively)'] : []),
...r.kept.map(k => ' kept your edit: ' + k),
...(r.hook ? [' start hook: ' + r.hook.state + (r.hook.state === 'active' ? ' (new sessions get the note; a running gateway picks it up when it reloads)' : '') + (r.hook.detail ? ' - ' + r.hook.detail : '') + (r.hook.fix ? '; ' + r.hook.fix : '')] : []),
' ledger: ' + r.ledger,
' remove: ' + self + ' unregister ' + r.id,
];
}
const receipt = (r: RegisterResult) => console.log(receiptLines(r).join('\n'));
/** A start hook this run turned on, or was asked for, that did not end up active: exit 4 after the receipt. */
function hookFailed(r: RegisterResult): number {
if (!r.hook?.required || r.hook.state === 'active') return 0;
console.error(new HarnessError('HARNESS_HOOK_INACTIVE', TARGETS[r.target].displayName + ' start hook is ' + r.hook.state + (r.hook.detail ? ': ' + r.hook.detail : '') + (r.hook.fix ? '. Turn it on: ' + r.hook.fix : '')).report());
return 4;
}
function usage(code: number): never {
const text = fs.readFileSync(process.argv[1], 'utf8').split('\n').slice(1).filter(l => l.startsWith(' *')).map(l => l.slice(3)).join('\n');
@@ -73,6 +86,7 @@ async function main(): Promise<number> {
const mode = flag('--skills') as SkillsMode | undefined;
const skillsDir = flag('--skills-dir'); const context = flag('--context');
const print = bool('--print'); const yes = bool('--yes'); const noRegistry = bool('--no-registry');
const startHook = bool('--start-hook') ? true : bool('--no-start-hook') ? false : undefined;
const t = getTarget(argv.shift() ?? '');
if (print) { console.log(printPayload(checkout)); return 0; }
if (t.name === 'qm') {
@@ -88,10 +102,10 @@ async function main(): Promise<number> {
return 3;
}
if (context) (json ? console.error : console.log)('gstack-harness: context block: ' + path.resolve(context) + ' (' + contextScope(t, context, env, home) + ' scope)');
const r = register({ ...base, target: t, mode, skillsDir, context, registry: !noRegistry });
const r = register({ ...base, target: t, mode, skillsDir, context, registry: !noRegistry, startHook });
if (json) console.log(JSON.stringify({ ...r, receipt: receiptLines(r).map(l => l.trim()).join('; ') }));
else receipt(r);
return 0;
return hookFailed(r);
}
case 'unregister': {
const remote = bool('--remote');
@@ -113,23 +127,25 @@ async function main(): Promise<number> {
const extra = (['muse', 'grok'] as TargetName[]).filter(n => TARGETS[n].present(env, home)).map(n => ({ target: n, ...(check ? harnessSees(TARGETS[n], '', env, ['review', 'gstack-review'], true) : { visible: 'unknown' as const }) }));
if (json) { console.log(JSON.stringify({ registrations: rows, detected: extra })); return 0; }
if (!rows.length && !extra.length) console.log('gstack-harness: no harness registrations on this machine');
for (const r of rows) console.log(r.target + '\t' + r.mode + '\t' + r.dest + '\t' + r.files + ' files\t' + (r.stale ? 'stale (' + r.version + '; refresh: ' + self + ' refresh)' : r.version) + '\tvisible to harness: ' + r.visible + (r.checked ? ' (' + r.checked + ')' : '') + (r.missing?.length ? ' missing: ' + r.missing.slice(0, 5).join(', ') : '') + '\t' + r.id);
for (const r of rows) console.log(r.target + '\t' + r.mode + '\t' + r.dest + '\t' + r.files + ' files\t' + (r.stale ? 'stale (' + r.version + '; refresh: ' + self + ' refresh)' : r.version) + '\tvisible to harness: ' + r.visible + (r.checked ? ' (' + r.checked + ')' : '') + (r.missing?.length ? ' missing: ' + r.missing.slice(0, 5).join(', ') : '') + '\t' + r.id
+ (r.hook ? '\n start hook: ' + r.hook.state + (r.hook.detail ? ' - ' + r.hook.detail : '') + (r.hook.state !== 'active' && r.hook.fix ? '; ' + r.hook.fix : '') : ''));
for (const x of extra) console.log(x.target + '\tdetect\t~/.claude/skills\t' + (x.visible === 'yes' ? 'discovered (Claude-rendered, unverified)' : 'unverified') + (x.checked ? ' (' + x.checked + ')' : ''));
return 0;
}
case 'refresh': {
for (const r of refresh(base)) if (r.status !== 'unchanged') receipt(r);
return 0;
let code = 0;
for (const r of refresh(base)) { if (r.status !== 'unchanged' || r.hook?.required || r.hook?.retried || r.hook?.detail) receipt(r); code ||= hookFailed(r); }
return code;
}
case 'auto': {
const d = detect(env, home);
let failed = 0;
const done = new Set<string>();
for (const n of d.inside.filter(n => TARGETS[n].mechanism === 'skill-folder')) {
try { const r = register({ ...base, target: TARGETS[n], registry: true }); done.add(r.id); receipt(r); }
try { const r = register({ ...base, target: TARGETS[n], registry: true }); done.add(r.id); receipt(r); hookFailed(r); }
catch (e) { if (e instanceof HarnessError) { console.error(e.report()); failed = 1; } else throw e; }
}
try { for (const r of refresh(base, done)) if (r.status !== 'unchanged') receipt(r); }
try { for (const r of refresh(base, done)) { if (r.status !== 'unchanged' || r.hook?.required || r.hook?.retried || r.hook?.detail) receipt(r); hookFailed(r); } }
catch (e) { if (e instanceof HarnessError) { console.error(e.report()); failed = 1; } else throw e; }
for (const n of d.present.filter(n => !d.inside.includes(n) && TARGETS[n].mechanism === 'skill-folder' && !listLedgers(stateRoot).some(l => l.ledger.meta.target === n))) {
console.log('gstack-harness: ' + TARGETS[n].displayName + ' is installed here; to give it gstack skills: ' + self + ' register ' + n);
+57 -4
View File
@@ -20,12 +20,25 @@
# (ledgers without the row: when only whitespace is left). Folders gstack
# created are removed only when empty. A QM pack is never touched here: detaching it from
# the org is `gstack-harness unregister qm --remote`.
#
# A registration with the start hook on (meta start_hook on) owns a second root, the
# gstack-start plugin folder in the harness profile (meta hook_root). Before its files go,
# gstack_harness_hook_release turns the plugin off through the harness's own CLI
# (lib/harness-start-hook.ts), the same step `register --no-start-hook` runs.
_gstack_harness_sha() {
if command -v sha256sum >/dev/null 2>&1; then sha256sum ${1:+"$1"} | cut -d' ' -f1
else shasum -a 256 ${1:+"$1"} | cut -d' ' -f1; fi
}
# True when $1 is below $2 and nothing between them is a symlink ($2 itself may be one).
_gstack_harness_below() {
local p="$1" top="$2"
[ -n "$top" ] || return 1
case "$p" in "$top"/*) ;; *) return 1 ;; esac
while [ "$p" != "$top" ]; do [ -L "$p" ] && return 1; p="$(dirname "$p")"; done
}
# True when $1 is below $2 and no path from $2 down to $1 is a symlink.
_gstack_harness_owned() {
local p="$1" dest="$2"
@@ -57,16 +70,53 @@ _gstack_harness_cut_block() {
rm -f "$tmp"
}
# Turns a registration's start hook off in its harness, from the ledger: OpenClaw drops
# plugins.entries.gstack-start; Hermes removes the plugin and its config entries when its
# folder holds exactly the files gstack wrote, unedited, and is reached without a symlink
# below the profile; otherwise it only disables it so an edit or a kept copy survives. The harness runs with PATH, HOME and the profile only. Without the harness on
# PATH it prints the command to run instead.
gstack_harness_hook_release() {
local ledger="$1" kind a b target="" on="" profile="" root="" edited=0 to="" sub cmd n=0 safe=1 total
[ -f "$ledger" ] || return 0
while IFS=$'\t' read -r kind a b; do
case "$kind" in
meta) case "$a" in target) target="$b" ;; start_hook) on="$b" ;; hook_profile) profile="$b" ;; hook_root) root="$b" ;; esac ;;
file) if [ -n "$root" ]; then case "$b" in "$root"/*) if [ -f "$b" ]; then n=$((n + 1)); [ "$(_gstack_harness_sha "$b")" != "$a" ] && edited=1; fi ;; esac; fi ;;
esac
done < "$ledger"
{ [ "$on" = on ] && [ -n "$profile" ] && [ -n "$root" ]; } || return 0
if [ -d "$root" ]; then
_gstack_harness_below "$root" "$profile" || { safe=0; edited=1; }
total="$(find "$root" -type f ! -path "$root/__pycache__/*" 2>/dev/null | wc -l | tr -d ' ')"
[ "${total:-0}" -gt "$n" ] && edited=1
fi
command -v timeout >/dev/null 2>&1 && to="timeout 60"
case "$target" in
openclaw) cmd="config unset plugins.entries.gstack-start"
if command -v openclaw >/dev/null 2>&1 && env -i PATH="$PATH" HOME="$HOME" OPENCLAW_STATE_DIR="$profile" $to openclaw $cmd </dev/null >/dev/null 2>&1; then :
else echo " start hook: turn it off in OpenClaw yourself: OPENCLAW_STATE_DIR=\"$profile\" openclaw $cmd"; fi ;;
hermes) sub=remove; [ "$edited" = 1 ] && sub=disable
if command -v hermes >/dev/null 2>&1 && env -i PATH="$PATH" HOME="$HOME" HERMES_HOME="$profile" $to hermes plugins "$sub" gstack-start </dev/null >/dev/null 2>&1; then :
else echo " start hook: turn it off in Hermes yourself: HERMES_HOME=\"$profile\" hermes plugins disable gstack-start"; fi ;;
esac
if [ "$safe" = 1 ] && [ -d "$root/__pycache__" ] && _gstack_harness_owned "$root/__pycache__" "$root"; then
rm -f "$root"/__pycache__/*.pyc
rmdir "$root/__pycache__" 2>/dev/null
fi
return 0
}
gstack_harness_unregister_ledger() {
local ledger="$1" stamp kind a b target="" dest="" pack="" pad="" padpath=""
local ledger="$1" stamp kind a b target="" dest="" pack="" pad="" padpath="" hroot="" hprof=""
: "${GSTACK_STATE_ROOT:?gstack-harness-ledger: GSTACK_STATE_ROOT is not set}"
[ -f "$ledger" ] || return 0
stamp="$(date -u +%Y-%m-%dT%H-%M-%SZ)"
gstack_harness_hook_release "$ledger"
while IFS=$'\t' read -r kind a b; do
case "$kind" in
meta) case "$a" in target) target="$b" ;; dest) dest="$b" ;; qm_pack) pack="$b" ;; esac ;;
meta) case "$a" in target) target="$b" ;; dest) dest="$b" ;; qm_pack) pack="$b" ;; hook_root) hroot="$b" ;; hook_profile) hprof="$b" ;; esac ;;
file)
{ [ -f "$b" ] && _gstack_harness_owned "$b" "$dest"; } || continue
{ [ -f "$b" ] && { _gstack_harness_owned "$b" "$dest" || { _gstack_harness_owned "$b" "$hroot" && _gstack_harness_below "$hroot" "$hprof"; }; }; } || continue
if [ "$(_gstack_harness_sha "$b")" = "$a" ]; then rm -f "$b"
else mv "$b" "$b.edited-$stamp" && echo " kept your edit: $b.edited-$stamp"; fi ;;
blockpad) pad="$a"; padpath="$b" ;;
@@ -75,7 +125,10 @@ gstack_harness_unregister_ledger() {
done < "$ledger"
while IFS=$'\t' read -r kind a b; do
[ "$kind" = dir ] && [ -d "$b" ] && [ ! -L "$b" ] || continue
case "$b" in "$dest"/*) _gstack_harness_owned "$b" "$dest" || continue ;; esac
case "$b" in
"$dest"/*) _gstack_harness_owned "$b" "$dest" || continue ;;
*) if [ -n "$hprof" ]; then case "$b" in "$hprof"/*) _gstack_harness_below "$b" "$hprof" || continue ;; esac; fi ;;
esac
rmdir "$b" 2>/dev/null
done < "$ledger"
case "$target" in
+1 -1
View File
@@ -24,7 +24,7 @@
# host:render-dir for every host whose render lives in its own checkout dir.
# Claude renders into the tracked tree. test/host-renders.test.ts keeps this
# equal to hostSubdir in hosts/*.ts.
GSTACK_HOST_RENDER_DIRS="codex:.agents kiro:.kiro factory:.factory opencode:.opencode cursor:.cursor copilot:.copilot agy:.agy slate:.slate openclaw:.openclaw hermes:.hermes gbrain:.gbrain"
GSTACK_HOST_RENDER_DIRS="codex:.agents kiro:.kiro factory:.factory opencode:.opencode cursor:.cursor copilot:.copilot agy:.agy gemini:.gemini-cli slate:.slate openclaw:.openclaw hermes:.hermes gbrain:.gbrain"
gstack_render_hosts_file() { printf '%s/.gstack-installed-hosts\n' "$1"; }
+2 -1
View File
@@ -184,7 +184,7 @@ gstack_install_render_for() {
gstack_host_tier() {
case "$1" in
claude) echo full ;;
codex|kiro|factory|opencode|cursor|copilot|agy|openclaw|hermes) echo experimental ;;
codex|kiro|factory|opencode|cursor|copilot|agy|gemini|openclaw|hermes) echo experimental ;;
slate|gbrain) echo instruction-only ;;
*) echo unknown ;;
esac
@@ -256,6 +256,7 @@ gstack_install_known_roots() {
printf 'cursor\tglobal\t%s\t%s\n' "$HOME/.cursor/skills" "$HOME/.cursor/skills/gstack"
printf 'copilot\tglobal\t%s\t%s\n' "$HOME/.copilot/skills" "$HOME/.copilot/skills/gstack"
printf 'agy\tglobal\t%s\t%s\n' "$HOME/.gemini/antigravity-cli/skills" "$HOME/.gemini/antigravity-cli/skills/gstack"
printf 'gemini\tglobal\t%s\t%s\n' "$HOME/.gemini/skills" "$HOME/.gemini/skills/gstack"
proj="$(git rev-parse --show-toplevel 2>/dev/null || true)"
if [ -n "$proj" ] && [ "$proj" != "$HOME" ]; then
printf 'claude\tproject\t%s\t%s\n' "$proj/.claude/skills" "$proj/.claude/skills/gstack"
+5 -1
View File
@@ -91,8 +91,12 @@ for path in paths:
sys.exit(1)
# Timestamp-ordered entries first (group 0), then hash-ordered (group 1).
# UTF-8 bytes and LF on every platform: print() encodes with the stdout code
# page and writes CRLF on Windows (#3659).
out = sys.stdout.buffer
for line, _ in sorted(seen.items(), key=lambda item: item[1]):
print(line)
out.write(line.encode('utf-8') + b'\n')
out.flush()
PYEOF
_PYEXIT=$?
+26 -11
View File
@@ -69,6 +69,7 @@ import {
withErrorContext,
} from "../lib/gstack-memory-helpers";
import { execGbrainText, gbrainConfigDir, spawnGbrain, spawnGbrainAsync } from "../lib/gbrain-exec";
import { classifyGbrainMcpEntry, type GbrainMcpEntry } from "../lib/gbrain-mcp-locality";
import { constrainSourceId, parseSourcesList } from "../lib/gbrain-sources";
import {
BISECT_AFTER_REFUSALS,
@@ -427,9 +428,19 @@ function fileSha256(path: string): string {
}
}
/**
* #3539: a page staged for a remote brain's pull (`staged` / `remote-http`)
* was never imported here. Once the brain is local (a loopback serve, or the
* entry switched back to stdio), that page has not landed, so it must be
* prepared again instead of counting as unchanged.
*/
function stagedForRemotePull(entry: StateEntry | undefined): boolean {
return !!entry && entry.status === "staged" && entry.source_id === "remote-http" && !isRemoteHttpMcpMode();
}
function fileChangedSinceState(path: string, state: IngestState, verifyHash = false): boolean {
const entry = state.sessions[path];
if (isRetryable(entry)) return true;
if (isRetryable(entry) || stagedForRemotePull(entry)) return true;
try {
const st = statSync(path);
const mtimeNs = Math.floor(st.mtimeMs * 1e6);
@@ -1418,7 +1429,7 @@ async function probeMode(args: CliArgs): Promise<ProbeReport> {
totalBytes += size;
const entry = state.sessions[path];
if (!entry) newCount++;
if (!entry || stagedForRemotePull(entry)) newCount++;
else if (fileChangedSinceState(path, state, args.scanSecrets)) updatedCount++;
else unchangedCount++;
}
@@ -1800,23 +1811,27 @@ function makePersistentTranscriptDir(): string {
*
* Reads ~/.claude.json directly (same fallback chain as gstack-gbrain-detect
* Tier 3). Cheap: one fs read, no fork-exec.
*
* #3539: an HTTP entry on a loopback host that reaches this machine's own
* gbrain engine (a resident `gbrain serve --http`) is not remote; see
* lib/gbrain-mcp-locality.ts. Memoized: the change check consults it per
* state entry, and ~/.claude.json can be megabytes.
*/
let remoteHttpMcpMode: boolean | undefined;
function isRemoteHttpMcpMode(): boolean {
remoteHttpMcpMode ??= readRemoteHttpMcpMode();
return remoteHttpMcpMode;
}
function readRemoteHttpMcpMode(): boolean {
const home = process.env.HOME || homedir();
const claudeJsonPath = join(home, ".claude.json");
if (!existsSync(claudeJsonPath)) return false;
try {
const parsed = JSON.parse(readFileSync(claudeJsonPath, "utf-8")) as {
mcpServers?: {
gbrain?: { type?: string; transport?: string; url?: string };
};
mcpServers?: { gbrain?: GbrainMcpEntry };
};
const entry = parsed.mcpServers?.gbrain;
if (!entry) return false;
const mtype = entry.type || entry.transport || "";
if (mtype === "url" || mtype === "http" || mtype === "sse") return true;
if (entry.url) return true;
return false;
return classifyGbrainMcpEntry(parsed.mcpServers?.gbrain) === "remote";
} catch {
return false;
}
+22 -2
View File
@@ -4,12 +4,32 @@ import * as path from 'node:path';
import { atomicWriteSync } from '../lib/fs-atomic.ts';
import { assessOfficeHoursReviews, extractOfficeHoursReviewBlock, loadOfficeHoursReviews, officeHoursDesignChanges, officeHoursMaxRounds, officeHoursSnapshotChanges, officeHoursVerdictReceipt, parseOfficeHoursVerdictReceipt, renderOfficeHoursReview, renderOfficeHoursReviewerPrompt, replaceOfficeHoursReviewBlock, verifyOfficeHoursCitations, verifyOfficeHoursVerdictReceipt } from '../lib/office-hours-review.ts';
import { parseSpecFixes, verifySpecFixesAtCap } from '../lib/spec-review-cap.ts';
import { repoRootOf, resolveProbe, runProbe } from '../lib/office-hours-probe.ts';
import { resultLine } from '../lib/result-codes.ts';
const shellQuote = value => `'${value.replaceAll("'", `'\\''`)}'`;
// The reviewer's one execution right: --help for a gstack bin or a listed read-only CLI (lib/office-hours-probe.ts).
function probe(args) {
if (args.length < 2 || args.length > 4 || args[0] !== '--help') throw new Error('usage: gstack-office-hours-review probe --help <command> [<subcommand> [<subcommand>]]');
const named = args.slice(1).join(' ');
const where = { pathEnv: process.env.PATH ?? '', repoRoot: repoRootOf(process.cwd()) };
const target = resolveProbe(args[1], { ...where, gstackBin: path.dirname(fs.realpathSync(process.argv[1])) }, args.slice(2));
if ('refused' in target) {
console.error(resultLine('gstack-office-hours-review probe', 'OFFICE_HOURS_PROBE_REFUSED', `${named}: ${target.refused}`));
process.exitCode = 3;
return;
}
const r = runProbe(target.exe, args.slice(2), where);
console.log(`PROBE: ${named} ${r.timedOut ? 'timeout' : `exit=${r.status}`}`);
if (r.output) console.log(r.output);
if (r.timedOut) process.exitCode = 1;
}
function main(args) {
const command = args.shift();
if (!['check', 'prepare', 'seal', 'finalize'].includes(command)) throw new Error('usage: gstack-office-hours-review check --receipt <reviewer response> <round.json...> | prepare --design <doc> --out-dir <reviewdir> [round.json...] | seal --design <doc> --verdict <round-N.json> [preceding round.json...] | finalize --design <doc> [--report <report>] [--unreviewed <reason>] [--fixes <fixes.json>] <round.json...> (each also takes --max-rounds 1|2|3, default 3)');
if (command === 'probe') return probe(args);
if (!['check', 'prepare', 'seal', 'finalize'].includes(command)) throw new Error('usage: gstack-office-hours-review probe --help <command> [<subcommand>] | check --receipt <reviewer response> <round.json...> | prepare --design <doc> --out-dir <reviewdir> [round.json...] | seal --design <doc> --verdict <round-N.json> [preceding round.json...] | finalize --design <doc> [--report <report>] [--unreviewed <reason>] [--fixes <fixes.json>] <round.json...> (each also takes --max-rounds 1|2|3, default 3)');
const options = {};
const files = [];
while (args.length) {
@@ -82,7 +102,7 @@ function main(args) {
if (!captured) atomicWriteSync(snapshotPath, design, { mode: 0o600 });
if (!existing) atomicWriteSync(promptPath, prompt, { mode: 0o600 });
const seal = [shellQuote(fs.realpathSync(process.argv[1])), 'seal', ...(maxRounds === 3 ? [] : [`--max-rounds ${maxRounds}`]), '--design', shellQuote(designPath), '--verdict', shellQuote(verdictPath), ...files.map(file => shellQuote(fs.realpathSync(file)))].join(' ');
const dispatch = `Read ${JSON.stringify(promptPath)} and execute the independent spec review as instructed.\nDocument: ${designPath}\nPrompt: ${promptPath}\nVerdict: ${verdictPath}\nSeal: ${seal}`;
const dispatch = `Read ${JSON.stringify(promptPath)} and execute the independent spec review as instructed.\nDocument: ${designPath}\nPrompt: ${promptPath}\nVerdict: ${verdictPath}\nProbe: ${shellQuote(fs.realpathSync(process.argv[1]))} probe --help <command> [<subcommand>]\nSeal: ${seal}`;
console.log(JSON.stringify({ round, promptPath, verdictPath, dispatch }));
return;
}
+12 -6
View File
@@ -4,7 +4,7 @@
*
* gstack-outside-voice run --runner codex-cli|api|host-subagent --prompt <file> --out <file>
* [--model <id>] [--result <file>] [--native-model <id>] [--fallback <runner>]
* [--gate review|structured|spec|proposal] [--timeout-ms N] [--cwd <dir>] [--json]
* [--gate review|structured|spec|proposal] [--timeout-ms N] [--max-tokens N] [--cwd <dir>] [--json]
* gstack-outside-voice runners [--model <id>] [--json] the runner table: capabilities and availability here
* gstack-outside-voice family <model-id> the model's family (same-family rule)
*
@@ -20,18 +20,21 @@ import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
import type { OutsideGate } from '../lib/outside-review-result';
import { RUNNER_IDS, createRunner, familyConflict, modelFamily, outsideStatusLine, runnerTable, selectRunner, type RunnerId } from '../lib/outside-voice-runner';
import { outsideConsent } from '../lib/outside-consent';
import { apiTimeoutMs } from '../lib/outside-voice-api';
import { resultLine } from '../lib/result-codes';
const HELP = `gstack-outside-voice — one outside voice, three runners (docs/unattended.md)
run --runner <r> --prompt <file> --out <file> [--model <id>] [--result <file>] [--native-model <id>]
[--fallback <r>] [--gate review|structured|spec|proposal] [--timeout-ms N] [--cwd <dir>] [--json]
[--fallback <r>] [--gate review|structured|spec|proposal] [--timeout-ms N] [--max-tokens N] [--cwd <dir>] [--json]
runners [--model <id>] [--json] the runner table: id, capabilities, available here
family <model-id> print the model family (anthropic, openai, google, ...)
Runners: codex-cli (repository review via codex exec), api (supplied-input review via
OPENAI_API_KEY/ANTHROPIC_API_KEY; needs --model), host-subagent (binds --result from a
cross-family subagent the host ran; needs --model). --native-model enforces the same-family
cross-family subagent the host ran; needs --model). --max-tokens caps the api runner's output
(Anthropic default 16384; its default time limit grows with it); a response cut off at the limit
is unavailable (truncated). --native-model enforces the same-family
rule. GSTACK_OUTSIDE_RUNNER names the fallback when --fallback is absent.
Prints OUTSIDE_STATUS: <status> provider=<runner> model=<id> family=<f> and writes <out>.meta.json.
codex_reviews=disabled (or an unreadable config) refuses every runner before it starts:
@@ -75,9 +78,12 @@ async function main(argv: string[]): Promise<number> {
if (!fs.existsSync(promptFile)) return usage(`prompt file not found: ${promptFile}`);
const gate = (flag(rest, '--gate') ?? 'review') as OutsideGate;
if (!['review', 'structured', 'spec', 'proposal'].includes(gate)) return usage('--gate must be review, structured, spec or proposal');
const maxTokensText = flag(rest, '--max-tokens');
const maxTokens = maxTokensText === undefined ? undefined : Number(maxTokensText);
if (maxTokens !== undefined && (!Number.isInteger(maxTokens) || maxTokens <= 0)) return usage('--max-tokens needs a positive integer');
const timeoutText = flag(rest, '--timeout-ms');
const timeoutMs = timeoutText === undefined ? 300_000 : Number(timeoutText);
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) return usage('--timeout-ms needs a positive integer');
const timeoutMs = timeoutText === undefined ? undefined : Number(timeoutText);
if (timeoutMs !== undefined && (!Number.isInteger(timeoutMs) || timeoutMs <= 0)) return usage('--timeout-ms needs a positive integer');
const fallbackText = flag(rest, '--fallback') ?? process.env.GSTACK_OUTSIDE_RUNNER;
if (fallbackText !== undefined && !isRunner(fallbackText)) return usage(`--fallback must be one of ${RUNNER_IDS.join(', ')}`);
const model = flag(rest, '--model');
@@ -110,7 +116,7 @@ async function main(argv: string[]): Promise<number> {
const stop = () => runner.cancel();
process.on('SIGTERM', stop);
process.on('SIGINT', stop);
const result = await runner.run({ promptFile: path.resolve(promptFile), outFile: path.resolve(outFile), model, timeoutMs, cwd, gate, resultFile: resultFile ? path.resolve(resultFile) : undefined });
const result = await runner.run({ promptFile: path.resolve(promptFile), outFile: path.resolve(outFile), model, timeoutMs: timeoutMs ?? (choice.runner === 'api' ? apiTimeoutMs(maxTokens) : 300_000), cwd, gate, maxTokens, resultFile: resultFile ? path.resolve(resultFile) : undefined });
fs.mkdirSync(path.dirname(path.resolve(outFile)), { recursive: true });
fs.writeFileSync(`${path.resolve(outFile)}.meta.json`, JSON.stringify(result, null, 2) + '\n');
if (json) process.stdout.write(JSON.stringify(result) + '\n');
+4 -2
View File
@@ -8,13 +8,15 @@ DO_PREFIX="$2"
# Normalize prefix arg
case "$DO_PREFIX" in true|1) DO_PREFIX=1 ;; *) DO_PREFIX=0 ;; esac
# Names are read with a bash builtin loop: no process per skill (#2120).
. "$(dirname "$0")/gstack-skill-name.sh"
PATCHED=0
for skill_dir in "$GSTACK_DIR"/*/; do
[ -f "$skill_dir/SKILL.md" ] || continue
dir_name="$(basename "$skill_dir")"
dir_name=${skill_dir%/}; dir_name=${dir_name##*/}
[ "$dir_name" = "node_modules" ] && continue
cur=$(grep -m1 '^name:' "$skill_dir/SKILL.md" 2>/dev/null | sed 's/^name:[[:space:]]*//' | tr -d '[:space:]' || true)
gstack_skill_md_name "$skill_dir/SKILL.md"; cur=$GSTACK_SKILL_NAME
[ -z "$cur" ] && continue
[ "$cur" = "gstack" ] && continue # never prefix root skill
if [ "$DO_PREFIX" -eq 1 ]; then
+23 -10
View File
@@ -3,26 +3,30 @@
* gstack-plan-reality — the reality rows of a plan review (plan D1).
*
* gstack-plan-reality rows --phase <ceo|eng|dx|design> [--scope <csv>] [--deep] [--json]
* gstack-plan-reality check --phase <p> <review-file|-> [--scope <csv>] [--session-kind <kind>] [--plan <reviewed plan>] [--json]
* gstack-plan-reality check --phase <p> <review-file|-> [--scope <csv>] [--session-kind <kind>] [--plan <reviewed plan>] [--root <dir>]... [--json]
*
* Logic: lib/plan-reality.ts. Codes: lib/result-codes.ts (REALITY_ROW_MISSING, REALITY_RECEIPT_MISSING).
* Logic: lib/plan-reality.ts, lib/plan-reality-receipts.ts. Codes: lib/result-codes.ts (REALITY_ROW_MISSING, REALITY_RECEIPT_MISSING, REALITY_RECEIPT_UNRESOLVED).
*/
import * as fs from 'node:fs';
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
import { checkRealityRows, parseScopes, REALITY_LINE_SHAPE, REALITY_SCOPES, renderRows, requiredRows } from '../lib/plan-reality';
import { checkRealityRows, parseRealityLines, parseScopes, REALITY_LINE_SHAPE, REALITY_SCOPES, renderRows, requiredRows } from '../lib/plan-reality';
import { defaultRoots, resolveReceipts } from '../lib/plan-reality-receipts';
import { resultLine } from '../lib/result-codes';
const HELP = `gstack-plan-reality — reality rows for plan reviews
rows --phase <p> [--scope <csv>] [--deep] [--json] the rows this review must carry (tier 1 always, tier 2 by scope)
check --phase <p> <file|-> [--scope <csv>] [--session-kind <kind>] [--plan <reviewed plan>] [--json]
check --phase <p> <file|-> [--scope <csv>] [--session-kind <kind>] [--plan <reviewed plan>] [--root <dir>]... [--json]
parse the review's REALITY: lines; exit 1 with the missing rows
--plan: a number inside a "quoted" span copied verbatim from it needs no receipt
--root: where file:line receipts must exist (repeatable; default the git top level)
Row line: \`${REALITY_LINE_SHAPE}\` (receipt: path:line, path:line-line or a commit sha).
Scopes: ${REALITY_SCOPES.filter(s => s !== 'always').join(', ')}. Session kind unattended → verdict=incomplete; else verdict=repair.
Prints \`PLAN_REALITY: phase=<p> rows=<n>/<m> missing=<ids|none> [unreceipted=<ids>] [unreceipted_numbers=<n>] [unquoted=<n>] [unverified=<n>] verdict=complete|incomplete|repair\`,
then one \`NUMBER_UNRECEIPTED: <clause>\` per measured number (unit or measured/costs/took) without its own receipt.
Prints \`PLAN_REALITY: phase=<p> rows=<n>/<m> missing=<ids|none> [unreceipted=<ids>] [unreceipted_numbers=<n>] [unquoted=<n>] [unverified=<n>] [unresolved=<ids>] verdict=complete|incomplete|repair\`,
then one \`NUMBER_UNRECEIPTED: <clause>\` per measured number (unit or measured/costs/took) without its own receipt,
one \`RECEIPT_UNRESOLVED: <row> <receipt> <reason>\` per receipt that is false in the roots (missing file, line past the end; blocks),
and, on stderr, one \`RECEIPT_UNVERIFIABLE: <row> <receipt> <reason>\` per receipt the roots cannot judge (reported, never blocks).
A line claiming a decision rule must quote it (\`"..."\`, a fenced or > block after it) or link it (file:line).
${exitTableHelp()}`;
@@ -50,7 +54,7 @@ function main(argv: string[]): number {
return EXIT.ok;
}
if (cmd === 'check') {
const valueFlags = new Set(['--phase', '--scope', '--session-kind', '--plan']);
const valueFlags = new Set(['--phase', '--scope', '--session-kind', '--plan', '--root']);
const file = rest.find((a, i) => !a.startsWith('--') && !(i > 0 && valueFlags.has(rest[i - 1]!)));
if (!file) return usage('a review file (or - for stdin) is required');
let text: string;
@@ -60,14 +64,23 @@ function main(argv: string[]): number {
let planText: string | undefined;
try { planText = planFile ? fs.readFileSync(planFile, 'utf8') : undefined; } catch (e: any) { process.stderr.write(`gstack-plan-reality: ${planFile}: ${e.message}\n`); return EXIT.fail; }
const result = checkRealityRows(text, { phase, scopes, sessionKind, planText });
if (json) process.stdout.write(JSON.stringify(result) + '\n');
else process.stdout.write([result.line, ...result.numberLines].join('\n') + '\n');
const rootFlags = rest.flatMap((a, i) => (a === '--root' && rest[i + 1] ? [rest[i + 1]!] : []));
const receipts = resolveReceipts(parseRealityLines(text), { roots: rootFlags.length ? rootFlags : defaultRoots(process.cwd()) });
const unresolved = receipts.filter(r => r.outcome === 'unresolved');
const unverifiable = receipts.filter(r => r.outcome === 'unverifiable');
const unresolvedRows = [...new Set(unresolved.map(r => r.row))];
const verdict = result.verdict === 'complete' && unresolved.length ? (sessionKind === 'unattended' ? 'incomplete' : 'repair') : result.verdict;
const line = unresolvedRows.length ? result.line.replace(/ verdict=\S+$/, ` unresolved=${unresolvedRows.join(',')} verdict=${verdict}`) : result.line;
if (json) process.stdout.write(JSON.stringify({ ...result, verdict, line, receipts }) + '\n');
else process.stdout.write([line, ...result.numberLines, ...unresolved.map(r => `RECEIPT_UNRESOLVED: ${r.row} ${r.receipt} ${r.reason}`)].join('\n') + '\n');
for (const r of unverifiable) process.stderr.write(`RECEIPT_UNVERIFIABLE: ${r.row} ${r.receipt} ${r.reason}\n`);
for (const id of result.unknownRows) process.stderr.write(`gstack-plan-reality: ${file}: REALITY row ${JSON.stringify(id)} is not a known row; it was ignored\n`);
if (result.missing.length) process.stderr.write(resultLine('gstack-plan-reality check', 'REALITY_ROW_MISSING', `phase ${phase}: ${result.missing.join(', ')}`) + '\n');
if (result.unreceipted.length) process.stderr.write(resultLine('gstack-plan-reality check', 'REALITY_RECEIPT_MISSING', `phase ${phase}: ${result.unreceipted.join(', ')}`) + '\n');
if (result.unreceipted_numbers.length) process.stderr.write(resultLine('gstack-plan-reality check', 'REALITY_RECEIPT_MISSING', `phase ${phase}: ${result.unreceipted_numbers.length} measured number(s) share or lack a receipt; give each clause its own file:line, script path or command`) + '\n');
for (const claim of result.unquoted) process.stderr.write(resultLine('gstack-plan-reality check', 'REALITY_RECEIPT_MISSING', `phase ${phase}: a decision rule is claimed without its text; quote it or link its file:line: ${claim}`) + '\n');
return result.verdict === 'complete' ? EXIT.ok : EXIT.fail;
if (unresolved.length) process.stderr.write(resultLine('gstack-plan-reality check', 'REALITY_RECEIPT_UNRESOLVED', `phase ${phase}: ${unresolved.map(r => `${r.row} ${r.receipt} (${r.reason})`).join('; ')}`) + '\n');
return verdict === 'complete' ? EXIT.ok : EXIT.fail;
}
return usage(`unknown command ${cmd}`);
}
+25 -37
View File
@@ -4,12 +4,12 @@
*
* gstack-pregate [run] [--stage preflight|tests|all] [--only <check,..>] [--base <branch>]
* [--allow-repo-commands] [--policy-from base|head] [--out <pregate.json>]
* [--timeout <s>] [--stall-timeout <s>] [--remote --spend-cap <usd>] [--run <id>] [--explain] [--json]
* [--timeout <s>] [--stall-timeout <s>] [--remote --spend-cap <usd>] [--run <id>] [--from-run <id|url>] [--explain] [--json]
* gstack-pregate verify [--out <pregate.json>] [--json] publication check: current tree, no fail/incomplete, obligations cleared
* gstack-pregate clear --remote <workflow.yml/job> --run-url <url> | --run <id|url> [--run …] [--allow-repo-commands] | --bundle-lane <label> | --receipt <file> [--out <pregate.json>]
* gstack-pregate lanes [--explain] the lane list and platform tiers read from .github/workflows
* gstack-pregate validate | init [--explain] [--force] the working-tree .gstack/pregate.json
* gstack-pregate sealed [--range <a..b>] [--remote <name>] [--base <branch>] [--json]
* gstack-pregate sealed [--range <a..b>] [--remote <name>] [--base <branch>] [--no-skip] [--json]
* gstack-pregate regate --against <gated-head> --bundle <evidence-bundle.json> [--require <lanes>] [--pr <n>]
* [--from <gate-ahead.json>] [--predecessor-pr <n> | --predecessor-ref <ref>] [--base <branch>] [--json]
* does the gated head's evidence still cover HEAD (lib/pregate/regate.ts)
@@ -26,18 +26,17 @@
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { baseContainsHead } from '../lib/default-base';
import { EXIT, exitTableHelp, formatResult, type PregateFile } from '../lib/headless-artifacts';
import { PREGATE_CONFIG_PATH, explainConfig, isConfigUnavailable, loadConfigFromBase, loadConfigFromHead } from '../lib/pregate/config';
import { PREGATE_CONFIG_PATH, explainConfig, loadConfigFromHead } from '../lib/pregate/config';
import { describeDraft, draftConfig } from '../lib/pregate/init-draft';
import { buildRunCheck } from '../lib/pregate/run-check';
import { buildContext, defaultUserConfigPath, describeRefusal, loadedDotenvFiles } from '../lib/pregate/context';
import { CHECKS, CHECK_IDS, PREGATE_OUT_REL, PREGATE_TIER2, clearDecision, clearObligation, readPregateFile, renderTable, runPregate, verifyPregate, writePregateFile, type RunCheck } from '../lib/pregate/index';
import { ghRunDeps, runInventory } from '../lib/run-evidence';
import { wtreeFingerprint } from '../lib/script-invocation';
import { CHECKS, CHECK_IDS, PREGATE_OUT_REL, PREGATE_TIER2, clearDecision, clearObligation, readPregateFile, renderTable, runPregate, verifyPregate, writePregateFile } from '../lib/pregate/index';
import { describeLane, discoverLanes } from '../lib/pregate/workflows';
import { REMOTE_GATE_LANE } from '../lib/pregate/checks/lanes';
import { describeSubstitution } from '../lib/pregate/remote-receipt';
import { ensureTmpExcluded } from '../lib/restamp';
import { normalizeRemoteUrl } from '../lib/ship-queue-train-record';
import { RESULT_CODES, resultCodeSuffix, resultLine } from '../lib/result-codes';
import { DEFAULT_STALL_TIMEOUT_S } from '../lib/pregate/context';
import { cancelActive } from '../lib/pregate/supervise';
@@ -62,8 +61,9 @@ const HELP = `gstack-pregate — cheap checks before anything paid or remote (do
profile, --run-url is verified the same way
lanes print every CI lane with its platform tier (from .github/workflows/*.yml)
validate | init the working-tree ${PREGATE_CONFIG_PATH}
sealed [--range <a..b>] [--remote <name>] [--base <branch>]
the sealed-output guard (docs/pregate.md#sealed) over the commits <remote> lacks, or the range:
sealed [--range <a..b>] [--remote <name>] [--base <branch>] [--no-skip]
the sealed-output guard (docs/pregate.md#sealed) over the commits <remote> lacks, or the range,
minus commits <remote> already holds as a squashed PR's refs/pull/<n>/head or whose patch landed (--no-skip scans all):
run it before any push or PR creation in a repo with a \`sealed\` config; exit 1 refuses (SEALED_OUTPUT_REFUSED);
it never pushes: policy=<base> + commit <sha> names where the policy was read
regate --against <gated-head> --bundle <file> [--require <lanes>] [--pr <n>] [--predecessor-pr <n> | --predecessor-ref <ref>]
@@ -72,7 +72,8 @@ const HELP = `gstack-pregate — cheap checks before anything paid or remote (do
--stage preflight|tests|all default all
--only <check,...> restrict to these checks (${CHECK_IDS.join(', ')}; tier 2 later: ${PREGATE_TIER2.join(', ')})
--base <branch> a branch on origin, not a commit (default: origin/HEAD's branch, else main)
--base <branch> a branch on origin, not a commit (default: origin/HEAD's branch unless it is the
checked-out one, else the PR's base, origin/main, origin/master; lib/default-base.ts)
--allow-repo-commands run pin-file commands and the test runner (what /ship passes); without it those checks are incomplete
--policy-from base|head base (default, reviewed) or head (unreviewed; needs --allow-repo-commands; the head file may
only add test lanes over the base copy, else PREGATE_HEAD_POLICY_LOOSENS)
@@ -90,6 +91,9 @@ const HELP = `gstack-pregate — cheap checks before anything paid or remote (do
spent, reserved and unknown
--run <id> the run label recorded in pregate.json and the name of this run's scratch directory (a taken name gets a numbered sibling, <id>-2 … -9)
(<state root>/projects/<slug>/pregate/runs/<id>: lane and guard logs, scan files; default a unique id)
--from-run <id|url> a Free Tests run on this exact tree, verified against the base branch's run profile for this OS
whose workflow holds a local lane (docs/pregate.md#from-run): the selected test files its tree.json lists count as passed and
are recorded with its receipt; the rest still run. A refused run prints REUSE: refused and runs everything
--explain print each selected test's provenance and each uncertain mapping
--json one JSON object on stdout; a refusal before any check runs (unavailable base, invalid pin
file, WTREE_UNAVAILABLE) is {ok:false,status:'refused',code,detail,fix,docs} with exit 3,
@@ -108,13 +112,13 @@ function flag(args: string[], name: string): string | undefined { const i = args
/** Flags each verb accepts: `[flags that take a value, bare flags]`. Anything else is usage (exit 2), never a silent default run. */
const VERB_FLAGS: Record<string, [string[], string[]]> = {
run: [['--stage', '--only', '--base', '--policy-from', '--out', '--timeout', '--stall-timeout', '--spend-cap', '--run'], ['--allow-repo-commands', '--explain', '--json', '--remote']],
run: [['--stage', '--only', '--base', '--policy-from', '--out', '--timeout', '--stall-timeout', '--spend-cap', '--run', '--from-run'], ['--allow-repo-commands', '--explain', '--json', '--remote']],
verify: [['--out'], ['--json']],
clear: [['--remote', '--run-url', '--run', '--bundle-lane', '--receipt', '--out'], ['--allow-repo-commands']],
lanes: [[], ['--explain', '--json']],
validate: [[], ['--json']],
init: [[], ['--explain', '--force']],
sealed: [['--range', '--base', '--remote'], ['--json']],
sealed: [['--range', '--base', '--remote'], ['--json', '--no-skip']],
regate: [['--against', '--bundle', '--require', '--pr', '--from', '--predecessor', '--predecessor-pr', '--predecessor-ref', '--base', '--current'], ['--json']],
};
const HINTS: Record<string, string> = { '--sealed': 'the sealed-output guard is the verb `gstack-pregate sealed`' };
@@ -141,6 +145,8 @@ function outPath(root: string, rest: string[]): string { const o = flag(rest, '-
async function cmdRun(root: string, rest: string[], json: boolean): Promise<number> {
const stage = (flag(rest, '--stage') ?? 'all') as 'preflight' | 'tests' | 'all';
if (!['preflight', 'tests', 'all'].includes(stage)) { process.stderr.write('gstack-pregate: --stage needs preflight, tests or all\n'); return EXIT.usage; }
const fromRun = flag(rest, '--from-run');
if (fromRun !== undefined && stage === 'preflight') { process.stderr.write('gstack-pregate: --from-run applies to the tests stage (--stage tests or all)\n'); return EXIT.usage; }
const only = flag(rest, '--only')?.split(',').map(s => s.trim()).filter(Boolean);
const unknown = (only ?? []).filter(id => !CHECK_IDS.includes(id));
if (unknown.length) { process.stderr.write(`gstack-pregate: unknown check(s) ${unknown.join(', ')}${unknown.some(u => (PREGATE_TIER2 as readonly string[]).includes(u)) ? ' (tier 2, not in this gstack)' : ''}; known: ${CHECK_IDS.join(', ')}\n`); return EXIT.usage; }
@@ -172,6 +178,7 @@ async function cmdRun(root: string, rest: string[], json: boolean): Promise<numb
return EXIT.refused;
}
const ctx = loaded.ctx;
if (fromRun !== undefined) ctx.fromRun = { ref: fromRun, check: buildRunCheck(root, null, allow, base) };
ensureTmpExcluded(root);
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.once(signal, () => {
@@ -228,7 +235,7 @@ function cmdClear(root: string, rest: string[]): number {
const runs = rest.flatMap((a, i) => a === '--run' ? [rest[i + 1]!] : []);
const runUrl = flag(rest, '--run-url');
const bundleLane = flag(rest, '--bundle-lane');
const checked = runs.length || runUrl || bundleLane ? runCheck(root, lane, rest.includes('--allow-repo-commands')) : undefined;
const checked = runs.length || runUrl || bundleLane ? buildRunCheck(root, lane, rest.includes('--allow-repo-commands')) : undefined;
if (checked && 'error' in checked && (runs.length || checked.profiled)) { process.stderr.write(`gstack-pregate clear: ${checked.error}${checked.profiled ? `; ${lane} may have a run profile on the base branch, so it clears only with a verified --run` : ''}\n`); return EXIT.fail; }
const evidence = checked && !('error' in checked) ? checked : undefined;
const decision = clearDecision(read.file, lane, { runUrl, runs, bundleLane, receiptPath: flag(rest, '--receipt'), evidence }, root);
@@ -248,28 +255,6 @@ function cmdClear(root: string, rest: string[]): number {
return EXIT.ok;
}
/** The run-evidence inputs for `clear --run`: the base branch's run profiles, this repository on GitHub, and the gate's inventory command (a pin-file command, so only with --allow-repo-commands). */
function runCheck(root: string, lane: string, allowRepoCommands: boolean): RunCheck | { error: string; profiled?: boolean } {
const base = resolveBaseBranch(root);
const loaded = loadConfigFromBase(root, base);
// Unknown profiles are treated as present: a lane is cleared unverified only once the base shows it has no profile.
if (isConfigUnavailable(loaded)) return { error: `the base branch's ${PREGATE_CONFIG_PATH} is unreadable (${loaded.detail}); run profiles come only from the base`, profiled: true };
if (loaded.errors.length) return { error: `the base branch's ${PREGATE_CONFIG_PATH} is invalid: ${loaded.errors.map(e => `${e.path}: ${e.message}`).join('; ')}`, profiled: true };
const repo = /^https:\/\/github\.com\/(.+)$/.exec(normalizeRemoteUrl(gitOut(root, ['config', '--get', 'remote.origin.url']) ?? ''))?.[1];
if (!repo) return { error: 'origin is not a github.com repository; run evidence is read from GitHub Actions', profiled: lane === REMOTE_GATE_LANE ? !!loaded.config.remote_gate_runs : !!loaded.config.run_profiles[lane] };
return {
repo, deps: ghRunDeps(repo, root), profiles: loaded.config.run_profiles, gateRuns: loaded.config.remote_gate_runs, now: new Date(),
inventory: (command, tree) => {
if (!allowRepoCommands) return { error: 'the inventory command is a pin-file command; pass --allow-repo-commands' };
const at = () => { const fp = wtreeFingerprint({ cwd: root }); return 'tree' in fp ? fp.tree : null; };
if (at() !== tree) return { error: `the working tree is not pregate.json's tree ${tree.slice(0, 12)}; the inventory must list that tree's gate files` };
const listed = runInventory(command, root);
if (at() !== tree) return { error: `the working tree changed while ${command} ran` };
return listed;
},
};
}
function gitOut(root: string, args: string[]): string | null {
const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: 30_000 });
return r.status === 0 ? r.stdout.trim() : null;
@@ -285,15 +270,18 @@ function cmdSealed(root: string, rest: string[], json: boolean): number {
if (!pushedSha) { process.stderr.write(`gstack-pregate sealed: ${end} is not a commit\n`); return EXIT.usage; }
const listed = range ? rangeCommits(root, range) : publishedCommits(root, { localSha: pushedSha, remoteName: remote });
const baseRef = gitOut(root, ['rev-parse', '--verify', '--quiet', `refs/remotes/${remote}/${base}^{commit}`]) ? `refs/remotes/${remote}/${base}` : null;
const self = baseRef && baseContainsHead(root, baseRef);
if (self) { process.stderr.write(`${resultLine('gstack-pregate sealed', 'BASE_CONTAINS_HEAD', self)}; the policy would be the branch's own\n`); return EXIT.refused; }
const remoteUrl = gitOut(root, ['remote', 'get-url', remote]);
const report = 'error' in listed
? sealedErrorReport(listed.error, remoteUrl ?? remote)
: evaluateSealed({ repoRoot: root, targets: [{ commits: listed.commits, pushedSha }], baseRef, baseLabel: `${remote}/${base}`, remoteUrl, target: remoteUrl ?? remote });
: evaluateSealed({ repoRoot: root, targets: [{ commits: listed.commits, pushedSha }], baseRef, baseLabel: `${remote}/${base}`, remoteUrl, target: remoteUrl ?? remote, remoteName: remoteUrl ? remote : null, skip: !rest.includes('--no-skip') });
const blocks = report.status === 'refused' || report.status === 'error';
if (json) { process.stdout.write(JSON.stringify({ ...report, ok: !blocks }) + '\n'); return blocks ? EXIT.fail : EXIT.ok; }
process.stderr.write(renderSealed(report, 'gstack-pregate sealed'));
const unchecked = report.findings.filter(f => f.rule === 'sealed.label_join_unchecked').length;
const policy = report.policyLabels.length ? report.policyLabels.join(' + ') : `no sealed key on ${baseRef ?? `${remote}/${base}`} or ${end}`;
process.stdout.write(`SEALED: ${report.status} findings=${report.findings.length} commits=${report.commits} visibility=${report.visibility}${report.visibility === 'unknown' ? ' (counted public)' : ''} waived=${report.waived.length} exempt=${report.exempt.length} policy=${policy}\n`);
process.stdout.write(`SEALED: ${report.status} findings=${report.findings.length} commits=${report.commits} skipped=${report.skipped.length} visibility=${report.visibility}${report.visibility === 'unknown' ? ' (counted public)' : ''} waived=${report.waived.length} exempt=${report.exempt.length}${unchecked ? ` unchecked=${unchecked}` : ''} policy=${policy}\n`);
return blocks ? EXIT.fail : EXIT.ok;
}
+33
View File
@@ -23,6 +23,12 @@
# question_id, the clicked UI label is mapped to its
# registered option key (#2623) and kept in choice_label.
# Unmatched labels and other sources stay as given.
# signal_key — optional SIGNAL_MAP key an unregistered question_id's
# answer speaks to (#3442; `gstack-question-preference
# --signal-keys` lists them). Kept only when it is a
# signal key and the id is unregistered; the choice is
# then mapped to that signal's option keys. A dropped
# hint logs the row anyway (QLOG_SIGNAL_KEY_DROPPED).
# recommended — option key the agent recommended (optional; mapped
# the same way on hook-captured events)
# followed_recommendation — bool (optional — computed if both present)
@@ -197,6 +203,32 @@ if (j.source === 'hook') {
}
}
// A signal hint on an unregistered id (#3442) is kept only when it names a
// SIGNAL_MAP key; the choice is then mapped to that signal's option keys
// (exact key or its own words). A dropped hint never fails the log.
if (j.signal_key !== undefined) {
const { SIGNAL_MAP, resolveHintedOptionKey } = await import(process.env.GSTACK_SCRIPTS_DIR + '/psychographic-signals.ts');
const { getQuestion } = await import(process.env.GSTACK_SCRIPTS_DIR + '/question-registry.ts');
const hint = typeof j.signal_key === 'string' ? j.signal_key.toLowerCase() : '';
let reason = '';
if (!Object.hasOwn(SIGNAL_MAP, hint)) reason = 'it is not a signal key; list them with gstack-question-preference --signal-keys';
else if (getQuestion(j.question_id)) reason = 'a registered id keeps its own signal';
if (reason) {
process.stderr.write('gstack-question-log: logged the answer without its signal_key: ' + reason + ' (QLOG_SIGNAL_KEY_DROPPED)\n');
delete j.signal_key;
} else {
j.signal_key = hint;
const key = resolveHintedOptionKey(hint, j.user_choice);
if (key !== undefined && key !== j.user_choice) {
j.choice_label = j.user_choice;
j.user_choice = key;
}
if (j.recommended !== undefined) {
j.recommended = resolveHintedOptionKey(hint, j.recommended) ?? j.recommended;
}
}
}
// followed_recommendation — compute if both sides present. An __unknown__
// choice means extraction failed, not that the user rejected the
// recommendation — leave the field absent so metrics can't be poisoned.
@@ -233,6 +265,7 @@ if [ $VALIDATE_RC -ne 0 ] || [ -z "$VALIDATED" ]; then
fi
exit 1
fi
grep -h 'QLOG_SIGNAL_KEY_DROPPED' "$TMPERR" >&2 || true
LOG_FILE="$GSTACK_HOME/projects/$SLUG/question-log.jsonl"
+16
View File
@@ -19,6 +19,8 @@
# --read → dump preferences JSON
# --clear [<id>] → clear one or all preferences
# --stats → short summary (inert one-way prefs counted separately)
# --signal-keys → one line per signal key with its option keys, the
# menu for an unregistered id's ;signal= hint (#3442)
#
# User-origin gate
# ----------------
@@ -365,8 +367,22 @@ do_stats() {
")
}
do_signal_keys() {
cd "$ROOT_DIR"
bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
import('./scripts/psychographic-signals.ts').then((m) => {
console.log('Optional signal hint for an unregistered question_id whose answer shows one of these preferences:');
console.log('write <gstack-qid:{question_id};signal={signal_key}>, start each option label with one of the');
console.log('option keys listed after that signal key, and add a signal_key field with that key to the gstack-question-log call.');
console.log('Omit the hint when no line fits.');
for (const line of m.signalKeyLines()) console.log(line);
}).catch(err => { console.error('signal-keys:', err.message); process.exit(1); });
"
}
case "$CMD" in
--check) do_check "$@" ;;
--signal-keys) do_signal_keys ;;
--write) do_write "$@" ;;
--read|"") do_read ;;
--clear) do_clear "$@" ;;
+5 -3
View File
@@ -611,7 +611,7 @@ function sealedForPush(refs: Array<{ localSha: string; remoteSha: string }>): Se
if ("error" in published) return sealedErrorReport(published.error, remoteUrl ?? "the push target");
targets.push({ commits: published.commits, pushedSha: ref.localSha });
}
return evaluateSealed({ repoRoot: ".", targets, baseRef: defaultRemoteBranch(), remoteUrl, target: remoteUrl ?? remoteName ?? "the push target" });
return evaluateSealed({ repoRoot: ".", targets, baseRef: defaultRemoteBranch(), remoteUrl, target: remoteUrl ?? remoteName ?? "the push target", remoteName });
}
function main() {
@@ -686,13 +686,15 @@ function main() {
// as public-strict (HIGH blocks regardless either way).
// Sliced (see scanAddedLines) so a large-but-legitimate diff is actually
// scanned rather than blocked unscanned on the engine's size cap.
// Scanned per file so a pattern can use the file's type (#3048).
// Scanned per file so a pattern can use the file's type (#3048). A Dart or
// Python file's added lines go without their path: their source-syntax
// exemption needs the whole file to see a triple-quoted string (#3630).
// An allowlisted span is found by scanning with and without the entries,
// so the report names exactly what they suppressed.
const allowed: string[] = [];
const findingKey = (f: Finding) => `${f.id}:${f.line}:${f.col}`;
for (const { part, allowlist } of parts) {
const opts = { repoVisibility: "private" as const, ...emails.options, ...(part.file ? { sourcePath: part.file } : {}) };
const opts = { repoVisibility: "private" as const, ...emails.options, ...(part.file && !/\.(?:dart|pyi?)$/i.test(part.file) ? { sourcePath: part.file } : {}) };
const findings = scanAddedLines(part.text, opts);
const kept = allowlist.length
? new Set(scanAddedLines(part.text, { ...opts, allowlist }).map(findingKey))
+27 -4
View File
@@ -18,6 +18,10 @@
# plan_mode_preamble is off (#3168); empty or unset means on. The committed
# files carry the preamble, so with it off the default install is rendered.
#
# Callers set _LOADOUT_ARG with gstack_claude_loadout_arg (claude_loadout,
# written by ./setup --loadout; GSTA-161): a non-full loadout is rendered
# into the render dir like a pinned overlay, and the checkout stays full.
#
# Callers define `_swap_in_render` (setup and bin/gstack-config each keep
# their tested copy) and may define `log` and `bun_cmd`; plain echo and bun
# are used otherwise.
@@ -56,6 +60,23 @@ gstack_claude_overlay() {
_gstack_render_say " note: $_GSTACK_OVERLAY_NOTE"
}
# gstack_claude_loadout_arg CONFIG_BIN — set _LOADOUT_ARG to --loadout=<name>
# for a saved non-full claude_loadout, else empty. A malformed value is
# ignored with a note, so the full skills are served.
gstack_claude_loadout_arg() {
local CONFIG_BIN="$1" value
_LOADOUT_ARG=""
value="$("$CONFIG_BIN" get claude_loadout 2>/dev/null || true)"
case "$value" in
''|full) return 0 ;;
esac
if printf '%s' "$value" | grep -Eq '^[a-z0-9-]{1,40}$'; then
_LOADOUT_ARG="--loadout=$value"
else
_gstack_render_say " note: claude_loadout is not a loadout name; serving the full skills. Fix: ./setup --loadout full"
fi
}
# gstack_claude_overlay_field RECORD KEY — one field of an activation record.
gstack_claude_overlay_field() {
[ -f "$1" ] || return 0
@@ -94,10 +115,11 @@ gstack_render_unlock() {
gstack_claude_render_action() {
local render_dir="$1" gbrain="$2" previous
if [ "$gbrain" = ok ]; then _GSTACK_RENDER_ACTION=user; return 0; fi
if [ "$_GSTACK_OVERLAY" != claude ] || [ -n "${_PLAN_MODE_ARG:-}" ]; then _GSTACK_RENDER_ACTION=plain; return 0; fi
if [ "$_GSTACK_OVERLAY" != claude ] || [ -n "${_PLAN_MODE_ARG:-}" ] || [ -n "${_LOADOUT_ARG:-}" ]; then _GSTACK_RENDER_ACTION=plain; return 0; fi
previous="$(gstack_claude_overlay_field "$render_dir.overlay" overlay)"
if [ "$gbrain" = unknown ] && [ "${previous:-claude}" = claude ] \
&& [ -z "$(gstack_claude_overlay_field "$render_dir.overlay" plan_mode)" ]; then
&& [ -z "$(gstack_claude_overlay_field "$render_dir.overlay" plan_mode)" ] \
&& [ -z "$(gstack_claude_overlay_field "$render_dir.overlay" loadout)" ]; then
_GSTACK_RENDER_ACTION=keep
else
_GSTACK_RENDER_ACTION=remove
@@ -110,8 +132,8 @@ gstack_claude_render_plain() {
local src="$1" RENDER_DIR="$2" RENDER_TMP out rc=0
RENDER_TMP="$RENDER_DIR.tmp.$$"
rm -rf "$RENDER_TMP"
_gstack_render_say "Rendering Claude skills with the $_GSTACK_OVERLAY overlay${_PLAN_MODE_ARG:+ and no plan-mode preamble} into $RENDER_DIR (source checkout stays clean)..."
out="$(cd "$src" && _gstack_render_bun run gen:skill-docs --host claude --out-dir "$RENDER_TMP" --link-root "$RENDER_DIR" --model "$_GSTACK_OVERLAY" ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"} 2>&1)" || rc=$?
_gstack_render_say "Rendering Claude skills with the $_GSTACK_OVERLAY overlay${_PLAN_MODE_ARG:+ and no plan-mode preamble}${_LOADOUT_ARG:+ and the ${_LOADOUT_ARG#--loadout=} loadout} into $RENDER_DIR (source checkout stays clean)..."
out="$(cd "$src" && _gstack_render_bun run gen:skill-docs --host claude --out-dir "$RENDER_TMP" --link-root "$RENDER_DIR" --model "$_GSTACK_OVERLAY" ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"} ${_LOADOUT_ARG:+"$_LOADOUT_ARG"} 2>&1)" || rc=$?
if [ "$rc" -ne 0 ]; then
rm -rf "$RENDER_TMP"
printf '%s\n' "$out" | tail -3
@@ -162,6 +184,7 @@ gstack_claude_render_settle() {
printf 'overlay=%s\nsource=%s\nmodel=%s\nversion=%s\ngbrain=%s\nrender=%s\n' \
"$served" "$_GSTACK_OVERLAY_SOURCE" "$_GSTACK_OVERLAY_ID" "${version:-unknown}" "$gbrain" "$render_kind"
[ "$render_kind" != user ] || [ -z "${_PLAN_MODE_ARG:-}" ] || printf 'plan_mode=off\n'
[ "$render_kind" != user ] || [ -z "${_LOADOUT_ARG:-}" ] || printf 'loadout=%s\n' "${_LOADOUT_ARG#--loadout=}"
} > "$record.tmp.$$" && mv "$record.tmp.$$" "$record"
fi
gstack_render_unlock "$render_dir"
+1 -1
View File
@@ -25,7 +25,7 @@ fi
# PWD-keyed slug cache matches other gstack project-state writers.
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true)
[ -z "${REPO_ROOT:-}" ] && { echo "REPO_MODE=unknown"; exit 0; }
SLUG=$(cd "$REPO_ROOT" && "$SCRIPT_DIR/gstack-slug" | sed -n 's/^SLUG=//p' | head -1)
SLUG=$(cd "$REPO_ROOT" && "$SCRIPT_DIR/gstack-slug" --get REPO_SLUG | head -1)
[ -z "${SLUG:-}" ] && { echo "REPO_MODE=unknown"; exit 0; }
# Validate: only allow known values (prevent shell injection via source <(...))
+71 -10
View File
@@ -10,11 +10,18 @@
* Runs gstack-version-bump write, the policy's release_tool, the CHANGELOG
* re-heading and the stamp_paths rewrites in an isolated staging worktree,
* shows the diff limited to stamp files, and writes the branch only after
* every stage validated (journal: .gstack/tmp/restamp-journal.json).
* Idempotent; --dry-run writes nothing. Workflow command: ends with the
* every stage validated (journal: .gstack/tmp/restamp-journal.json), then
* stages what it wrote (`git add -A -- <FILES>`, STAGED:), so a bare commit after
* a --no-commit base merge cannot drop the stamp (#3531). A stamp file holding
* unstaged edits of the caller's own refuses before anything is written
* (RESTAMP_UNSTAGED_EDITS); a staging failure leaves the files written and
* .gstack/tmp/restamp-staging.json, which the noop rerun stages, or drops when a
* file changed since (RESTAMP_NOT_STAGED). Idempotent; --dry-run writes nothing. Workflow command: ends with the
* GSTACK_RESULT line. Logic: lib/restamp.ts; policy: lib/ship-policy.ts.
*/
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT, exitTableHelp, formatResult } from '../lib/headless-artifacts';
import { GATE_AHEAD_REL, JOURNAL_REL, restamp, type RestampResult } from '../lib/restamp';
import { RESULT_CODES, type ResultCodeName } from '../lib/result-codes';
@@ -39,7 +46,7 @@ const HELP = `gstack-restamp — stamp the version, release tool, CHANGELOG and
--dry-run print the full diff, write nothing
--json JSON only on stdout
Prints STAMP_VERSION: <X>, the stage table, the stamp diff and
Prints STAMP_VERSION: <X>, the stage table, the stamp diff, STAGED: <files it staged> and
GSTACK_RESULT: skill=restamp status=complete|refused|incomplete run=${JOURNAL_REL}.
${exitTableHelp()}`;
@@ -48,6 +55,49 @@ function flag(args: string[], name: string): string | undefined {
return i >= 0 ? args[i + 1] : undefined;
}
const STAGING_REL = '.gstack/tmp/restamp-staging.json';
interface Pending { files: string[]; blobs: Record<string, string | null> }
interface Staging { staged?: string[]; dropped?: string[]; failure?: string }
const gitIn = (root: string, args: string[]) => spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: 60_000 });
/** Each file's blob sha in the worktree (one git call), null for a file that does not exist. */
function blobsOf(root: string, files: string[]): Record<string, string | null> {
const present = files.filter(f => fs.existsSync(path.join(root, f)));
const shas = present.length ? gitIn(root, ['hash-object', '--', ...present]).stdout.trim().split('\n') : [];
return Object.fromEntries(files.map(f => [f, present.includes(f) ? shas[present.indexOf(f)] ?? '' : null]));
}
function readPending(root: string): Pending | null {
try { return JSON.parse(fs.readFileSync(path.join(root, STAGING_REL), 'utf8')) as Pending; } catch { return null; }
}
/** Files with unstaged edits that are the caller's own (not bytes a pending restamp wrote and could not stage). */
function ownUnstaged(root: string): string[] {
const pending = readPending(root);
const unstaged = gitIn(root, ['diff', '--name-only', '-z']).stdout.split('\0').filter(Boolean);
const now = pending ? blobsOf(root, unstaged.filter(f => f in pending.blobs)) : {};
return unstaged.filter(f => !(pending && f in now && pending.blobs[f] === now[f]));
}
/**
* Stage what restamp wrote (`written`), or what a pending marker says is still unstaged; a marker file whose bytes
* changed since (a hand commit, another branch, a base merge) is stale for that file and dropped, never a failure.
* Null when there is nothing to stage.
*/
function stage(root: string, written: string[] | null): Staging | null {
const marker = path.join(root, STAGING_REL);
const pending = written ? { files: written, blobs: blobsOf(root, written) } : readPending(root);
if (!pending?.files.length) return null;
if (written) { fs.mkdirSync(path.dirname(marker), { recursive: true }); fs.writeFileSync(marker, JSON.stringify(pending, null, 2) + '\n'); }
const now = written ? pending.blobs : blobsOf(root, pending.files);
const dropped = pending.files.filter(f => now[f] !== pending.blobs[f]);
const files = pending.files.filter(f => !dropped.includes(f));
if (!files.length) { fs.rmSync(marker, { force: true }); return { staged: [], dropped }; }
const add = gitIn(root, ['add', '-A', '--', ...files]);
if (add.status !== 0) return { failure: `git add of ${files.join(' ')} failed (${(add.stderr || add.stdout).trim().split('\n')[0]}); the stamp stays written and ${STAGING_REL} names it` };
fs.rmSync(marker, { force: true });
return { staged: gitIn(root, ['diff', '--cached', '--name-only', '-z', '--', ...files]).stdout.split('\0').filter(Boolean), dropped };
}
function main(argv: string[]): number {
if (argv.includes('--help') || argv.includes('-h')) { process.stdout.write(HELP + '\n'); return EXIT.ok; }
const json = argv.includes('--json');
@@ -65,23 +115,32 @@ function main(argv: string[]): number {
const after = flag(argv, '--after');
const base = resolveBaseBranch(repoRoot, flag(argv, '--base'));
const result = restamp({
const options = {
repoRoot, base, version: flag(argv, '--version'), next: argv.includes('--next'), bump, queueMode,
after: after ? { pr: after, ref: flag(argv, '--predecessor-ref') } : undefined, pr: flag(argv, '--pr'),
expectBase: flag(argv, '--expect-base'), was: flag(argv, '--was'), expectOrder: flag(argv, '--expect-order')?.split(',').map(s => s.trim()).filter(Boolean),
policyFrom, allowRepoCommands: argv.includes('--allow-repo-commands'), dryRun: argv.includes('--dry-run'),
failAfter: process.env.GSTACK_RESTAMP_FAIL_AFTER,
});
return report(result, json);
};
const writes = !after && !options.dryRun;
const own = writes ? ownUnstaged(repoRoot) : [];
const dry = own.length ? restamp({ ...options, dryRun: true }) : null;
if (dry && (dry.status === 'refused' || dry.status === 'failed')) return report(dry, json);
const clash = (dry?.files ?? []).filter(f => own.includes(f));
if (clash.length) return report({ status: 'refused', code: 'RESTAMP_UNSTAGED_EDITS', message: `${clash.join(', ')} ${clash.length === 1 ? 'holds' : 'hold'} unstaged edits of your own, which staging the stamp would sweep into the stamp commit; nothing written`, stages: [] }, json);
const result = restamp(options);
const staging = writes && (result.status === 'stamped' || result.status === 'noop') ? stage(repoRoot, result.status === 'stamped' ? result.files ?? [] : null) : null;
return report(result, json, staging);
}
function report(r: RestampResult, json: boolean): number {
const status = r.status === 'refused' ? 'refused' : r.status === 'failed' ? 'incomplete' : 'complete';
const code = r.status === 'refused' ? EXIT.refused : r.status === 'failed' ? EXIT.fail : EXIT.ok;
function report(r: RestampResult, json: boolean, staging: Staging | null = null): number {
const status = r.status === 'refused' ? 'refused' : r.status === 'failed' || staging?.failure ? 'incomplete' : 'complete';
const code = r.status === 'refused' ? EXIT.refused : r.status === 'failed' || staging?.failure ? EXIT.fail : EXIT.ok;
if (json) {
process.stdout.write(JSON.stringify({ ...r, result: status }) + '\n');
process.stdout.write(JSON.stringify({ ...r, ...(staging ?? {}), ...(staging?.failure && !r.code ? { code: 'RESTAMP_NOT_STAGED' } : {}), result: status }) + '\n');
return code;
}
if (staging?.failure) process.stderr.write(`gstack-restamp: ${staging.failure}; fix: ${RESULT_CODES.RESTAMP_NOT_STAGED.fix} (RESTAMP_NOT_STAGED)\n`);
if (r.code) {
const row = RESULT_CODES[r.code as ResultCodeName];
process.stderr.write(`gstack-restamp: ${r.message}; fix: ${r.fix ?? row.fix} (${r.code})\n`);
@@ -95,6 +154,8 @@ function report(r: RestampResult, json: boolean): number {
if (r.gateAhead) process.stdout.write(`GATE_AHEAD: head=${r.gateAhead.gated_head} tree=${r.gateAhead.gated_tree} predecessor=#${r.gateAhead.predecessor_pr}@${r.gateAhead.predecessor_head.slice(0, 12)} worktree=${r.gateAhead.worktree} evidence_branch=${r.gateAhead.evidence_branch_key}\n`);
if (r.diff) process.stdout.write(r.diff + (r.diff.endsWith('\n') ? '' : '\n'));
process.stdout.write(`FILES: ${(r.files ?? []).join(' ') || '(none)'}\n`);
if (staging?.staged) process.stdout.write(`STAGED: ${staging.staged.join(' ') || '(none)'}\n`);
if (staging?.dropped?.length) process.stdout.write(`NOTE: ${staging.dropped.join(' ')} changed since an earlier restamp wrote ${staging.dropped.length === 1 ? 'it' : 'them'}; not staged, and that run's staging marker is dropped\n`);
if (r.mergeCondition) process.stdout.write(`MERGE_CONDITION: sha=${r.mergeCondition}\n`);
if (r.status === 'gate-ahead' && r.gateAhead) process.stdout.write(`next: run the verification gate in ${r.gateAhead.worktree}, then \`gstack-evidence bundle --out <file>\` there; the branch is untouched. At this PR's turn rerun \`gstack-restamp --next\` here and read \`gstack-tree-receipt --from ${GATE_AHEAD_REL}\` before deciding what to rerun.\n`);
else if ((r.status === 'stamped' || r.status === 'noop') && r.queueMode === 'stamp-at-merge') process.stdout.write(`next: NEW_VERSION=${r.newVersion}; commit and push only the stamp diff above (FILES), with no edit of your own: the train's verify accepts the CHANGELOG only as the base's plus this PR's entry re-headed; merge with the expected head ${r.mergeCondition ?? 'sha'} and recompute on a stale queue.\n`);
+108 -3
View File
@@ -28,15 +28,32 @@
# --since midnight-aligned ISO date ("2026-03-11T00:00:00") or a relative
# expression ("24 hours ago"). Default: "7 days ago".
# --until optional window end (compare mode's prior window).
#
# gstack-retro-metrics --history [--next <YYYY-MM-DD>]
#
# Retro history for this project (#3749): prints RETRO_HISTORY_DIR (the
# state root's projects/<slug>/retros, shared by every worktree of the
# repository) and one RETRO_PRIOR line per snapshot, newest first. Moves any
# legacy .context/retros/*.json from this worktree and the main checkout
# into it first: a copy identical to a stored snapshot of the same date
# collapses, a same-name snapshot that differs takes the next free number
# for its date, and a .context/retros that resolves outside its worktree
# is left alone. --next <YYYY-MM-DD> also reserves the next unused
# <date>-<n>.json (written as {} so a concurrent run cannot take it) and
# prints it as RETRO_FILE.
GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac
BASE=""
SINCE="7 days ago"
UNTIL=""
while [ $# -gt 0 ]; do
case "$1" in
--base) BASE="$2"; shift 2 ;;
--since) SINCE="$2"; shift 2 ;;
--until) UNTIL="$2"; shift 2 ;;
--base) BASE="$2"; shift 2 || break ;;
--since) SINCE="$2"; shift 2 || break ;;
--until) UNTIL="$2"; shift 2 || break ;;
--history) HISTORY=1; shift ;;
--next) NEXT_DATE="$2"; shift 2 || break ;;
*) shift ;;
esac
done
@@ -49,6 +66,62 @@ _BIN="$_SCRIPT_DIR"
gstack_state_root_select
_GH="$_gstack_sr_root"
if [ -n "${HISTORY:-}" ]; then
_SLUG=$("$_BIN/gstack-slug" --get SLUG 2>/dev/null || true)
if [ -z "$_SLUG" ]; then
echo "RETRO_HISTORY_ERROR: could not resolve the project slug (gstack-slug failed). fix: reinstall with ./setup or /gstack-upgrade"
exit 0
fi
_HD="$_GH/projects/$_SLUG/retros"
mkdir -p "$_HD" || { echo "RETRO_HISTORY_ERROR: cannot create $_HD"; exit 0; }
if [ -n "${NEXT_DATE:-}" ]; then
case "$NEXT_DATE" in
[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]) ;;
*) echo "RETRO_HISTORY_ERROR: --next takes a YYYY-MM-DD date, got '$NEXT_DATE'"; exit 0 ;;
esac
fi
_HDR=$(cd -P "$_HD" && pwd)
_TOP=$(git rev-parse --show-toplevel 2>/dev/null || true)
_MAIN=$(git worktree list --porcelain 2>/dev/null | sed -n '1s/^worktree //p')
_MOVED=0
for _TREE in "$_TOP" "$_MAIN"; do
[ -n "$_TREE" ] && [ -d "$_TREE/.context/retros" ] || continue
_SRC=$(cd -P "$_TREE/.context/retros" 2>/dev/null && pwd) || continue
_TR=$(cd -P "$_TREE" 2>/dev/null && pwd) || continue
case "$_SRC" in "$_TR"/*) ;; *) continue ;; esac
[ "$_SRC" != "$_HDR" ] || continue
for _F in "$_SRC"/*.json; do
[ -f "$_F" ] && [ ! -L "$_F" ] || continue
_B=$(basename "$_F")
_DUP=""
for _E in "$_HD/$_B" "$_HD/${_B%-*}"-*.json; do
[ -f "$_E" ] && cmp -s "$_F" "$_E" && { _DUP=1; break; }
done
if [ -n "$_DUP" ]; then rm -f "$_F"; continue; fi
_DEST="$_HD/$_B"
_N=1
while [ -e "$_F" ] && [ "$_N" -le 1000 ]; do
[ -e "$_DEST" ] || mv -n "$_F" "$_DEST" 2>/dev/null
[ -e "$_F" ] || { _MOVED=$((_MOVED + 1)); break; }
_N=$((_N + 1)); _DEST="$_HD/${_B%-*}-$_N.json"
done
done
rmdir "$_TREE/.context/retros" 2>/dev/null || true
done
echo "RETRO_HISTORY_DIR: $_HD"
[ "$_MOVED" -gt 0 ] && echo "RETRO_HISTORY_MIGRATED: $_MOVED from .context/retros"
ls -t "$_HD"/*.json 2>/dev/null | sed 's/^/RETRO_PRIOR: /'
if [ -n "${NEXT_DATE:-}" ]; then
_N=1
until ( set -C; printf '{}\n' > "$_HD/$NEXT_DATE-$_N.json" ) 2>/dev/null; do
_N=$((_N + 1))
[ "$_N" -le 1000 ] || { echo "RETRO_HISTORY_ERROR: cannot reserve a snapshot file in $_HD"; exit 0; }
done
echo "RETRO_FILE: $_HD/$NEXT_DATE-$_N.json"
fi
exit 0
fi
echo "RETRO_METRICS_PROTO: 1"
if ! git rev-parse --git-dir >/dev/null 2>&1; then
@@ -375,8 +448,40 @@ fi
# ── Aux inputs (presence only — the model Reads what exists) ─────────────────
[ -f "$_GH/retro-context.md" ] && echo "RETRO_CONTEXT: present ($_GH/retro-context.md)" || echo "RETRO_CONTEXT: absent"
[ -f "$_GH/greptile-history.md" ] && echo "GREPTILE_HISTORY: present ($_GH/greptile-history.md)" || echo "GREPTILE_HISTORY: absent"
[ -f "$_GH/review-bot-history.md" ] && echo "REVIEW_BOT_HISTORY: present ($_GH/review-bot-history.md)" || echo "REVIEW_BOT_HISTORY: absent"
[ -f "TODOS.md" ] && echo "TODOS_FILE: present (TODOS.md)" || echo "TODOS_FILE: absent"
[ -f "$_GH/analytics/skill-usage.jsonl" ] && echo "SKILL_USAGE_LOG: present ($_GH/analytics/skill-usage.jsonl)" || echo "SKILL_USAGE_LOG: absent"
[ -f "$_GH/analytics/eureka.jsonl" ] && echo "EUREKA_LOG: present ($_GH/analytics/eureka.jsonl)" || echo "EUREKA_LOG: absent"
# ── Friction by skill (this project's timeline + question log) ──────────────
# lib/retro-friction.ts does the counting; the window bounds come from git's own
# date parser so they match the commit queries above. Any failure prints
# FRICTION: unavailable and the script still reaches RETRO_METRICS_END.
_FR_SLUG=$("$_BIN/gstack-slug" --get SLUG 2>/dev/null || true)
_FR_SINCE=$(git rev-parse "--since=$SINCE" 2>/dev/null | sed -n 's/^--max-age=//p' | head -1)
_FR_UNTIL=""
[ -n "$UNTIL" ] && _FR_UNTIL=$(git rev-parse "--until=$UNTIL" 2>/dev/null | sed -n 's/^--min-age=//p' | head -1)
_FR_QLOG=""
[ "$("$_BIN/gstack-config" get question_tuning 2>/dev/null)" = "true" ] && _FR_QLOG="$_GH/projects/$_FR_SLUG/question-log.jsonl"
_FR_LIB="$_SCRIPT_DIR/../lib/retro-friction.ts"
case "$(uname -s)" in
MINGW*|MSYS*|CYGWIN*) command -v cygpath >/dev/null 2>&1 && _FR_LIB="$(cygpath -m "$_FR_LIB")" ;;
esac
if [ -z "$_FR_SLUG" ]; then
echo "FRICTION: unavailable (no project slug)"
elif [ -z "$_FR_SINCE" ] || { [ -n "$UNTIL" ] && [ -z "$_FR_UNTIL" ]; }; then
echo "FRICTION: unavailable (window bounds did not resolve)"
elif ! command -v bun >/dev/null 2>&1; then
echo "FRICTION: unavailable (bun not found)"
else
_FR_OUT=$(bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$_FR_LIB" \
--timeline "$_GH/projects/$_FR_SLUG/timeline.jsonl" \
--questions "$_FR_QLOG" \
--since-epoch "$_FR_SINCE" --until-epoch "$_FR_UNTIL" 2>/dev/null) || _FR_OUT=""
case "$_FR_OUT" in
*FRICTION:*) printf '%s\n' "$_FR_OUT" ;;
*) echo "FRICTION: unavailable (friction helper failed)" ;;
esac
fi
echo "RETRO_METRICS_END: ok"
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* gstack-review-bots — the PR review-bot comments /review and /ship triage (fetch, record).
*
* gstack-review-bots fetch [--pr <n>]
* gstack-review-bots record --dir <REVIEW_BOTS_DIR> --id <comment id> --type fix|fp|already-fixed --category <c>
*
* The bots are gstack-pr-wait's lists: gstack-config set pr_wait_gating_bots / pr_wait_advisory_bots.
* Logic: lib/review-bots.ts.
*/
import * as os from "os";
import * as path from "path";
import { spawnSync } from "child_process";
import { runReviewBots } from "../lib/review-bots";
process.exitCode = runReviewBots(process.argv.slice(2), {
run(cmd, args) {
const r = spawnSync(cmd, args, { encoding: "utf8", timeout: 120_000, maxBuffer: 64 * 1024 * 1024 });
return { status: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? (r.error ? String(r.error) : "") };
},
vars: process.env,
binDir: path.resolve(import.meta.dir),
tmpDir: process.env.TMPDIR || os.tmpdir(),
now: () => Date.now(),
out: (t) => process.stdout.write(t),
err: (t) => process.stderr.write(t),
});
+174 -46
View File
@@ -19,33 +19,39 @@ import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
import { BRIEF_ROLES, BriefError, briefFile, promptHash, renderBrief, sectionTexts, type BriefRole, type Slots } from '../lib/review-brief';
import { cleanMarkers, collect, mayBeUnavailable, parseFindings, readJsonl, readLedger, readerKey, recordJudgment, recordSent, appendEvent, verify, type JudgedLine, type MergedLine, type VerifyRow } from '../lib/review-ledger';
import { BRIEF_ROLES, BriefError, briefFile, checkReviewedTree, checkoutRoot, checkoutTree, promptHash, renderBrief, sectionTexts, withWorkspace, type BriefRole, type Slots, type TreeCheck } from '../lib/review-brief';
import { cleanMarkers, collect, fixableRaises, mayBeUnavailable, parseFindings, readJsonl, readLedger, readerKey, recordJudgment, recordSent, appendEvent, verify, type JudgedLine, type MergedLine, type VerifyRow } from '../lib/review-ledger';
import { buildManifest, chunkCount, chunkOf, manifestPath, markReader, readManifest, renderStage, writeManifest, STAGES, type Extra, type Manifest, type PassReader, type ReaderState, type Stage } from '../lib/review-pass';
import type { Dispatch } from '../lib/review-depth';
import { runScriptSync } from '../lib/script-invocation';
import { TARGETED_RULES, parseClassList, targetedBriefSha, targetedPatchId } from '../lib/review-targeted';
import { TARGETED_RULES, parseClassList, targetedBriefSha, targetedRead } from '../lib/review-targeted';
import { resolveBaseBranch } from '../lib/ship-policy';
import { REVIEW_DIFF_MAX_BYTES, ceilingContext, disclosureDigest, disclosureLine, parseTail, readSelection, tooLargeLine, unreadableLine, type PlanExclude, type ReaderDiff } from '../lib/review-exclude';
const HELP = `gstack-review-dispatch — verbatim reviewer briefs and loss-free collection for /ship
render --role <role> [--name <n>] --out <dir> [--ledger <f>] [slots]
roles: ${BRIEF_ROLES.join(', ')}
slots: --diff-base <sha> (every role but fix-check) --exclude '<DIFF_CMD tail>' --stack <s> --test-fw <s>
--keep '<DIFF_KEEP tail after <diff-base>>' --disclosure '<the line every brief carries>' (both only under review_exclude_paths)
--learnings-file <f> --checklist <path> (default <root>/review/specialists/<name>.md; red-team.md for red-team)
--checklists <a,b> (specialist-batched: the plan's checklists= names, or paths) --findings-file <f> --specialists <n> (red-team)
--fix-list <f> --hunks <f> --tests <f> [--callers <f>] (fix-check)
--classes <money,gate,auth,secret> (targeted: the plan's TARGETED classes; the brief is review/specialists/targeted.md;
--diff-base must be the merge base with --base, default origin/HEAD's branch, on every pass)
writes <out>/<role>[-<name>].prompt.md, records it in the ledger (default <out>/review-ledger.jsonl) and prints
writes <out>/<role>[-<name>].prompt.md, the brief followed by the workspace paragraph naming the toplevel of the
checkout it runs in (the one its diff and merge base come from), records it in the ledger (default <out>/review-ledger.jsonl) and prints
BRIEF: reader=<role/name> sha256=<h> bytes=<n> file=<path>. Pass that file's content as the reader's prompt, unchanged.
sent --ledger <f> --role <role> [--name <n>] --sent <file>
the exact prompt a dispatch passed: BRIEF_SENT: … match, or BRIEF_MISMATCH: … (exit 1; the pass is not reviewed)
collect --ledger <f> --role <role> [--name <n>] --output <file> --merged <merged.jsonl>
collect --ledger <f> --role <role> [--name <n>] --output <file> --merged <merged.jsonl> [--manifest <f>]
the reader's raw output: hashes it and appends each finding to merged.jsonl with its label as written. A reply with
no finding must carry its brief's clean marker (NO FINDINGS; the Recommendation: line for the voices); without one it is
COLLECT_NOT_REVIEWED (exit 1): a refusal, an empty reply or a reader that never saw the diff, and verify reports NOT_COLLECTED
until the brief is re-sent and answered with a review, which voids it. --unavailable '<reason>' (the outside voice only; refused when the
until the brief is re-sent and answered with a review, which voids it. A reply whose REVIEWED_TREE line is missing or names
another checkout or tree than its brief was rendered on is COLLECT_WRONG_TREE (COLLECT_TREE_MOVED when only the tree
moved; exit 1, nothing merged, verify reports NOT_COLLECTED, and with --manifest <f> the reader becomes failed); the
outside voice's, when it holds no finding, is recorded as unavailable coverage instead --unavailable '<reason>' (the outside voice only; refused when the
output holds findings) records its failure text as missing coverage: COLLECT_UNAVAILABLE, and verify prints UNAVAILABLE and unavailable=N, never a review
judge --ledger <f> --merged <merged.jsonl> --judged <judged.jsonl>
judged lines {"id","severity","reason"?,"duplicate_of"?}: every merged id kept at its severity or above (a
@@ -54,13 +60,23 @@ const HELP = `gstack-review-dispatch — verbatim reviewer briefs and loss-free
verify --ledger <f> [--targeted-out <file>]
BRIEF, OUTPUT and JUDGE rows from the ledger alone, then VERIFY: clean (exit 0), VERIFY: dirty (exit 1) or
VERIFY: no brief records (exit 3: this run's ledger kept no prompts). A ledger with a targeted brief also prints
TARGETED_RECORD: and --targeted-out writes it as the JSON gstack-ship-receipt write --review-targeted reads
pass --base <ref> --out <dir> --pass <id> [--ledger <f>] [--diff-base <sha>] [--learnings-file <f>] [--extras <json>]
[--checklist-root <dir>] [-- <gstack-review-plan flags>]
TARGETED_RECORD: and --targeted-out writes it as the JSON gstack-ship-receipt write --review-targeted reads; under
review_exclude_paths prints REVIEW_EXCLUDE_RECORD: digest=<d> (the selection every reader's newest brief names: the
value gstack-ship-receipt write --review-exclude takes), and a mixed set of digests makes it dirty
pass --base <ref> --out <dir> --pass <id> [--ledger <f>] [--diff-base <sha>] [--learnings-file <f>] [--extras <json>] [--audit-only]
[--checklist-root <dir>] [--fix-list <f> --hunks <f> --tests <f> [--callers <f>]] [-- <gstack-review-plan flags>]
runs gstack-review-plan and writes <out>/pass-manifest.json: stage first (audit readers marked first in --extras),
specialists (with the other --extras readers), red-team, voices (Step 11). Renders first and specialists now; prints
the plan, MANIFEST: and one READER: line per reader. A plan whose NEXT is not full or scoped prints PASS: none.
the plan, MANIFEST: and one READER: line per reader. A plan whose NEXT is not full, scoped or fix-check prints PASS: none.
A fix-check reader (stage specialists) renders from decision 3.5's --fix-list, --hunks, --tests and --callers files.
--diff-base sets every reader's base, a scoped pass's Red Team included (the targeted reader keeps the merge base).
An existing manifest for the same --pass is reused (resumed). --extras: [{"name","prompt_file","first"?,"model"?}]
--audit-only skips the plan: the --extras readers are the whole pass (Step 14.5's docs audit)
Before anything is written (and again before stage renders), each reviewer's diff is read once with a
${REVIEW_DIFF_MAX_BYTES}-byte ceiling: over it prints REVIEW_DIFF_TOO_LARGE with the largest folders and the fix for where the
base policy's review_exclude_paths stands (exit 3); a git failure or timeout prints REVIEW_DIFF_UNREADABLE with the
stderr written under --out (exit 1). render applies the same ceiling to the selection --exclude and --keep name
whenever --diff-base is a commit here (always for the outside voice, whose brief embeds that selection).
show --manifest <f> MANIFEST_JSON: <the manifest on one line>
stage --manifest <f> --stage <first|specialists|red-team|voices>
renders a stage; the Red Team only after every earlier reader is collected, its findings file being the
@@ -68,7 +84,8 @@ const HELP = `gstack-review-dispatch — verbatim reviewer briefs and loss-free
chunk --manifest <f> --reader <r> --index <i>
BRIEF_CHUNK: reader=… index=i of=n sha256=…, then one JSON string line of at most 65,536 brief characters
mark --manifest <f> --reader <r> --state <intent|created|queued|collected|failed> [--path <p>] [--output <f>] [--error <e>]
mark --manifest <f> --before <tokens>,<credits>
an audit reader is collected with --output, whose REVIEWED_TREE line must match its render (else failed, COLLECT_WRONG_TREE)
mark --manifest <f> --before <tokens>,<credits> [--voices] the pass's reading at its first dispatch; --voices keeps Step 11's own
mark --manifest <f> --log-start <key> --token <t> records a review-log start token once, so a rerun starts only what is missing
reader states move forward only; intent is recorded before the subagent is created, its path right after
program [--ledger <f>]
@@ -94,46 +111,77 @@ function render(): number {
const brief = role === 'targeted' ? readFile(checklist, 'targeted brief') : undefined;
if (role === 'targeted') {
const base = resolveBaseBranch(process.cwd(), flag('--base'));
const mb = [`origin/${base}`, base].map(ref => spawnSync('git', ['merge-base', ref, 'HEAD'], { encoding: 'utf8', timeout: 30_000 })).find(r => r.status === 0)?.stdout.trim();
const mb = mergeBaseWith(base);
const full = diffBase ? spawnSync('git', ['rev-parse', '--verify', '--quiet', `${diffBase}^{commit}`], { encoding: 'utf8', timeout: 30_000 }).stdout?.trim() : undefined;
if (!full || full !== mb) throw new BriefError(`the targeted reader reads the whole diff on every pass: --diff-base must be the merge base with ${base} (${mb ?? 'unresolved'}), got ${diffBase ?? 'none'}`);
if (!flag('--disclosure') && ceilingContext(process.cwd(), base).state === 'active') throw new BriefError(`${base}'s review_exclude_paths is active, so the targeted reader renders in the pass manifest's voices stage (gstack-review-dispatch stage --manifest <f> --stage voices), which carries the exclusion and its disclosure line`);
}
const checklists = flag('--checklists')?.split(',').map(s => s.trim()).filter(Boolean).map(c => (c.includes('/') ? c : path.join(ROOT, 'review/specialists', `${c}.md`)));
for (const c of checklists ?? []) if (!fs.existsSync(c)) throw new BriefError(`checklist not found: ${c}`);
let diff: string | undefined;
if (role === 'outside-voice' && diffBase) {
const r = spawnSync('git', ['-c', 'core.quotePath=false', 'diff', '--no-color', '--no-ext-diff', diffBase], { encoding: 'utf8', timeout: 60_000, maxBuffer: 256 * 1024 * 1024 });
if (r.status !== 0) throw new BriefError(`git diff ${diffBase} failed: ${(r.stderr ?? '').trim().split('\n')[0]}`);
diff = r.stdout;
const keepItems = parseTail(flag('--keep')).filter(s => s !== '--');
const policyItems = parseTail(flag('--exclude')).filter(s => s.startsWith(':(top,exclude,glob)'));
const resolvable = !!diffBase && git(['rev-parse', '--verify', '--quiet', `${diffBase}^{commit}`]) !== null;
if (role !== 'fix-check' && diffBase && (resolvable || role === 'outside-voice')) {
const over = ceiling('gstack-review-dispatch render', resolveBaseBranch(process.cwd(), flag('--base')), out, [{ reader: readerKey(role, name), exclude: flag('--exclude'), keep: keepItems }], diffBase, undefined);
if (over) { process.stderr.write(over.line + '\n'); return over.exit; }
if (role === 'outside-voice') diff = branchDiff(diffBase, { exclude: flag('--exclude'), keep: keepItems });
}
const slots: Slots = {
name, diff_base: diffBase, exclude: flag('--exclude'), stack: flag('--stack'), test_fw: flag('--test-fw'),
learnings: readFile(flag('--learnings-file'), 'learnings'), checklist, checklists: checklists?.join(','),
findings: readFile(flag('--findings-file'), 'findings'), specialists: flag('--specialists'), diff,
classes: flag('--classes'), brief,
...(flag('--disclosure') ? { disclosure: flag('--disclosure'), policy_exclude: policyItems.map(quote).join(' ') || undefined, keep: keepItems.map(quote).join(' ') || undefined } : {}),
fix_list: readFile(flag('--fix-list'), 'fix list'), hunks: readFile(flag('--hunks'), 'hunks'), tests: readFile(flag('--tests'), 'tests'), callers: readFile(flag('--callers'), 'callers'),
};
const text = renderBrief(role, slots, sectionTexts(ROOT));
const workdir = checkoutRoot();
const tree = checkoutTree(workdir);
if (!tree) throw new BriefError(`cannot read HEAD^{tree} and the toplevel of ${workdir}; a brief names the checkout its reply must prove`);
const text = withWorkspace(renderBrief(role, slots, sectionTexts(ROOT)), workdir);
fs.mkdirSync(out, { recursive: true, mode: 0o700 });
const file = path.join(path.resolve(out), briefFile(role, name));
fs.writeFileSync(file, text, { mode: 0o600 });
const ledger = flag('--ledger') ?? path.join(out, 'review-ledger.jsonl');
const sha = promptHash(text);
const targeted = brief === undefined ? {} : { classes: parseClassList(flag('--classes')!), rules: TARGETED_RULES, brief_sha: targetedBriefSha(checklist), diff_base: diffBase ?? null, patch_id: diffBase ? targetedPatchId(process.cwd(), diffBase) : 'none' };
appendEvent(ledger, { event: 'rendered', reader: readerKey(role, name), role, name: name ?? null, file, sha256: sha, bytes: Buffer.byteLength(text), ...targeted });
const targeted = brief === undefined ? {} : { classes: parseClassList(flag('--classes')!), rules: TARGETED_RULES, brief_sha: targetedBriefSha(checklist), diff_base: diffBase ?? null, ...(diffBase ? targetedRead(process.cwd(), diffBase) : { patch_id: 'none', head: null }) };
appendEvent(ledger, { event: 'rendered', reader: readerKey(role, name), role, name: name ?? null, file, sha256: sha, bytes: Buffer.byteLength(text), workdir, tree, ...targeted, ...(disclosureDigest(flag('--disclosure')) ? { exclude: disclosureDigest(flag('--disclosure')) } : {}) });
process.stdout.write(`BRIEF: reader=${readerKey(role, name)} sha256=${sha} bytes=${Buffer.byteLength(text)} file=${file}\n`);
return EXIT.ok;
}
const quote = (s: string) => `'${s.replace(/'/g, "'\\''")}'`;
const wrongTree = (reader: string, t: TreeCheck) => `${t.moved ? 'COLLECT_TREE_MOVED' : 'COLLECT_WRONG_TREE'}: reader=${reader} reported=${JSON.stringify(t.reported)} expected=${JSON.stringify(t.expected)} (${t.moved ? 'the checkout moved after this brief was rendered' : 'the reply did not review the checkout its brief named'}; nothing merged; restore it with a fresh pass under a new --pass and --out)\n`;
const PROGRAM_FILE = path.join(ROOT, 'lib', 'capy-review-pass.js');
const PROGRAM_VERSION = 1;
const git = (a: string[]) => { const r = spawnSync('git', a, { encoding: 'utf8', timeout: 60_000, maxBuffer: 256 * 1024 * 1024 }); return r.status === 0 ? r.stdout : null; };
const mergeBaseWith = (base: string) => [`origin/${base}`, base].map(ref => git(['merge-base', ref, 'HEAD'])?.trim()).find(Boolean);
const readerLine = (m: Manifest, r: PassReader) => `READER: ${r.reader} stage=${r.stage} state=${r.state} title="${r.title}"${r.sha256 ? ` sha256=${r.sha256} bytes=${r.bytes} chunks=${r.chunks}` : ''}${r.path ? ` path=${r.path}` : ''}`;
const branchDiff = (base: string) => {
const d = git(['-c', 'core.quotePath=false', 'diff', '--no-color', '--no-ext-diff', base]);
if (d === null) throw new BriefError(`git diff ${base} failed`);
return d;
/** Each selection is read once per process: the ceiling check and the outside voice's embedded diff share it. */
const selections = new Map<string, ReaderDiff>();
const selectionOf = (base: string, exclude: string | undefined, keep: readonly string[] = []) => {
const key = JSON.stringify([base, exclude ?? '', keep]);
if (!selections.has(key)) selections.set(key, readSelection(process.cwd(), base, parseTail(exclude), keep));
return selections.get(key)!;
};
const branchDiff = (base: string, r?: Pick<PassReader, 'exclude' | 'keep'>) => {
const d = selectionOf(base, r?.exclude, r?.keep);
if (!d.ok) throw new BriefError(`git diff ${base} failed`);
return d.text;
};
/** The ceiling over every reader about to render: the first one over or unreadable stops the step before it writes anything else. */
function ceiling(context: string, base: string, outDir: string, readers: ReadonlyArray<Pick<PassReader, 'reader' | 'exclude' | 'keep' | 'diff_base' | 'prompt_file'> & { diff_base?: string }>, diffBase: string, known: PlanExclude | undefined): { exit: number; line: string } | null {
for (const r of readers) {
if (r.prompt_file) continue;
const at = r.diff_base ?? diffBase;
const d = selectionOf(at, r.exclude, r.keep);
if (!d.ok) return { exit: EXIT.fail, line: unreadableLine(context, r.reader, d, at, base, outDir) };
if (d.bytes > REVIEW_DIFF_MAX_BYTES) return { exit: EXIT.refused, line: tooLargeLine(context, r.reader, d, ceilingContext(process.cwd(), base, known)).line };
}
return null;
}
function pass(): number {
const base = flag('--base'), out = flag('--out'), id = flag('--pass');
@@ -146,23 +194,45 @@ function pass(): number {
return EXIT.ok;
}
const planArgs = args.includes('--') ? args.slice(args.indexOf('--') + 1) : [];
if (args.includes('--audit-only')) {
const extrasFile = flag('--extras');
if (!extrasFile) { process.stderr.write('gstack-review-dispatch pass --audit-only: needs --extras (the audit readers, e.g. Step 14.5\'s docs reader)\n'); return EXIT.usage; }
const extras = JSON.parse(readFile(extrasFile, 'extras')!) as Extra[];
const m = buildManifest({ pass: id, out, ledger: flag('--ledger') ?? path.join(out, 'review-ledger.jsonl'), base, diffBase: mergeBaseWith(base) ?? 'HEAD', root: git(['rev-parse', '--show-toplevel'])?.trim() ?? process.cwd(),
checklistRoot: flag('--checklist-root') ?? ROOT, dispatches: [], next: 'audit', planLines: ['PASS: audit-only (no reviewer plan; its extras run as one batch)'], extras });
fs.mkdirSync(m.out, { recursive: true, mode: 0o700 });
const t = sectionTexts(ROOT);
renderStage(m, 'first', t, branchDiff);
renderStage(m, 'specialists', t, branchDiff);
writeManifest(file, m);
process.stdout.write([...m.plan_lines, `MANIFEST: file=${file} pass=${id} readers=${m.readers.length} audit-only`, ...m.readers.map(r => readerLine(m, r))].join('\n') + '\n');
return EXIT.ok;
}
const plan = (json: boolean) => runScriptSync(path.join(ROOT, 'bin', 'gstack-review-plan'), ['--base', base, ...planArgs, ...(json ? ['--json'] : [])], { encoding: 'utf8', timeout: 180_000, maxBuffer: 64 * 1024 * 1024 });
const text = plan(false);
if (text.status !== 0) { process.stdout.write(text.stdout ?? ''); process.stderr.write(text.stderr ?? ''); return EXIT.fail; }
const j = plan(true);
if (j.status !== 0) { process.stderr.write(j.stderr ?? ''); return EXIT.fail; }
const p = JSON.parse(j.stdout) as { next: { step: string }; dispatches: Dispatch[]; targeted?: { classes: string[] } };
const planLines = text.stdout.trimEnd().split('\n');
const p = JSON.parse(j.stdout) as { next: { step: string }; dispatches: Dispatch[]; targeted?: { classes: string[] }; fix_range?: { base: string } | null; review_exclude?: Record<string, unknown> & { state: string; excluded?: number; kept_files?: unknown; references?: unknown } };
const step = p.next.step;
if (step !== 'full' && step !== 'scoped') { process.stdout.write([...planLines, `PASS: none next=${step} (no reviewer pass; follow NEXT)`].join('\n') + '\n'); return EXIT.ok; }
const diffBase = flag('--diff-base') ?? git(['merge-base', base, 'HEAD'])?.trim();
const fixChecks = step === 'fix-check' ? [] : p.dispatches.filter(d => d.role === 'fix-check');
const planLines = [...text.stdout.trimEnd().split('\n'), ...fixChecks.map(d => `FIX_CHECK: planned effort=${d.effort} (Step 3.5 runs it on its own; not in this manifest)`)];
if (step !== 'full' && step !== 'scoped' && step !== 'fix-check') { process.stdout.write([...planLines, `PASS: none next=${step} (no reviewer pass; follow NEXT)`].join('\n') + '\n'); return EXIT.ok; }
const rangeBase = step === 'scoped' ? git(['rev-parse', '--verify', '--quiet', `${p.fix_range?.base ?? ''}^{commit}`])?.trim() : undefined;
if (step === 'scoped' && !rangeBase) throw new BriefError('a scoped plan names no readable fix range base');
const mergeBase = mergeBaseWith(base);
const diffBase = flag('--diff-base') ?? (planArgs.includes('--adversarial-ran') ? rangeBase : undefined) ?? mergeBase;
if (!diffBase) throw new BriefError(`no merge base between ${base} and HEAD`);
const root = git(['rev-parse', '--show-toplevel'])?.trim() ?? process.cwd();
const extrasFile = flag('--extras');
const extras = extrasFile ? JSON.parse(readFile(extrasFile, 'extras')!) as Extra[] : [];
const m = buildManifest({ pass: id, out, ledger: flag('--ledger') ?? path.join(out, 'review-ledger.jsonl'), base, diffBase, root, checklistRoot: flag('--checklist-root') ?? ROOT,
dispatches: p.dispatches, next: step, planLines, learnings: readFile(flag('--learnings-file'), 'learnings') ?? null, extras,
targetedClasses: p.targeted?.classes, mergeBase: git(['merge-base', base, 'HEAD'])?.trim() });
dispatches: step === 'fix-check' ? p.dispatches : p.dispatches.filter(d => d.role !== 'fix-check'), next: step, planLines, learnings: readFile(flag('--learnings-file'), 'learnings') ?? null, extras,
targetedClasses: p.targeted?.classes, mergeBase, rangeBase: flag('--diff-base') ? undefined : rangeBase,
fixCheck: step === 'fix-check' && (flag('--fix-list') || flag('--hunks') || flag('--tests')) ? { fix_list: flag('--fix-list')!, hunks: flag('--hunks')!, tests: flag('--tests')!, callers: flag('--callers') } : undefined,
...planExclude(p.review_exclude) });
const over = ceiling('gstack-review-dispatch pass', base, m.out, m.readers, m.diff_base, (m.review_exclude as PlanExclude) ?? null);
if (over) { process.stdout.write(planLines.join('\n') + '\n'); process.stderr.write(over.line + '\n'); return over.exit; }
fs.mkdirSync(m.out, { recursive: true, mode: 0o700 });
const t = sectionTexts(ROOT);
renderStage(m, 'first', t, branchDiff);
@@ -173,6 +243,13 @@ function pass(): number {
return EXIT.ok;
}
/** The manifest's copy of the plan's review_exclude (no file lists) and the brief's disclosure line, when the plan had one. */
function planExclude(x: (Record<string, unknown> & { state: string; excluded?: number }) | undefined): { reviewExclude?: Record<string, unknown>; disclosure?: string } {
if (!x) return {};
const { kept_files: _k, references: _r, ...rest } = x;
return { reviewExclude: rest, ...(x.state === 'active' ? { disclosure: disclosureLine(x as unknown as Parameters<typeof disclosureLine>[0]) } : {}) };
}
function passStep(cmd: string): number {
const file = flag('--manifest');
if (!file) { process.stderr.write(`gstack-review-dispatch ${cmd}: --manifest <file> is required\n`); return EXIT.usage; }
@@ -181,6 +258,9 @@ function passStep(cmd: string): number {
if (cmd === 'stage') {
const stage = flag('--stage') as Stage | undefined;
if (!stage || !STAGES.includes(stage)) { process.stderr.write(`gstack-review-dispatch stage: --stage ${STAGES.join('|')}\n`); return EXIT.usage; }
const pending = m.readers.filter(r => r.stage === stage && r.state === 'planned');
const over = ceiling('gstack-review-dispatch stage', m.base, m.out, pending, m.diff_base, (m.review_exclude as PlanExclude) ?? null);
if (over) { process.stderr.write(over.line + '\n'); return over.exit; }
const done = renderStage(m, stage, sectionTexts(ROOT), branchDiff);
writeManifest(file, m);
process.stdout.write([`STAGE: ${stage} rendered=${done.length}`, ...done.map(r => readerLine(m, r))].join('\n') + '\n');
@@ -209,12 +289,24 @@ function passStep(cmd: string): number {
const before = flag('--before');
if (before) {
const [tokens, credits] = before.split(',').map(Number);
if (!Number.isFinite(tokens) || !Number.isFinite(credits)) { process.stderr.write('gstack-review-dispatch mark: --before <tokens>,<credits>\n'); return EXIT.usage; }
m.before ??= { tokens: tokens!, credits: credits! };
if (!Number.isFinite(tokens) || !Number.isFinite(credits)) { process.stderr.write('gstack-review-dispatch mark: --before <tokens>,<credits> [--voices]\n'); return EXIT.usage; }
const key = args.includes('--voices') ? 'before_voices' : 'before';
m[key] ??= { tokens: tokens!, credits: credits! };
writeManifest(file, m);
process.stdout.write(`BEFORE: tokens=${m.before.tokens} credits=${m.before.credits}\n`);
process.stdout.write(`BEFORE: ${key === 'before_voices' ? 'stage=voices ' : ''}tokens=${m[key]!.tokens} credits=${m[key]!.credits}\n`);
return EXIT.ok;
}
const audit = m.readers.find(x => x.reader === flag('--reader'));
if (flag('--state') === 'collected' && audit?.prompt_file && audit.tree !== undefined) {
if (!flag('--output')) { process.stderr.write(`gstack-review-dispatch mark: ${audit.reader} is collected with --output <its reply>, which must prove the checkout it reviewed\n`); return EXIT.usage; }
const tree = checkReviewedTree(readFile(flag('--output'), 'reader output')!, audit.tree);
if (!tree.ok) {
markReader(m, audit.reader, 'failed', { output: flag('--output'), error: `reviewed another tree: ${tree.reported}` });
writeManifest(file, m);
process.stdout.write(wrongTree(audit.reader, tree));
return EXIT.fail;
}
}
const r = markReader(m, flag('--reader') ?? '', flag('--state') as ReaderState, { path: flag('--path'), output: flag('--output'), error: flag('--error') });
writeManifest(file, m);
process.stdout.write(readerLine(m, r) + '\n');
@@ -234,19 +326,35 @@ function program(): number {
/**
* The receipt's review_targeted record from the newest targeted dispatch: done only when its brief was sent verbatim, its output
* collected, and the committed diff it was rendered on is still the one at verify (targetedPatchId; uncommitted never proves it).
* collected, and it was rendered on a committed diff (uncommitted never proves what was read). `head` names the commit it read:
* a later commit does not undo the read, and gstack-ship-receipt write binds the record only while that commit's product patch is
* the receipt's (GST-986: Step 11 fixes after the read printed status=failed findings=0 for a completed reader).
*/
function targetedRecord(ledger: string, rows: VerifyRow[]): { classes: string[]; rules: string; brief_sha: string; status: 'done' | 'failed'; findings: number; raised_by: string } | null {
function targetedRecord(ledger: string, rows: VerifyRow[]): { classes: string[]; rules: string; brief_sha: string; status: 'done' | 'failed'; findings: number; raised_by: string; head: string | null } | null {
const events = readLedger(ledger);
const render = events.filter(e => e.event === 'rendered' && e.reader === 'targeted').at(-1);
if (!render) return null;
const label = rows.filter(r => r.kind === 'BRIEF' && r.reader.split('@')[0] === 'targeted').at(-1)?.reader;
const brief = rows.filter(r => r.kind === 'BRIEF' && r.reader === label).at(-1);
const output = rows.filter(r => r.kind === 'OUTPUT' && r.reader === label).at(-1);
const read = typeof render.patch_id === 'string' && !['uncommitted', 'none'].includes(render.patch_id) && typeof render.diff_base === 'string' && targetedPatchId(process.cwd(), render.diff_base) === render.patch_id;
const done = brief?.status === 'MATCH' && output?.status === 'MATCH' && read;
const head = typeof render.head === 'string' && /^[0-9a-f]{40,64}$/.test(render.head) ? render.head : null;
const committed = typeof render.patch_id === 'string' && !['uncommitted', 'none'].includes(render.patch_id) && head !== null;
const done = brief?.status === 'MATCH' && output?.status === 'MATCH' && committed;
const ids = events.filter(e => e.event === 'output' && e.reader === 'targeted').at(-1)?.ids;
return { classes: (render.classes as string[]) ?? [], rules: String(render.rules), brief_sha: String(render.brief_sha), status: done ? 'done' : 'failed', findings: done && Array.isArray(ids) ? ids.length : 0, raised_by: 'targeted' };
return { classes: (render.classes as string[]) ?? [], rules: String(render.rules), brief_sha: String(render.brief_sha), status: done ? 'done' : 'failed', findings: Array.isArray(ids) ? ids.length : 0, raised_by: 'targeted', head };
}
/**
* The selection the verified briefs read under review_exclude_paths: the digest every reader's newest brief names (fix-check reads
* named hunks, not the selection); null when no brief names one, so a repository without the key verifies as before.
*/
function excludeRecord(ledger: string): { digest: string } | { mixed: string[] } | null {
const newest = new Map<string, Record<string, unknown>>();
for (const e of readLedger(ledger)) if (e.event === 'rendered' && e.role && e.role !== 'fix-check') newest.set(String(e.reader), e);
const named = [...newest.values()].map(e => (typeof e.exclude === 'string' ? e.exclude : 'none'));
if (!named.some(d => d !== 'none')) return null;
const digests = [...new Set(named)];
return digests.length === 1 ? { digest: digests[0]! } : { mixed: digests };
}
function main(): number {
@@ -276,7 +384,23 @@ function main(): number {
if (unavailable !== undefined && !mayBeUnavailable(reader)) { process.stderr.write(`gstack-review-dispatch collect: --unavailable is for the outside voice only; ${reader}'s missing coverage leaves the review incomplete, never clean\n`); return EXIT.usage; }
if (unavailable !== undefined && !unavailable.trim()) { process.stderr.write('gstack-review-dispatch collect: --unavailable needs a reason\n'); return EXIT.usage; }
if (unavailable !== undefined && parseFindings(reader, output).length) { process.stderr.write(`gstack-review-dispatch collect: ${reader}'s output holds findings; collect it without --unavailable so none is lost\n`); return EXIT.usage; }
const r = collect(ledger, reader, output, merged, path.resolve(outFile!), unavailable);
const rendered = readLedger(ledger).filter(e => e.event === 'rendered' && e.reader === reader).at(-1);
const tree = unavailable === undefined && rendered && 'tree' in rendered ? checkReviewedTree(output, typeof rendered.tree === 'string' ? rendered.tree : null) : undefined;
if (tree && !tree.ok && mayBeUnavailable(reader) && !parseFindings(reader, output).length) {
const why = `reviewed another tree: reported ${tree.reported}, expected ${tree.expected}`;
const r = collect(ledger, reader, output, merged, path.resolve(outFile!), why);
process.stdout.write(`COLLECT_UNAVAILABLE: reader=${reader} sha256=${r.sha} reason=${JSON.stringify(why)} (missing outside coverage: unavailable, never clean)\n`);
return EXIT.ok;
}
const mf = flag('--manifest');
let plan: ReturnType<typeof readManifest> | undefined;
try { plan = mf ? readManifest(mf) : undefined; } catch (e) { process.stderr.write(`gstack-review-dispatch collect: ${(e as Error).message}; native FIXABLE collects as CRITICAL\n`); }
const r = collect(ledger, reader, output, merged, path.resolve(outFile!), unavailable, tree, fixableRaises(plan && path.resolve(plan.ledger) === path.resolve(ledger) ? plan.plan_lines : undefined));
if (tree && !tree.ok) {
if (mf && plan) { markReader(plan, reader, 'failed', { output: outFile, error: `reviewed another tree: ${tree.reported}` }); writeManifest(mf, plan); }
process.stdout.write(wrongTree(reader, tree));
return EXIT.fail;
}
if (unavailable !== undefined) { process.stdout.write(`COLLECT_UNAVAILABLE: reader=${reader} sha256=${r.sha} reason=${JSON.stringify(unavailable)} (missing outside coverage: unavailable, never clean)\n`); return EXIT.ok; }
if (!r.review) {
process.stdout.write(`COLLECT_NOT_REVIEWED: reader=${reader} findings=0 sha256=${r.sha} (no finding and no ${cleanMarkers(reader).map(([m]) => m).join(' or ')} line: a refusal, an empty reply or a reader that never saw the diff is not a review; verify reports this brief NOT_COLLECTED)\n`);
@@ -290,27 +414,31 @@ function main(): number {
const judged = flag('--judged');
if (!merged || !judged) { process.stderr.write('gstack-review-dispatch judge: needs --merged <file> and --judged <file>\n'); return EXIT.usage; }
const r = recordJudgment(ledger, readJsonl<MergedLine>(merged), readJsonl<JudgedLine>(judged), judged, merged);
process.stdout.write(r.ok ? `JUDGMENT: ok added=${r.added.length} promoted=${r.promoted.length} folded=${r.folded.length}\n`
: `JUDGMENT_REJECTED: dropped=${JSON.stringify(r.dropped)} downgraded=${JSON.stringify(r.downgraded)} unexplained=${JSON.stringify(r.unexplained)} invalid=${JSON.stringify(r.invalid)} (lead judgment may add or promote a finding, never drop or downgrade one)\n`);
process.stdout.write(r.ok ? `JUDGMENT: ok added=${r.added.length} promoted=${r.promoted.length} folded=${r.folded.length}${r.dismissed.length ? ` dismissed=${r.dismissed.length}` : ''}\n`
: `JUDGMENT_REJECTED: dropped=${JSON.stringify(r.dropped)} downgraded=${JSON.stringify(r.downgraded)} unexplained=${JSON.stringify(r.unexplained)} invalid=${JSON.stringify(r.invalid)} (lead judgment may add or promote a finding, never drop or downgrade one; a summary_candidate line may be lowered with a reason)\n`);
return r.ok ? EXIT.ok : EXIT.fail;
}
if (cmd === 'verify') {
if (flag('--targeted-out')) fs.rmSync(flag('--targeted-out')!, { force: true });
const v = verify(ledger);
for (const r of v.rows) process.stdout.write(`${r.kind} ${r.reader} ${r.status} ${r.detail}\n`);
if (!v.briefs) { process.stdout.write('VERIFY: no brief records (this run\'s ledger kept no prompts; dispatched-vs-rendered cannot be checked)\n'); return EXIT.refused; }
const record = targetedRecord(ledger, v.rows);
if (record) {
process.stdout.write(`TARGETED_RECORD: classes=${record.classes.join(',')} status=${record.status} findings=${record.findings} brief=${record.brief_sha.slice(0, 12)} rules=${record.rules}\n`);
process.stdout.write(`TARGETED_RECORD: classes=${record.classes.join(',')} status=${record.status} findings=${record.findings} brief=${record.brief_sha.slice(0, 12)} rules=${record.rules} read=${record.head?.slice(0, 12) ?? 'none'}\n`);
if (flag('--targeted-out')) fs.writeFileSync(flag('--targeted-out')!, JSON.stringify(record) + '\n');
}
process.stdout.write(`VERIFY: ${v.clean ? 'clean' : 'dirty'} briefs=${v.briefs} outputs=${v.rows.filter(r => r.kind === 'OUTPUT').length} voided=${v.voided}${v.unavailable ? ` unavailable=${v.unavailable}` : ''}\n`);
return v.clean ? EXIT.ok : EXIT.fail;
const sel = excludeRecord(ledger);
if (sel && 'digest' in sel) process.stdout.write(`REVIEW_EXCLUDE_RECORD: digest=${sel.digest} (gstack-ship-receipt write --review-exclude ${sel.digest})\n`);
if (sel && 'mixed' in sel) process.stdout.write(`REVIEW_EXCLUDE_RECORD: mixed digests=${sel.mixed.join(',')} (readers read different review_exclude_paths selections; rerun the review so every reader reads HEAD's)\n`);
const clean = v.clean && !(sel && 'mixed' in sel);
process.stdout.write(`VERIFY: ${clean ? 'clean' : 'dirty'} briefs=${v.briefs} outputs=${v.rows.filter(r => r.kind === 'OUTPUT').length} voided=${v.voided}${v.unavailable ? ` unavailable=${v.unavailable}` : ''}\n`);
return clean ? EXIT.ok : EXIT.fail;
}
process.stderr.write(`gstack-review-dispatch: unknown command ${cmd}\n${HELP}\n`);
return EXIT.usage;
}
try { process.exit(main()); } catch (e) {
if (e instanceof BriefError) { process.stderr.write(`BRIEF_RENDER_FAILED: ${e.message}\n`); process.exit(EXIT.fail); }
throw e;
try { process.exitCode = main(); } catch (e) {
if (e instanceof BriefError) { process.stderr.write(`BRIEF_RENDER_FAILED: ${e.message}\n`); process.exitCode = EXIT.fail; } else throw e;
}
+2 -1
View File
@@ -27,6 +27,7 @@ export GIT_OPTIONAL_LOCKS=0
export GIT_CONFIG_PARAMETERS="${GIT_CONFIG_PARAMETERS:+$GIT_CONFIG_PARAMETERS }'core.fsmonitor=false' 'core.untrackedCache=false'"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
SLUG=$("$SCRIPT_DIR/gstack-slug" --get REPO_SLUG 2>/dev/null || true)
. "$(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"
@@ -68,7 +69,7 @@ if [ -n "$COMMIT_FULL" ]; then
esac
trap 'rm -rf "$WTREE_OBJECTS"' EXIT
trap 'exit 130' INT TERM
WTREE=$(GSTACK_WTREE_OBJECT_DIR="$WTREE_OBJECTS" "$SCRIPT_DIR/gstack-wtree" || true)
WTREE=$(GSTACK_WTREE_OBJECT_DIR="$WTREE_OBJECTS" "$SCRIPT_DIR/gstack-wtree" --review || true)
export GIT_ALTERNATE_OBJECT_DIRECTORIES="$WTREE_OBJECTS${GIT_ALTERNATE_OBJECT_DIRECTORIES:+:$GIT_ALTERNATE_OBJECT_DIRECTORIES}"
else
echo "gstack-review-log: cannot create a private object directory under ${TMPDIR:-/tmp}; no working-tree fingerprint. Set TMPDIR to a writable directory." >&2
+66 -17
View File
@@ -5,7 +5,7 @@
* gstack-review-plan --base <ref> [--pass <full passes run>] [--last none|full|fix-check] [--fixed-blocking] [--fixed-below]
* [--blocking-open] [--touched-gate] [--critical-found] [--adversarial-ran] [--gate-tagged <n>] [--gated a,b] [--force a,b|all]
* [--reviewed-numbers <fingerprint>] [--depth-floor standard|full] [--range-base <sha>] [--pass1-depth light|standard|full] [--no-tiers]
* [--targeted-ran <targeted_patch_id>] [--json]
* [--targeted-ran <targeted_patch_id>] [--grant <approval id>] [--json]
* gstack-review-plan --fix-check-prompt --fix-list <file> --hunks <file> --tests <file> [--callers <file>]
*
* Reads the diff against the merge base with <ref> (committed, tracked uncommitted
@@ -16,15 +16,23 @@
* FIX_RANGE: (with --range-base), REVIEW_PLAN:, one DEPTH_ERROR: per input that failed
* the depth closed, one DISPATCH: plus DIFF_CMD: (and DIFF_EXCLUDE: none) per
* reviewer and one SKIP: per reviewer left out. A plan that fails closed to full
* exits 0; a diff git cannot list exits 1. Logic: lib/review-depth.ts.
* exits 0; a diff git cannot list exits 1, and so does a base that is HEAD or already
* contains it while the branch has commits its real base lacks (BASE_CONTAINS_HEAD,
* lib/default-base.ts). Logic: lib/review-depth.ts.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
import { baseContainsHead } from '../lib/default-base';
import { locate, readRecord } from '../lib/approval-record';
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
import { resultLine } from '../lib/result-codes';
import { runScriptSync } from '../lib/script-invocation';
import { collectCommit, collectLive } from '../lib/review-collect';
import { classifyDiff, diffCommand, planReview, reviewDepthLine, reviewPlanLine, scanNumbers, SCOPED_RANGE_LINES, type Depth, type History } from '../lib/review-depth';
import { excludeLines, reviewExclude, type Exclusion } from '../lib/review-exclude';
import { isUnavailable } from '../lib/ship-policy';
import { loadPolicyAt } from '../lib/ship-policy-at';
import { classifyDiff, diffCommand, keepCommand, planReview, reviewDepthLine, reviewPlanLine, scanNumbers, SCOPED_RANGE_LINES, type Depth, type History } from '../lib/review-depth';
import { classList, targetedBriefSha, targetedClasses, targetedPatchId } from '../lib/review-targeted';
import { renderFixCheckPrompt } from '../scripts/resolvers/fix-check';
@@ -54,11 +62,17 @@ const HELP = `gstack-review-plan — the review depth of the next /ship step (do
--pass1-depth <d> pass 1's REVIEW_DEPTH depth: full, or absent, keeps pass 2 full
--targeted-ran <id> the TARGETED line's targeted_patch_id (the whole committed diff, tests included) when the targeted
reader last read it: the same patch skips it; any other id, none, or uncommitted edits dispatch it again
--grant <approval id> the owner's grant of one scoped fix-check after round 2, recorded with gstack-approvals add (approved,
its ask or scope naming review_loop_grant and the stop head's sha, at or after --range-base and at or
before HEAD; needs --range-base): the stop after a round-2 full pass with a fixed blocking finding or a
gate-class edit plans that fix-check instead; a fix-check never earns another; any other row is refused (exit 3)
--fix-check-prompt print the fix-check reviewer's prompt from --fix-list, --hunks, --tests and --callers files
Depth: light (runtime lines <= 50), standard (above 50), full (gate class or any DEPTH_ERROR). TARGETED: the money,
Depth (R51, risk not size): full on gate class, a risk class (money, auth, secret, security, data) or any DEPTH_ERROR;
standard only by --depth-floor or a raise; otherwise light. TARGETED: the money,
gate, auth and secret classes the diff touches; any class dispatches the targeted reader at every depth. NEXT: full, scoped
(a full pass on the range: fix-check, Red Team, native adversarial), fix-check, stop or done. A full plan
(a full pass on the range: fix-check, Red Team, native adversarial), fix-check, voices (gate class: Step 11 before any stop,
then ask again with --adversarial-ran), stop or done. A full plan
from an error exits 0; exit 1 means no plan could be built (git could not list the diff).
${exitTableHelp()}`;
@@ -91,17 +105,30 @@ function main(): number {
if (!Number.isInteger(pass) || pass < 0) { process.stderr.write('gstack-review-plan: --pass needs an integer >= 0\n'); return EXIT.usage; }
const floor = flag('--depth-floor') as Depth | undefined;
if (has('--depth-floor') && floor !== 'standard' && floor !== 'full') { process.stderr.write('gstack-review-plan: --depth-floor must be standard or full (a floor only raises the depth)\n'); return EXIT.usage; }
const grant = flag('--grant');
if (has('--grant') && (!grant || grant.startsWith('-'))) { process.stderr.write('gstack-review-plan: --grant needs the approval id gstack-approvals add printed\n'); return EXIT.usage; }
const rangeBase = flag('--range-base');
if (has('--range-base') && (!rangeBase || rangeBase.startsWith('-'))) { process.stderr.write('gstack-review-plan: --range-base needs the fix range\'s base commit (this pass\'s PASS_HEAD)\n'); return EXIT.usage; }
if (grant && !rangeBase) { process.stderr.write('gstack-review-plan: --grant needs --range-base (round 2\'s PASS_HEAD), which binds the grant to the stop it answers\n'); return EXIT.usage; }
const root = git(['rev-parse', '--show-toplevel'])?.trim();
if (!root) { process.stderr.write('gstack-review-plan: run it inside a git checkout\n'); return EXIT.fail; }
process.chdir(root);
if (grant) {
const loc = locate({ cwd: root, env: process.env });
const row = ('file' in loc ? readRecord(loc).rows : []).find(r => r.id === grant && r.outcome === 'approved' && `${r.ask} ${r.scope}`.includes('review_loop_grant'));
const passHead = git(['rev-parse', '--verify', '--quiet', `${rangeBase}^{commit}`])?.trim();
const ancestor = (a: string, d: string) => git(['merge-base', '--is-ancestor', a, d]) !== null;
const stopHead = row && passHead ? `${row.ask} ${row.scope}`.match(/\b[0-9a-f]{7,40}\b/g)?.map(s => git(['rev-parse', '--verify', '--quiet', `${s}^{commit}`])?.trim()).find(s => s && ancestor(passHead, s) && ancestor(s, 'HEAD')) : undefined;
if (!stopHead) { process.stderr.write(`gstack-review-plan: --grant ${grant}: no approved row ${grant} naming review_loop_grant and this invocation's stop head (a commit at or after --range-base ${rangeBase}, at or before HEAD) in this branch's approval record (${'file' in loc ? 'gstack-approvals list' : loc.message}); record the owner's grant with gstack-approvals add --ask '... (review_loop_grant <stop head sha>)' first\n`); return EXIT.refused; }
}
const collected = collectLive(root, base);
if (collected.errors.length) {
for (const e of collected.errors) process.stderr.write(`gstack-review-plan: git could not list the diff (${e.input}: ${e.detail}); fix: ${e.repair}; no plan is safer than a plan for an empty diff\n`);
return EXIT.fail;
}
const head = git(['rev-parse', 'HEAD'])?.trim() ?? 'unknown';
const self = collected.mergeBase === head ? baseContainsHead(root, git(['rev-parse', '--verify', '-q', `origin/${base}^{commit}`]) ? `origin/${base}` : base) : null;
if (self) { process.stderr.write(`${resultLine('gstack-review-plan', 'BASE_CONTAINS_HEAD', self)}; no plan is safer than a plan for a diff compared with itself\n`); return EXIT.fail; }
const binDir = path.dirname(Bun.fileURLToPath(import.meta.url));
const scopeOf = (args: string[]): Record<string, string> => {
const run = runScriptSync(path.join(binDir, 'gstack-diff-scope'), args, { encoding: 'utf8', timeout: 60_000 });
@@ -110,14 +137,32 @@ function main(): number {
if (run.status !== 0 && !s.SCOPE_ERROR) s.SCOPE_ERROR = `exit ${run.status}`;
return s;
};
const gateTagsOf = (args: string[]): number | null => {
const g = runScriptSync(path.join(binDir, 'gstack-gate-diff'), args, { encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
const m = /GATE_SUMMARY: .*?tagged=\{([^}]*)\}/.exec(g.stdout ?? '');
return g.status === 0 && m ? m[1]!.split(',').reduce((n, kv) => n + (Number(kv.split(':')[1]) || 0), 0) : null;
const runGateDiff = (args: string[]) => runScriptSync(path.join(binDir, 'gstack-gate-diff'), args, { encoding: 'utf8', timeout: 120_000, maxBuffer: 64 * 1024 * 1024 });
const tagCount = (stdout: string | undefined, status: number | null): number | null => {
const m = /GATE_SUMMARY: .*?tagged=\{([^}]*)\}/.exec(stdout ?? '');
return status === 0 && m ? m[1]!.split(',').reduce((n, kv) => n + (Number(kv.split(':')[1]) || 0), 0) : null;
};
const scope = scopeOf([base]);
let gateTagged = flag('--gate-tagged') !== undefined ? Number(flag('--gate-tagged')) : gateTagsOf([base]);
const gateTagsOf = (args: string[]): number | null => { const g = runGateDiff(args); return tagCount(g.stdout, g.status); };
const diffBase = collected.mergeBase ?? base;
const scope = scopeOf([diffBase]);
const ref = git(['rev-parse', '--verify', '-q', `origin/${base}^{commit}`]) !== null ? `origin/${base}` : base;
const baseSha = collected.registry.sha ?? git(['rev-parse', '--verify', '-q', `${ref}^{commit}`])?.trim() ?? '';
const policy = baseSha ? loadPolicyAt(root, baseSha, ref) : null;
const declares = !!policy && !isUnavailable(policy) && policy.policy.review_exclude_paths.length > 0;
let tagged: Set<string> | null = null;
let gateTagged: number | null;
if (declares) {
const g = runGateDiff([diffBase, '--format', 'jsonl']);
gateTagged = tagCount(g.stdout, g.status);
tagged = gateTagged === null ? null : new Set((g.stdout ?? '').split('\n').slice(1).flatMap(l => { try { const h = JSON.parse(l) as { path?: string; tags?: unknown[] }; return h.path && h.tags?.length ? [h.path] : []; } catch { return []; } }));
if (flag('--gate-tagged') !== undefined) gateTagged = Number(flag('--gate-tagged'));
} else gateTagged = flag('--gate-tagged') !== undefined ? Number(flag('--gate-tagged')) : gateTagsOf([diffBase]);
if (gateTagged !== null && !Number.isFinite(gateTagged)) gateTagged = null;
const exclusion: Exclusion = !baseSha || !collected.mergeBase ? { state: 'none' } : reviewExclude({ root, baseSha, ref, mergeBase: collected.mergeBase, perPath: collected.perPath, added: collected.added, classRoot: root, gateTagged: tagged, context: 'gstack-review-plan' });
if (exclusion.state === 'refused') { for (const l of exclusion.lines) process.stderr.write(l + '\n'); return exclusion.code === 'REVIEW_DIFF_UNREADABLE' ? EXIT.fail : EXIT.refused; }
const active = exclusion.state === 'active' ? exclusion : null;
const kept = <T,>(rec: Record<string, T>) => (active ? Object.fromEntries(Object.entries(rec).filter(([p]) => !active.excluded.has(p))) : rec);
const exclusionInput = active ? { excluded: active.excluded, exclusion: { specs: active.specs, keep: active.keep } } : {};
const force = flag('--force') === 'all' ? 'all' as const : list('--force');
const tiersSetting = runScriptSync(path.join(binDir, 'gstack-config'), ['get', 'review_effort_tiers'], { encoding: 'utf8', timeout: 15_000 });
const auditEffort = has('--no-tiers') || (tiersSetting.stdout ?? '').trim() === 'off' ? 'host' : 'light';
@@ -131,28 +176,30 @@ function main(): number {
else if (git(['diff', '--quiet', 'HEAD', '--']) === null || collected.untracked.length) fixRange = unread('uncommitted or untracked edits: commit the fixes first');
else {
const commit = collectCommit(root, resolved, 'HEAD');
const range = classifyDiff({ ...commit, registry, scope: scopeOf([resolved, '--head', 'HEAD']), gateTagged: gateTagsOf([resolved]) });
const range = classifyDiff({ ...commit, registry, scope: scopeOf([resolved, '--head', 'HEAD']), gateTagged: gateTagsOf([resolved]), ...(active ? { excluded: active.excluded } : {}) });
fixRange = range.errors.length ? unread(range.errors.map(e => `${e.code} ${e.input}`).join('; '))
: !commit.paths.length ? unread('the fix range is empty: commit the fix after the range base')
: { base: rangeBase, runtime_lines: range.runtimeLines, ...(range.class === 'gate' ? { touched_gate: true as const } : {}) };
}
}
const targeted = targetedClasses({ ...collected, scope, gateTagged });
const targeted = targetedClasses({ ...collected, ...(active ? { paths: paths.filter(p => !active.excluded.has(p)), added: kept(added), removed: kept(collected.removed) } : {}), scope, gateTagged });
const patch = collected.mergeBase ? targetedPatchId(process.cwd(), collected.mergeBase) : 'none';
const brief = targetedBriefSha();
const plan = planReview({
...collected, scope, gateTagged, targeted: { classes: targeted.classes, ran: !['uncommitted', 'none'].includes(patch) && flag('--targeted-ran') === patch }, numbers: scanNumbers(added), force, gated: list('--gated'), floor, tiers: auditEffort !== 'host',
...collected, ...exclusionInput, scope, gateTagged, targeted: { classes: targeted.classes, ran: !['uncommitted', 'none'].includes(patch) && flag('--targeted-ran') === patch }, numbers: scanNumbers(kept(added)), force, gated: list('--gated'), floor, tiers: auditEffort !== 'host',
criticalFound: has('--critical-found'), adversarialRan: has('--adversarial-ran'), reviewedNumbers: flag('--reviewed-numbers'),
history: { fullPasses: pass, last, fixedBlocking: has('--fixed-blocking'), fixedBelow: has('--fixed-below') && last !== 'fix-check', blockingOpen: has('--blocking-open'), touchedGate: has('--touched-gate') || !!fixRange?.touched_gate, pass1Depth, rangeRuntimeLines: fixRange?.runtime_lines },
history: { fullPasses: pass, last, fixedBlocking: has('--fixed-blocking'), fixedBelow: has('--fixed-below') && last !== 'fix-check', blockingOpen: has('--blocking-open'), touchedGate: has('--touched-gate') || !!fixRange?.touched_gate, pass1Depth, rangeRuntimeLines: fixRange?.runtime_lines, granted: grant },
});
const gateScan = { tagged: gateTagged, head };
if (has('--json')) {
const { runtimeLines, stepDepth, depthReasons, ...rest } = plan;
process.stdout.write(JSON.stringify({ ...rest, targeted: { ...targeted, brief, targeted_patch_id: patch }, step_depth: stepDepth, runtime_lines: runtimeLines, depth_reasons: depthReasons, depth_floor: floor ?? null, gate_scan: gateScan, fix_range: fixRange ?? null, registry: { sha: registry.sha, outputs: registry.outputs }, per_path: perPath, diff_lines: diffLines, pass, audit_effort: auditEffort, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly, consumed: collected.consumed }) + '\n');
process.stdout.write(JSON.stringify({ ...rest, targeted: { ...targeted, brief, targeted_patch_id: patch }, step_depth: stepDepth, runtime_lines: runtimeLines, depth_reasons: depthReasons, depth_floor: floor ?? null, gate_scan: gateScan, fix_range: fixRange ?? null, registry: { sha: registry.sha, outputs: registry.outputs }, per_path: perPath, diff_lines: diffLines, pass, audit_effort: auditEffort, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly, consumed: collected.consumed, drawings: collected.drawings,
...(exclusion.state === 'head-only' ? { review_exclude: exclusion } : active ? { review_exclude: { state: 'active', ...active.record, specs: active.specs, keep: active.keep, kept_files: active.kept, content: active.content, references: active.references } } : {}) }) + '\n');
return EXIT.ok;
}
const out = [`REVIEW_CLASS: ${plan.class} (${plan.reasons.slice(0, 3).join('; ') || 'no signals'})`, reviewDepthLine(plan), `GATE_SCAN: ${gateTagged === null ? 'unavailable' : `tagged=${gateTagged}`} head=${head}`,
`TARGETED: classes=${classList(targeted.classes)} rules=${targeted.rules} brief=${brief} targeted_patch_id=${patch}${targeted.reasons.length ? ` reasons=${targeted.reasons.join('; ')}` : ''}`];
out.push(...excludeLines(exclusion, base));
if (fixRange) out.push(`FIX_RANGE: ${fixRange.runtime_lines === null ? `unread (${fixRange.error})` : `runtime_lines=${fixRange.runtime_lines}`} base=${fixRange.base}`);
out.push(reviewPlanLine(plan, pass + (plan.next.step === 'full' || plan.next.step === 'scoped' ? 1 : 0)), `NEXT: ${plan.next.step} (${plan.next.reason})`);
for (const e of plan.errors) out.push(`DEPTH_ERROR: ${e.code} input=${e.input} detail=${e.detail}; fix: ${e.repair}${e.result ? ` (${e.result})` : ''}`);
@@ -160,6 +207,8 @@ function main(): number {
const key = d.name.split(' ')[0];
out.push(`DISPATCH: ${d.role} ${d.name} effort=${d.effort}${d.checklists ? ` checklists=${d.checklists.join(',')}` : ''}${d.reason ? ` reason=${d.reason}` : ''}`, `DIFF_CMD: ${key} ${diffCommand(d)}`);
if (!d.exclude.length) out.push(`DIFF_EXCLUDE: ${key} none`);
const keepCmd = keepCommand(d);
if (keepCmd) out.push(`DIFF_KEEP: ${key} ${keepCmd}`);
}
for (const s of plan.skipped) out.push(`SKIP: ${s}`);
out.push(`AUDIT_EFFORT: ${auditEffort} (coverage, plan-completion and docs audits, decision-trail reviewers, watchers; reviewers above run at their effort=)`);
@@ -167,4 +216,4 @@ function main(): number {
return EXIT.ok;
}
process.exit(main());
process.exitCode = main();
+3 -2
View File
@@ -4,7 +4,7 @@
#
# Emits, in order: reviews JSONL with computed review_freshness, ---CONFIG--- (skip_eng_review),
# ---HEAD--- (short sha), ---WTREE--- (current working-tree fingerprint from
# bin/gstack-wtree, or "unknown"), ---TREE--- (HEAD tree, informational) and
# bin/gstack-wtree --review, or "unknown"), ---TREE--- (HEAD tree, informational) and
# ---DIRTY--- (tracked-file dirty flag). Consumers grade diff-scoped review
# rows using review_freshness; legacy or uncaptured diff evidence cannot fall
# back to HEAD equality. Plan rows are unchanged.
@@ -15,10 +15,11 @@ case "$(uname -s)" in
MINGW*|MSYS*|CYGWIN*) command -v cygpath >/dev/null 2>&1 && SCRIPT_DIR="$(cygpath -m "$SCRIPT_DIR")" ;;
esac
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
SLUG=$("$SCRIPT_DIR/gstack-slug" --get REPO_SLUG 2>/dev/null || true)
. "$(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"
WTREE=$("$SCRIPT_DIR/gstack-wtree" 2>/dev/null || echo "unknown")
WTREE=$("$SCRIPT_DIR/gstack-wtree" --review 2>/dev/null || echo "unknown")
if [ -f "$GSTACK_HOME/projects/$SLUG/$BRANCH-reviews.jsonl" ]; then
GSTACK_REVIEW_LIB="$SCRIPT_DIR/../lib/review-evidence.ts" GSTACK_REVIEW_WTREE="$WTREE" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e '
const { reviewFreshness } = await import(process.env.GSTACK_REVIEW_LIB);
+33 -8
View File
@@ -5,7 +5,7 @@
* gstack-review-scope classify --base <ref> --pass <n> [--range-base <sha>] [--policy frozen|diff-plus-blocking|all] [--findings <file>|-] [--json]
* gstack-review-scope defer --findings <classified.json> [--pr <n>] [--repo <owner/repo>] [--branch <name>] [--out <deferred.json>] [--dry-run] [--json]
* gstack-review-scope render --deferred <deferred.json> [--findings <classified.json>]
* gstack-review-scope summary --since <iso-timestamp> [--out <file>] [--json]
* gstack-review-scope summary --since <iso-timestamp> (--run <dir> | --ledger <f>... | --no-ledger) [--out <file>] [--json]
*
* `classify` reads a JSON array of findings (the Step 9.3 result) and stamps
* each with `locus` (diff | fix-range | outside), `kind` (defect | test-gap |
@@ -23,12 +23,14 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
import { resultLineWithDocs } from '../lib/result-codes';
import { runScriptSync } from '../lib/script-invocation';
import { isUnavailable, loadPolicyFromBase, policyUnavailableLine, resolveBaseBranch } from '../lib/ship-policy';
import { EXIT as POST_EXIT, runPost } from '../lib/gstack-post';
import {
parseHunks, renderDeferredIssue, renderDeferredSection, reviewLoopLine, scopeFindings, scopeLine, summarizeReviewLoop,
ledgerPass, parseHunks, renderDeferredIssue, renderDeferredSection, reviewLoopLine, scopeFindings, scopeLine, summarizeReviewLoop,
ALREADY_FILED_REASON, DEFERRED_MARKER, findingIdentity, findingWhere, REVIEW_SCOPE_POLICIES, type DeferredRecord, type ReviewScopePolicy, type ScopeFinding, type ScopedFinding,
} from '../lib/review-scope';
import { ledgerBlockers, type LedgerBlocker } from '../lib/review-ledger';
const HELP = `gstack-review-scope — finding scope for /ship's review loop (docs/ship-policy.md#review-scope)
@@ -41,8 +43,12 @@ const HELP = `gstack-review-scope — finding scope for /ship's review loop (doc
render --deferred <file> [--findings <classified.json>]
the PR-body sections: ## Deferred findings (references, never closing keywords)
and ## Review notes (scope=note findings)
summary --since <iso> [--out <file>] the invocation's passes from this branch's review log: REVIEW_LOOP: passes=…
blocking_found=… deferred=… noted=… (the ship receipt's --review-loop input)
summary --since <iso> (--run <dir> | --ledger <f>... | --no-ledger) [--out <file>]
the invocation's passes from this branch's review log: REVIEW_LOOP: passes=…
blocking_found=… deferred=… noted=… (the ship receipt's --review-loop input);
blocking_found also counts every CRITICAL the dispatch ledgers collected or judged
(--run finds each review-ledger.jsonl under the run directory); unrecorded=N names
blockers no row gives a disposition. An unreadable ledger: REVIEW_SCOPE_LEDGER
Defaults: --policy from the base branch's .gstack/ship-policy.json review_scope (frozen); an unresolvable base refuses
(POLICY_SOURCE_UNAVAILABLE) unless --policy is passed; --findings - reads stdin; --branch the current branch.
@@ -116,7 +122,7 @@ function readFindings(spec: string | undefined, what: string): ScopeFinding[] {
if (!list || !list.every((f: unknown) => f && typeof f === 'object' && !Array.isArray(f))) throw new Error(`${what}: expected a JSON array of finding objects`);
return list as ScopeFinding[];
}
function refuse(cmd: string, code: 'REVIEW_SCOPE_INPUT' | 'REVIEW_SCOPE_RANGE', detail: string): number {
function refuse(cmd: string, code: 'REVIEW_SCOPE_INPUT' | 'REVIEW_SCOPE_RANGE' | 'REVIEW_SCOPE_LEDGER', detail: string): number {
process.stderr.write(resultLineWithDocs(`gstack-review-scope ${cmd}`, code, detail) + '\n');
return EXIT.fail;
}
@@ -237,14 +243,33 @@ function cmdRender(rest: string[]): number {
function cmdSummary(rest: string[], json: boolean): number {
const since = flag(rest, '--since');
if (!since || !Number.isFinite(Date.parse(since))) return refuse('summary', 'REVIEW_SCOPE_INPUT', '--since <ISO-8601 timestamp> (the invocation\'s first Step 9 entry) is required');
const read = spawnSync(path.join(path.dirname(Bun.fileURLToPath(import.meta.url)), 'gstack-review-read'), [], { encoding: 'utf8', timeout: 60_000, maxBuffer: 64 * 1024 * 1024 });
if (read.status !== 0) return refuse('summary', 'REVIEW_SCOPE_INPUT', `gstack-review-read failed: ${(read.stderr ?? '').trim().split('\n')[0] ?? ''}`);
const read = runScriptSync(path.join(path.dirname(Bun.fileURLToPath(import.meta.url)), 'gstack-review-read'), [], { encoding: 'utf8', timeout: 60_000, maxBuffer: 64 * 1024 * 1024 });
if (read.status !== 0) return refuse('summary', 'REVIEW_SCOPE_INPUT', `gstack-review-read failed: ${read.error?.message ?? (read.stderr ?? '').trim().split('\n')[0] ?? ''}`);
const rows: Record<string, unknown>[] = [];
for (const line of (read.stdout ?? '').split('---CONFIG---')[0]!.split('\n')) {
if (!line.trim() || line === 'NO_REVIEWS') continue;
try { rows.push(JSON.parse(line)); } catch { /* malformed row: gstack-review-read already warned */ }
}
const summary = summarizeReviewLoop(rows, since);
const files: string[] = [];
for (let i = 0; i < rest.length; i++) {
if (rest[i] !== '--ledger') continue;
if (!rest[i + 1] || rest[i + 1]!.startsWith('--')) return refuse('summary', 'REVIEW_SCOPE_INPUT', '--ledger needs a file');
files.push(rest[++i]!);
}
const run = flag(rest, '--run');
if (run) {
let found: string[];
try { found = (fs.readdirSync(run, { recursive: true }) as string[]).filter(p => path.basename(p) === 'review-ledger.jsonl').sort().map(p => path.join(run, p)); } catch (e: any) { return refuse('summary', 'REVIEW_SCOPE_LEDGER', `--run ${run}: ${e.code ?? e.message}`); }
if (!found.length) return refuse('summary', 'REVIEW_SCOPE_LEDGER', `--run ${run} holds no review-ledger.jsonl: name the run directory the passes wrote, or each --ledger`);
files.push(...found);
}
const noLedger = rest.includes('--no-ledger');
if (noLedger && files.length) return refuse('summary', 'REVIEW_SCOPE_INPUT', '--no-ledger contradicts --run and --ledger');
// The ledgers hold every reader's findings, the voices' too, before any row classifies them; a summary without them can undercount.
if (!noLedger && !files.length) return refuse('summary', 'REVIEW_SCOPE_LEDGER', 'name the review\'s dispatch ledgers (--run <run dir> or --ledger <f>), or --no-ledger when no reader ran through gstack-review-dispatch');
let ledgers: LedgerBlocker[][];
try { ledgers = files.map(ledgerBlockers); } catch (e: any) { return refuse('summary', 'REVIEW_SCOPE_LEDGER', e.message); }
const summary = summarizeReviewLoop(rows, since, ledgers, files.map(ledgerPass));
const line = reviewLoopLine(summary);
// A /ship invocation always runs Step 9 once; zero matching rows means the wrong --since or a review log written elsewhere, never a loop to record.
if (summary.passes === 0) return refuse('summary', 'REVIEW_SCOPE_INPUT', `no review-log row of this branch is a via:ship review at or after ${since}; check REVIEW_SINCE (${line})`);
+3 -1
View File
@@ -246,6 +246,8 @@ case "$_P_PULL" in
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" ;;
tracked-by-project*|version-reverted*)
echo "gstack auto-update: update held ($_P_PULL); nothing was installed or changed. gstack retries at the next check. The full fix: bash $_Q/bin/gstack-ff-update --preflight $_Q. https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#auto-update-tracked-by-project" ;;
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" ;;
*)
@@ -500,7 +502,7 @@ CALLER_PID=$(gstack_harness_pid "$PPID")
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 ;;
3:*|6:*) 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
+1 -1
View File
@@ -38,7 +38,7 @@ const ctx = { env: process.env, cwd: process.cwd() };
const commands: Record<string, () => Outcome> = {
start: () => cmdStart(rest, ctx), note: () => cmdNote(rest, ctx), check: () => cmdCheck(rest, ctx), settle: () => cmdSettle(rest, ctx), release: () => cmdSettle(rest, ctx, true),
handoff: () => cmdHandoff(rest, ctx), launch: () => cmdLaunch(rest, ctx), 'verify-handoff': () => cmdVerifyHandoff(rest, ctx), return: () => cmdReturn(rest, ctx),
reconcile: () => cmdReconcile(rest, ctx), resume: () => cmdResume(rest, ctx), close: () => cmdClose(rest, ctx), calls: () => cmdCalls(rest),
reconcile: () => cmdReconcile(rest, ctx), resume: () => cmdResume(rest, ctx), close: () => cmdClose(rest, ctx), calls: () => cmdCalls(rest, ctx),
};
if (!command || command === '--help' || command === '-h' || !commands[command]) {
const lines = (await Bun.file(import.meta.path).text()).split('\n');
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* gstack-ship-parts — the section text /ship's next phase uses, verbatim, split at headings and
* returned once per run inside the phase program before it (GSTA-167 C5). Logic: lib/ship-parts.ts.
*/
import { shipParts } from '../lib/ship-parts';
const out = shipParts(process.argv.slice(2));
(out.exit === 2 ? process.stderr : process.stdout).write(out.text);
process.exit(out.exit);
+9 -5
View File
@@ -14,12 +14,13 @@
* gstack-ship-queue gate-ahead --list --pr <n>
* gstack-ship-queue verify --pr <n> --screen (train policy: the head's screen verdict from its run)
* gstack-ship-queue verify --post-merge <sha> [--timeout <s>] (after a merge: main's push workflows at that commit until they settle)
* gstack-ship-queue land --pr <n> [--dry-run] [--no-wait] [--break-lock] (the merge step; lib/ship-queue-land.ts)
* gstack-ship-queue land --pr <n> [--dry-run] [--no-wait] [--break-lock [--break-train-lock]] (the merge step; lib/ship-queue-land.ts)
* gstack-ship-queue land --pr <n> --repair (after QUEUE_LAND_DRIFT: open the outside merge's release PR)
* gstack-ship-queue land --census
* gstack-ship-queue train build|status|verdict|bisect|rebuild|watch|prune ... (merge trains; train --help)
* gstack-ship-queue wave plan|build|status|settle ... (wave PRs: 3-6 same-area PRs in one train slot; wave --help)
*
* Every subcommand takes --base <branch> (default origin/HEAD, else main) and
* Every subcommand takes --base <branch> (default lib/default-base.ts) and
* ends with GSTACK_RESULT: skill=ship-queue status=complete|refused run=<path>.
* gstack-restamp stays the primitive. Nothing here pushes, except land, ready
* under changelog_fragments (its READY receipt note), and under a
@@ -56,7 +57,7 @@ const HELP = `gstack-ship-queue — the shared merge queue's protocol steps (doc
verify --pr <n> --screen (train policy) the PR head's screen verdict read from its run (SCREEN_VERDICT)
verify --post-merge <sha> [--timeout <s>] wait for every check run at a merged commit to settle: POST_MERGE: green|red|pending
and one CHECK: line per workflow; resumable, default timeout 2700 s
land --pr <n> [--dry-run] [--no-wait] [--timeout <min>] [--break-lock] [--allow-repo-commands]
land --pr <n> [--dry-run] [--no-wait] [--timeout <min>] [--break-lock [--break-train-lock]] [--allow-repo-commands]
the merge step: lock, merge the base, stamp (fragments folded), push the stamp commit,
wait for its checks, squash with --match-head-commit, confirm the base tree; resumable
land --pr <n> --repair after QUEUE_LAND_DRIFT: open the PR that gives the merge outside land its own
@@ -65,8 +66,10 @@ const HELP = `gstack-ship-queue — the shared merge queue's protocol steps (doc
train build|status|verdict|bisect|rebuild|watch|prune ...
merge trains, when the base policy declares a train block (train --help;
docs/ship-policy.md#run-a-merge-train); ready then takes --screen|--no-screen
wave plan|build|status|settle ... wave PRs under a train policy: 3-6 same-area PRs in one slot and one version
(wave --help; docs/ship-policy.md#wave-prs)
--base <branch>: base branch (default: origin/HEAD, else main). Nothing here pushes, except land (the stamp commit and the
--base <branch>: base branch (default: origin/HEAD unless it names the checked-out branch, else the PR's base, origin/main). Nothing here pushes, except land (the stamp commit and the
refs/notes/gstack-land lock), ready under changelog_fragments (its READY receipt note) and merge trains.
${exitTableHelp()}`;
@@ -162,13 +165,14 @@ async function main(argv: string[]): Promise<number> {
if (!args[0] || args[0].startsWith('-')) { process.stdout.write(train.TRAIN_HELP + '\n'); return args.includes('--help') || args.includes('-h') ? EXIT.ok : EXIT.usage; }
return train.trainMain(ctx, args);
}
if (cmd === 'wave') return (await import('../lib/ship-queue-wave')).waveMain(ctx, args);
if (cmd === 'verify' && flag(args, '--post-merge') !== undefined) return (await import('../lib/ship-queue-train-cli')).postMergeMain(ctx, args);
if (cmd === 'land') {
const l = await import('../lib/ship-queue-land');
if (args.includes('--census')) return finish('land', l.census(ctx));
if (!pr || !/^\d+$/.test(pr)) return usage('land needs --pr <number> (or --census)');
const timeout = flag(args, '--timeout');
return finish('land', l.land(ctx, { pr, repair: args.includes('--repair'), dryRun: args.includes('--dry-run'), noWait: args.includes('--no-wait'), breakLock: args.includes('--break-lock'), allowRepoCommands: args.includes('--allow-repo-commands'), waitTimeoutMin: timeout && /^\d+$/.test(timeout) ? Number(timeout) : undefined }));
return finish('land', l.land(ctx, { pr, repair: args.includes('--repair'), dryRun: args.includes('--dry-run'), noWait: args.includes('--no-wait'), breakLock: args.includes('--break-lock'), breakTrainLock: args.includes('--break-train-lock'), allowRepoCommands: args.includes('--allow-repo-commands'), waitTimeoutMin: timeout && /^\d+$/.test(timeout) ? Number(timeout) : undefined }));
}
if (!pr || !/^\d+$/.test(pr)) return usage(`${cmd} needs --pr <number>`);
const gateExit = flag(args, '--gate-exit');
+140 -31
View File
@@ -32,10 +32,13 @@
*/
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 { EXIT, exitTableHelp, type ShipReceipt } from '../lib/headless-artifacts';
import { resultLine, resultLineWithDocs } from '../lib/result-codes';
import { versionAtRef } from '../lib/restamp';
import { branchSides, type BranchSide } from '../lib/stamp-only';
import { runScriptSync } from '../lib/script-invocation';
import { readPregateFile, verifyPregate } from '../lib/pregate/index';
import { isUnavailable, loadPolicyFromBase, matchesAny, policyUnavailableLine, resolveBaseBranch, runningGstackVersion, stampFilesInTree, validatePolicy, type ShipPolicy } from '../lib/ship-policy';
import { approvalForReceipt } from '../lib/approval-citations';
@@ -45,7 +48,8 @@ import {
receiptIssuesFrom, receiptSummaryLine, renderCensus, renderReceiptBlock, validateReceipt, type CensusPr,
} from '../lib/ship-receipt';
import { checkDocsCaps, docsCapLine, docsExceptionLine, parseReceiptDocs } from '../lib/ship-receipt-docs';
import { deeper, depthBelow, exceptionProblems, parseReviewLoopException, parseReviewTargeted, receiptDepthAtHead, reviewLoopExceptionLine } from '../lib/ship-receipt-depth';
import { comparePath, contextPacket, PACKET_SECTION_CAP, readScopedReview, touchedPaths } from '../lib/ship-patch';
import { deeper, depthBelow, exceptionProblems, parseReviewLoopException, parseReviewTargeted, receiptExclude, receiptReviewAtHead, receiptTargetedAtHead, reviewLoopExceptionLine, targetedClassesSince } from '../lib/ship-receipt-depth';
import { checkReceiptProse, proseLine } from '../lib/ship-receipt-prose';
import { resolveStateRoot } from '../lib/state-root';
import { readPause, receiptBaseRed } from '../lib/base-red';
@@ -92,9 +96,14 @@ write flags: --pr --head --base --version --gated-tree --predecessor --gate <jso
"Review-loop exception: …" line verbatim; else REVIEW_LOOP_EXCEPTION_INVALID). Every receipt records gstack_version and
review_depth: the depth HEAD needs, recomputed from the committed range (--head must be HEAD; a dirty checkout or an
unreadable input records full).
--review-targeted <json|@file> ({classes, rules, brief_sha, status, findings, raised_by}: gstack-review-dispatch verify
--targeted-out); recorded as review_targeted bound to the receipt's product_patch_id. Without it the receipt has no
review_targeted, which the queue reports as legacy.
--review-exclude <digest|none> (the digest= on gstack-review-dispatch verify's REVIEW_EXCLUDE_RECORD: line, which the reviewers' briefs
name; none without that line): must equal the digest HEAD has under the base policy's review_exclude_paths, else
RECEIPT_INVALID field=review_exclude and nothing is written (rerun the review; write alone cannot clear it); recorded as review_exclude. Carry and the queue compare that digest.
--review-targeted <json|@file> ({classes, rules, brief_sha, status, findings, raised_by, head}: gstack-review-dispatch verify
--targeted-out); recorded as review_targeted bound to the receipt's product_patch_id, and refused (RECEIPT_INVALID) when the
commit the reader read (head) is not an ancestor or a change since it carries a targeted class (gate scan included). write and carry
refuse a receipt whose head needs a targeted class (money, gate, auth, secret) that no done pass covers, as the queue's
targeted verdict does (#3859) (RECEIPT_INVALID, RECEIPT_CARRY_REFUSED).
--approval-run <autoplan run dir> (repeatable; resolves plan-sha citations) (W2: a class declared in origin/<base>'s
.gstack/approval-classes.json with hits needs a citation that resolves and binds to its effects: APPROVAL_CITATION_MISSING)
--out <file> --body <pr-body.md>. Defaults: head=HEAD, base=origin/HEAD's branch, version=the
@@ -118,7 +127,7 @@ function jsonArg(raw: string | undefined): Record<string, unknown> | undefined {
return parsed;
}
function git(args: string[], cwd?: string): string | undefined {
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000 });
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000, maxBuffer: 256 * 1024 * 1024 });
return r.status === 0 ? r.stdout.trim() : undefined;
}
function gh(args: string[]): { ok: boolean; out: string; err: string } {
@@ -177,7 +186,7 @@ function cmdWrite(rest: string[], json: boolean): number {
const tree = receiptFile ? JSON.parse(fs.readFileSync(receiptFile, 'utf8')) : null;
let sessionKind = flag(rest, '--session-kind') ?? process.env.GSTACK_SESSION_KIND;
if (!sessionKind) {
const r = spawnSync(path.join(path.dirname(Bun.fileURLToPath(import.meta.url)), 'gstack-session-kind'), [], { encoding: 'utf8', timeout: 15_000 });
const r = runScriptSync(path.join(path.dirname(Bun.fileURLToPath(import.meta.url)), 'gstack-session-kind'), [], { encoding: 'utf8', timeout: 15_000 });
sessionKind = r.status === 0 ? r.stdout.trim() || undefined : undefined;
}
let pregate: PregateField | undefined;
@@ -214,11 +223,19 @@ function cmdWrite(rest: string[], json: boolean): number {
const line = reviewLoopExceptionLine(loopException.exception);
if (bodyFile && !bodyText.split(/\r?\n/).some(l => l.trim() === line)) return refuse(`${bodyFile} does not carry the exception line verbatim: ${JSON.stringify(line)}`);
}
const reviewDepth = receiptDepthAtHead(repoRoot, base, path.dirname(Bun.fileURLToPath(import.meta.url)), headSha ?? head);
const review = receiptReviewAtHead(repoRoot, base, path.dirname(Bun.fileURLToPath(import.meta.url)), headSha ?? head);
const reviewDepth = review.depth;
if (review.exclude.state === 'refused') { for (const l of review.exclude.lines) process.stderr.write(`${l}; nothing written\n`); return EXIT.refused; }
const excludeNow = review.exclude.state === 'active' ? review.exclude.record : null;
const excludeGiven = flag(rest, '--review-exclude') ?? 'none';
if ((excludeNow?.digest ?? 'none') !== excludeGiven) {
process.stderr.write(`gstack-ship-receipt write: field=review_exclude expected=${excludeNow?.digest ?? 'none'} recorded=${excludeGiven} source=${excludeNow?.source ?? `origin/${base} (no review_exclude_paths)`}: the review --review-exclude names read another selection than ${base}'s review_exclude_paths gives ${head.slice(0, 12)}; fix: rerun /ship's review on ${head.slice(0, 12)} (gstack-review-dispatch pass --base ${base}), run gstack-review-dispatch verify --ledger <ledger> and write with the --review-exclude <d> its REVIEW_EXCLUDE_RECORD: line prints (none when it prints no such line); nothing written (RECEIPT_INVALID)\n`);
return EXIT.fail;
}
const product = reviewedProduct(repoRoot, base, head, pregateFile);
let targeted: ReturnType<typeof parseReviewTargeted> | undefined;
if (flag(rest, '--review-targeted') !== undefined) {
try { targeted = parseReviewTargeted(jsonArg(flag(rest, '--review-targeted')), product.product_patch_id ?? 'none'); } catch (e: any) { targeted = { ok: false, message: `--review-targeted: ${e.message}` }; }
try { targeted = parseReviewTargeted(jsonArg(flag(rest, '--review-targeted')), product.product_patch_id ?? 'none', read => reviewedProduct(repoRoot, base, read, pregateFile).product_patch_id, read => targetedClassesSince(repoRoot, read, headSha ?? head)); } catch (e: any) { targeted = { ok: false, message: `--review-targeted: ${e.message}` }; }
}
if (targeted && !targeted.ok) { process.stderr.write(resultLine('gstack-ship-receipt write', 'RECEIPT_INVALID', `${targeted.message}; nothing written`) + '\n'); return EXIT.fail; }
const approval = approvalForReceipt({ repoRoot, base, head, citationText: [bodyText, ...flags(rest, '--approval')].join('\n'), runDirs: flags(rest, '--approval-run') });
@@ -253,6 +270,7 @@ function cmdWrite(rest: string[], json: boolean): number {
review_loop: reviewLoop, ...(docs?.ok ? { docs: docs.docs } : {}), ...product,
gstack_version: runningGstackVersion(), review_depth: reviewDepth, review_loop_exception: loopException?.ok ? loopException.exception : undefined, review_targeted: targeted?.ok ? targeted.targeted : undefined,
base_red: pause ? receiptBaseRed(pause) : undefined,
review_exclude: receiptExclude(review.exclude),
};
} catch (e: any) { process.stderr.write(`gstack-ship-receipt write: ${e.message}\n`); return EXIT.usage; }
if (input.pr !== undefined && !Number.isInteger(input.pr)) { process.stderr.write('gstack-ship-receipt write: --pr must be an integer\n'); return EXIT.usage; }
@@ -263,6 +281,12 @@ function cmdWrite(rest: string[], json: boolean): number {
process.stderr.write('gstack-ship-receipt write: nothing written\n');
return EXIT.fail;
}
const targetedVerdict = receiptTargetedAtHead(repoRoot, `origin/${base.replace(/^origin\//, '')}`, receipt, receipt.head);
if (targetedVerdict.line.includes('(unchecked: ')) process.stderr.write(targetedVerdict.line + '\n');
if (!targetedVerdict.ok) {
process.stderr.write(`${targetedVerdict.line}\n${resultLine('gstack-ship-receipt write', 'RECEIPT_INVALID', `${targetedVerdict.message}; run the targeted reader on HEAD and pass its record (--review-targeted @<gstack-review-dispatch verify --targeted-out file>); nothing written`)}\n`);
return EXIT.fail;
}
const body = bodyFile;
const missing = body && receipt.issues ? missingIssueLines(fs.existsSync(body) ? fs.readFileSync(body, 'utf8') : '', receipt.issues) : [];
if (missing.length) {
@@ -398,12 +422,37 @@ function writeRunRecord(a: { receipt: ShipReceipt; head: string; pr: string | un
*/
function patchId(repoRoot: string, from: string, to: string, pathspec: string[]): string | { error: string } {
const firstLine = (s: string | null | undefined, fallback: string) => (s ?? '').trim().split('\n')[0]?.slice(0, 200) || fallback;
const diff = spawnSync('git', ['diff', '--no-color', '--no-ext-diff', '--full-index', from, to, '--', ...pathspec], { cwd: repoRoot, encoding: 'utf8', timeout: 120_000, maxBuffer: 256 * 1024 * 1024 });
if (diff.status !== 0) return { error: `git diff ${from.slice(0, 12)} ${to.slice(0, 12)}: ${firstLine(diff.stderr, diff.signal ? `killed by ${diff.signal}` : `exit ${diff.status}`)}` };
if (!diff.stdout.trim()) return '';
const id = spawnSync('git', ['patch-id', '--verbatim'], { cwd: repoRoot, input: diff.stdout, encoding: 'utf8', timeout: 120_000, maxBuffer: 64 * 1024 * 1024 });
if (id.status !== 0) return { error: `git patch-id --verbatim: ${firstLine(id.stderr, id.signal ? `killed by ${id.signal}` : `exit ${id.status}`)}` };
return id.stdout.trim().split(/\s+/)[0] ?? '';
const root = path.resolve(repoRoot, git(['rev-parse', '--git-path', 'gstack-patch-id'], repoRoot)?.trim() || '.git/gstack-patch-id');
sweepStalePatchDirs(root);
let dir: string;
try { fs.mkdirSync(root, { recursive: true }); dir = fs.mkdtempSync(path.join(root, 'run-')); } catch (e) { return { error: `cannot create a temp file under ${root} (${firstLine((e as Error).message, 'mkdtemp failed')}); make that directory writable` }; }
const file = path.join(dir, 'diff.patch');
let fd: number | undefined;
try {
const diff = spawnSync('git', ['diff', '--no-color', '--no-ext-diff', '--full-index', `--output=${file}`, from, to, '--', ...pathspec], { cwd: repoRoot, encoding: 'utf8', timeout: 120_000 });
if (diff.status !== 0) return { error: `git diff ${from.slice(0, 12)} ${to.slice(0, 12)}: ${firstLine(diff.stderr, diff.signal ? `killed by ${diff.signal}` : `exit ${diff.status}`)}` };
fd = fs.openSync(file, 'r');
const id = spawnSync('git', ['patch-id', '--verbatim'], { cwd: repoRoot, stdio: [fd, 'pipe', 'pipe'], encoding: 'utf8', timeout: 120_000 });
if (id.status !== 0) return { error: `git patch-id --verbatim: ${firstLine(id.stderr, id.signal ? `killed by ${id.signal}` : `exit ${id.status}`)}` };
return id.stdout.trim().split(/\s+/)[0] ?? '';
} finally {
if (fd !== undefined) fs.closeSync(fd);
fs.rmSync(dir, { recursive: true, force: true });
}
}
/** A carry killed mid patch-id (preflight's timeout) skips patchId's `finally`; its product diff is removed by the next run once an hour old. */
let swept = false;
function sweepStalePatchDirs(root: string): void {
if (swept) return;
swept = true;
const cutoff = Date.now() - 60 * 60 * 1000;
let names: string[] = [];
try { names = fs.readdirSync(root).filter(n => n.startsWith('run-')); } catch { return; }
for (const n of names) {
const p = path.join(root, n);
try { if (fs.statSync(p).mtimeMs < cutoff) fs.rmSync(p, { recursive: true, force: true }); } catch { /* another run removed it */ }
}
}
/** `git patch-id --verbatim` exists from git 2.39 (docs/ship-policy.md#receipt-carry); an older git is a named refusal, never a silent fallback to the whitespace-stripping default. */
@@ -459,8 +508,15 @@ function productRules(repoRoot: string, base: string, outputs: readonly string[]
* The product pathspec between two commits: every path but tests and release
* metadata, where a metadata-listed path whose change from `from` to `to` is
* not a stamp stays product (every glob that matches it leaves the exclusion).
* With `branch` (the paths the branch itself changed against the base, and its
* base..head ranges), only the branch's own non-stamp changes demote a glob: a
* touched path whose change is not a stamp on either side, or between the two
* heads. A path only a base merge brought in has the same patch against the base
* at both heads, and demoting its glob would pull a matching path the branch did
* change, such as a CHANGELOG fragment next to the base's changelog.d/README.md
* (GST-343, #3606), into the product patch-id at one head only.
*/
function productSpec(repoRoot: string, rules: ProductRules, from: string, to: string): { changed: Array<{ path: string; class: ReturnType<typeof classifyCarryPath> }>; product: string[]; tests: string[] } | undefined {
function productSpec(repoRoot: string, rules: ProductRules, from: string, to: string, branch?: { touched: ReadonlySet<string>; sides: ReadonlyArray<readonly [string, string]>; own: (rel: string) => BranchSide | undefined }): { changed: Array<{ path: string; class: ReturnType<typeof classifyCarryPath> }>; product: string[]; tests: string[] } | undefined {
const changedRaw = git(['diff', '--name-only', from, to], repoRoot);
if (changedRaw === undefined) return undefined;
const blobAt = (ref: string, p: string): string | null => { const r = spawnSync('git', ['show', `${ref}:${p}`], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 }); return r.status === 0 ? r.stdout : null; };
@@ -469,11 +525,14 @@ function productSpec(repoRoot: string, rules: ProductRules, from: string, to: st
if (file !== null) return file.trim() || null;
try { const v = JSON.parse(blobAt(ref, 'package.json') ?? 'null')?.version; return typeof v === 'string' ? v : null; } catch { return null; }
};
const oldVersion = versionAt(from);
const newVersion = versionAt(to);
const changed = changedRaw.split('\n').filter(Boolean).map(p => ({ path: p, class: classifyCarryPath(p, rules.extra, matchesAny(rules.metadata, p) && !matchesAny(rules.extra.outputs, p) ? { before: blobAt(from, p), after: blobAt(to, p), oldVersion, newVersion, templates: rules.templates } : undefined) }));
const versions = new Map<string, string | null>();
const version = (ref: string) => { if (!versions.has(ref)) versions.set(ref, versionAt(ref)); return versions.get(ref)!; };
const stamped = (p: string) => matchesAny(rules.metadata, p) && !matchesAny(rules.extra.outputs, p);
const classify = (p: string, a: string, b: string) => classifyCarryPath(p, rules.extra, stamped(p) ? { before: blobAt(a, p), after: blobAt(b, p), oldVersion: version(a), newVersion: version(b), templates: rules.templates, side: a === from && b === to ? branch?.own(p) : undefined } : undefined);
const changed = changedRaw.split('\n').filter(Boolean).map(p => ({ path: p, class: classify(p, from, to) }));
const excludeGlobs = (globs: readonly string[]) => globs.map(g => `:(exclude,glob)${g}`);
const demoted = changed.filter(c => c.class === 'product' && matchesAny(rules.metadata, c.path)).map(c => c.path);
const demoted = !branch ? changed.filter(c => c.class === 'product' && matchesAny(rules.metadata, c.path)).map(c => c.path)
: [...branch.touched].filter(p => stamped(p) && (changed.some(c => c.path === p && c.class === 'product') || branch.sides.some(([a, b]) => blobAt(a, p) !== blobAt(b, p) && classify(p, a, b) === 'product')));
const metadataGlobs = rules.metadata.filter(g => !demoted.some(p => matchesAny([g], p)));
return { changed, product: ['.', ...excludeGlobs(CARRY_TEST_GLOBS), ...excludeGlobs(metadataGlobs)], tests: [...CARRY_TEST_GLOBS.map(g => `:(glob)${g}`), ...excludeGlobs(metadataGlobs)] };
}
@@ -506,7 +565,7 @@ function cmdCarry(rest: string[], json: boolean): number {
if (!repoRoot) { process.stderr.write('gstack-ship-receipt carry: run it inside the git checkout whose head carries the receipt\n'); return EXIT.usage; }
const head = (flag(rest, '--head') ?? git(['rev-parse', 'HEAD'], repoRoot))?.toLowerCase();
if (!head || !/^[0-9a-f]{40,64}$/.test(head)) { process.stderr.write('gstack-ship-receipt carry: cannot resolve HEAD\n'); return EXIT.usage; }
const refused = (code: 'QUEUE_RECEIPT_MISSING' | 'QUEUE_RECEIPT_CONDITIONAL' | 'RECEIPT_CARRY_REFUSED' | 'RECEIPT_CARRY_PRODUCT_CHANGED' | 'RECEIPT_CARRY_TREE_MISMATCH' | 'RECEIPT_GIT_TOO_OLD' | 'RECEIPT_CARRY_PREGATE_STALE', message: string) => {
const refused = (code: 'QUEUE_RECEIPT_MISSING' | 'QUEUE_RECEIPT_CONDITIONAL' | 'RECEIPT_CARRY_REFUSED' | 'RECEIPT_CARRY_PRODUCT_CHANGED' | 'RECEIPT_CARRY_SCOPED_REVIEW' | 'RECEIPT_CARRY_TREE_MISMATCH' | 'RECEIPT_GIT_TOO_OLD' | 'RECEIPT_CARRY_PREGATE_STALE', message: string) => {
process.stderr.write(resultLineWithDocs('gstack-ship-receipt carry', code, message) + '\n');
process.stdout.write(json ? JSON.stringify({ skill: 'ship', status: 'refused', run: '-', code, message }) + '\n' : `CARRY: refused (${code})\nGSTACK_RESULT: skill=ship status=refused run=-\n`);
return EXIT.refused;
@@ -570,6 +629,7 @@ function cmdCarry(rest: string[], json: boolean): number {
const reviewedBase = receipt.base.replace(/^origin\//, '');
const retarget = reviewedBase !== base.replace(/^origin\//, '');
let plan: ReturnType<typeof planCarry>;
let scopedDryRun: string | undefined;
if (!ancestor) {
// Rewritten history: the receipt follows only onto a tree it already covers, with the product diff it recorded.
const recorded = receipt.product_patch_id !== undefined ? (receipt.product_patch_id === 'none' ? '' : receipt.product_patch_id) : fromKnown ? productPatchAgainstBase(repoRoot, rules, baseRef, from) : { error: 'the receipt records no product patch-id and its head is not in this clone' };
@@ -578,7 +638,10 @@ function cmdCarry(rest: string[], json: boolean): number {
} else {
const fromBase = git(['merge-base', baseRef, from], repoRoot);
if (!fromBase) return refused('RECEIPT_CARRY_REFUSED', `cannot resolve the merge base of ${baseRef} (fetch it first)`);
const spec = productSpec(repoRoot, rules, from, head);
const listed = [touchedPaths(repoRoot, fromBase, from), touchedPaths(repoRoot, headBase, head)];
if (!listed[0] || !listed[1]) return refused('RECEIPT_CARRY_REFUSED', `git could not list the paths the branch touched against ${base}; run /ship on ${head.slice(0, 12)}`);
const touched = new Set([...listed[0], ...listed[1]]);
const spec = productSpec(repoRoot, rules, from, head, { touched, sides: [[fromBase, from], [headBase, head]], own: branchSides(repoRoot, baseRef, from, '') });
if (!spec) return refused('RECEIPT_CARRY_REFUSED', `git diff ${from.slice(0, 12)} ${head.slice(0, 12)} failed`);
const { changed } = spec;
const productFrom = patchId(repoRoot, fromBase, from, spec.product);
@@ -601,25 +664,64 @@ function cmdCarry(rest: string[], json: boolean): number {
}
const current = retarget ? headPatch : productHead;
const changedProduct: string[] = [];
const contextPaths: string[] = [];
if (retarget && reviewed !== current) changedProduct.push(`HEAD's product diff against ${base} is not the one reviewed against ${reviewedBase} (the parent has not fully landed on ${base}, or the branch changed since)`);
else if (productFrom !== productHead && !retarget) {
// planCarry names at most CARRY_NAMED_PATHS differing paths, so the per-path comparison (two patch-ids each) stops there,
// and it walks only the paths the branch itself touched against the base at either head: a base merge brings in every
// path the base changed, none of which can differ between the two base diffs.
const touched = new Set([...(git(['diff', '--name-only', fromBase, from], repoRoot) ?? '').split('\n'), ...(git(['diff', '--name-only', headBase, head], repoRoot) ?? '').split('\n')].filter(Boolean));
for (const p of [...touched].sort()) {
// planCarry names at most CARRY_NAMED_PATHS differing paths, so the walk stops there, and it reads only the paths the
// branch itself touched against the base at either head: a base merge brings in every path the base changed, none of
// which can differ between the two base diffs. A touched path with the same blob at both heads and at both bases has
// the same patch, so only paths that moved on one side are compared (GST-448: 6000 touched paths). A path whose only difference is base context next to unchanged hunks
// (lib/ship-patch.ts) needs a scoped review instead of the loop.
const sides = { reviewedBase: fromBase, reviewed: from, currentBase: headBase, current: head };
const moved = [touchedPaths(repoRoot, from, head), touchedPaths(repoRoot, fromBase, headBase)];
if (moved.some(l => !l)) return refused('RECEIPT_CARRY_REFUSED', `git could not list the paths the branch touched against ${base}; run /ship on ${head.slice(0, 12)}`);
const [movedHead, movedBase] = moved as Set<string>[];
for (const p of [...touched].filter(p => movedHead.has(p) || movedBase.has(p)).sort()) {
if (changedProduct.length >= CARRY_NAMED_PATHS) break;
const cls = changed.find(c => c.path === p)?.class ?? classifyCarryPath(p, rules.extra);
if (cls !== 'product') continue;
if (asId(patchId(repoRoot, fromBase, from, [p])) !== asId(patchId(repoRoot, headBase, head, [p]))) changedProduct.push(p);
const verdict = comparePath(repoRoot, sides, p);
if (verdict === 'changed') changedProduct.push(p);
else if (verdict === 'context') contextPaths.push(p);
}
if (!changedProduct.length && !contextPaths.length) changedProduct.push('(a product path outside the branch\'s touched files; compare the two base diffs)');
else if (!changedProduct.length) {
const rest = [...spec.product, ...contextPaths.map(p => `:(exclude,literal)${p}`)];
const restFrom = patchId(repoRoot, fromBase, from, rest);
const restHead = patchId(repoRoot, headBase, head, rest);
if (typeof restFrom !== 'string' || typeof restHead !== 'string' || restFrom !== restHead) changedProduct.push(`(a product path outside ${contextPaths.join(', ')}; compare the two base diffs without them)`);
}
}
let scopedReview: string | undefined;
const packetBody = !changedProduct.length && contextPaths.length ? contextPacket(repoRoot, { reviewedBase: fromBase, reviewed: from, currentBase: headBase, current: head }, contextPaths) : null;
if (packetBody?.over.length) changedProduct.push(...packetBody.over.map(p => `${p} (too large for the scoped review: over ${PACKET_SECTION_CAP} lines in a section)`));
if (packetBody && !changedProduct.length) {
const record = flag(rest, '--scoped-review');
if (!record && rest.includes('--dry-run')) {
scopedDryRun = `base context moved next to the reviewed hunks in ${contextPaths.join(', ')}: carry without --dry-run writes the scoped-review packet for one fix-check reviewer, then carry --scoped-review <record.json>; nothing written`;
scopedReview = `base context moved (scoped review pending: ${contextPaths.join(', ')})`;
} else if (!record) {
const gitPath = git(['rev-parse', '--git-path', `gstack/scoped-review-${head.slice(0, 12)}.md`], repoRoot);
const packet = path.resolve(repoRoot, gitPath ?? `.git/gstack/scoped-review-${head.slice(0, 12)}.md`);
try {
fs.mkdirSync(path.dirname(packet), { recursive: true });
fs.writeFileSync(packet, packetBody.text);
} catch (e) {
return refused('RECEIPT_CARRY_REFUSED', `base context moved next to the reviewed hunks in ${contextPaths.join(', ')}, but the scoped-review packet ${packet} cannot be written (${(e as Error).message.split('\n')[0]}); make its directory writable, then rerun carry`);
}
return refused('RECEIPT_CARRY_SCOPED_REVIEW', `base context moved next to the reviewed hunks in ${contextPaths.join(', ')}: one fix-check reviewer reads ${packet}, then rerun carry with --scoped-review <record.json> ({"head":"${head}","paths":[…],"reviewer":"<model>","verdict":"clean"}); a blocking finding means /ship on ${head.slice(0, 12)}`);
}
if (record) {
const review = readScopedReview(record, { head }, contextPaths);
if (!review.ok) return refused('RECEIPT_CARRY_REFUSED', `--scoped-review: ${review.message}`);
scopedReview = `base context moved (scoped review by ${review.review.reviewer}: ${contextPaths.join(', ')})`;
}
if (!changedProduct.length) changedProduct.push('(a product path outside the branch\'s touched files; compare the two base diffs)');
}
const mergeCommits = (git(['rev-list', '--merges', '--count', `${from}..${head}`], repoRoot) ?? '0').trim();
// Evidence for the record, never the decision: a slow range-diff is reported unavailable rather than waited for.
const rangeRaw = spawnSync('git', ['range-diff', '--no-color', `${fromBase}..${from}`, `${headBase}..${head}`], { cwd: repoRoot, encoding: 'utf8', timeout: 15_000, maxBuffer: 64 * 1024 * 1024 });
const rangeDiff = rangeRaw.status === 0 ? (rangeRaw.stdout.trim().split('\n').slice(0, 20).join('\n') || '(empty: identical ranges)') : `unavailable: ${(rangeRaw.stderr ?? '').trim().split('\n')[0] || (rangeRaw.signal ? `range-diff killed by ${rangeRaw.signal} after 15 s` : 'range-diff failed')}`;
plan = planCarry({ from, head, changed, productPatchId: { from: reviewed as string, head: current as string }, testPatchId: { from: testFrom, head: testHead }, changedProduct, mergeCommits: Number(mergeCommits) || 0, rangeDiff, ...(retarget ? { retargetedFrom: reviewedBase } : {}) });
plan = planCarry({ from, head, changed, productPatchId: { from: reviewed as string, head: current as string }, testPatchId: { from: testFrom, head: testHead }, changedProduct, mergeCommits: Number(mergeCommits) || 0, rangeDiff, ...(retarget ? { retargetedFrom: reviewedBase } : {}), ...(scopedReview ? { scopedReview } : {}) });
}
if (!plan.ok) return refused(plan.code, plan.message);
// Eligibility is not publication: a receipt that recorded a pregate is rebound only with a pregate current for HEAD,
@@ -643,17 +745,24 @@ function cmdCarry(rest: string[], json: boolean): number {
if (!receipt.review_depth) return refused('RECEIPT_CARRY_REFUSED', `the receipt records gstack_version but no review_depth, so the queue would refuse it; run /ship on ${head.slice(0, 12)}`);
const checkedOut = git(['rev-parse', 'HEAD'], repoRoot)?.toLowerCase();
if (checkedOut !== head) return refused('RECEIPT_CARRY_REFUSED', `--head ${head.slice(0, 12)} is not the checked-out HEAD ${(checkedOut ?? 'none').slice(0, 12)}; the gate scan reads the checkout, so check out ${head.slice(0, 12)} and carry there`);
const now = receiptDepthAtHead(repoRoot, base, path.dirname(Bun.fileURLToPath(import.meta.url)), head);
const review = receiptReviewAtHead(repoRoot, base, path.dirname(Bun.fileURLToPath(import.meta.url)), head);
const now = review.depth;
if (review.exclude.state === 'refused') return refused('RECEIPT_CARRY_REFUSED', `${review.exclude.lines.join('; ')}; run /ship on ${head.slice(0, 12)} once the base policy reads`);
const digest = review.exclude.state === 'active' ? review.exclude.record.digest : 'none';
if (digest !== (receipt.review_exclude?.digest ?? 'none')) return refused('RECEIPT_CARRY_REFUSED', `review_exclude changed: recorded=${receipt.review_exclude?.digest ?? 'none'} now=${digest} source=${review.exclude.state === 'active' ? review.exclude.record.source : `${base} (no review_exclude_paths)`}; the reviewers read another selection than HEAD ${head.slice(0, 12)} has under the base policy, so carry does not apply; run /ship on ${head.slice(0, 12)}`);
if (depthBelow(receipt.review_depth.depth, now.depth)) return refused('RECEIPT_CARRY_REFUSED', `HEAD ${head.slice(0, 12)} needs ${now.depth} review depth (${now.reasons.join(', ')}), above the receipt's ${receipt.review_depth.depth}; run /ship on ${head.slice(0, 12)}`);
depthField = { review_depth: { ...now, depth: deeper(receipt.review_depth.depth, now.depth) } };
depthField = { review_depth: { ...now, depth: deeper(receipt.review_depth.depth, now.depth) }, review_exclude: receiptExclude(review.exclude) };
}
// The rebound product_patch_id is HEAD's diff against the carry's base, so a retargeted receipt names that base from now on.
const rebound = { ...receipt, ...(retarget ? { base: base.replace(/^origin\//, '') } : {}), ...(pregateField ?? {}), ...depthField };
const carried = carryReceipt(rebound, head, plan.carry, { tree: headTree, fromTree, productPatchId: headPatch });
const errors = validateReceipt(carried);
if (errors.length) return refused('RECEIPT_CARRY_REFUSED', `the carried receipt does not validate: ${errors[0]!.message} at ${errors[0]!.path}`);
const targetedVerdict = receiptTargetedAtHead(repoRoot, baseRef, carried, head);
if (!targetedVerdict.ok) return refused('RECEIPT_CARRY_REFUSED', `${targetedVerdict.line}: ${targetedVerdict.message}, so the queue refuses it too (#3859); run /ship on ${head.slice(0, 12)}`);
const newBody = placeReceiptFirst(body, carried);
const line = receiptSummaryLine(carried);
if (scopedDryRun) return refused('RECEIPT_CARRY_SCOPED_REVIEW', scopedDryRun);
if (rest.includes('--dry-run')) {
process.stderr.write(line + '\n');
process.stdout.write(json ? JSON.stringify({ skill: 'ship', status: 'dry-run', head, carry: plan.carry }) + '\n' : `CARRY: would carry from ${from.slice(0, 12)} to ${head.slice(0, 12)} (${plan.carry.reason}); nothing written\n`);
+21
View File
@@ -0,0 +1,21 @@
# shellcheck shell=bash
# gstack-skill-name.sh: sourced helper (bash 3.2). gstack_skill_md_name FILE
# sets GSTACK_SKILL_NAME to the first column-0 "name:" line of FILE with every
# whitespace character removed (CR included), or to "" when there is none or
# FILE cannot be read. Always returns 0.
#
# It starts no process: no subshell, pipe or command substitution, and callers
# must not wrap it in $( ). Setup reads ~55 names per run, and the
# grep | sed | tr pipeline it replaces deadlocked MSYS2's fork() under x64
# emulation on Windows ARM64 (#2120).
gstack_skill_md_name() {
GSTACK_SKILL_NAME=
[ -f "$1" ] && [ -r "$1" ] || return 0
local _gsn_line
while IFS= read -r _gsn_line || [ -n "$_gsn_line" ]; do
case "$_gsn_line" in
name:*) _gsn_line=${_gsn_line#name:}; GSTACK_SKILL_NAME=${_gsn_line//[[:space:]]/}; return 0 ;;
esac
done < "$1"
return 0
}
+74 -3
View File
@@ -41,14 +41,18 @@ SKILL_NAME=""
MODEL_OVERLAY="none"
PARENT_PID=""
BRAIN_HEALTH="no"
LOADOUT="" LOADOUT_MODEL="" FULL_ROOT=""
while [ $# -gt 0 ]; do
case "$1" in
--skill) SKILL_NAME="$2"; shift 2 ;;
--model) MODEL_OVERLAY="$2"; shift 2 ;;
--parent-pid) PARENT_PID="$2"; shift 2 ;;
--brain-health) BRAIN_HEALTH="yes"; shift ;;
--loadout) LOADOUT="$2"; shift 2 ;;
--loadout-model) LOADOUT_MODEL="$2"; shift 2 ;;
--full-root) FULL_ROOT="$2"; shift 2 ;;
-h|--help)
echo 'Usage: gstack-skill-start --skill NAME [--model OVERLAY] [--parent-pid PID] [--brain-health]'
echo 'Usage: gstack-skill-start --skill NAME [--model OVERLAY] [--parent-pid PID] [--brain-health] [--loadout NAME --loadout-model ID --full-root DIR]'
echo 'Starts a skill session and prints its STATUS lines. --help starts nothing.'
exit 0 ;;
*) shift ;;
@@ -326,6 +330,33 @@ if [ -d ".claude/skills/gstack" ] && [ ! -L ".claude/skills/gstack" ]; then
fi
echo "VENDORED_GSTACK: $_VENDORED"
echo "MODEL_OVERLAY: $MODEL_OVERLAY"
# Loadout (GSTA-161, docs/loadouts.md): a render chosen for one model bakes
# its loadout, that model id and the full install root into the start command.
# Every value is re-validated before it is echoed, and GSTACK_MODEL (the
# running model, when the harness sets it) is only compared, never echoed
# unless it is a plain model id.
_LO_MODEL_RE='^[a-z0-9][a-z0-9._-]{0,79}$'
if [ -n "$LOADOUT" ]; then
[[ "$LOADOUT" =~ ^[a-z0-9-]{1,40}$ ]] || LOADOUT="invalid"
[[ "$LOADOUT_MODEL" =~ $_LO_MODEL_RE ]] || LOADOUT_MODEL=""
[[ "$FULL_ROOT" =~ ^/[A-Za-z0-9_.@+/-]*$ ]] || FULL_ROOT=""
[[ "$SKILL_NAME" =~ ^[a-z0-9-]{1,60}$ ]] || FULL_ROOT=""
if [ -z "$LOADOUT_MODEL" ] || [ -z "$FULL_ROOT" ]; then
echo "LOADOUT: $LOADOUT model=unknown: the start line is malformed; read the full SKILL.md for this skill instead of this file."
else
_lo_full="$FULL_ROOT/$SKILL_NAME/SKILL.md"
echo "LOADOUT: $LOADOUT model=$LOADOUT_MODEL full_root=$FULL_ROOT"
echo "LOADOUT_RULE: this file was rendered for $LOADOUT_MODEL. If you are not running $LOADOUT_MODEL, read $_lo_full now and follow it instead of this file."
if [ -n "${GSTACK_MODEL:-}" ]; then
_lo_run=$(printf '%s' "$GSTACK_MODEL" | tr '[:upper:]' '[:lower:]' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's/\[[^]]*\]$//' -e 's/[[:space:]]*$//' -e 's#^[a-z0-9-]*/##')
if ! [[ "$_lo_run" =~ $_LO_MODEL_RE ]]; then
echo "LOADOUT_MISMATCH: $LOADOUT_MODEL vs an invalid GSTACK_MODEL: read $_lo_full now and follow it instead of this file."
elif [ "$_lo_run" != "$LOADOUT_MODEL" ] && ! [[ "${_lo_run#"$LOADOUT_MODEL"-}" =~ ^([0-9]{8}|latest)$ && "$_lo_run" == "$LOADOUT_MODEL"-* ]]; then
echo "LOADOUT_MISMATCH: $LOADOUT_MODEL vs $_lo_run: read $_lo_full now and follow it instead of this file."
fi
fi
fi
fi
# Plan-mode hint for skills that branch on plan-mode state. Detected best-effort
# from CLAUDE_PLAN_FILE (set by the harness when plan mode is active); "inactive"
# is the safe default (file+execute pipeline).
@@ -395,7 +426,29 @@ if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.claude.json" ] && grep -q '"gbr
_GBRAIN_MCP_ENTRY=$(jq -c --arg cwd "$PWD" '((.projects // {}) | to_entries | map(select((.key as $k | $cwd == $k or ($cwd | startswith($k + "/")) or ($cwd | startswith($k + "\\"))) and ((try .value.mcpServers.gbrain catch null) != null))) | sort_by(.key | length) | last | .value.mcpServers.gbrain) // .mcpServers.gbrain // empty' "$HOME/.claude.json" 2>/dev/null)
_GBRAIN_MCP_TYPE=$(printf '%s' "$_GBRAIN_MCP_ENTRY" | jq -r '.type // .transport // empty' 2>/dev/null)
case "$_GBRAIN_MCP_TYPE" in
url|http|sse) _GBRAIN_MCP_MODE="remote-http" ;;
url|http|sse)
_GBRAIN_MCP_MODE="remote-http"
# #3539: a loopback URL whose local gbrain config names a Postgres
# engine (a database_url, engine not pglite) and no remote_mcp marker is
# this machine's own `gbrain serve --http`, not a remote brain. A conservative subset of lib/gbrain-mcp-locality.ts, not
# an exact mirror: the URL test accepts only canonical loopback
# spellings (localhost, 127.a.b.c with valid decimal octets, [::1], a
# valid port), so other spellings stay remote here. It lowercases with
# ascii_downcase, not the "i" flag, which case-folds U+017F to "s". As in
# TS, a URL containing "${" is remote (Claude Code expands it), and the
# regex rejects userinfo. The config test
# matches the TS one: remote_mcp is a marker when JS-truthy, as in
# gbrain's isThinClient (null, false, 0 and "" are not), and
# database_url counts when non-blank after JS trim() and engine is not
# pglite. GBRAIN_HOME is the
# parent of .gbrain, as in gbrainConfigDir. test/gbrain-mcp-locality.test.ts
# runs both programs below against the TS rule.
if printf '%s' "$_GBRAIN_MCP_ENTRY" | jq -e '(.url // "") | ascii_downcase | (contains("${") | not) and test("\\Ahttps?://(localhost|127(\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|\\[::1\\])(:([0-9]{0,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?([/?#]|\\z)")' >/dev/null 2>&1 \
&& jq -e '((.remote_mcp // false) | . == false or . == 0 or . == "") and .engine != "pglite" and ((.database_url // "") | (type == "string" and test("[^\\s\\x{feff}]|\\x{0085}")))' \
"${GBRAIN_HOME:-$HOME}/.gbrain/config.json" >/dev/null 2>&1; then
_GBRAIN_MCP_MODE="local-stdio"
fi
;;
stdio) _GBRAIN_MCP_MODE="local-stdio" ;;
esac
fi
@@ -609,8 +662,26 @@ fi
# is a quoted heredoc (markdown backticks must stay literal), so the resolved
# bin path is substituted via sed on the __BIN__ placeholder. Under off the
# routing rules are the auto-invocation the user turned off, so no offer.
# Under core the heredoc's rule lines are replaced by the core rules below, so
# the offered section routes only the four core skills.
_CORE_ROUTING_RULES=$(cat <<'EOR'
- Product ideas/brainstorming → invoke /office-hours
- QA/testing site behavior → invoke /qa
- Code review/diff check → invoke /review
- Ship/deploy/PR → invoke /ship
Do not suggest or auto-invoke other gstack skills: they run when the user types them, or when a running skill hands off to them (`gstack-config set proactive all` routes every skill).
EOR
)
_routing_offer() {
if [ "$_PROACTIVE" = "core" ]; then
_GSTACK_CORE_RULES="$_CORE_ROUTING_RULES" awk '/^- .* → invoke \// { if (!done) { print ENVIRON["_GSTACK_CORE_RULES"]; done = 1 } next } { print }'
else
cat
fi | sed "s|__BIN__|$_BIN|g"
}
if [ "$_HAS_ROUTING" = "no" ] && [ "$_ROUTING_DECLINED" = "false" ] && [ "$_PROACTIVE_PROMPTED" = "yes" ] && [ "$_PROACTIVE_OFF" = "no" ]; then
sed "s|__BIN__|$_BIN|g" <<'EOI' | _emit_block routing-injection
_routing_offer <<'EOI' | _emit_block routing-injection
gstack works best when the project CLAUDE.md includes skill routing rules. If no CLAUDE.md exists in the project root, create it. Use AskUserQuestion:
> gstack works best when your project's CLAUDE.md includes skill routing rules.
+215 -7
View File
@@ -46,6 +46,11 @@
#
# Resolution order (highest precedence first):
# 0. $GSTACK_PROJECT_SLUG env override (documented escape hatch)
# 0m. Project marker (#2649, #1923): the innermost counted .gstack-project on
# the cwd chain makes its folder the project root; nothing above it is
# consulted (see _find_project_marker for when a marker counts). Its
# result has its own cache entry, so removing the marker restores the
# folder's earlier slug untouched.
# 1. Walk UP from $(pwd) to the OUTERMOST ancestor containing a canonical
# project-identity marker (.git, .project.yaml, package.json, pyproject.toml,
# Cargo.toml, Gemfile, go.mod). Use that ancestor as the "project root".
@@ -108,8 +113,8 @@ case "${1:-}" in
"") ;;
--identity) MODE=identity ;;
--get) MODE=get; MODE_ARG="${2:-}"
case "$MODE_ARG" in SLUG|BRANCH|PROJECT_REMOTE|PROJECT_ROOT|LEGACY_SLUG|FORMER_SLUGS) ;;
*) echo "usage: gstack-slug --get SLUG|BRANCH|PROJECT_REMOTE|PROJECT_ROOT|LEGACY_SLUG|FORMER_SLUGS" >&2; exit 2 ;;
case "$MODE_ARG" in SLUG|REPO_SLUG|BRANCH|PROJECT_REMOTE|PROJECT_ROOT|PROJECT_MARKER|LEGACY_SLUG|FORMER_SLUGS) ;;
*) echo "usage: gstack-slug --get SLUG|REPO_SLUG|BRANCH|PROJECT_REMOTE|PROJECT_ROOT|PROJECT_MARKER|LEGACY_SLUG|FORMER_SLUGS" >&2; exit 2 ;;
esac ;;
--stamp-checkpoint) MODE=stamp; MODE_ARG="${2:-}"
[[ -n "$MODE_ARG" ]] || { echo "usage: gstack-slug --stamp-checkpoint <file>" >&2; exit 2; } ;;
@@ -127,6 +132,11 @@ case "${1:-}" in
MODE=$([[ "$1" == "--from" ]] && echo adopt-from || echo adopt-dismiss)
MODE_ARG=$(printf '%s' "${2:-}" | tr -cd 'a-zA-Z0-9._-')
case "$MODE_ARG" in ""|.|..) echo "usage: gstack-slug --adopt-legacy $1 <slug>" >&2; exit 2 ;; esac
ADOPT_APPLY=0
if [[ "$1" == "--from" && "${3:-}" == "--under-root" ]]; then
MODE=adopt-under
case "${4:-}" in "") ;; --apply) ADOPT_APPLY=1 ;; *) echo "usage: gstack-slug --adopt-legacy --from <slug> --under-root [--apply]" >&2; exit 2 ;; esac
fi
elif [[ -n "${1:-}" ]]; then
echo "usage: gstack-slug --adopt-legacy [--copy <relpath>... | --from <slug> | --dismiss <slug>]" >&2; exit 2
fi ;;
@@ -136,6 +146,8 @@ gstack-slug — print SLUG=<project slug> and BRANCH=<branch> for the current di
eval "$(gstack-slug)" set SLUG and BRANCH
SLUG=$(gstack-slug --get SLUG) print one raw value (SLUG, BRANCH, or an --identity
field); no eval needed
gstack-slug --get REPO_SLUG the slug ignoring every .gstack-project marker, for records
that belong to one repository (policy, approvals, consent)
gstack-slug --identity also print PROJECT_REMOTE (canonical, credential-free),
PROJECT_ROOT and LEGACY_SLUG (pre-#3003 bucket, if different)
gstack-slug --stamp-checkpoint FILE write remote:/project_root:/head: into FILE's frontmatter
@@ -154,6 +166,12 @@ gstack-slug — print SLUG=<project slug> and BRANCH=<branch> for the current di
de-duplication, missing files copy, differing files are
listed as conflicts and never overwritten; S is kept
gstack-slug --adopt-legacy --dismiss S stop naming former bucket S at skill start
gstack-slug --adopt-legacy --from S --under-root [--apply]
preview (or copy and restamp) S's checkpoints saved inside
the folder a .gstack-project marks
Marker: an empty .gstack-project file makes its folder a project of its own (nested repos,
umbrella folders over several repos); --identity shows which marker applied or why one
was ignored. Docs: docs/state-root.md#nested-repos-and-umbrella-folders
Env: GSTACK_PROJECT_SLUG pins the slug (ssh host aliases, vendored sub-repos); never cached.
USAGE
exit 0 ;;
@@ -319,13 +337,137 @@ _REMOTE_RESOLVED=0
_resolve_remote() {
if [[ "$_REMOTE_RESOLVED" -eq 1 ]]; then return 0; fi
_REMOTE_RESOLVED=1
REMOTE_ROOT=$(_outermost_remote_repo "$PROJECT_DIR")
if [[ -n "${MARKER_ROOT:-}" ]]; then
# A marked folder consults only its own remote, never an outer repo's.
REMOTE_ROOT=""
[[ -e "$MARKER_ROOT/.git" ]] && git -C "$MARKER_ROOT" remote get-url origin >/dev/null 2>&1 && REMOTE_ROOT="$MARKER_ROOT"
else
REMOTE_ROOT=$(_outermost_remote_repo "$PROJECT_DIR")
fi
if [[ -n "$REMOTE_ROOT" ]]; then
REMOTE_URL=$(git -C "$REMOTE_ROOT" remote get-url origin 2>/dev/null) || REMOTE_URL=""
fi
return 0
}
# 0m. Project marker (#2649, #1923). A .gstack-project counts when it is a
# regular file (not a link or a directory) owned by this user, below $HOME
# when the cwd is under $HOME, and its folder is a git toplevel or not inside
# any git work tree. So a cloned repo can't plant one in a subfolder to pick a
# bucket by folder name, and a stray ~/.gstack-project can't swallow every
# project. Ignored markers are listed by --identity only.
MARKER_ROOT=""
MARKER_IGNORED=""
_HOME_LOG="${HOME:-}"
_HOME_PHYS=$(cd "$_HOME_LOG" 2>/dev/null && pwd -P) || _HOME_PHYS=""
_UNDER_HOME=0
for _h in "$_HOME_LOG" "$_HOME_PHYS"; do
[[ -n "$_h" && "$_h" != / && "$PROJECT_DIR" == "$_h"/* ]] && _UNDER_HOME=1
done
_is_home_or_above() {
local h
for h in "$_HOME_LOG" "$_HOME_PHYS"; do
[[ -n "$h" && ( "$h" == "$1" || "$h" == "$1"/* || "$1" == / ) ]] && return 0
done
return 1
}
# Prints why the marker in folder $1 does not count; nothing when it counts.
# $2 overrides whether the walk stops at $HOME (default: the cwd is under it).
_marker_reason() {
local m="$1/.gstack-project" d parent depth=0 under="${2:-$_UNDER_HOME}"
if [[ -L "$m" ]]; then echo "a symbolic link"; return 0; fi
if [[ -d "$m" ]]; then echo "a directory, not a file: rmdir '$m' && touch '$m'"; return 0; fi
[[ -f "$m" ]] || { echo "not a regular file"; return 0; }
[[ -O "$m" ]] || { echo "owned by another user"; return 0; }
case "$(basename "$1" | tr -cd 'a-zA-Z0-9._-')" in
""|.|..) echo "its folder name has no letter or digit gstack can use in a project name; rename the folder or set GSTACK_PROJECT_SLUG"; return 0 ;;
esac
if [[ "$under" -eq 1 ]] && _is_home_or_above "$1"; then echo "at or above \$HOME"; return 0; fi
[[ ! -e "$1/.git" ]] || return 0
d=$(dirname "$1")
while [[ $depth -lt 64 ]]; do
if [[ "$under" -eq 1 ]] && _is_home_or_above "$d"; then break; fi
if [[ -e "$d/.git" ]]; then echo "inside the git work tree at $d; a marker counts only at a repository's top folder"; return 0; fi
parent=$(dirname "$d"); [[ "$parent" == "$d" ]] && break
d="$parent"; depth=$((depth + 1))
done
return 0
}
_find_project_marker() {
local dir="$1" parent="" why="" depth=0
while [[ -n "$dir" && $depth -lt 64 ]]; do
if [[ -e "$dir/.gstack-project" || -L "$dir/.gstack-project" ]]; then
why=$(_marker_reason "$dir")
if [[ -z "$why" ]]; then MARKER_ROOT=$(cd "$dir" 2>/dev/null && pwd -P) || MARKER_ROOT=""; [[ -z "$MARKER_ROOT" ]] || return 0; why="unreadable folder"; fi
MARKER_IGNORED="${MARKER_IGNORED}$dir/.gstack-project ($why)"$'\n'
fi
if [[ "$_UNDER_HOME" -eq 1 ]] && _is_home_or_above "$dir"; then break; fi
parent=$(dirname "$dir"); [[ "$parent" == "$dir" ]] && break
dir="$parent"; depth=$((depth + 1))
done
return 0
}
# A linked worktree outside the marked folder follows its main checkout: on a
# miss the walk repeats from the main checkout, under that folder's $HOME rule.
if [[ -z "$SLUG" && ! ( "$MODE" == get && "$MODE_ARG" == REPO_SLUG ) ]]; then
_find_project_marker "$PROJECT_DIR"
_main=""
[[ "$(basename "${GIT_COMMON_DIR:-x}")" != .git ]] || _main=$(dirname "$GIT_COMMON_DIR")
if [[ -z "$MARKER_ROOT" && -n "$_main" && "$PROJECT_DIR" != "$_main" && "$PROJECT_DIR" != "$_main"/* ]]; then
_cwd_under=$_UNDER_HOME
_UNDER_HOME=0
for _h in "$_HOME_LOG" "$_HOME_PHYS"; do
[[ -n "$_h" && "$_h" != / && "$_main" == "$_h"/* ]] && _UNDER_HOME=1
done
_find_project_marker "$_main"
_UNDER_HOME=$_cwd_under
fi
# A marker at a linked worktree's top folder stands for its main checkout.
if [[ -n "$MARKER_ROOT" && -f "$MARKER_ROOT/.git" ]]; then
_mc=$(_git_common_dir "$MARKER_ROOT")
[[ "$(basename "${_mc:-x}")" != .git ]] || MARKER_ROOT=$(dirname "$_mc")
fi
fi
_write_cache_entry() { # $1 file, $2 content; atomic, best-effort
local tmp
mkdir -p "$(dirname "$1")" 2>/dev/null || return 0
tmp=$(mktemp "$(dirname "$1")/.slug-XXXXXX" 2>/dev/null) || return 0
printf '%s' "$2" > "$tmp" && mv "$tmp" "$1" 2>/dev/null || rm -f "$tmp" 2>/dev/null
return 0
}
if [[ -n "$MARKER_ROOT" ]]; then
PROJECT_ROOT="$MARKER_ROOT"
MARKER_CACHE="$CACHE_DIR/marker/$(printf '%s' "$MARKER_ROOT" | sed -e 's/%/%25/g' -e 's/_/%5f/g' -e 's#/#_#g')"
_m=$(cat "$MARKER_CACHE" 2>/dev/null || true)
if [[ "$_m" == v2:* ]]; then
SLUG=$(printf '%s' "${_m#v2:}" | tr -cd 'a-zA-Z0-9._-')
case "$SLUG" in .|..) SLUG="" ;; esac
fi
if [[ -z "$SLUG" ]]; then
_resolve_remote
if [[ -n "$REMOTE_URL" ]]; then
gstack_remote_slug "$REMOTE_URL"
SLUG="$_gri_slug"
case "$SLUG" in ""|.|..|*/*) SLUG="" ;; esac
fi
SLUG="${SLUG:-$(basename "$MARKER_ROOT" | tr -cd 'a-zA-Z0-9._-')}"
case "$SLUG" in .|..) SLUG="" ;; esac
if [[ -n "$SLUG" ]]; then
_write_cache_entry "$MARKER_CACHE" "v2:$SLUG"
# One notice when the marker first moves this folder away from a bucket
# that holds checkpoints (stderr only; later runs are marker-cache hits).
for _f in "$CACHE_FILE" "$MAIN_CACHE_FILE" "$LEGACY_CACHE_FILE"; do
[[ -n "$_f" && -f "$_f" ]] || continue
_old=$(cat "$_f" 2>/dev/null || true); _old=$(printf '%s' "${_old#v2:}" | tr -cd 'a-zA-Z0-9._-')
if [[ -n "$_old" && "$_old" != "$SLUG" && "$_old" != . && "$_old" != .. && -n "$(ls -A "$GSTACK_STATE_ROOT/projects/$_old/checkpoints" 2>/dev/null)" ]]; then
echo "gstack: $MARKER_ROOT/.gstack-project files this folder under projects/$SLUG; checkpoints saved here before are in projects/$_old (preview recovering them with: gstack-slug --adopt-legacy --from $_old --under-root)" >&2
fi
break
done
fi
fi
fi
# 1b. Cached identity is STICKY (#2212): a project that used gstack before it
# adopted a git remote keeps its pre-origin slug — recomputing from the
# remote here would rename the project mid-life and orphan everything
@@ -408,6 +550,19 @@ if [[ -z "$SLUG" ]]; then
# slash-bearing slugs and fall through to the basename fallback below.
# (tr -cd already deletes "/", so */* is belt-and-braces.)
case "$SLUG" in ""|.|..|*/*) SLUG="" ;; esac
# One hint on a fresh resolve: this checkout has its own differing remote
# but files under an outer repo's (stderr only; the cache keeps it quiet).
if [[ -n "$SLUG" && -n "$REMOTE_ROOT" && "$(basename "${GIT_COMMON_DIR:-x}")" == .git ]]; then
_here=$(dirname "$GIT_COMMON_DIR")
_outer=$(cd "$REMOTE_ROOT" 2>/dev/null && pwd -P) || _outer=""
if [[ -n "$_outer" && "$_here" != "$_outer" ]]; then
_own=$(git -C "$_here" remote get-url origin 2>/dev/null) || _own=""
if [[ -n "$_own" ]] && gstack_remote_slug "$_own" && [[ -n "$_gri_slug" && "$_gri_slug" != "$SLUG" ]]; then
echo "gstack: $_here has its own remote but files under projects/$SLUG (the outer repo at $_outer); to give it its own memory: touch '$_here/.gstack-project' (docs/state-root.md#nested-repos-and-umbrella-folders)" >&2
fi
gstack_remote_slug "$REMOTE_URL"
fi
fi
fi
fi
@@ -432,7 +587,7 @@ SLUG=$(printf '%s' "$SLUG" | tr -cd 'a-zA-Z0-9._-')
# A worktree whose own pre-#2767 entry named a different bucket that still
# holds data: remember it so skill start can name the merge command.
if [[ -n "$SLUG" && "$SLUG_FROM_ENV" -eq 0 && "$_ENTRY_FILE" != "$LEGACY_CACHE_FILE" && -f "$LEGACY_CACHE_FILE" ]]; then
if [[ -n "$SLUG" && "$SLUG_FROM_ENV" -eq 0 && -z "$MARKER_ROOT" && "$_ENTRY_FILE" != "$LEGACY_CACHE_FILE" && -f "$LEGACY_CACHE_FILE" ]]; then
_OWN=$(cat "$LEGACY_CACHE_FILE" 2>/dev/null || true)
_OWN=$(printf '%s' "${_OWN#v2:}" | tr -cd 'a-zA-Z0-9._-')
_FORMER_FILE="$GSTACK_STATE_ROOT/projects/$SLUG/.former-slugs"
@@ -442,7 +597,7 @@ if [[ -n "$SLUG" && "$SLUG_FROM_ENV" -eq 0 && "$_ENTRY_FILE" != "$LEGACY_CACHE_F
fi
fi
if [[ -n "$SLUG" && "$SLUG_FROM_ENV" -eq 0 ]]; then
if [[ -n "$SLUG" && "$SLUG_FROM_ENV" -eq 0 && -z "$MARKER_ROOT" ]]; then
for _f in "$CACHE_FILE" "$LEGACY_CACHE_FILE"; do
CURRENT_CACHE=""
if [[ -f "$_f" ]]; then
@@ -470,8 +625,8 @@ RAW_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) || RAW_BRANCH=""
BRANCH=$(printf '%s' "${RAW_BRANCH:-}" | tr '/' '-' | tr -cd 'a-zA-Z0-9._-')
BRANCH="${BRANCH:-unknown}"
if [[ "$MODE" == "get" && ( "$MODE_ARG" == SLUG || "$MODE_ARG" == BRANCH ) ]]; then
if [[ "$MODE_ARG" == SLUG ]]; then printf '%s\n' "$SLUG"; else printf '%s\n' "$BRANCH"; fi
if [[ "$MODE" == "get" && ( "$MODE_ARG" == SLUG || "$MODE_ARG" == REPO_SLUG || "$MODE_ARG" == BRANCH ) ]]; then
if [[ "$MODE_ARG" == BRANCH ]]; then printf '%s\n' "$BRANCH"; else printf '%s\n' "$SLUG"; fi
exit 0
fi
FORMER_FILE="$GSTACK_STATE_ROOT/projects/$SLUG/.former-slugs"
@@ -665,6 +820,7 @@ case "$MODE" in
case "$MODE_ARG" in
PROJECT_REMOTE) printf '%s\n' "$ID_REMOTE" ;;
PROJECT_ROOT) printf '%s\n' "$ID_ROOT" ;;
PROJECT_MARKER) [[ -z "$MARKER_ROOT" ]] && echo || printf '%s\n' "$MARKER_ROOT/.gstack-project" ;;
LEGACY_SLUG) printf '%s\n' "$ID_LEGACY" ;;
esac
;;
@@ -674,7 +830,11 @@ case "$MODE" in
echo "BRANCH=$BRANCH"
printf 'PROJECT_REMOTE=%q\n' "$ID_REMOTE"
printf 'PROJECT_ROOT=%q\n' "$ID_ROOT"
printf 'PROJECT_MARKER=%q\n' "${MARKER_ROOT:+$MARKER_ROOT/.gstack-project}"
echo "LEGACY_SLUG=$ID_LEGACY"
while IFS= read -r _ign; do
if [[ -n "$_ign" ]]; then printf 'MARKER_IGNORED=%q\n' "$_ign"; fi
done <<<"$MARKER_IGNORED"
;;
stamp)
@@ -799,6 +959,54 @@ case "$MODE" in
done
;;
adopt-under)
# Recover checkpoints a former bucket holds for the marked folder: only
# those stamped (worktree:, else project_root:) at or below it. Preview
# unless --apply; copies are restamped with this identity and keep head:.
[[ -n "$MARKER_ROOT" ]] || { echo "gstack-slug: no .gstack-project applies here, so there is no marked folder to recover checkpoints for (see gstack-slug --identity)" >&2; exit 2; }
[[ "$MODE_ARG" != "$SLUG" ]] || { echo "gstack-slug: projects/$MODE_ARG is this project's own bucket." >&2; exit 2; }
AU_FROM="$STATE_PHYS/projects/$MODE_ARG/checkpoints"
AU_TO="$STATE_PHYS/projects/$SLUG/checkpoints"
if [[ ! -d "$AU_FROM" || -L "$AU_FROM" ]]; then echo "Nothing to recover: no checkpoints in projects/$MODE_ARG."; exit 0; fi
AU_WANT=0; AU_HAVE=0; AU_UNSTAMPED=0; AU_RC=0
while IFS= read -r f; do
[[ -n "$f" && -f "$f" && ! -L "$f" ]] || continue
IFS=$'\037' read -r _b root wt _t <<<"$(_checkpoint_meta "$f")"
if [[ -z "$root" && -z "$wt" ]]; then AU_UNSTAMPED=$((AU_UNSTAMPED + 1)); continue; fi
where="${wt:-$root}"
[[ "$where" == "$MARKER_ROOT" || "$where" == "$MARKER_ROOT"/* ]] || continue
dst="$AU_TO/$(basename "$f")"
if [[ -e "$dst" || -L "$dst" ]]; then echo "ALREADY $dst"; AU_HAVE=$((AU_HAVE + 1)); continue; fi
AU_WANT=$((AU_WANT + 1))
if [[ "$ADOPT_APPLY" -eq 0 ]]; then echo "WOULD_COPY $f"; continue; fi
AU_WT="${wt:-$root}"; [[ "$AU_WT" != "$ID_ROOT" ]] || AU_WT=""
mkdir -p "$AU_TO" && AU_TMP=$(mktemp "$AU_TO/.adopt-XXXXXX") || { echo "FAILED $f"; AU_RC=1; continue; }
if GRI_REMOTE="$ID_REMOTE" GRI_ROOT="$ID_ROOT" GRI_WORKTREE="$AU_WT" awk '
function ids() {
if (ENVIRON["GRI_REMOTE"] != "") print "remote: " ENVIRON["GRI_REMOTE"]
print "project_root: " ENVIRON["GRI_ROOT"]
if (ENVIRON["GRI_WORKTREE"] != "") print "worktree: " ENVIRON["GRI_WORKTREE"]
}
NR == 1 && $0 == "---" { infm = 1; print; next }
infm && $0 == "---" { ids(); print; infm = 0; next }
infm && (/^remote:/ || /^project_root:/ || /^worktree:/) { next }
{ print }' "$f" > "$AU_TMP" && touch -r "$f" "$AU_TMP" && ln "$AU_TMP" "$dst" 2>/dev/null; then
rm -f "$AU_TMP"; echo "COPIED $dst"
elif [[ -e "$dst" || -L "$dst" ]]; then
rm -f "$AU_TMP"; echo "ALREADY $dst"; AU_WANT=$((AU_WANT - 1)); AU_HAVE=$((AU_HAVE + 1))
else
rm -f "$AU_TMP"; echo "FAILED $f"; AU_RC=1
fi
done < <(find "$AU_FROM" -maxdepth 1 -name '*.md' -type f 2>/dev/null | LC_ALL=C sort)
if [[ "$ADOPT_APPLY" -eq 1 ]]; then
echo "recovered $AU_WANT checkpoint(s) from projects/$MODE_ARG into projects/$SLUG ($AU_HAVE already there); projects/$MODE_ARG is unchanged"
else
echo "$AU_WANT checkpoint(s) in projects/$MODE_ARG were saved inside $MARKER_ROOT ($AU_HAVE already here); nothing copied. To copy and restamp them: gstack-slug --adopt-legacy --from $MODE_ARG --under-root --apply"
fi
[[ "$AU_UNSTAMPED" -eq 0 ]] || echo "$AU_UNSTAMPED checkpoint(s) predate identity stamps, so their folder is unknown; they stay in projects/$MODE_ARG"
exit "$AU_RC"
;;
adopt)
if [[ -z "$ID_LEGACY" ]]; then
echo "Nothing to adopt: this project's slug ($SLUG) did not change."
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* gstack-tasks — a plan review's task list, and its reconciliation across reruns (G2, #1791).
*
* gstack-tasks prior --phase <p> [--branch <b>] [--dir <d>] [--json]
* gstack-tasks add --phase <p> [--branch <b>] [--dir <d>]
* gstack-tasks supersede --phase <p> --keep <list> [--branch <b>] [--dir <d>] [--json]
*
* --dir defaults to <state root>/projects/<gstack-slug SLUG>, --branch to the current git branch.
* Logic: lib/tasks-prior.ts. Codes: lib/result-codes-plan.ts (TASKS_SUPERSEDE_REFUSED).
*/
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
import { resultLine } from '../lib/result-codes';
import { runScriptSync } from '../lib/script-invocation';
import { resolveStateRoot } from '../lib/state-root';
import { SupersedeRefused, TASK_PHASES, addRows, finishPending, priorSet, readTaskFiles, rowsFileName, supersede, type TaskPhase } from '../lib/tasks-prior';
const HELP = `gstack-tasks — a plan review's task list across reruns
prior --phase <p> [--json] the newest earlier list for this phase and branch, and how to reconcile it;
deletes any rows file an unfinished run left
add --phase <p> validate the rows file, write this run's list (empty: ran, no findings) and its
dispositions to task-dispositions-<p>-<branch>.jsonl, then supersede this branch's earlier lists
supersede --phase <p> --keep <list> [--json] move this branch's reconciled earlier lists out of the aggregate
Every command takes [--dir <d>] (default <state root>/projects/<slug>) and [--branch <b>] (default the current git branch).
Rows file <repo>/.gstack/tmp/tasks-<p>-rows.jsonl, one JSON object per line, written with a file-write tool
(free text never goes on a command line; a missing file means zero rows):
task {"id","priority","component","title","source_finding","effort_human","effort_cc"} strings,
"files" an array of strings, optional "carried_from": "<run_id>/<id>"
disposition {"prior": "<run_id>/<id>", "disposition": "done" | "dropped", "reason": "<commit or why>"}
prior prints \`PRIOR_TASKS: none\` or \`PRIOR_TASKS: <file> run=<run_id> tasks=<n>\`, one JSON row per task, then a RECONCILE: line;
\`PRIOR_TASKS_MALFORMED: <file>:<line>\` goes to stderr for a line that is not a JSON object (skipped, never edited).
supersede renames each earlier tasks-<p>-*.jsonl whose rows are all this branch and not newer than --keep to
superseded-<name> and prints \`SUPERSEDED: <file>\` or \`KEPT: <file> (<reason>)\`; --keep must validate first.
${exitTableHelp()}`;
function flag(args: string[], name: string): string | undefined {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
}
function usage(message: string): number {
process.stderr.write(`gstack-tasks: ${message}\n${HELP}\n`);
return EXIT.usage;
}
const git = (...args: string[]) => { const r = spawnSync('git', args, { encoding: 'utf8', timeout: 10_000 }); return r.status === 0 ? r.stdout.trim() : ''; };
function slug(name: 'SLUG' | 'BRANCH'): string {
const r = runScriptSync(path.join(import.meta.dir, 'gstack-slug'), ['--get', name], { encoding: 'utf8', timeout: 10_000 });
return (r.status === 0 && r.stdout.trim()) || 'unknown';
}
/** The repository's git-excluded .gstack/tmp, where a host's file-write tool works without a permission card; the tasks directory outside a repository. */
function rowsDir(fallback: string): string {
const top = git('rev-parse', '--show-toplevel');
if (!top) return fallback;
const exclude = git('rev-parse', '--git-path', 'info/exclude');
if (exclude) {
const file = path.resolve(exclude);
fs.mkdirSync(path.dirname(file), { recursive: true });
if (!(fs.existsSync(file) && fs.readFileSync(file, 'utf8').split('\n').includes('/.gstack/tmp/'))) fs.appendFileSync(file, '/.gstack/tmp/\n');
}
return path.join(top, '.gstack', 'tmp');
}
function main(argv: string[]): number {
const [cmd, ...rest] = argv;
if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') { process.stdout.write(HELP + '\n'); return cmd ? EXIT.ok : EXIT.usage; }
if (!['prior', 'add', 'supersede'].includes(cmd)) return usage(`unknown command ${cmd}`);
const phase = flag(rest, '--phase') as TaskPhase | undefined;
if (!phase || !TASK_PHASES.includes(phase)) return usage(`--phase must be one of ${TASK_PHASES.join(', ')}`);
const dir = path.resolve(flag(rest, '--dir') ?? path.join(resolveStateRoot(), 'projects', slug('SLUG')));
const branch = flag(rest, '--branch') ?? (git('rev-parse', '--git-dir') ? git('branch', '--show-current') : 'unknown');
const json = rest.includes('--json');
try {
if (cmd === 'add') {
const r = addRows(dir, phase, branch, flag(rest, '--branch') ?? slug('BRANCH'), git('rev-parse', 'HEAD') || 'unknown', path.join(rowsDir(dir), rowsFileName(phase)));
process.stdout.write([`ADDED: tasks=${r.tasks} dispositions=${r.dispositions} ${r.file}`, ...r.superseded.map(f => `SUPERSEDED: ${f}`)].join('\n') + '\n');
return EXIT.ok;
}
if (cmd === 'prior') {
finishPending(dir, phase);
fs.rmSync(path.join(rowsDir(dir), rowsFileName(phase)), { force: true });
const files = readTaskFiles(dir, phase);
for (const f of files) for (const n of f.malformed) process.stderr.write(`PRIOR_TASKS_MALFORMED: ${f.file}:${n}\n`);
const prior = priorSet(files, phase, branch);
if (json) { process.stdout.write(JSON.stringify(prior ?? { file: null, run_id: null, rows: [] }) + '\n'); return EXIT.ok; }
if (!prior) { process.stdout.write('PRIOR_TASKS: none\n'); return EXIT.ok; }
process.stdout.write([`PRIOR_TASKS: ${prior.file} run=${prior.run_id} tasks=${prior.rows.length}`, ...prior.rows.map(r => JSON.stringify(r)),
`RECONCILE: decide each task above in this run's rows file (.gstack/tmp/tasks-${phase}-rows.jsonl). Still needed: list it again with "carried_from": "${prior.run_id}/<id>". Shipped: a disposition row {"prior": "${prior.run_id}/<id>", "disposition": "done", "reason": "<commit>"}. Not wanted: the same with "dropped" and why. gstack-tasks add then retires this list.`].join('\n') + '\n');
return EXIT.ok;
}
const keep = flag(rest, '--keep');
if (!keep) return usage('supersede needs --keep <the list this run wrote>');
const r = supersede(dir, phase, branch, keep);
if (json) process.stdout.write(JSON.stringify(r) + '\n');
else process.stdout.write([...r.moved.map(f => `SUPERSEDED: ${f}`), ...r.skipped.map(s => `KEPT: ${s.file} (${s.reason})`)].join('\n') + (r.moved.length || r.skipped.length ? '\n' : ''));
return EXIT.ok;
} catch (e) {
if (!(e instanceof SupersedeRefused)) throw e;
process.stderr.write(resultLine(`gstack-tasks ${cmd}`, 'TASKS_SUPERSEDE_REFUSED', e.message) + '\n');
return EXIT.refused;
}
}
process.exitCode = main(process.argv.slice(2));
+2 -2
View File
@@ -82,7 +82,7 @@ else
```bash
_GS=""
for _D in "${GSTACK_ROOT:-}" "$HOME/.claude/skills/gstack" "$HOME/.codex/skills/gstack" "$HOME/.factory/skills/gstack" "$HOME/.kiro/skills/gstack" "$HOME/.config/opencode/skills/gstack" "$HOME/.slate/skills/gstack" "$HOME/.cursor/skills/gstack" "$HOME/.openclaw/skills/gstack" "$HOME/.hermes/skills/gstack" "$HOME/.gbrain/skills/gstack" "$HOME/.copilot/skills/gstack" "$HOME/.gemini/antigravity-cli/skills/gstack" "$HOME/.gstack/repos/gstack"; do
for _D in "${GSTACK_ROOT:-}" "$HOME/.claude/skills/gstack" "$HOME/.codex/skills/gstack" "$HOME/.factory/skills/gstack" "$HOME/.kiro/skills/gstack" "$HOME/.config/opencode/skills/gstack" "$HOME/.slate/skills/gstack" "$HOME/.cursor/skills/gstack" "$HOME/.openclaw/skills/gstack" "$HOME/.hermes/skills/gstack" "$HOME/.gbrain/skills/gstack" "$HOME/.copilot/skills/gstack" "$HOME/.gemini/antigravity-cli/skills/gstack" "$HOME/.gemini/skills/gstack" "$HOME/.gstack/repos/gstack"; do
[ -z "$_GS" ] && [ -n "$_D" ] && [ -d "$_D/bin" ] && _GS="$_D"
done
[ -n "$_GS" ] && echo "GSTACK_OK: $_GS" || echo "GSTACK_MISSING"
@@ -153,7 +153,7 @@ const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
const roots = [process.env.GSTACK_ROOT || ''].concat([
'.claude/skills/gstack', '.codex/skills/gstack', '.factory/skills/gstack', '.kiro/skills/gstack',
'.config/opencode/skills/gstack', '.slate/skills/gstack', '.cursor/skills/gstack', '.openclaw/skills/gstack',
'.hermes/skills/gstack', '.gbrain/skills/gstack', '.copilot/skills/gstack', '.gemini/antigravity-cli/skills/gstack', '.gstack/repos/gstack',
'.hermes/skills/gstack', '.gbrain/skills/gstack', '.copilot/skills/gstack', '.gemini/antigravity-cli/skills/gstack', '.gemini/skills/gstack', '.gstack/repos/gstack',
].map((rel) => path.join(home, rel)));
const gstackRoot = roots.find((root) => {
try { return root !== '' && fs.statSync(path.join(root, 'bin')).isDirectory(); } catch { return false; }
+2 -2
View File
@@ -64,10 +64,10 @@ function main(argv: string[]): number {
if (predSha || predPr || predRef) predecessor = { pr: predPr ?? predecessor?.pr ?? null, recordedHead: predSha ?? predecessor?.recordedHead ?? null, ref: predRef ?? null };
const policyFrom = flag(argv, '--policy-from') ?? 'base';
const base = resolveBaseBranch(repoRoot, flag(argv, '--base'));
let loaded: LoadedPolicy;
if (policyFrom === 'head') loaded = loadPolicyFromHead(repoRoot);
else {
const base = resolveBaseBranch(repoRoot, flag(argv, '--base'));
const l = loadPolicyFromBase(repoRoot, base);
if (isUnavailable(l)) { process.stderr.write(policyUnavailableLine('gstack-tree-receipt', base, l.detail) + '\n'); return EXIT.refused; }
loaded = l;
@@ -76,7 +76,7 @@ function main(argv: string[]): number {
const receipt = buildReceipt({
repoRoot, gated, current: flag(argv, '--current'), policy: loaded.policy, policyLabel: loaded.source.kind === 'head' ? 'head (unreviewed)' : loaded.source.label,
predecessor, bundlePath: flag(argv, '--bundle'), rerunCommand: flag(argv, '--rerun-command'),
predecessor, bundlePath: flag(argv, '--bundle'), rerunCommand: flag(argv, '--rerun-command'), baseRef: `origin/${base}`,
});
if ('error' in receipt) { process.stderr.write(resultLine('gstack-tree-receipt', receipt.code, receipt.error) + '\n'); return EXIT.fail; }
process.stdout.write(json ? JSON.stringify(receipt) + '\n' : renderReceipt(receipt));
+23 -2
View File
@@ -24,6 +24,7 @@
# ~/.config/opencode/skills/gstack* — OpenCode skill install + per-skill symlinks
# ~/.copilot/skills/gstack* — GitHub Copilot CLI skill install + per-skill symlinks
# ~/.gemini/antigravity-cli/skills/gstack* — Antigravity CLI skill install + per-skill symlinks
# ~/.gemini/skills/gstack* — Gemini CLI skill install + per-skill symlinks (gstack-managed only)
# harness registrations — every file gstack-harness wrote into OpenClaw,
# Hermes, a Dot or another harness (from its
# ledger; edited files move to a backup), and
@@ -59,6 +60,9 @@ GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
STATE_DIR="$HOME/.gstack"
_STATE_DOC="https://github.com/garrytan/gstack/blob/main/docs/state-root.md"
. "$(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 ($_STATE_DOC)" >&2; exit 1; }
# Skill names without a process per skill (#2120); an older copy lacking the helper keeps the pipeline.
. "$(dirname "$0")/gstack-skill-name.sh" 2>/dev/null \
|| gstack_skill_md_name() { GSTACK_SKILL_NAME=$(grep -m1 '^name:' "$1" 2>/dev/null | sed 's/^name:[[:space:]]*//' | tr -d '[:space:]' || true); }
gstack_state_root_select
RESOLVED_ROOT="$_gstack_sr_root"
RESOLVED_VAR="$_gstack_sr_var"
@@ -141,6 +145,7 @@ if [ "$FORCE" -eq 0 ]; then
[ -d "$HOME/.config/opencode/skills" ] && echo " ~/.config/opencode/skills/gstack*"
[ -d "$HOME/.copilot/skills" ] && echo " ~/.copilot/skills/gstack*"
[ -d "$HOME/.gemini/antigravity-cli/skills" ] && echo " ~/.gemini/antigravity-cli/skills/gstack*"
[ -d "$HOME/.gemini/skills" ] && echo " ~/.gemini/skills/gstack*"
[ "$KEEP_STATE" -eq 0 ] && [ -d "$STATE_DIR" ] && echo " $STATE_DIR"
[ -n "$_LEAVE_ROOT" ] && echo " (kept) $_LEAVE_ROOT: state root selected by $RESOLVED_VAR; left in place"
@@ -288,8 +293,8 @@ _INVENTORY=" _gstack-command connect-chrome gstack-connect-chrome "
if [ -d "$GSTACK_DIR" ]; then
for _SRC in "$GSTACK_DIR"/*/; do
[ -f "$_SRC/SKILL.md" ] || continue
_SRC_NAME="$(basename "$_SRC")"
_FM_NAME=$(grep -m1 '^name:' "$_SRC/SKILL.md" 2>/dev/null | sed 's/^name:[[:space:]]*//' | tr -d '[:space:]' || true)
_SRC_NAME=${_SRC%/}; _SRC_NAME=${_SRC_NAME##*/}
gstack_skill_md_name "$_SRC/SKILL.md"; _FM_NAME=$GSTACK_SKILL_NAME
for _N in "$_SRC_NAME" "$_FM_NAME"; do
[ -n "$_N" ] || continue
case "$_INVENTORY" in *" $_N "*) ;; *) _INVENTORY="$_INVENTORY$_N gstack-$_N " ;; esac
@@ -546,6 +551,22 @@ if [ -d "$ANTIGRAVITY_SKILLS" ]; then
done
fi
# ─── Remove Gemini CLI skills ───────────────────────────────
# Same provenance gate. ~/.gemini/skills also holds the user's own skills, so
# only gstack-managed gstack* entries go.
GEMINI_SKILLS="$HOME/.gemini/skills"
if [ -d "$GEMINI_SKILLS" ]; then
for _ITEM in "$GEMINI_SKILLS"/gstack*; do
[ -e "$_ITEM" ] || [ -L "$_ITEM" ] || continue
if _cursor_item_is_gstack_managed "$_ITEM"; then
rm -rf "$_ITEM"
REMOVED+=("gemini/$(basename "$_ITEM")")
else
_SKIPPED_DIRS+=("$_ITEM")
fi
done
fi
# ─── Reconcile the install registry ─────────────────────────
# A registry that survives (--keep-state, or a state root selected by an env
# var and left in place) must forget the installs removed above, or the next
+6 -2
View File
@@ -21,7 +21,9 @@
# 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
# lock; never stashes, never resets. Before any fetch it also refuses when
# bin/gstack-ff-update --preflight does: an enclosing project tracks the
# install's files, or VERSION was put back behind HEAD (#1022). An unreachable remote is
# 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
@@ -50,7 +52,7 @@ case "${1:-}" in
--check) CHECK=1 ;;
--ff-only-no-reinstall) REINSTALL=0 ;;
'') ;;
-h|--help) sed -n '2,38p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
-h|--help) sed -n '2,41p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "gstack-upgrade: unknown argument '$1'" >&2; exit 2 ;;
esac
@@ -71,6 +73,8 @@ bounded() { if command -v timeout >/dev/null 2>&1; then timeout "${GSTACK_UPGRAD
TOPLEVEL=$(git -C "$ROOT" rev-parse --show-toplevel 2>/dev/null)
[ -f "$ROOT/VERSION" ] && [ -f "$ROOT/setup" ] && [ -n "$TOPLEVEL" ] && gstack_path_same "$TOPLEVEL" "$ROOT" \
|| refuse "$ROOT is not a gstack checkout" "run gstack-upgrade from a git checkout of https://github.com/garrytan/gstack"
PF=$(bash "$ROOT/bin/gstack-ff-update" --preflight "$ROOT" 2>/dev/null)
[ $? -eq 6 ] && { echo "gstack-upgrade: $PF (UPGRADE_REFUSED)" >&2; exit 3; }
export GIT_TERMINAL_PROMPT=0
HOST=$(git -C "$ROOT" remote get-url origin 2>/dev/null); HOST="${HOST#*://}"; HOST="${HOST#*@}"; HOST="${HOST%%[/:]*}"
R="user ran gstack-upgrade"
+28 -1
View File
@@ -9,6 +9,12 @@
# look like a mid-run edit (GST-421); tracked files under it still count, and a
# tree without that folder (or with it gitignored) hashes exactly as a plain
# `git add -A` would.
#
# --review (gstack-review-log and gstack-review-read only, GST-985) also leaves
# out every root dot-directory that holds no tracked file, such as a test
# lane's `.review-units-test-*/` scratch, so scratch appearing or vanishing
# mid-review cannot unbind a review. `.github/` and `.gstack/` always stay in:
# they hold CI and gate policy. Every other caller keeps the whole tree.
# Properties that make this the right staleness fingerprint, vs
# `git rev-parse HEAD^{tree}`:
#
@@ -40,6 +46,13 @@
# treat that as "no fingerprint"; a partial hash is never printed.
set -euo pipefail
REVIEW=""
case "$#:${1:-}" in
0:) ;;
1:--review) REVIEW=1 ;;
*) echo "Usage: gstack-wtree [--review]" >&2; exit 2 ;;
esac
TOP=$(git rev-parse --show-toplevel 2>/dev/null) || exit 1
# Resolve the REAL index path BEFORE exporting GIT_INDEX_FILE — with the env
# var set, `git rev-parse --git-path index` returns the temp index itself and
@@ -95,7 +108,21 @@ fi
# call .bun not ignored.
EXCLUDE_BUN=""
if [ -e "$TOP/.bun" ] && ! git -C "$TOP" check-ignore --no-index -q .bun 2>/dev/null; then EXCLUDE_BUN=':(exclude,top).bun'; fi
if ! ERR=$(git -C "$TOP" add -u 2>&1 >/dev/null) || ! ERR=$(git -C "$TOP" add -A -- . ${EXCLUDE_BUN:+"$EXCLUDE_BUN"} 2>&1 >/dev/null); then
EXCLUDES=()
if [ -n "$EXCLUDE_BUN" ]; then EXCLUDES+=("$EXCLUDE_BUN"); fi
if [ -n "$REVIEW" ]; then
TRACKED_INDEX="$TMPIDX"
if [ -n "$REAL_INDEX" ] && [ -f "$REAL_INDEX" ]; then TRACKED_INDEX="$REAL_INDEX"; fi
for DOTDIR in "$TOP"/.[!.]* "$TOP"/..?*; do
NAME=${DOTDIR##*/}
case "$NAME" in .git|.github|.gstack|.bun) continue ;; esac
if [ ! -d "$DOTDIR" ] || [ -L "$DOTDIR" ]; then continue; fi
if GIT_INDEX_FILE="$TRACKED_INDEX" git -C "$TOP" ls-files --error-unmatch -- ":(top,literal)$NAME" >/dev/null 2>&1; then continue; fi
if git -C "$TOP" check-ignore --no-index -q -- "$NAME" 2>/dev/null; then continue; fi
EXCLUDES+=(":(exclude,top,literal)$NAME")
done
fi
if ! ERR=$(git -C "$TOP" add -u 2>&1 >/dev/null) || ! ERR=$(git -C "$TOP" add -A -- . ${EXCLUDES[@]+"${EXCLUDES[@]}"} 2>&1 >/dev/null); then
echo "gstack-wtree: cannot stage the working tree: $(printf '%s' "$ERR" | head -1)" >&2
exit 1
fi
+11
View File
@@ -318,6 +318,17 @@ console.log("ASIDE_DIR=" + pwd); await closeTab(pg); console.log("GSTACK_STEP_OK
'
```
**Touch (verified in Aside 1.26; add the Responsive override for phone size):**
```bash
aside repl '
const pg = await openTab("<url>"), t = (m, p) => pg._sendToTarget(m, p);
await t("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 5 });
for (const [type, touchPoints] of [["touchStart", [{ x: <tap-x>, y: <tap-y> }]], ["touchEnd", []]]) await t("Input.dispatchTouchEvent", { type, touchPoints });
await closeTab(pg); console.log("GSTACK_STEP_OK");
'
```
**Links and their status (same-origin; on a LOCAL target each link is HEAD-checked, on a real site the user's cookies would ride every request so links are listed as `LINK ?` unfetched — consent to LOOK is not consent to hit every URL):**
```bash
+9
View File
@@ -6,6 +6,15 @@
# bin/gstack-remote-identity.sh.
set -e
. "$(dirname "$0")/../../bin/gstack-remote-identity.sh" 2>/dev/null || { echo "$0: bin/gstack-remote-identity.sh is missing. fix: reinstall with ./setup or /gstack-upgrade" >&2; exit 1; }
# A counted .gstack-project marker (#2649, #1923), found on the cwd chain or
# from a linked worktree's main checkout, decides identity the way bin/gstack-slug
# does, so design-doc lookups find docs where skills saved them.
_id=$("$(dirname "$0")/../../bin/gstack-slug" --identity 2>/dev/null || true)
_marker=$(printf '%s\n' "$_id" | sed -n 's/^PROJECT_MARKER=//p')
if [ -n "$_marker" ] && [ "$_marker" != "''" ]; then
printf '%s\n' "$_id" | sed -n 's/^SLUG=//p'
exit 0
fi
URL=$(git remote get-url origin 2>/dev/null || true)
_gri_slug=""
if [ -n "$URL" ]; then
+5
View File
@@ -95,15 +95,20 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`.
| `cookie <name>=<value>` | Set cookie on current page domain |
| `cookie-import <json>` | Import cookies from JSON file |
| `cookie-import-browser [browser] [--domain d] [--profile p] [--all] [--clear-storage] [--verify-auth]` | Copy cookies from chrome, chromium, brave, edge, Windows-only opera, opera-gx, or macOS-only comet or arc. Omitted browser retains legacy comet; select the intended browser explicitly. --domain requires a matching current page; no scope flag opens the picker. --profile is the source directory; ambiguous profiles require selection. --all explicitly selects every non-expired cookie and cannot accompany --domain or --clear-storage. Storage is preserved unless --clear-storage resets captured-origin localStorage (shared across context tabs) and target-tab sessionStorage. --verify-auth requires daemon GSTACK_COOKIE_AUTH_SELECTOR and GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY before startup; missing config rejects before mutation. Verified means an exact visible identity match, not cookie counts or HTTP 200. Windows native extraction remains disabled pending qualification. |
| `device [<name> [--landscape] | off | --list]` | Emulate a phone on the current tab only: size, pixel ratio, touch support (pointer: coarse) and user agent from Playwright's devices table (names match case-insensitively). Other tabs stay desktop. No name prints the active profile or "none"; --list prints every name; off (or reset, auto) restores. viewport and responsive refuse while a device is active. |
| `dialog-accept [text]` | Auto-accept next alert/confirm/prompt. Optional text is sent as the prompt response |
| `dialog-dismiss` | Auto-dismiss next dialog |
| `fill <sel> <val>` | Fill input |
| `header <name>:<value>` | Set custom request header (colon-separated, sensitive values auto-redacted) |
| `hover <sel>` | Hover element |
| `pinch <@ref|selector|x y> <scale>` | Two-finger pinch around an element's center or x y: scale above 1 spreads the fingers (zoom in), below 1 brings them together. Needs $B device first. |
| `press <key>` | Press a Playwright keyboard key against the focused element. Names are case-sensitive: Enter, Tab, Escape, ArrowUp/Down/Left/Right, Backspace, Delete, Home, End, PageUp, PageDown. Modifiers combine with +: Shift+Enter, Control+A, Meta+K. Single printable chars (a, A, 1) work too. Full key list: https://playwright.dev/docs/api/class-keyboard#keyboard-press |
| `scroll [sel|@ref]` | With a selector, smooth-scrolls the element into view. Without a selector, jumps to page bottom. No --by/--to amount option; for pixel-precise scrolling use `js window.scrollTo(0, N)`. |
| `select <sel> <val>` | Select dropdown option by value, label, or visible text |
| `style <sel> <prop> <value> | style --undo [N]` | Modify CSS property on element (with undo support) |
| `swipe <@ref|selector|x y> <up|down|left|right> [--distance <px>] [--ms <n>]` | One-finger swipe from an element's center or x y; the finger moves in the given direction (default 300px over 300ms, 10 moves). Needs $B device first. |
| `tap <@ref|selector|x y> [--hold <ms>]` | Touch and release at an element's center (scrolled into view) or at x y. Needs $B device first. |
| `touch [down|move <finger 0-9> <x> <y> | up <finger> | cancel]` | Hold, move and lift numbered fingers for multi-finger flows (hold a joystick with finger 0, tap fire with finger 1). Lifting one finger leaves the others down. No action lists held fingers; cancel releases all. Navigation and device off cancel held fingers. Needs $B device first. |
| `type [--selector <sel>] [--] <text>` | Send keystrokes to the focused element, or to <sel> with --selector (focuses it first). Text that starts with -- goes after a -- separator. |
| `upload <sel> <file> [file2...]` | Upload file(s) |
| `useragent <string>` | Set user agent |
+5 -1
View File
@@ -42,6 +42,8 @@ export interface BrowseClientOptions {
token?: string;
/** Tab id to target (every command can scope to a tab). Default: BROWSE_TAB env or undefined (active tab). */
tabId?: number;
/** Session whose own tab runs every command (root token only; tabId wins when both are set). Default: none. */
session?: string;
/** Per-request timeout in milliseconds. Default: 30_000. */
timeoutMs?: number;
/** Override state-file path. Default: BROWSE_STATE_FILE env or <git-root>/.gstack/browse.json. */
@@ -133,6 +135,7 @@ export class BrowseClient {
readonly port: number;
readonly token: string;
readonly tabId?: number;
readonly session?: string;
readonly timeoutMs: number;
constructor(opts: BrowseClientOptions = {}) {
@@ -140,6 +143,7 @@ export class BrowseClient {
this.port = auth.port;
this.token = auth.token;
this.tabId = opts.tabId ?? parseIntegerEnvValue(process.env.BROWSE_TAB);
this.session = opts.session;
this.timeoutMs = opts.timeoutMs ?? 30_000;
}
@@ -150,7 +154,7 @@ export class BrowseClient {
const body = JSON.stringify({
command: cmd,
args,
...(this.tabId !== undefined && !isNaN(this.tabId) ? { tabId: this.tabId } : {}),
...(this.tabId !== undefined && !isNaN(this.tabId) ? { tabId: this.tabId } : this.session ? { session: this.session } : {}),
});
let resp: Response;
Loaded 100 of 907 files, more files were not shown because too many files have changed in this diff. Show more