mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 15:27:56 +01:00
Merge remote-tracking branch 'origin/main' into capy/end-four-non-subject
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> # Conflicts: # CHANGELOG.md
This commit is contained in:
commit
606b4dc380
848 files changed
+67567
-9404
No files matched your search
@@ -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
|
||||
|
||||
Executable
+48
@@ -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")"
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
@@ -278,10 +289,14 @@ jobs:
|
||||
# Free Tests red on this head fails the run without paid work (re-run this
|
||||
# workflow after a green Free Tests rerun). Dispatches never wait: they are
|
||||
# explicit (eval:bg, census). Version Gate is not an input, so a stamp push
|
||||
# waits for its own Free Tests only and always gets its gate.
|
||||
# waits for its own Free Tests only and always gets its gate. A pr-summary
|
||||
# run (a repository with a train block) plans no paid slices, so it skips
|
||||
# the wait: the train gate and the dispatched screen carry the paid work,
|
||||
# and Free Tests stays the required free check.
|
||||
readiness:
|
||||
runs-on: ubuntu-24.04
|
||||
if: github.event_name == 'pull_request' && github.actor != 'dependabot[bot]' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
needs: [route]
|
||||
if: github.event_name == 'pull_request' && github.actor != 'dependabot[bot]' && github.event.pull_request.head.repo.full_name == github.repository && needs.route.outputs.mode != 'pr-summary'
|
||||
timeout-minutes: 90
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -466,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
|
||||
@@ -475,6 +491,7 @@ jobs:
|
||||
- name: Print the screening selection and its estimated cost (no paid case runs)
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
EVALS_SCREEN_POLICY: narrow
|
||||
run: |
|
||||
EVALS_TIER=gate bun --no-install run scripts/test-paid-shards.ts --tier gate --emit-plan /tmp/paid-plan/manifest.json --slices 1
|
||||
bun --no-env-file --config=/dev/null --no-install run scripts/train-gate.ts screen-summary /tmp/paid-plan/manifest.json --pr "$PR_NUMBER" >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -523,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
|
||||
@@ -569,6 +587,9 @@ jobs:
|
||||
EVALS_CACHE_DIR: ${{ steps.reuse.outputs.reuse == 'on' && '/tmp/gstack-eval-input-cache' || '' }}
|
||||
# Dispatch diff base (CEO-17): a screen's merge-commit first parent, else the exact merge-base, else the named base branch, else detection.
|
||||
EVALS_BASE: ${{ needs.route.outputs.screen_base || inputs.base_sha || (inputs.base_ref != '' && format('origin/{0}', inputs.base_ref)) || '' }}
|
||||
# Dispatched screens and eval:bg:pr default to narrow; a broad screen (`ready --screen --broad`) and a
|
||||
# pull_request run (the merge gate when no train block exists) keep every derived dependent.
|
||||
EVALS_SCREEN_POLICY: ${{ inputs.screen_policy || (github.event_name == 'workflow_dispatch' && 'narrow') || 'broad' }}
|
||||
run: EVALS_TIER=gate bun --no-install run scripts/test-paid-shards.ts --tier gate --emit-plan /tmp/paid-plan/manifest.json --slice-budget 420 --jobs 2 --max-parallel 20
|
||||
|
||||
# R2a/R2: reuse holds only when this head's own plan selects exactly the
|
||||
@@ -662,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
|
||||
@@ -895,6 +917,7 @@ jobs:
|
||||
with:
|
||||
ref: ${{ inputs.screen_merge_sha }}
|
||||
fetch-depth: 0
|
||||
filter: blob:none
|
||||
persist-credentials: false
|
||||
|
||||
- name: Fix bun temp
|
||||
@@ -1021,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
|
||||
@@ -1165,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
|
||||
|
||||
@@ -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"
|
||||
@@ -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:
|
||||
@@ -23,6 +32,22 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
free_runner:
|
||||
description: 'Runner-sizing experiments only: Ubicloud runner for the free-suite shards (ubicloud-standard-2, -4 or -8; empty keeps FREE_SUITE_RUNNER). Never cite such a run as a PR or train Free Tests result.'
|
||||
type: string
|
||||
default: ''
|
||||
free_shards:
|
||||
description: 'Runner-sizing experiments only: shard count, 1-40 (empty keeps FREE_SUITE_SHARDS)'
|
||||
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.
|
||||
env:
|
||||
FREE_SUITE_RUNNER: ubicloud-standard-4
|
||||
FREE_SUITE_SHARDS: '20'
|
||||
|
||||
# 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
|
||||
@@ -38,27 +63,81 @@ 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:
|
||||
matrix: ${{ steps.plan.outputs.matrix }}
|
||||
runner: ${{ steps.plan.outputs.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
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
|
||||
- id: plan
|
||||
name: Inventory and balance the complete free suite
|
||||
# Warns in the job summary when more than 5 files are unseeded.
|
||||
env:
|
||||
RUNNER_OVERRIDE: ${{ inputs.free_runner }}
|
||||
SHARDS_OVERRIDE: ${{ inputs.free_shards }}
|
||||
run: |
|
||||
matrix=$(bun run scripts/test-free-shards.ts --ci-plan "$RUNNER_TEMP/free-plan.json" --shards 20)
|
||||
set -euo pipefail
|
||||
runner="${RUNNER_OVERRIDE:-$FREE_SUITE_RUNNER}"
|
||||
shards="${SHARDS_OVERRIDE:-$FREE_SUITE_SHARDS}"
|
||||
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 ;;
|
||||
esac
|
||||
case "$shards" in
|
||||
''|*[!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"
|
||||
fi
|
||||
matrix=$(bun run scripts/test-free-shards.ts --ci-plan "$RUNNER_TEMP/free-plan.json" --shards "$shards")
|
||||
echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
|
||||
echo "runner=$runner" >> "$GITHUB_OUTPUT"
|
||||
- name: Seed growth ratchet (60 s ceiling, shrink-only allowlist)
|
||||
run: |
|
||||
GSTACK_FREE_SEED_BASE="$(git merge-base HEAD origin/main)" bun test test/free-seed-ratchet.test.ts
|
||||
@@ -69,6 +148,8 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
cso-macos-launcher:
|
||||
needs: reuse
|
||||
if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
@@ -107,6 +188,8 @@ jobs:
|
||||
# default, and the job fails if that layout is not symlinked. It also sources
|
||||
# the Codex probe from zsh, macOS's default shell (#3024; no paid call).
|
||||
macos-named-regressions:
|
||||
needs: reuse
|
||||
if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
@@ -151,6 +234,8 @@ jobs:
|
||||
bun test "${files[@]}"
|
||||
|
||||
cso-windows-launcher:
|
||||
needs: reuse
|
||||
if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
@@ -179,6 +264,8 @@ jobs:
|
||||
retention-days: 14
|
||||
|
||||
cso-docker-integration:
|
||||
needs: reuse
|
||||
if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
@@ -209,6 +296,8 @@ jobs:
|
||||
DOCKER_HOST: unix:///var/run/docker.sock
|
||||
|
||||
typecheck:
|
||||
needs: reuse
|
||||
if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
@@ -232,6 +321,8 @@ jobs:
|
||||
# the quick tier also runs on it. Its pin equals GSTACK_BUN_DEFAULT;
|
||||
# test/bun-version-drift.test.ts checks this job exists and agrees.
|
||||
quick-bun-default:
|
||||
needs: reuse
|
||||
if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
|
||||
runs-on: ubicloud-standard-8
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
@@ -288,6 +379,21 @@ jobs:
|
||||
installed="${chrome%/*}/chrome-sandbox"
|
||||
test -f "$helper" && test ! -L "$helper"
|
||||
sudo install -T -o root -g root -m 4755 "$helper" "$installed"
|
||||
# Runner image 20261026.1.0 keeps kernel.apparmor_restrict_unprivileged_userns=1
|
||||
# but loads no AppArmor profiles, so Chromium's namespace sandbox aborts with
|
||||
# "credentials.cc: Permission denied". Grant user namespaces to this one
|
||||
# resolved binary, the way Ubuntu's own chrome profile does.
|
||||
- name: Let the bundled Chromium create its sandbox namespaces
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chrome=$(bun -e 'import { chromium } from "playwright"; import { realpathSync } from "node:fs"; console.log(realpathSync(chromium.executablePath()))')
|
||||
case "$chrome" in
|
||||
"$HOME"/.cache/ms-playwright/chromium-*/chrome-linux*/chrome) ;;
|
||||
*) echo "Unexpected Chromium installation path" >&2; exit 1 ;;
|
||||
esac
|
||||
profile=/etc/apparmor.d/gstack-playwright-chromium
|
||||
printf 'abi <abi/4.0>,\ninclude <tunables/global>\nprofile gstack-playwright-chromium %s flags=(unconfined) {\n userns,\n}\n' "$chrome" | sudo tee "$profile" > /dev/null
|
||||
sudo apparmor_parser -r "$profile"
|
||||
- name: Install Xvfb + X11 utilities + gate tools
|
||||
run: sudo apt-get install -y --no-install-recommends xvfb x11-utils poppler-utils fonts-noto-color-emoji zsh
|
||||
- name: Configure git identity (tests init temp repos)
|
||||
@@ -313,8 +419,9 @@ jobs:
|
||||
run: xvfb-run -a bun run test:quick
|
||||
|
||||
free-suite:
|
||||
needs: free-plan
|
||||
runs-on: ubicloud-standard-8
|
||||
needs: [free-plan, reuse]
|
||||
if: ${{ !cancelled() && needs.free-plan.result == 'success' && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
|
||||
runs-on: ${{ needs.free-plan.outputs.runner }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -397,6 +504,21 @@ jobs:
|
||||
sudo install -T -o root -g root -m 4755 "$helper" "$installed"
|
||||
test "$(stat -c '%u:%a' "$installed")" = '0:4755'
|
||||
cmp -s "$helper" "$installed"
|
||||
# Runner image 20261026.1.0 keeps kernel.apparmor_restrict_unprivileged_userns=1
|
||||
# but loads no AppArmor profiles, so Chromium's namespace sandbox aborts with
|
||||
# "credentials.cc: Permission denied". Grant user namespaces to this one
|
||||
# resolved binary, the way Ubuntu's own chrome profile does.
|
||||
- name: Let the bundled Chromium create its sandbox namespaces
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chrome=$(bun -e 'import { chromium } from "playwright"; import { realpathSync } from "node:fs"; console.log(realpathSync(chromium.executablePath()))')
|
||||
case "$chrome" in
|
||||
"$HOME"/.cache/ms-playwright/chromium-*/chrome-linux*/chrome) ;;
|
||||
*) echo "Unexpected Chromium installation path" >&2; exit 1 ;;
|
||||
esac
|
||||
profile=/etc/apparmor.d/gstack-playwright-chromium
|
||||
printf 'abi <abi/4.0>,\ninclude <tunables/global>\nprofile gstack-playwright-chromium %s flags=(unconfined) {\n userns,\n}\n' "$chrome" | sudo tee "$profile" > /dev/null
|
||||
sudo apparmor_parser -r "$profile"
|
||||
|
||||
# Headed-browser tests (handoff, extension sidepanel DOM) need a real
|
||||
# DISPLAY — first Linux run failed with Playwright's "launched a headed
|
||||
@@ -446,6 +568,18 @@ jobs:
|
||||
- name: Build trusted CSO helper used by free contract tests
|
||||
run: bun run build:cso
|
||||
|
||||
# Runner sizing: the suite runs one test at a time, so a shard needs RAM
|
||||
# and disk more than cores. Sample both while it runs and report the peak.
|
||||
- name: Start the memory and disk sampler
|
||||
run: |
|
||||
cat > "$RUNNER_TEMP/sample-resources.sh" <<'EOF'
|
||||
while :; do
|
||||
printf '%s %s %s\n' "$(date +%s)" "$(free -k | awk 'NR == 2 { print $3 }')" "$(df -Pk / | awk 'NR == 2 { print $4 }')"
|
||||
sleep 2
|
||||
done
|
||||
EOF
|
||||
nohup sh "$RUNNER_TEMP/sample-resources.sh" > "$RUNNER_TEMP/resources.log" 2>&1 &
|
||||
|
||||
# GSTACK_EXPECT_BINARIES=1 arms make-pdf/test/e2e/ci-prereqs.test.ts:
|
||||
# if a future edit drops the gate build (or poppler), the lane FAILS
|
||||
# instead of the gates silently self-skipping back to false green.
|
||||
@@ -464,6 +598,12 @@ jobs:
|
||||
GSTACK_FREE_RETRY_FLAKY: "1"
|
||||
GSTACK_FLAKE_LEDGER: ${{ runner.temp }}/flake-ledger.jsonl
|
||||
|
||||
- name: Report peak memory and minimum free disk
|
||||
if: always()
|
||||
run: |
|
||||
if [ ! -s "$RUNNER_TEMP/resources.log" ]; then echo "RESOURCES: no samples"; exit 0; fi
|
||||
awk '{ if ($2 > used) used = $2; if (free == "" || $3 < free) free = $3 } END { printf "RESOURCES: peak_used_mib=%d min_free_disk_mib=%d samples=%d\n", used / 1024, free / 1024, NR }' "$RUNNER_TEMP/resources.log" | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload strict shard result
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
@@ -505,13 +645,38 @@ jobs:
|
||||
# Branch protection already requires the `free-tests` context. Keep that
|
||||
# stable name as an always-running aggregate so every platform-specific CSO
|
||||
# gate is merge-blocking without a separate branch-protection migration.
|
||||
# Its first step picks one of two exclusive paths before any download: a
|
||||
# verified reuse (main pushes only) links the reused run and passes; anything
|
||||
# else takes the strict path below unchanged, which alone uploads tested-tree.
|
||||
free-tests:
|
||||
if: always()
|
||||
needs: [free-suite, typecheck, quick-bun-default, cso-macos-launcher, macos-named-regressions, cso-windows-launcher, cso-docker-integration]
|
||||
needs: [reuse, free-suite, typecheck, quick-bun-default, cso-macos-launcher, macos-named-regressions, cso-windows-launcher, cso-docker-integration]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Take the reuse path only for a verified main-push reuse
|
||||
id: route
|
||||
env:
|
||||
REUSE_RESULT: ${{ needs.reuse.result }}
|
||||
REUSED: ${{ needs.reuse.outputs.reused }}
|
||||
REUSE_RUN_ID: ${{ needs.reuse.outputs.run_id }}
|
||||
REUSE_RUN_URL: ${{ needs.reuse.outputs.run_url }}
|
||||
REUSE_TREE: ${{ needs.reuse.outputs.tree }}
|
||||
run: |
|
||||
set -eu
|
||||
if [ "$REUSE_RESULT" != success ] || [ "$REUSED" != true ]; then
|
||||
echo 'reused=false' >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
test "$GITHUB_EVENT_NAME" = push
|
||||
test "$GITHUB_REF" = refs/heads/main
|
||||
case "$REUSE_RUN_ID" in ''|*[!0-9]*) echo "::error::the reuse job named no run id"; exit 1 ;; esac
|
||||
case "$REUSE_TREE" in ''|*[!0-9a-f]*) echo "::error::the reuse job named no tree"; exit 1 ;; esac
|
||||
test "$REUSE_RUN_URL" = "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$REUSE_RUN_ID"
|
||||
echo "Reused $REUSE_RUN_URL, which passed on tree $REUSE_TREE; this push ran no suite of its own." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo 'reused=true' >> "$GITHUB_OUTPUT"
|
||||
- name: Require the free suite, typecheck, the quick tier on the default Bun, macOS regressions, and every CSO platform gate
|
||||
if: steps.route.outputs.reused != 'true'
|
||||
env:
|
||||
FREE_SUITE_RESULT: ${{ needs.free-suite.result }}
|
||||
TYPECHECK_RESULT: ${{ needs.typecheck.result }}
|
||||
@@ -530,16 +695,20 @@ jobs:
|
||||
test "$CSO_WINDOWS_RESULT" = success
|
||||
test "$CSO_DOCKER_RESULT" = success
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
if: steps.route.outputs.reused != 'true'
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
if: steps.route.outputs.reused != 'true'
|
||||
with:
|
||||
bun-version: 1.4.2
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
|
||||
if: steps.route.outputs.reused != 'true'
|
||||
with:
|
||||
name: free-plan
|
||||
path: ${{ runner.temp }}
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
|
||||
if: steps.route.outputs.reused != 'true'
|
||||
with:
|
||||
pattern: free-result-*
|
||||
merge-multiple: true
|
||||
@@ -548,10 +717,27 @@ jobs:
|
||||
# flake-ledger.jsonl). A green run is where a retried flake hides, so the
|
||||
# summary runs before strict verification.
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
|
||||
if: steps.route.outputs.reused != 'true'
|
||||
with:
|
||||
pattern: flake-ledger-*
|
||||
path: ${{ runner.temp }}/flake-ledgers
|
||||
- name: Summarize flaky passes absorbed by the serial retry
|
||||
if: steps.route.outputs.reused != 'true'
|
||||
run: bun run scripts/test-health-report.ts flake-summary "$RUNNER_TEMP/flake-ledgers" >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Require exact coverage and complete strict results
|
||||
if: steps.route.outputs.reused != 'true'
|
||||
run: bun run scripts/test-free-shards.ts --ci-verify "$RUNNER_TEMP/free-plan.json" --results "$RUNNER_TEMP/free-results"
|
||||
# Only after strict verification passed, so a red or partial run never
|
||||
# leaves a record; the reuse path never reaches here.
|
||||
- name: Record the tested tree
|
||||
if: steps.route.outputs.reused != 'true'
|
||||
env:
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: bun run scripts/ci-tree-reuse.ts record --plan "$RUNNER_TEMP/free-plan.json" --results "$RUNNER_TEMP/free-results" --os Linux,macOS,Windows --out "$RUNNER_TEMP/tested-tree/tree.json"
|
||||
- name: Upload the tested tree
|
||||
if: steps.route.outputs.reused != 'true'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: tested-tree
|
||||
path: ${{ runner.temp }}/tested-tree/tree.json
|
||||
if-no-files-found: error
|
||||
@@ -1,12 +1,10 @@
|
||||
name: Version Gate
|
||||
|
||||
# Every PR: under changelog_fragments a PR that touches no release file must
|
||||
# still prove it carries a fragment (docs/ship-policy.md#changelog-fragments).
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'VERSION'
|
||||
- 'CHANGELOG.md'
|
||||
- 'package.json'
|
||||
|
||||
concurrency:
|
||||
group: version-gate-${{ github.event.pull_request.number }}
|
||||
@@ -25,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.
|
||||
@@ -51,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"
|
||||
|
||||
@@ -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,15 +25,17 @@ 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:
|
||||
@@ -50,8 +58,36 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Main pushes only, and the one job with actions: read; see free-tests.yml.
|
||||
# A skipped or failed reuse job runs every job below as before.
|
||||
reuse:
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
outputs:
|
||||
reused: ${{ steps.find.outputs.reused }}
|
||||
run_id: ${{ steps.find.outputs.run_id }}
|
||||
run_url: ${{ steps.find.outputs.run_url }}
|
||||
tree: ${{ steps.find.outputs.tree }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
bun-version: 1.4.2
|
||||
- id: find
|
||||
name: Find a green pull request or dispatch run of this exact tree
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: bun run scripts/ci-tree-reuse.ts find --workflow windows-free-tests.yml --profile ci-reuse/windows-free-tests
|
||||
|
||||
windows-plan:
|
||||
if: ${{ !inputs.record_durations }}
|
||||
needs: reuse
|
||||
if: ${{ !cancelled() && !inputs.record_durations && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
@@ -79,7 +115,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 +143,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 +217,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 +252,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 }}
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
@@ -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/
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"tests": [
|
||||
"test/*tripwire*.test.ts",
|
||||
"test/*ratchet*.test.ts",
|
||||
"test/env-host-fence-conformance.test.ts",
|
||||
"test/free-fixtures.test.ts",
|
||||
"test/bun-entry-isolation.test.ts",
|
||||
"test/egress-receipt-wiring.test.ts",
|
||||
"test/review-packets-removed.test.ts"
|
||||
]
|
||||
}
|
||||
@@ -16,9 +16,12 @@
|
||||
"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" }
|
||||
},
|
||||
"warn": [],
|
||||
"strays": { "allow": [] }
|
||||
"strays": { "allow": ["modules/loadouts.json", "modules/loadouts.schema.json"] }
|
||||
}
|
||||
@@ -13,6 +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 },
|
||||
"min_gstack_version": "1.91.146.0"
|
||||
"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"
|
||||
}
|
||||
@@ -34,6 +34,7 @@ Invoke them by name (e.g., `/office-hours`).
|
||||
| `/codex` | Second opinion via OpenAI Codex. Review, challenge, or consult modes. Available outside the Codex harness. |
|
||||
| `/claude-code` | Second opinion via Claude Code. Review, challenge, or consult modes. Available outside the Claude Code harness. |
|
||||
| `/investigate` | Systematic root-cause debugging. No fixes without investigation. |
|
||||
| `/verify` | Build-and-verify loop while you code: the repo's checks plus the tests pregate selects, fix, repeat, then hand off to `/ship`. Never green on an unmapped change. |
|
||||
| `/design-review` | Live-site visual audit + fix loop with atomic commits. |
|
||||
| `/design-shotgun` | Generate multiple AI design variants, comparison board, iterate. |
|
||||
| `/design-html` | Generate production-quality Pretext-native HTML/CSS. |
|
||||
@@ -70,7 +71,7 @@ Invoke them by name (e.g., `/office-hours`).
|
||||
| `/captain` | Run a crew from one controller: READY contract, merge trains, digest, approvals shapes, daily flaky pass. Also `/crew`. Templates in `captain/templates/`, team guide in `docs/captain.md`. |
|
||||
| `/benchmark` | Performance regression detection (page load, Core Web Vitals). |
|
||||
| `/ubicloud` | Run a heavy suite, build or eval on an ephemeral 16-vCPU Ubicloud VM: TTL-named VMs with a reaper, quota preflight, hang watchdog, one committed verify per repository. |
|
||||
| `/benchmark-models` | Cross-model benchmark for skills (Claude, GPT, Gemini side-by-side). |
|
||||
| `/benchmark-models` | Cross-model benchmark for skills (Claude, GPT, Gemini, plus particle-1 and DeepSeek V4.1 Flash through OpenRouter, side-by-side). |
|
||||
| `/eval-plan` | Preregister a paid eval (bars, arms, decoys, stop rules), dry-run it for $0, pilot three priced items, then approve the spend. |
|
||||
| `/cso` | Supported security findings with explicit coverage. Static assessment remains available without catalog profiles. Contained runtime and scanner execution needs Docker and a matching qualified profile. The runtime catalog qualifies ten images: Node 24.4.0, Bun 1.3.10, Python 3.13.4 with uv 0.8.0, Rails on Ruby 3.4.4, and PostgreSQL 17.2, each for linux/amd64 and linux/arm64. The scanner catalog qualifies twelve: gitleaks, OSV-Scanner, Schemathesis, Semgrep, Trivy and zizmor on both platforms. Without Docker, or where no profile matches (native Windows Docker, for example), `/cso` runs static assessment only. Runtime-tested bundles authenticate separate external assertions. Project-test completion remains `self_reported` because target code controls the test process; `tested` is reserved for a future target-independent completion witness. |
|
||||
| `/setup-gbrain` | Set up gbrain for cross-machine session memory sync. |
|
||||
@@ -280,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.
|
||||
|
||||
+1599
-8
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
@@ -148,7 +148,7 @@ a skill's own bash.
|
||||
|
||||
**Size budgets:** generated SKILL.md files warn above 160KB (~40K tokens);
|
||||
`test/catalog-budget.test.ts` caps the always-loaded skill catalog at
|
||||
`CATALOG_BUDGET_TOKEN_EQUIVALENTS` (1,393 today; each new skill ratchets it); and
|
||||
`CATALOG_BUDGET_TOKEN_EQUIVALENTS` (1,423 today; each new skill ratchets it); and
|
||||
`test/context-budget-ratchet.test.ts` pins per-skill token ceilings against
|
||||
`test/fixtures/context-budget.json` (for legitimate growth or a landed
|
||||
reduction, re-run `bun test/helpers/capture-context-budget.ts` and commit the
|
||||
|
||||
+11
-10
@@ -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
|
||||
@@ -190,9 +190,9 @@ the new defaults.
|
||||
| Check an ordinary edit quickly | `bun run test:quick` | Free, about a minute | Bun 1.4.2 |
|
||||
| Run one free test file while repairing | `bun test <file>` | Free, seconds | Never bare `bun test` for the suite |
|
||||
| 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` |
|
||||
| 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`; keyless: nothing |
|
||||
| Run the curated Windows-safe subset | `bun run test:windows` (CI: six `windows-latest` jobs) | Free | Windows, Git Bash |
|
||||
| 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, 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 | — |
|
||||
@@ -205,6 +205,7 @@ the new defaults.
|
||||
| Validate periodic cases on a branch in CI | `gh workflow run evals-periodic.yml --ref <branch>` (periodic lane only) | CI runners + API spend | Pushed branch, `gh` with workflow rights |
|
||||
| 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 |
|
||||
| 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 |
|
||||
@@ -427,7 +428,7 @@ eval files, and misses the strict classifier. No API keys needed.
|
||||
- **Generator tests** (`test/gen-skill-docs.test.ts`) — Tests the template system: verifies placeholders resolve correctly, output includes value hints for flags (e.g. `-d <N>` not just `-d`), enriched descriptions for key commands (e.g. `is` lists valid states, `press` lists key examples), and that a template's `codex_default_prompt:` frontmatter key reaches the Codex `agents/openai.yaml` `default_prompt` (a template without the key keeps `Use <name> for this task.`) while no host's SKILL.md render carries the key.
|
||||
- **Design detector, catalog, and DESIGN.md** (`test/gstack-design-detect.test.ts`, `test/design-detect-contract.test.ts`, `test/design-catalog.test.ts`, `test/design-checklist-sync.test.ts`, `test/design-md.test.ts`, `test/frontend-scope.test.ts`, `test/impeccable-fixtures.test.ts`) — Drive `bin/gstack-design-detect.ts` through the fake engine in `test/fixtures/fake-impeccable.ts` (probe order, the never-execute-a-repository-file rule, the `--changed` target allow-list, `design_detector: off`, analytics lines, output sanitizing), pin the catalog invariants and the generated `review/design-checklist.md`, round-trip the open DESIGN.md reader/writer, and check the real engine captures (`test/fixtures/impeccable-*.json`, engine 0.1.3) against the contract. `test/dom-dump-hygiene.test.ts` runs `lib/dom-dump.js` in a real Chromium page through the built browse binary; it self-skips without the binary and is opt-in outside CI (`GSTACK_DOM_DUMP_HYGIENE=1`).
|
||||
- **Tier-alignment invariant** (`test/e2e-tier-alignment.test.ts`) — For every self-gated `test/skill-e2e-*.test.ts` named in a touchfiles dep list, the file's `EVALS_TIER` self-gate must match its declared tier in `E2E_TIERS`. Kills the "inert demotion" class where a test is re-tiered in `touchfiles.ts` but the file still gates on the old tier and keeps running in the wrong lane. Unmapped or mixed-tier files are reported, never silently skipped.
|
||||
- **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,393 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.
|
||||
- **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.
|
||||
@@ -735,11 +736,11 @@ worth the review overhead.
|
||||
|
||||
## Multi-host development
|
||||
|
||||
gstack generates SKILL.md files for 10 hosts from one set of `.tmpl` templates.
|
||||
gstack generates SKILL.md files for 13 hosts from one set of `.tmpl` templates.
|
||||
Each host is a typed config in `hosts/*.ts`. The generator reads these configs
|
||||
to produce host-appropriate output (different frontmatter, paths, tool names).
|
||||
|
||||
**Supported hosts:** Claude (primary), Codex, Factory, Kiro, OpenCode, Slate, Cursor, OpenClaw, Hermes, GBrain.
|
||||
**Supported hosts:** Claude (primary), Codex, Factory, Kiro, OpenCode, Slate, Cursor, OpenClaw, Hermes, GBrain, Copilot, Antigravity, Gemini CLI.
|
||||
|
||||
### Generating for all hosts
|
||||
|
||||
@@ -748,7 +749,7 @@ to produce host-appropriate output (different frontmatter, paths, tool names).
|
||||
bun run gen:skill-docs # Claude (default)
|
||||
bun run gen:skill-docs --host codex # Codex
|
||||
bun run gen:skill-docs --host opencode # OpenCode
|
||||
bun run gen:skill-docs --host all # All 10 hosts
|
||||
bun run gen:skill-docs --host all # All 13 hosts
|
||||
|
||||
# Or use build, which does all hosts + compiles binaries
|
||||
bun run build
|
||||
|
||||
@@ -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` (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 `gstack` skill | advisory, not blocked | — | `./setup --host openclaw` |
|
||||
| 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
|
||||
|
||||
@@ -148,6 +149,7 @@ Each skill feeds into the next. `/office-hours` writes a design doc that `/plan-
|
||||
| `/write-skill` | **Skill Editor** | Write a new agent skill or improve one without bloat. A five-step edit ladder (fix the tool first, add nothing the model already does, edit the line instead of appending, lowest level that reaches the agent, rule not story), `gstack-skill-bill` for size per level and growth since the last diet, a drift check that every gstack command and flag a skill names still exists, and full diets proven by a before/after/no-skill test. |
|
||||
| `/test-audit` | **Test Auditor** | Sweep existing tests for ones that cost more than they protect: source greps, duplicates, assertion-free probes, test-only exports. Every candidate carries an evidence card; report-only unless you approve a batch. |
|
||||
| `/investigate` | **Debugger** | Systematic root-cause debugging. Iron Law: no fixes without investigation. Traces data flow, tests hypotheses, stops after 3 failed fixes. |
|
||||
| `/verify` | **Build Loop** | Run the repo's own checks (typecheck, lint, build) and the tests `/ship`'s pre-gate would pick for your change, fix what breaks, repeat until green, then hand off to `/ship`. Asks once before running repo commands; never calls a change green when no test covers it. |
|
||||
| `/design-review` | **Designer Who Codes** | Same audit as /plan-design-review, then fixes what it finds. Atomic commits, before/after screenshots. If you have impeccable installed, its engine runs first and every mechanical finding arrives tagged with its rule id. |
|
||||
| `/devex-review` | **DX Tester** | Live developer experience audit. Actually tests your onboarding: navigates docs, tries the getting started flow, times TTHW, triggers CLI errors the way an agent meets them (`gstack-error-capture`: no terminal, contained, redacted). Compares against `/plan-devex-review` scores — the boomerang that shows if your plan matched reality. |
|
||||
| `/design-shotgun` | **Design Explorer** | "Show me options." Generates 4-6 AI mockup variants, opens a comparison board in your browser, collects your feedback, and iterates. Taste memory learns what you like. Repeat until you love something, then hand it to `/design-html`. |
|
||||
@@ -166,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. |
|
||||
@@ -224,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`.
|
||||
@@ -242,6 +246,7 @@ On Windows without Developer Mode (MSYS2 / Git Bash), `setup` falls back to file
|
||||
|-----|---------------|
|
||||
| [Skill Deep Dives](docs/skills.md) | Philosophy, examples, and workflow for every skill (includes Greptile integration) |
|
||||
| [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 |
|
||||
| [Install details](docs/install.md) | Setup timing, requirements in detail, team mode, the hooks setup registers, agent harness registration |
|
||||
| [Other agents](docs/hosts.md) | Agent harnesses (OpenClaw, Hermes, dots, QM, Muse Code, Grok, any other), OpenClaw and ClawHub, per-host notes, the Codex profile and model tiers, outside-review providers, the design binary's models |
|
||||
|
||||
@@ -208,6 +208,7 @@ quality gates that produce better results than answering inline.
|
||||
- User asks to write a skill, improve or slim a skill, or add a lesson to a named skill → invoke `/write-skill`
|
||||
- User asks about visual polish, design audit of a live site, "this looks off" → invoke `/design-review`
|
||||
- User asks to audit the live developer experience, time-to-hello-world → invoke `/devex-review`
|
||||
- User asks to run the checks, make the tests pass, "verify my change", "check my work" while building → invoke `/verify`
|
||||
- User asks to ship, deploy, push, create a PR, "let's land this", "send it" → invoke `/ship`
|
||||
- User asks to merge + deploy + verify as one flow → invoke `/land-and-deploy`
|
||||
- User asks to rebase or revive an old PR, "modernize this PR", whether a stale PR is still worth landing → invoke `/modernize`
|
||||
|
||||
@@ -84,6 +84,7 @@ quality gates that produce better results than answering inline.
|
||||
- User asks to write a skill, improve or slim a skill, or add a lesson to a named skill → invoke `/write-skill`
|
||||
- User asks about visual polish, design audit of a live site, "this looks off" → invoke `/design-review`
|
||||
- User asks to audit the live developer experience, time-to-hello-world → invoke `/devex-review`
|
||||
- User asks to run the checks, make the tests pass, "verify my change", "check my work" while building → invoke `/verify`
|
||||
- User asks to ship, deploy, push, create a PR, "let's land this", "send it" → invoke `/ship`
|
||||
- User asks to merge + deploy + verify as one flow → invoke `/land-and-deploy`
|
||||
- User asks to rebase or revive an old PR, "modernize this PR", whether a stale PR is still worth landing → invoke `/modernize`
|
||||
|
||||
@@ -2,6 +2,32 @@
|
||||
|
||||
## NEXT PRIORITY
|
||||
|
||||
### 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.
|
||||
- **The root `.bun/` blind spot.** `gstack-wtree` leaves every untracked file under a root `.bun/` out of the fingerprint, so an edit there never makes evidence STALE. Narrow the exclusion to Bun's cache subfolder if anything starts loading code from it. **Priority:** P3.
|
||||
|
||||
### P1: review-depth receipt follow-ups (filed 2026-10-28, from the #3567 /ship plan audit)
|
||||
|
||||
- **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. 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.
|
||||
- **A bootstrap head policy can still certify itself without `--remote` (P3)** — with no trusted policy, the head file may set `remote_gate: null`, or produce an obligation that `gstack-pregate clear --receipt` accepts from the branch's own gate. Decide what a bootstrap `pregate.json` may certify, then enforce it in `verify` from the recorded policy label instead of per entry point. The `--remote` refusal also prints the generic `PREGATE_HEAD_POLICY_LOOSENS` fix line, which does not apply when there is no trusted policy. **Effort:** S. **Priority:** P3.
|
||||
|
||||
### P3: `/codex --effort` follow-ups (filed 2026-10-11, from the #3146 autoplan review)
|
||||
|
||||
- **Show the effort that ran.** Step 0.5 prints `CODEX_EFFORT: <level> (source: /codex --effort)` once, but the result footers do not repeat the effort, so it scrolls away. Add an `Effort:` line to the review, challenge and consult result footers. Blocked on skeleton bytes in `codex/SKILL.md` (cap 60,200). **Effort:** S. **Priority:** P3.
|
||||
@@ -62,8 +88,8 @@ Each item was decided in the Wave 9 plan and parked with its reason. Credit the
|
||||
|
||||
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.
|
||||
|
||||
- **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, plus #3122)** — 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), 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.
|
||||
- **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.
|
||||
@@ -783,15 +809,15 @@ Each item was deferred with a reason during the `/claude-api prompt-audit` clean
|
||||
format errors, so it stays on today's request), the default workflow judge
|
||||
(landed, but one provider refusal on corpus item f03 under the new prompt,
|
||||
matched by one under the old prompt) and the cookie workflow judge (GSTA-91:
|
||||
held on one new false pass on a07, zero new-request format errors, so it stays
|
||||
on today's request; today's request itself false-passes a07 in `old-b` and on
|
||||
held on one new false pass on a07, zero new-request format errors; the hold was
|
||||
released on GStack Minder's 2026-10-29 ruling and it now sends the schema; today's request itself false-passes a07 in `old-b` and on
|
||||
both valid `old-a` samples, and a08 in `old-a`, so the prose step targets those). Reuse `test/fixtures/judge-calibration`
|
||||
and `scripts/judge-calibration.ts`. Priority P3. Effort M.
|
||||
- **Uncalibrated workflow-judge configurations** — build a corpus and calibrate
|
||||
schema transport for the frontier configuration (review/SKILL.md workflow); it
|
||||
stays on today's request. The cookie configuration
|
||||
(setup-browser-cookies/SKILL.md workflow) has a corpus and a held comparison-1
|
||||
(GSTA-91), so it also stays on today's request. After any transport change,
|
||||
(GSTA-91); it moved to the schema on GStack Minder's 2026-10-29 release ruling. After any transport change,
|
||||
re-run the review and cookie workflow judges. Priority P3. Effort M.
|
||||
- **Claude model auto-detection for the skill overlay** — when no
|
||||
`claude_overlay_model` is set, detect the Claude Code model (`ANTHROPIC_MODEL`,
|
||||
@@ -1711,6 +1737,14 @@ run locally on a machine that has the CLI or device.
|
||||
**Re-entry:** the device or Aside is available to a CI runner. Remove each
|
||||
file's exclude entry when its prerequisite exists.
|
||||
|
||||
**Model panel (GSTA-173):** `skill-e2e-model-panel` is excluded too: it needs
|
||||
`OPENROUTER_API_KEY`, which no CI workflow carries, and it is a research
|
||||
instrument run on demand. Before its next preregistered run, fix three harness
|
||||
gaps the October 2026 review found: score a timed-out cell's working tree
|
||||
instead of calling it INFRA (`classifyCell`), summarize only the current run's
|
||||
rows (`afterAll` reads every row in the output directory), and drain the worker
|
||||
pool on the first error so no cell starts after a failure (`pool`).
|
||||
|
||||
**Review by:** 2026-12-28. **Effort:** S per file. **Priority:** P3.
|
||||
|
||||
### ✅ DONE (2026-10-02, parallel-waves fix wave): Install the Codex CLI in the CI image
|
||||
@@ -3582,6 +3616,34 @@ Linux cookie import shipped in v0.11.11.0 (Wave 3). Supports Chrome, Chromium, B
|
||||
|
||||
## Ship
|
||||
|
||||
### Crew template rule 22: say when a lane picks `--screen` (GSTA-165 1a follow-up)
|
||||
|
||||
**What:** Update the rule 22 text in `captain/templates/CREW.md` (and its `docs/captain.md` home if it repeats the rule) so a lane picks `--screen` only when `gstack-ship-queue ready` recommends it: the paid selector's mode is `full-fallback` or it cannot select.
|
||||
|
||||
**Why:** `gstack-ship-queue ready` now recommends `--no-screen` for every diff the selector can narrow, and unscreened PRs fill train seats. A crew instantiated from the template still reads the older screen guidance, so lanes can keep paying for screens the recommendation no longer asks for.
|
||||
|
||||
**Context:** The GSTA-165 plan's PR 1 (D1) listed this doc change beside the code; PR #3619 shipped the code, `docs/ship-policy.md` and tests and left the template text out. Template rule 22 ("/ship before every READY") names the receipt and its homes but not the screen choice, and rule 6 names `--screen|--no-screen` without saying which to pick.
|
||||
|
||||
Deferred from plan: /home/user/.capy/drive/captain-gstack-minder/plans/GSTA-165.md
|
||||
|
||||
**Effort:** S
|
||||
**Priority:** P1
|
||||
**Depends on:** PR #3619
|
||||
|
||||
### Train records: attribute a red train to the PR that caused it (GSTA-165 v2 item 6)
|
||||
|
||||
**What:** Add an attribution field to the merge-train record so each red train names the PR (or none, or infra) that caused it.
|
||||
|
||||
**Why:** The trigger for reverting GSTA-165 D1 (screen only full-fallback diffs) is "PR-attributed red trains exceed the forecast (about 10% at size 3, 16% at size 5) by 2x over 20 trains". Without an attribution field nobody can measure it, so the default cannot be revisited on evidence.
|
||||
|
||||
**Context:** Restated in handoffs/GSTA-165/v2-additions.md item 6 and unassigned to a PR. Start from the train record in `lib/ship-queue-train-record.ts` and what `train bisect` writes back to it.
|
||||
|
||||
Deferred from plan: /home/user/.capy/drive/captain-gstack-minder/plans/GSTA-165.md
|
||||
|
||||
**Effort:** M
|
||||
**Priority:** P1
|
||||
**Depends on:** PR #3619
|
||||
|
||||
### Runtime enforcement of foreground dispatch (PreToolUse hook)
|
||||
|
||||
**What:** A PreToolUse hook (settings.json) that forces or verifies `run_in_background: false` on Agent tool calls made inside gstack workflows, making the #497/#2440 bug class structurally impossible on Claude Code instead of prose-pinned.
|
||||
@@ -5481,12 +5543,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)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.91.304.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.92.51.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,
|
||||
|
||||
+15
-15
@@ -26,7 +26,7 @@ line yourself; gstack never writes the plan.
|
||||
|
||||
1. `next` prints one `ATTEMPT: <id> phase=<p> voice=native|outside prompt=<file> result=<file>`
|
||||
per voice. Dispatch the native prompt to a subagent on your model and the outside prompt to
|
||||
a subagent on another model family (or `gstack-outside-voice run`). Each writes `result=`.
|
||||
a subagent on another model family (or `gstack-outside-voice run`). Each writes `result=`. The first `next` also prints `REVIEW_ESTIMATE:` (most attempts and reservations the review can take).
|
||||
2. Submit each voice exactly once (`BOUND:`). Both voices in closes the phase (`PHASE_CLOSED:`).
|
||||
3. Repeat `next` until `status=gate_pending` (`GATE: gate_rev=<n>`); render it with `gstack-gate render <dir>/gate.json --all` and relay every page with the two cost lines printed under `GATE:`: `GATE_SPEND:` (spent, worst case, cap, headroom, attempts; host-subagent attempts are priced unknown and counted at their reservation, so quote their real figure from the host's cost view) and `GATE_REC_COST:` (the phases `rec` can reopen and the estimate for them, an upper bound; `gate_cost` in `--json`).
|
||||
You never approve the gate for the owner. Offer `rec` (every recommendation, plan changes included) as the default.
|
||||
@@ -38,6 +38,7 @@ line yourself; gstack never writes the plan.
|
||||
5. `REOPENED: <phases>` means loop `next` again (Eng last). `APPROVED:` means `export` (`DISPATCHES: <n> est_input_tokens=<k>`, relative).
|
||||
6. Rounds: one by default; a second only for High or Critical findings; cap 2 (`--max-rounds`). A `revise` whose decisions all resolve below
|
||||
High prints `APPLIED: <ids> without re-review (below High)`. At the cap with High open, `ESCALATE: evidence sufficient, decision needed`: relay it, never ask for another round.
|
||||
7. Carry-forward ([docs/unattended.md#carry-forward](../docs/unattended.md#carry-forward); `--no-carry-forward` on the first `next` opts out): a later gate that asks nothing new takes the owner's words from the gate before it (`CARRIED:`, then `APPROVED:`); `CARRY: no (<why>)` leaves it for the owner; `CARRY_CHECK:` means one reviewer from another model family reads the printed delta prompt, then `check --out <dir> --attempt <id> --result <file> --model <id>` (or `--unavailable "<reason>"`).
|
||||
|
||||
### On Capy
|
||||
|
||||
@@ -51,34 +52,33 @@ line yourself; gstack never writes the plan.
|
||||
with `gstack-config set autoplan_checkpoint drive`) so a run survives machine replacement: a
|
||||
generation is published at rest, after each bound voice and after each phase transition; restore
|
||||
with `resume --out <new dir> --from <dir>/<run>`, which never redispatches a bound voice.
|
||||
- Plans carry `## Before → after`, `## Why it matters`, `## What caused this work`, `## Big decisions`;
|
||||
relay the printed `RELAY: gstack-owner-brief render --captain` output to your Captain (or the person),
|
||||
then `gstack-approvals import --autoplan <dir>` after `answer` (`capy/skill/gstack/captain.md`).
|
||||
- Plans carry `## ELI5` (5-10 plain sentences), `## Before → after`, `## Why it matters`, `## What caused this work`, `## Big decisions`;
|
||||
relay the printed `RELAY: gstack-owner-brief render --captain` output to your Captain (or the person), then `gstack-approvals import --autoplan <dir>` after `answer` (`--stack <b1,b2,...>` for a stack; `capy/skill/gstack/captain.md`).
|
||||
|
||||
### 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> [scope flags] [--light] [--spend-cap <usd>] [--max-rounds <n>] [--estimate-usd <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); 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.
|
||||
- `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
|
||||
|
||||
@@ -23,7 +23,7 @@ line yourself; gstack never writes the plan.
|
||||
|
||||
1. `next` prints one `ATTEMPT: <id> phase=<p> voice=native|outside prompt=<file> result=<file>`
|
||||
per voice. Dispatch the native prompt to a subagent on your model and the outside prompt to
|
||||
a subagent on another model family (or `gstack-outside-voice run`). Each writes `result=`.
|
||||
a subagent on another model family (or `gstack-outside-voice run`). Each writes `result=`. The first `next` also prints `REVIEW_ESTIMATE:` (most attempts and reservations the review can take).
|
||||
2. Submit each voice exactly once (`BOUND:`). Both voices in closes the phase (`PHASE_CLOSED:`).
|
||||
3. Repeat `next` until `status=gate_pending` (`GATE: gate_rev=<n>`); render it with `gstack-gate render <dir>/gate.json --all` and relay every page with the two cost lines printed under `GATE:`: `GATE_SPEND:` (spent, worst case, cap, headroom, attempts; host-subagent attempts are priced unknown and counted at their reservation, so quote their real figure from the host's cost view) and `GATE_REC_COST:` (the phases `rec` can reopen and the estimate for them, an upper bound; `gate_cost` in `--json`).
|
||||
You never approve the gate for the owner. Offer `rec` (every recommendation, plan changes included) as the default.
|
||||
@@ -35,6 +35,7 @@ line yourself; gstack never writes the plan.
|
||||
5. `REOPENED: <phases>` means loop `next` again (Eng last). `APPROVED:` means `export` (`DISPATCHES: <n> est_input_tokens=<k>`, relative).
|
||||
6. Rounds: one by default; a second only for High or Critical findings; cap 2 (`--max-rounds`). A `revise` whose decisions all resolve below
|
||||
High prints `APPLIED: <ids> without re-review (below High)`. At the cap with High open, `ESCALATE: evidence sufficient, decision needed`: relay it, never ask for another round.
|
||||
7. Carry-forward ([docs/unattended.md#carry-forward](../docs/unattended.md#carry-forward); `--no-carry-forward` on the first `next` opts out): a later gate that asks nothing new takes the owner's words from the gate before it (`CARRIED:`, then `APPROVED:`); `CARRY: no (<why>)` leaves it for the owner; `CARRY_CHECK:` means one reviewer from another model family reads the printed delta prompt, then `check --out <dir> --attempt <id> --result <file> --model <id>` (or `--unavailable "<reason>"`).
|
||||
|
||||
### On Capy
|
||||
|
||||
@@ -48,9 +49,8 @@ line yourself; gstack never writes the plan.
|
||||
with `gstack-config set autoplan_checkpoint drive`) so a run survives machine replacement: a
|
||||
generation is published at rest, after each bound voice and after each phase transition; restore
|
||||
with `resume --out <new dir> --from <dir>/<run>`, which never redispatches a bound voice.
|
||||
- Plans carry `## Before → after`, `## Why it matters`, `## What caused this work`, `## Big decisions`;
|
||||
relay the printed `RELAY: gstack-owner-brief render --captain` output to your Captain (or the person),
|
||||
then `gstack-approvals import --autoplan <dir>` after `answer` (`capy/skill/gstack/captain.md`).
|
||||
- Plans carry `## ELI5` (5-10 plain sentences), `## Before → after`, `## Why it matters`, `## What caused this work`, `## Big decisions`;
|
||||
relay the printed `RELAY: gstack-owner-brief render --captain` output to your Captain (or the person), then `gstack-approvals import --autoplan <dir>` after `answer` (`--stack <b1,b2,...>` for a stack; `capy/skill/gstack/captain.md`).
|
||||
|
||||
### Commands
|
||||
|
||||
|
||||
@@ -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` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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--)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>`
|
||||
@@ -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>`
|
||||
@@ -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)"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -196,12 +196,14 @@ If C: ask for the path. Verify it exists. Use as positional argument.
|
||||
## Step 2: Choose providers
|
||||
|
||||
```bash
|
||||
"$BIN" --prompt "unused, dry-run" --models claude,gpt,gemini --dry-run
|
||||
"$BIN" --prompt "unused, dry-run" --models claude,gpt,gemini,particle,deepseek --dry-run
|
||||
```
|
||||
|
||||
Show the dry-run output. The "Adapter availability" section tells the user which providers will actually run (OK) vs skip (NOT READY — remediation hint included).
|
||||
|
||||
If ALL three show NOT READY: stop with a clear message — benchmark can't run without at least one authed provider. Suggest `claude login`, `codex login`, or `gemini login` / `export GOOGLE_API_KEY`.
|
||||
If every provider shows NOT READY: stop with a clear message — benchmark can't run without at least one authed provider. Suggest `claude login`, `codex login`, `gemini login` / `export GOOGLE_API_KEY`, or `export OPENROUTER_API_KEY` for particle and deepseek (they run in Claude Code, so they also need the `claude` CLI).
|
||||
|
||||
particle and deepseek send the prompt and the working directory's files the model reads to OpenRouter and the model vendor. They load no Claude Code settings files (so a key in a settings.json `env` block is never sent) and run with the user's environment minus Anthropic credentials. Say so before including them.
|
||||
|
||||
If at least one is OK: AskUserQuestion:
|
||||
- **Simplify:** "Which models should we include? The dry-run above showed which are authed. Unauthed ones will be skipped cleanly — they won't abort the batch."
|
||||
|
||||
@@ -4,7 +4,8 @@ preamble-tier: 1
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Cross-model benchmark for gstack skills. Runs the same prompt through Claude,
|
||||
GPT (via Codex CLI), and Gemini side-by-side — compares latency, tokens, cost,
|
||||
GPT (via Codex CLI), Gemini, and (through OpenRouter in Claude Code) particle-1
|
||||
and DeepSeek V4.1 Flash side-by-side — compares latency, tokens, cost,
|
||||
and optionally quality via LLM judge. Answers "which model is actually best
|
||||
for this skill?" with data instead of vibes. Separate from /benchmark, which
|
||||
measures web page performance. Use when: "benchmark models", "compare models",
|
||||
@@ -71,12 +72,14 @@ If C: ask for the path. Verify it exists. Use as positional argument.
|
||||
## Step 2: Choose providers
|
||||
|
||||
```bash
|
||||
"$BIN" --prompt "unused, dry-run" --models claude,gpt,gemini --dry-run
|
||||
"$BIN" --prompt "unused, dry-run" --models claude,gpt,gemini,particle,deepseek --dry-run
|
||||
```
|
||||
|
||||
Show the dry-run output. The "Adapter availability" section tells the user which providers will actually run (OK) vs skip (NOT READY — remediation hint included).
|
||||
|
||||
If ALL three show NOT READY: stop with a clear message — benchmark can't run without at least one authed provider. Suggest `claude login`, `codex login`, or `gemini login` / `export GOOGLE_API_KEY`.
|
||||
If every provider shows NOT READY: stop with a clear message — benchmark can't run without at least one authed provider. Suggest `claude login`, `codex login`, `gemini login` / `export GOOGLE_API_KEY`, or `export OPENROUTER_API_KEY` for particle and deepseek (they run in Claude Code, so they also need the `claude` CLI).
|
||||
|
||||
particle and deepseek send the prompt and the working directory's files the model reads to OpenRouter and the model vendor. They load no Claude Code settings files (so a key in a settings.json `env` block is never sent) and run with the user's environment minus Anthropic credentials. Say so before including them.
|
||||
|
||||
If at least one is OK: AskUserQuestion:
|
||||
- **Simplify:** "Which models should we include? The dry-run above showed which are authed. Unauthed ones will be skipped cleanly — they won't abort the batch."
|
||||
|
||||
+1
-1
@@ -412,7 +412,7 @@ TOP 10 SLOWEST RESOURCES
|
||||
RECOMMENDATIONS:
|
||||
- vendor.chunk.js: code-split — 320KB on initial load
|
||||
- analytics.js: load async/defer — blocks rendering for 250ms
|
||||
- hero-image.webp: add width/height (CLS), lazy-load
|
||||
- hero-image.webp: add width/height (CLS)
|
||||
```
|
||||
|
||||
Third-party = any host but the page's origin. Each recommendation names a resource here or in a Phase 5 regression, the metric it moves and one remedy (split, defer, compress, lazy-load, cache).
|
||||
|
||||
@@ -201,7 +201,7 @@ TOP 10 SLOWEST RESOURCES
|
||||
RECOMMENDATIONS:
|
||||
- vendor.chunk.js: code-split — 320KB on initial load
|
||||
- analytics.js: load async/defer — blocks rendering for 250ms
|
||||
- hero-image.webp: add width/height (CLS), lazy-load
|
||||
- hero-image.webp: add width/height (CLS)
|
||||
```
|
||||
|
||||
Third-party = any host but the page's origin. Each recommendation names a resource here or in a Phase 5 regression, the metric it moves and one remedy (split, defer, compress, lazy-load, cache).
|
||||
|
||||
+6
-3
@@ -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)
|
||||
|
||||
|
||||
+39
-4
@@ -5,7 +5,7 @@
|
||||
* gstack-approvals add --ask <text> --words <exact words> --by <who> --scope <what it covers>
|
||||
* [--pending <text>] [--outcome approved|rejected|pending|standing]
|
||||
* [--date YYYY-MM-DD] [--source <ref>] [--branch <name>] [--json]
|
||||
* gstack-approvals import --autoplan <run dir> [--by <whose words>] [--branch <name>] [--json]
|
||||
* gstack-approvals import --autoplan <run dir> [--by <whose words>] [--branch <name> | --stack <b1,b2,...>] [--json]
|
||||
* gstack-approvals render [--no-heading] [--branch <name>]
|
||||
* gstack-approvals list [--json] [--branch <name>]
|
||||
* gstack-approvals sync [--branch <name>]
|
||||
@@ -20,14 +20,15 @@
|
||||
*/
|
||||
import * as path from 'node:path';
|
||||
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
|
||||
import { appendRows, buildRow, importAutoplan, locate, readRecord, renderRecord, syncRecord, type RecordError, type StoreLocation } from '../lib/approval-record';
|
||||
import { appendRows, branchExists, buildRow, importAutoplan, locate, readRecord, renderRecord, syncRecord, type RecordError, type StoreLocation } from '../lib/approval-record';
|
||||
import { resultLine } from '../lib/result-codes';
|
||||
|
||||
const HELP = `gstack-approvals — the per-thread approval record (capy/skill/gstack/captain.md)
|
||||
|
||||
add --ask <text> --words <exact words> --by <who> --scope <what it covers>
|
||||
[--pending <text>] [--outcome approved|rejected|pending|standing] [--date YYYY-MM-DD] [--source <ref>]
|
||||
import --autoplan <run dir> [--by <whose words, e.g. Garry>]
|
||||
import --autoplan <run dir> [--by <whose words, e.g. Garry>] [--stack <b1,b2,...>]
|
||||
--stack writes the same rows to every branch of a stack; each branch is checked first, and one unknown branch writes none
|
||||
render [--no-heading] the ## Approvals section for a PR body or report
|
||||
list [--json]
|
||||
sync re-mirror the record (GSTACK_APPROVALS_DIR, the Captain's drive approvals/, or the project-user Drive)
|
||||
@@ -40,7 +41,7 @@ Example:
|
||||
|
||||
${exitTableHelp()}`;
|
||||
|
||||
const VALUE_FLAGS = new Set(['--ask', '--words', '--by', '--scope', '--pending', '--outcome', '--date', '--source', '--branch', '--autoplan']);
|
||||
const VALUE_FLAGS = new Set(['--ask', '--words', '--by', '--scope', '--pending', '--outcome', '--date', '--source', '--branch', '--autoplan', '--stack']);
|
||||
|
||||
function parse(args: string[]): { flags: Record<string, string>; bools: Set<string>; error?: string } {
|
||||
const flags: Record<string, string> = {};
|
||||
@@ -64,11 +65,45 @@ function fail(cmd: string, e: RecordError): number {
|
||||
return EXIT.refused;
|
||||
}
|
||||
|
||||
/** `import --stack`: every branch checked first, then the same rows (ids are branch-independent) written to each branch's record. */
|
||||
function importStack(cmd: string, flags: Record<string, string>, json: boolean): number {
|
||||
if (cmd !== 'import' || !flags['--autoplan'] || flags['--branch'] !== undefined) { process.stderr.write(`gstack-approvals: --stack goes with import --autoplan <run dir>, without --branch\n`); return EXIT.usage; }
|
||||
const branches = [...new Set(flags['--stack']!.split(',').map(b => b.trim()).filter(Boolean))];
|
||||
if (!branches.length) { process.stderr.write(`gstack-approvals: --stack needs <b1,b2,...>\n`); return EXIT.usage; }
|
||||
const cwd = process.cwd();
|
||||
const missing = branches.filter(b => !branchExists(cwd, b));
|
||||
if (missing.length) return fail(cmd, { code: 'APPROVAL_RECORD_NO_BRANCH', message: `no local or origin branch ${missing.join(', ')}; nothing was written` });
|
||||
const runDir = path.resolve(flags['--autoplan']);
|
||||
const results: Array<{ branch: string; added: string[]; skipped: number; file: string; mirror: string | null; error?: string }> = [];
|
||||
let firstError: RecordError | undefined;
|
||||
const plans: Array<{ branch: string; loc: StoreLocation; rows: ReturnType<typeof importAutoplan> & unknown[] }> = [];
|
||||
for (const branch of branches) {
|
||||
const loc = locate({ cwd, env: process.env, branch });
|
||||
if ('code' in loc) return fail(cmd, loc);
|
||||
const rows = importAutoplan(runDir, branch, flags['--by']);
|
||||
if ('code' in rows) return fail(cmd, rows);
|
||||
plans.push({ branch, loc, rows });
|
||||
}
|
||||
for (const { branch, loc, rows } of plans) {
|
||||
const res = appendRows(loc, rows);
|
||||
if ('code' in res) { firstError ??= res; results.push({ branch, added: [], skipped: 0, file: loc.file, mirror: loc.mirror ?? null, error: res.message }); continue; }
|
||||
if (res.mirrorError) firstError ??= res.mirrorError;
|
||||
results.push({ branch, added: res.added.map(r => r.id), skipped: res.skipped, file: loc.file, mirror: loc.mirror ?? null, ...(res.mirrorError ? { error: res.mirrorError.message } : {}) });
|
||||
}
|
||||
if (json) process.stdout.write(JSON.stringify({ branches: results }) + '\n');
|
||||
else {
|
||||
for (const r of results) process.stdout.write(`APPROVAL_IMPORTED: branch=${r.branch} n=${r.added.length} skipped=${r.skipped} ${r.file}${r.mirror ? ` mirror=${r.mirror}` : ''}${r.error ? ` error=${r.error}` : ''}\n`);
|
||||
process.stdout.write(`APPROVAL_IMPORTED_STACK: branches=${results.length} added=${results.reduce((n, r) => n + r.added.length, 0)}\n`);
|
||||
}
|
||||
return firstError ? fail(cmd, firstError) : EXIT.ok;
|
||||
}
|
||||
|
||||
function main(argv: string[]): number {
|
||||
const [cmd, ...rest] = argv;
|
||||
if (!cmd || cmd === '--help' || cmd === '-h') { process.stdout.write(HELP + '\n'); return cmd ? EXIT.ok : EXIT.usage; }
|
||||
const { flags, bools, error } = parse(rest);
|
||||
if (error) { process.stderr.write(`gstack-approvals: ${error}\n${HELP}\n`); return EXIT.usage; }
|
||||
if (flags['--stack'] !== undefined) return importStack(cmd, flags, bools.has('--json'));
|
||||
const loc = locate({ cwd: process.cwd(), env: process.env, branch: flags['--branch'] });
|
||||
if ('code' in loc) return fail(cmd, loc);
|
||||
const json = bools.has('--json');
|
||||
|
||||
+78
-19
@@ -9,8 +9,8 @@
|
||||
* 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
|
||||
@@ -19,13 +19,14 @@
|
||||
* gstack-autoplan release --out <dir> --attempt <id> drop a reservation for an attempt that never ran
|
||||
* gstack-autoplan close --out <dir> --phase <p> --deferred "<reason>" --by <human|agent:<id>> close a phase accepted-with-findings
|
||||
* 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 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),
|
||||
* lib/autoplan-export.ts, lib/autoplan-prompts.ts, lib/spend-ledger.ts. Codes: lib/result-codes.ts.
|
||||
* Workflow commands end with GSTACK_RESULT: skill=autoplan status=<s> run=<dir>; the run is never approved here.
|
||||
* Mutating verbs (next, submit, answer, recommend, revise, close, budget, export, run) pass admission first (lib/autoplan-admission.ts)
|
||||
* Mutating verbs (next, submit, answer, recommend, revise, close, budget, check, export, run) pass admission first (lib/autoplan-admission.ts)
|
||||
* and bring an older run directory forward (lib/autoplan-migrate.ts) under the run lock.
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
@@ -33,6 +34,7 @@ import * as path from 'node:path';
|
||||
import { amendmentSteps, answerGate, closeDeferred, finishGate, recommendGate } from '../lib/autoplan-gate';
|
||||
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 { ANSWERED_BY_RE } from '../lib/gate-list';
|
||||
import { checkpointLines, checkpointRun, restoreCheckpoint, type CheckpointMoment } from '../lib/autoplan-checkpoint';
|
||||
import { admit, touchInstallLease } from '../lib/autoplan-admission';
|
||||
@@ -41,11 +43,13 @@ 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 { 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';
|
||||
@@ -57,9 +61,12 @@ DRIVER: ${driverFile()}
|
||||
[--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
|
||||
--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
|
||||
--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
|
||||
--carry-check-cap <usd> (first next): the most one delta check may reserve before a carry (default ${DEFAULT_CARRY_CHECK_CAP_USD.toFixed(2)})
|
||||
the first next prints REVIEW_ESTIMATE: the most the review can reserve at this scope and round cap
|
||||
prepare the current phase; one ATTEMPT: line per voice; ends awaiting_result
|
||||
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]
|
||||
@@ -75,15 +82,21 @@ 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);
|
||||
--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
|
||||
resume --out <dir> [--from <checkpoint>] [--json] --from restores a hash-verified checkpoint into an empty --out first
|
||||
(a collection or root restores its newest valid generation; GSTACK_AUTOPLAN_CHECKPOINT_DIR set: every command
|
||||
leaving the run at rest publishes a generation under <dir>/<run>/ and prints CHECKPOINT: on stderr)
|
||||
check --out <dir> --attempt <id> --result <file> --model <id> [--same-family "<why>"] | --unavailable "<reason>"
|
||||
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
|
||||
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
|
||||
@@ -170,7 +183,7 @@ 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[], json: boolean, price?: NextOptions['price']): number {
|
||||
const cwd = process.cwd();
|
||||
const initLines: string[] = [];
|
||||
const fresh = !fs.existsSync(path.join(out, FILES.run));
|
||||
@@ -180,11 +193,14 @@ function doNext(out: string, rest: string[], json: boolean): number {
|
||||
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,
|
||||
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];
|
||||
@@ -257,7 +273,7 @@ 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(...gateLines(state, out)); }
|
||||
if (!currentPhase(state)) { finishGate(state); lines.push(...carryLines(state), ...(state.status === 'gate_pending' ? gateLines(state, out) : [])); }
|
||||
else { state.status = 'running'; lines.push(`NEXT: gstack-autoplan next --out ${out}`); }
|
||||
writeState(state);
|
||||
lines.push(resultLineFor(state.status, out));
|
||||
@@ -265,6 +281,19 @@ function doClose(out: string, rest: string[], json: boolean): number {
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
/** `check`: the delta reviewer's verdict before a carry (lib/autoplan-carry.ts). */
|
||||
function doCheck(out: string, rest: string[], json: boolean): number {
|
||||
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;
|
||||
}
|
||||
|
||||
function doBudget(out: string, rest: string[], json: boolean): number {
|
||||
const state = readState(out);
|
||||
const lines = changeBudget(state, { spendCap: num(rest, '--spend-cap'), maxRounds: num(rest, '--max-rounds'), by: flag(rest, '--by') });
|
||||
@@ -281,10 +310,25 @@ 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));
|
||||
const code = withRunLock(out, () => doNext(out, rest, true, price));
|
||||
if (code !== EXIT.ok) return code;
|
||||
const state = readState(out);
|
||||
if (state.status !== 'awaiting_result') {
|
||||
@@ -292,27 +336,39 @@ async function doRun(out: string, rest: string[], json: boolean): Promise<number
|
||||
process.stdout.write(resultLineFor(state.status, out) + '\n'); return EXIT.ok;
|
||||
}
|
||||
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 { withRunLock(out, () => doSubmit(out, ['--phase', phase, '--voice', voice, '--result', `${open.result}.raw`, '--model', res.model ?? modelOf(voice), '--attempt', open.attempt, '--runner', res.runner, ...usage], true)); }
|
||||
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;
|
||||
@@ -338,6 +394,7 @@ async function main(argv: string[]): Promise<number> {
|
||||
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 === '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);
|
||||
@@ -361,10 +418,12 @@ async function main(argv: string[]): Promise<number> {
|
||||
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; }
|
||||
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 state = readState(out);
|
||||
const r = answerGate(state, Number(rev), reply, by, { recHash: flag(rest, '--rec-hash'), ownerReply });
|
||||
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));
|
||||
@@ -416,7 +475,7 @@ async function main(argv: string[]): Promise<number> {
|
||||
if (e instanceof RunError) {
|
||||
process.stderr.write(resultLine(`gstack-autoplan ${cmd}`, e.code, e.detail) + '\n');
|
||||
if (e.after.lines?.length) process.stdout.write(e.after.lines.join('\n') + '\n');
|
||||
if (['next', 'submit', 'answer', 'resume', 'run', 'revise', 'recommend', 'close', 'budget'].includes(cmd)) process.stdout.write(resultLineFor(e.after.status ?? 'refused', out) + '\n');
|
||||
if (['next', 'submit', 'answer', 'resume', 'run', 'revise', 'recommend', 'close', 'budget', 'check'].includes(cmd)) process.stdout.write(resultLineFor(e.after.status ?? 'refused', out) + '\n');
|
||||
return e.exit === 3 ? EXIT.refused : EXIT.fail;
|
||||
}
|
||||
process.stderr.write(`gstack-autoplan ${cmd}: ${e?.message ?? e}\n`);
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
Executable
+88
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
||||
/**
|
||||
* gstack-base-red — /ship Step 5's red-base proof and pause (docs/ship-policy.md#base-red).
|
||||
*
|
||||
* gstack-base-red classify --failures <evidence.json> [--base <branch>] [--head <sha>] [--pause] [--json]
|
||||
* gstack-base-red wait --pause <file> [--timeout <s>] [--poll <s>] [--json]
|
||||
*
|
||||
* `classify` reads the base branch's `base_red` policy (never the working tree),
|
||||
* prints one BASE_RED: or IN_BRANCH: line per failure and a BASE_RED_VERDICT:
|
||||
* line, and with --pause writes the pause record when every failure is
|
||||
* base-red (PAUSE: <file>). Exit 0 only for base-red; 1 in-branch.
|
||||
* `wait` returns 0 (BASE_GREEN: <sha>) once a new base head passes the
|
||||
* paused cases, 3 on timeout or an expired pause. Logic: lib/base-red.ts.
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import { classifyBaseRed, classifyLines, parseEvidence, readPause, waitBaseGreen, writePause } from '../lib/base-red';
|
||||
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
|
||||
import { resultLineWithDocs } from '../lib/result-codes';
|
||||
import { isUnavailable, loadPolicyFromBase, policyUnavailableLine, resolveBaseBranch } from '../lib/ship-policy';
|
||||
import { git } from '../lib/ship-queue-gate';
|
||||
|
||||
const HELP = `gstack-base-red — prove a Step 5 failure is the base branch's own, and pause /ship on it
|
||||
|
||||
classify --failures <evidence.json> [--base <branch>] [--head <sha>] [--pause] [--json]
|
||||
evidence: { "lane": "<Step 5 label>", "total": <failures the lane reported>,
|
||||
"failures": [{ "id": "<test name>", "file": "<test file>", "signature": "<first error line>" }] }
|
||||
wait --pause <file> [--timeout <s>] [--poll <s>] [--json]
|
||||
|
||||
${exitTableHelp()}`;
|
||||
|
||||
const flag = (args: string[], name: string) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; };
|
||||
const say = (json: boolean, lines: string[], value: unknown) => process.stdout.write(json ? JSON.stringify(value) + '\n' : lines.join('\n') + '\n');
|
||||
|
||||
function policyFor(repoRoot: string, base: string, cmd: string) {
|
||||
git(repoRoot, ['fetch', '--no-tags', '-q', 'origin', `+refs/heads/${base}:refs/remotes/origin/${base}`]);
|
||||
const load = loadPolicyFromBase(repoRoot, base);
|
||||
if (isUnavailable(load)) { process.stderr.write(policyUnavailableLine(cmd, base, load.detail) + '\n'); return 'unavailable' as const; }
|
||||
if (load.errors.length) { process.stderr.write(`${cmd}: ${load.source.label} is invalid (${load.errors[0]!.message} at ${load.errors[0]!.path}); every failure stays in-branch\n`); return null; }
|
||||
return load.policy.base_red;
|
||||
}
|
||||
|
||||
function classify(args: string[], json: boolean, repoRoot: string): number {
|
||||
const file = flag(args, '--failures');
|
||||
if (!file) { process.stderr.write(`gstack-base-red classify: --failures <evidence.json> is required\n${HELP}\n`); return EXIT.usage; }
|
||||
let raw: unknown;
|
||||
try { raw = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e: any) { process.stderr.write(`gstack-base-red classify: cannot read ${file}: ${e.message}\n`); return EXIT.usage; }
|
||||
const evidence = parseEvidence(raw);
|
||||
if (typeof evidence === 'string') { process.stderr.write(`gstack-base-red classify: ${file}: ${evidence}\n`); return EXIT.usage; }
|
||||
const base = resolveBaseBranch(repoRoot, flag(args, '--base'));
|
||||
const policy = policyFor(repoRoot, base, 'gstack-base-red classify');
|
||||
if (policy === 'unavailable') return EXIT.fail;
|
||||
const r = classifyBaseRed({ repoRoot, base, head: flag(args, '--head') ?? 'HEAD', evidence, policy });
|
||||
const lines = classifyLines(r);
|
||||
if (r.code !== 'BASE_RED' && r.code !== 'IN_BRANCH') process.stderr.write(resultLineWithDocs('gstack-base-red classify', r.code, r.detail) + '\n');
|
||||
let pause: string | undefined;
|
||||
if (r.code === 'BASE_RED' && args.includes('--pause')) {
|
||||
const branch = git(repoRoot, ['rev-parse', '--abbrev-ref', 'HEAD']).out || r.head;
|
||||
pause = writePause(repoRoot, branch, r, evidence).file;
|
||||
lines.push(`PAUSE: ${pause}`);
|
||||
}
|
||||
say(json, lines, { ...r, pause });
|
||||
return r.code === 'BASE_RED' ? EXIT.ok : EXIT.fail;
|
||||
}
|
||||
|
||||
function wait(args: string[], json: boolean, repoRoot: string): number {
|
||||
const file = flag(args, '--pause');
|
||||
if (!file) { process.stderr.write(`gstack-base-red wait: --pause <file> is required\n${HELP}\n`); return EXIT.usage; }
|
||||
const pause = readPause(file);
|
||||
if (typeof pause === 'string') { process.stderr.write(`gstack-base-red wait: ${pause}\n`); return EXIT.usage; }
|
||||
const policy = policyFor(repoRoot, pause.base, 'gstack-base-red wait');
|
||||
if (!policy || policy === 'unavailable') { process.stderr.write(resultLineWithDocs('gstack-base-red wait', 'BASE_RED_UNCONFIGURED', `origin/${pause.base} no longer declares a usable base_red`) + '\n'); return EXIT.refused; }
|
||||
const timeoutSec = Number(flag(args, '--timeout') ?? 21600);
|
||||
const pollSec = Number(flag(args, '--poll') ?? 120);
|
||||
if (!(timeoutSec > 0) || !(pollSec >= 5)) { process.stderr.write('gstack-base-red wait: --timeout must be > 0 and --poll >= 5 seconds\n'); return EXIT.usage; }
|
||||
const r = waitBaseGreen({ repoRoot, pause, policy, timeoutSec, pollSec });
|
||||
if (r.code === 'BASE_GREEN') { say(json, [`BASE_GREEN: origin/${pause.base} ${r.sha} passes ${pause.cases.length} paused case(s) after ${r.checked} base head(s); resume with /ship --resume <record>`], r); return EXIT.ok; }
|
||||
process.stderr.write(resultLineWithDocs('gstack-base-red wait', r.code, r.detail) + '\n');
|
||||
say(json, [`BASE_RED_WAIT: ${r.code} last base ${r.sha.slice(0, 12)} (${r.checked} head(s) checked)`], r);
|
||||
return EXIT.refused;
|
||||
}
|
||||
|
||||
const [cmd, ...rest] = process.argv.slice(2);
|
||||
const json = rest.includes('--json');
|
||||
const repoRoot = git(process.cwd(), ['rev-parse', '--show-toplevel']).out || process.cwd();
|
||||
if (cmd === 'classify') process.exit(classify(rest, json, repoRoot));
|
||||
if (cmd === 'wait') process.exit(wait(rest, json, repoRoot));
|
||||
process.stdout.write(HELP + '\n');
|
||||
process.exit(cmd === '--help' || cmd === '-h' ? EXIT.ok : EXIT.usage);
|
||||
+128
-12
@@ -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" ;;
|
||||
|
||||
+30
-5
@@ -29,7 +29,10 @@
|
||||
# reuse, Bun at gstack's own supported minimum with the project's pins never
|
||||
# applied, no ./setup, no host profiles, no shim; the doctor certifies only the
|
||||
# read-only helpers, and an ok verdict a fuller install wrote for this HEAD is
|
||||
# kept. Without the Drive skill, gstack_capy_bootstrap_cmd prints the command
|
||||
# kept: the profile then writes no launch file and announces that install's
|
||||
# own launch file and path, so the lane's environment is never rewritten
|
||||
# (GST-401). A profile's own launch directory lives under <verdict>.profile/,
|
||||
# never <verdict>.launch. Without the Drive skill, gstack_capy_bootstrap_cmd prints the command
|
||||
# that clones into an explicit root and runs the checkout's own bootstrapper.
|
||||
# Chromium is lazy by default (bin/gstack-browser-ensure
|
||||
# installs it on first use); --with-browser installs it now. The last lines
|
||||
@@ -67,6 +70,7 @@ while [ $# -gt 0 ]; do
|
||||
done
|
||||
[ -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
|
||||
@@ -145,7 +149,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
|
||||
@@ -382,6 +386,11 @@ if [ -n "$RES_BIN" ]; then
|
||||
esac
|
||||
if [ -z "$LAUNCH_DIR" ]; then
|
||||
LAUNCH_DIR="${VERDICT%.json}.launch"
|
||||
if [ "$PROFILE" -eq 1 ]; then
|
||||
LAUNCH_DIR="${VERDICT%.json}.profile/$FOR.launch"
|
||||
(umask 077 && mkdir -p "${VERDICT%.json}.profile") \
|
||||
|| stop INSTALL_PREFLIGHT_FAILED "could not create ${VERDICT%.json}.profile" "make $(dirname "$VERDICT") writable, then re-run"
|
||||
fi
|
||||
gstack_launch_dir "$LAUNCH_DIR" "$RES_BIN" "$_launch_node" \
|
||||
|| stop INSTALL_PREFLIGHT_FAILED "could not publish the launch directory $LAUNCH_DIR" "make $(dirname "$LAUNCH_DIR") writable, then re-run"
|
||||
fi
|
||||
@@ -656,13 +665,16 @@ WARN behind — installed $INSTALLED is ${GSTACK_CAPY_BEHIND:-?} commits behind
|
||||
[ -n "$NOTES" ] && TRAILER="$TRAILER ($NOTES)" ;;
|
||||
esac
|
||||
# The launch file: the same assignments as a sourceable script, mode 0600,
|
||||
# beside the verdict. `. <file>` applies them without ever printing them; the
|
||||
# beside the verdict. It also appends the checkout's bin directory to PATH
|
||||
# (after everything already there, so it shadows nothing): a skill's bare
|
||||
# `gstack-ship-budget` resolves in a shell that sourced only this file. `. <file>` applies them without ever printing them; the
|
||||
# line carries no secret, but a caller that re-typed it as `export $(...)`
|
||||
# ran a bare `export` when the substitution came back empty and dumped the
|
||||
# whole environment, API keys included, into its tool output (GSTA-96). The
|
||||
# file is part of the ok verdict: a passing run that cannot write it fails,
|
||||
# so no caller is left with only the line and the re-typing path.
|
||||
LAUNCH_FILE="${VERDICT%.json}.env"
|
||||
BIN_DIR=$(cd "$ROOT/bin" 2>/dev/null && pwd -P)
|
||||
write_launch_file() {
|
||||
local tmp
|
||||
mkdir -p "$(dirname "$LAUNCH_FILE")" || return 1
|
||||
@@ -672,21 +684,34 @@ write_launch_file() {
|
||||
echo "# Apply it with: . <this file>"
|
||||
echo "# Never print it or the environment: no \`export \$(...)\`, \`env\`, \`printenv\` or bare \`set\` in a tool fence."
|
||||
[ -n "$LAUNCH_DIR" ] && echo "case \":\$PATH:\" in *:$(shq "$LAUNCH_DIR"):*) ;; *) PATH=$(shq "$LAUNCH_DIR"):\"\$PATH\" ;; esac; export PATH"
|
||||
[ -n "$BIN_DIR" ] && echo "case \":\$PATH:\" in *:$(shq "$BIN_DIR"):*) ;; *) PATH=\"\${PATH:+\$PATH:}\"$(shq "$BIN_DIR") ;; esac; export PATH"
|
||||
echo "export GSTACK_SESSION_KIND=unattended"
|
||||
echo "export GSTACK_STATE_ROOT=$(shq "$GSTACK_STATE_ROOT")"
|
||||
echo "export GSTACK_EPHEMERAL=$(shq "${GSTACK_EPHEMERAL:-1}")"
|
||||
echo "export GSTACK_CAPY=1"
|
||||
echo "export GSTACK_INSTALL_VERDICT=$(shq "$VERDICT")"
|
||||
if [ -n "${GSTACK_PREFERENCES_FILE:-}" ]; then echo "export GSTACK_PREFERENCES_FILE=$(shq "$GSTACK_PREFERENCES_FILE")"; fi
|
||||
} > "$tmp" && chmod 0600 "$tmp" && mv -f "$tmp" "$LAUNCH_FILE" || { rm -f "$tmp"; return 1; }
|
||||
}
|
||||
if [ "$RC" -eq 0 ] && ! write_launch_file; then
|
||||
# A profile check that keeps a fuller ok verdict borrows that install's launch
|
||||
# file and path instead of writing over them (GST-401), unless that install
|
||||
# recorded no launch path while this shell needs one to reach its Bun.
|
||||
BORROWED=0 _kept_launch=""
|
||||
verdict_kept && _kept_launch=$(gstack_install_verdict_field "$VERDICT" launch_path)
|
||||
if [ "$RC" -eq 0 ] && verdict_kept && [ -f "$LAUNCH_FILE" ] && { [ -n "$_kept_launch" ] || [ -z "$LAUNCH_DIR" ]; }; then
|
||||
BORROWED=1 LAUNCH_DIR=$_kept_launch
|
||||
elif [ "$RC" -eq 0 ] && verdict_kept; then
|
||||
LAUNCH_FILE="${VERDICT%.json}.profile/$FOR.env"
|
||||
(umask 077 && mkdir -p "${VERDICT%.json}.profile") || true
|
||||
fi
|
||||
if [ "$RC" -eq 0 ] && [ "$BORROWED" -eq 0 ] && ! write_launch_file; then
|
||||
TRAILER="gstack: fail launch-file — could not write the launch file $LAUNCH_FILE; fix: make $(dirname "$LAUNCH_FILE") writable, or set GSTACK_STATE_ROOT to a writable directory (LAUNCH_FILE_UNWRITABLE)"
|
||||
RC=1
|
||||
fi
|
||||
# A failing run announces no launch file and removes the one its verdict owned
|
||||
# (where the directory lets it); a profile check that keeps a fuller ok verdict
|
||||
# leaves that verdict's file in place.
|
||||
[ "$RC" -eq 0 ] || { verdict_kept || rm -f "$LAUNCH_FILE" 2>/dev/null; LAUNCH_FILE=""; }
|
||||
[ "$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)"
|
||||
[ "$CHECK_ONLY" -eq 1 ] || [ "$PROFILE" -eq 1 ] || write_shim
|
||||
printf '%s\n' "$OUT" | grep -v '^gstack: '
|
||||
|
||||
+43
-2
@@ -23,7 +23,16 @@
|
||||
# applies: dirty, diverged, pinned, Bun hold, a live workflow lease) and on
|
||||
# success re-execs the NEW checkout's copy of this script with the same
|
||||
# arguments (GSTACK_CAPY_SELF_UPGRADED=1 stops a second attempt) before the
|
||||
# installer runs. Lines: `SELF_UPGRADE: done <old> -> <new>` | `skipped (<reason>)`
|
||||
# installer runs. A live lease under 120 minutes old (the lease prune window)
|
||||
# held by this session's own process chain also skips it (#3571):
|
||||
# gstack-upgrade never counts the caller's own session, but an install run from
|
||||
# inside a workflow (/ship's docs step) must not change gstack under it. The age
|
||||
# bound matters on Capy, where the leased harness pid is the machine's
|
||||
# long-lived capy-server and a live lease alone would never end the deferral;
|
||||
# each gstack-skill-start renews it (UPGRADE_BUSY; GSTACK_UPGRADE_FORCE=1 overrides).
|
||||
# An install profile (`--for review-voice`, a dispatched reviewer on a machine
|
||||
# whose checkout a lane shares) never self-upgrades, lease or not, and
|
||||
# GSTACK_UPGRADE_FORCE does not override it (UPGRADE_SKIPPED, GST-290). Lines: `SELF_UPGRADE: done <old> -> <new>` | `skipped (<reason>)`
|
||||
# | `none (current)` | `disabled (<the variable: GSTACK_CAPY_SELF_UPGRADE=off or
|
||||
# GSTACK_CAPY_AUTO_UPGRADE=0>)`.
|
||||
# --check never moves the checkout; behind origin it prints
|
||||
@@ -64,6 +73,8 @@ fi
|
||||
[ "${GSTACK_CAPY_REMOTE_MIN:-}" = unknown ] && echo "gstack-capy-session: remote minimum unknown (fetch failed); the revision row is local only"
|
||||
# shellcheck source=bin/gstack-install-verdict.sh
|
||||
. "$ROOT/bin/gstack-install-verdict.sh"
|
||||
# shellcheck source=bin/gstack-doctor-components.sh
|
||||
. "$ROOT/bin/gstack-doctor-components.sh" 2>/dev/null || gstack_doctor_install_profile() { [ "$1" = review-voice ]; }
|
||||
MIN_WRAPPER=$(gstack_support_env_get "$(gstack_source_root "$ROOT/bin")" GSTACK_CAPY_MIN_WRAPPER)
|
||||
if [ -n "$MIN_WRAPPER" ] && [ "${GSTACK_CAPY_WRAPPER_REV:-0}" -lt "$MIN_WRAPPER" ] 2>/dev/null; then
|
||||
echo "gstack-capy-skill: owner action — Drive copy is wrapper ${GSTACK_CAPY_WRAPPER_REV:-0}, minimum $MIN_WRAPPER; copy $ROOT/capy/skill/gstack/ (CAPY_WRAPPER_STALE)"
|
||||
@@ -85,12 +96,25 @@ report_behind() {
|
||||
GSTACK_CAPY_BEHIND_TO="$(git -C "$ROOT" show FETCH_HEAD:VERSION 2>/dev/null | head -1)" GSTACK_CAPY_BEHIND_FIX="$1"
|
||||
}
|
||||
unset GSTACK_CAPY_BEHIND GSTACK_CAPY_BEHIND_TO GSTACK_CAPY_BEHIND_FIX GSTACK_CAPY_BEHIND_BLOCKED
|
||||
# The owner's preferences.yaml sits beside the Drive skill's SKILL.md and is
|
||||
# read where it lies (never copied), so --check never writes the skill folder
|
||||
# and a newer file is seen by the next command. The installer writes the path
|
||||
# into the launch file; gstack-models resolve --role agent reads it. A wrapper
|
||||
# inside this checkout is no Drive skill, so it names no preferences file.
|
||||
if [ -n "${GSTACK_CAPY_WRAPPER_PATH:-}" ]; then
|
||||
_prefs_dir="$(dirname "$(dirname "$GSTACK_CAPY_WRAPPER_PATH")")"
|
||||
case "$_prefs_dir/" in
|
||||
"$ROOT"/*) ;;
|
||||
*) export GSTACK_PREFERENCES_FILE="$_prefs_dir/preferences.yaml" ;;
|
||||
esac
|
||||
fi
|
||||
RERUN="bash ${GSTACK_CAPY_WRAPPER_PATH:-<skill>/scripts/install.sh}${SKILL:+ --for $SKILL}"
|
||||
if [ "$CHECK" -eq 1 ]; then
|
||||
if behind; then
|
||||
# The rerun fast-forwards only what gstack-upgrade would move; otherwise
|
||||
# gstack-upgrade itself names the refusal and its fix.
|
||||
if [ "${GSTACK_CAPY_SELF_UPGRADE:-}" = off ]; then report_behind "unset GSTACK_CAPY_SELF_UPGRADE, then $RERUN"
|
||||
if gstack_doctor_install_profile "$SKILL"; then report_behind "$ROOT/bin/gstack-upgrade"
|
||||
elif [ "${GSTACK_CAPY_SELF_UPGRADE:-}" = off ]; then report_behind "unset GSTACK_CAPY_SELF_UPGRADE, then $RERUN"
|
||||
elif [ "${GSTACK_CAPY_AUTO_UPGRADE:-1}" = 0 ]; then report_behind "unset GSTACK_CAPY_AUTO_UPGRADE, then $RERUN"
|
||||
elif [ "$(git -C "$ROOT" symbolic-ref -q --short HEAD 2>/dev/null)" != "$(sed -n "1s/.*branch '\([^']*\)' of .*/\1/p" "$(git -C "$ROOT" rev-parse --absolute-git-dir 2>/dev/null)/FETCH_HEAD" 2>/dev/null)" ] \
|
||||
|| [ -n "$(git -C "$ROOT" status --porcelain --untracked-files=no 2>/dev/null)" ] \
|
||||
@@ -105,8 +129,25 @@ elif [ "${GSTACK_CAPY_SELF_UPGRADED:-}" != 1 ]; then
|
||||
elif [ "${GSTACK_CAPY_AUTO_UPGRADE:-1}" = 0 ]; then disabled GSTACK_CAPY_AUTO_UPGRADE
|
||||
elif [ -z "${GSTACK_CAPY_REMOTE_MIN:-}" ] || [ "$GSTACK_CAPY_REMOTE_MIN" = unknown ]; then echo "SELF_UPGRADE: none (remote minimum unknown)"
|
||||
elif ! behind; then echo "SELF_UPGRADE: none (current)"
|
||||
elif gstack_doctor_install_profile "$SKILL"; then
|
||||
REASON="$SKILL never self-upgrades a shared checkout; fix: GSTACK_UPGRADE_FORCE=1 $ROOT/bin/gstack-upgrade when no other session uses this checkout (UPGRADE_SKIPPED)"
|
||||
echo "SELF_UPGRADE: skipped ($REASON)"
|
||||
report_behind "$ROOT/bin/gstack-upgrade"
|
||||
export GSTACK_CAPY_BEHIND_BLOCKED="$REASON"
|
||||
elif [ ! -x "$ROOT/bin/gstack-upgrade" ]; then echo "SELF_UPGRADE: skipped ($ROOT/bin/gstack-upgrade is missing)"; report_behind "$RERUN"
|
||||
export GSTACK_CAPY_BEHIND_BLOCKED="$ROOT/bin/gstack-upgrade is missing; fix: $RERUN (UPGRADE_REFUSED)"
|
||||
elif [ "${GSTACK_UPGRADE_FORCE:-}" != 1 ] && [ -x "$ROOT/bin/gstack-lease" ] \
|
||||
&& OWN=$("$ROOT/bin/gstack-lease" live --root "$ROOT" 2>/dev/null | awk '{ a = $2; sub(/m$/, "", a); if (a + 0 < 120) print }') \
|
||||
&& [ -n "$OWN" ] \
|
||||
&& ! "$ROOT/bin/gstack-lease" live --root "$ROOT" --exclude-ancestry-of $$ >/dev/null 2>&1; then
|
||||
HOLDERS=""
|
||||
while read -r _pid _age; do HOLDERS="$HOLDERS${HOLDERS:+, }pid $_pid ($_age)"; done <<EOF_OWN
|
||||
$OWN
|
||||
EOF_OWN
|
||||
REASON="a gstack run in this session holds a workflow lease on this installation: $HOLDERS, so the upgrade waits until the lease is 120 minutes old; fix: $ROOT/bin/gstack-upgrade once the lease is 120 minutes old, or GSTACK_UPGRADE_FORCE=1 $ROOT/bin/gstack-upgrade when no other session uses this checkout (UPGRADE_BUSY)"
|
||||
echo "SELF_UPGRADE: skipped ($REASON)"
|
||||
report_behind "$ROOT/bin/gstack-upgrade"
|
||||
export GSTACK_CAPY_BEHIND_BLOCKED="$REASON"
|
||||
else
|
||||
OLD=$(head -1 "$ROOT/VERSION" 2>/dev/null)
|
||||
UP_OUT=$("$ROOT/bin/gstack-upgrade" --ff-only-no-reinstall 2>&1); UP_RC=$?
|
||||
|
||||
@@ -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."
|
||||
|
||||
Executable
+94
@@ -0,0 +1,94 @@
|
||||
#!/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.
|
||||
*/
|
||||
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';
|
||||
|
||||
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)
|
||||
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>]
|
||||
run the checks and the tests pregate selects for the change; one line per check:
|
||||
CHECK <name> PASS|FAIL|ZERO_RUN|UNMAPPED|TOO_BROAD|ENV|TIMEOUT|INCOMPLETE <secs>
|
||||
status READY_FOR_SHIP when the last run passed every check at this tree, else NOT_READY;
|
||||
runs nothing
|
||||
|
||||
--base <branch> the branch pregate selects against (default: origin/HEAD's branch, else main)
|
||||
|
||||
Exit codes: 0 green or ready · 1 a red check or not ready · 2 an environment failure or timeout ·
|
||||
3 consent required, no checks, or no pregate context · 64 usage
|
||||
`;
|
||||
|
||||
function flag(args: string[], name: string): string | undefined {
|
||||
const i = args.indexOf(name);
|
||||
return i >= 0 ? args[i + 1] : undefined;
|
||||
}
|
||||
|
||||
function repoRoot(): string | null {
|
||||
const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', timeout: 15_000 });
|
||||
return r.status === 0 && r.stdout.trim() ? r.stdout.trim() : null;
|
||||
}
|
||||
|
||||
function loadOrRefuse(root: string, args: string[], timeoutMs: number): Loaded | null {
|
||||
const l = load(root, flag(args, '--base'), timeoutMs);
|
||||
if ('refused' in l) { process.stderr.write(`gstack-checks: no pregate context, so tests cannot be selected: ${l.refused}\n`); return null; }
|
||||
return l;
|
||||
}
|
||||
|
||||
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 root = repoRoot();
|
||||
if (!root) { process.stderr.write('gstack-checks: not inside a git repository\n'); return 3; }
|
||||
const timeoutS = Number(flag(args, '--timeout') ?? 600);
|
||||
if (!Number.isFinite(timeoutS) || timeoutS <= 0) { process.stderr.write('gstack-checks: --timeout needs a positive number of seconds\n'); return 64; }
|
||||
const l = loadOrRefuse(root, args, timeoutS * 1000);
|
||||
if (!l) return 3;
|
||||
const trusted = isTrusted(root, l.set);
|
||||
const NONE = 'CHECKS: none (no gstack:verify: line, Health Stack section, manifest check, pregate policy or test file); nothing to run\n';
|
||||
if (nothingToRun(l) && (verb === 'run' || verb === 'detect')) { process.stdout.write(NONE); return verb === 'run' ? 3 : 0; }
|
||||
if (verb === 'detect') {
|
||||
for (const c of l.checks) process.stdout.write(`CHECK ${c.name} ${c.command}${c.body ? ` (runs: ${c.body})` : ''} (${c.source})\n`);
|
||||
if (!l.checks.length) process.stdout.write('CHECKS: tests only (no gstack:verify: line, Health Stack section or manifest check)\n');
|
||||
const selection = Object.values(l.ctx.config.selection ?? {});
|
||||
process.stdout.write(`TESTS pregate selection against ${l.ctx.baseRef} (policy ${l.ctx.configLabel}; runner ${l.ctx.config.runner ?? 'detected per file'}${selection.length ? `; selection commands: ${selection.join(' | ')}` : ''})\n`);
|
||||
process.stdout.write(`TRUST ${trusted ? 'trusted' : 'untrusted (run gstack-checks trust after the user agrees, or pass --allow-repo-commands)'}\n`);
|
||||
return 0;
|
||||
}
|
||||
if (verb === 'trust') {
|
||||
recordTrust(root, l.set, l.checks, l.ctx.config);
|
||||
process.stdout.write(`gstack-checks: trusted ${l.checks.length} check(s) and the pregate test commands for ${root}\n`);
|
||||
return 0;
|
||||
}
|
||||
if (verb === 'status') {
|
||||
const s = status(l, readLedger(ledgerPath(root)));
|
||||
process.stdout.write(s.line + '\n');
|
||||
return s.ready ? 0 : 1;
|
||||
}
|
||||
if (!trusted && !args.includes('--allow-repo-commands')) {
|
||||
process.stderr.write(`gstack-checks: these commands are not trusted for ${root}; nothing was run (CHECKS_CONSENT_REQUIRED)\n`);
|
||||
for (const c of l.checks) process.stderr.write(` ${c.name}: ${c.command}\n`);
|
||||
process.stderr.write(` tests: ${l.ctx.config.runner ?? 'the runner pregate detects'} on the files pregate selects${Object.keys(l.ctx.config.selection ?? {}).length ? ' (and its selection manifest commands)' : ''}\n`);
|
||||
process.stderr.write('fix: ask the user, then gstack-checks trust; or pass --allow-repo-commands when the session was launched to run them\n');
|
||||
return 3;
|
||||
}
|
||||
const only = flag(args, '--only')?.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const unknown = (only ?? []).filter(n => n !== TESTS && !l.checks.some(c => c.name === n));
|
||||
if (unknown.length) { process.stderr.write(`gstack-checks: unknown check(s) ${unknown.join(', ')}; known: ${[...l.checks.map(c => c.name), TESTS].join(', ')}\n`); return 64; }
|
||||
for (const signal of ['SIGINT', 'SIGTERM'] as const) process.once(signal, () => { cancelActive(); process.exit(signal === 'SIGINT' ? 130 : 143); });
|
||||
const { outcomes, ledger } = await runChecks(l, { root, only, timeoutMs: timeoutS * 1000 });
|
||||
for (const o of outcomes) {
|
||||
process.stdout.write(`CHECK ${o.name} ${o.verdict} ${(o.ms / 1000).toFixed(1)}s${o.verdict === 'PASS' ? '' : ` ${o.detail}`}\n`);
|
||||
if (o.verdict !== 'PASS') { for (const line of o.lines.slice(-MAX_FAIL_LINES)) process.stdout.write(` | ${line}\n`); if (o.log) process.stdout.write(` log: ${o.log}\n`); }
|
||||
}
|
||||
if (ledger.notCovered.length) process.stdout.write(`NOT_COVERED ${ledger.notCovered.join(' ')} (platform lanes /ship's pregate owns)\n`);
|
||||
if (outcomes.every(o => o.verdict === 'PASS')) return 0;
|
||||
return outcomes.some(o => o.verdict === 'FAIL' || o.verdict === 'ZERO_RUN' || o.verdict === 'UNMAPPED' || o.verdict === 'TOO_BROAD' || o.verdict === 'INCOMPLETE') ? 1 : 2;
|
||||
}
|
||||
|
||||
main(process.argv.slice(2)).then(code => process.exit(code), e => { process.stderr.write(`gstack-checks: ${e?.stack ?? e}\n`); process.exit(1); });
|
||||
+29
-2
@@ -194,6 +194,9 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne
|
||||
# Optional pins: model_frontier_claude, model_frontier_openai,
|
||||
# model_smart_claude and model_smart_openai. Leave pins absent for catalog defaults;
|
||||
# empty pins are invalid. gstack-models list prints copyable set/unset commands.
|
||||
# agent_model: the model for gstack agents (lanes, subagents, reviewers): host,
|
||||
# a family (opus, fable, sonnet, haiku, astra, sol, luna, terra) or <provider>/<model>.
|
||||
# Absent = host. A preference, not a policy: gstack-models resolve --role agent.
|
||||
# Invalid writes are rejected and preserve the stored value.
|
||||
# codex_effort: # Codex reasoning effort for outside reviews and /codex:
|
||||
# # minimal | low | medium | high | xhigh | max | ultra.
|
||||
@@ -205,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.
|
||||
@@ -362,8 +373,10 @@ lookup_default() {
|
||||
plan_review_tier) echo "frontier" ;; # frontier | smart | host — lib/model-policy.ts reads it strictly
|
||||
implementation_tier) echo "smart" ;; # frontier | smart
|
||||
model_frontier_claude|model_frontier_openai|model_smart_claude|model_smart_openai) echo "" ;; # empty = shipped catalog ID (gstack-models list)
|
||||
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
|
||||
@@ -829,6 +842,10 @@ $_gstack_legacy_root"
|
||||
echo "Error: review_effort_tiers '$VALUE' not recognized. Valid values: on, off. Existing value left unchanged." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$KEY" = "harness_start_hook" ] && [ "$VALUE" != "on" ] && [ "$VALUE" != "off" ]; then
|
||||
echo "Error: harness_start_hook '$VALUE' not recognized. Valid values: on, off. Existing value left unchanged. fix: gstack-config unset harness_start_hook keeps each registration's saved choice (docs/hosts.md#start-hook)" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$KEY" = "codex_effort" ]; then
|
||||
case "$VALUE" in
|
||||
minimal|low|medium|high|xhigh|max|ultra) ;;
|
||||
@@ -836,6 +853,16 @@ $_gstack_legacy_root"
|
||||
exit 1 ;;
|
||||
esac
|
||||
fi
|
||||
if [ "$KEY" = "agent_model" ]; then
|
||||
VALUE="${VALUE#"${VALUE%%[![:space:]]*}"}"
|
||||
VALUE="${VALUE%"${VALUE##*[![:space:]]}"}"
|
||||
case "$(printf '%s' "$VALUE" | LC_ALL=C tr 'A-Z' 'a-z')" in
|
||||
host|opus|fable|sonnet|haiku|astra|sol|luna|terra) ;;
|
||||
*) printf '%s' "$VALUE" | LC_ALL=C grep -qE '^[a-z][a-z0-9-]{0,31}/[A-Za-z0-9][A-Za-z0-9._:@+-]{0,199}$' || {
|
||||
echo "Error: agent_model '$VALUE' not recognized. Use host, a family (opus, fable, sonnet, haiku, astra, sol, luna, terra) or <provider>/<model> such as anthropic/claude-opus-5-5. Existing value left unchanged. fix: gstack-config unset agent_model returns to host (https://github.com/garrytan/gstack/blob/main/docs/model-policy.md#your-model-preference)" >&2
|
||||
exit 1; } ;;
|
||||
esac
|
||||
fi
|
||||
case "$KEY" in
|
||||
plan_review_tier|implementation_tier|model_frontier_claude|model_frontier_openai|model_smart_claude|model_smart_openai)
|
||||
_mp_doc="https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#model-policy-config"
|
||||
@@ -1086,7 +1113,7 @@ $_gstack_legacy_root"
|
||||
ship_measure_judge_outputs ship_measure_ask_per_trial_usd ship_measure_budget_usd \
|
||||
ship_measure_max_rounds ship_measure_sweep_cases ship_measure_sweep_budget_usd ship_rerun_backend test_backend \
|
||||
plan_review_tier implementation_tier model_frontier_claude model_frontier_openai \
|
||||
model_smart_claude model_smart_openai codex_effort review_effort_tiers; 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"
|
||||
@@ -1111,7 +1138,7 @@ $_gstack_legacy_root"
|
||||
ship_measure_judge_outputs ship_measure_ask_per_trial_usd ship_measure_budget_usd \
|
||||
ship_measure_max_rounds ship_measure_sweep_cases ship_measure_sweep_budget_usd ship_rerun_backend test_backend \
|
||||
plan_review_tier implementation_tier model_frontier_claude model_frontier_openai \
|
||||
model_smart_claude model_smart_openai codex_effort review_effort_tiers; 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
|
||||
;;
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* and a cell whose first byte a spreadsheet would run as a formula (=, +, -, @)
|
||||
* gets a leading apostrophe, so evidence taken from PR titles, filenames or
|
||||
* generated text cannot execute when a reviewer opens the file. License text:
|
||||
* licenses/MIT-pstack.txt; NOTICE.md carries the entry.
|
||||
* licenses/MIT-pstack.txt; NOTICE.md carries the entry. The append rules live in
|
||||
* lib/decision-trail.ts, shared with the autoplan runner.
|
||||
*
|
||||
* The start-row rule is gstack's mechanisation of the same skill's prose: a run
|
||||
* (one agent conversation, with its later turns) that writes to a trail already
|
||||
@@ -26,11 +27,8 @@
|
||||
* id, and an append with `--run <id>` refuses until that run's start row is the
|
||||
* newest start row in the file (DECISION_TRAIL_START_REQUIRED, exit 3).
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { appendTrailRow as appendRow, readTrailRows as readRows, type TrailRow as Row } from "../lib/decision-trail";
|
||||
|
||||
const COLUMNS = ["ts", "phase", "decision", "why", "evidence", "result"] as const;
|
||||
const HEADER = COLUMNS.join("\t") + "\n";
|
||||
const USAGE = `Usage:
|
||||
gstack-decision-trail [--run <id>] <file> <phase> <decision> <why> <evidence> <result>
|
||||
gstack-decision-trail start <file> <run-id>
|
||||
@@ -56,58 +54,6 @@ letters, digits and . _ : @ / - (a Capy jam_ id, a session id, a PR number).
|
||||
when the trail has rows and the newest start row is another run's.
|
||||
`;
|
||||
|
||||
type Row = { line: number; cells: string[] };
|
||||
|
||||
function clean(cell: string): string {
|
||||
const flat = cell.replace(/[\t\n\r]/g, " ");
|
||||
return /^[=+\-@]/.test(flat) ? `'${flat}` : flat;
|
||||
}
|
||||
|
||||
function readRows(file: string): Row[] {
|
||||
if (!fs.existsSync(file)) return [];
|
||||
const lines = fs.readFileSync(file, "utf8").split("\n");
|
||||
const rows: Row[] = [];
|
||||
lines.forEach((raw, i) => {
|
||||
let text = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
|
||||
if (i === 0 && text.startsWith("\uFEFF")) text = text.slice(1);
|
||||
// A repeated header (the append-on-flaky-mount case) is never a row.
|
||||
if (text === "" || text === HEADER.trimEnd()) return;
|
||||
rows.push({ line: i + 1, cells: text.split("\t") });
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
function appendRow(file: string, cells: readonly string[]): number {
|
||||
const dir = path.dirname(file);
|
||||
if (dir && dir !== "." && !fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
// Append the header too, never truncate: on a flaky mount the worst case is
|
||||
// one stray header line, not lost rows.
|
||||
// A file with no header line anywhere (empty, blank, or rows written by hand)
|
||||
// gets one appended once; readRows skips a header line wherever it sits.
|
||||
const size = fs.existsSync(file) ? fs.statSync(file).size : 0;
|
||||
if (size > 0 && !endsWithNewline(file, size)) fs.appendFileSync(file, "\n");
|
||||
if (size === 0 || !hasHeader(file)) fs.appendFileSync(file, HEADER);
|
||||
const ts = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
fs.appendFileSync(file, [ts, ...cells.map(clean)].join("\t") + "\n");
|
||||
return readRows(file).length;
|
||||
}
|
||||
|
||||
function hasHeader(file: string): boolean {
|
||||
return fs.readFileSync(file, "utf8").replace(/^\uFEFF/, "").split("\n")
|
||||
.some(line => line.replace(/\r$/, "") === HEADER.trimEnd());
|
||||
}
|
||||
|
||||
function endsWithNewline(file: string, size: number): boolean {
|
||||
const fd = fs.openSync(file, "r");
|
||||
try {
|
||||
const last = Buffer.alloc(1);
|
||||
fs.readSync(fd, last, 0, 1, size - 1);
|
||||
return last[0] === 0x0a;
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
const RUN_ID = /^[A-Za-z0-9._:@/-]+$/;
|
||||
function checkRunId(runId: string): void {
|
||||
if (!RUN_ID.test(runId)) fail(`run id must match ${RUN_ID} (letters, digits, . _ : @ / -), got ${JSON.stringify(runId)}`, 2);
|
||||
|
||||
@@ -394,6 +394,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"
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
# gstack-diff-scope — categorize what changed in the diff against a base branch
|
||||
# Usage: source <(gstack-diff-scope main) → sets SCOPE_FRONTEND=true SCOPE_BACKEND=false ...
|
||||
# Or: gstack-diff-scope main → prints SCOPE_*=... lines
|
||||
# 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).
|
||||
#
|
||||
# Output contract (#2526 — all-false must be distinguishable from "we could
|
||||
# not look" and from "nothing matched"):
|
||||
@@ -31,6 +34,8 @@ _default_base() {
|
||||
echo "main"
|
||||
}
|
||||
BASE="${1:-$(_default_base)}"
|
||||
HEAD_REF=""
|
||||
if [ "${2:-}" = "--head" ]; then HEAD_REF="${3:-}"; fi
|
||||
|
||||
FRONTEND=false
|
||||
BACKEND=false
|
||||
@@ -79,6 +84,21 @@ _collect() {
|
||||
[ -n "$f" ] && FILES_LIST+=("$f")
|
||||
done
|
||||
}
|
||||
if [ -n "$HEAD_REF" ] || [ "${2:-}" = "--head" ]; then
|
||||
if ! git rev-parse --verify -q "${HEAD_REF}^{commit}" >/dev/null 2>&1; then
|
||||
_print_flags
|
||||
echo "SCOPE_ERROR=no_head"
|
||||
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
|
||||
_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)
|
||||
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)
|
||||
# Working-tree changes (staged + unstaged). `git diff HEAD` fails on a repo
|
||||
@@ -87,6 +107,7 @@ _collect < <(git diff -z HEAD --name-only 2>/dev/null || true)
|
||||
# Untracked files: a brand-new component/migration/test is exactly what a
|
||||
# reviewer should see, and /ship commits it in Step 15 regardless.
|
||||
_collect < <(git ls-files -z --others --exclude-standard 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
if [ "${#FILES_LIST[@]}" -eq 0 ]; then
|
||||
_print_flags
|
||||
|
||||
@@ -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,26 @@
|
||||
* selected paths are the committed, staged, unstaged and untracked paths.
|
||||
* --docs takes files or directories (directories expand to their tracked and
|
||||
* untracked files). It prints a short summary; the JSON file is the record.
|
||||
* --exception names the blocked .gstack/tmp/docs-audit.json an owner accepted
|
||||
* (Step 14.5 Blocked recovery): the record then carries that exception, so
|
||||
* Step 16 can tell an accepted risk from a skipped audit.
|
||||
*
|
||||
* compare re-reads the same state and prints one JSON object: whether HEAD or
|
||||
* the index changed, which recorded paths' content changed or disappeared, and
|
||||
* 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 { 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 +94,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 +121,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 +135,8 @@ function main(argv: string[]): void {
|
||||
const mode = one(args, '--mode');
|
||||
if (mode !== 'edit' && mode !== 'read-only') throw new Error('--mode must be edit or read-only');
|
||||
const base = git(['rev-parse', '--verify', one(args, '--base') + '^{commit}']).trim();
|
||||
const exceptionFile = flag(args, '--exception')[0];
|
||||
const exception = exceptionFile === undefined ? undefined : acceptedException(exceptionFile);
|
||||
process.chdir(top);
|
||||
const now = state(base);
|
||||
const listed = nul(git(['ls-files', '-z', '--cached', '--others', '--exclude-standard']));
|
||||
@@ -119,10 +144,11 @@ function main(argv: string[]): void {
|
||||
: [...new Set([...now.committed, ...now.staged, ...now.unstaged, ...now.untracked])].sort();
|
||||
const docs = expand(listed, flag(args, '--docs'));
|
||||
const tracked = [...new Set([...selected, ...docs, ...now.staged, ...now.unstaged, ...now.untracked])].sort();
|
||||
const record = { schema_version: 1, audit_id: one(args, '--audit-id'), mode, base, ...now, selected, docs, hashes: hashes(tracked) };
|
||||
const record = { schema_version: 1, audit_id: one(args, '--audit-id'), mode, base, ...now, selected, docs, hashes: hashes(tracked), ...(exception ? { exception } : {}) };
|
||||
fs.writeFileSync(out, JSON.stringify(record, null, 2) + '\n', { mode: 0o600 });
|
||||
console.log(`CANDIDATE: ${out}`);
|
||||
console.log(`base=${base} head=${now.head} branch=${now.branch || '(detached)'} mode=${mode}`);
|
||||
if (exception) console.log(`EXCEPTION: accepted by ${exception.accepted_by} ${exception.date} via ${exception.via}`);
|
||||
console.log(`committed=${now.committed.length} staged=${now.staged.length} unstaged=${now.unstaged.length} untracked=${now.untracked.length} selected=${selected.length} docs=${docs.length} hashed=${tracked.length}`);
|
||||
for (const [label, list] of [['committed', now.committed], ['staged', now.staged], ['unstaged', now.unstaged], ['untracked', now.untracked], ['docs', docs]] as const) {
|
||||
if (list.length) console.log(`${label}: ${list.map(p => JSON.stringify(p)).join(' ')}`);
|
||||
@@ -160,6 +186,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
@@ -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
|
||||
|
||||
@@ -8,7 +8,7 @@ GSTACK_DOCTOR_COMPONENTS="claude codex patch codex-cli browse-bundle browser cso
|
||||
GSTACK_DOCTOR_CORE="runtime pins state-root privacy cores revision floor wrapper"
|
||||
GSTACK_DOCTOR_HOSTS="claude codex"
|
||||
GSTACK_DOCTOR_OPTIONAL="codex-cli cso"
|
||||
GSTACK_DOCTOR_SKILLS="autoplan plan-ceo-review plan-eng-review plan-design-review plan-devex-review plan-tune office-hours spec review ship investigate codex cso browse qa qa-only design-review design-consultation design-html design-motion devex-review scrape skillify canary benchmark make-pdf diagram pair-agent open-gstack-browser setup-browser-cookies connect-chrome land-and-deploy benchmark-models captain careful claude-code context-restore context-save design-shotgun deslop-shared-libs document-generate document-release eval-plan freeze gstack-upgrade guard health ios-clean ios-design-review ios-fix ios-qa ios-sync landing-report learn write-skill modernize retro setup-deploy setup-gbrain sync-gbrain test-audit ubicloud unfreeze review-voice"
|
||||
GSTACK_DOCTOR_SKILLS="autoplan plan-ceo-review plan-eng-review plan-design-review plan-devex-review plan-tune office-hours spec review ship investigate codex cso browse qa qa-only design-review design-consultation design-html design-motion devex-review scrape skillify canary benchmark make-pdf diagram pair-agent open-gstack-browser setup-browser-cookies connect-chrome land-and-deploy benchmark-models captain careful claude-code context-restore context-save design-shotgun deslop-shared-libs document-generate document-release eval-plan freeze gstack-upgrade guard health ios-clean ios-design-review ios-fix ios-qa ios-sync landing-report learn write-skill modernize retro setup-deploy setup-gbrain sync-gbrain test-audit ubicloud unfreeze verify review-voice"
|
||||
GSTACK_BROWSER_LAZY_REASON="lazy; gstack-browser-ensure installs on first use"
|
||||
GSTACK_DOCTOR_HELPERS="gstack-pregate gstack-plan-reality gstack-artifact gstack-autoplan"
|
||||
GSTACK_DOCTOR_NOT_NEEDED="none"
|
||||
@@ -69,7 +69,7 @@ gstack_doctor_skill_requires() {
|
||||
codex) printf '%s' 'claude codex patch codex-cli' ;;
|
||||
cso) printf '%s' 'claude cso' ;;
|
||||
browse|qa|qa-only|design-review|design-consultation|design-html|design-motion|devex-review|scrape|skillify|canary|benchmark|make-pdf|diagram|pair-agent|open-gstack-browser|setup-browser-cookies|connect-chrome|land-and-deploy) printf '%s' 'claude codex patch browse-bundle browser' ;;
|
||||
benchmark-models|captain|careful|claude-code|context-restore|context-save|design-shotgun|deslop-shared-libs|document-generate|document-release|eval-plan|freeze|gstack-upgrade|guard|health|ios-clean|ios-design-review|ios-fix|ios-qa|ios-sync|landing-report|learn|write-skill|modernize|retro|setup-deploy|setup-gbrain|sync-gbrain|test-audit|ubicloud|unfreeze) printf '%s' 'claude' ;;
|
||||
benchmark-models|captain|careful|claude-code|context-restore|context-save|design-shotgun|deslop-shared-libs|document-generate|document-release|eval-plan|freeze|gstack-upgrade|guard|health|ios-clean|ios-design-review|ios-fix|ios-qa|ios-sync|landing-report|learn|write-skill|modernize|retro|setup-deploy|setup-gbrain|sync-gbrain|test-audit|ubicloud|unfreeze|verify) printf '%s' 'claude' ;;
|
||||
review-voice) printf '%s' 'helpers' ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
@@ -82,7 +82,7 @@ gstack_doctor_skill_optional() {
|
||||
codex) printf '%s' '' ;;
|
||||
cso) printf '%s' '' ;;
|
||||
browse|qa|qa-only|design-review|design-consultation|design-html|design-motion|devex-review|scrape|skillify|canary|benchmark|make-pdf|diagram|pair-agent|open-gstack-browser|setup-browser-cookies|connect-chrome|land-and-deploy) printf '%s' 'codex-cli' ;;
|
||||
benchmark-models|captain|careful|claude-code|context-restore|context-save|design-shotgun|deslop-shared-libs|document-generate|document-release|eval-plan|freeze|gstack-upgrade|guard|health|ios-clean|ios-design-review|ios-fix|ios-qa|ios-sync|landing-report|learn|write-skill|modernize|retro|setup-deploy|setup-gbrain|sync-gbrain|test-audit|ubicloud|unfreeze) printf '%s' '' ;;
|
||||
benchmark-models|captain|careful|claude-code|context-restore|context-save|design-shotgun|deslop-shared-libs|document-generate|document-release|eval-plan|freeze|gstack-upgrade|guard|health|ios-clean|ios-design-review|ios-fix|ios-qa|ios-sync|landing-report|learn|write-skill|modernize|retro|setup-deploy|setup-gbrain|sync-gbrain|test-audit|ubicloud|unfreeze|verify) printf '%s' '' ;;
|
||||
review-voice) printf '%s' '' ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
@@ -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' ;;
|
||||
@@ -194,7 +195,6 @@ gstack_result_anchor() {
|
||||
ISSUE_UNREADABLE) printf '%s' 'issue-unreadable' ;;
|
||||
ISSUE_LOOKUP_FAILED) printf '%s' 'issue-lookup-failed' ;;
|
||||
ISSUE_LINKS_MISSING) printf '%s' 'issue-links-missing' ;;
|
||||
PREREG_INCOMPLETE) printf '%s' 'prereg-incomplete' ;;
|
||||
EVAL_PRICE_MISSING) printf '%s' 'eval-price-missing' ;;
|
||||
EVAL_DRY_RUN_FAILED) printf '%s' 'eval-dry-run-failed' ;;
|
||||
EVAL_ARMS_UNRESOLVED) printf '%s' 'eval-arms-unresolved' ;;
|
||||
@@ -240,7 +240,6 @@ gstack_result_anchor() {
|
||||
OUTSIDE_VOICE_UNAVAILABLE) printf '%s' 'outside-voice-unavailable' ;;
|
||||
ARTIFACT_MACHINE_PATH) printf '%s' 'artifact-machine-path' ;;
|
||||
ARTIFACT_PUBLIC_TARGET_EXISTS) printf '%s' 'artifact-public-target-exists' ;;
|
||||
PREREG_DIAGNOSTIC_UNTESTED) printf '%s' 'prereg-diagnostic-untested' ;;
|
||||
EVAL_MODELS_INVALID) printf '%s' 'eval-models-invalid' ;;
|
||||
EVAL_MODEL_DENIED) printf '%s' 'eval-model-denied' ;;
|
||||
EVAL_MODEL_OLD_GENERATION) printf '%s' 'eval-model-old-generation' ;;
|
||||
@@ -281,11 +280,34 @@ gstack_result_anchor() {
|
||||
QUEUE_TRAIN_BISECT_CAPPED) printf '%s' 'queue-train-bisect-capped' ;;
|
||||
QUEUE_TRAIN_PREFIX_INVALID) printf '%s' 'queue-train-prefix-invalid' ;;
|
||||
QUEUE_TRAIN_REBUILD_REFUSED) printf '%s' 'queue-train-rebuild-refused' ;;
|
||||
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' ;;
|
||||
BASE_RED_UNCONFIGURED) printf '%s' 'base-red-unconfigured' ;;
|
||||
BASE_RED_UNNAMED) printf '%s' 'base-red-unnamed' ;;
|
||||
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' ;;
|
||||
QUEUE_RECEIPT_DEPTH_RAISED) printf '%s' 'queue-receipt-depth-raised' ;;
|
||||
QUEUE_RECEIPT_EXCEPTION_INVALID) printf '%s' 'queue-receipt-exception-invalid' ;;
|
||||
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' ;;
|
||||
@@ -306,13 +328,22 @@ gstack_result_anchor() {
|
||||
REVIEW_SCOPE_RANGE) printf '%s' 'review-scope-range' ;;
|
||||
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_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' ;;
|
||||
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' ;;
|
||||
@@ -328,6 +359,22 @@ gstack_result_anchor() {
|
||||
PROJECT_FLOOR_UNMET) printf '%s' 'project-floor-unmet' ;;
|
||||
PROJECT_NODE_PIN_UNMET) printf '%s' 'project-node-pin-unmet' ;;
|
||||
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' ;;
|
||||
BUDGET_CHECK_SKIPPED) printf '%s' 'budget-check-skipped' ;;
|
||||
HANDOFF_PIN_MOVED) printf '%s' 'handoff-pin-moved' ;;
|
||||
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' ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
+32
-10
@@ -6,10 +6,11 @@
|
||||
* 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,model> --dry-run <dry-run.json> [--n 3]
|
||||
* 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 { checkPrereg, DEFAULT_TOKENS, dryRun, pilotReport, preregTemplate, priceGate, releasePilot, renderArms, renderDryRun, renderGatePrice, renderPilotReport, renderPreregDiagnostics, resolveArms, resolvePlanSource, runPreregDiagnostics, settlePilot, startPilot } from '../lib/eval-plan';
|
||||
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,7 +43,8 @@ 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
|
||||
@@ -50,9 +53,14 @@ const HELP = `gstack-eval-plan — preregistration, priced arms, $0 dry run, pri
|
||||
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]
|
||||
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 --dry-run dry-run.json [--n 3]
|
||||
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
|
||||
@@ -90,7 +98,7 @@ function lintModels(args: string[], uses: Array<{ model: string; scope: EvalScop
|
||||
if (from !== 'base' && from !== 'head') throw new Error('--models-from needs base or head');
|
||||
const top = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', timeout: 30_000 });
|
||||
const repoRoot = top.status === 0 ? top.stdout.trim() : null;
|
||||
const check = checkModelRules(repoRoot, uses, { from, base: repoRoot ? resolveBaseBranch(repoRoot, flag(args, '--base')) : 'main' });
|
||||
const check = checkModelRules(repoRoot, uses.map(u => (armModel(u.model) === u.model ? u : { ...u, model: armModel(u.model), where: `${u.where} (${u.model})` })), { from, base: repoRoot ? resolveBaseBranch(repoRoot, flag(args, '--base')) : 'main' });
|
||||
if (!args.includes('--json')) process.stdout.write(check.lines.join('\n') + '\n');
|
||||
for (const e of check.errors) process.stderr.write(resultLine(`gstack-eval-plan ${command}`, e.code, e.message) + '\n');
|
||||
return check;
|
||||
@@ -116,17 +124,30 @@ function doPrereg(rest: string[]): number {
|
||||
const run = runnable && allowed ? runPreregDiagnostics(d, repoRoot) : null;
|
||||
if (rest.includes('--json')) process.stdout.write(JSON.stringify({ file, ...r, diagnostics_run: run }) + '\n');
|
||||
else {
|
||||
process.stdout.write(`PREREG: ${r.ok ? 'ok' : 'incomplete'} present=${r.present.length}/5${r.missing.length ? ` missing=${r.missing.join(',')}` : ''}${r.empty.length ? ` empty=${r.empty.join(',')}` : ''}${d ? ` diagnostics=${d.errors.length ? 'invalid' : d.expected.length}` : ''} file=${file}\n`);
|
||||
process.stdout.write(`PREREG: ${r.ok ? 'ok' : 'incomplete'} present=${r.present.length}/${PREREG_SECTIONS.length}${r.missing.length ? ` missing=${r.missing.join(',')}` : ''}${r.empty.length ? ` empty=${r.empty.join(',')}` : ''}${r.comparison.stack ? ` stack=${r.comparison.stack}` : ''}${r.no_gstack.required ? ` no_gstack_arm=${r.no_gstack.ok ? 'ok' : r.no_gstack.problems[0] === 'missing section' ? 'missing' : 'incomplete'}` : ''}${d ? ` diagnostics=${d.errors.length ? 'invalid' : d.expected.length}` : ''} file=${file}\n`);
|
||||
if (runnable && !allowed) process.stdout.write(`PREREG_COMMAND: summarizer=${JSON.stringify(d.summarizer)} fixture=${d.fixture} cwd=${repoRoot}\n`);
|
||||
if (run) process.stdout.write(renderPreregDiagnostics(run));
|
||||
}
|
||||
if (!r.ok) process.stderr.write(resultLine('gstack-eval-plan prereg', 'PREREG_INCOMPLETE', [...r.missing.map(m => `missing ${m}`), ...r.empty.map(e => `empty ${e}`), ...(d?.errors ?? []).map(e => `diagnostics: ${e}`)].join('; ')) + '\n');
|
||||
const incomplete = [...r.missing.map(m => `missing ${m}`), ...r.empty.map(e => `empty ${e}`), ...r.comparison.errors.map(e => `comparison: ${e}`), ...(d?.errors ?? []).map(e => `diagnostics: ${e}`)];
|
||||
if (incomplete.length) process.stderr.write(resultLine('gstack-eval-plan prereg', 'PREREG_INCOMPLETE', incomplete.join('; ')) + '\n');
|
||||
if (!r.no_gstack.ok) process.stderr.write(resultLine('gstack-eval-plan prereg', 'PREREG_NO_GSTACK_ARM', `no-gstack arm: ${r.no_gstack.problems.join('; ')}`) + '\n');
|
||||
if (run && !run.ok) process.stderr.write(resultLine('gstack-eval-plan prereg', 'PREREG_DIAGNOSTIC_UNTESTED', run.error ?? run.rows.filter(row => row.status !== 'ok').map(row => `${row.name} ${row.status} (expected ${row.expected ?? 'undeclared'}, got ${row.got ?? 'none'})`).join('; ')) + '\n');
|
||||
if (!r.ok || (run && !run.ok)) return EXIT.fail;
|
||||
if (runnable && !allowed) { process.stderr.write(resultLine('gstack-eval-plan prereg', 'REPO_COMMANDS_NOT_ALLOWED', `the Diagnostics section declares a summarizer; it runs only with --allow-repo-commands, and the preregistration is not checked until it does`) + '\n'); return EXIT.refused; }
|
||||
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);
|
||||
@@ -166,7 +187,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`);
|
||||
@@ -240,6 +261,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; }
|
||||
|
||||
+11
-3
@@ -14,7 +14,7 @@
|
||||
* `run` is a TRANSPARENT wrapper: it streams the child's output through
|
||||
* unchanged, tees it to a 0600 log (2MB cap with a truncation marker), and
|
||||
* appends {ts, label, command, cmd_sha256, exit, duration_s, commit, tree,
|
||||
* dirty, wtree, log_path, tests_ran} to ~/.gstack/projects/<slug>/<branch>-evidence.jsonl.
|
||||
* dirty, wtree or wtree_changed, log_path, tests_ran} to ~/.gstack/projects/<slug>/<branch>-evidence.jsonl.
|
||||
*
|
||||
* `tests_ran` (zero-run green, W3 of docs/designs/HONEST_WORK_GATE_INTEGRITY.md)
|
||||
* is parsed from a bounded tail of the streamed output for the supported
|
||||
@@ -89,6 +89,8 @@ interface EvidenceRecord {
|
||||
tree?: string;
|
||||
dirty?: boolean;
|
||||
wtree?: string;
|
||||
/** Up to five paths whose content changed between the before-run and after-run fingerprints (set instead of wtree). */
|
||||
wtree_changed?: string[];
|
||||
log_path?: string;
|
||||
redacted?: boolean;
|
||||
/** The runner's own test count (executed for bun/jest/vitest/pytest/rspec; ran with skips included for gstack's shard runner and the remote gate), 0 for ZERO-RUN, or "unknown" when no supported summary was seen. */
|
||||
@@ -524,7 +526,10 @@ function record(
|
||||
if (wtreeBefore && wtreeAfter && wtreeBefore === wtreeAfter) {
|
||||
rec.wtree = wtreeAfter;
|
||||
} else if (wtreeBefore || wtreeAfter) {
|
||||
warn("working-tree content changed during the run — evidence recorded without a content fingerprint (will grade STALE)");
|
||||
const changed = wtreeBefore && wtreeAfter ? (git(["diff", "--name-only", wtreeBefore, wtreeAfter]) ?? "").split("\n").map((s) => s.trim()).filter(Boolean) : [];
|
||||
if (changed.length) rec.wtree_changed = changed.slice(0, 5);
|
||||
const named = changed.length ? `: ${changed.slice(0, 5).join(", ")}${changed.length > 5 ? ", ..." : ""}` : "";
|
||||
warn(`working-tree content changed during the run${named} — evidence recorded without a content fingerprint (will grade STALE)`);
|
||||
}
|
||||
}
|
||||
if (logPath) rec.log_path = logPath;
|
||||
@@ -599,6 +604,7 @@ function cmdCheck(argv: string[]): number {
|
||||
continue;
|
||||
}
|
||||
const testsRan = typeof latest.tests_ran === "number" ? latest.tests_ran : "unknown";
|
||||
const changedDuringRun = Array.isArray(latest.wtree_changed) ? latest.wtree_changed.filter((p): p is string => typeof p === "string" && p !== "" && !/[\u0000-\u001f\u007f]/.test(p)).slice(0, 5) : [];
|
||||
const detail = `label=${label} exit=${latest.exit} tests_ran=${testsRan} ts=${latest.ts}${latest.log_path ? ` log=${latest.log_path}` : ""}`;
|
||||
let verdict: "FRESH" | "STALE" | "ZERO-RUN" = "FRESH";
|
||||
let reason = "";
|
||||
@@ -631,7 +637,9 @@ function cmdCheck(argv: string[]): number {
|
||||
// forged/corrupt ledger line must degrade, never inject options.
|
||||
verdict = "STALE";
|
||||
reason = !latest.wtree
|
||||
? "record has no content fingerprint"
|
||||
? changedDuringRun.length
|
||||
? `content changed during the run: ${changedDuringRun.join(", ")}`
|
||||
: "record has no content fingerprint"
|
||||
: !/^[0-9a-f]{40}$/.test(latest.wtree)
|
||||
? "record has malformed fingerprint"
|
||||
: "current fingerprint unavailable";
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
* [--out-dir <dir>] [--csv <file>] [--json] [--cache-dir <dir>]
|
||||
* [--fixtures <recorded gh responses.json>] [--now <iso>]
|
||||
*
|
||||
* The window is [now - since-days, now): --now (default: the current time) is
|
||||
* its end, and every line counts only items inside it.
|
||||
* --repo defaults to the GitHub owner/name of the origin remote; without one,
|
||||
* pass it. Every run writes this window's scorecard.{csv,md} and appends its
|
||||
* rows to trend.csv in --out-dir, so the weeks line up.
|
||||
|
||||
+47
-3
@@ -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"
|
||||
|
||||
Executable
+125
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
||||
/**
|
||||
* gstack-flaky (GSTA-158): rerun failed tests once and record the flake, for
|
||||
* any runner that writes JUnit (Playwright, bun, jest, pytest, vitest). CREW
|
||||
* rule 16 for every repository: "you can just run the failed test again and
|
||||
* note that it is flaky". The rules live in lib/flaky-run.ts; docs/flaky.md is
|
||||
* the how-to and the error-code table.
|
||||
*/
|
||||
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';
|
||||
|
||||
const USAGE = `Usage:
|
||||
gstack-flaky run --junit <file.xml> [--runner playwright|bun|generic] [--max-reruns <n>]
|
||||
[--rerun-scope command|test] [--ledger <file>] -- <test command...>
|
||||
gstack-flaky ids --junit <file> [--runner playwright|bun|generic]
|
||||
gstack-flaky --help
|
||||
|
||||
run: runs the command (an argv, no shell), reads the JUnit report it writes at
|
||||
--junit, and reruns each failed test once when the runner made no retries and
|
||||
at most --max-reruns (${DEFAULT_MAX_RERUNS}) tests failed. The rerun repeats the whole
|
||||
command and counts only on the runner's full green verdict (exit 0, no failed
|
||||
or missing test). --rerun-scope test reruns the failed test alone (playwright:
|
||||
<file> --grep <title> --project <p>; bun: <file> --test-name-pattern <title>)
|
||||
when the runner's summary proves the first run complete and nothing but the
|
||||
failed tests can fail it (no coverage, no runner-flaky test); otherwise, and
|
||||
always for generic runners, the whole command reruns. A test that passes its
|
||||
rerun once, with the rerun exiting 0, is a flaky pass: green, and one line on the ledger (--ledger, else
|
||||
GSTACK_FLAKE_LEDGER_JUNIT, else <state root>/flake-ledger-junit.jsonl). --junit
|
||||
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.
|
||||
|
||||
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>
|
||||
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).
|
||||
`;
|
||||
|
||||
const FIX: Record<string, string> = {
|
||||
FLAKY_NO_RESULTS: 'make the command write JUnit to the --junit path (playwright: PLAYWRIGHT_JUNIT_OUTPUT_NAME=<file> with --reporter=junit; bun: --reporter=junit --reporter-outfile=<file>)',
|
||||
FLAKY_JUNIT_INVALID: 'the report is not well-formed XML; check that one process writes it and nothing else appends to it',
|
||||
FLAKY_RETRIES_HIDDEN: 'set PLAYWRIGHT_JUNIT_INCLUDE_RETRIES=1 where the report is written',
|
||||
FLAKY_COMMAND_FAILED: 'read the command output above: the suite crashed or a non-test step failed; that is a real red',
|
||||
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',
|
||||
};
|
||||
|
||||
function die(msg: string, code = 2): never {
|
||||
process.stderr.write(`gstack-flaky: ${msg}\n`);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
function coded(e: JunitError): never {
|
||||
process.stderr.write(`gstack-flaky: ${e.message} (${e.code})\nfix: ${FIX[e.code] ?? 'see docs/flaky.md'}\n`);
|
||||
process.exit(3);
|
||||
}
|
||||
|
||||
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: [] };
|
||||
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; };
|
||||
if (a === '--') { o.command = argv.slice(i + 1); break; }
|
||||
if (a === '--junit') o.junit = val();
|
||||
else if (a === '--runner') {
|
||||
const r = val();
|
||||
if (r !== 'playwright' && r !== 'bun' && r !== 'generic') die(`--runner must be playwright, bun or generic, got ${JSON.stringify(r)}`);
|
||||
o.runner = r;
|
||||
} else if (a === '--max-reruns') {
|
||||
const n = Number(val());
|
||||
if (!Number.isInteger(n) || n < 0) die('--max-reruns must be a whole number');
|
||||
o.maxReruns = n;
|
||||
} else if (a === '--rerun-scope') {
|
||||
const v = val();
|
||||
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 die(`unknown option ${a}\n${USAGE}`);
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
const [sub, ...rest] = process.argv.slice(2);
|
||||
if (!sub || sub === '--help' || sub === '-h') { process.stdout.write(USAGE); process.exit(sub ? 0 : 2); }
|
||||
|
||||
if (sub === 'ids') {
|
||||
const o = parse(rest);
|
||||
if (!o.junit) die('ids needs --junit <file>');
|
||||
try {
|
||||
for (const t of readJunit(o.junit, o.runner).tests) process.stdout.write(`${safeText(t.id)}\n`);
|
||||
} catch (e) { if (e instanceof JunitError) coded(e); throw e; }
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (sub !== 'run') die(`unknown command ${sub}\n${USAGE}`);
|
||||
const o = parse(rest);
|
||||
if (!o.junit) die('run needs --junit <file.xml>');
|
||||
if (!/\.xml$/i.test(o.junit) || fs.statSync(o.junit, { throwIfNoEntry: false })?.isDirectory()) die(`--junit must name a .xml file (it is deleted before every attempt), got ${JSON.stringify(o.junit)}`);
|
||||
let existing: string | false | undefined;
|
||||
try { existing = fs.statSync(o.junit, { throwIfNoEntry: false }) && fs.readFileSync(o.junit, 'utf-8').slice(0, 4096); } catch (e) {
|
||||
die(`--junit ${JSON.stringify(o.junit)} exists but cannot be read (${(e as Error).message}); gstack-flaky deletes that path before every attempt, so it refuses to touch it`);
|
||||
}
|
||||
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 result;
|
||||
try {
|
||||
result = await runFlaky({ command: o.command, junit: o.junit, runner: o.runner, maxReruns: o.maxReruns, rerunScope: o.rerunScope });
|
||||
} 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`));
|
||||
for (const l of renderResult(result, ledger)) process.stdout.write(`${l}\n`);
|
||||
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`);
|
||||
}
|
||||
if (result.code) {
|
||||
process.stderr.write(`gstack-flaky: ${result.message} (${result.code})\nfix: ${FIX[result.code] ?? 'see docs/flaky.md'}\n`);
|
||||
}
|
||||
process.exit(result.exitCode);
|
||||
+167
-42
@@ -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(", ")}`);
|
||||
}
|
||||
|
||||
+32
-11
@@ -8,7 +8,16 @@
|
||||
*
|
||||
* Usage:
|
||||
* gstack-harness detect [--json]
|
||||
* gstack-harness register <target> [--skills router|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
|
||||
@@ -20,7 +29,7 @@ import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { resolveStateRoot } from '../lib/state-root';
|
||||
import {
|
||||
HarnessError, TARGETS, detect, getTarget, harnessSees, listLedgers, printPayload, refresh, register, sourceSkills, status, unregister,
|
||||
HarnessError, TARGETS, contextScope, detect, getTarget, harnessSees, listLedgers, printPayload, refresh, register, sourceSkills, status, unregister,
|
||||
type RegisterResult, type SkillsMode, type TargetName,
|
||||
} from '../lib/harness-register';
|
||||
import { registerQm, removeQmPack } from '../lib/harness-qm';
|
||||
@@ -38,15 +47,23 @@ const self = path.join(checkout, 'bin', 'gstack-harness');
|
||||
|
||||
function receiptLines(r: RegisterResult): string[] {
|
||||
const t = TARGETS[r.target];
|
||||
const how = { router: 'reachable through one router skill', hybrid: 'registered, the task skills listed directly and the rest through one router skill', all: 'registered' }[r.mode];
|
||||
return [
|
||||
'gstack-harness: ' + t.displayName + ': ' + r.skills + ' gstack skills ' + (r.mode === 'router' ? 'reachable through one router skill' : 'registered') + ' at ' + r.dest + ' (' + r.status + (r.written ? ', ' + r.written + ' files written' : '') + ')',
|
||||
...(r.router ? [' router: ' + r.router + (r.mode === 'router' ? ' (register with --skills all to list every skill natively)' : '')] : []),
|
||||
'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');
|
||||
@@ -69,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') {
|
||||
@@ -83,10 +101,11 @@ async function main(): Promise<number> {
|
||||
console.log('gstack-harness: ' + t.displayName + ': unverified (' + (seen.checked ? seen.checked + ' did not list gstack' : t.name + ' is not on PATH') + '). Install gstack for Claude Code (./setup), or paste the block from: ' + self + ' register generic --print');
|
||||
return 3;
|
||||
}
|
||||
const r = register({ ...base, target: t, mode, skillsDir, context, registry: !noRegistry });
|
||||
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, 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');
|
||||
@@ -108,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);
|
||||
|
||||
@@ -13,16 +13,32 @@
|
||||
# uninstall of the state root and the checkout. Only paths below the recorded
|
||||
# destination, reached through no symlink, are touched. A context block is cut
|
||||
# only when its file holds exactly one whole-line begin/end marker pair whose
|
||||
# lines hash to the ledger's block hash; otherwise the file is left as it is
|
||||
# (the file is removed only when nothing else is left in it). Folders gstack
|
||||
# lines hash to the ledger's block hash; otherwise the file is left as it is.
|
||||
# Its blockpad row says what gstack added before the block, so the cut restores
|
||||
# the file byte for byte: the blank line (and newline) it appended are removed
|
||||
# too, and a file gstack created is removed when nothing else is left in it
|
||||
# (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"
|
||||
@@ -33,7 +49,7 @@ _gstack_harness_owned() {
|
||||
}
|
||||
|
||||
_gstack_harness_cut_block() {
|
||||
local file="$1" want="$2" span s e tmp
|
||||
local file="$1" want="$2" pad="${3:-}" span s e tmp
|
||||
local begin='<!-- gstack:begin harness-routing -->' end='<!-- gstack:end harness-routing -->'
|
||||
{ [ -f "$file" ] && [ ! -L "$file" ]; } || return 0
|
||||
span="$(awk -v B="$begin" -v E="$end" '$0 == B { nb++; s = NR } $0 == E { ne++; e = NR } END { if (nb == 1 && ne == 1 && e > s) print s, e; else if (nb || ne) print "bad" }' "$file")"
|
||||
@@ -44,29 +60,75 @@ _gstack_harness_cut_block() {
|
||||
return 0
|
||||
fi
|
||||
tmp="$file.gstack-cut.$$"
|
||||
awk -v s="$s" -v e="$e" 'NR < s || NR > e' "$file" > "$tmp" || { rm -f "$tmp"; return 1; }
|
||||
if [ -z "$(tr -d '[:space:]' < "$tmp")" ]; then rm -f "$file"; else cat "$tmp" > "$file"; fi
|
||||
awk -v s="$s" -v e="$e" -v pad="$pad" '{ line[NR] = $0 }
|
||||
END {
|
||||
from = s; if ((pad == "1" || pad == "2") && s > 1 && line[s - 1] == "") from = s - 1
|
||||
n = 0; for (i = 1; i <= NR; i++) if (i < from || i > e) out[++n] = line[i]
|
||||
for (i = 1; i <= n; i++) { printf "%s", out[i]; if (i < n || !(pad == "2" && e == NR)) printf "\n" }
|
||||
}' "$file" > "$tmp" || { rm -f "$tmp"; return 1; }
|
||||
if { [ "$pad" = new ] && [ ! -s "$tmp" ]; } || { [ -z "$pad" ] && [ -z "$(tr -d '[:space:]' < "$tmp")" ]; }; then rm -f "$file"; else cat "$tmp" > "$file"; fi
|
||||
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=""
|
||||
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 ;;
|
||||
block) _gstack_harness_cut_block "$b" "$a" ;;
|
||||
blockpad) pad="$a"; padpath="$b" ;;
|
||||
block) if [ "$padpath" = "$b" ]; then _gstack_harness_cut_block "$b" "$a" "$pad"; else _gstack_harness_cut_block "$b" "$a"; fi ;;
|
||||
esac
|
||||
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
|
||||
|
||||
@@ -24,3 +24,15 @@ gstack_harness_pid() {
|
||||
done
|
||||
printf '%s\n' "$_hp_pid"
|
||||
}
|
||||
|
||||
# Harnesses that run every session on a machine under one process: Capy runs
|
||||
# each tool call as a fresh shell under capy-server, so every session's lease
|
||||
# names the same pid and a caller's own ancestry includes every sibling's
|
||||
# holder. Matched on comm, path and a leading - stripped as above.
|
||||
GSTACK_SHARED_HARNESSES="capy-server"
|
||||
# gstack_shared_harness PID — true when PID's command is a shared harness.
|
||||
gstack_shared_harness() {
|
||||
_sh_comm=$(ps -o comm= -p "$1" 2>/dev/null | head -1) || return 1
|
||||
_sh_comm=${_sh_comm##*/}; _sh_comm=${_sh_comm#-}
|
||||
case " $GSTACK_SHARED_HARNESSES " in *" $_sh_comm "*) [ -n "$_sh_comm" ] ;; *) return 1 ;; esac
|
||||
}
|
||||
@@ -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"; }
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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=$?
|
||||
|
||||
+15
-7
@@ -16,11 +16,14 @@
|
||||
# passes its parent pid); leases older than 120 min are pruned. Exits 3
|
||||
# and writes nothing when a live process holds the directory lock or
|
||||
# the lock cannot be taken
|
||||
# gstack-lease live [--root <checkout>] [--exclude-ancestry-of <pid>]
|
||||
# gstack-lease live [--root <checkout>] [--exclude-ancestry-of <pid>] [--count-shared-harness]
|
||||
# print `<pid> <age>m <command>` per lease held by a live process that
|
||||
# is neither the given pid nor one of its ancestors (the caller's own
|
||||
# session); exit 0 when any live lease exists, 1 when none. A stale lease
|
||||
# is reclaimed (deleted, one `gstack-lease: reclaimed …` line on stderr)
|
||||
# is reclaimed (deleted, one `gstack-lease: reclaimed …` line on stderr).
|
||||
# --count-shared-harness (bin/gstack-upgrade only) still prints an
|
||||
# ancestor that is a shared harness (capy-server: every session on the
|
||||
# machine runs under it) while its lease is under 120 minutes old
|
||||
# gstack-lease list [--root <checkout>]
|
||||
# print `<pid> <age>m alive|dead|stale` for every lease
|
||||
# A pid alone is not a holder: pids are reused after a reboot or a machine
|
||||
@@ -38,23 +41,27 @@ BIN="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
# shellcheck source=bin/gstack-install-verdict.sh
|
||||
. "$BIN/gstack-install-verdict.sh" 2>/dev/null || gstack_source_root() { (cd "$1/.." && pwd -P); }
|
||||
# shellcheck source=bin/gstack-harness-pid.sh
|
||||
. "$BIN/gstack-harness-pid.sh" 2>/dev/null || gstack_harness_pid() { printf '%s\n' "$1"; }
|
||||
. "$BIN/gstack-harness-pid.sh" 2>/dev/null || {
|
||||
gstack_harness_pid() { printf '%s\n' "$1"; }
|
||||
gstack_shared_harness() { [ "$(ps -o comm= -p "$1" 2>/dev/null | head -1 | sed 's|.*/||; s/^-//')" = capy-server ]; }
|
||||
}
|
||||
|
||||
VERB="${1:-}"; [ $# -gt 0 ] && shift
|
||||
ROOT="" PID="" EXCLUDE="" FROM=""
|
||||
ROOT="" PID="" EXCLUDE="" FROM="" SHARED=0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--root) ROOT="${2:-}"; shift ;;
|
||||
--pid) PID="${2:-}"; shift ;;
|
||||
--from) FROM="${2:-}"; shift ;;
|
||||
--exclude-ancestry-of) EXCLUDE="${2:-}"; shift ;;
|
||||
--count-shared-harness) SHARED=1 ;;
|
||||
*) echo "gstack-lease: unknown argument '$1'" >&2; exit 2 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
case "$VERB" in
|
||||
touch|live|list) ;;
|
||||
-h|--help|'') sed -n '2,25p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
-h|--help|'') sed -n '2,28p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "gstack-lease: unknown verb '$VERB'" >&2; exit 2 ;;
|
||||
esac
|
||||
[ -n "$ROOT" ] || ROOT=$(gstack_source_root "$BIN")
|
||||
@@ -290,8 +297,9 @@ FOUND=1
|
||||
for f in "$DIR"/*; do
|
||||
[ -f "$f" ] || continue
|
||||
p=${f##*/}
|
||||
case "$ANCESTRY" in *" $p "*) continue ;; esac
|
||||
_rec=$(head -1 "$f" 2>/dev/null) _age=$(age_min "$f")
|
||||
_age=$(age_min "$f")
|
||||
case "$ANCESTRY" in *" $p "*) [ "$SHARED" -eq 1 ] && [ "$_age" -lt 120 ] && gstack_shared_harness "$p" || continue ;; esac
|
||||
_rec=$(head -1 "$f" 2>/dev/null)
|
||||
case "$(state "$p" "$_rec" "$_age")" in
|
||||
dead) continue ;;
|
||||
stale)
|
||||
|
||||
+14
-17
@@ -7,7 +7,8 @@
|
||||
* gstack-model-benchmark <skill-or-prompt-file> [options]
|
||||
*
|
||||
* Options:
|
||||
* --models claude,gpt,gemini Comma-separated provider list (default: claude)
|
||||
* --models claude,gpt,gemini Comma-separated provider list (default: claude); particle and
|
||||
* deepseek run in Claude Code through OpenRouter (OPENROUTER_API_KEY)
|
||||
* --prompt "<text>" Inline prompt instead of a file
|
||||
* --workdir <path> Working dir passed to each CLI (default: cwd)
|
||||
* --timeout-ms <n> Per-provider timeout (default: 300000)
|
||||
@@ -28,15 +29,7 @@ import '../lib/conductor-env-shim';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { runBenchmark, formatTable, formatJson, formatMarkdown, type BenchmarkInput } from '../test/helpers/benchmark-runner';
|
||||
import { ClaudeAdapter } from '../test/helpers/providers/claude';
|
||||
import { GptAdapter } from '../test/helpers/providers/gpt';
|
||||
import { GeminiAdapter } from '../test/helpers/providers/gemini';
|
||||
|
||||
const ADAPTER_FACTORIES = {
|
||||
claude: () => new ClaudeAdapter(),
|
||||
gpt: () => new GptAdapter(),
|
||||
gemini: () => new GeminiAdapter(),
|
||||
};
|
||||
import { PROVIDERS, PROVIDER_NAMES, isProviderName, type ProviderName } from '../test/helpers/providers/registry';
|
||||
|
||||
type OutputFormat = 'table' | 'json' | 'markdown';
|
||||
|
||||
@@ -76,16 +69,20 @@ function positionalArgs(args: string[]): string[] {
|
||||
return positional;
|
||||
}
|
||||
|
||||
function parseProviders(s: string | undefined): Array<'claude' | 'gpt' | 'gemini'> {
|
||||
function parseProviders(s: string | undefined): ProviderName[] {
|
||||
if (!s) return ['claude'];
|
||||
const seen = new Set<'claude' | 'gpt' | 'gemini'>();
|
||||
const seen = new Set<ProviderName>();
|
||||
for (const p of s.split(',').map(x => x.trim()).filter(Boolean)) {
|
||||
if (p === 'claude' || p === 'gpt' || p === 'gemini') seen.add(p);
|
||||
if (isProviderName(p)) seen.add(p);
|
||||
else {
|
||||
console.error(`WARN: unknown provider '${p}' — skipping. Valid: claude, gpt, gemini.`);
|
||||
console.error(`WARN: unknown provider '${p}' — skipping. Valid: ${PROVIDER_NAMES.join(', ')}.`);
|
||||
}
|
||||
}
|
||||
return seen.size ? Array.from(seen) : ['claude'];
|
||||
if (!seen.size) {
|
||||
console.error(`No valid provider in --models '${s}'. Valid: ${PROVIDER_NAMES.join(', ')}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
return Array.from(seen);
|
||||
}
|
||||
|
||||
function parsePositiveIntegerFlag(name: string, def: string): number {
|
||||
@@ -163,7 +160,7 @@ async function main(): Promise<void> {
|
||||
|
||||
async function dryRunReport(opts: {
|
||||
prompt: string;
|
||||
providers: Array<'claude' | 'gpt' | 'gemini'>;
|
||||
providers: ProviderName[];
|
||||
workdir: string;
|
||||
timeoutMs: number;
|
||||
output: OutputFormat;
|
||||
@@ -181,7 +178,7 @@ async function dryRunReport(opts: {
|
||||
lines.push('Adapter availability:');
|
||||
let authFailures = 0;
|
||||
for (const name of opts.providers) {
|
||||
const factory = ADAPTER_FACTORIES[name];
|
||||
const factory = PROVIDERS[name];
|
||||
if (!factory) {
|
||||
lines.push(` ${name}: UNKNOWN PROVIDER`);
|
||||
authFailures += 1;
|
||||
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
||||
import { modulesMain } from '../scripts/modules-cli.ts';
|
||||
|
||||
void modulesMain(process.argv.slice(2)).then(result => {
|
||||
if (result.stdout) process.stdout.write(result.stdout);
|
||||
if (result.stderr) process.stderr.write(result.stderr);
|
||||
process.exitCode = result.code;
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* gstack-owner-brief — the one-page owner brief at the autoplan gate (plan E3).
|
||||
*
|
||||
* gstack-owner-brief render --out <dir> [--page N] [--summary <file>] [--write] [--json]
|
||||
* gstack-owner-brief render --captain --out <dir> [--allow-incomplete] [--write] [--json]
|
||||
* gstack-owner-brief render --captain --out <dir> [--captain-name <name>] [--allow-incomplete] [--write] [--json]
|
||||
* gstack-owner-brief close --out <dir> --plan <file> [--date YYYY-MM-DD] [--json]
|
||||
*
|
||||
* `render` prints the plain-language summary (--summary <file>, else <dir>/summary.md
|
||||
@@ -12,8 +12,9 @@
|
||||
* taken if unanswered, approval items stay pending) and points at review-record.md
|
||||
* for the full review; --write also saves brief.md in the run directory.
|
||||
* `render --captain` prints the plan ask in the shape Capy Captains relay (lib/captain-brief.ts): header with
|
||||
* the one-word reply, Before → after, Why it matters, What caused this work, Big decisions, What we need from
|
||||
* you, and one trailing line for the relaying agent; a plan missing a section exits 1
|
||||
* the "do it" reply, Before → after, Why it matters, What caused this work, Big decisions, What we need from
|
||||
* you (the lettered ask lines of docs/captain.md § The ask format, headed by --captain-name), and one
|
||||
* trailing line for the relaying agent; a plan missing a section exits 1
|
||||
* (CAPTAIN_BRIEF_INCOMPLETE) unless --allow-incomplete. --write saves captain-brief.md.
|
||||
* `close` rewrites every `draft direction stands until the owner decides` line in
|
||||
* the plan to the decided option and date; pending lines stay and are reported
|
||||
@@ -29,7 +30,7 @@ import { resultLine } from '../lib/result-codes';
|
||||
const HELP = `gstack-owner-brief — one page for the owner: summary, URGENT block, numbered decisions (docs/unattended.md#owner-brief)
|
||||
|
||||
render --out <dir> [--page N] [--summary <file>] [--write] [--json]
|
||||
render --captain --out <dir> [--allow-incomplete] [--write] [--json] the plan ask for a Capy Captain (capy/skill/gstack/captain.md)
|
||||
render --captain --out <dir> [--captain-name <name>] [--allow-incomplete] [--write] [--json] the plan ask for a Capy Captain (capy/skill/gstack/captain.md); --captain-name heads the lettered ask
|
||||
close --out <dir> --plan <file> [--date YYYY-MM-DD] [--json]
|
||||
|
||||
${exitTableHelp()}`;
|
||||
@@ -49,7 +50,7 @@ function main(argv: string[]): number {
|
||||
const json = rest.includes('--json');
|
||||
|
||||
if (cmd === 'render' && rest.includes('--captain')) {
|
||||
const brief = renderCaptainBrief(loaded.inputs, { outDir: path.resolve(out) });
|
||||
const brief = renderCaptainBrief(loaded.inputs, { outDir: path.resolve(out), captainName: flag(rest, '--captain-name') });
|
||||
if (rest.includes('--write')) fs.writeFileSync(path.join(path.resolve(out), 'captain-brief.md'), brief.text);
|
||||
if (json) process.stdout.write(JSON.stringify({ run: loaded.inputs.gate.run, gate_rev: loaded.inputs.gate.gate_rev, rec_hash: brief.recHash, pending: brief.pending, missing: brief.missing, brief: brief.text }) + '\n');
|
||||
else process.stdout.write(brief.text);
|
||||
|
||||
+23
-10
@@ -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}`);
|
||||
}
|
||||
|
||||
+45
-20
@@ -4,9 +4,9 @@
|
||||
*
|
||||
* 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> | --bundle-lane <label> | --receipt <file> [--out <pregate.json>]
|
||||
* 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]
|
||||
@@ -29,6 +29,7 @@ import * as path from 'node:path';
|
||||
import { EXIT, exitTableHelp, formatResult, type PregateFile } from '../lib/headless-artifacts';
|
||||
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 } from '../lib/pregate/index';
|
||||
import { describeLane, discoverLanes } from '../lib/pregate/workflows';
|
||||
@@ -40,7 +41,7 @@ import { DEFAULT_STALL_TIMEOUT_S } from '../lib/pregate/context';
|
||||
import { cancelActive } from '../lib/pregate/supervise';
|
||||
import { defaultGh, evaluateRegate, renderRegate } from '../lib/pregate/regate';
|
||||
import { GATE_AHEAD_REL } from '../lib/restamp';
|
||||
import { evaluateSealed, publishedCommits, rangeCommits, renderSealed } from '../lib/sealed-guard';
|
||||
import { evaluateSealed, publishedCommits, rangeCommits, renderSealed, sealedErrorReport } from '../lib/sealed-guard';
|
||||
import { isUnavailable, loadPolicyFromBase, policyUnavailableLine, resolveBaseBranch } from '../lib/ship-policy';
|
||||
import { fromGateAhead } from '../lib/tree-receipt';
|
||||
|
||||
@@ -52,12 +53,17 @@ const HELP = `gstack-pregate — cheap checks before anything paid or remote (do
|
||||
--receipt <file> (the gate's PREGATE_REMOTE: line, checked against the tested tree; a tree that
|
||||
differs from pregate.json's only in the base branch's stamp rule is accepted and recorded as a
|
||||
substitution, docs/pregate.md#remote-gate-stamp-only); a
|
||||
test_backend/<backend> run URL is recorded as attested, not validated
|
||||
test_backend/<backend> run URL is recorded as attested, not validated. --run <id|url> (repeatable)
|
||||
verifies GitHub Actions runs against the base branch's run_profiles instead (docs/pregate.md#run-evidence):
|
||||
one run of the profile named after the lane, or for pregate.json/remote_gate one run per
|
||||
remote_gate_runs profile covering the gate's inventory (needs --allow-repo-commands); where a lane has a
|
||||
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:
|
||||
run it before any push or PR creation in a repo with a \`sealed\` config; exit 1 refuses (SEALED_OUTPUT_REFUSED)
|
||||
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>]
|
||||
a fresh tree receipt, each lane's reuse verdict, migration collisions (base branch and open PRs), then
|
||||
REGATE: needed|not-needed (docs/pregate.md#regate); \`gstack-pregate --against <head>\` is the same verb; exit 1 = needed
|
||||
@@ -66,20 +72,25 @@ const HELP = `gstack-pregate — cheap checks before anything paid or remote (do
|
||||
--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)
|
||||
--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)
|
||||
--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)
|
||||
--out <file> pregate.json path (default ${PREGATE_OUT_REL})
|
||||
--timeout <s> timeout for each runner process (default 600): the unit-file group, each serial file, each guard
|
||||
without timeout_s; the stall timeout catches a silent one sooner
|
||||
--timeout <s> timeout for each runner process (default timeout_s from the base .gstack/pregate.json, else 600):
|
||||
the unit-file group, each serial file, each guard without timeout_s, the remote gate; the stall
|
||||
timeout catches a silent one sooner
|
||||
--stall-timeout <s> kill a test command's process group after this long with no output and record the check
|
||||
incomplete (PREGATE_STALLED); default stall_timeout_s from the base .gstack/pregate.json,
|
||||
else ${DEFAULT_STALL_TIMEOUT_S}; 0 disables
|
||||
--remote run the base branch's remote_gate now instead of local tests (needs --allow-repo-commands and
|
||||
--policy-from base); it clears the requires-remote obligation only on its
|
||||
else ${DEFAULT_STALL_TIMEOUT_S}; 0 disables; the --remote gate is exempt (only --timeout bounds it)
|
||||
--remote run the base branch's remote_gate now instead of local tests (needs --allow-repo-commands;
|
||||
under --policy-from head the gate is still the base copy's); it clears the requires-remote obligation only on its
|
||||
PREGATE_REMOTE: tree=<tested tree> tests=<n> failed=0 line with n > 0
|
||||
--spend-cap <usd> the cap --remote passes to the gate (default \$GSTACK_SPEND_CAP_USD); spend is reported as
|
||||
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,
|
||||
@@ -98,9 +109,9 @@ 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', '--bundle-lane', '--receipt', '--out'], []],
|
||||
clear: [['--remote', '--run-url', '--run', '--bundle-lane', '--receipt', '--out'], ['--allow-repo-commands']],
|
||||
lanes: [[], ['--explain', '--json']],
|
||||
validate: [[], ['--json']],
|
||||
init: [[], ['--explain', '--force']],
|
||||
@@ -131,6 +142,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; }
|
||||
@@ -138,14 +151,15 @@ async function cmdRun(root: string, rest: string[], json: boolean): Promise<numb
|
||||
if (!['base', 'head'].includes(policyFrom)) { process.stderr.write('gstack-pregate: --policy-from needs base|head\n'); return EXIT.usage; }
|
||||
const allow = rest.includes('--allow-repo-commands');
|
||||
if (policyFrom === 'head' && !allow) { process.stderr.write('gstack-pregate: --policy-from head needs --allow-repo-commands (the working-tree pin files are unreviewed)\n'); return EXIT.refused; }
|
||||
const timeoutMs = Number(flag(rest, '--timeout') ?? 600) * 1000;
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { process.stderr.write('gstack-pregate: --timeout needs a positive number of seconds\n'); return EXIT.usage; }
|
||||
const timeoutFlag = flag(rest, '--timeout');
|
||||
const timeoutMs = timeoutFlag === undefined ? undefined : Number(timeoutFlag) * 1000;
|
||||
if (timeoutMs !== undefined && (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) { process.stderr.write('gstack-pregate: --timeout needs a positive number of seconds\n'); return EXIT.usage; }
|
||||
const stallFlag = flag(rest, '--stall-timeout');
|
||||
const stallTimeoutMs = stallFlag === undefined ? undefined : Number(stallFlag) * 1000;
|
||||
if (stallTimeoutMs !== undefined && (!Number.isFinite(stallTimeoutMs) || stallTimeoutMs < 0)) { process.stderr.write('gstack-pregate: --stall-timeout needs a number of seconds (0 disables)\n'); return EXIT.usage; }
|
||||
let remote: { spendCapUsd: number } | null = null;
|
||||
if (rest.includes('--remote')) {
|
||||
if (!allow || policyFrom === 'head') { process.stderr.write('gstack-pregate: --remote runs the active policy\'s remote_gate (the base branch\'s copy, or the user-owned file when the base carries none) and needs --allow-repo-commands with --policy-from base\n'); return EXIT.refused; }
|
||||
if (!allow) { process.stderr.write('gstack-pregate: --remote runs the trusted policy\'s remote_gate (the base branch\'s copy, or the user-owned file when the base carries none; a head policy may not change it) and needs --allow-repo-commands\n'); return EXIT.refused; }
|
||||
const cap = Number(flag(rest, '--spend-cap') ?? process.env.GSTACK_SPEND_CAP_USD ?? NaN);
|
||||
if (!Number.isFinite(cap) || cap <= 0) { process.stderr.write('gstack-pregate: --remote spends money; pass --spend-cap <usd> (or set GSTACK_SPEND_CAP_USD)\n'); return EXIT.usage; }
|
||||
remote = { spendCapUsd: cap };
|
||||
@@ -161,6 +175,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, () => {
|
||||
@@ -214,16 +229,26 @@ function cmdClear(root: string, rest: string[]): number {
|
||||
const out = outPath(root, rest);
|
||||
const read = fs.existsSync(out) ? readPregateFile(out) : { file: null, errors: [] };
|
||||
if (!read.file) { process.stderr.write(resultLine('gstack-pregate clear', 'PREGATE_STALE', `${out} is missing or invalid; run gstack-pregate first`) + '\n'); return EXIT.fail; }
|
||||
const decision = clearDecision(read.file, lane, { runUrl: flag(rest, '--run-url'), bundleLane: flag(rest, '--bundle-lane'), receiptPath: flag(rest, '--receipt') }, root);
|
||||
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 ? 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);
|
||||
if ('refused' in decision) {
|
||||
if (decision.refused === 'usage') { process.stderr.write(`gstack-pregate clear: ${decision.message}\n`); return EXIT.usage; }
|
||||
process.stderr.write(`gstack-pregate clear: receipt refused — ${decision.message}; fix: ${decision.fix ?? RESULT_CODES.PREGATE_RECEIPT_INVALID.fix} ${resultCodeSuffix('PREGATE_RECEIPT_INVALID')}\n`);
|
||||
return EXIT.fail;
|
||||
}
|
||||
const file = clearObligation(read.file, lane, decision.receipt, `gstack-pregate clear ${new Date().toISOString()}`, decision.substitution);
|
||||
const current = readPregateFile(out).file;
|
||||
if (!current || current.tree !== read.file.tree || current.generated_at !== read.file.generated_at) { process.stderr.write(resultLine('gstack-pregate clear', 'PREGATE_STALE', `${out} changed while ${lane} was being verified; rerun the clear against the new file`) + '\n'); return EXIT.fail; }
|
||||
const file = clearObligation(current, lane, decision.receipt, `gstack-pregate clear ${new Date().toISOString()}`, decision.substitution);
|
||||
writePregateFile(root, out, file);
|
||||
if (/ runs=\S+ profiles=/.test(decision.receipt)) process.stdout.write(`REUSE: accepted ${lane} ${decision.receipt}\n`);
|
||||
if (decision.substitution) process.stdout.write(`PREGATE_RECEIPT_SUBSTITUTED: ${describeSubstitution(decision.substitution)}\n`);
|
||||
process.stdout.write(`PREGATE_CLEARED: ${lane} receipt=${decision.receipt} verdict=${file.verdict}${decision.attested ? ` (attested, not validated: ${lane} has no machine-checkable receipt)` : ''}\n`);
|
||||
if (checked && 'error' in checked) process.stderr.write(`gstack-pregate clear: run profiles unavailable (${checked.error}); ${lane} was recorded without run verification\n`);
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
@@ -244,13 +269,13 @@ function cmdSealed(root: string, rest: string[], json: boolean): number {
|
||||
const baseRef = gitOut(root, ['rev-parse', '--verify', '--quiet', `refs/remotes/${remote}/${base}^{commit}`]) ? `refs/remotes/${remote}/${base}` : null;
|
||||
const remoteUrl = gitOut(root, ['remote', 'get-url', remote]);
|
||||
const report = 'error' in listed
|
||||
? { status: 'error' as const, error: listed.error, policyLabels: [], commits: 0, findings: [], waived: [], visibility: 'unknown' as const, visibilitySource: '', target: remoteUrl ?? remote }
|
||||
? sealedErrorReport(listed.error, remoteUrl ?? remote)
|
||||
: evaluateSealed({ repoRoot: root, targets: [{ commits: listed.commits, pushedSha }], baseRef, baseLabel: `${remote}/${base}`, remoteUrl, target: remoteUrl ?? remote });
|
||||
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 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} policy=${policy}\n`);
|
||||
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`);
|
||||
return blocks ? EXIT.fail : EXIT.ok;
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ import * as path from "path";
|
||||
import { normalizeWithMap, scan, type Finding, type ScanOptions } from "../lib/redact-engine";
|
||||
import { mkdirpSync } from "../lib/fs-utils";
|
||||
import { resolveStateRoot } from "../lib/state-root";
|
||||
import { evaluateSealed, publishedCommits, renderSealed, type SealedReport } from "../lib/sealed-guard";
|
||||
import { evaluateSealed, publishedCommits, renderSealed, sealedErrorReport, type SealedReport } from "../lib/sealed-guard";
|
||||
|
||||
const ZERO = /^0+$/;
|
||||
let emptyTree: string | undefined;
|
||||
@@ -608,7 +608,7 @@ function sealedForPush(refs: Array<{ localSha: string; remoteSha: string }>): Se
|
||||
const targets: Array<{ commits: string[]; pushedSha: string }> = [];
|
||||
for (const ref of refs) {
|
||||
const published = publishedCommits(".", { localSha: ref.localSha, remoteSha: ref.remoteSha, remoteName });
|
||||
if ("error" in published) return { status: "error", error: published.error, policyLabels: [], commits: 0, findings: [], waived: [], visibility: "unknown", visibilitySource: "", target: remoteUrl ?? "the push target" };
|
||||
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" });
|
||||
@@ -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))
|
||||
|
||||
@@ -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 <(...))
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
# expression ("24 hours ago"). Default: "7 days ago".
|
||||
# --until optional window end (compare mode's prior window).
|
||||
|
||||
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=""
|
||||
@@ -379,4 +381,35 @@ fi
|
||||
[ -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"
|
||||
Executable
+373
@@ -0,0 +1,373 @@
|
||||
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
||||
/**
|
||||
* gstack-review-dispatch — /ship's reviewers get their briefs byte for byte and the parent changes
|
||||
* nothing they return (GSTA-167 no-loss gate; docs/review-policy.md#verbatim-dispatch).
|
||||
*
|
||||
* gstack-review-dispatch render --role <role> [--name <n>] --out <dir> [slots] write the brief, print its sha256
|
||||
* gstack-review-dispatch sent --ledger <f> --role <role> [--name <n>] --sent <file> the prompt actually passed; mismatch fails
|
||||
* gstack-review-dispatch collect --ledger <f> --role <role> [--name <n>] --output <file> --merged <file>
|
||||
* gstack-review-dispatch judge --ledger <f> --merged <file> --judged <file> no drop, no downgrade
|
||||
* gstack-review-dispatch verify --ledger <f> [--targeted-out <file>] replay every hash and the judgment
|
||||
* gstack-review-dispatch pass --base <b> --out <dir> --pass <id> [-- plan flags] the pass's staged manifest
|
||||
* gstack-review-dispatch show|stage|chunk|mark --manifest <f> ... one manifest step
|
||||
* gstack-review-dispatch program [--ledger <f>] the checked-in Capy pass program
|
||||
*
|
||||
* Logic: lib/review-brief.ts (rendering from the sections), lib/review-ledger.ts (ledger, collect, judge, verify),
|
||||
* lib/review-pass.ts (the staged manifest) and lib/capy-review-pass.js (the Capy program that runs it).
|
||||
*/
|
||||
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, checkReviewedTree, checkoutRoot, checkoutTree, promptHash, renderBrief, sectionTexts, withWorkspace, type BriefRole, type Slots, type TreeCheck } 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 { 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 { resolveBaseBranch } from '../lib/ship-policy';
|
||||
|
||||
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>
|
||||
--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, 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> [--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. 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
|
||||
duplicate folds into one at least as severe); added and promoted findings need a reason. JUDGMENT: ok …, or
|
||||
JUDGMENT_REJECTED: dropped=… downgraded=… (exit 1)
|
||||
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>] [--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, 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.
|
||||
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)
|
||||
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
|
||||
specialists' merged.jsonl lines byte for byte
|
||||
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>]
|
||||
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>]
|
||||
PROGRAM: v<n> sha256=… bytes=…, then lib/capy-review-pass.js as one JSON string line (see the gstack Capy skill)
|
||||
${exitTableHelp()}`;
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const flag = (n: string) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : undefined; };
|
||||
const readFile = (f: string | undefined, what: string) => {
|
||||
if (!f) return undefined;
|
||||
try { return fs.readFileSync(f, 'utf8'); } catch { throw new BriefError(`cannot read ${what} file ${f}`); }
|
||||
};
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
|
||||
function render(): number {
|
||||
const role = flag('--role') as BriefRole | undefined;
|
||||
const out = flag('--out');
|
||||
if (!role || !BRIEF_ROLES.includes(role) || !out) { process.stderr.write(`gstack-review-dispatch render: needs --role <${BRIEF_ROLES.join('|')}> and --out <dir>\n`); return EXIT.usage; }
|
||||
const name = flag('--name');
|
||||
const diffBase = flag('--diff-base');
|
||||
const checklist = flag('--checklist') ?? (role === 'red-team' ? path.join(ROOT, 'review/specialists/red-team.md') : role === 'targeted' ? path.join(ROOT, 'review/specialists/targeted.md') : role === 'specialist' && name ? path.join(ROOT, 'review/specialists', `${name}.md`) : undefined);
|
||||
if (checklist && (role === 'specialist' || role === 'red-team' || role === 'targeted') && !fs.existsSync(checklist)) throw new BriefError(`checklist not found: ${checklist}`);
|
||||
const brief = role === 'targeted' ? readFile(checklist, 'targeted brief') : undefined;
|
||||
if (role === 'targeted') {
|
||||
const base = resolveBaseBranch(process.cwd(), flag('--base'));
|
||||
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'}`);
|
||||
}
|
||||
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 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,
|
||||
fix_list: readFile(flag('--fix-list'), 'fix list'), hunks: readFile(flag('--hunks'), 'hunks'), tests: readFile(flag('--tests'), 'tests'), callers: readFile(flag('--callers'), 'callers'),
|
||||
};
|
||||
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), workdir, tree, ...targeted });
|
||||
process.stdout.write(`BRIEF: reader=${readerKey(role, name)} sha256=${sha} bytes=${Buffer.byteLength(text)} file=${file}\n`);
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
function pass(): number {
|
||||
const base = flag('--base'), out = flag('--out'), id = flag('--pass');
|
||||
if (!base || !out || !id || !/^[a-z0-9-]+$/.test(id)) { process.stderr.write('gstack-review-dispatch pass: needs --base <ref>, --out <dir> and --pass <id> (lowercase letters, digits, dashes)\n'); return EXIT.usage; }
|
||||
const file = manifestPath(out);
|
||||
if (fs.existsSync(file)) {
|
||||
const m = readManifest(file);
|
||||
if (m.pass !== id) throw new BriefError(`${file} belongs to pass ${m.pass}, not ${id}`);
|
||||
process.stdout.write([...m.plan_lines, `MANIFEST: file=${file} pass=${m.pass} readers=${m.readers.length} resumed`, ...m.readers.map(r => readerLine(m, r))].join('\n') + '\n');
|
||||
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[] }; fix_range?: { base: string } | null };
|
||||
const step = p.next.step;
|
||||
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: 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,
|
||||
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 });
|
||||
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);
|
||||
const count = (s: string) => m.readers.filter(r => r.stage === s).length;
|
||||
process.stdout.write([...planLines, `MANIFEST: file=${file} pass=${id} readers=${m.readers.length} first=${count('first')} specialists=${count('specialists')} red-team=${count('red-team')} voices=${count('voices')}`, ...m.readers.map(r => readerLine(m, r))].join('\n') + '\n');
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
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; }
|
||||
const m = readManifest(file);
|
||||
if (cmd === 'show') { process.stdout.write(`MANIFEST_JSON: ${JSON.stringify(m)}\n`); return EXIT.ok; }
|
||||
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 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');
|
||||
return EXIT.ok;
|
||||
}
|
||||
if (cmd === 'chunk') {
|
||||
const r = m.readers.find(x => x.reader === flag('--reader'));
|
||||
if (!r?.file || !r.sha256) { process.stderr.write(`gstack-review-dispatch chunk: no rendered reader ${flag('--reader')}\n`); return EXIT.usage; }
|
||||
const text = readFile(r.file, 'brief')!;
|
||||
if (promptHash(text) !== r.sha256) throw new BriefError(`${r.reader}'s brief ${r.file} changed since it was rendered`);
|
||||
const index = Number(flag('--index') ?? '0');
|
||||
process.stdout.write(`BRIEF_CHUNK: reader=${r.reader} index=${index} of=${chunkCount(text)} sha256=${r.sha256}\n${JSON.stringify(chunkOf(text, index))}\n`);
|
||||
return EXIT.ok;
|
||||
}
|
||||
if (cmd === 'mark') {
|
||||
const logStart = flag('--log-start');
|
||||
if (logStart) {
|
||||
const token = flag('--token');
|
||||
if (!token) { process.stderr.write('gstack-review-dispatch mark: --log-start <key> --token <t>\n'); return EXIT.usage; }
|
||||
m.log_starts ??= {};
|
||||
m.log_starts[logStart] ??= token;
|
||||
writeManifest(file, m);
|
||||
process.stdout.write(`LOG_START: ${logStart} token=${m.log_starts[logStart]}\n`);
|
||||
return EXIT.ok;
|
||||
}
|
||||
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> [--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: ${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');
|
||||
return EXIT.ok;
|
||||
}
|
||||
return EXIT.usage;
|
||||
}
|
||||
|
||||
function program(): number {
|
||||
const text = readFile(PROGRAM_FILE, 'pass program')!;
|
||||
const sha = promptHash(text);
|
||||
const ledger = flag('--ledger');
|
||||
if (ledger) appendEvent(ledger, { event: 'program', version: PROGRAM_VERSION, sha256: sha });
|
||||
process.stdout.write(`PROGRAM: v${PROGRAM_VERSION} sha256=${sha} bytes=${Buffer.byteLength(text)}\n${JSON.stringify(text)}\n`);
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
function targetedRecord(ledger: string, rows: VerifyRow[]): { classes: string[]; rules: string; brief_sha: string; status: 'done' | 'failed'; findings: number; raised_by: string } | 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 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' };
|
||||
}
|
||||
|
||||
function main(): number {
|
||||
const cmd = args[0];
|
||||
if (!cmd || cmd === '--help' || cmd === '-h') { process.stdout.write(HELP + '\n'); return cmd ? EXIT.ok : EXIT.usage; }
|
||||
if (cmd === 'render') return render();
|
||||
if (cmd === 'pass') return pass();
|
||||
if (cmd === 'program') return program();
|
||||
if (['show', 'stage', 'chunk', 'mark'].includes(cmd)) return passStep(cmd);
|
||||
const ledger = flag('--ledger');
|
||||
if (!ledger) { process.stderr.write(`gstack-review-dispatch ${cmd}: --ledger <file> is required\n`); return EXIT.usage; }
|
||||
const reader = flag('--role') ? readerKey(flag('--role')!, flag('--name')) : '';
|
||||
if (cmd === 'sent') {
|
||||
const sent = readFile(flag('--sent'), 'sent prompt');
|
||||
if (!reader || sent === undefined) { process.stderr.write('gstack-review-dispatch sent: needs --role and --sent <file>\n'); return EXIT.usage; }
|
||||
const r = recordSent(ledger, reader, sent);
|
||||
process.stdout.write(r.ok ? `BRIEF_SENT: reader=${reader} sha256=${r.sent} match\n`
|
||||
: `BRIEF_MISMATCH: reader=${reader} rendered=${r.rendered ?? 'none'} sent=${r.sent} (dispatch the rendered file unchanged; this reader's review does not count)\n`);
|
||||
return r.ok ? EXIT.ok : EXIT.fail;
|
||||
}
|
||||
if (cmd === 'collect') {
|
||||
const outFile = flag('--output');
|
||||
const output = readFile(outFile, 'reader output');
|
||||
const merged = flag('--merged');
|
||||
if (!reader || output === undefined || !merged) { process.stderr.write('gstack-review-dispatch collect: needs --role, --output <file> and --merged <file>\n'); return EXIT.usage; }
|
||||
const unavailable = flag('--unavailable');
|
||||
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 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 r = collect(ledger, reader, output, merged, path.resolve(outFile!), unavailable, tree);
|
||||
if (tree && !tree.ok) {
|
||||
const mf = flag('--manifest');
|
||||
if (mf) { const m = readManifest(mf); markReader(m, reader, 'failed', { output: outFile, error: `reviewed another tree: ${tree.reported}` }); writeManifest(mf, m); }
|
||||
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`);
|
||||
return EXIT.fail;
|
||||
}
|
||||
process.stdout.write(`COLLECT: reader=${reader} findings=${r.lines.length} sha256=${r.sha} merged=${merged}\n`);
|
||||
return EXIT.ok;
|
||||
}
|
||||
if (cmd === 'judge') {
|
||||
const merged = flag('--merged');
|
||||
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`);
|
||||
return r.ok ? EXIT.ok : EXIT.fail;
|
||||
}
|
||||
if (cmd === 'verify') {
|
||||
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`);
|
||||
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;
|
||||
}
|
||||
process.stderr.write(`gstack-review-dispatch: unknown command ${cmd}\n${HELP}\n`);
|
||||
return EXIT.usage;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
+42
-4
@@ -3,6 +3,11 @@
|
||||
# Usage: gstack-review-log '{"skill":"...","timestamp":"...","status":"..."}'
|
||||
#
|
||||
# Before reading a diff: gstack-review-log --start review
|
||||
# Under a /ship token cap (docs/ship-budget.md): --start SKILL --reservation ID
|
||||
# with the RESERVATION id gstack-ship-budget check printed for this dispatch
|
||||
# batch; refuses (exit 1, BUDGET_CHECK_SKIPPED) without an unspent one, and
|
||||
# --finish settles it with the pass's other dispatch reservations of that
|
||||
# step. Without a cap no marker exists and nothing changes.
|
||||
# Fix-range repeat (#3339): gstack-review-log --start review --fix-range
|
||||
# prints the token, then FIX_BASE=<sha>; refuses (exit 1) unless the latest
|
||||
# pass completed with fixes on unchanged content and its commit is an
|
||||
@@ -22,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"
|
||||
@@ -32,15 +38,18 @@ export GSTACK_REVIEW_DIR="$GSTACK_HOME/projects/$SLUG"
|
||||
export GSTACK_REVIEW_REPO="$(git rev-parse --show-toplevel 2>/dev/null || true)"
|
||||
export GSTACK_REVIEW_BRANCH="$(git symbolic-ref --short HEAD 2>/dev/null || git rev-parse HEAD 2>/dev/null || true)"
|
||||
export GSTACK_REVIEW_LIB="$SCRIPT_DIR/../lib/review-evidence.ts"
|
||||
export GSTACK_SHIP_BUDGET_LIB="$SCRIPT_DIR/../lib/ship-budget.ts"
|
||||
case "$(uname -s)" in
|
||||
MINGW*|MSYS*|CYGWIN*)
|
||||
if command -v cygpath >/dev/null 2>&1; then
|
||||
GSTACK_REVIEW_LIB="$(cygpath -m "$GSTACK_REVIEW_LIB")"
|
||||
GSTACK_SHIP_BUDGET_LIB="$(cygpath -m "$GSTACK_SHIP_BUDGET_LIB")"
|
||||
GSTACK_REVIEW_DIR="$(cygpath -m "$GSTACK_REVIEW_DIR")"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
export GSTACK_REVIEW_LOG="$GSTACK_REVIEW_DIR/$BRANCH-reviews.jsonl"
|
||||
export GSTACK_SHIP_BUDGET_MARKER="$GSTACK_REVIEW_DIR/$BRANCH-ship-budget.json"
|
||||
|
||||
# Compute binding fields (best-effort; empty outside a git repo).
|
||||
COMMIT_FULL=$(git rev-parse HEAD 2>/dev/null || true)
|
||||
@@ -77,10 +86,28 @@ fi
|
||||
SESSION_KIND=$("$SCRIPT_DIR/gstack-session-kind" 2>/dev/null || echo interactive)
|
||||
export GSTACK_STAMP_COMMIT_FULL="$COMMIT_FULL" GSTACK_STAMP_TREE="$TREE" GSTACK_STAMP_WTREE="$WTREE" GSTACK_STAMP_DIRTY="$DIRTY" GSTACK_STAMP_SESSION_KIND="$SESSION_KIND"
|
||||
if [ "$INPUT" = --start ]; then
|
||||
case "${3:-}" in ''|--fix-range) ;; *) echo 'Usage: gstack-review-log --start SKILL [--fix-range]' >&2; exit 2 ;; esac
|
||||
GSTACK_REVIEW_SKILL="${2:-}" GSTACK_REVIEW_FIX_RANGE="${3:-}" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e '
|
||||
START_SKILL="${2:-}" START_FIX="" START_RESERVATION=""
|
||||
if [ "$#" -ge 2 ]; then shift 2; else shift "$#"; fi
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--fix-range) START_FIX=--fix-range; shift ;;
|
||||
--reservation) [ "$#" -ge 2 ] || { echo 'Usage: gstack-review-log --start SKILL [--fix-range] [--reservation ID]' >&2; exit 2; }; START_RESERVATION="$2"; shift 2 ;;
|
||||
*) echo 'Usage: gstack-review-log --start SKILL [--fix-range] [--reservation ID]' >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
if [ ! -f "$GSTACK_SHIP_BUDGET_MARKER" ]; then
|
||||
GSTACK_REVIEW_SKILL="$START_SKILL" GSTACK_REVIEW_FIX_RANGE="$START_FIX" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e '
|
||||
const { captureReviewStart } = await import(process.env.GSTACK_REVIEW_LIB);
|
||||
try { console.log(captureReviewStart(process.env.GSTACK_REVIEW_SKILL, process.env, process.env.GSTACK_REVIEW_FIX_RANGE === "--fix-range")); }
|
||||
catch (e) { console.error(e.message); process.exit(1); }
|
||||
'
|
||||
exit $?
|
||||
fi
|
||||
GSTACK_REVIEW_SKILL="$START_SKILL" GSTACK_REVIEW_FIX_RANGE="$START_FIX" GSTACK_REVIEW_RESERVATION="$START_RESERVATION" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e '
|
||||
const { captureReviewStart } = await import(process.env.GSTACK_REVIEW_LIB);
|
||||
try { console.log(captureReviewStart(process.env.GSTACK_REVIEW_SKILL, process.env, process.env.GSTACK_REVIEW_FIX_RANGE === "--fix-range")); }
|
||||
const { admitReviewStart } = await import(process.env.GSTACK_SHIP_BUDGET_LIB);
|
||||
const capture = () => captureReviewStart(process.env.GSTACK_REVIEW_SKILL, process.env, process.env.GSTACK_REVIEW_FIX_RANGE === "--fix-range");
|
||||
try { console.log(admitReviewStart(process.env.GSTACK_SHIP_BUDGET_MARKER, process.env.GSTACK_REVIEW_RESERVATION || undefined, capture)); }
|
||||
catch (e) { console.error(e.message); process.exit(1); }
|
||||
'
|
||||
exit $?
|
||||
@@ -93,7 +120,7 @@ if [ "$INPUT" = --check-shared-libs ] && [ "$#" -eq 2 ]; then
|
||||
exit $?
|
||||
fi
|
||||
usage() {
|
||||
echo 'Usage: gstack-review-log JSON [--finish TOKEN] [--findings FILE] | --start SKILL [--fix-range] | --check-shared-libs TOKEN < finding.json' >&2
|
||||
echo 'Usage: gstack-review-log JSON [--finish TOKEN] [--findings FILE] | --start SKILL [--fix-range] [--reservation ID] | --check-shared-libs TOKEN < finding.json' >&2
|
||||
echo 'Exit codes: 0 written · 1 rejected · 2 usage' >&2
|
||||
exit 2
|
||||
}
|
||||
@@ -126,6 +153,17 @@ try {
|
||||
|
||||
echo "$STAMPED" >> "$GSTACK_HOME/projects/$SLUG/$BRANCH-reviews.jsonl"
|
||||
|
||||
# A finished pass settles the budget reservation its start consumed (its
|
||||
# completion receipt) and every other dispatch reservation started in the same
|
||||
# step; stdout stays unchanged and a failure never undoes the row.
|
||||
if [ -n "$TOKEN" ] && [ -f "$GSTACK_SHIP_BUDGET_MARKER" ]; then
|
||||
GSTACK_REVIEW_TOKEN="$TOKEN" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e '
|
||||
const { settleReviewFinish } = await import(process.env.GSTACK_SHIP_BUDGET_LIB);
|
||||
try { const id = settleReviewFinish(process.env.GSTACK_SHIP_BUDGET_MARKER, process.env.GSTACK_REVIEW_TOKEN); if (id) console.error("review-log: settled budget reservations " + id); }
|
||||
catch (e) { console.error("review-log: budget reservation not settled: " + e.message); }
|
||||
' || true
|
||||
fi
|
||||
|
||||
# gbrain-sync: enqueue for cross-machine sync (no-op if sync is off). Plan B1:
|
||||
# an unattended session never enqueues, whatever the inherited sync mode.
|
||||
if [ "$("$SCRIPT_DIR/gstack-session-kind" 2>/dev/null || echo interactive)" = "unattended" ]; then
|
||||
|
||||
+99
-54
@@ -4,20 +4,28 @@
|
||||
*
|
||||
* 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>] [--no-tiers] [--json]
|
||||
* [--reviewed-numbers <fingerprint>] [--depth-floor standard|full] [--range-base <sha>] [--pass1-depth light|standard|full] [--no-tiers]
|
||||
* [--targeted-ran <targeted_patch_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, working tree and
|
||||
* untracked), runs gstack-diff-scope and, unless --gate-tagged is given,
|
||||
* gstack-gate-diff; prints REVIEW_CLASS:, REVIEW_PLAN:, one DISPATCH: line per
|
||||
* reviewer and one SKIP: line per reviewer left out. Logic: lib/review-depth.ts.
|
||||
* Reads the diff against the merge base with <ref> (committed, tracked uncommitted
|
||||
* edits and untracked files, through lib/review-collect.ts collectLive), runs
|
||||
* gstack-diff-scope and, unless --gate-tagged is given, gstack-gate-diff; prints
|
||||
* REVIEW_CLASS:, REVIEW_DEPTH: (depth, runtime lines, the rules hash, reasons and
|
||||
* result codes), GATE_SCAN:, TARGETED: (the money, gate, auth and secret classes, lib/review-targeted.ts),
|
||||
* 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.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
|
||||
import { runScriptSync } from '../lib/script-invocation';
|
||||
import { planReview, reviewPlanLine, scanNumbers, type History } from '../lib/review-depth';
|
||||
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 { classList, targetedBriefSha, targetedClasses, targetedPatchId } from '../lib/review-targeted';
|
||||
import { renderFixCheckPrompt } from '../scripts/resolvers/fix-check';
|
||||
|
||||
const HELP = `gstack-review-plan — the review depth of the next /ship step (docs/review-policy.md#review-depth)
|
||||
@@ -25,19 +33,34 @@ const HELP = `gstack-review-plan — the review depth of the next /ship step (do
|
||||
--base <ref> the base branch (merge base with HEAD is the diff base); required
|
||||
--pass <n> full review passes already run in this invocation (default 0)
|
||||
--last none|full|fix-check the step that just finished (default none: plan pass 1)
|
||||
--fixed-blocking the finished step fixed a blocking finding
|
||||
--fixed-below the finished step fixed findings below the floor
|
||||
--fixed-blocking the finished step fixed a blocking finding (raises light to standard)
|
||||
--fixed-below the finished step fixed findings below the floor (ignored after a fix-check: below-floor
|
||||
fixes never repeat review, so only --fixed-blocking or --blocking-open stops there)
|
||||
--blocking-open a blocking finding is still open in fix scope
|
||||
--touched-gate a fix touched a gate-class path
|
||||
--critical-found a specialist reported a CRITICAL finding (Red Team on code class)
|
||||
--critical-found a specialist reported a CRITICAL finding (Red Team on code class; raises light to standard)
|
||||
--adversarial-ran Step 11's native adversarial pass already ran (a repeat skips it)
|
||||
--gate-tagged <n> gate hunks the gate scan tagged (default: run gstack-gate-diff; failure means unavailable)
|
||||
--gated a,b specialists the hit-rate stats tag GATE_CANDIDATE
|
||||
--force a,b|all specialists forced by prompt flags (--all-specialists is all)
|
||||
--force a,b|all specialists forced by prompt flags (--all-specialists is all): at standard and full depth they join the
|
||||
batched specialist's checklists, where maintainability, performance and simplification are read only when
|
||||
forced; light runtime-0 and scoped passes dispatch them one reader each
|
||||
--reviewed-numbers <fp> the numeric-claim fingerprint the outside voice already reviewed
|
||||
--no-tiers audits and watchers at the host's default effort too (as gstack-config review_effort_tiers off does)
|
||||
--json the plan as JSON
|
||||
--depth-floor standard|full raise the depth to at least this (never lowers it); /ship passes its prompt flag on every run
|
||||
--range-base <sha> the fix range's base (this pass's PASS_HEAD): after a fixed blocker on a diff below full depth, a
|
||||
committed range of at most ${SCOPED_RANGE_LINES} runtime lines plans a scoped pass; anything unread plans the full pass
|
||||
--no-tiers specialists, audits and watchers at the host's default effort too (as gstack-config review_effort_tiers off does)
|
||||
--json the plan as JSON (depth, runtime_lines, rules, errors with input and repair, gate_scan, per-dispatch exclude)
|
||||
--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
|
||||
--fix-check-prompt print the fix-check reviewer's prompt from --fix-list, --hunks, --tests and --callers files
|
||||
|
||||
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
|
||||
from an error exits 0; exit 1 means no plan could be built (git could not list the diff).
|
||||
${exitTableHelp()}`;
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
@@ -63,64 +86,86 @@ function main(): number {
|
||||
if (!base) { process.stderr.write(`gstack-review-plan: --base <ref> is required\n${HELP}\n`); return EXIT.usage; }
|
||||
const last = (flag('--last') ?? 'none') as History['last'];
|
||||
if (!['none', 'full', 'fix-check'].includes(last)) { process.stderr.write('gstack-review-plan: --last must be none, full or fix-check\n'); return EXIT.usage; }
|
||||
const pass1Depth = (flag('--pass1-depth') ?? 'full') as Depth;
|
||||
if (!['light', 'standard', 'full'].includes(pass1Depth)) { process.stderr.write('gstack-review-plan: --pass1-depth must be light, standard or full\n'); return EXIT.usage; }
|
||||
const pass = Number(flag('--pass') ?? '0');
|
||||
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 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; }
|
||||
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);
|
||||
const ref = git(['rev-parse', '--verify', '-q', `origin/${base}`]) !== null ? `origin/${base}` : base;
|
||||
const mergeBase = git(['merge-base', ref, 'HEAD'])?.trim();
|
||||
if (!mergeBase) { process.stderr.write(`gstack-review-plan: no merge base with ${ref} (fetch the base first)\n`); return EXIT.fail; }
|
||||
const nameStatusRaw = git(['diff', '--name-status', '--no-renames', mergeBase]);
|
||||
const untrackedRaw = git(['ls-files', '--others', '--exclude-standard']);
|
||||
const numstatRaw = git(['diff', '--numstat', mergeBase]);
|
||||
if (nameStatusRaw === null || untrackedRaw === null || numstatRaw === null) { process.stderr.write('gstack-review-plan: git could not list the diff; no plan is safer than a plan for an empty diff\n'); return EXIT.fail; }
|
||||
const scratch = (p: string) => p.startsWith('.gstack/tmp/');
|
||||
const nameStatus = nameStatusRaw.split('\n').filter(Boolean).map(l => l.split('\t')).filter(c => !scratch(c[1] ?? ''));
|
||||
const untracked = untrackedRaw.split('\n').filter(Boolean).filter(p => !scratch(p));
|
||||
const paths = [...new Set([...nameStatus.map(c => c[1]!).filter(Boolean), ...untracked])].sort();
|
||||
const deleted = nameStatus.filter(c => c[0] === 'D').map(c => c[1]!);
|
||||
const stampOnly = paths.filter(p => p.endsWith('package.json') && !untracked.includes(p)).filter(p => {
|
||||
const changed = (git(['diff', '-U0', '--no-color', mergeBase, '--', p]) ?? '').split('\n').filter(l => /^[+-]/.test(l) && !/^(\+\+\+|---) /.test(l));
|
||||
return changed.length > 0 && changed.every(l => /^[+-]\s*"version"\s*:/.test(l));
|
||||
});
|
||||
const diffLines = numstatRaw.split('\n').reduce((n, l) => { const [a, d] = l.split('\t'); return n + (Number(a) || 0) + (Number(d) || 0); }, 0)
|
||||
+ untracked.reduce((n, p) => { try { return n + fs.readFileSync(p, 'utf8').split('\n').length; } catch { return n; } }, 0);
|
||||
const added: Record<string, string[]> = {};
|
||||
let current = '';
|
||||
for (const line of (git(['diff', '-U0', '--no-color', mergeBase]) ?? '').split('\n')) {
|
||||
if (line.startsWith('+++ b/')) { current = line.slice(6); continue; }
|
||||
if (line.startsWith('+') && !line.startsWith('+++') && current) (added[current] ??= []).push(line.slice(1));
|
||||
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;
|
||||
}
|
||||
for (const p of untracked) { try { added[p] = fs.readFileSync(p, 'utf8').split('\n'); } catch { /* unreadable: no claims */ } }
|
||||
const head = git(['rev-parse', 'HEAD'])?.trim() ?? 'unknown';
|
||||
const binDir = path.dirname(Bun.fileURLToPath(import.meta.url));
|
||||
const scopeRun = runScriptSync(path.join(binDir, 'gstack-diff-scope'), [base], { encoding: 'utf8', timeout: 60_000 });
|
||||
const scope: Record<string, string> = {};
|
||||
for (const l of (scopeRun.stdout ?? '').split('\n')) { const m = /^(SCOPE_[A-Z_]+)=(.*)$/.exec(l); if (m) scope[m[1]!] = m[2]!; }
|
||||
if (scopeRun.status !== 0 && !scope.SCOPE_ERROR) scope.SCOPE_ERROR = `exit ${scopeRun.status}`;
|
||||
let gateTagged: number | null = null;
|
||||
if (flag('--gate-tagged') !== undefined) gateTagged = Number(flag('--gate-tagged'));
|
||||
else {
|
||||
const g = runScriptSync(path.join(binDir, 'gstack-gate-diff'), [base], { encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
|
||||
const scopeOf = (args: string[]): Record<string, string> => {
|
||||
const run = runScriptSync(path.join(binDir, 'gstack-diff-scope'), args, { encoding: 'utf8', timeout: 60_000 });
|
||||
const s: Record<string, string> = {};
|
||||
for (const l of (run.stdout ?? '').split('\n')) { const m = /^(SCOPE_[A-Z_]+)=(.*)$/.exec(l); if (m) s[m[1]!] = m[2]!; }
|
||||
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 ?? '');
|
||||
gateTagged = g.status === 0 && m ? m[1]!.split(',').reduce((n, kv) => n + (Number(kv.split(':')[1]) || 0), 0) : null;
|
||||
}
|
||||
return g.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]);
|
||||
if (gateTagged !== null && !Number.isFinite(gateTagged)) gateTagged = null;
|
||||
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';
|
||||
const { added, diffLines, paths, deleted, stampOnly, perPath, registry } = collected;
|
||||
let fixRange: { base: string; runtime_lines: number | null; error?: string; touched_gate?: true } | undefined;
|
||||
if (rangeBase) {
|
||||
const unread = (error: string) => ({ base: rangeBase, runtime_lines: null, error });
|
||||
const resolved = git(['rev-parse', '--verify', '--quiet', `${rangeBase}^{commit}`])?.trim();
|
||||
if (!resolved) fixRange = unread('not a commit in this repository');
|
||||
else if (git(['merge-base', '--is-ancestor', resolved, 'HEAD']) === null) fixRange = unread('not an ancestor of HEAD');
|
||||
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]) });
|
||||
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 patch = collected.mergeBase ? targetedPatchId(process.cwd(), collected.mergeBase) : 'none';
|
||||
const brief = targetedBriefSha();
|
||||
const plan = planReview({
|
||||
paths, deleted, stampOnly, root, scope, gateTagged, diffLines, numbers: scanNumbers(added), force, gated: list('--gated'),
|
||||
...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',
|
||||
criticalFound: has('--critical-found'), adversarialRan: has('--adversarial-ran'), reviewedNumbers: flag('--reviewed-numbers'),
|
||||
history: { fullPasses: pass, last, fixedBlocking: has('--fixed-blocking'), fixedBelow: has('--fixed-below'), blockingOpen: has('--blocking-open'), touchedGate: has('--touched-gate') },
|
||||
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 },
|
||||
});
|
||||
if (has('--json')) { process.stdout.write(JSON.stringify({ ...plan, diff_lines: diffLines, pass, audit_effort: auditEffort, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly }) + '\n'); return EXIT.ok; }
|
||||
const out = [`REVIEW_CLASS: ${plan.class} (${plan.reasons.slice(0, 3).join('; ') || 'no signals'})`, reviewPlanLine(plan, pass + (plan.next.step === 'full' ? 1 : 0)), `NEXT: ${plan.next.step} (${plan.next.reason})`];
|
||||
for (const d of plan.dispatches) out.push(`DISPATCH: ${d.role} ${d.name} effort=${d.effort}`);
|
||||
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');
|
||||
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('; ')}` : ''}`];
|
||||
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})` : ''}`);
|
||||
for (const d of plan.dispatches) {
|
||||
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`);
|
||||
}
|
||||
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 stay full)`);
|
||||
out.push(`AUDIT_EFFORT: ${auditEffort} (coverage, plan-completion and docs audits, decision-trail reviewers, watchers; reviewers above run at their effort=)`);
|
||||
process.stdout.write(out.join('\n') + '\n');
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
process.exit(main());
|
||||
process.exitCode = main();
|
||||
@@ -15,6 +15,7 @@ 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"
|
||||
|
||||
@@ -23,6 +23,7 @@ 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 {
|
||||
@@ -224,6 +225,8 @@ function cmdRender(rest: string[]): number {
|
||||
let noted: ScopedFinding[] = [];
|
||||
try {
|
||||
if (deferredFile) deferred = JSON.parse(fs.readFileSync(deferredFile, 'utf8'));
|
||||
const wrapped = deferred as unknown as { deferred?: unknown };
|
||||
if (!Array.isArray(deferred) && Array.isArray(wrapped?.deferred)) deferred = wrapped.deferred as DeferredRecord[];
|
||||
const findingsFile = flag(rest, '--findings');
|
||||
if (findingsFile) noted = (readFindings(findingsFile, 'classified findings') as ScopedFinding[]).filter(f => f.scope === 'note');
|
||||
} catch (e: any) { return refuse('render', 'REVIEW_SCOPE_INPUT', e.message); }
|
||||
@@ -235,8 +238,8 @@ 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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
||||
/**
|
||||
* gstack-ship-budget — the /ship token budget and fresh-context handoff
|
||||
* (docs/ship-budget.md). An admission cap: no step or dispatch starts unless
|
||||
* used + open reservations + unknown + its projection + the tail and stop
|
||||
* reserve fit under the cap. --mode observe (ship/DRIVER.md) only records:
|
||||
* note at each program's end, and the cap is one hard stop. Every --reading
|
||||
* takes --credits <cost.credits> beside it.
|
||||
*
|
||||
* start --record <path> [--token-cap <n|none>] [--mode admit|observe] [--meter capy|claude|estimate] [--reading <n>] [--credits <c>]
|
||||
* [--prior-reading <n>] [--session <id>|--nonce <v>] [--reserve <n>] [--delegate auto|never] [--calibration <file>] [--ledger <path>]
|
||||
* note --run|--record --step <id> [--label <text>] --reading <n> [--credits <c>] [--dispatched-at <n> [--dispatched-credits <c>] --readers <k>]
|
||||
* check --run <id>|--record <path> --step <id> --next <id|dispatch:<role>[,<role>...]> [--reading <n>] [--credits <c>] [--prior-reading <n>]
|
||||
* [--returned <id>[,<id>...]] [--runtime-lines <n>] [--dispatched-at <n> --readers <k>]
|
||||
* settle --run|--record --reservation <id> [--unknown] [--reading <n>] [--credits <c>] [--dispatched-at <n> --readers <k>]
|
||||
* release --run|--record --reservation <id>
|
||||
* handoff --run|--record --from <step> --to <step> --base origin/<base> [--reviewed-tree <t>] [--trail <f> --trail-run <id>]
|
||||
* [--sub-cap <n>] [--reading <n>] [--child-session <id>]
|
||||
* launch --in <brief> (Claude Code: background headless child with explicit permissions)
|
||||
* verify-handoff --in <brief> [--reading <n>] [--session <id>]
|
||||
* return --run <child> --to <step> [--changed <sections>] [--status complete|refused|incomplete] [--reading <n>]
|
||||
* reconcile --run|--record (--return <file>|--handoff <sha256>) [--reading <n>]
|
||||
* resume --record <path>|--run <id> [--reading <n>] [--meter <m>] [--session <id>] [--token-cap <n> --approved-by <who> --words <text>]
|
||||
* [--rebuild-from-record]
|
||||
* close --run|--record [--reading <n>] [--approved-by <who> --words <text>]
|
||||
* calls --events <file> [--since <iso>] [--until <iso>] (the host's transcript items: one CALLS: line, lib/ship-calls.ts)
|
||||
*
|
||||
* Exit: 0 continue/delegate · 3 stop (the next step does not start) · 2 usage
|
||||
* or validation (stderr). Logic: lib/ship-budget.ts, lib/ship-budget-cli.ts,
|
||||
* lib/ship-handoff.ts.
|
||||
*/
|
||||
import { BudgetError } from '../lib/ship-budget';
|
||||
import { cmdCalls, cmdCheck, cmdClose, cmdNote, cmdResume, cmdSettle, cmdStart, type Outcome } from '../lib/ship-budget-cli';
|
||||
import { cmdHandoff, cmdLaunch, cmdReconcile, cmdReturn, cmdVerifyHandoff } from '../lib/ship-handoff';
|
||||
|
||||
const [command, ...rest] = process.argv.slice(2);
|
||||
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, ctx),
|
||||
};
|
||||
if (!command || command === '--help' || command === '-h' || !commands[command]) {
|
||||
const lines = (await Bun.file(import.meta.path).text()).split('\n');
|
||||
const text = lines.slice(2, lines.indexOf(' */')).map(l => l.replace(/^ \* ?/, '')).join('\n');
|
||||
(command && command !== '--help' && command !== '-h' ? console.error : console.log)(text);
|
||||
process.exit(command && command !== '--help' && command !== '-h' ? 2 : 0);
|
||||
}
|
||||
try {
|
||||
const out = commands[command]!();
|
||||
if (out.stdout.length) console.log(out.stdout.join('\n'));
|
||||
process.exit(out.exit);
|
||||
} catch (e) {
|
||||
if (!(e instanceof BudgetError)) throw e;
|
||||
if (e.exit === 3) console.log(e.message); else console.error(e.message);
|
||||
process.exit(e.exit);
|
||||
}
|
||||
Executable
+10
@@ -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);
|
||||
@@ -18,7 +18,7 @@ import * as path from 'node:path';
|
||||
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
|
||||
import { resultLine } from '../lib/result-codes';
|
||||
import {
|
||||
POLICY_KEYS, POLICY_KEY_INTRODUCED, SHIP_POLICY_PATH, examplePolicyJson, explainPolicy, isUnavailable, loadPolicyFromBase, loadPolicyFromHead,
|
||||
BUMP_NEVER_KEY_VERSION, POLICY_KEYS, POLICY_KEY_INTRODUCED, SHIP_POLICY_PATH, examplePolicyJson, explainPolicy, isUnavailable, loadPolicyFromBase, loadPolicyFromHead,
|
||||
minimumReader, policyUnavailableLine, renderEffective, resolveBaseBranch, type LoadedPolicy,
|
||||
} from '../lib/ship-policy';
|
||||
|
||||
@@ -87,8 +87,8 @@ function main(argv: string[]): number {
|
||||
const loaded = load(root, rest, cmd === 'validate' ? 'head' : 'base');
|
||||
if (typeof loaded === 'number') return loaded;
|
||||
const set = POLICY_KEYS.filter(k => loaded.values[k] && loaded.values[k] !== 'default');
|
||||
const introduced = Object.fromEntries(set.map(k => [k, POLICY_KEY_INTRODUCED[k]]));
|
||||
const minReader = set.length ? minimumReader(introduced) : null;
|
||||
const introduced = Object.fromEntries(set.map(k => [k, k === 'bump' && loaded.policy.bump === 'never' ? BUMP_NEVER_KEY_VERSION : POLICY_KEY_INTRODUCED[k]]));
|
||||
const minReader = set.length ? minimumReader(Object.fromEntries(set.map(k => [k, loaded.policy[k]]))) : null;
|
||||
if (json) {
|
||||
process.stdout.write(JSON.stringify({ valid: loaded.errors.length === 0, present: loaded.present, source: loaded.source, policy: loaded.policy, values: loaded.values, introduced, min_reader: minReader, errors: loaded.errors }) + '\n');
|
||||
return loaded.errors.length ? EXIT.fail : EXIT.ok;
|
||||
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
||||
/**
|
||||
* gstack-ship-preflight — /ship Steps 1-3 and Step 5's pregate and lanes in one call, with
|
||||
* STOP:/NEXT: lines wherever the agent must judge. Logic: lib/ship-preflight.ts.
|
||||
*/
|
||||
import { preflight } from '../lib/ship-preflight';
|
||||
|
||||
const out = await preflight(process.argv.slice(2), process.cwd());
|
||||
(out.exit === 2 ? process.stderr : process.stdout).write(out.text);
|
||||
process.exit(out.exit);
|
||||
+24
-2
@@ -14,11 +14,16 @@
|
||||
* 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> --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
|
||||
* ends with GSTACK_RESULT: skill=ship-queue status=complete|refused run=<path>.
|
||||
* gstack-restamp stays the primitive. Nothing here pushes, except under a
|
||||
* gstack-restamp stays the primitive. Nothing here pushes, except land, ready
|
||||
* under changelog_fragments (its READY receipt note), and under a
|
||||
* ship policy with a `train` block: merge trains push train/<nonce> branches
|
||||
* and the refs/notes/gstack-trains records, and dispatch through eval-bg.
|
||||
* Logic: lib/ship-queue.ts, lib/ship-queue-gate.ts, lib/ship-queue-train*.ts
|
||||
@@ -52,11 +57,20 @@ 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]
|
||||
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
|
||||
release; /ship and land it, which releases the lock
|
||||
land --census what land would do with every open PR (read-only)
|
||||
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.
|
||||
--base <branch>: base branch (default: origin/HEAD, else 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()}`;
|
||||
|
||||
function flag(args: string[], name: string): string | undefined {
|
||||
@@ -151,7 +165,15 @@ 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 }));
|
||||
}
|
||||
if (!pr || !/^\d+$/.test(pr)) return usage(`${cmd} needs --pr <number>`);
|
||||
const gateExit = flag(args, '--gate-exit');
|
||||
if (gateExit !== undefined && !/^-?\d+$/.test(gateExit)) return usage('--gate-exit needs an exit code');
|
||||
|
||||
+306
-75
@@ -7,6 +7,7 @@
|
||||
* [--session-kind <k>] [--artifacts-consumed <n>/<m>] [--run <id>] [--thread <id>]
|
||||
* [--receipt <tree-receipt.json>] [--policy <label>] [--preregistration <text>] [--history <text>]
|
||||
* [--pregate <pregate.json>] [--issues <json|@file>] [--approval <line>]... [--approval-run <dir>]...
|
||||
* [--base-red <pause.json>]
|
||||
* [--out <file>] [--body <pr-body.md>] [--json]
|
||||
* gstack-ship-receipt read --pr <n> [--repo <owner/repo>] [--check-prose] [--json]
|
||||
* gstack-ship-receipt read --body <file> [--json]
|
||||
@@ -31,25 +32,31 @@
|
||||
*/
|
||||
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 { readPregateFile, verifyPregate } from '../lib/pregate/index';
|
||||
import { isUnavailable, loadPolicyFromBase, matchesAny, policyUnavailableLine, resolveBaseBranch, stampFilesInTree, validatePolicy, type ShipPolicy } from '../lib/ship-policy';
|
||||
import { isUnavailable, loadPolicyFromBase, matchesAny, policyUnavailableLine, resolveBaseBranch, runningGstackVersion, stampFilesInTree, validatePolicy, type ShipPolicy } from '../lib/ship-policy';
|
||||
import { approvalForReceipt } from '../lib/approval-citations';
|
||||
import {
|
||||
CARRY_METADATA_GLOBS, CARRY_NAMED_PATHS, CARRY_TEST_GLOBS,
|
||||
bindReceipt, carryReceipt, classifyCarryPath, computeCensus, extractReceipt, missingIssueLines, orderReceipt, parseReviewLoop, placeReceiptFirst, planCarry,
|
||||
bindReceipt, carryReceipt, classifyCarryPath, computeCensus, extractReceipt, missingIssueLines, orderReceipt, parseReviewLoop, placeReceiptFirst, planCarry, planTreeCarry,
|
||||
receiptIssuesFrom, receiptSummaryLine, renderCensus, renderReceiptBlock, validateReceipt, type CensusPr,
|
||||
} from '../lib/ship-receipt';
|
||||
import { checkDocsCaps, docsCapLine, docsExceptionLine, parseReceiptDocs } from '../lib/ship-receipt-docs';
|
||||
import { comparePath, contextPacket, PACKET_SECTION_CAP, readScopedReview, touchedPaths } from '../lib/ship-patch';
|
||||
import { deeper, depthBelow, exceptionProblems, parseReviewLoopException, parseReviewTargeted, receiptDepthAtHead, reviewLoopExceptionLine } 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';
|
||||
import { isRegistryUnavailable, loadRegistryFromBase } from '../lib/regen';
|
||||
|
||||
const HELP = `gstack-ship-receipt — the \`\`\`gstack-ship-receipt\`\`\` block (docs/ship-policy.md#ship-receipt)
|
||||
|
||||
write [flags] compose, validate and print the block; --body <file> puts it first in that PR body
|
||||
write [flags] compose, validate and print the block; --body <file> puts it first in that PR body;
|
||||
--base-red <pause.json> makes it conditional (gstack-base-red classify --pause)
|
||||
read --pr <n> [--repo <r>] parse the block from the PR body through gh (RECEIPT_MISSING / RECEIPT_INVALID)
|
||||
read --body <file> parse the block from a file
|
||||
read … --check-prose also compare every counter the body quotes (passes=2, 1 deferred, passed: 41)
|
||||
@@ -61,10 +68,12 @@ const HELP = `gstack-ship-receipt — the \`\`\`gstack-ship-receipt\`\`\` block
|
||||
census --repo <r> --since <tag|date> [--cohort <file>]
|
||||
the P7 adoption number over merged PRs (deduplicated by run and thread)
|
||||
carry --pr <n> [--repo <r>] | --body <file> [--base <ref>] [--head <sha>] [--pregate <pregate.json>] [--dry-run] [--out <dir>]
|
||||
carry the receipt to HEAD when only a base merge, tests or release metadata changed
|
||||
since its head (product patch-id unchanged): rewrites the receipt with a \`carry\` record,
|
||||
posts the body (gh pr edit) and prints GSTACK_RESULT: skill=ship status=complete;
|
||||
RECEIPT_CARRY_PRODUCT_CHANGED / RECEIPT_CARRY_REFUSED leave the body unchanged (exit 3);
|
||||
carry the receipt to HEAD when only a base merge, tests, release metadata or registered
|
||||
outputs the --pregate regen check proved (recreated them) changed since its head (product patch-id
|
||||
unchanged), or after a rebase onto a tree the receipt covers with the recorded product
|
||||
patch-id: rewrites the receipt with a \`carry\` record, posts the body (gh pr edit) and
|
||||
prints GSTACK_RESULT: skill=ship status=complete; RECEIPT_CARRY_PRODUCT_CHANGED /
|
||||
RECEIPT_CARRY_TREE_MISMATCH / RECEIPT_CARRY_REFUSED leave the body unchanged (exit 3);
|
||||
a receipt that records a pregate needs --pregate current for HEAD (RECEIPT_CARRY_PREGATE_STALE);
|
||||
needs git >= 2.39 (RECEIPT_GIT_TOO_OLD)
|
||||
docs-caps [--base <ref>] /ship Step 14.5: one DOCS_CAP: <path> <bytes>/<max> ok|over line per docs.caps entry of the
|
||||
@@ -80,6 +89,14 @@ write flags: --pr --head --base --version --gated-tree --predecessor --gate <jso
|
||||
--review-loop <json|@file> ({passes, blocking_found, deferred[, noted, fixed, scope]}: gstack-review-scope summary --out)
|
||||
--docs <json|@file> ({status: updated|current|blocked, scope[, exception: {accepted_by, date, via, reason}]}: Step 14.5's outcome;
|
||||
blocked without an exception is DOCS_AUDIT_BLOCKED, and with --body the body must carry the exception line verbatim)
|
||||
--review-loop-exception <json|@file> ({accepted_by, date, via, reason, fix_check: {head, findings[, files]}}: the owner accepted a
|
||||
stop after a fix-check edit; needs --review-loop with fix_checks >= 1, fix_check.head an ancestor of the head, and with --body the
|
||||
"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.
|
||||
--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
|
||||
@@ -103,7 +120,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 } {
|
||||
@@ -134,10 +151,30 @@ function receiptVersion(repoRoot: string, head: string, base: string): string |
|
||||
return 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* What a later carry across rewritten history compares: the head's tree and its
|
||||
* product patch-id against the base. Both are evidence, never a gate: a head
|
||||
* outside a checkout (or a base that does not resolve) records neither, and
|
||||
* such a receipt then carries only along its own history.
|
||||
*/
|
||||
function reviewedProduct(repoRoot: string, base: string, head: string, pregateFile: string | undefined): { head_tree?: string; product_patch_id?: string } {
|
||||
const tree = git(['rev-parse', '--verify', '--quiet', `${head}^{tree}`], repoRoot);
|
||||
if (!tree) return {};
|
||||
const baseRef = `origin/${base.replace(/^origin\//, '')}`;
|
||||
const id = productPatchAgainstBase(repoRoot, productRules(repoRoot, base, verifiedOutputs(repoRoot, base, pregateFile)), baseRef, head);
|
||||
return typeof id === 'string' ? { head_tree: tree, product_patch_id: id || 'none' } : { head_tree: tree };
|
||||
}
|
||||
|
||||
function cmdWrite(rest: string[], json: boolean): number {
|
||||
const repoRoot = git(['rev-parse', '--show-toplevel']) ?? process.cwd();
|
||||
const head = flag(rest, '--head') ?? git(['rev-parse', 'HEAD'], repoRoot);
|
||||
const checkedOut = git(['rev-parse', 'HEAD'], repoRoot);
|
||||
const head = flag(rest, '--head') ?? checkedOut;
|
||||
if (!head) { process.stderr.write('gstack-ship-receipt write: --head is required outside a git repository\n'); return EXIT.usage; }
|
||||
const headSha = git(['rev-parse', '--verify', '--quiet', `${head}^{commit}`], repoRoot);
|
||||
if (checkedOut && headSha !== checkedOut) {
|
||||
process.stderr.write(resultLine('gstack-ship-receipt write', 'RECEIPT_INVALID', `--head ${head.slice(0, 12)} is not the checked-out HEAD ${checkedOut.slice(0, 12)}; the receipt binds the reviewed tree, so check out ${head.slice(0, 12)} and write there; nothing written`) + '\n');
|
||||
return EXIT.fail;
|
||||
}
|
||||
const receiptFile = flag(rest, '--receipt');
|
||||
const tree = receiptFile ? JSON.parse(fs.readFileSync(receiptFile, 'utf8')) : null;
|
||||
let sessionKind = flag(rest, '--session-kind') ?? process.env.GSTACK_SESSION_KIND;
|
||||
@@ -152,6 +189,8 @@ function cmdWrite(rest: string[], json: boolean): number {
|
||||
if (!read.ok) { for (const e of read.errors) process.stderr.write(resultLine('gstack-ship-receipt write', e.code, `${e.message}; nothing written`) + '\n'); return EXIT.fail; }
|
||||
pregate = read.field;
|
||||
}
|
||||
const pause = flag(rest, '--base-red') ? readPause(flag(rest, '--base-red')!) : undefined;
|
||||
if (typeof pause === 'string') { process.stderr.write(`gstack-ship-receipt write: --base-red: ${pause}; nothing written\n`); return EXIT.usage; }
|
||||
const base = flag(rest, '--base') ?? resolveBaseBranch(repoRoot);
|
||||
const bodyFile = flag(rest, '--body');
|
||||
const bodyText = bodyFile && fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, 'utf8').replace(/```gstack-ship-receipt[\s\S]*?\n```/g, '') : '';
|
||||
@@ -165,6 +204,25 @@ function cmdWrite(rest: string[], json: boolean): number {
|
||||
return EXIT.refused;
|
||||
}
|
||||
}
|
||||
let loopException: ReturnType<typeof parseReviewLoopException> | undefined;
|
||||
if (flag(rest, '--review-loop-exception') !== undefined) {
|
||||
const refuse = (why: string) => { process.stderr.write(resultLineWithDocs('gstack-ship-receipt write', 'REVIEW_LOOP_EXCEPTION_INVALID', `${why}; nothing written`) + '\n'); return EXIT.fail; };
|
||||
try { loopException = parseReviewLoopException(jsonArg(flag(rest, '--review-loop-exception'))); } catch (e: any) { return refuse(`--review-loop-exception: ${e.message}`); }
|
||||
if (!loopException.ok) return refuse(loopException.message);
|
||||
let loop: unknown; try { loop = jsonArg(flag(rest, '--review-loop')); } catch {}
|
||||
if (!parseReviewLoop(loop)?.fix_checks) return refuse('--review-loop-exception needs --review-loop with fix_checks >= 1: the stop it accepts follows a fix-check');
|
||||
const fc = loopException.exception.fix_check.head;
|
||||
if (spawnSync('git', ['merge-base', '--is-ancestor', fc, head], { cwd: repoRoot }).status !== 0) return refuse(`fix_check.head ${fc.slice(0, 12)} is not ${head.slice(0, 12)} or an ancestor of it`);
|
||||
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 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}` }; }
|
||||
}
|
||||
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') });
|
||||
if (approval.kind === 'invalid') {
|
||||
for (const line of approval.lines) process.stderr.write(resultLine('gstack-ship-receipt write', 'APPROVAL_CLASSES_INVALID', line) + '\n');
|
||||
@@ -194,7 +252,9 @@ function cmdWrite(rest: string[], json: boolean): number {
|
||||
session_kind: sessionKind, artifacts_consumed: flag(rest, '--artifacts-consumed'), run: flag(rest, '--run'), thread: flag(rest, '--thread'),
|
||||
policy: flag(rest, '--policy') ?? tree?.policy, queue_mode: flag(rest, '--queue-mode'), preregistration: flag(rest, '--preregistration'), history: flag(rest, '--history'),
|
||||
...pregate, issues: issuesFile ? receiptIssuesFrom(issuesFile) : undefined, approval: approval.kind === 'ok' ? approval.approval : undefined,
|
||||
review_loop: reviewLoop, ...(docs?.ok ? { docs: docs.docs } : {}),
|
||||
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,
|
||||
};
|
||||
} 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; }
|
||||
@@ -303,13 +363,16 @@ function cmdResult(rest: string[], json: boolean): number {
|
||||
}
|
||||
source = `PR #${pr}${repo ? ` (${repo})` : ''}`;
|
||||
} else { process.stderr.write(`gstack-ship-receipt result: --pr <n> or --body <file> is required\n${HELP}\n`); return EXIT.usage; }
|
||||
const bound = bindReceipt(body, pr, [head]);
|
||||
const bound = bindReceipt(body, pr, [head], { conditional: true });
|
||||
if (!bound.ok) return refused(bound.code, bound.message);
|
||||
return writeRunRecord({ receipt: bound.receipt, head, pr, source, repoRoot, out: flag(rest, '--out'), json, line: bound.line, incomplete });
|
||||
const red = bound.receipt.base_red;
|
||||
if (red) process.stderr.write(`SHIP_PAUSED: base-red cases=${red.cases.length} base=${red.base.slice(0, 12)} lane=${red.lane} expires=${red.expires_at}; gstack-base-red wait --pause <file>, then /ship --resume <record>\n`);
|
||||
return writeRunRecord({ receipt: bound.receipt, head, pr, source, repoRoot, out: flag(rest, '--out'), json, line: bound.line, incomplete, status: red ? 'incomplete' : 'complete' });
|
||||
}
|
||||
|
||||
/** The run record under <state>/ship/runs/ and the terminal line, shared by `result` and `carry`. */
|
||||
function writeRunRecord(a: { receipt: ShipReceipt; head: string; pr: string | undefined; source: string; repoRoot: string | undefined; out: string | undefined; json: boolean; line: string; incomplete: (why: string) => number; extra?: Record<string, unknown> }): number {
|
||||
function writeRunRecord(a: { receipt: ShipReceipt; head: string; pr: string | undefined; source: string; repoRoot: string | undefined; out: string | undefined; json: boolean; line: string; incomplete: (why: string) => number; extra?: Record<string, unknown>; status?: 'complete' | 'incomplete' }): number {
|
||||
const status = a.status ?? 'complete';
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const dir = a.out ? path.resolve(a.out) : path.join(resolveStateRoot(), 'ship', 'runs', `${stamp}-${a.head.slice(0, 12)}`);
|
||||
const receiptPath = path.join(dir, 'ship-receipt.md');
|
||||
@@ -318,15 +381,15 @@ function writeRunRecord(a: { receipt: ShipReceipt; head: string; pr: string | un
|
||||
fs.writeFileSync(receiptPath, renderReceiptBlock(a.receipt));
|
||||
} catch (e) { return a.incomplete(`cannot write the run record under ${dir}: ${e instanceof Error ? e.message : String(e)}`); }
|
||||
const run = {
|
||||
schema_version: 1, skill: 'ship', status: 'complete', head: a.head, pr: a.receipt.pr ?? (a.pr ? Number(a.pr) : null), base: a.receipt.base, version: a.receipt.version ?? null,
|
||||
schema_version: 1, skill: 'ship', status, head: a.head, pr: a.receipt.pr ?? (a.pr ? Number(a.pr) : null), base: a.receipt.base, version: a.receipt.version ?? null,
|
||||
queue_mode: a.receipt.queue_mode ?? null, session_kind: a.receipt.session_kind ?? process.env.GSTACK_SESSION_KIND ?? null,
|
||||
receipt: receiptPath, receipt_source: a.source, branch: a.repoRoot ? git(['rev-parse', '--abbrev-ref', 'HEAD'], a.repoRoot) ?? null : null, created_at: new Date().toISOString(),
|
||||
...(a.extra ?? {}),
|
||||
};
|
||||
try { fs.writeFileSync(path.join(dir, 'ship-run.json'), JSON.stringify(run, null, 2) + '\n'); } catch (e) { return a.incomplete(`cannot write ${path.join(dir, 'ship-run.json')}: ${e instanceof Error ? e.message : String(e)}`); }
|
||||
process.stderr.write(a.line + '\n');
|
||||
process.stdout.write(a.json ? JSON.stringify({ ...run, run: dir }) + '\n' : `GSTACK_RESULT: skill=ship status=complete run=${dir}\n`);
|
||||
return EXIT.ok;
|
||||
process.stdout.write(a.json ? JSON.stringify({ ...run, run: dir }) + '\n' : `GSTACK_RESULT: skill=ship status=${status} run=${dir}\n`);
|
||||
return status === 'complete' ? EXIT.ok : EXIT.fail;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -337,12 +400,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. */
|
||||
@@ -367,13 +455,84 @@ function pregateFieldFor(repoRoot: string, file: string, head: string): { ok: tr
|
||||
return { ok: true, field: { pregate: `${read.file.verdict ?? 'pass'} @${read.file.tree.slice(0, 12)} (${read.file.checks.length} checks, current for ${head.slice(0, 12)})`, requires_remote: remote.length ? remote.map(r => `${r.lane}: ${r.receipt}`) : undefined } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generated outputs the carry may set aside as metadata: the paths the pregate
|
||||
* passed for HEAD proved (its regen check deleted each touched output and the
|
||||
* entry's command recreated it byte-identically), when that check ran
|
||||
* origin/<base>'s `.gstack/generated.json` at the commit this carry reads and the
|
||||
* path is an output of an entry with a `command` there. A proven name with glob
|
||||
* syntax is product too: the carry uses these paths as pathspec globs.
|
||||
*/
|
||||
function verifiedOutputs(repoRoot: string, base: string, pregateFile: string | undefined): string[] {
|
||||
if (!pregateFile) return [];
|
||||
const regen = readPregateFile(pregateFile).file?.checks.find(c => c.id === 'regen');
|
||||
if (!regen || regen.status !== 'pass' || !regen.proven?.length) return [];
|
||||
const load = loadRegistryFromBase(repoRoot, base);
|
||||
if (isRegistryUnavailable(load) || load.errors.length || regen.inputs?.registry !== load.source.label) return [];
|
||||
const owned = load.registry.entries.filter(e => e.command).flatMap(e => e.outputs);
|
||||
return regen.proven.filter(p => !/[*?[\]\\]/.test(p) && matchesAny(owned, p));
|
||||
}
|
||||
|
||||
interface ProductRules { extra: { metadata: string[]; outputs: readonly string[]; fragments?: string }; metadata: string[]; templates: readonly string[] }
|
||||
function productRules(repoRoot: string, base: string, outputs: readonly string[]): ProductRules {
|
||||
const load = loadPolicyFromBase(repoRoot, base);
|
||||
const policy: ShipPolicy | null = isUnavailable(load) ? null : load.policy;
|
||||
const fragments = policy?.changelog_fragments?.dir.replace(/^(\.\/)+/, '').replace(/\/+$/, '');
|
||||
const extra = { metadata: [...(policy?.stamp_files ?? []), ...(policy?.stamp_paths ?? []), ...(policy?.release_outputs ?? []), ...(policy?.changelog ? [policy.changelog] : [])], outputs, fragments };
|
||||
return { extra, metadata: [...CARRY_METADATA_GLOBS, ...extra.metadata, ...(fragments ? [`${fragments}/*.md`] : []), ...outputs], templates: policy?.stamp_patterns ?? [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, branch?: { touched: ReadonlySet<string>; sides: ReadonlyArray<readonly [string, string]> }): { 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; };
|
||||
const versionAt = (ref: string): string | null => {
|
||||
const file = blobAt(ref, 'VERSION');
|
||||
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 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 } : 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 = !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)] };
|
||||
}
|
||||
|
||||
/** A commit's product patch-id against its merge base with the base ('' for no product diff), as write records it and a rewritten-history carry recomputes it. */
|
||||
function productPatchAgainstBase(repoRoot: string, rules: ProductRules, baseRef: string, commit: string): string | { error: string } {
|
||||
const mergeBase = git(['merge-base', baseRef, commit], repoRoot);
|
||||
if (!mergeBase) return { error: `cannot resolve the merge base of ${baseRef} and ${commit.slice(0, 12)}` };
|
||||
const spec = productSpec(repoRoot, rules, mergeBase, commit);
|
||||
if (!spec) return { error: `git diff ${mergeBase.slice(0, 12)} ${commit.slice(0, 12)} failed` };
|
||||
return patchId(repoRoot, mergeBase, commit, spec.product);
|
||||
}
|
||||
|
||||
/**
|
||||
* The receipt follows HEAD when the reviewed product is unchanged
|
||||
* (docs/unattended.md#the-terminal-line; harness lane E row 3). Four Captains
|
||||
* reran a full /ship on identical product code after a base merge or a
|
||||
* test-only fix; the carry compares the product diff against the base at the
|
||||
* receipt's head and at HEAD by patch-id, classifies every changed path, and
|
||||
* records the range-diff as evidence in the receipt it rewrites.
|
||||
* records the range-diff as evidence in the receipt it rewrites. After a rebase
|
||||
* or force-push it carries only onto a tree the receipt covers whose product
|
||||
* patch-id against the current base is the recorded one (planTreeCarry).
|
||||
*/
|
||||
function cmdCarry(rest: string[], json: boolean): number {
|
||||
const bodyFile = flag(rest, '--body');
|
||||
@@ -384,7 +543,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' | 'RECEIPT_CARRY_REFUSED' | 'RECEIPT_CARRY_PRODUCT_CHANGED' | '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;
|
||||
@@ -416,6 +575,7 @@ function cmdCarry(rest: string[], json: boolean): number {
|
||||
if (!ex.receipt || ex.errors.length) return refused('QUEUE_RECEIPT_MISSING', `${source}'s ship receipt is invalid (${ex.errors[0]?.message ?? 'invalid'}); run /ship`);
|
||||
const receipt = ex.receipt;
|
||||
if (pr !== undefined && receipt.pr !== undefined && String(receipt.pr) !== pr) return refused('RECEIPT_CARRY_REFUSED', `the receipt names pr=${receipt.pr}, not this PR`);
|
||||
if (receipt.base_red) return refused('QUEUE_RECEIPT_CONDITIONAL', 'the receipt is paused on a red base, so there is nothing passing to carry; /ship --resume <record> re-runs Step 5 and writes a full receipt');
|
||||
const from = receipt.head.toLowerCase();
|
||||
if (!/^[0-9a-f]{40,64}$/.test(from)) return refused('RECEIPT_CARRY_REFUSED', `the receipt's head ${receipt.head.slice(0, 12)} is not a full commit id`);
|
||||
if (from === head) {
|
||||
@@ -425,66 +585,122 @@ function cmdCarry(rest: string[], json: boolean): number {
|
||||
}
|
||||
const oldGit = gitVersionBelow(repoRoot, CARRY_MIN_GIT);
|
||||
if (oldGit) return refused('RECEIPT_GIT_TOO_OLD', `git ${oldGit} cannot run patch-id --verbatim; the carry needs git ${CARRY_MIN_GIT} or later`);
|
||||
const ancestor = spawnSync('git', ['merge-base', '--is-ancestor', from, head], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000 });
|
||||
if (ancestor.status !== 0) return refused('RECEIPT_CARRY_REFUSED', `the receipt's head ${from.slice(0, 12)} is not an ancestor of HEAD ${head.slice(0, 12)} (rebased or force-pushed); merge the base in next time, and run /ship now`);
|
||||
// The carry binds HEAD alone: pending edits are not in the commits it compares, and a parent that stops at CARRY: carried would report them shipped.
|
||||
const status = spawnSync('git', ['status', '--porcelain', '--untracked-files=all'], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
|
||||
if (status.status !== 0) return refused('RECEIPT_CARRY_REFUSED', 'git status failed; the carry needs a readable working tree');
|
||||
const dirty = status.stdout.split('\n').filter(Boolean).map(l => l.slice(3).replace(/^.* -> /, ''));
|
||||
if (dirty.length) return refused('RECEIPT_CARRY_REFUSED', `the working tree has uncommitted changes (${dirty.slice(0, 5).join(', ')}${dirty.length > 5 ? ` +${dirty.length - 5} more` : ''}); commit them and carry at the new head, or run /ship`);
|
||||
const fromKnown = git(['cat-file', '-e', `${from}^{commit}`], repoRoot) !== undefined;
|
||||
const ancestor = fromKnown && spawnSync('git', ['merge-base', '--is-ancestor', from, head], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000 }).status === 0;
|
||||
const fromTree = fromKnown ? git(['rev-parse', `${from}^{tree}`], repoRoot) : undefined;
|
||||
const covered = [...(receipt.head_tree ? [receipt.head_tree] : fromTree ? [fromTree] : []), ...(receipt.carry?.trees ?? [])];
|
||||
if (!ancestor && !covered.length) return refused('RECEIPT_CARRY_REFUSED', `the receipt's head ${from.slice(0, 12)} is not an ancestor of HEAD ${head.slice(0, 12)} (rebased or force-pushed), and the receipt records no tree to compare; merge the base in next time, and run /ship now`);
|
||||
const base = flag(rest, '--base') ?? receipt.base ?? resolveBaseBranch(repoRoot);
|
||||
const baseRef = `origin/${base.replace(/^origin\//, '')}`;
|
||||
const fromBase = git(['merge-base', baseRef, from], repoRoot);
|
||||
const headBase = git(['merge-base', baseRef, head], repoRoot);
|
||||
if (!fromBase || !headBase) return refused('RECEIPT_CARRY_REFUSED', `cannot resolve the merge base of ${baseRef} (fetch it first)`);
|
||||
const load = loadPolicyFromBase(repoRoot, base);
|
||||
const policy = isUnavailable(load) ? null : load.policy;
|
||||
const extra = { metadata: [...(policy?.stamp_files ?? []), ...(policy?.stamp_paths ?? []), ...(policy?.release_outputs ?? []), ...(policy?.changelog ? [policy.changelog] : [])] };
|
||||
const changedRaw = git(['diff', '--name-only', from, head], repoRoot);
|
||||
if (changedRaw === undefined) return refused('RECEIPT_CARRY_REFUSED', `git diff ${from.slice(0, 12)} ${head.slice(0, 12)} failed`);
|
||||
// Every metadata-listed path is read at both heads: only a stamp keeps it out of the product patch.
|
||||
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; };
|
||||
const versionAt = (ref: string): string | null => {
|
||||
const file = blobAt(ref, 'VERSION');
|
||||
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(head);
|
||||
const allMetadataGlobs = [...CARRY_METADATA_GLOBS, ...extra.metadata];
|
||||
const changed = changedRaw.split('\n').filter(Boolean).map(p => ({ path: p, class: classifyCarryPath(p, extra, matchesAny(allMetadataGlobs, p) ? { before: blobAt(from, p), after: blobAt(head, p), oldVersion, newVersion, templates: policy?.stamp_patterns ?? [] } : undefined) }));
|
||||
// Pathspecs by class: tests and metadata are excluded from the product patch; the test patch is the tests alone.
|
||||
// A metadata-listed path whose change was not a stamp stays in the product patch: every glob that matches it is dropped from the exclusion.
|
||||
const excludeGlobs = (globs: readonly string[]) => globs.map(g => `:(exclude,glob)${g}`);
|
||||
const demoted = changed.filter(c => c.class === 'product' && matchesAny(allMetadataGlobs, c.path)).map(c => c.path);
|
||||
const metadataGlobs = allMetadataGlobs.filter(g => !demoted.some(p => matchesAny([g], p)));
|
||||
const productSpec = ['.', ...excludeGlobs(CARRY_TEST_GLOBS), ...excludeGlobs(metadataGlobs)];
|
||||
const testSpec = [...CARRY_TEST_GLOBS.map(g => `:(glob)${g}`), ...excludeGlobs(metadataGlobs)];
|
||||
const productFrom = patchId(repoRoot, fromBase, from, productSpec);
|
||||
const productHead = patchId(repoRoot, headBase, head, productSpec);
|
||||
const asId = (r: string | { error: string }) => (typeof r === 'string' ? r : '');
|
||||
const testFrom = asId(patchId(repoRoot, fromBase, from, testSpec));
|
||||
const testHead = asId(patchId(repoRoot, headBase, head, testSpec));
|
||||
for (const r of [productFrom, productHead]) if (typeof r !== 'string') return refused('RECEIPT_CARRY_REFUSED', `git could not compute the product patch-id at one of the heads: ${r.error}`);
|
||||
// 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 changedProduct: string[] = [];
|
||||
if (productFrom !== productHead) {
|
||||
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()) {
|
||||
if (changedProduct.length >= CARRY_NAMED_PATHS) break;
|
||||
const cls = changed.find(c => c.path === p)?.class ?? classifyCarryPath(p, extra);
|
||||
if (cls !== 'product') continue;
|
||||
if (asId(patchId(repoRoot, fromBase, from, [p])) !== asId(patchId(repoRoot, headBase, head, [p]))) changedProduct.push(p);
|
||||
if (!headBase) return refused('RECEIPT_CARRY_REFUSED', `cannot resolve the merge base of ${baseRef} (fetch it first)`);
|
||||
const rules = productRules(repoRoot, base, verifiedOutputs(repoRoot, base, flag(rest, '--pregate')));
|
||||
const headTree = git(['rev-parse', `${head}^{tree}`], repoRoot);
|
||||
if (!headTree) return refused('RECEIPT_CARRY_REFUSED', `git could not read the tree of HEAD ${head.slice(0, 12)}`);
|
||||
const headPatch = productPatchAgainstBase(repoRoot, rules, baseRef, head);
|
||||
if (typeof headPatch !== 'string') return refused('RECEIPT_CARRY_REFUSED', `git could not compute HEAD's product patch-id: ${headPatch.error}`);
|
||||
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' };
|
||||
if (typeof recorded !== 'string') return refused('RECEIPT_CARRY_REFUSED', `the reviewed product patch-id is unavailable: ${recorded.error}; run /ship on ${head.slice(0, 12)}`);
|
||||
plan = planTreeCarry({ from, head, headTree, coveredTrees: covered, productPatchId: { recorded, head: headPatch } });
|
||||
} 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 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]] });
|
||||
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);
|
||||
const productHead = patchId(repoRoot, headBase, head, spec.product);
|
||||
const asId = (r: string | { error: string }) => (typeof r === 'string' ? r : '');
|
||||
const testFrom = asId(patchId(repoRoot, fromBase, from, spec.tests));
|
||||
const testHead = asId(patchId(repoRoot, headBase, head, spec.tests));
|
||||
for (const r of [productFrom, productHead]) if (typeof r !== 'string') return refused('RECEIPT_CARRY_REFUSED', `git could not compute the product patch-id at one of the heads: ${r.error}`);
|
||||
// A stacked child whose parent landed on the base (#3570): the receipt reviewed the child's own range against the
|
||||
// parent branch, and the PR now targets the base. That reviewed range (the recorded product patch-id, else the one
|
||||
// against origin/<receipt base> when it still resolves) must be HEAD's whole product diff against the base, computed
|
||||
// the way write records it (headPatch): the parent fully landed and the child is unchanged. Anything else fails closed.
|
||||
let reviewed = productFrom;
|
||||
if (retarget) {
|
||||
const parentRef = `origin/${reviewedBase}`;
|
||||
const recorded = receipt.product_patch_id !== undefined ? (receipt.product_patch_id === 'none' ? '' : receipt.product_patch_id)
|
||||
: git(['rev-parse', '--verify', '--quiet', `${parentRef}^{commit}`], repoRoot) ? productPatchAgainstBase(repoRoot, rules, parentRef, from) : { error: `the receipt records no product patch-id and ${parentRef} no longer resolves` };
|
||||
if (typeof recorded !== 'string') return refused('RECEIPT_CARRY_REFUSED', `the receipt was reviewed against ${reviewedBase}, not ${base}, and its reviewed product diff is unavailable: ${recorded.error}; run /ship on ${head.slice(0, 12)}`);
|
||||
reviewed = recorded;
|
||||
}
|
||||
if (!changedProduct.length) changedProduct.push('(a product path outside the branch\'s touched files; compare the two base diffs)');
|
||||
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 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;
|
||||
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(', ')})`;
|
||||
}
|
||||
}
|
||||
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 } : {}), ...(scopedReview ? { scopedReview } : {}) });
|
||||
}
|
||||
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')}`;
|
||||
const plan = planCarry({ from, head, changed, productPatchId: { from: productFrom, head: productHead }, testPatchId: { from: testFrom, head: testHead }, changedProduct, mergeCommits: Number(mergeCommits) || 0, rangeDiff });
|
||||
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,
|
||||
// so a carried receipt never claims the old tree's checks (docs/ship-policy.md#receipt-carry).
|
||||
@@ -499,11 +715,26 @@ function cmdCarry(rest: string[], json: boolean): number {
|
||||
} else if (receipt.pregate && !rest.includes('--dry-run')) {
|
||||
return refused('RECEIPT_CARRY_PREGATE_STALE', `the receipt records a pregate for ${receipt.head.slice(0, 12)}; run gstack-pregate at HEAD ${head.slice(0, 12)} and pass --pregate <pregate.json>`);
|
||||
}
|
||||
const carried = carryReceipt(pregateField ? { ...receipt, ...pregateField } : receipt, head, plan.carry);
|
||||
let depthField: Partial<ShipReceipt> = {};
|
||||
const isAncestor = (commit: string) => !ancestor || spawnSync('git', ['merge-base', '--is-ancestor', commit, head], { cwd: repoRoot, timeout: 30_000 }).status === 0;
|
||||
const problems = exceptionProblems(body, receipt, isAncestor);
|
||||
if (problems.length) return refused('RECEIPT_CARRY_REFUSED', `the receipt's owner exception does not stand: ${problems.join('; ')}; run /ship on ${head.slice(0, 12)}`);
|
||||
if (receipt.review_depth || receipt.gstack_version) {
|
||||
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);
|
||||
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) } };
|
||||
}
|
||||
// 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 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`);
|
||||
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
||||
/**
|
||||
* gstack-ship-release — /ship Step 12 (version), Steps 15-16 (finish) and Step 17 (push),
|
||||
* with STOP:/NEXT: lines wherever the agent must judge. Logic: lib/ship-release.ts.
|
||||
*/
|
||||
import { release } from '../lib/ship-release';
|
||||
|
||||
const out = await release(process.argv.slice(2), process.cwd());
|
||||
(out.exit === 2 ? process.stderr : process.stdout).write(out.text);
|
||||
process.exit(out.exit);
|
||||
+19
-1
@@ -609,8 +609,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
@@ -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."
|
||||
|
||||
+17
-1
@@ -5,6 +5,7 @@
|
||||
* gstack-state-matrix detect [--base <ref>] [--diff-base <sha>]
|
||||
* gstack-state-matrix check <specialist-output|-> [--repo <dir>] [--expect <expectation.json>] [--json]
|
||||
* gstack-state-matrix outfile
|
||||
* gstack-state-matrix findings <reviewer-output|->
|
||||
*
|
||||
* `detect` prints `SCOPE_STATE_MACHINE=true|false` (shell-safe for
|
||||
* `source <(...)`, with `# state-machine:` comment lines naming the hits) from
|
||||
@@ -14,7 +15,10 @@
|
||||
* last; exit 0 ok, 1 incomplete (STATE_MATRIX_INCOMPLETE). `outfile` creates and
|
||||
* prints a new empty 0600 file in the repo's private `.gstack/tmp/` (0700, kept
|
||||
* out of git through info/exclude) for the specialist's output, so a later
|
||||
* review never checks or commits an earlier run's file. Logic: lib/state-matrix.ts.
|
||||
* review never checks or commits an earlier run's file. `findings` prints the
|
||||
* output for Step 9.3's merge with each untested cell mapped below the floor
|
||||
* (lib/review-scope.ts mapStateMatrixOutput) and `STATE_MATRIX_FINDINGS:` on
|
||||
* stderr. Logic: lib/state-matrix.ts.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
@@ -24,6 +28,7 @@ import { addedLines } from '../lib/banned-terms';
|
||||
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
|
||||
import { resultLine } from '../lib/result-codes';
|
||||
import { resolveBaseBranch } from '../lib/ship-policy';
|
||||
import { mapStateMatrixOutput } from '../lib/review-scope';
|
||||
import { checkStateMatrix, detectStateMachine, renderStateMatrixCheck, type Expectation } from '../lib/state-matrix';
|
||||
|
||||
const HELP = `gstack-state-matrix — kind × owner action × resulting row for proposal, hold and approval state machines
|
||||
@@ -32,6 +37,7 @@ const HELP = `gstack-state-matrix — kind × owner action × resulting row for
|
||||
check <file|-> [--repo <dir>] [--expect <json>] [--json]
|
||||
gaps in the state-machines specialist's output (review/specialists/state-machines.md)
|
||||
outfile print a new empty private file for that output
|
||||
findings <file|-> that output for the Step 9.3 merge, each untested cell mapped below the floor
|
||||
|
||||
${exitTableHelp()}`;
|
||||
|
||||
@@ -71,6 +77,15 @@ function check(rest: string[]): number {
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
function findings(rest: string[]): number {
|
||||
const input = rest.find(a => !a.startsWith('--'));
|
||||
if (!input) { process.stderr.write(`gstack-state-matrix findings: a reviewer output file (or -) is required\n${HELP}\n`); return EXIT.usage; }
|
||||
const r = mapStateMatrixOutput(input === '-' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(input, 'utf8'));
|
||||
process.stdout.write(r.text.endsWith('\n') ? r.text : `${r.text}\n`);
|
||||
process.stderr.write(`STATE_MATRIX_FINDINGS: mapped=${r.mapped} (untested cells below the floor; docs/review-policy.md test gap)\n`);
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
function outfile(): number {
|
||||
const root = git(['rev-parse', '--show-toplevel']);
|
||||
const exclude = git(['rev-parse', '--git-path', 'info/exclude']);
|
||||
@@ -94,6 +109,7 @@ function main(argv: string[]): number {
|
||||
if (cmd === 'detect') return detect(rest);
|
||||
if (cmd === 'check') return check(rest);
|
||||
if (cmd === 'outfile') return outfile();
|
||||
if (cmd === 'findings') return findings(rest);
|
||||
process.stderr.write(`gstack-state-matrix: unknown command ${cmd}\n${HELP}\n`);
|
||||
return EXIT.usage;
|
||||
}
|
||||
|
||||
Executable
+108
@@ -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));
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
@@ -141,6 +142,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"
|
||||
|
||||
@@ -546,6 +548,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
|
||||
|
||||
+23
-7
@@ -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
|
||||
|
||||
@@ -64,11 +66,15 @@ bounded() { if command -v timeout >/dev/null 2>&1; then timeout "${GSTACK_UPGRAD
|
||||
|
||||
# Git for Windows answers --show-toplevel as C:/x while pwd -P in Git Bash is
|
||||
# /c/x; compare the paths, not their spellings.
|
||||
# shellcheck source=bin/gstack-harness-pid.sh
|
||||
. "$ROOT/bin/gstack-harness-pid.sh" 2>/dev/null || GSTACK_SHARED_HARNESSES="capy-server"
|
||||
# shellcheck source=bin/gstack-path-spelling.sh
|
||||
. "$ROOT/bin/gstack-path-spelling.sh" 2>/dev/null || gstack_path_same() { [ "$1" = "$2" ]; }
|
||||
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"
|
||||
@@ -208,14 +214,24 @@ if ! git -C "$ROOT" merge-base --is-ancestor HEAD FETCH_HEAD 2>/dev/null; then
|
||||
fi
|
||||
# Workflow leases (bin/gstack-lease): sessions sharing this installation that
|
||||
# are alive and not this process's own chain. Moving the checkout under them
|
||||
# is what the lease exists to prevent; the caller's own session never counts.
|
||||
# is what the lease exists to prevent; the caller's own session never counts,
|
||||
# except under a shared harness (capy-server), where every session on the
|
||||
# machine is in the caller's chain: its lease counts until it is 120 minutes
|
||||
# old (GST-396).
|
||||
if [ "${GSTACK_UPGRADE_FORCE:-}" != 1 ] && [ -x "$ROOT/bin/gstack-lease" ] \
|
||||
&& BUSY=$("$ROOT/bin/gstack-lease" live --root "$ROOT" --exclude-ancestry-of $$ 2>/dev/null); then
|
||||
HOLDERS=""
|
||||
while read -r _pid _age; do HOLDERS="$HOLDERS${HOLDERS:+, }pid $_pid ($_age)"; done <<EOF_BUSY
|
||||
&& BUSY=$("$ROOT/bin/gstack-lease" live --root "$ROOT" --exclude-ancestry-of $$ --count-shared-harness 2>/dev/null); then
|
||||
HOLDERS="" SHARED_HOLDER=""
|
||||
while read -r _pid _age _comm; do
|
||||
HOLDERS="$HOLDERS${HOLDERS:+, }pid $_pid ($_age${_comm:+ $_comm})"
|
||||
case " $GSTACK_SHARED_HARNESSES " in *" ${_comm:-none} "*) SHARED_HOLDER="$_comm, pid $_pid) holds a lease $_age old" ;; esac
|
||||
done <<EOF_BUSY
|
||||
$BUSY
|
||||
EOF_BUSY
|
||||
echo "gstack-upgrade: live gstack sessions hold this installation: $HOLDERS; nothing was changed; fix: wait for those sessions to finish, or re-run with GSTACK_UPGRADE_FORCE=1 when you know they are idle (UPGRADE_BUSY)" >&2
|
||||
if [ -n "$SHARED_HOLDER" ]; then
|
||||
echo "gstack-upgrade: a shared harness ($SHARED_HOLDER; every session on this machine runs under it, so the upgrade waits until the lease is 120 minutes old (counted from the last renewal by any session); nothing was changed; fix: re-run then, or with GSTACK_UPGRADE_FORCE=1 when no other session uses this checkout (UPGRADE_BUSY)" >&2
|
||||
else
|
||||
echo "gstack-upgrade: live gstack sessions hold this installation: $HOLDERS; nothing was changed; fix: wait for those sessions to finish, or re-run with GSTACK_UPGRADE_FORCE=1 when you know they are idle (UPGRADE_BUSY)" >&2
|
||||
fi
|
||||
exit 3
|
||||
fi
|
||||
# shellcheck source=bin/gstack-bun-version.sh
|
||||
|
||||
+11
-2
@@ -25,8 +25,14 @@
|
||||
// the caller must handle), exit 2 on bad args / unresolvable base /
|
||||
// broken version source.
|
||||
//
|
||||
// write --version <X.Y.Z.W> [--version-path <p>] [--regen-digest] [--changelog <p>]
|
||||
// write --version <X.Y.Z.W> [--version-path <p>] [--regen-digest] [--changelog <p>] [--manifest-form npm|full]
|
||||
// Validates the 4-digit pattern, writes VERSION + package.json.version.
|
||||
// --manifest-form full: the manifest and its npm lockfiles carry the
|
||||
// version as written instead of the npm-valid translation (default
|
||||
// npm). For repos whose declared convention is a four-part manifest
|
||||
// (gbrain); gstack-restamp passes it only when the policy lists the
|
||||
// manifest as a stamp file and the base's copy holds four parts. No
|
||||
// effect when the version path is itself a JSON manifest.
|
||||
// --changelog <p> (opt-in): re-head the branch's `## [Unreleased]`
|
||||
// entry in <p> as `## [<version>] - <UTC date>` at the top, with
|
||||
// lib/changelog-check's reheadChangelog — the same function
|
||||
@@ -455,6 +461,8 @@ function cmdWrite(args: string[], cwd: string): void {
|
||||
if (!VERSION_RE.test(version!)) {
|
||||
fail(`NEW_VERSION (${version}) does not match MAJOR.MINOR.PATCH[.MICRO]. Aborting.`, 2);
|
||||
}
|
||||
const manifestForm = argVal(args, "--manifest-form") ?? "npm";
|
||||
if (manifestForm !== "npm" && manifestForm !== "full") fail("write: --manifest-form needs npm|full", 2);
|
||||
const src = versionSource(cwd, argVal(args, "--version-path"));
|
||||
if (src.outcome === "broken") failBroken(src, "write");
|
||||
if (src.outcome !== "valid") {
|
||||
@@ -528,7 +536,8 @@ function cmdWrite(args: string[], cwd: string): void {
|
||||
// 3-digit translation — npm rejects a fourth component, so mirroring the
|
||||
// raw 4-digit form breaks `npm ci` in any repo npm actually manages.
|
||||
// VERSION keeps the full 4-digit form; it stays the source of truth.
|
||||
const manifestV = npmVersion(version!);
|
||||
// --manifest-form full keeps a repo's declared four-part manifest as is.
|
||||
const manifestV = manifestForm === "full" ? version! : npmVersion(version!);
|
||||
if (hasPkg) {
|
||||
let pkgWritten = false;
|
||||
try {
|
||||
|
||||
+19
-6
@@ -1,10 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
# gstack-wtree — print a working-tree CONTENT fingerprint (a git tree hash).
|
||||
#
|
||||
# Builds a temp index, stages the full working tree into it (`git add -A`, so
|
||||
# .gitignore'd scratch stays out and UNTRACKED source is included), and prints
|
||||
# `git write-tree` of that index. Properties that make this the right
|
||||
# staleness fingerprint, vs `git rev-parse HEAD^{tree}`:
|
||||
# Builds a temp index, stages the full working tree into it (`git add -u` for
|
||||
# every tracked file, then `git add -A` for the rest, so .gitignore'd scratch
|
||||
# stays out and UNTRACKED source is included), and prints `git write-tree` of
|
||||
# that index. An untracked `.bun/` folder at the repository root (Bun's install
|
||||
# cache, which a test run can create) is left out so a passing run does not
|
||||
# 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.
|
||||
# Properties that make this the right staleness fingerprint, vs
|
||||
# `git rev-parse HEAD^{tree}`:
|
||||
#
|
||||
# - Committing identical content does NOT change the fingerprint, so a
|
||||
# record made on a dirty tree stays valid after the exact same content is
|
||||
@@ -15,7 +21,7 @@
|
||||
#
|
||||
# Performance: the temp index is seeded by COPYING the real index (git writes
|
||||
# it atomically via rename, so the copy is a consistent snapshot). That
|
||||
# preserves the stat cache, so `git add -A` only re-hashes files whose stat
|
||||
# preserves the stat cache, so staging only re-hashes files whose stat
|
||||
# changed — measured 40x faster than a `read-tree HEAD` seed, which zeroes
|
||||
# stat data and forces a full re-hash of every tracked file. Both seeds
|
||||
# produce the identical write-tree hash. Fallback: `read-tree HEAD` when the
|
||||
@@ -82,7 +88,14 @@ if [ -n "$REAL_INDEX" ] && [ -f "$REAL_INDEX" ] && cp "$REAL_INDEX" "$TMPIDX" 2>
|
||||
else
|
||||
git -C "$TOP" read-tree HEAD 2>/dev/null || exit 1
|
||||
fi
|
||||
if ! ERR=$(git -C "$TOP" add -A 2>&1 >/dev/null); then
|
||||
# The exclude is added only when the root .bun/ exists and is not ignored: an
|
||||
# exclude pathspec naming an ignored path makes `git add -A` exit 1 (GST-449),
|
||||
# and an ignored .bun/ stays out of a plain `git add -A` anyway. --no-index
|
||||
# asks the ignore rules alone; without it a tracked file under .bun/ makes git
|
||||
# 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
|
||||
echo "gstack-wtree: cannot stage the working tree: $(printf '%s' "$ERR" | head -1)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -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
|
||||
|
||||
Loaded 100 of 848 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user