mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 16:34:00 +01:00
merge origin/main (v1.91.237.0) into capy/ready-ci-rollup-and-docs; the conflict fixture carries a ship receipt, the --screen refusal is named as main prints it
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
commit
e3f3510208
173 files changed
+8817
-538
No files matched your search
@@ -7,6 +7,11 @@ name: Main backstop gate
|
||||
# backstop writes the freeze record gstack-ship-queue reads. Inert (no paid
|
||||
# work, nothing written) unless main's .gstack/ship-policy.json declares a
|
||||
# `train` block; without one the per-PR gates cover main as before.
|
||||
#
|
||||
# The verdict is published on the commit as the main-backstop check run
|
||||
# (success when a green train covers the tree or the backstop gated it green,
|
||||
# neutral when the push was superseded or deferred, failure when it froze the
|
||||
# queue or could not finish); the weekly release reads it (docs/releases.md).
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -25,6 +30,8 @@ jobs:
|
||||
if: github.actor != 'dependabot[bot]'
|
||||
# Settle window (60) + the full gate (270) + margin.
|
||||
timeout-minutes: 345
|
||||
outputs:
|
||||
action: ${{ steps.gate.outputs.action }}
|
||||
permissions:
|
||||
# Pushes the freeze record to refs/notes/gstack-trains.
|
||||
contents: write
|
||||
@@ -56,10 +63,38 @@ jobs:
|
||||
bun-version: 1.4.2
|
||||
|
||||
- name: Gate main unless a green train covers its tree
|
||||
id: gate
|
||||
if: steps.policy.outputs.train == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GSTACK_STATE_ROOT: ${{ runner.temp }}/gstack-state
|
||||
run: |
|
||||
bun --no-env-file --config=/dev/null --no-install run scripts/train-backstop.ts run --repo "$GITHUB_REPOSITORY" --branch "$GITHUB_REF_NAME" --sha "$GITHUB_SHA" \
|
||||
--summary "$GITHUB_STEP_SUMMARY"
|
||||
--summary "$GITHUB_STEP_SUMMARY" --outputs "$GITHUB_OUTPUT"
|
||||
|
||||
# The per-commit verdict the weekly release reads (scripts/release-cut.ts).
|
||||
# Its own job with checks: write and no repository code (the same split as
|
||||
# evals.yml's train-check): only the action word the gate step recorded.
|
||||
backstop-check:
|
||||
runs-on: ubuntu-24.04
|
||||
needs: backstop
|
||||
if: ${{ !cancelled() && needs.backstop.outputs.action != '' }}
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
checks: write
|
||||
steps:
|
||||
- name: Publish the main-backstop check run on this commit
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
ACTION: ${{ needs.backstop.outputs.action }}
|
||||
run: |
|
||||
case "$ACTION" in
|
||||
covered|gated) conclusion=success ;;
|
||||
inert|superseded|deferred) conclusion=neutral ;;
|
||||
frozen|failed) conclusion=failure ;;
|
||||
*) echo "::error::unknown backstop action '$ACTION'"; exit 1 ;;
|
||||
esac
|
||||
gh api "repos/${GITHUB_REPOSITORY}/check-runs" -X POST -f name=main-backstop -f head_sha="$GITHUB_SHA" -f status=completed -f conclusion="$conclusion" \
|
||||
-f details_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
|
||||
-f "output[title]=Main backstop: ${ACTION}" -f "output[summary]=${ACTION}: see the backstop job summary of run ${GITHUB_RUN_ID}" >/dev/null
|
||||
echo "- main-backstop: ${conclusion} (${ACTION}) on ${GITHUB_SHA}" >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -47,6 +47,6 @@ jobs:
|
||||
path: results.sarif
|
||||
retention-days: 5
|
||||
|
||||
- uses: github/codeql-action/upload-sarif@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
|
||||
- uses: github/codeql-action/upload-sarif@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
@@ -0,0 +1,114 @@
|
||||
name: Weekly release
|
||||
# Tags the newest green commit on main as v<VERSION>, publishes the GitHub
|
||||
# Release, and fast-forwards refs/heads/stable to it (docs/releases.md). Team
|
||||
# installs follow stable, so this is the only thing that moves them. Runs
|
||||
# every Monday morning Pacific; run it by hand with a ref for a hotfix, or
|
||||
# with dry_run to see the plan and change nothing. A pull request that touches
|
||||
# the release tooling runs the dry run against main with a read-only token,
|
||||
# which is the only pre-merge exercise of the real workflow. The candidate's
|
||||
# gate must already be green (free-tests and main-backstop check runs
|
||||
# succeeded, every check run finished and none red, no active freeze): this
|
||||
# workflow never runs tests or paid evals. Dispatch it from main: the cut job
|
||||
# runs only there, with main's tooling.
|
||||
on:
|
||||
schedule:
|
||||
# Monday 14:00 UTC: 07:00 PDT, 06:00 PST.
|
||||
- cron: '0 14 * * 1'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'Print the plan (candidate, tag, stable move, notes) and change nothing'
|
||||
type: boolean
|
||||
default: true
|
||||
ref:
|
||||
description: 'Hotfix: release this commit of main instead of the newest green one (full 40-hex SHA)'
|
||||
type: string
|
||||
default: ''
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/weekly-release.yml'
|
||||
- 'scripts/release-cut.ts'
|
||||
|
||||
# One cut at a time; a second dispatch waits for the first. A pull request's
|
||||
# dry run is its own group.
|
||||
concurrency:
|
||||
group: weekly-release-${{ github.event.pull_request.number || 'cut' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
cut:
|
||||
name: Cut the release
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
# The tag, the stable branch and the release.
|
||||
contents: write
|
||||
# Reads the candidate's check runs.
|
||||
checks: read
|
||||
steps:
|
||||
# The dispatched ref supplies the tooling; the release candidate always
|
||||
# comes from origin/main, which fetch-depth 0 brings along with every
|
||||
# branch.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
bun-version: 1.4.2
|
||||
|
||||
- name: Fetch stable and the tags
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --quiet origin '+refs/heads/stable:refs/remotes/origin/stable' 2>/dev/null || echo "origin has no stable yet: this cut creates it"
|
||||
git fetch --quiet --tags origin
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Plan and cut
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run && 'true' || 'false' }}
|
||||
RELEASE_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "$RELEASE_REF" ] && ! [[ "$RELEASE_REF" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "::error::ref must be the full 40-hex SHA of a commit on main, not '$RELEASE_REF'"
|
||||
exit 1
|
||||
fi
|
||||
args=(--repo "$GITHUB_REPOSITORY" --summary "$GITHUB_STEP_SUMMARY")
|
||||
[ "$DRY_RUN" = true ] && args+=(--dry-run)
|
||||
[ -n "$RELEASE_REF" ] && args+=(--ref "$RELEASE_REF")
|
||||
echo "## Weekly release ($([ "$DRY_RUN" = true ] && echo 'dry run' || echo 'cut'))" >> "$GITHUB_STEP_SUMMARY"
|
||||
bun --no-env-file --config=/dev/null --no-install run scripts/release-cut.ts "${args[@]}"
|
||||
|
||||
dry-run:
|
||||
name: Dry run against main
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
checks: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
bun-version: 1.4.2
|
||||
|
||||
- name: Plan only
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --quiet origin '+refs/heads/stable:refs/remotes/origin/stable' 2>/dev/null || echo "origin has no stable yet"
|
||||
git fetch --quiet --tags origin
|
||||
echo "## Weekly release (dry run from PR #${{ github.event.pull_request.number }})" >> "$GITHUB_STEP_SUMMARY"
|
||||
bun --no-env-file --config=/dev/null --no-install run scripts/release-cut.ts --repo "$GITHUB_REPOSITORY" --summary "$GITHUB_STEP_SUMMARY" --dry-run
|
||||
@@ -303,4 +303,4 @@ Agents poll `eval:bg:*` logs for the `### gstack-detach EXIT=<code> ###` sentine
|
||||
- Every Codex run's verdict goes through `lib/outside-review-result.ts`; its reason codes live in `lib/gate-outcomes.ts`, each with a `docs/troubleshooting.md` anchor.
|
||||
- `test/module-size-ratchet.test.ts` keeps refactored owner modules at or under 800 lines (150 per function) and residual files from growing.
|
||||
- The `claude` CLI binary resolves via `lib/claude-bin.ts` (re-exported from `browse/src/claude-bin.ts` for browse internals; `Bun.which()` + `GSTACK_CLAUDE_BIN` override). Set `GSTACK_CLAUDE_BIN=wsl` plus `GSTACK_CLAUDE_BIN_ARGS='["claude"]'` to run Claude through WSL on Windows.
|
||||
- Every Bun gstack starts ignores the working directory's `.env` files and `bunfig.toml`: bun shebangs are `#!/usr/bin/env -S bun --no-env-file --config=/dev/null`, shell and skill callers pass `bun --no-env-file --config=/dev/null`, TypeScript spawns use `bunIsolationArgs()` from `lib/bun-isolation.ts`, and `bun build --compile` adds `--no-compile-autoload-dotenv --no-compile-autoload-bunfig`. `test/bun-entry-isolation.test.ts` enforces all four.
|
||||
- Every Bun gstack starts ignores the working directory's `.env` files and `bunfig.toml`: bun shebangs are `#!/usr/bin/env -S bun --no-env-file --config=/dev/null`, skill prose passes `bun --no-env-file --config=/dev/null`, shipped shell files define `GSTACK_BUN_CONFIG` with the line in `test/bun-entry-isolation.test.ts` (`NUL` when `OSTYPE` is `msys` or `cygwin` and `cygpath` is on `PATH`, where native Bun cannot open `/dev/null`, #3415) and pass `bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}"`, TypeScript spawns use `bunIsolationArgs()` from `lib/bun-isolation.ts`, and `bun build --compile` adds `--no-compile-autoload-dotenv --no-compile-autoload-bunfig`. `test/bun-entry-isolation.test.ts` enforces all four.
|
||||
+1
-1
@@ -275,7 +275,7 @@ reach for it.
|
||||
|
||||
```bash
|
||||
# One-time: build the binary (browse/dist/browse, ~58MB)
|
||||
bun install && bun run build
|
||||
bun install --production --frozen-lockfile && bun run build
|
||||
|
||||
# Set $B once and forget about it
|
||||
B=./browse/dist/browse # or ~/.claude/skills/gstack/browse/dist/browse
|
||||
|
||||
+180
-1
@@ -7,11 +7,13 @@
|
||||
### Fixed
|
||||
- **`ready`'s `ci:` line** (`lib/ship-queue.ts`, new `latestRunPerName`) judges each check (one app's check name) by its latest run (newest `completed_at`, then highest id; a run still in progress is newest), so a cancelled or re-run earlier attempt no longer counts as a failure; two apps reporting the same name stay separate, a run without a name or without a readable `completed_at` counts on its own, and commit-status contexts are counted one each, as before.
|
||||
- **`ready` prints a `ci-hint:` line on a conflicting PR** (`gh pr view` now also reads `mergeable`, `headRefName` and `isCrossRepository`): with no CI at the head it names the procedure (dispatch `free-tests.yml` and `windows-free-tests.yml` with `gh workflow run … --ref <branch>`, clear the pregate lanes with the run URLs, rerun `ready`); with green runs at the head it says the dispatched runs count; a head on a fork gets a hint that leaves the dispatch to the coordinator; a branch name outside `[\w./-]`, or starting with `-`, prints as `<branch>`. Every hint ends in `(advisory)`; nothing parses it. No hint without a conflict, or while the runs are red or pending.
|
||||
- **Docs:** `docs/ship-policy.md` records that procedure under "Lane: report ready" (and that `--screen` refuses on such a PR for want of a merge ref, with advice that does not apply to a CHANGELOG-only conflict), rewords the `stamp-at-merge` row's `next:` description to the train wording `gstack-restamp` prints since #3439 (`commit and push only the stamp diff above (FILES), with no edit of your own`), and rewords the restamp `changelog` stage so `CHANGELOG_DUPLICATE_HEADING` is described as #3439 implements it: only a duplicate the PR introduces counts, measured against the base's CHANGELOG.
|
||||
- **Docs:** `docs/ship-policy.md` records that procedure under "Lane: report ready" (and that `--screen` is `QUEUE_SCREEN_STALE_REF` on such a PR for want of a merge ref, with advice that does not apply to a CHANGELOG-only conflict), rewords the `stamp-at-merge` row's `next:` description to the train wording `gstack-restamp` prints since #3439 (`commit and push only the stamp diff above (FILES), with no edit of your own`), and rewords the restamp `changelog` stage so `CHANGELOG_DUPLICATE_HEADING` is described as #3439 implements it: only a duplicate the PR introduces counts, measured against the base's CHANGELOG.
|
||||
|
||||
### Tests
|
||||
- `test/gstack-ship-queue.test.ts`: two runs of one check name where the older is cancelled read green (either order), a newer failure reads red, a newer in-progress or queued run reads pending, equal `completed_at` falls back to the id, unnamed and undated runs stay separate, two apps with one name stay separate, a null conclusion reads red, and a commit status never collapses with a check run of the same name; a conflicting PR with no CI refuses with the dispatch hint, with green dispatched runs READYs with the "counts" hint, a fork head and an odd or dash-led branch name get the safe hint, and no hint appears without a conflict or while runs are red or pending.
|
||||
|
||||
## [1.91.237.0] - 2026-10-26
|
||||
|
||||
**An unstamped PR can change a release output again (GSTA-96 on #3434).** Regenerating `agents-digest/gstack-AGENTS.md` after a generator change made the Version Gate classify the head as stamped (the digest is in `release_outputs`) and demand base + bump, while changing only `scripts/gen-agents-digest.ts` failed `test/agents-digest.test.ts`'s freshness check, so a digest rewording could not land before its slot.
|
||||
|
||||
### Fixed
|
||||
@@ -20,6 +22,183 @@
|
||||
### Tests
|
||||
- `test/compare-pr-version.test.ts`: a generator change with its regenerated output at the merge base VERSION passes as unstamped (red without the fix), and stays unstamped after main stamps again; a tool whose background child outlives it does not hold the gate open, nor does one that traps TERM (`regeneratedAtBase` is exported for that test); a hand-edited output, an output generated at the bumped version, an output the tool never writes, a VERSION bump without the output, a failing release tool and a policy without `release_tool` all still refuse; the tool sees no `GH_TOKEN`; no worktree is left behind.
|
||||
|
||||
## [1.91.236.0] - 2026-10-26
|
||||
|
||||
**The merge queue now refuses a PR that never ran /ship.** Garry, 2026-10-25: "we should really be using the gstack ship skill before merging PRs." `gstack-ship-queue ready` printed `SHIP_RECEIPT: none in the PR body` as information and returned ok, so every PR in the pool was READY'd without a /ship run. The receipt /ship writes into the PR body is now a precondition at `ready`, `train build` and `verify --train`, with no flag around it.
|
||||
|
||||
### Changed
|
||||
- **`gstack-ship-queue ready`** refuses with `QUEUE_RECEIPT_MISSING` when the PR body has no valid ```gstack-ship-receipt``` block and with `QUEUE_RECEIPT_STALE` when the receipt's `head` is not the head being READY'd (or its `pr` is another PR). The re-READY after a slot's stamp push accepts the receipt written for the pin when HEAD is the pin plus stamp files, or (from slot 2 on) the pin merged with main plus stamp files. The informational `SHIP_RECEIPT:` line still prints in the block, and it is one line whatever the receipt's strings contain (control characters are collapsed, so a crafted `base` cannot add a forged `MERGE_CONDITION:` line to `verify --train`'s output).
|
||||
- **`gstack-ship-queue ready --screen` refuses a stale merge ref** with the new `QUEUE_SCREEN_STALE_REF` (GSTA-95 on #3431): GitHub stops recomputing `refs/pull/<n>/merge` while a PR conflicts with the base (a CHANGELOG-only conflict after a stamp counts), so a screen dispatched there planned 0 cases, reported success and `ready` printed `SCREEN_DISPATCH:` as if a screen ran. `ready` now compares the merge ref's head parent with the PR head before dispatching (a missing merge ref carries the same code), never adopts a finished screen that planned no cases (a superseded run skips plan-slices and uploads no `paid-plan` artifact; a manifest with nothing planned counts the same), and `train build` prints `SCREEN: #<n> run <id> planned no cases …; counted as not-run` and seats that slot unscreened (a plan it cannot read keeps the slot screened and prints `… counted as screened, unverified`).
|
||||
- **`gstack-ship-queue train build`** refuses a pin whose PR has no current receipt with the same codes, naming the PR, before any write, push or dispatch, `--dry-run` included. **`verify --train`** refuses the slot the same way before `MERGE_CONDITION`, and accepts a receipt written for the slot's pinned head when the current head is its stamp commit (the stamp changes only stamp files, or merges main into the pin from slot 2 on), since no /ship runs on a stamp.
|
||||
- **`gstack-ship-receipt result`** (new) is /ship's terminal step: it binds the posted receipt to HEAD, writes `<state root>/ship/runs/<utc>-<head12>/{ship-run.json,ship-receipt.md}` (a /ship record, not a `run` v1 manifest: `gstack-artifact validate` does not apply) and prints `GSTACK_RESULT: skill=ship status=complete run=<dir>`, `status=refused run=-` with the receipt code, or `status=incomplete run=-` when gh cannot read the PR, so a parent's merge check reads `ship-run.json` instead of parsing the PR body. The ship skill's new Step 22 runs it after the pr-body and handoff sections.
|
||||
- **Unattended /ship may update an existing PR's body.** `gstack-skill-start`'s unattended block and `UNATTENDED_SHIP:` line, the ship skill's Step 18 and `docs/unattended.md` now say: when an open PR for the branch exists and HEAD is its pushed head, Step 18 takes the pr-body section's existing-PR path (body with the receipt first, and title); it never runs `pr-create` and never pushes a commit; anything else takes the handoff section. Before this the block said the receipt lived only in `.gstack/tmp/ship-receipt.md` while rule 22 and `ready` read it from the PR body.
|
||||
- **`INSTALL_UNVERIFIED` with no verdict names the directory to install from.** An installer run from the skill's own folder records a verdict for no project, so /ship launched from the project found none and the fix said only "run the installer on this machine" (GSTA-85). Both twins (`lib/install-verdict.ts`, `bin/gstack-install-verdict.sh`) now say `run … scripts/install.sh --for ship from <project>: the verdict is keyed by the project directory the installer runs in`.
|
||||
- **Docs:** `docs/ship-policy.md#ship-before-ready` (the lane runs /ship before every READY and after every push; the stamp keeps the pin's receipt; the receipt is a process gate, not an authenticity check), the three codes in `docs/troubleshooting.md`, `docs/unattended.md` (/ship now prints the result line), and the ship skill's pr-body section says what refuses without a receipt.
|
||||
- **Gate edit (size budgets):** `test/helpers/carve-guards.ts` raises ship's `maxSkeletonBytes` 81,000 → 81,600 and `maxSizeRatio` 1.6690 → 1.6740 for Step 22 and the Step 18 routing, offset in part by tightening Steps 14, 16, 18 and 20 prose (measured skeleton 81,535, union 314,027; the 2026-10-14 tightening had left 2 bytes of headroom). No threshold, trial count or case kind changed.
|
||||
|
||||
### Tests
|
||||
- `test/ship-queue-train.test.ts`: READY, train build (dry run and live) and verify --train refuse a missing, invalid, other-head and other-PR receipt and admit the current one; the pinned-head receipt admits a stamp commit; verify refuses `QUEUE_PROOF_MISSING` when gh cannot read the PR; `--screen` refuses a lagging or missing merge ref and never follows a 0-case screen. The train fixture's `refs/pull/<n>/merge` is now a real merge commit (`mergeRefCommit`), as GitHub keeps it. `test/gstack-ship-queue.test.ts`: `ready` at a stamp commit (slot 1, and slot 2 with main merged in) accepts the pin's receipt; a bare merge of main with no stamp on top, an empty commit over the merge, or an edit on the pin side under the slot-2 stamp, is `QUEUE_RECEIPT_STALE`. `gstack-ship-receipt result --pr <n>` refuses `QUEUE_RECEIPT_STALE` when HEAD is not the PR's pushed head and prints `status=incomplete` when gh returns no head or the run record cannot be written; `ready --screen` names a failed `ls-remote` or fetch of the merge ref instead of blaming a conflict. Both install-verdict twins tell an unreadable verdict file from a missing one. `test/auq-error-fallback-hook.test.ts`: the unattended directive names the same `GSTACK_RESULT` writers as docs/unattended.md and `gstack-skill-start` (GSTA-91). `test/gstack-ship-receipt.test.ts`: the `bindReceipt` head-matching table (12-character floor, other PR, one-line `SHIP_RECEIPT:`), `result --body` refusals, `--head`, a non-git cwd and gh failures. The train fixture's `openPr` now writes a receipt for the head it opens (`shipped: false` leaves the body empty). `test/install-verdict.test.ts` pins the GSTA-85 fix in both twins; `test/gstack-skill-start.test.ts` pins the new unattended /ship block.
|
||||
|
||||
## [1.91.235.0] - 2026-10-26
|
||||
|
||||
**An issue we intend to fix later now stays open under the `parked` label instead of being closed.** The Oct 11 triage closed 75 feature requests with a "not rejected" note, and GitHub showed them as closed with no fix. Those 75 are open again under the new `parked` label on garrytan/gstack ("Not fixed yet, not rejected: stays open; a comment says where it is tracked and what fixes it"), each with one comment saying where the work is tracked and what fixes it. The rule is written down: an issue closes only as fixed, duplicate or won't fix, each with its reason; a report that main already fixed closes as fixed, not won't fix; and a duplicate uses GitHub's own duplicate close reason.
|
||||
|
||||
### Added
|
||||
- **`docs/triage.md`**: the three issue states (open, parked, closed), how to park an issue (the label plus one comment naming where it is tracked and what fixes it), when the label comes off, the three close reasons with their GitHub close reason and label, and where the parked themes live.
|
||||
|
||||
### For contributors
|
||||
- **`CONTRIBUTING.md`**: an "Issue triage" section ahead of the PR wave process, pointing at docs/triage.md.
|
||||
- **`TODOS.md`**: the Wave 9 feature-themes note says issues stay open under `parked` and points at their theme there (73 listed, 75 with #3061 and #3122), instead of closing with a link.
|
||||
|
||||
## [1.91.234.0] - 2026-10-26
|
||||
|
||||
**Weekly tagged releases, and a `stable` channel that team-mode auto-update follows by default.** Main kept stamping a version per merge, a dozen a day, with no tag or release anywhere, and `./setup --team` installs moved to every one of them within the hour. Now a Monday-morning workflow tags the newest green commit on main as `v<VERSION>`, publishes a GitHub Release from the CHANGELOG headlines and fast-forwards a `stable` branch to it; team installs follow `stable` and move once a week (plus hotfixes), contributors keep following main, and one config key switches either way. Nothing about per-merge stamping or the merge train changed (docs/releases.md).
|
||||
|
||||
### Added
|
||||
- **`.github/workflows/weekly-release.yml`**, **`scripts/release-cut.ts`**: every Monday at 14:00 UTC, or by `workflow_dispatch` (`dry_run`, default true for a manual run; `ref` for a hotfix), the newest first-parent commit on main whose `free-tests` and `main-backstop` check runs succeeded, with every check run finished and none red and no active freeze in `refs/notes/gstack-trains`, is tagged `v<VERSION>`, released with one headline per CHANGELOG version since the previous release (capped at 60 KB), and becomes `refs/heads/stable`. Stable only moves forward along main; a candidate behind it, a tag naming another commit, or a frozen queue is `REFUSED`. A cut that stopped half-way (tag and `stable` pushed, release not created) is finished by the next run. A dry run prints the plan and the exact commands and changes nothing (nothing green is exit 0 for a dry run, with every skip named); a pull request that touches the workflow or the script runs that dry run against main as a read-only job. The cut job runs only from `refs/heads/main`, takes `ref` as a full 40-hex SHA, and has `contents: write` and `checks: read` only; both rulesets cover `refs/heads/main` alone, so the token can push the tag and `stable`.
|
||||
- **`.github/workflows/evals-main-backstop.yml`**, **`scripts/train-backstop.ts`**: the backstop's verdict is published on the commit as the `main-backstop` check run from a code-free `checks: write` job (`--outputs` records the action): success for `covered` and `gated`, neutral for `superseded`, `deferred` and `inert`, failure for `frozen` and `failed`. The run's own conclusion is exit 0 for a superseded push (main moved past it before it was gated), so reading the run would have called a never-gated tree green; the release reads the check run instead.
|
||||
- **`bin/gstack-update-channel.sh`**: resolves the channel (`gstack_update_channel`: `update_channel` `auto` is `stable` when `team_mode` is true, else `main`; `GSTACK_UPDATE_CHANNEL` overrides), and as a command (`--fetch-ref`) fetches the channel ref and prints `origin/stable` or `origin/main` for the /gstack-upgrade skill.
|
||||
- **`bin/gstack-config`**: new key `update_channel` (`auto` | `stable` | `main`), validated on `set`, in the header, `list` and `defaults`.
|
||||
- **`docs/releases.md`**: what a release is, what green means, the channels and how to switch, hotfixes, dry runs and every outcome line.
|
||||
|
||||
### Changed
|
||||
- **`bin/gstack-session-update`**: on the stable channel it fetches `+refs/heads/stable:refs/remotes/origin/stable` (team installs are single-branch clones) and fast-forwards to it, logging `CHANNEL stable target=<sha>`. Before the first release exists it follows main (`CHANNEL_FALLBACK stable->main`). An install already past the release stays put (`CHANNEL stable ahead_of_release`, then `UP_TO_DATE`): a depth-1 clone cannot relate the two commits, so the VERSIONs decide. The main channel's fetch, target and log lines are unchanged.
|
||||
- **`bin/gstack-update-check`**: reads `<channel>/VERSION`, resolves the SHA with `ls-remote refs/heads/<channel>` and compares against it; the cache identity carries `#stable` so a channel switch never replays the other channel's outcome. With no `stable` on origin yet it follows main for that check, as the session updater and `gstack-upgrade` do, instead of caching `CHECK_FAILED` from a 404 until the first cut.
|
||||
- **`bin/gstack-upgrade`**: fetches and fast-forwards to `stable` on that channel (`gstack-upgrade: channel stable ...` on stderr), follows the default branch with a note when origin has no `stable` yet (an `ls-remote` that fails is a fetch failure, not an empty answer), and treats a release whose VERSION is not higher than the installed one as current instead of diverged in a shallow clone. `--check` output lines are unchanged.
|
||||
- **`gstack-upgrade/SKILL.md.tmpl`**: Step 4 resolves `UPSTREAM_REF` through the helper and uses it for the Bun-floor check, the fast-forward, the unpushed-commit gate and the reset fallback instead of a literal `origin/main`; the fallback block requires the carried `UPSTREAM_REF` (`:?`) instead of defaulting to `origin/main` in a fresh shell, and a team install already past the release stops with `AHEAD_OF_STABLE` instead of `FF_REFUSED` and an offer to reset backwards. The helper's `--fetch-ref` keeps `origin/stable` when the fetch fails for any reason other than a missing release, so a network blink never moves a team install to main; its unused `gstack_update_channel_ref` is gone.
|
||||
- **README** Step 2 (team mode) says team mode follows `stable` and how to follow every merge instead.
|
||||
|
||||
### Tests
|
||||
- `test/release-cut.test.ts`: on a real bare origin with a fake `gh`, the newest green first-parent commit wins and every skip is named (pending or neutral `main-backstop`, red check run, missing or running `free-tests`, no `main-backstop` check run, an unrelated check run still going); an active freeze refuses and a cleared one does not; a cut creates the annotated tag, `stable` and the release with `--verify-tag` and the headline in the notes; re-running is `NOTHING_NEW`; a cut whose `gh release create` failed is finished by the next run with notes against the release before it; the CLI exits 0 on a dry run with nothing green and 1 on the real cut; a hotfix `--ref` promotes a later main commit with a compare link; stable never moves backwards; a side-branch ref and a clashing tag are refused; a thousand-version span stays under the notes cap.
|
||||
- `test/update-channel.test.ts`: the resolution matrix (team mode × key × env override); the session-start hook on a depth-1 team install moves to stable (B) not main (C), follows main on `update_channel=main`, falls back to main with no stable on origin, and stays put when already past the release with nothing pending; `gstack-upgrade --check` and the upgrade follow stable then main after the key flips, say when no release exists, and call a depth-1 install past the release current; `gstack-update-check` on stable stays silent while main is ahead, asks `ls-remote` for `refs/heads/stable`, carries the channel in its cache identity, and follows main when origin has no stable yet; `--fetch-ref` keeps `origin/stable` when origin is unreachable.
|
||||
- `test/weekly-release-workflow.test.ts`: the workflow's triggers, concurrency, the cut job's main-only guard, permissions and 40-hex `ref` check, and the read-only pull-request dry run. `test/evals-train-workflow.test.ts`, `test/train-backstop.test.ts`: the `backstop-check` job's action-to-conclusion map, its code-free `checks: write` split, and the CLI's `--outputs` line.
|
||||
- `test/upgrade-template-pins.test.ts`, `test/gstack-upgrade-fresh-shell.test.ts`: the pinned fast-forward, unpushed-commit gate and reset markers name `"$UPSTREAM_REF"` instead of `origin/main`; the ordering invariants are unchanged.
|
||||
|
||||
### Gate integrity
|
||||
- No threshold, budget, timeout, trial count, case kind or assertion changed. The release workflow reads check runs and the trains notes ref; it dispatches nothing and runs no tests or evals. The backstop gates exactly as before; it additionally publishes its verdict as a check run.
|
||||
|
||||
## [1.91.233.0] - 2026-10-26
|
||||
|
||||
**/plan-tune's inferred profile no longer pins at 1.0 after a few reviews, and it now records how many answers moved each dimension.** This closes the last two parts of #2623. One /plan-eng-review produces about five answers, and every answer added its full delta, so eight sessions of "fix now" took `architecture_care` to 0.998, as uninformative as the 0.5 it replaced. Now each UTC day moves a dimension at most as far as one strong answer, and eight days of the same answers read 0.808. The profile JSON (`--profile`) also records `contributing_events` per dimension, so a dimension with no evidence reads 0 there instead of a bare 0.5; /plan-tune's display and `--check-mismatch` don't read it yet (#3436).
|
||||
|
||||
### Fixed
|
||||
- **`bin/gstack-developer-profile`**: `--derive` sums each UTC day's deltas per dimension (by the row's `ts`) and clamps the sum to the largest single delta in the signal map (±0.06 today). Answers within a day net out before the clamp. Only an ISO 8601 date or date-time `ts` counts as dated, and a time without a zone is read as UTC; every other row shares one undated bucket, so a junk timestamp can't escape the bound. `diversity.days_span` counts the same UTC days. The bound ignores `session_id` on purpose: the PostToolUse hook and the agent log different ids for one review, and an unsubstituted `SESSION_ID` placeholder would merge every review into one bound or leave each answer unbounded. Several reviews on one day count as one. `--trace` uses the same accumulation, so it lists every contributing answer, each clamped day ("day 2026-04-01: +0.250 capped to +0.060") and a `TOTAL` line whose value matches `--derive`.
|
||||
- **`bin/gstack-developer-profile`**: a logged choice named after an `Object.prototype` member, such as `constructor`, or a log line that parses to a non-object such as `null`, no longer makes every later `--derive` and `--trace` fail; signal lookups use own properties only. `--trace` replaces control characters in the logged fields it prints, so a logged `ts` can't forge a `TOTAL` line.
|
||||
|
||||
### Added
|
||||
- **`inferred.contributing_events`**: per dimension, the number of logged answers that moved it (an answer that writes two deltas to one dimension counts once). Ported from #2625 by @v639dragoon, with co-author credit. #2625's autonomy signal from `followed_recommendation` stays parked, as decided in its Oct 8 review.
|
||||
|
||||
### Docs
|
||||
- `docs/designs/PLAN_TUNING_V0.md` documents `signal_events`, `contributing_events` and the per-day bound in the profile schema, including why the bound ignores `session_id`.
|
||||
- `TODOS.md`: the #2623 entry now covers only its remaining Gate 1 work, the `{skill}-{slug}` hinted fallback, tracked as #3442.
|
||||
|
||||
### Tests
|
||||
- `test/gstack-developer-profile.test.ts`: `contributing_events` counts unique rows when one answer writes two dimensions, and is all zero for an answer that matches no signal. The bound equals the signal map's largest delta. Five answers on one day move a dimension by exactly the bound in both directions, whether they carry one `session_id`, five different ones, the literal `SESSION_ID` placeholder, or one review logged by both the hook and the agent. The day is the UTC day of `ts` even when the machine's zone is west of UTC, and a `ts` without a zone, `T`- or space-separated, is read as UTC. `--trace` prints the clamped days and a total that matches `--derive`, and replaces control characters. A `constructor` choice, a `null` line and array-valued `question_id` or `user_choice` rows are ignored. Rows whose `ts` is not ISO 8601 (`TS`, `junk 1`, a number) share one undated bucket and add no days to `days_span`. Negative controls: the same answers on separate days, a second day after a capped first day, and an answer pair that nets out are not clamped. The original positive tests fail against the old bin; the day-bound, `constructor` and control-character tests fail against the per-session version; a one-sided clamp fails the bound tests, and local-day bucketing fails the UTC tests.
|
||||
- The test helper `logQuestion` sets `GSTACK_QUESTION_LOG_NO_DERIVE=1`, so the background derive can't race a test's own `--derive` (from #2625).
|
||||
|
||||
### Gate integrity
|
||||
- No test expectation, threshold, budget, timeout, trial count or case kind changed.
|
||||
|
||||
## [1.91.232.0] - 2026-10-26
|
||||
|
||||
**The setup-browser-cookies workflow judge now has a preregistered calibration corpus, and its structured-output calibration is recorded as held, so the judge stays on today's request (GSTA-91).** In train-4 gate 40173759210, the cookie judge (claude-sonnet-5-5, free-text transport) returned JSON with an unescaped quote. The repo's fix for that shape is the JSON-schema transport, and `test/fixtures/judge-calibration/README.md` lets a configuration switch only after a calibration lands. The cookie configuration had no corpus until now. Its comparison-1 run removed the parse errors (old 2 of 144 samples, new 0 of 72) and matched or improved every other measure. It is held on one landing condition: a new false pass on a07. a07 scored 4/4/4 on all three new samples and on all three `old-b` samples. `old-a`'s a07 panel is incomplete because one old sample returned invalid JSON (both valid `old-a` a07 samples also scored 4/4/4), and false passes are counted against `old-a` alone, so the result counts against the new request only.
|
||||
|
||||
### Tests
|
||||
- `test/fixtures/judge-calibration/cookie-workflow/`: 24 items. p01 is the production bundle (`buildCookieWorkflowJudgeInput`); a02 and a05 are real earlier revisions; the rest are hand-edited variants rendered by the same builder. Labels were preregistered and committed before any request with the schema was sent. A separate run (openai/gpt-6.1-sol) then labeled every item blind, and a third reviewer (google/gemini-4-argon) adjudicated the two disagreements: a01 kept pass, a08 changed to fail. `runs/` holds every sample and the result. The cost was 216 calls and $2.81, priced in `manifest.json` before dispatch as a separately funded entry.
|
||||
- `scripts/judge-calibration-configs.ts`: the `cookie-workflow` configuration builds prompts with `buildWorkflowJudgePrompt(COOKIE_WORKFLOW_JUDGE, …)` and uses the eval's median floors of 4/3/4. Its new request differs from the old one only by `WORKFLOW_JUDGE_RESPONSE_SCHEMA`.
|
||||
- `test/judge-calibration.test.ts` covers the new configuration and its pass rule, including the actionability floor. The wiring test now requires the cookie registration to send the schema exactly when the recorded cookie calibration lands. It doesn't land, so the registration stays unchanged and 15 workflow registrations use the schema. The cookie registration may only spread `COOKIE_WORKFLOW_JUDGE`, so the calibrated request stays the one production sends.
|
||||
- Two new free tests: the calibration builds byte-for-byte the prompt the cookie eval sends, and `analyze()` reproduces the committed result from its labels, samples, stop record and request options.
|
||||
|
||||
### Gate integrity
|
||||
- No judge request, rubric, threshold, sample count, landing rule or case changed. The cookie judge keeps its free-text request. No gate exception is proposed.
|
||||
|
||||
## [1.91.231.0] - 2026-10-26
|
||||
|
||||
**A fresh install is about 520 MB smaller: `./setup` installs production dependencies only.** Setup ran `bun install` without `--production`, so every user also got the test and eval toolchain from `devDependencies`, mostly the Claude Agent SDK's native binaries (both the glibc and musl builds on Linux, ~440 MB). Measured on a clean Ubuntu 24.04 container with a cold Bun cache (4 vCPU, two runs each): `node_modules` goes from 1,094 MB to 572 MB, the checkout from 1.74 GB to 1.22 GB, and the whole install with Chromium from 2.42 GB to 1.90 GB. `./setup` took 24.6 s and 20.0 s before, 21.1 s and 18.9 s after.
|
||||
|
||||
### Changed
|
||||
- **`setup`**: every dependency install (before the build, the Codex render and each host render) goes through one `install_runtime_deps` function: `bun install --production --frozen-lockfile`. Bun treats `--production` as frozen, so when `bun.lock` is behind `package.json` (a fork or a local edit) it refreshes the lockfile with `bun install --lockfile-only` and installs production-only again. **`bin/gstack-browser-ensure`** installs the same way when `node_modules` is missing, and its failure line suggests the production install rather than a full `bun install`.
|
||||
- **`package.json`**: `@anthropic-ai/sdk` (loaded by `gstack-distill-free-text`, /plan-tune's dream cycle) and `xterm` and `xterm-addon-fit` (copied into the browser extension by `bun run build`) move to `dependencies`, because a user install runs them. The Claude Agent SDK, TypeScript, Prettier and `@types/bun` stay dev-only.
|
||||
- **Contributors and CI keep the full install.** `./setup` treats a checkout that already has the dev toolchain (`node_modules/typescript`) as a contributor's and runs the full `bun install --frozen-lockfile` there, so dev packages keep following `bun.lock` after a pull (a production install would report "no changes" and leave them at old versions). `bin/dev-setup` now always runs `bun install` (it skipped it when `node_modules` existed, which would have left a checkout first set up by `./setup` without the dev toolchain). CI workflows already run `bun install --frozen-lockfile` themselves. CONTRIBUTING.md says which command installs what.
|
||||
|
||||
### If you already have gstack installed
|
||||
- An existing install keeps its dev dependencies and keeps getting the full install, because it already has the dev toolchain. To get the space back: `cd ~/.claude/skills/gstack && rm -rf node_modules && bun install --production --frozen-lockfile && rm -rf "$(bun pm cache)"/@anthropic-ai/claude-agent-sdk*`. The last step matters: Bun hardlinks `node_modules` into its global cache, so the Agent SDK's binaries stay on disk until their cache entries go. Don't do this in a checkout you develop gstack in.
|
||||
|
||||
### Tests
|
||||
- `test/setup-production-deps.test.ts`: `install_runtime_deps`, sliced out of `setup` and run against a recording `bun` that models Bun's frozen `--production`, passes `--production` on the frozen-lockfile call, refreshes a stale lockfile before retrying, and stops setup when the refresh fails. A checkout with the dev toolchain gets the full frozen install, and a failed full install stops setup too. A walk of `bun.lock`'s production closure (with required-peer and optional-peer controls) proves no production dependency brings in `typescript`, the marker setup checks. The real-Bun rows run with `HOME`, `XDG_CONFIG_HOME` and `BUN_CONFIG_*` isolated, so a developer's global `.bunfig.toml` cannot change them. Run with the real Bun on local `file:` packages: a fresh install leaves out devDependencies, a later production install keeps the ones a full install added, and a stale lockfile is refreshed while the install stays production-only. No other line in `setup` calls `bun install` (the pre-change line is the negative control). `bin/dev-setup`'s install block, run over an existing `node_modules`, still calls plain `bun install`. A scan of every shipped file (`bin`, `lib`, `browse/src`, `design/src`, `make-pdf/src`, `hosts`, `extension`, the resolvers, and the scripts `setup` and the build call by path) finds no import or `require` of a package outside `dependencies`. Modules the shipped code reaches by relative import (gen-skill-docs' `scripts/` helpers, and the `test/helpers/providers` modules `gstack-model-benchmark` loads) are scanned too. Negative controls: a planted Agent SDK import is caught, including one split over several lines and a re-export; a type-only import is not; and moving `@anthropic-ai/sdk` back to dev flags `gstack-distill-free-text`. The packages `bun run build` copies out of `node_modules` are production dependencies.
|
||||
- `test/gstack-browser-ensure.test.ts`: with `node_modules` missing, the preflight runs the frozen production install, refreshes a stale lockfile before retrying, and ends a failed install in `BROWSER_UNAVAILABLE` with a production-install fix line.
|
||||
- The install fixtures' stub `bun` (`test/helpers/install-fixture.ts`, `test/helpers/claude-overlay-fixture.ts`, `test/team-mode.test.ts`) now matches `install --production --frozen-lockfile`. The team-mode stub fails on any other argv; the two helper stubs hand other commands to the real `bun`, so for them the new argv is a match, not a regression check.
|
||||
|
||||
### Gate integrity
|
||||
- No threshold, budget, timeout, trial count, case kind or assertion was weakened. The four stub patterns (in three files) changed to the new argv only.
|
||||
|
||||
## [1.91.230.0] - 2026-10-26
|
||||
|
||||
### Changed
|
||||
- **The archetype the user picks on the /design-shotgun board now reaches /design-html.** Captions name the archetype id (`classic-hero`, `long-form-story`, `minimal-conversion`, `comparison`). On approval, approved.json also gets the chosen `archetype` and its `sections` outline, added with the Edit tool so no free text reaches a shell command. /design-html reads both in Case A, and the craft section uses them as given instead of choosing again or asking.
|
||||
- **Accepted craft exceptions persist.** /design-html's finalized.json records each accepted `CRAFT_CHECK` finding in `craft_exceptions` (`id`, `count`, `reason`) and carries forward earlier ones. A later run passes that file with `--accepted`, so a finding the user already accepted is reported as accepted instead of flagged again. A higher count is still flagged as a new issue.
|
||||
|
||||
### Added
|
||||
- **`gstack-design-craft --accepted <finalized.json>`**: a finding whose id an exception covers, at no more than the accepted count, prints `CRAFT_ACCEPTED: <id> <count> <reason>` and doesn't fail the check. `CRAFT_RESULT` adds the accepted count. Exit codes: 0 when only accepted findings remain, 1 for unaccepted ones, and 2 for an unknown argument or a missing, unreadable or malformed exceptions file. A bad exceptions file still gets the full check with nothing accepted, after a warning, so a broken record can't switch the craft check off. A finalized.json with no `craft_exceptions` field, written before this change, means no exceptions.
|
||||
|
||||
### Tests
|
||||
- `design-html-slop-gate` is now selected by changes to `lib/design-craft.ts` and `bin/gstack-design-craft` (`test/helpers/touchfiles-data.ts`), which it already exercised but did not list.
|
||||
- `test/design-craft.test.ts` also covers accepted exceptions: count coverage and overflow, duplicate ids carried forward (the highest count wins), a bad exceptions file still checked in full, the finalized.json block in design-html/SKILL.md parsed by the same reader the CLI uses, rejected malformed lists, CLI exit codes with `--accepted`, and the archetype handoff contract (the same four ids in /design-shotgun and the craft section, the approved.json step, Case A and the craft section's no-re-ask rule).
|
||||
|
||||
## [1.91.229.0] - 2026-10-26
|
||||
|
||||
**The OpenSSF Scorecard workflow uploads its results with `github/codeql-action/upload-sarif` 4.39.0 (from 4.38.3), recreated from Dependabot's #3307.** Dependabot's branch can't take a version stamp at merge time, so the same one-line change ships from a crew branch. The new pin `6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee` is the commit that the annotated tag `v4.39.0` points to in github/codeql-action, and GitHub shows that commit as verified. 4.39.0's one breaking change raises the minimum CodeQL bundle to 2.20.7, which applies to `init` and `analyze`. This workflow uses only `upload-sarif` with Scorecard's own SARIF file.
|
||||
|
||||
### Changed
|
||||
- **`.github/workflows/scorecard.yml`**: `upload-sarif` pinned to `6f2ac81f…` (`# v4.39.0`), byte-for-byte the change in #3307.
|
||||
|
||||
## [1.91.228.0] - 2026-10-26
|
||||
|
||||
**Skills start at once on Windows again: the workflow lease lock no longer depends on symlinks (#3330).** `bin/gstack-lease` took its lock with `ln -s "<holder token>" .lock`. In Git for Windows' default symlink mode (`MSYS` unset), `ln -s` cannot link to a target that does not exist, so the lock was never taken. Every skill start then waited out all 300 tries (0.1 s apart, plus process start costs) before `gstack-skill-start` printed anything, and still wrote no lease. One report measured 499 s per skill start. The error also blamed a "live process" with an empty pid.
|
||||
|
||||
### Fixed
|
||||
- **`bin/gstack-lease`**: the lock is now a `.lock` directory taken with `mkdir`, which is atomic on every host gstack runs on, with an `owner` file naming the holder's pid and start identity. A taker writes a claim (`.lock.claim.<pid>`) before its `mkdir` and renames it in as the owner after, so a lock left ownerless by a taker killed between the two steps is broken once no claimant lives and the lock is at least 2 s old and unchanged while it was checked (a live taker between its two steps is never mistaken for a crash, and a new lock made meanwhile is never removed). A claim that records no start identity (Git Bash has none) is abandoned after 2 minutes, so a reused pid cannot keep an orphan forever. An owner that names no valid pid (0, zero-padded or not a number) counts as a dead holder, an owner file that exists but cannot be read keeps the lock, and an empty owner, a stray file at `.lock`, or an ownerless lock whose mtime is a minute or more in the future (clock skew) counts as ownerless instead of wedging every skill start. A lock is removed by renaming it aside first, so nobody sees it half-removed; once renamed it is released even if the renamed copy cannot be deleted, and `touch` prunes leftover copies and claims over 2 minutes old whose claimant is gone or recorded no identity. A late taker never overwrites an owner already in the lock it made. A waiter no longer writes a claim or runs `mkdir` while the lock is visibly held, and a live holder that never lets go bounds the wait at 300 tries or 30 s, whichever comes first (a non-numeric `GSTACK_LEASE_LOCK_TRIES` falls back to 300 instead of looping forever). Stale-holder semantics are unchanged: a lock is broken only when its holder is dead or its pid now belongs to another process, breakers still serialize on `.lock.break` and reread before removing, and unlock removes only the caller's own lock. A symlink `.lock` left by an older gstack is still read and broken the same way, and it excludes the new lock at the same path. The reverse does not hold: an older gstack's `ln -s` onto the directory lock links inside it, so a process that started before an upgrade of the same checkout can briefly share the lock (documented as a known limit, with a taker stalled over 2 minutes mid-take on a host without start identities).
|
||||
- A dead holder's lock that cannot be renamed aside (a Windows file in use) is removed in place under `.lock.break`, and when that fails too the try is counted, so `touch` ends with exit 3 instead of spinning without sleeping. The message no longer calls a dead holder live.
|
||||
- The 120-minute prune in `touch` no longer reaches into the lock: it removed the `owner` file of a holder that resumed past the window, which then left its own lock behind.
|
||||
- When the lock cannot be taken although nobody holds it, `touch` now fails after ten quick retries with `gstack-lease: cannot take the lease directory lock <path>` instead of waiting out every try and naming an empty pid. `gstack-skill-start` stays fail-open, as before.
|
||||
- `docs/capy-install.md` describes the directory lock.
|
||||
|
||||
### Tests
|
||||
- `test/gstack-lease-git-bash.test.ts` (new, in the curated windows-free-tests lane, launched through `lib/script-invocation.ts`, so Git Bash on `windows-latest`): `touch` with the default 300 tries takes the lock on its first try (no sleep between tries), writes its lease and leaves no lock. On `windows-latest` the old bin fails this case; a host whose `ln -s` fails like Git Bash's still takes the lock (with a control that the shim really fails `ln -s`); a lock held by a live Git Bash process is kept and named, and a dead holder's lock is broken. The last two fail against the old bin.
|
||||
- `test/gstack-lease.test.ts`: the dead-holder, live-holder and dead-breaker cases run against both the directory lock and the legacy symlink lock. The race cases inject at `mkdir` instead of `ln`. New cases: a lock held by a running gstack-lease excludes a second taker, which names it; 16 parallel touches all land and leave nothing behind; an ownerless lock is kept while a claimant lives and broken once none does; a lock that cannot be taken while nobody holds it fails without sleeping between tries. Seven of these fail against the old bin.
|
||||
- From /ship's coverage audit and review: the 16 parallel touches now also prove no two are inside the lock at once (a mutant whose `mkdir` always succeeds fails it); the exclusion case holds the lock until the test releases it instead of for a 3 s sleep; a young ownerless lock is kept and malformed locks (empty owner, pid 0 or 00, a non-numeric owner, a stray file, an mtime an hour ahead) are broken; an identity-less claim older than 2 minutes neither keeps an ownerless lock nor survives `touch` while an old claim with a live identity is honored; an unreadable owner file keeps the lock; `--help` prints the whole usage again; leftover renamed-aside copies are pruned; a live holder bounds `touch` by the 30 s deadline with the default and with a non-numeric tries value, without one `mkdir` on the held lock; the prune keeps a resumed holder's lock; a renamed-aside lock that cannot be deleted never takes a new taker's lock with it; a dead holder's lock that cannot be renamed aside ends `touch` instead of spinning. Each case that pins a fix fails against the bin before that fix. Also covered: a live or ownerless `.lock.break`, a failed owner rename, a lock released between a failed take and its check, and unlock's fallback. The Git Bash case now also checks that the live holder still owns the lock after a blocked `touch`.
|
||||
|
||||
### Gate integrity
|
||||
- No threshold, budget, timeout, trial count, case kind or assertion was weakened. Three existing race tests moved their fault injection from `ln` to `mkdir`, because the lock no longer calls `ln`. Their assertions are unchanged. Under /ship review, the exclusion case's wait for the holder's owner file rose from 2 s to 10 s and its fixed 3 s hold became a release file (no assertion changed), the ownerless-lock cases backdate their planted lock by a minute because an ownerless lock is now broken only once 2 s old (their assertions are unchanged; the new young-lock case pins the guard), and the exclusion and parallel cases got an explicit 30 s test timeout instead of Bun's 5 s default.
|
||||
|
||||
## [1.91.227.0] - 2026-10-26
|
||||
|
||||
**Windows: gstack's record bins stop losing review, question, learning and timeline records when Git Bash path conversion is off (#3415).** Since GSTA-56 every shell bin started Bun with `--config=/dev/null`. Native `bun.exe` cannot open `/dev/null`: it exits 1 with `EINVAL` on Bun 1.3.10, 1.4.2 and 1.4.4 (measured on windows-latest). Git Bash hides this by rewriting the argument to `nul`, but only while MSYS path conversion is on. With it off (`MSYS_NO_PATHCONV` set to any value, even empty; `MSYS2_ARG_CONV_EXCL=*`; or Cygwin), `gstack-review-log`, `gstack-question-log` and `gstack-learnings-log` exited 1 with nothing written, and `gstack-timeline-log` exited 0 with nothing written.
|
||||
|
||||
### Fixed
|
||||
- **40 shipped shell files** (the bash bins, the Claude hook wrappers, `setup`, `scripts/build-cso.sh` and one migration) define `GSTACK_BUN_CONFIG` once and start Bun with `--config="${GSTACK_BUN_CONFIG:-/dev/null}"`. The value is `NUL` only when `OSTYPE` is `msys` or `cygwin` (Git Bash, MSYS2 and Cygwin, the check `gstack-doctor` already uses) and `cygpath` is on `PATH`; everywhere else it is `/dev/null`. The `cygpath` check is a builtin `command -v`, so it forks nothing. It means an `OSTYPE=msys` inherited by a Linux or macOS bash keeps `/dev/null`, instead of passing a relative `NUL` that Bun there resolves to a file in the repository. Bun 1.4.2 and later, the version gstack tests against, read `NUL` as an empty config whether or not conversion is on. Bun 1.3.10 on Windows exits 1 on `--config=NUL` and on the lowercase `nul` that Git Bash used to produce alike (measured from PowerShell on windows-latest; the #3085 entry recorded a panic). Versions between 1.3.3 and 1.4.1, which setup and `gstack-doctor` only warn about, were not measured. The `:-/dev/null` default covers a call that loses its definition: it still passes `/dev/null`, so POSIX is unchanged and Windows fails loudly as before, and never reads the repository's `bunfig.toml`. `bin/gstack-codex-probe` is sourced into the caller's shell, so it defines the variable inside the one function that starts Bun and still runs nothing at source time.
|
||||
- **`bin/gstack-timeline-log`** and **`gstack-developer-profile --log-session`**: invalid input still exits 0 with nothing logged (the hook-path contract). Their validators now print `valid` or `invalid`, and any non-zero exit, which is Bun itself failing, is reported on stderr and returned instead of being skipped as invalid. The verdict is not an exit code because a Bun crash on Windows exits 3. When stamping a missing `ts` fails, `gstack-timeline-log` keeps the original event instead of appending an empty line. Their production callers (`gstack-skill-start`, `gstack-skill-end` and the `/office-hours` templates) still discard stderr and the exit status, so the report shows only when the bin is run directly.
|
||||
- Unchanged: bun shebangs keep `--config=/dev/null` because a shebang cannot name a per-platform path, and skill prose (templates and resolvers) keeps it because `test/bun-entry-isolation.test.ts` holds skill sources to the literal spelling. TypeScript spawns keep `bunIsolationArgs()`, and `scripts/ubicloud/` keeps `/dev/null` because it runs on Linux.
|
||||
- Not covered: callers that hand `bun.exe` a POSIX script path built from `pwd` or `$HOME` (the Claude hook wrappers, `autoplan/bin/phase-publication-hook`, `plan-ceo-review/bin/mode-handoff-hook`, and some `gstack-doctor`, `gstack-codex-probe`, `setup` and migration calls) only got the config spelling. With path conversion off they may still fail on the `/c/...` script path itself. The record bins convert their own paths with `cygpath -m`; the others were not verified on Windows with conversion off. Bun shebang entry points and Bun calls in skill prose still pass the literal `--config=/dev/null`, so with conversion off they fail with `EINVAL` as before. One example is the outside-voice probe in `scripts/resolvers/outside-voice.ts`, which then reports Claude as not installed.
|
||||
|
||||
### Tests
|
||||
- `test/bun-config-shell-bins.test.ts` (new; force-included in the Windows lane):
|
||||
- On every platform, the four record bins run through `lib/script-invocation.ts` with conversion off in a repository whose `bunfig.toml` preload and `.env` are hostile. Each writes exactly one record, and neither the preload nor the `.env` state root takes effect. Invalid timeline input still exits 0 silently.
|
||||
- On POSIX hosts a simulated Windows bash (`OSTYPE=msys`, a `cygpath`, and a `bun` stand-in that rejects `/dev/null` with EINVAL as `bun.exe` does) reproduces the bug: on main the four bins write nothing and the timeline control exits 0 (5 failures); with the fix they pass. The controls, without `OSTYPE=msys`, show each bin exits non-zero with nothing written.
|
||||
- An inherited `OSTYPE=msys` with no `cygpath`, in a repository that commits a file named `NUL` with a preload, leaves each bin on `/dev/null`: the record is written and the preload never runs. This fails on the first version of this fix (4 failures).
|
||||
- `gstack-developer-profile --log-session` reports a rejected config on stderr and exits non-zero, and still skips invalid input silently. This fails on main.
|
||||
- A `bun` that exits 3 with no output, the way a Bun crash exits on Windows, makes both bins report and exit non-zero instead of skipping the event as invalid input. This fails with an exit-code verdict.
|
||||
- `test/bun-isolation-launch.test.ts`: the shell-file spelling stays isolated with conversion off, and a win32-only control shows that the literal `--config=/dev/null` fails there with EINVAL.
|
||||
- `test/bun-entry-isolation.test.ts`: shipped shell files must use the fail-closed spelling and define `GSTACK_BUN_CONFIG` with the canonical line before first use. A `local` definition covers only the rest of its function. Skill sources keep the literal spelling.
|
||||
- `test/learnings-injection.test.ts`, `browse/test/learnings-injection.test.ts` and `test/setup-hook-canonical-paths.test.ts` now anchor on the new spelling. The two learnings-injection files also check that the anchored `bun -e` block exists. Before, a missing anchor sliced the script's last character and the negative assertions passed without checking anything.
|
||||
|
||||
### Gate integrity
|
||||
- `test/bun-entry-isolation.test.ts` is strengthened. On POSIX the required shell spelling still resolves to `/dev/null`.
|
||||
- Now offenders: the literal `--config=/dev/null` in a shipped shell file, and an undefined or redefined `GSTACK_BUN_CONFIG`.
|
||||
- The shell-caller detector also skips a `--config` value before deciding whether a call starts a program, so `bun --no-env-file --config=<anything> -e` no longer passes unchecked. The tree has no such call.
|
||||
- Negative controls, each of which fails the check: the old spelling, a variable without the default, an empty `--config=`, a missing, late or altered definition, a `local` definition used from another function, and a reverted bin.
|
||||
- No threshold, budget, timeout, trial count or case kind changed.
|
||||
|
||||
## [1.91.226.0] - 2026-10-26
|
||||
|
||||
**/review can re-review just its own fixes after a fixing pass, under the same rules as /ship (#3339).** Before, every /review fix cycle reran the whole review over the whole branch, so a large branch could hit the three-cycle stop on findings in code the earlier passes had already read. Now the pass after an edited pass starts with `gstack-review-log --start review --fix-range` and its checklist, specialists and QA read only what changed since that pass. The Step 4.8 adversarial review still reads the whole branch on every pass, because its rows carry their own freshness. A narrowed pass is never the final review: when it makes no edits, /review runs one full pass before writing the final record, and a fix-range record is never CURRENT. Garry's "do it" on 2026-10-24 for a fix-range re-review with a final full pass covers it.
|
||||
|
||||
@@ -14,6 +14,8 @@ 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.
|
||||
|
||||
> **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`.
|
||||
|
||||
### First free check (no API key, no browser)
|
||||
@@ -907,6 +909,14 @@ bun install && bun run build && ./setup
|
||||
|
||||
This affects all projects. To revert: `git checkout main && git pull && bun run build && ./setup`.
|
||||
|
||||
## Issue triage
|
||||
|
||||
An issue is open, parked or closed, and closed means fixed, duplicate or won't
|
||||
fix, each with its reason in the closing comment. An issue we intend to fix
|
||||
later stays open under the `parked` label with one comment saying where it is
|
||||
tracked (a TODOS.md theme or a tracking issue) and what fixes it. The full rule,
|
||||
including the labels and the close reasons, is in [docs/triage.md](docs/triage.md).
|
||||
|
||||
## Community PR triage (wave process)
|
||||
|
||||
When community PRs accumulate, batch them into themed waves:
|
||||
|
||||
@@ -62,7 +62,7 @@ From inside your repo, paste this. Switches you to team mode, bootstraps the rep
|
||||
(cd ~/.claude/skills/gstack && ./setup --team) && ~/.claude/skills/gstack/bin/gstack-team-init required && git add .claude/ CLAUDE.md && git commit -m "require gstack for AI-assisted work"
|
||||
```
|
||||
|
||||
No vendored files in your repo, no version drift, no manual upgrades. Every Claude Code session starts with a fast auto-update check (throttled to once/hour, network-failure-safe, completely silent).
|
||||
No vendored files in your repo, no version drift, no manual upgrades. Every Claude Code session starts with a fast auto-update check (throttled to once/hour, network-failure-safe, completely silent). Team mode follows the `stable` channel: the weekly tagged release (Monday mornings, plus off-cycle hotfixes), not every merge to main. `gstack-config set update_channel main` follows every merge instead; see [docs/releases.md](docs/releases.md).
|
||||
|
||||
Swap `required` for `optional` if you'd rather nudge teammates than block them.
|
||||
|
||||
@@ -790,7 +790,7 @@ A project install lives in the project's `.claude/skills/gstack` or
|
||||
|
||||
**`/browse` (or `/qa`, `/design-review`) says `NEEDS_ASIDE` or `ASIDE_NOT_RUNNING`?** That's the probe telling you it's about to use the fallback browser. Want Aside? Open the app and sign in — `aside --version` should print a version and `aside repl 'console.log("ok")'` should print `ok` — then re-run. gstack never installs it for you. Want the fallback on purpose while Aside is open? `GSTACK_SKIP_ASIDE=1` makes every skill, the renderer, and `./setup` treat Aside as absent. When Aside is absent the probe prints `NEEDS_ASIDE: <OS>` and skills trust that line for the macOS-only download pitch; `GSTACK_PLATFORM` overrides the OS it names, for tests and unusual hosts (set it in your shell — gstack never reads it from a project `.env`).
|
||||
|
||||
**`/browse` fails on the fallback browser?** `cd ~/.claude/skills/gstack && bun install && bun run build`
|
||||
**`/browse` fails on the fallback browser?** `cd ~/.claude/skills/gstack && bun install --production --frozen-lockfile && bun run build`
|
||||
|
||||
**`/make-pdf` or `/diagram` can't render?** Same two paths: with Aside open they print through Aside (`bun run ~/.claude/skills/gstack/bin/gstack-render.ts some.html --screenshot /tmp/out.png` tests it directly, and its first line, `ENGINE=aside` or `ENGINE=browse`, names the browser that actually rendered); without it they use the bundled browser, so `bun run build` is the fix.
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ The 158 older open issues were re-checked against main 5cb5e1c (v1.91.83.0). Of
|
||||
Each item was decided in the Wave 9 plan and parked with its reason. Credit the reporter or contributor when it is picked up.
|
||||
|
||||
- **Fold `triggers:` into skill descriptions (#2118)** — `scripts/gen-skill-docs.ts` folds only `voice-triggers:`, and the trimmed description keeps one sentence, so natural-language triggers never reach the listing. Parked because it conflicts with the catalog-trim design and would regenerate every skill. Mechanism when picked up: a `when_to_use:` frontmatter key for the Claude host, built from `triggers:`, deduped, and kept inside the catalog byte budget (Claude Code's 1,536-character combined listing limit); hosts that allowlist name and description get a bounded "Use when…" sentence or nothing. **Effort:** M. **Priority:** P2.
|
||||
- **Plan-tune signals stuck at 0.5 (#2623)** — `hosts/claude/hooks/question-log-hook.ts` logs the UI label, while `scripts/psychographic-signals.ts` looks up stable option keys, so no row ever matches. The two-line key mapping is capture plumbing only and does not fix saturation. Acceptance criteria: labels map to stable option keys at log time; `profile.inferred` records contributing events per dimension; per-event and per-session accumulation is bounded so a dimension cannot pin at 1.0; `{skill}-{slug}` ids get a hinted fallback. Needs a design call on the key source. **Effort:** M. **Priority:** P2.
|
||||
- **Plan-tune `{skill}-{slug}` ids never reach a signal (#3442, split from #2623)** — the label-to-key capture (#3394), `contributing_events` and the per-day bound (#3397) closed the rest of #2623. Remaining: `scripts/resolvers/question-tuning.ts` lets skills use `{skill}-{slug}` ids, which have no registry entry and so no `signal_key`; those answers never move a dimension. Acceptance: `{skill}-{slug}` ids get a hinted fallback to a registered signal key. Needs a design call on the key source. Credit @v639dragoon. **Effort:** M. **Priority:** P2.
|
||||
- **Stateless, instruction-only host mode (#3061, @m-check1B)** — hosted multi-tenant runtimes have no install, state root or helper binaries, but skill bodies still read and write `$GSTACK_STATE_ROOT`, call `bin/gstack-*` and log telemetry. Deferred to the new-hosts theme below. Requirements kept on record: tenant-owned state (a `capabilities.state: 'none'` host renders host-provided hooks instead of shell steps), named artifact hooks (`ARTIFACT_SAVE`/`ARTIFACT_FIND` per kind, `WEB_RESEARCH`, `SECRET_SCAN`, `USER_PREF`) mapped the way the gbrain resolvers already are, a generator check that fails a stateless render containing `~/.gstack`, `$GSTACK_`, `bin/gstack-*` or `--event-type`, and the `claude` host's output byte-identical. The contributor offered an office-hours overlay plus the generator check as a first PR. **Effort:** L. **Priority:** P3.
|
||||
- **Legacy unprefixed Cursor skill folders (#3084)** — 1.31 Cursor installs created `~/.cursor/skills/<skill>/SKILL.md` links; 1.91 links `gstack-*` folders and `_prune_stale_generated` in `setup` only looks at `gstack-*` names, so Cursor can load two skills with the same name. `setup` already removes old unprefixed Claude entries; Cursor needs the same owned-link cleanup (remove only links that point into a gstack checkout). Split from #3084's data-loss fix, which Wave 9 Lane A0 ships. **Effort:** S. **Priority:** P3.
|
||||
- **`/retro global` says what a "session" is (#1315, third problem)** — `bin/gstack-global-discover.ts` counts transcript files and rollouts, so cron- or agent-driven repos show hundreds of "sessions" with no interactive work. Annotate the retro output that sessions are tool invocations or files, not distinct human working sessions. Problems 1 and 2 are fixed (v1.91.66.0 and Wave 9 Lane F). **Effort:** S. **Priority:** P3.
|
||||
@@ -60,7 +60,7 @@ Each item was decided in the Wave 9 plan and parked with its reason. Credit the
|
||||
|
||||
### P3: feature themes from the Wave 9 triage (filed 2026-10-11)
|
||||
|
||||
The 73 open feature requests, grouped by theme with counted demand (issues in the theme). Each issue closes with a link to its theme here; the three largest cross-cutting themes (new hosts, provider pluralism, install footprint) stay open as tracking issues. Pick a theme up as a whole and credit every linked reporter.
|
||||
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.
|
||||
@@ -777,18 +777,22 @@ Each item was deferred with a reason during the `/claude-api prompt-audit` clean
|
||||
- **Judge prose and rubric cleanup (W2 deferral)** — the arm, qa health-rubric,
|
||||
qa anti-refusal, cross-skill and voice judges, and the default workflow judge,
|
||||
now send a JSON schema; their prompt-audit s11 M1-M7 prose cleanup is still
|
||||
open, as is the qa anti-refusal rubric's "always … regardless" wording. Two
|
||||
open, as is the qa anti-refusal rubric's "always … regardless" wording. Three
|
||||
prose steps were triggered and not run: the qa workflow judge (its schema
|
||||
comparison held: 4/24 new flips vs 2/24 old, one new false pass on a06, zero
|
||||
format errors, so it stays on today's request) and the default workflow judge
|
||||
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). Reuse `test/fixtures/judge-calibration`
|
||||
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
|
||||
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 corpora and calibrate
|
||||
schema transport for the frontier configuration (review/SKILL.md workflow) and
|
||||
the cookie configuration (setup-browser-cookies/SKILL.md workflow); both stay on
|
||||
today's request. After any transport change, re-run the review and cookie
|
||||
workflow judges. 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,
|
||||
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`,
|
||||
user and managed settings `model`) in `./setup` and render its pinned overlay.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.91.226.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.91.237.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,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Native Read barrier. A broken install must return deny JSON, never silence.
|
||||
set -euo pipefail
|
||||
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
|
||||
_AUTOPLAN_DECIDED=''
|
||||
_autoplan_backstop() {
|
||||
if [ -z "$_AUTOPLAN_DECIDED" ]; then
|
||||
@@ -9,7 +10,7 @@ _autoplan_backstop() {
|
||||
}
|
||||
trap _autoplan_backstop EXIT
|
||||
_AUTOPLAN_HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
_AUTOPLAN_RESULT="$(bun --no-env-file --config=/dev/null "$_AUTOPLAN_HOOK_DIR/phase-publication-hook.ts")"
|
||||
_AUTOPLAN_RESULT="$(bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$_AUTOPLAN_HOOK_DIR/phase-publication-hook.ts")"
|
||||
test -n "$_AUTOPLAN_RESULT"
|
||||
printf '%s\n' "$_AUTOPLAN_RESULT"
|
||||
_AUTOPLAN_DECIDED=1
|
||||
+5
-5
@@ -22,11 +22,11 @@ if [ ! -f "$REPO_ROOT/.env" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# 2. Install dependencies
|
||||
if [ ! -d "$REPO_ROOT/node_modules" ]; then
|
||||
echo "Installing dependencies..."
|
||||
(cd "$REPO_ROOT" && bun install)
|
||||
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.
|
||||
echo "Installing dependencies..."
|
||||
(cd "$REPO_ROOT" && bun install)
|
||||
|
||||
# 3. Create .claude/skills/ inside the repo
|
||||
mkdir -p "$REPO_ROOT/.claude/skills"
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
BASH_COMPAT=50
|
||||
|
||||
set -uo pipefail
|
||||
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
|
||||
|
||||
. "$(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
|
||||
@@ -576,7 +577,7 @@ PYEOF
|
||||
CONSENT_TS="$SCRIPT_DIR/../lib/transcript-consent.ts"
|
||||
transcript_policy() {
|
||||
command -v bun >/dev/null 2>&1 || return 2
|
||||
GSTACK_STATE_ROOT="$GSTACK_HOME" bun --no-env-file --config=/dev/null run "$CONSENT_TS" "$@"
|
||||
GSTACK_STATE_ROOT="$GSTACK_HOME" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" run "$CONSENT_TS" "$@"
|
||||
}
|
||||
|
||||
# 0 when staged transcript pages may sync at all.
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
# GSTACK_CHROMIUM_NO_SANDBOX=1 for the probe. Bash 3.2 plus POSIX tools.
|
||||
BASH_COMPAT=50
|
||||
set -u
|
||||
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
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" || { echo "gstack-browser-ensure: cannot resolve the install root" >&2; exit 1; }
|
||||
DOCS="https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#browser-ensure-failed"
|
||||
@@ -58,7 +59,7 @@ probe() {
|
||||
else PROBE_OUT=$(cd "$ROOT" && node -e "$PROBE" </dev/null 2>&1); PROBE_RC=$?; fi
|
||||
elif command -v bun >/dev/null 2>&1; then
|
||||
ENGINE=bun
|
||||
PROBE_OUT=$(cd "$ROOT" && bun --no-env-file --config=/dev/null -e "$PROBE" </dev/null 2>&1); PROBE_RC=$?
|
||||
PROBE_OUT=$(cd "$ROOT" && bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "$PROBE" </dev/null 2>&1); PROBE_RC=$?
|
||||
else
|
||||
fail "neither node nor bun is on PATH to launch Chromium" "install Node.js (https://nodejs.org) or Bun, then re-run $0"
|
||||
fi
|
||||
@@ -73,7 +74,7 @@ if [ ! -d "$ROOT/node_modules/playwright" ]; then
|
||||
[ "$CHECK" -eq 1 ] && fail "playwright is not installed under $ROOT/node_modules" "cd $ROOT && ./setup"
|
||||
command -v bun >/dev/null 2>&1 || fail "playwright is not installed under $ROOT/node_modules and bun is not on PATH" "install Bun, then cd $ROOT && ./setup"
|
||||
log "gstack-browser-ensure: installing node modules (bun install)..."
|
||||
(cd "$ROOT" && { bun install --frozen-lockfile >/dev/null 2>&1 || bun install >/dev/null; }) || fail "bun install failed under $ROOT" "cd $ROOT && bun install, then re-run $0"
|
||||
(cd "$ROOT" && { bun install --production --frozen-lockfile >/dev/null 2>&1 || { bun install --lockfile-only >/dev/null && bun install --production --frozen-lockfile >/dev/null; }; }) || fail "bun install failed under $ROOT" "cd $ROOT && bun install --production --frozen-lockfile, then re-run $0"
|
||||
fi
|
||||
|
||||
probe
|
||||
|
||||
@@ -157,6 +157,7 @@ _gstack_codex_select_model() {
|
||||
# working-directory context forwarded to the shared role resolver.
|
||||
# The TOML is read by the TypeScript resolver; bash never parses it.
|
||||
local _kind="${1:-exec}" _explicit="${2:-}" _role="${3:-}" _cwd="${4:-}" _line _rc _rest
|
||||
local 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
|
||||
case "$_kind" in exec|review) ;; *) echo "ERROR: _gstack_codex_select_model takes exec or review" >&2; return 2 ;; esac
|
||||
case "$_role" in ''|plan-review) ;; *) echo "ERROR: _gstack_codex_select_model role must be plan-review" >&2; return 2 ;; esac
|
||||
if ! command -v bun >/dev/null 2>&1; then
|
||||
@@ -168,7 +169,7 @@ _gstack_codex_select_model() {
|
||||
[ -n "$_role" ] && set -- "$@" --role "$_role"
|
||||
[ -n "$_explicit" ] && set -- "$@" --explicit "$_explicit"
|
||||
[ -n "$_cwd" ] && set -- "$@" --cwd "$_cwd"
|
||||
_line=$(bun --no-env-file --config=/dev/null run "$_GSTACK_CODEX_BIN/../scripts/resolve-codex-generation-model.ts" "$@")
|
||||
_line=$(bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" run "$_GSTACK_CODEX_BIN/../scripts/resolve-codex-generation-model.ts" "$@")
|
||||
_rc=$?
|
||||
_GSTACK_CODEX_SEL="${_line%%$'\t'*}"
|
||||
_rest="${_line#*$'\t'}"
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
# Writes via bin/gstack-question-log so source tagging, dedup, and async
|
||||
# derive all apply uniformly.
|
||||
set -euo pipefail
|
||||
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
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
|
||||
gstack_state_root_select
|
||||
@@ -101,7 +102,7 @@ IMPORTED=0
|
||||
SKIPPED_NO_ANSWER=0
|
||||
|
||||
for SESSION_FILE in "${SESSION_FILES[@]}"; do
|
||||
COUNT_LINE=$(SESSION_FILE_PATH="$SESSION_FILE" QLOG_BIN="$SCRIPT_DIR/gstack-question-log" bun --no-env-file --config=/dev/null -e '
|
||||
COUNT_LINE=$(SESSION_FILE_PATH="$SESSION_FILE" QLOG_BIN="$SCRIPT_DIR/gstack-question-log" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e '
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { spawnSync } = require("child_process");
|
||||
|
||||
+11
-2
@@ -80,6 +80,9 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne
|
||||
# ─── Updates ─────────────────────────────────────────────────────────
|
||||
# auto_upgrade: false # true = silently upgrade on session start
|
||||
# update_check: true # false = suppress version check notifications
|
||||
# update_channel: auto # auto = stable when team_mode is true, else main
|
||||
# # stable = the weekly tagged release (refs/heads/stable)
|
||||
# # main = every merge to main (docs/releases.md)
|
||||
#
|
||||
# ─── Skill naming ────────────────────────────────────────────────────
|
||||
# skill_prefix: false # true = namespace skills as /gstack-qa, /gstack-ship
|
||||
@@ -284,6 +287,7 @@ lookup_default() {
|
||||
telemetry) echo "off" ;;
|
||||
auto_upgrade) echo "false" ;;
|
||||
update_check) echo "true" ;;
|
||||
update_channel) echo "auto" ;; # auto | stable | main — bin/gstack-update-channel.sh resolves auto (docs/releases.md)
|
||||
skill_prefix) echo "false" ;;
|
||||
explain_level) echo "default" ;;
|
||||
plan_mode_preamble) echo "on" ;; # on | off — off drops the plan-mode preamble on the next ./setup (#3168)
|
||||
@@ -841,6 +845,11 @@ $_gstack_legacy_root"
|
||||
echo "Error: $KEY '$VALUE' not recognized. Valid values: local, ubicloud. Existing value left unchanged." >&2
|
||||
exit 1
|
||||
fi ;;
|
||||
update_channel)
|
||||
case "$VALUE" in auto|stable|main) ;; *)
|
||||
echo "Error: update_channel '$VALUE' not recognized. Valid values: auto, stable, main. Existing value left unchanged. (https://github.com/garrytan/gstack/blob/main/docs/releases.md#channels)" >&2
|
||||
exit 1 ;;
|
||||
esac ;;
|
||||
esac
|
||||
# design_detector_install_prompted records "never ask again" for the one-time
|
||||
# engine install offer. Rejecting a typo keeps the offer from silently coming
|
||||
@@ -1025,7 +1034,7 @@ $_gstack_legacy_root"
|
||||
echo ""
|
||||
echo "# ─── Active values (including defaults for unset keys) ───"
|
||||
report_root_disagreement
|
||||
for KEY in proactive routing_declined telemetry auto_upgrade update_check \
|
||||
for KEY in proactive routing_declined telemetry auto_upgrade update_check update_channel \
|
||||
skill_prefix explain_level plan_mode_preamble \
|
||||
codex_reviews gstack_contributor skip_eng_review workspace_root \
|
||||
artifacts_sync_mode artifacts_sync_mode_prompted artifacts_sync_removals plan_tune_hooks \
|
||||
@@ -1050,7 +1059,7 @@ $_gstack_legacy_root"
|
||||
;;
|
||||
defaults)
|
||||
echo "# gstack-config defaults"
|
||||
for KEY in proactive routing_declined telemetry auto_upgrade update_check \
|
||||
for KEY in proactive routing_declined telemetry auto_upgrade update_check update_channel \
|
||||
skill_prefix explain_level plan_mode_preamble \
|
||||
codex_reviews gstack_contributor skip_eng_review workspace_root \
|
||||
artifacts_sync_mode artifacts_sync_mode_prompted artifacts_sync_removals plan_tune_hooks \
|
||||
|
||||
+28
-11
@@ -1,20 +1,27 @@
|
||||
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
||||
// gstack-design-craft — free craft check for a generated page (/design-html Step 4).
|
||||
//
|
||||
// Usage: gstack-design-craft <file.html>
|
||||
// Usage: gstack-design-craft <file.html> [--accepted <finalized.json>]
|
||||
//
|
||||
// Prints one "CRAFT_CHECK: <id> <count> <fix>" line per tell, then
|
||||
// "CRAFT_RESULT: clean" (exit 0) or "CRAFT_RESULT: <n> issue(s)" (exit 1).
|
||||
// Exit 2: usage error, a non-HTML file, an unreadable or empty file. Head and
|
||||
// navigation rules run only on a full HTML document; a fragment gets the copy
|
||||
// and code rules alone.
|
||||
// --accepted reads the craft_exceptions a finalized.json recorded: a tell an
|
||||
// exception covers (same id, count at most the accepted count) prints as
|
||||
// "CRAFT_ACCEPTED: <id> <count> <reason>" and does not fail the check; the
|
||||
// result line then adds " (<k> accepted)" or ", <k> accepted".
|
||||
// Exit 2: usage error, a non-HTML file, or an unreadable or empty file, with
|
||||
// nothing checked; or an unreadable or malformed exceptions file, after a
|
||||
// warning and a full check with no exceptions accepted, so a bad record never
|
||||
// hides the page's tells. Head and navigation rules run only on a full HTML
|
||||
// document; a fragment gets the copy and code rules alone.
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { checkCraft, formatCraft } from '../lib/design-craft';
|
||||
import { acceptFindings, checkCraft, formatCraft, parseCraftExceptions, type CraftException } from '../lib/design-craft';
|
||||
|
||||
const file = process.argv[2];
|
||||
if (!file) {
|
||||
console.error('Usage: gstack-design-craft <file.html>');
|
||||
const USAGE = 'Usage: gstack-design-craft <file.html> [--accepted <finalized.json>]';
|
||||
const [file, flag, acceptedFile, ...rest] = process.argv.slice(2);
|
||||
if (!file || (flag !== undefined && (flag !== '--accepted' || !acceptedFile)) || rest.length > 0) {
|
||||
console.error(USAGE);
|
||||
process.exit(2);
|
||||
}
|
||||
if (!/\.html?$/i.test(file)) {
|
||||
@@ -32,6 +39,16 @@ if (source.trim() === '') {
|
||||
console.error(`gstack-design-craft: ${file} is empty; nothing was checked`);
|
||||
process.exit(2);
|
||||
}
|
||||
const findings = checkCraft(source);
|
||||
console.log(formatCraft(findings));
|
||||
process.exit(findings.length === 0 ? 0 : 1);
|
||||
let exceptions: CraftException[] = [];
|
||||
let badExceptions = false;
|
||||
if (acceptedFile) {
|
||||
try {
|
||||
exceptions = parseCraftExceptions(readFileSync(acceptedFile, 'utf-8'));
|
||||
} catch (err) {
|
||||
console.error(`gstack-design-craft: cannot use exceptions from ${acceptedFile}: ${(err as Error).message}; checked with none accepted`);
|
||||
badExceptions = true;
|
||||
}
|
||||
}
|
||||
const { issues, accepted } = acceptFindings(checkCraft(source), exceptions);
|
||||
console.log(formatCraft(issues, accepted));
|
||||
process.exit(badExceptions ? 2 : issues.length === 0 ? 0 : 1);
|
||||
+124
-52
@@ -11,7 +11,8 @@
|
||||
# write updated ~/.gstack/developer-profile.json.
|
||||
# --profile emit the full profile as JSON (all fields).
|
||||
# --gap emit declared-vs-inferred gap as JSON.
|
||||
# --trace <dim> show events that contributed to a dimension.
|
||||
# --trace <dim> show events that contributed to a dimension, each
|
||||
# UTC day clamped by the per-day bound, and the total.
|
||||
# --narrative (v2 stub) output a coach bio paragraph.
|
||||
# --vibe (v2 stub) output the one-word archetype.
|
||||
# --check-mismatch detect meaningful gaps between declared and observed.
|
||||
@@ -28,6 +29,7 @@
|
||||
# docs/designs/PLAN_TUNING_V0.md). Event file: ~/.gstack/projects/{SLUG}/
|
||||
# question-events.jsonl.
|
||||
set -euo pipefail
|
||||
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
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
@@ -70,7 +72,7 @@ do_migrate() {
|
||||
TMPOUT=$(mktemp "$GSTACK_HOME/developer-profile.json.tmp.XXXXXX")
|
||||
trap 'rm -f "$TMPOUT"' EXIT
|
||||
|
||||
cat "$LEGACY_FILE" | bun --no-env-file --config=/dev/null -e "
|
||||
cat "$LEGACY_FILE" | bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
|
||||
const lines = (await Bun.stdin.text()).trim().split('\n').filter(Boolean);
|
||||
const sessions = [];
|
||||
const signalsAcc = {};
|
||||
@@ -127,7 +129,7 @@ do_migrate() {
|
||||
mv "$LEGACY_FILE" "$LEGACY_FILE.migrated-$TS"
|
||||
|
||||
local COUNT
|
||||
COUNT=$(PROFILE_FILE_PATH="$PROFILE_FILE" bun --no-env-file --config=/dev/null -e "console.log(JSON.parse(require('fs').readFileSync(process.env.PROFILE_FILE_PATH,'utf-8')).sessions.length)" 2>/dev/null || echo "?")
|
||||
COUNT=$(PROFILE_FILE_PATH="$PROFILE_FILE" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "console.log(JSON.parse(require('fs').readFileSync(process.env.PROFILE_FILE_PATH,'utf-8')).sessions.length)" 2>/dev/null || echo "?")
|
||||
echo "MIGRATE: ok — migrated $COUNT sessions from builder-profile.jsonl"
|
||||
}
|
||||
|
||||
@@ -175,7 +177,8 @@ EOF
|
||||
# and update aggregates (signals_accumulated, resources_shown, topics).
|
||||
# Fix for #1671: the writer side of the v1.0.0.0 migration. Reader and
|
||||
# writer now share the same file.
|
||||
# Silent skip on invalid input (matches gstack-timeline-log:22-26 pattern).
|
||||
# Invalid input is skipped silently; a Bun failure is reported and returned,
|
||||
# as in gstack-timeline-log (#3415).
|
||||
# -----------------------------------------------------------------------
|
||||
do_log_session() {
|
||||
local INPUT="${1:-}"
|
||||
@@ -184,12 +187,23 @@ do_log_session() {
|
||||
fi
|
||||
|
||||
# Validate: input must be parseable JSON with required fields (date, mode).
|
||||
if ! printf '%s' "$INPUT" | bun --no-env-file --config=/dev/null -e "
|
||||
const j = JSON.parse(await Bun.stdin.text());
|
||||
if (!j.date || !j.mode) process.exit(1);
|
||||
" 2>/dev/null; then
|
||||
return 0
|
||||
# The validator prints its verdict; a non-zero exit means Bun itself failed.
|
||||
local VALIDATE_RC=0 VALIDATE_OUT
|
||||
VALIDATE_OUT=$(printf '%s' "$INPUT" | bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
|
||||
const text = await Bun.stdin.text();
|
||||
let j;
|
||||
try { j = JSON.parse(text); } catch {}
|
||||
console.log(j && j.date && j.mode ? 'valid' : 'invalid');
|
||||
") || VALIDATE_RC=$?
|
||||
if [ "$VALIDATE_RC" -ne 0 ]; then
|
||||
echo "gstack-developer-profile: bun exited $VALIDATE_RC before the session was validated; nothing was logged" >&2
|
||||
return "$VALIDATE_RC"
|
||||
fi
|
||||
case "$VALIDATE_OUT" in
|
||||
valid) ;;
|
||||
invalid) return 0 ;;
|
||||
*) echo "gstack-developer-profile: bun printed no verdict while validating the session; nothing was logged" >&2; return 1 ;;
|
||||
esac
|
||||
|
||||
ensure_profile
|
||||
|
||||
@@ -197,7 +211,7 @@ do_log_session() {
|
||||
TMPOUT=$(mktemp "$GSTACK_HOME/developer-profile.json.tmp.XXXXXX")
|
||||
trap 'rm -f "$TMPOUT"' EXIT
|
||||
|
||||
PROFILE_FILE_PATH="$PROFILE_FILE" RECORD_INPUT="$INPUT" TMPOUT_PATH="$TMPOUT" bun --no-env-file --config=/dev/null -e "
|
||||
PROFILE_FILE_PATH="$PROFILE_FILE" RECORD_INPUT="$INPUT" TMPOUT_PATH="$TMPOUT" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
|
||||
const fs = require('fs');
|
||||
const entry = JSON.parse(process.env.RECORD_INPUT);
|
||||
if (!entry.ts) entry.ts = new Date().toISOString();
|
||||
@@ -244,7 +258,7 @@ do_reconcile() {
|
||||
TMPOUT=$(mktemp "$GSTACK_HOME/developer-profile.json.tmp.XXXXXX")
|
||||
trap 'rm -f "$TMPOUT"' EXIT
|
||||
|
||||
PROFILE_FILE_PATH="$PROFILE_FILE" PROJECTS_DIR="$GSTACK_HOME/projects" TMPOUT_PATH="$TMPOUT" bun --no-env-file --config=/dev/null -e "
|
||||
PROFILE_FILE_PATH="$PROFILE_FILE" PROJECTS_DIR="$GSTACK_HOME/projects" TMPOUT_PATH="$TMPOUT" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const profile = JSON.parse(fs.readFileSync(process.env.PROFILE_FILE_PATH, 'utf-8'));
|
||||
@@ -314,7 +328,7 @@ do_reconcile() {
|
||||
# -----------------------------------------------------------------------
|
||||
do_read() {
|
||||
ensure_profile
|
||||
cat "$PROFILE_FILE" | bun --no-env-file --config=/dev/null -e "
|
||||
cat "$PROFILE_FILE" | bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
|
||||
const p = JSON.parse(await Bun.stdin.text());
|
||||
const sessions = p.sessions || [];
|
||||
|
||||
@@ -388,7 +402,7 @@ do_profile() {
|
||||
# -----------------------------------------------------------------------
|
||||
do_gap() {
|
||||
ensure_profile
|
||||
cat "$PROFILE_FILE" | bun --no-env-file --config=/dev/null -e "
|
||||
cat "$PROFILE_FILE" | bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
|
||||
const p = JSON.parse(await Bun.stdin.text());
|
||||
const declared = p.declared || {};
|
||||
const inferred = (p.inferred && p.inferred.values) || {};
|
||||
@@ -403,6 +417,79 @@ do_gap() {
|
||||
"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Signal accumulation shared by --derive and --trace, so the trace always
|
||||
# explains exactly the value derive wrote. Each row's SIGNAL_MAP deltas are
|
||||
# summed per (UTC day, dimension) and each day's sum is clamped to ±cap,
|
||||
# where cap is the largest single delta in SIGNAL_MAP: one day moves a
|
||||
# dimension at most as far as one strong answer. Without the bound a single
|
||||
# /plan-eng-review (about five answers) moved a dimension by 0.25 and about
|
||||
# eight reviews pinned it at 1.0 (#2623). The bound keys on the row's ts,
|
||||
# not its session_id: writers log different ids for one review (the
|
||||
# PostToolUse hook logs the host conversation, the agent the preamble run)
|
||||
# and an unsubstituted placeholder such as SESSION_ID would merge or split
|
||||
# reviews. Only an ISO 8601 date or date-time counts as dated; a time
|
||||
# without a zone is read as UTC. Every other row shares one undated bucket,
|
||||
# so junk timestamps cannot escape the bound (Date alone parses strings
|
||||
# like "junk 1" as real days). diversity.days_span counts the same days.
|
||||
# The body is a single-quoted bash string: its JS must not contain a single
|
||||
# quote (use double quotes or backticks), or the assignment ends early and
|
||||
# every subcommand fails to parse.
|
||||
# -----------------------------------------------------------------------
|
||||
ACCUMULATE_SIGNALS_JS='
|
||||
function accumulateSignals(lines, QUESTIONS, SIGNAL_MAP, dims) {
|
||||
let cap = 0;
|
||||
for (const byChoice of Object.values(SIGNAL_MAP)) {
|
||||
for (const deltas of Object.values(byChoice)) {
|
||||
for (const d of deltas) cap = Math.max(cap, Math.abs(d.delta));
|
||||
}
|
||||
}
|
||||
const contributing = Object.fromEntries(dims.map((d) => [d, 0]));
|
||||
const buckets = new Map();
|
||||
const rows = [];
|
||||
const skills = new Set();
|
||||
const qids = new Set();
|
||||
const days = new Set();
|
||||
let count = 0;
|
||||
let signalEvents = 0;
|
||||
for (const line of lines) {
|
||||
let e;
|
||||
try { e = JSON.parse(line); } catch { continue; }
|
||||
if (!e || typeof e !== "object" || typeof e.question_id !== "string" || typeof e.user_choice !== "string" || !e.question_id || !e.user_choice) continue;
|
||||
count++;
|
||||
skills.add(e.skill);
|
||||
qids.add(e.question_id);
|
||||
const m = typeof e.ts === "string" ? /^(\d{4}-\d\d-\d\d)(?:[Tt ](\d\d:\d\d(?::\d\d(?:\.\d+)?)?)(Z|z|[+-]\d\d:\d\d)?)?$/.exec(e.ts) : null;
|
||||
const when = m ? new Date(m[2] ? m[1] + "T" + m[2] + (m[3] || "Z").toUpperCase() : m[1]) : null;
|
||||
const day = when && !Number.isNaN(when.getTime()) ? when.toISOString().slice(0, 10) : null;
|
||||
if (day) days.add(day);
|
||||
const def = Object.hasOwn(QUESTIONS, e.question_id) ? QUESTIONS[e.question_id] : undefined;
|
||||
const byChoice = def && def.signal_key && Object.hasOwn(SIGNAL_MAP, def.signal_key) ? SIGNAL_MAP[def.signal_key] : {};
|
||||
const deltas = Object.hasOwn(byChoice, e.user_choice) ? byChoice[e.user_choice] : [];
|
||||
if (!Array.isArray(deltas) || deltas.length === 0) continue;
|
||||
signalEvents++;
|
||||
const key = day || "undated";
|
||||
const sums = buckets.get(key) || {};
|
||||
buckets.set(key, sums);
|
||||
for (const d of deltas) {
|
||||
sums[d.dim] = (sums[d.dim] || 0) + d.delta;
|
||||
rows.push({ ts: e.ts, question_id: e.question_id, choice: e.user_choice, dim: d.dim, delta: d.delta });
|
||||
}
|
||||
for (const dim of new Set(deltas.map((d) => d.dim))) contributing[dim] = (contributing[dim] ?? 0) + 1;
|
||||
}
|
||||
const totals = Object.fromEntries(dims.map((d) => [d, 0]));
|
||||
const capped = [];
|
||||
for (const [day, sums] of buckets) {
|
||||
for (const [dim, sum] of Object.entries(sums)) {
|
||||
const bounded = Math.max(-cap, Math.min(cap, sum));
|
||||
if (Math.abs(sum - bounded) > 1e-9) capped.push({ day, dim, sum, bounded });
|
||||
totals[dim] = (totals[dim] ?? 0) + bounded;
|
||||
}
|
||||
}
|
||||
return { cap, totals, contributing, rows, capped, count, signalEvents, skills, qids, days };
|
||||
}
|
||||
'
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Derive: recompute inferred dimensions from question-events.jsonl
|
||||
# -----------------------------------------------------------------------
|
||||
@@ -417,12 +504,13 @@ do_derive() {
|
||||
fi
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
PROFILE_FILE_PATH="$PROFILE_FILE" EVENTS_PATH="$EVENTS" bun --no-env-file --config=/dev/null -e "
|
||||
PROFILE_FILE_PATH="$PROFILE_FILE" EVENTS_PATH="$EVENTS" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
|
||||
import('./scripts/question-registry.ts').then(async (regmod) => {
|
||||
const sigmod = await import('./scripts/psychographic-signals.ts');
|
||||
const fs = require('fs');
|
||||
const { QUESTIONS } = regmod;
|
||||
const { SIGNAL_MAP, applySignal, newDimensionTotals, normalizeToDimensionValue } = sigmod;
|
||||
const { SIGNAL_MAP, ALL_DIMENSIONS, normalizeToDimensionValue } = sigmod;
|
||||
$ACCUMULATE_SIGNALS_JS
|
||||
|
||||
const profilePath = process.env.PROFILE_FILE_PATH;
|
||||
const eventsPath = process.env.EVENTS_PATH;
|
||||
@@ -433,25 +521,8 @@ do_derive() {
|
||||
lines = fs.readFileSync(eventsPath, 'utf-8').trim().split('\n').filter(Boolean);
|
||||
}
|
||||
|
||||
const totals = newDimensionTotals();
|
||||
const skills = new Set();
|
||||
const qids = new Set();
|
||||
const days = new Set();
|
||||
let count = 0;
|
||||
let signalEvents = 0;
|
||||
for (const line of lines) {
|
||||
let e;
|
||||
try { e = JSON.parse(line); } catch { continue; }
|
||||
if (!e.question_id || !e.user_choice) continue;
|
||||
count++;
|
||||
skills.add(e.skill);
|
||||
qids.add(e.question_id);
|
||||
if (e.ts) days.add(String(e.ts).slice(0,10));
|
||||
const def = QUESTIONS[e.question_id];
|
||||
if (def && def.signal_key && applySignal(totals, def.signal_key, e.user_choice).length > 0) {
|
||||
signalEvents++;
|
||||
}
|
||||
}
|
||||
const { totals, contributing, count, signalEvents, skills, qids, days } =
|
||||
accumulateSignals(lines, QUESTIONS, SIGNAL_MAP, ALL_DIMENSIONS);
|
||||
|
||||
const values = {};
|
||||
for (const [dim, total] of Object.entries(totals)) {
|
||||
@@ -464,6 +535,10 @@ do_derive() {
|
||||
// Events that actually moved a dimension (B10): sample_size counts every
|
||||
// logged answer, most of which match no signal and leave the 0.5 seed.
|
||||
signal_events: signalEvents,
|
||||
// Unique rows that moved each dimension (#2625). Distinguishes 'no
|
||||
// evidence' from 'balanced evidence': both print 0.5, which is how
|
||||
// five 0.5s sat beside sample_size 1286 (#2623).
|
||||
contributing_events: contributing,
|
||||
diversity: {
|
||||
skills_covered: skills.size,
|
||||
question_ids_covered: qids.size,
|
||||
@@ -504,34 +579,31 @@ do_trace() {
|
||||
return 0
|
||||
fi
|
||||
cd "$ROOT_DIR"
|
||||
EVENTS_PATH="$EVENTS" TRACE_DIM="$DIM" bun --no-env-file --config=/dev/null -e "
|
||||
EVENTS_PATH="$EVENTS" TRACE_DIM="$DIM" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
|
||||
import('./scripts/question-registry.ts').then(async (regmod) => {
|
||||
const sigmod = await import('./scripts/psychographic-signals.ts');
|
||||
const fs = require('fs');
|
||||
const { QUESTIONS } = regmod;
|
||||
const { SIGNAL_MAP } = sigmod;
|
||||
const { SIGNAL_MAP, ALL_DIMENSIONS, normalizeToDimensionValue } = sigmod;
|
||||
$ACCUMULATE_SIGNALS_JS
|
||||
const target = process.env.TRACE_DIM;
|
||||
const lines = fs.readFileSync(process.env.EVENTS_PATH, 'utf-8').trim().split('\n').filter(Boolean);
|
||||
const rows = [];
|
||||
for (const line of lines) {
|
||||
let e;
|
||||
try { e = JSON.parse(line); } catch { continue; }
|
||||
const def = QUESTIONS[e.question_id];
|
||||
if (!def || !def.signal_key) continue;
|
||||
const deltas = SIGNAL_MAP[def.signal_key]?.[e.user_choice] || [];
|
||||
for (const d of deltas) {
|
||||
if (d.dim === target) {
|
||||
rows.push({ ts: e.ts, question_id: e.question_id, choice: e.user_choice, delta: d.delta });
|
||||
}
|
||||
}
|
||||
}
|
||||
const acc = accumulateSignals(lines, QUESTIONS, SIGNAL_MAP, ALL_DIMENSIONS);
|
||||
const rows = acc.rows.filter((r) => r.dim === target);
|
||||
const signed = (n, digits) => (n > 0 ? '+' : '') + (digits === undefined ? String(n) : n.toFixed(digits));
|
||||
const clean = (s) => String(s ?? '').replace(/[\x00-\x1f\x7f-\x9f]/g, '?');
|
||||
if (rows.length === 0) {
|
||||
console.log('TRACE: no events contribute to ' + target);
|
||||
} else {
|
||||
console.log('TRACE: ' + rows.length + ' events for ' + target);
|
||||
for (const r of rows) {
|
||||
console.log(' ' + (r.ts || '').slice(0,19) + ' ' + r.question_id + ' → ' + r.choice + ' (' + (r.delta > 0 ? '+' : '') + r.delta + ')');
|
||||
console.log(' ' + clean(r.ts).slice(0,19) + ' ' + clean(r.question_id) + ' → ' + clean(r.choice) + ' (' + signed(r.delta) + ')');
|
||||
}
|
||||
for (const c of acc.capped.filter((c) => c.dim === target)) {
|
||||
console.log(' day ' + clean(c.day) + ': ' + signed(c.sum, 3) + ' capped to ' + signed(c.bounded, 3));
|
||||
}
|
||||
const total = acc.totals[target] ?? 0;
|
||||
console.log('TOTAL: ' + signed(total, 3) + ' (each UTC day capped at ±' + acc.cap + ') → ' + normalizeToDimensionValue(total).toFixed(3));
|
||||
}
|
||||
});
|
||||
"
|
||||
@@ -542,7 +614,7 @@ do_trace() {
|
||||
# -----------------------------------------------------------------------
|
||||
do_check_mismatch() {
|
||||
ensure_profile
|
||||
cat "$PROFILE_FILE" | bun --no-env-file --config=/dev/null -e "
|
||||
cat "$PROFILE_FILE" | bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
|
||||
const p = JSON.parse(await Bun.stdin.text());
|
||||
const declared = p.declared || {};
|
||||
const inferred = (p.inferred && p.inferred.values) || {};
|
||||
@@ -586,7 +658,7 @@ do_narrative() {
|
||||
do_vibe() {
|
||||
ensure_profile
|
||||
cd "$ROOT_DIR"
|
||||
cat "$PROFILE_FILE" | PROFILE_DATA="$(cat "$PROFILE_FILE")" bun --no-env-file --config=/dev/null -e "
|
||||
cat "$PROFILE_FILE" | PROFILE_DATA="$(cat "$PROFILE_FILE")" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
|
||||
import('./scripts/archetypes.ts').then(async (mod) => {
|
||||
const p = JSON.parse(process.env.PROFILE_DATA);
|
||||
const dims = (p.inferred && p.inferred.values) || {
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
# gstack-distill-apply --proposal <N> --gbrain-published true
|
||||
# gstack-distill-apply --list # show pending proposals
|
||||
set -euo pipefail
|
||||
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
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
|
||||
gstack_state_root_select
|
||||
@@ -57,7 +58,7 @@ if [ ! -f "$PROPOSAL_FILE" ]; then
|
||||
fi
|
||||
|
||||
if [ "$ACTION" = "list" ]; then
|
||||
PROPOSAL_FILE_PATH="$PROPOSAL_FILE" bun --no-env-file --config=/dev/null -e '
|
||||
PROPOSAL_FILE_PATH="$PROPOSAL_FILE" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e '
|
||||
const fs = require("fs");
|
||||
const p = JSON.parse(fs.readFileSync(process.env.PROPOSAL_FILE_PATH, "utf-8"));
|
||||
const proposals = p.proposals || [];
|
||||
@@ -100,7 +101,7 @@ MEMORY_FILE_PATH="$MEMORY_FILE" \
|
||||
PROFILE_FILE_PATH="$PROFILE_FILE" \
|
||||
PREF_BIN="$SCRIPT_DIR/gstack-question-preference" \
|
||||
GBRAIN_PUBLISHED="$GBRAIN_PUBLISHED" \
|
||||
bun --no-env-file --config=/dev/null -e '
|
||||
bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e '
|
||||
const fs = require("fs");
|
||||
const { spawnSync } = require("child_process");
|
||||
const idx = parseInt(process.env.PROPOSAL_IDX, 10);
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
# auditability via --status when you want it.
|
||||
# Per D6: Anthropic SDK direct call, fail-loud on missing ANTHROPIC_API_KEY.
|
||||
set -euo pipefail
|
||||
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
|
||||
|
||||
# Heredoc delivery guard. bash 5.2+ writes a heredoc body <=64KiB through a
|
||||
# pipe in the forked child before exec, with no reader on the other end. On
|
||||
@@ -59,7 +60,7 @@ esac
|
||||
# --- Status subcommand --------------------------------------------------
|
||||
|
||||
if [ "$MODE" = "status" ]; then
|
||||
COST_LOG_PATH="$COST_LOG" SLUG_PATH="$SLUG" bun --no-env-file --config=/dev/null -e '
|
||||
COST_LOG_PATH="$COST_LOG" SLUG_PATH="$SLUG" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e '
|
||||
const fs = require("fs");
|
||||
const slug = process.env.SLUG_PATH;
|
||||
const path = process.env.COST_LOG_PATH;
|
||||
@@ -98,7 +99,7 @@ if [ ! -f "$LOG_FILE" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
EVENTS_JSON=$(LOG_FILE_PATH="$LOG_FILE" bun --no-env-file --config=/dev/null -e '
|
||||
EVENTS_JSON=$(LOG_FILE_PATH="$LOG_FILE" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e '
|
||||
const fs = require("fs");
|
||||
const lines = fs.readFileSync(process.env.LOG_FILE_PATH, "utf-8").trim().split("\n").filter(Boolean);
|
||||
const out = [];
|
||||
@@ -119,7 +120,7 @@ EVENTS_JSON=$(LOG_FILE_PATH="$LOG_FILE" bun --no-env-file --config=/dev/null -e
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
')
|
||||
|
||||
EVENT_COUNT=$(printf '%s' "$EVENTS_JSON" | bun --no-env-file --config=/dev/null -e 'const a = JSON.parse(await Bun.stdin.text()); console.log(a.length);')
|
||||
EVENT_COUNT=$(printf '%s' "$EVENTS_JSON" | bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e 'const a = JSON.parse(await Bun.stdin.text()); console.log(a.length);')
|
||||
if [ "$EVENT_COUNT" -eq 0 ]; then
|
||||
echo "NO_FREE_TEXT: nothing to distill"
|
||||
exit 0
|
||||
@@ -172,7 +173,7 @@ if [ "$MODE" = "dry-run" ]; then
|
||||
echo "$DISTILL_PROMPT"
|
||||
echo
|
||||
echo "=== EVENTS ($EVENT_COUNT) ==="
|
||||
echo "$EVENTS_JSON" | bun --no-env-file --config=/dev/null -e 'console.log(JSON.stringify(JSON.parse(await Bun.stdin.text()), null, 2));'
|
||||
echo "$EVENTS_JSON" | bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e 'console.log(JSON.stringify(JSON.parse(await Bun.stdin.text()), null, 2));'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -196,7 +197,7 @@ fi
|
||||
RESULT=$(EVENTS_JSON="$EVENTS_JSON" DISTILL_PROMPT="$DISTILL_PROMPT" \
|
||||
PROPOSAL_FILE_PATH="$PROPOSAL_FILE" LOG_FILE_PATH="$LOG_FILE" \
|
||||
ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
|
||||
bun --no-env-file --config=/dev/null --cwd "$ROOT_DIR" -e '
|
||||
bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" --cwd "$ROOT_DIR" -e '
|
||||
const fs = require("fs");
|
||||
const Anthropic = require("@anthropic-ai/sdk").default;
|
||||
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
|
||||
|
||||
+2
-1
@@ -40,6 +40,7 @@
|
||||
BASH_COMPAT=50
|
||||
|
||||
set -u
|
||||
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
|
||||
|
||||
usage() {
|
||||
sed -n '3,13p' "$0" | sed 's/^# \{0,1\}//'
|
||||
@@ -512,7 +513,7 @@ check_windows_bash() {
|
||||
row warn "windows bash" "not checked: bun not found" "install Bun (see the bun row), then rerun $BIN/gstack-doctor"
|
||||
return
|
||||
fi
|
||||
out=$(bun --no-env-file --config=/dev/null "$BIN/gstack-windows-bash.ts" ${GSTACK_DOCTOR_BASH_PROBE:+--probe "$GSTACK_DOCTOR_BASH_PROBE"} </dev/null 2>&1)
|
||||
out=$(bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$BIN/gstack-windows-bash.ts" ${GSTACK_DOCTOR_BASH_PROBE:+--probe "$GSTACK_DOCTOR_BASH_PROBE"} </dev/null 2>&1)
|
||||
rc=$?
|
||||
out=$(printf '%s\n' "$out" | tr -d '\000-\010\013-\037')
|
||||
bash=$(printf '%s\n' "$out" | sed -n 's/^WINDOWS_BASH: //p' | head -1)
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
# remote's capy/support.env, `unknown` when its fetch failed, or ""), LIVE.
|
||||
# GSTACK_CAPY_WRAPPER_PATH (optional) names the wrapper a below-minimum fix runs.
|
||||
|
||||
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
|
||||
. "$BIN/gstack-doctor-components.sh" || { echo "gstack-doctor: $BIN/gstack-doctor-components.sh is missing; reinstall with cd $SRC && ./setup" >&2; exit 1; }
|
||||
. "$BIN/gstack-install-registry.sh" 2>/dev/null || { echo "gstack-doctor: $BIN/gstack-install-registry.sh is missing; reinstall with cd $SRC && ./setup" >&2; exit 1; }
|
||||
. "$BIN/gstack-bun-version.sh" 2>/dev/null || { echo "gstack-doctor: $BIN/gstack-bun-version.sh is missing; reinstall with cd $SRC && ./setup" >&2; exit 1; }
|
||||
@@ -213,7 +214,7 @@ _dc_check_browser() {
|
||||
out=$(cd "$ROOT" && { command -v timeout >/dev/null 2>&1 && timeout 90 node -e "$script" </dev/null 2>&1 || node -e "$script" </dev/null 2>&1; }); rc=$?
|
||||
elif command -v bun >/dev/null 2>&1; then
|
||||
engine=bun
|
||||
out=$(cd "$ROOT" && bun --no-env-file --config=/dev/null -e "$script" </dev/null 2>&1); rc=$?
|
||||
out=$(cd "$ROOT" && bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "$script" </dev/null 2>&1); rc=$?
|
||||
else
|
||||
_dc_row FAIL browser "neither node nor bun is on PATH to launch Chromium" "install Node.js (https://nodejs.org) or Bun, then $fix" BROWSER_UNAVAILABLE
|
||||
return
|
||||
@@ -329,7 +330,7 @@ _dc_check_pins() {
|
||||
return
|
||||
fi
|
||||
node=$(node --version </dev/null 2>/dev/null | clamp 'v0-9.' 20); node="${node#v}"
|
||||
out=$(bun --no-env-file --config=/dev/null "$BIN/gstack-runtime-pins.ts" --project "$project" ${_dc_bun:+--bun "$_dc_bun"} ${node:+--node "$node"} </dev/null 2>&1); rc=$?
|
||||
out=$(bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$BIN/gstack-runtime-pins.ts" --project "$project" ${_dc_bun:+--bun "$_dc_bun"} ${node:+--node "$node"} </dev/null 2>&1); rc=$?
|
||||
verdict=$(printf '%s\n' "$out" | sed -n 's/^PINS_VERDICT: //p' | tail -1 | clamp 'a-z' 8)
|
||||
summary=$(printf '%s\n' "$out" | sed -n 's/^PIN_SUMMARY: //p' | tr '\n' '|' | sed 's/|$//; s/|/; /g')
|
||||
case "$verdict" in
|
||||
@@ -357,7 +358,7 @@ _dc_check_pins() {
|
||||
fi
|
||||
fi
|
||||
if [ -n "$_dc_ln_dir" ] && [ "$_dc_ln_ver" != "$_dc_bun" ] \
|
||||
&& bun --no-env-file --config=/dev/null "$BIN/gstack-runtime-pins.ts" --project "$project" --bun "$_dc_ln_ver" ${node:+--node "$node"} </dev/null 2>/dev/null | grep -q '^PINS_VERDICT: pass'; then
|
||||
&& bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$BIN/gstack-runtime-pins.ts" --project "$project" --bun "$_dc_ln_ver" ${node:+--node "$node"} </dev/null 2>/dev/null | grep -q '^PINS_VERDICT: pass'; then
|
||||
note="; bun $_dc_ln_ver found at $_dc_ln_dir (not on PATH) satisfies them" fix="$_dc_ln_fix"
|
||||
fi
|
||||
_dc_row FAIL pins "$failline$note" "$fix" PROJECT_PIN_MISMATCH ;;
|
||||
|
||||
@@ -277,6 +277,9 @@ gstack_result_anchor() {
|
||||
QUEUE_TRAIN_REBUILD_REFUSED) printf '%s' 'queue-train-rebuild-refused' ;;
|
||||
QUEUE_TRAIN_TREE_MISMATCH) printf '%s' 'queue-train-tree-mismatch' ;;
|
||||
QUEUE_TRAIN_FROZEN) printf '%s' 'queue-train-frozen' ;;
|
||||
QUEUE_RECEIPT_MISSING) printf '%s' 'queue-receipt-missing' ;;
|
||||
QUEUE_RECEIPT_STALE) printf '%s' 'queue-receipt-stale' ;;
|
||||
QUEUE_SCREEN_STALE_REF) printf '%s' 'queue-screen-stale-ref' ;;
|
||||
READY_SCREEN_CHOICE_REQUIRED) printf '%s' 'ready-screen-choice-required' ;;
|
||||
PREGATE_TIMING_ASSERTION) printf '%s' 'pregate-timing-assertion' ;;
|
||||
APPROVAL_CLASSES_INVALID) printf '%s' 'approval-classes-invalid' ;;
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
# 2 — prerequisite missing or invalid argument
|
||||
# 3 — post-install validation failed (PATH shadow, broken binary, etc.)
|
||||
set -euo pipefail
|
||||
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
|
||||
|
||||
# --- defaults ---
|
||||
# No version pin by default — install the latest default-branch HEAD (#1744).
|
||||
@@ -100,7 +101,7 @@ fi
|
||||
# bun, which this installer already requires. It used to need jq, which it
|
||||
# never checked for, so a successful install reported failure (#1675).
|
||||
pkg_field() {
|
||||
GSTACK_PKG_JSON="$1" GSTACK_PKG_FIELD="$2" bun --no-env-file --config=/dev/null -e '
|
||||
GSTACK_PKG_JSON="$1" GSTACK_PKG_FIELD="$2" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e '
|
||||
try {
|
||||
const pkg = JSON.parse(require("fs").readFileSync(process.env.GSTACK_PKG_JSON, "utf8"));
|
||||
const v = process.env.GSTACK_PKG_FIELD.split(".").reduce((o, k) => (o == null ? o : o[k]), pkg);
|
||||
@@ -198,7 +199,7 @@ _bun_exe_hint() {
|
||||
[ "$IS_WINDOWS" -eq 1 ] || return 0
|
||||
command -v bun.exe >/dev/null 2>&1 && return 0
|
||||
local real_bun
|
||||
real_bun=$(bun --no-env-file --config=/dev/null -e 'console.log(process.execPath)' 2>/dev/null | tr -d '\r' || true)
|
||||
real_bun=$(bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e 'console.log(process.execPath)' 2>/dev/null | tr -d '\r' || true)
|
||||
echo " detected: bun was installed via npm — bun.exe is NOT on %PATH%, and the gbrain.exe shim needs it." >&2
|
||||
if [ -n "$real_bun" ]; then
|
||||
echo " fix: add bun.exe's directory to PATH (persist it in your shell profile):" >&2
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
# Depends on: bun (already a gstack dependency), gbrain, git.
|
||||
|
||||
set -euo pipefail
|
||||
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
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
CONFIG_BIN="$SCRIPT_DIR/gstack-config"
|
||||
@@ -222,7 +223,7 @@ ensure_worktree() {
|
||||
# is not JSON.
|
||||
source_local_path() {
|
||||
gbrain sources list --json 2>/dev/null \
|
||||
| GB_SOURCE_ID="$1" bun --no-env-file --config=/dev/null -e '
|
||||
| GB_SOURCE_ID="$1" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e '
|
||||
const raw = await Bun.stdin.text();
|
||||
try {
|
||||
const s = (JSON.parse(raw).sources || []).find((x) => x.id === process.env.GB_SOURCE_ID);
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
# (needed when a hook runs TypeScript) is not found. Set GSTACK_HOOK_CHECK_BUN to
|
||||
# the bun to run (setup passes its own).
|
||||
set -u
|
||||
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
|
||||
|
||||
LIST_ONLY=0 SETUP_FILE=""
|
||||
while [ $# -gt 1 ]; do
|
||||
@@ -173,7 +174,7 @@ if [ -n "$ENTRIES" ]; then
|
||||
# up below (lib/path-spelling.ts shellPath; test/path-spelling.test.ts keeps
|
||||
# the two equal).
|
||||
# shellcheck disable=SC2086 # newline-split list (IFS is set to a newline below)
|
||||
(cd "$ROOT" && IFS=$NL && "$BUN" --no-env-file --config=/dev/null -e '
|
||||
(cd "$ROOT" && IFS=$NL && "$BUN" --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e '
|
||||
const shellPath = (value) => process.platform === "win32"
|
||||
? String(value).replace(/\\/g, "/").replace(/^([A-Za-z]):(?=\/|$)/, (_, drive) => `/${drive.toLowerCase()}`)
|
||||
: String(value);
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
# spawned sessions (ambient OpenClaw) only warn. GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1
|
||||
# admits anyway and says so.
|
||||
|
||||
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
|
||||
|
||||
# gstack_source_root BIN_DIR — the source checkout behind an install: an
|
||||
# installed runtime root's recorded .source-path (setup _link_runtime_dists),
|
||||
# else BIN_DIR/.. resolved. Prints the realpath; returns 1 when unresolvable.
|
||||
@@ -131,11 +133,15 @@ gstack_install_admission() {
|
||||
fix="run the Capy skill installer (scripts/install.sh --for $skill) on this machine, then launch with the GSTACK_LAUNCH line it prints"
|
||||
if [ ! -f "$file" ]; then
|
||||
detail="no install verdict at $file for $src"
|
||||
fix="run the Capy skill installer (scripts/install.sh --for $skill) from ${project:-this directory}: the verdict is keyed by the project directory the installer runs in, so a run from the skill's own folder does not cover ${project:-it}; then launch with the GSTACK_LAUNCH line it prints"
|
||||
else
|
||||
trailer=$(gstack_install_verdict_field "$file" trailer)
|
||||
v_head=$(gstack_install_verdict_field "$file" head)
|
||||
head=$(git -C "$src" rev-parse HEAD 2>/dev/null)
|
||||
if [ "$(gstack_install_verdict_field "$file" ok)" != true ]; then
|
||||
v_schema=$(gstack_install_verdict_field "$file" schema_version)
|
||||
if { [ -z "$(gstack_install_verdict_field "$file" ok)" ] && [ -z "$v_schema" ]; } || { [ "$v_schema" != 1 ] && [ "$v_schema" != 2 ]; }; then
|
||||
detail="the install verdict at $file is unreadable or has an unsupported schema_version; rerun the installer"
|
||||
elif [ "$(gstack_install_verdict_field "$file" ok)" != true ]; then
|
||||
detail="the install verdict says: $trailer"
|
||||
elif [ "$(gstack_install_verdict_field "$file" checkout)" != "$src" ]; then
|
||||
detail="the install verdict is for checkout $(gstack_install_verdict_field "$file" checkout), not $src"
|
||||
@@ -159,7 +165,7 @@ gstack_install_admission() {
|
||||
v_bun=$(gstack_install_verdict_field "$file" bun_path)
|
||||
if [ -z "$detail" ] && [ -n "$v_bun" ]; then
|
||||
running=$(gstack_realpath "$where" 2>/dev/null)
|
||||
_gstack_bun_is_shim "$where" && running=$(bun --no-env-file --config=/dev/null -p process.execPath </dev/null 2>/dev/null | head -1)
|
||||
_gstack_bun_is_shim "$where" && running=$(bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -p process.execPath </dev/null 2>/dev/null | head -1)
|
||||
if [ -n "$running" ] && [ "$running" != "$v_bun" ]; then
|
||||
_ia_status=runtime code=BUN_RESOLUTION_DRIFT
|
||||
detail="bun $found at $running is running, but the install verdict recorded bun $(gstack_install_verdict_field "$file" bun_version) at $v_bun"
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
# session tokens are outstanding.
|
||||
|
||||
set -euo pipefail
|
||||
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
|
||||
# The --help heredoc is over 512 bytes; keep it off a pipe (test/heredoc-pipe-deadlock.test.ts).
|
||||
BASH_COMPAT=50
|
||||
|
||||
@@ -67,4 +68,4 @@ if ! command -v bun >/dev/null 2>&1; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec bun --no-env-file --config=/dev/null run "$ENTRY" "$@"
|
||||
exec bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" run "$ENTRY" "$@"
|
||||
@@ -10,6 +10,7 @@
|
||||
# Allowlist file: ~/.gstack/ios-qa-allowlist.json (mode 0600).
|
||||
|
||||
set -euo pipefail
|
||||
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
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
GSTACK_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
@@ -25,4 +26,4 @@ if ! command -v bun >/dev/null 2>&1; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec bun --no-env-file --config=/dev/null run "$ENTRY" "$@"
|
||||
exec bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" run "$ENTRY" "$@"
|
||||
@@ -3,6 +3,7 @@
|
||||
# package and the app-owned typed state accessors.
|
||||
|
||||
set -euo pipefail
|
||||
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
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
@@ -144,7 +145,7 @@ do
|
||||
fi
|
||||
done
|
||||
|
||||
bun --no-env-file --config=/dev/null run "$GENERATOR" --input "$APP_SOURCE" --output "$GENERATED_DIR"
|
||||
bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" run "$GENERATOR" --input "$APP_SOURCE" --output "$GENERATED_DIR"
|
||||
|
||||
# Stamp only after successful accessor generation. ios-sync uses this marker
|
||||
# to distinguish a complete current install from an interrupted regeneration.
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
# Append-only storage. Duplicates (same key+type) are resolved at read time
|
||||
# by gstack-learnings-search ("latest winner" per key+type).
|
||||
set -euo pipefail
|
||||
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
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# Windows git-bash (#1950): pwd yields a POSIX path (/c/Users/...), which Bun
|
||||
# on Windows cannot resolve as an ES module specifier in the import below.
|
||||
@@ -27,7 +28,7 @@ INPUT="$1"
|
||||
TMPERR=$(mktemp "${TMPDIR:-/tmp}/gstack-learnings-log.XXXXXX") || { echo "gstack-learnings-log: could not create a temp file in ${TMPDIR:-/tmp}, so the learning was not recorded. Fix: point TMPDIR at a writable directory." >&2; exit 1; }
|
||||
trap 'rm -f "$TMPERR"' EXIT
|
||||
set +e
|
||||
VALIDATED=$(printf '%s' "$INPUT" | GSTACK_LIB_DIR="$SCRIPT_DIR/../lib" bun --no-env-file --config=/dev/null -e "
|
||||
VALIDATED=$(printf '%s' "$INPUT" | GSTACK_LIB_DIR="$SCRIPT_DIR/../lib" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
|
||||
const { hasInjection } = await import(process.env.GSTACK_LIB_DIR + '/jsonl-store.ts');
|
||||
const raw = await Bun.stdin.text();
|
||||
let j;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
# exits 127 and a failed read exits non-zero with its stderr (#2790): "could
|
||||
# not read" must never look like "nothing recorded".
|
||||
set -euo pipefail
|
||||
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
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
|
||||
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
|
||||
@@ -69,7 +70,7 @@ emit_tagged_file() {
|
||||
emit_tagged_file cross "$f"
|
||||
done
|
||||
fi
|
||||
} | GSTACK_SEARCH_TYPE="$TYPE" GSTACK_SEARCH_QUERY="$QUERY" GSTACK_SEARCH_LIMIT="$LIMIT" GSTACK_SEARCH_CROSS="$CROSS_PROJECT" bun --no-env-file --config=/dev/null -e "
|
||||
} | GSTACK_SEARCH_TYPE="$TYPE" GSTACK_SEARCH_QUERY="$QUERY" GSTACK_SEARCH_LIMIT="$LIMIT" GSTACK_SEARCH_CROSS="$CROSS_PROJECT" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
|
||||
const lines = (await Bun.stdin.text()).trim().split('\n').filter(Boolean);
|
||||
const now = Date.now();
|
||||
const type = process.env.GSTACK_SEARCH_TYPE || '';
|
||||
|
||||
+140
-32
@@ -14,7 +14,8 @@
|
||||
# (bin/gstack-harness-pid.sh, the same derivation the session marker
|
||||
# uses), or behind --from (a caller whose own parent is short-lived
|
||||
# passes its parent pid); leases older than 120 min are pruned. Exits 3
|
||||
# and writes nothing when a live process holds the directory lock
|
||||
# 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>]
|
||||
# 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
|
||||
@@ -31,7 +32,7 @@
|
||||
# runtime root's .source-path, else ../). A root that is not a git checkout
|
||||
# exits 2 and writes nothing. GSTACK_LEASE_DIR overrides the lease directory
|
||||
# (hermetic tests; a dev checkout's own tests must not lease it). Bash 3.2
|
||||
# plus git, ps, find, ln, readlink.
|
||||
# plus git, ps, find, stat, date, head, mkdir, mv, readlink.
|
||||
set -u
|
||||
BIN="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
# shellcheck source=bin/gstack-install-verdict.sh
|
||||
@@ -53,7 +54,7 @@ while [ $# -gt 0 ]; do
|
||||
done
|
||||
case "$VERB" in
|
||||
touch|live|list) ;;
|
||||
-h|--help|'') sed -n '2,24p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
-h|--help|'') sed -n '2,25p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "gstack-lease: unknown verb '$VERB'" >&2; exit 2 ;;
|
||||
esac
|
||||
[ -n "$ROOT" ] || ROOT=$(gstack_source_root "$BIN")
|
||||
@@ -99,43 +100,139 @@ state() {
|
||||
}
|
||||
# The lease directory's lock: touch's publish and prune, and reclaim's
|
||||
# reread-and-delete, run under it, so a reclaim never deletes a renewal and a
|
||||
# lease never disappears while it is live. The lock is a symlink whose target
|
||||
# names its holder (`<pid> <start identity>`): ln -s creates it and its owner
|
||||
# in one atomic step. It is broken only when that holder is dead or its pid
|
||||
# now belongs to another process, never by age, and unlock removes only the
|
||||
# caller's own lock. A live holder makes lock() give up after
|
||||
# GSTACK_LEASE_LOCK_TRIES tries 0.1 s apart (default 300).
|
||||
# lease never disappears while it is live. The lock is a directory, .lock,
|
||||
# whose owner file names its holder (`<pid> <start identity>`); mkdir is
|
||||
# atomic everywhere gstack runs, Git Bash on Windows included, where ln -s to a
|
||||
# target that does not exist always fails (#3330). A taker publishes its token
|
||||
# as a claim (.lock.claim.<pid>) before its mkdir and renames the claim into
|
||||
# the lock as its owner after, so a lock without an owner is either being
|
||||
# taken by a live claimant or orphaned by a dead one. A lock is broken only
|
||||
# when its holder is dead or its pid now belongs to another process, never by
|
||||
# age, and unlock removes only the caller's own lock. An ownerless lock is
|
||||
# broken once no claimant lives and it is at least 2 s old (and unchanged
|
||||
# while it was checked), so a taker between its mkdir and its owner rename is
|
||||
# never taken for a crash, even when its claim landed after the claims were
|
||||
# read. A claim is honored while its claimant lives; one that records no
|
||||
# start identity (so a reused pid cannot be told apart) is abandoned after 2
|
||||
# minutes, since a live taker holds one for milliseconds. Known limit: on such
|
||||
# a host a taker stalled over 2 minutes between its mkdir and its owner rename
|
||||
# can be broken and then publish into its successor's lock. An owner whose
|
||||
# pid is 0, zero-padded or not a number counts as a dead holder, and a lock
|
||||
# whose owner file exists but cannot be read is never treated as ownerless. A symlink .lock left by an older
|
||||
# gstack names its holder the same way and excludes a directory lock at the
|
||||
# same path. Known limit: the reverse does not hold, since an older gstack's
|
||||
# ln -s onto a directory lock links inside it and succeeds; only a process
|
||||
# that started before an upgrade of this checkout can run that code. A live holder
|
||||
# makes lock() give up after GSTACK_LEASE_LOCK_TRIES tries 0.1 s apart
|
||||
# (default 300) or 30 s, whichever comes first.
|
||||
LOCK="$DIR/.lock" TOKEN=""
|
||||
# take PATH — create the lock PATH owned by TOKEN; 1 when it exists.
|
||||
take() {
|
||||
if [ -e "$1" ] || [ -L "$1" ]; then return 1; fi
|
||||
{ printf '%s\n' "$TOKEN" > "$1.claim.$$"; } 2>/dev/null || { rm -f "$1.claim.$$"; return 1; }
|
||||
if mkdir "$1" 2>/dev/null; then
|
||||
[ ! -e "$1/owner" ] && mv -f "$1.claim.$$" "$1/owner" && return 0
|
||||
[ -e "$1/owner" ] || rm -rf "$1"
|
||||
fi
|
||||
rm -f "$1.claim.$$"
|
||||
return 1
|
||||
}
|
||||
# owner_of PATH — the token the lock PATH names, empty when it names none.
|
||||
owner_of() { if [ -L "$1" ]; then readlink "$1" 2>/dev/null; else head -1 "$1/owner" 2>/dev/null; fi; }
|
||||
mtime() { stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null; }
|
||||
# orphaned PATH — PATH is a lock (not a legacy symlink) whose owner file is
|
||||
# missing or readable and empty, has no live claimant, is at least 2 s old and kept
|
||||
# its mtime throughout. The clock is read before the first mtime, so a pause
|
||||
# only makes the lock look younger, and a lock made since (a new generation)
|
||||
# has a newer mtime. Claims are read before the owner, so a taker that renames
|
||||
# its claim in between is seen as the owner. An empty claim is a taker that
|
||||
# has not reached its mkdir, so it never made this lock. An mtime a minute or
|
||||
# more in the future (clock skew) counts as old.
|
||||
orphaned() {
|
||||
_oclock=$(date +%s)
|
||||
[ -e "$1" ] && [ ! -L "$1" ] || return 1
|
||||
_om=$(mtime "$1") || return 1
|
||||
for _c in "$1".claim.*; do
|
||||
_t=$(head -1 "$_c" 2>/dev/null)
|
||||
abandoned "$_c" "$_t" && continue
|
||||
[ -n "$_t" ] && ! holder_gone "$_t" && return 1
|
||||
done
|
||||
if [ -d "$1" ] && [ -e "$1/owner" ]; then
|
||||
_o=$(head -1 "$1/owner" 2>/dev/null) || return 1
|
||||
[ -z "$_o" ] || return 1
|
||||
fi
|
||||
[ "$(mtime "$1")" = "$_om" ] || return 1
|
||||
_oage=$((_oclock - _om))
|
||||
[ "$_oage" -ge 2 ] || [ "$_oage" -le -60 ]
|
||||
}
|
||||
# drop PATH — remove a lock in one step: a directory is renamed aside first,
|
||||
# so nobody sees it ownerless on the way out. Once renamed the lock is
|
||||
# released, whether or not the renamed copy can be deleted (touch prunes it).
|
||||
drop() {
|
||||
if [ -L "$1" ]; then rm -f "$1"; return; fi
|
||||
mv "$1" "$1.gone.$$" 2>/dev/null || return 1
|
||||
rm -rf "$1.gone.$$" 2>/dev/null
|
||||
return 0
|
||||
}
|
||||
lock() {
|
||||
[ -n "$TOKEN" ] || TOKEN="$$ $(identity $$)"
|
||||
_tries=0
|
||||
until ln -s "$TOKEN" "$LOCK" 2>/dev/null; do
|
||||
_tries=$((_tries + 1))
|
||||
LOCK_OWNER=$(readlink "$LOCK" 2>/dev/null)
|
||||
[ -n "$LOCK_OWNER" ] && holder_gone "$LOCK_OWNER" && break_lock "$LOCK_OWNER" && continue
|
||||
[ "$_tries" -ge "${GSTACK_LEASE_LOCK_TRIES:-300}" ] && return 1
|
||||
_tries=0 _gone=0 _max=${GSTACK_LEASE_LOCK_TRIES:-300} _start=$SECONDS
|
||||
case "$_max" in ''|*[!0-9]*) _max=300 ;; esac
|
||||
until take "$LOCK"; do
|
||||
LOCK_OWNER=$(owner_of "$LOCK")
|
||||
if [ ! -e "$LOCK" ] && [ ! -L "$LOCK" ]; then
|
||||
# Released since take, or take itself cannot work here: never wait out
|
||||
# the tries for a lock nobody holds.
|
||||
_gone=$((_gone + 1))
|
||||
[ "$_gone" -ge 10 ] && return 1
|
||||
continue
|
||||
fi
|
||||
_gone=0 _tries=$((_tries + 1))
|
||||
if [ -n "$LOCK_OWNER" ]; then holder_gone "$LOCK_OWNER" && break_lock "$LOCK_OWNER" && continue
|
||||
elif orphaned "$LOCK"; then break_lock "" && continue
|
||||
fi
|
||||
[ "$_tries" -ge "$_max" ] && return 1
|
||||
[ $((SECONDS - _start)) -ge 30 ] && return 1
|
||||
sleep 0.1
|
||||
done
|
||||
}
|
||||
holder_gone() { case "$(state "${1%% *}" "${1#* }" 0)" in dead|stale) return 0 ;; esac; return 1; }
|
||||
# break_lock OWNER — remove the lock only if it still names OWNER. Breakers
|
||||
# take a second symlink, .lock.break, and reread the lock under it, so a lock
|
||||
# taken since the verdict is never removed; a .lock.break whose holder is gone
|
||||
# is cleared. Returns 1 when another breaker holds .lock.break. Known limit:
|
||||
# that clear is not atomic, so two gstack-lease processes killed in a row (one
|
||||
# holding .lock, the next inside this function) can let a reclaim delete a
|
||||
# renewal; one crash is always safe.
|
||||
# abandoned CLAIM TOKEN — the claim records no start identity and is over 2
|
||||
# minutes old.
|
||||
abandoned() { [ -z "${2#* }" ] && [ -z "$(find "$1" -mmin -2 2>/dev/null)" ]; }
|
||||
holder_gone() {
|
||||
case "${1%% *}" in ''|*[!0-9]*|0*) return 0 ;; esac
|
||||
case "$(state "${1%% *}" "${1#* }" 0)" in dead|stale) return 0 ;; esac; return 1
|
||||
}
|
||||
# break_lock OWNER — remove the lock only if it still names OWNER (empty: is
|
||||
# still orphaned). Breakers take a second lock, .lock.break, and reread the
|
||||
# lock under it, so a lock taken since the verdict is never removed; a
|
||||
# .lock.break whose holder is gone is cleared. A lock that cannot be renamed
|
||||
# aside is removed in place (other breakers wait on .lock.break meanwhile).
|
||||
# Returns 1 when another breaker holds .lock.break or the lock could not be
|
||||
# removed, so lock() counts the try instead of spinning. Known limit: that
|
||||
# clear is check-then-remove, so a breaker killed while holding .lock.break,
|
||||
# followed by two live breakers clearing it at once, can let both break and a
|
||||
# reclaim delete a renewal.
|
||||
break_lock() {
|
||||
if ! ln -s "$TOKEN" "$LOCK.break" 2>/dev/null; then
|
||||
_b=$(readlink "$LOCK.break" 2>/dev/null)
|
||||
[ -n "$_b" ] && holder_gone "$_b" && [ "$(readlink "$LOCK.break" 2>/dev/null)" = "$_b" ] && rm -f "$LOCK.break"
|
||||
if ! take "$LOCK.break"; then
|
||||
_b=$(owner_of "$LOCK.break")
|
||||
if [ -n "$_b" ]; then
|
||||
holder_gone "$_b" && [ "$(owner_of "$LOCK.break")" = "$_b" ] && drop "$LOCK.break"
|
||||
else
|
||||
orphaned "$LOCK.break" && drop "$LOCK.break"
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
[ "$(readlink "$LOCK" 2>/dev/null)" = "$1" ] && rm -f "$LOCK"
|
||||
rm -f "$LOCK.break"
|
||||
return 0
|
||||
_kept=0
|
||||
if [ -n "$1" ]; then
|
||||
[ "$(owner_of "$LOCK")" = "$1" ] && { drop "$LOCK" || rm -rf "$LOCK" || _kept=1; }
|
||||
else
|
||||
orphaned "$LOCK" && { drop "$LOCK" || rm -rf "$LOCK" || _kept=1; }
|
||||
fi
|
||||
drop "$LOCK.break"
|
||||
return $_kept
|
||||
}
|
||||
unlock() { [ "$(readlink "$LOCK" 2>/dev/null)" = "$TOKEN" ] && rm -f "$LOCK"; return 0; }
|
||||
unlock() { [ "$(owner_of "$LOCK")" = "$TOKEN" ] && { drop "$LOCK" || rm -rf "$LOCK"; }; return 0; }
|
||||
# reclaim FILE RECORD — delete a stale lease only if, under the lock, it still
|
||||
# holds the record judged stale; a renewal since then keeps it.
|
||||
reclaim() {
|
||||
@@ -153,10 +250,21 @@ case "$VERB" in
|
||||
touch)
|
||||
[ -n "$PID" ] || PID=$(gstack_harness_pid "${FROM:-$PPID}")
|
||||
mkdir -p "$DIR" || exit 2
|
||||
lock || { echo "gstack-lease: the lease directory is locked by live process ${LOCK_OWNER%% *}; lease $PID not renewed" >&2; exit 3; }
|
||||
lock || {
|
||||
if [ -n "${LOCK_OWNER:-}" ] && ! holder_gone "$LOCK_OWNER"; then echo "gstack-lease: the lease directory is locked by live process ${LOCK_OWNER%% *}; lease $PID not renewed" >&2
|
||||
else echo "gstack-lease: cannot take the lease directory lock $LOCK; lease $PID not renewed" >&2
|
||||
fi
|
||||
exit 3
|
||||
}
|
||||
_tmp="$DIR/.$PID.$$"
|
||||
identity "$PID" > "$_tmp" && mv -f "$_tmp" "$DIR/$PID" || { rm -f "$_tmp"; unlock; exit 2; }
|
||||
find "$DIR" -mmin +120 -type f -exec rm -f {} + 2>/dev/null
|
||||
find "$DIR" -maxdepth 1 -mmin +120 -type f ! -name '.lock*' -exec rm -f {} + 2>/dev/null
|
||||
for _c in "$DIR"/.lock*.claim.*; do
|
||||
[ -f "$_c" ] || continue
|
||||
_t=$(head -1 "$_c" 2>/dev/null)
|
||||
[ -n "$(find "$_c" -mmin +2 2>/dev/null)" ] && { abandoned "$_c" "$_t" || holder_gone "$_t"; } && rm -f "$_c"
|
||||
done
|
||||
rm -rf "$DIR"/.lock*.gone.* 2>/dev/null
|
||||
unlock
|
||||
exit 0 ;;
|
||||
list)
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
# Heredoc delivery guard (see bin/gstack-settings-hook for the rationale).
|
||||
BASH_COMPAT=50
|
||||
set -uo pipefail
|
||||
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
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
@@ -374,7 +375,7 @@ status_bridge() {
|
||||
# JSON; a failed query is reported as unknown, never as an empty history.
|
||||
local egress_json
|
||||
if egress_json="$("$EGRESS_BIN" list --sink "$SINK" --json 2>/dev/null)"; then
|
||||
n="$(printf '%s' "$egress_json" | bun --no-env-file --config=/dev/null -e 'const a=JSON.parse(require("fs").readFileSync(0,"utf8")||"[]");console.log(Array.isArray(a)?a.length:0)' 2>/dev/null)"
|
||||
n="$(printf '%s' "$egress_json" | bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e 'const a=JSON.parse(require("fs").readFileSync(0,"utf8")||"[]");console.log(Array.isArray(a)?a.length:0)' 2>/dev/null)"
|
||||
case "$n" in *[!0-9]*|"") n="unknown (could not parse gstack-egress output)" ;; esac
|
||||
else
|
||||
n="unknown (gstack-egress list failed; run it yourself)"
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
#
|
||||
# Append-only JSONL. Dedup is at read time in gstack-question-sensitivity --read-log.
|
||||
set -euo pipefail
|
||||
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
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# Windows git-bash (#1950): pwd yields a POSIX path (/c/Users/...), which Bun
|
||||
# on Windows cannot resolve as an ES module specifier in bun -e imports.
|
||||
@@ -51,7 +52,7 @@ INPUT="$1"
|
||||
TMPERR=$(mktemp "${TMPDIR:-/tmp}/gstack-question-log.XXXXXX") || { echo "gstack-question-log: could not create a temp file in ${TMPDIR:-/tmp}, so the question was not logged. Fix: point TMPDIR at a writable directory." >&2; exit 1; }
|
||||
trap 'rm -f "$TMPERR"' EXIT
|
||||
set +e
|
||||
VALIDATED=$(printf '%s' "$INPUT" | GSTACK_LIB_DIR="$SCRIPT_DIR/../lib" GSTACK_SCRIPTS_DIR="$SCRIPT_DIR/../scripts" bun --no-env-file --config=/dev/null -e "
|
||||
VALIDATED=$(printf '%s' "$INPUT" | GSTACK_LIB_DIR="$SCRIPT_DIR/../lib" GSTACK_SCRIPTS_DIR="$SCRIPT_DIR/../scripts" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
|
||||
const { hasInjection } = await import(process.env.GSTACK_LIB_DIR + '/jsonl-store.ts');
|
||||
const { resolveOptionKey } = await import(process.env.GSTACK_SCRIPTS_DIR + '/psychographic-signals.ts');
|
||||
const path = require('path');
|
||||
@@ -241,7 +242,7 @@ LOG_FILE="$GSTACK_HOME/projects/$SLUG/question-log.jsonl"
|
||||
# Lookup is bounded so the bin stays cheap on hot paths.
|
||||
DEDUP_SKIP=""
|
||||
if [ -f "$LOG_FILE" ]; then
|
||||
DEDUP_SKIP=$(VALIDATED_JSON="$VALIDATED" LOG_FILE_PATH="$LOG_FILE" bun --no-env-file --config=/dev/null -e '
|
||||
DEDUP_SKIP=$(VALIDATED_JSON="$VALIDATED" LOG_FILE_PATH="$LOG_FILE" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e '
|
||||
const fs = require("fs");
|
||||
const j = JSON.parse(process.env.VALIDATED_JSON);
|
||||
if (!j.tool_use_id) { console.log(""); process.exit(0); }
|
||||
@@ -261,7 +262,7 @@ if [ -f "$LOG_FILE" ]; then
|
||||
fi
|
||||
|
||||
if [ "$DEDUP_SKIP" = "dup" ]; then
|
||||
echo "DEDUP: skipped (source=$(echo "$VALIDATED" | bun --no-env-file --config=/dev/null -e 'const j=JSON.parse(await Bun.stdin.text()); console.log(j.source);'), tool_use_id duplicate)"
|
||||
echo "DEDUP: skipped (source=$(echo "$VALIDATED" | bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e 'const j=JSON.parse(await Bun.stdin.text()); console.log(j.source);'), tool_use_id duplicate)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
# --check already ignores those prefs; storing them made --stats lie.
|
||||
# always-ask on a one-way id is fine (it agrees with the safety override).
|
||||
set -euo pipefail
|
||||
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
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
@@ -105,7 +106,7 @@ do_check() {
|
||||
fi
|
||||
ensure_file
|
||||
cd "$ROOT_DIR"
|
||||
PREF_FILE_PATH="$PREF_FILE" GLOBAL_PREF_FILE_PATH="$GLOBAL_PREF_FILE" QID="$QID" QSUMMARY="$QSUMMARY" bun --no-env-file --config=/dev/null -e "
|
||||
PREF_FILE_PATH="$PREF_FILE" GLOBAL_PREF_FILE_PATH="$GLOBAL_PREF_FILE" QID="$QID" QSUMMARY="$QSUMMARY" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
|
||||
import('./scripts/one-way-doors.ts').then((oneway) => {
|
||||
const fs = require('fs');
|
||||
const qid = process.env.QID;
|
||||
@@ -201,7 +202,7 @@ do_write() {
|
||||
|
||||
set +e
|
||||
local RESULT
|
||||
RESULT=$(cd "$ROOT_DIR" && printf '%s' "$INPUT" | PREF_FILE_PATH="$PREF_FILE" EVENT_FILE_PATH="$EVENT_FILE" FREE_TEXT="$FREE_TEXT" HAS_FREE_TEXT="$HAS_FREE_TEXT" bun --no-env-file --config=/dev/null -e "
|
||||
RESULT=$(cd "$ROOT_DIR" && printf '%s' "$INPUT" | PREF_FILE_PATH="$PREF_FILE" EVENT_FILE_PATH="$EVENT_FILE" FREE_TEXT="$FREE_TEXT" HAS_FREE_TEXT="$HAS_FREE_TEXT" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
|
||||
const fs = require('fs');
|
||||
const raw = await Bun.stdin.text();
|
||||
let j;
|
||||
@@ -320,7 +321,7 @@ do_clear() {
|
||||
echo '{}' > "$PREF_FILE"
|
||||
echo "OK: cleared all preferences"
|
||||
else
|
||||
PREF_FILE_PATH="$PREF_FILE" QID="$QID" bun --no-env-file --config=/dev/null -e "
|
||||
PREF_FILE_PATH="$PREF_FILE" QID="$QID" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
|
||||
const fs = require('fs');
|
||||
const prefs = JSON.parse(fs.readFileSync(process.env.PREF_FILE_PATH, 'utf-8'));
|
||||
if (prefs[process.env.QID] !== undefined) {
|
||||
@@ -339,7 +340,7 @@ do_clear() {
|
||||
# -----------------------------------------------------------------------
|
||||
do_stats() {
|
||||
ensure_file
|
||||
(cd "$ROOT_DIR" && PREF_FILE_PATH="$PREF_FILE" bun --no-env-file --config=/dev/null -e "
|
||||
(cd "$ROOT_DIR" && PREF_FILE_PATH="$PREF_FILE" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
|
||||
import('./scripts/one-way-doors.ts').then((oneway) => {
|
||||
const fs = require('fs');
|
||||
const prefs = JSON.parse(fs.readFileSync(process.env.PREF_FILE_PATH, 'utf-8'));
|
||||
|
||||
+2
-1
@@ -9,6 +9,7 @@
|
||||
# GSTACK_INSTALL_DIR — override gstack install directory
|
||||
# GSTACK_SKILLS_DIR — override target skills directory
|
||||
set -euo pipefail
|
||||
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
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
GSTACK_CONFIG="${SCRIPT_DIR}/gstack-config"
|
||||
@@ -474,7 +475,7 @@ if { [ "$DISABLED" != " " ] || grep -q '^Disabled on this install' "$_ROUTER" 2>
|
||||
_ROUTER_TMP="$(mktemp -d "${TMPDIR:-/tmp}/gstack-router.XXXXXX")" || _ROUTER_TMP=""
|
||||
_DISABLED_CSV="$(printf '%s' "$DISABLED" | xargs | tr ' ' ',')"
|
||||
if [ -n "$_ROUTER_TMP" ] && command -v bun >/dev/null 2>&1 \
|
||||
&& (cd "$_GEN_SRC" && bun --no-env-file --config=/dev/null run scripts/gen-skill-docs.ts --host claude --out-dir "$_ROUTER_TMP" ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"} >/dev/null 2>&1) \
|
||||
&& (cd "$_GEN_SRC" && bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" run scripts/gen-skill-docs.ts --host claude --out-dir "$_ROUTER_TMP" ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"} >/dev/null 2>&1) \
|
||||
&& [ -s "$_ROUTER_TMP/SKILL.md" ] && cp "$_ROUTER_TMP/SKILL.md" "$_ROUTER.tmp.$$" && mv "$_ROUTER.tmp.$$" "$_ROUTER"; then
|
||||
_link_root_skill_alias
|
||||
[ -n "$_DISABLED_CSV" ] && echo "Router: disabled skills left out of $_ROUTER"
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
# a model read the code. Caller-supplied binding fields are always discarded.
|
||||
# Plan-tier rows retain their legacy binding behavior.
|
||||
set -euo pipefail
|
||||
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
|
||||
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)"
|
||||
@@ -77,7 +78,7 @@ 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=/dev/null -e '
|
||||
GSTACK_REVIEW_SKILL="${2:-}" GSTACK_REVIEW_FIX_RANGE="${3:-}" 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); }
|
||||
@@ -85,7 +86,7 @@ if [ "$INPUT" = --start ]; then
|
||||
exit $?
|
||||
fi
|
||||
if [ "$INPUT" = --check-shared-libs ] && [ "$#" -eq 2 ]; then
|
||||
GSTACK_REVIEW_TOKEN="$2" bun --no-env-file --config=/dev/null -e '
|
||||
GSTACK_REVIEW_TOKEN="$2" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e '
|
||||
const { checkSharedLibsReuse } = await import(process.env.GSTACK_REVIEW_LIB);
|
||||
console.log(JSON.stringify(checkSharedLibsReuse(JSON.parse(await Bun.stdin.text()), process.env.GSTACK_REVIEW_TOKEN)));
|
||||
'
|
||||
@@ -113,7 +114,7 @@ case "$INPUT" in --*) usage ;; esac
|
||||
# Caller values for the binding keys are dropped before stamping. A binding or
|
||||
# validation failure reaches stderr as its own line (never folded into
|
||||
# "invalid JSON"), and exits 1 with nothing written.
|
||||
STAMPED=$(printf '%s' "$INPUT" | GSTACK_REVIEW_TOKEN="$TOKEN" GSTACK_REVIEW_FINDINGS="$FINDINGS" bun --no-env-file --config=/dev/null -e "
|
||||
STAMPED=$(printf '%s' "$INPUT" | GSTACK_REVIEW_TOKEN="$TOKEN" GSTACK_REVIEW_FINDINGS="$FINDINGS" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
|
||||
const { bindReview } = await import(process.env.GSTACK_REVIEW_LIB);
|
||||
let rec;
|
||||
try { rec = JSON.parse(await Bun.stdin.text()); } catch (e) { console.error('gstack-review-log: invalid JSON, skipping (' + e.message + ')'); process.exit(1); }
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
# rows using review_freshness; legacy or uncaptured diff evidence cannot fall
|
||||
# back to HEAD equality. Plan rows are unchanged.
|
||||
set -euo pipefail
|
||||
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
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
case "$(uname -s)" in
|
||||
MINGW*|MSYS*|CYGWIN*) command -v cygpath >/dev/null 2>&1 && SCRIPT_DIR="$(cygpath -m "$SCRIPT_DIR")" ;;
|
||||
@@ -19,7 +20,7 @@ gstack_state_root_select
|
||||
GSTACK_HOME="$_gstack_sr_root"
|
||||
WTREE=$("$SCRIPT_DIR/gstack-wtree" 2>/dev/null || echo "unknown")
|
||||
if [ -f "$GSTACK_HOME/projects/$SLUG/$BRANCH-reviews.jsonl" ]; then
|
||||
GSTACK_REVIEW_LIB="$SCRIPT_DIR/../lib/review-evidence.ts" GSTACK_REVIEW_WTREE="$WTREE" bun --no-env-file --config=/dev/null -e '
|
||||
GSTACK_REVIEW_LIB="$SCRIPT_DIR/../lib/review-evidence.ts" GSTACK_REVIEW_WTREE="$WTREE" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e '
|
||||
const { reviewFreshness } = await import(process.env.GSTACK_REVIEW_LIB);
|
||||
for (const line of (await Bun.stdin.text()).split("\n").filter(Boolean)) {
|
||||
try {
|
||||
|
||||
@@ -24,6 +24,13 @@
|
||||
# the release are shelved in refs/gstack/rescue/<oid>, never left as conflict
|
||||
# markers, and the next session start names them (at most once a day).
|
||||
#
|
||||
# Channel (bin/gstack-update-channel.sh, docs/releases.md): a team install
|
||||
# follows refs/heads/stable, the weekly tagged release, so it moves once a
|
||||
# week (plus hotfixes) instead of at every merge; update_channel=main follows
|
||||
# every merge as before. Until the first release is cut, origin has no stable
|
||||
# and the updater follows main (CHANNEL_FALLBACK in the log). An install
|
||||
# already past the release waits for stable to catch up; nothing moves back.
|
||||
#
|
||||
# Stages (B11): pull, setup and migrations are tracked separately. The
|
||||
# "just upgraded" marker is written only when every stage succeeded. A failed
|
||||
# stage is persisted in $STATE_DIR/session-update-pending and retried even
|
||||
@@ -47,6 +54,14 @@ STATE_DIR="$(gstack_state_root; printf x)"; STATE_DIR="${STATE_DIR%x}"
|
||||
# Bun floor comparison (E1): the incoming release's floor is applied before
|
||||
# the live checkout moves. Missing helper fails open, like every error here.
|
||||
. "$(cd "$(dirname "$0")" && pwd)/gstack-bun-version.sh" 2>/dev/null
|
||||
# Update channel (docs/releases.md): a team install follows refs/heads/stable,
|
||||
# the weekly tagged release, unless update_channel says main. A missing
|
||||
# helper means main, the behaviour before channels existed.
|
||||
. "$(cd "$(dirname "$0")" && pwd)/gstack-update-channel.sh" 2>/dev/null || {
|
||||
gstack_update_channel() { echo main; }
|
||||
gstack_update_channel_refspec() { :; }
|
||||
gstack_update_channel_missing() { return 1; }
|
||||
}
|
||||
THROTTLE_FILE="$STATE_DIR/.last-session-update"
|
||||
LOCK_DIR="$STATE_DIR/.setup-lock"
|
||||
LOG_FILE="$STATE_DIR/analytics/session-update.log"
|
||||
@@ -162,6 +177,25 @@ rescue_note() {
|
||||
grep -q "^$1 " "$RESCUE_FILE" 2>/dev/null && return 0
|
||||
printf '%s %s -\n' "$1" "$2" >> "$RESCUE_FILE" 2>/dev/null
|
||||
}
|
||||
# channel_behind_release <release> <head> — true when the release commit is at
|
||||
# or behind this install's head: an ancestor of it, or, when the shallow
|
||||
# history cannot relate the two, a VERSION that is not higher.
|
||||
channel_behind_release() {
|
||||
git -C "$GSTACK_DIR" merge-base --is-ancestor "$1" "$2" 2>/dev/null && return 0
|
||||
git -C "$GSTACK_DIR" merge-base --is-ancestor "$2" "$1" 2>/dev/null && return 1
|
||||
_cb_rel=$(git -C "$GSTACK_DIR" show "$1:VERSION" 2>/dev/null | head -1)
|
||||
_cb_head=$(git -C "$GSTACK_DIR" show "$2:VERSION" 2>/dev/null | head -1)
|
||||
[ -n "$_cb_rel" ] && [ -n "$_cb_head" ] || return 1
|
||||
_cb_i=1
|
||||
while :; do
|
||||
_cb_a=$(printf '%s' "$_cb_rel" | cut -d. -f"$_cb_i"); _cb_b=$(printf '%s' "$_cb_head" | cut -d. -f"$_cb_i")
|
||||
[ -z "$_cb_a" ] && [ -z "$_cb_b" ] && return 0
|
||||
case "${_cb_a:-0}${_cb_b:-0}" in *[!0-9]*) return 1 ;; esac
|
||||
[ "${_cb_a:-0}" -lt "${_cb_b:-0}" ] && return 0
|
||||
[ "${_cb_a:-0}" -gt "${_cb_b:-0}" ] && return 1
|
||||
_cb_i=$((_cb_i + 1))
|
||||
done
|
||||
}
|
||||
pending_get() { sed -n "s/^$1=//p" "$PENDING_FILE" 2>/dev/null | head -1; }
|
||||
# pending_save — persist the P_* stage state; nothing pending removes the file.
|
||||
pending_save() {
|
||||
@@ -392,15 +426,47 @@ CALLER_PID=$(gstack_harness_pid "$PPID")
|
||||
# bin/gstack-ff-update carries them over the release instead.
|
||||
# Capture stderr: the log must carry WHY a pull failed, never just the code.
|
||||
PULL_ERR_FILE=$(mktemp "${TMPDIR:-/tmp}/gstack-session-pull-XXXXXX" 2>/dev/null || echo "")
|
||||
GSTACK_HOME="$STATE_DIR" _receipted_git open session-update "${UPDATE_HOST:-unknown}" gstack-self-update-pull "auto_upgrade=true" \
|
||||
bash -c 'git -C "$1" fetch -q 2>"${2:-/dev/null}"' _ "$GSTACK_DIR" "$PULL_ERR_FILE"
|
||||
PULL_EXIT=$?
|
||||
# The channel decides which ref is fetched and followed. stable is fetched
|
||||
# by refspec: team installs are single-branch clones, whose plain fetch
|
||||
# brings main only. Before the first release is cut origin has no stable,
|
||||
# and the updater follows main instead of standing still.
|
||||
CHANNEL=$(gstack_update_channel "$GSTACK_DIR" 2>/dev/null) || CHANNEL=main
|
||||
PULL_EXIT=0
|
||||
if [ "$CHANNEL" = stable ]; then
|
||||
GSTACK_HOME="$STATE_DIR" _receipted_git open session-update "${UPDATE_HOST:-unknown}" gstack-self-update-pull "auto_upgrade=true" \
|
||||
bash -c 'git -C "$1" fetch -q origin "$3" 2>"${2:-/dev/null}"' _ "$GSTACK_DIR" "$PULL_ERR_FILE" "$(gstack_update_channel_refspec stable)"
|
||||
PULL_EXIT=$?
|
||||
if [ "$PULL_EXIT" -ne 0 ] && gstack_update_channel_missing "$(cat "${PULL_ERR_FILE:-/dev/null}" 2>/dev/null)"; then
|
||||
log_entry "CHANNEL_FALLBACK stable->main reason=no-stable-on-origin"
|
||||
CHANNEL=main
|
||||
fi
|
||||
fi
|
||||
if [ "$CHANNEL" = main ]; then
|
||||
GSTACK_HOME="$STATE_DIR" _receipted_git open session-update "${UPDATE_HOST:-unknown}" gstack-self-update-pull "auto_upgrade=true" \
|
||||
bash -c 'git -C "$1" fetch -q 2>"${2:-/dev/null}"' _ "$GSTACK_DIR" "$PULL_ERR_FILE"
|
||||
PULL_EXIT=$?
|
||||
fi
|
||||
# The fast-forward targets the exact revision whose requirement was read.
|
||||
# Setup runs `bun` from this PATH, so that is the binary checked; a
|
||||
# version it cannot read holds too (bun-version-unreadable, #3195).
|
||||
INCOMING="" HELD=""
|
||||
if [ "$PULL_EXIT" -eq 0 ]; then
|
||||
INCOMING=$(git -C "$GSTACK_DIR" rev-parse --verify '@{upstream}^{commit}' 2>"${PULL_ERR_FILE:-/dev/null}") || PULL_EXIT=1
|
||||
if [ "$CHANNEL" = stable ]; then
|
||||
INCOMING=$(git -C "$GSTACK_DIR" rev-parse --verify 'refs/remotes/origin/stable^{commit}' 2>"${PULL_ERR_FILE:-/dev/null}") || PULL_EXIT=1
|
||||
# An install ahead of the release (it followed main until now) waits
|
||||
# for stable to pass it; the updater never moves a checkout backwards.
|
||||
# A depth-1 clone cannot see that an older release is its ancestor, so
|
||||
# when the histories look unrelated the VERSIONs decide: main stamps a
|
||||
# higher version at every merge, and stable is always a main commit.
|
||||
if [ "$PULL_EXIT" -eq 0 ] && [ "$INCOMING" != "$OLD_HEAD" ] && channel_behind_release "$INCOMING" "$OLD_HEAD"; then
|
||||
log_entry "CHANNEL stable ahead_of_release head=$OLD_HEAD stable=$INCOMING"
|
||||
INCOMING=$OLD_HEAD
|
||||
elif [ "$PULL_EXIT" -eq 0 ]; then
|
||||
log_entry "CHANNEL stable target=$INCOMING"
|
||||
fi
|
||||
else
|
||||
INCOMING=$(git -C "$GSTACK_DIR" rev-parse --verify '@{upstream}^{commit}' 2>"${PULL_ERR_FILE:-/dev/null}") || PULL_EXIT=1
|
||||
fi
|
||||
fi
|
||||
if [ "$PULL_EXIT" -eq 0 ] && [ "$INCOMING" != "$OLD_HEAD" ]; then
|
||||
HELD=$(gstack_bun_incoming_hold "$GSTACK_DIR" "$INCOMING" 2>/dev/null) || HELD=""
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
BASH_COMPAT=50
|
||||
|
||||
set -euo pipefail
|
||||
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
|
||||
|
||||
ACTION="${1:-}"
|
||||
SETTINGS_FILE="${GSTACK_SETTINGS_FILE:-${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json}"
|
||||
@@ -307,7 +308,7 @@ function gsWriteIfChanged(path, beforeText, settings, existed) {
|
||||
|
||||
GSTACK_SETTINGS_INPUT="$SETTINGS_FILE"
|
||||
export GSTACK_SETTINGS_INPUT
|
||||
SETTINGS_FILE=$(bun --no-env-file --config=/dev/null -e "$_HOOK_JS_PRELUDE"'gsMain(function () {
|
||||
SETTINGS_FILE=$(bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "$_HOOK_JS_PRELUDE"'gsMain(function () {
|
||||
process.stdout.write(gsResolveSettingsPath(process.env.GSTACK_SETTINGS_INPUT));
|
||||
});') || exit $?
|
||||
|
||||
@@ -405,7 +406,7 @@ case "$ACTION" in
|
||||
fi
|
||||
_acquire_lock || exit 5
|
||||
_mutation_env
|
||||
GSTACK_SETTINGS_PATH="$SETTINGS_FILE" GSTACK_HOOK_CMD="$HOOK_CMD" bun --no-env-file --config=/dev/null -e "$_HOOK_JS_PRELUDE"'gsMain(function () {
|
||||
GSTACK_SETTINGS_PATH="$SETTINGS_FILE" GSTACK_HOOK_CMD="$HOOK_CMD" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "$_HOOK_JS_PRELUDE"'gsMain(function () {
|
||||
const settingsPath = process.env.GSTACK_SETTINGS_PATH;
|
||||
const hookCmd = process.env.GSTACK_HOOK_CMD;
|
||||
const loaded = gsLoadSettings(settingsPath);
|
||||
@@ -435,7 +436,7 @@ case "$ACTION" in
|
||||
[ -f "$SETTINGS_FILE" ] || exit 1
|
||||
_acquire_lock || exit 5
|
||||
_mutation_env
|
||||
GSTACK_SETTINGS_PATH="$SETTINGS_FILE" bun --no-env-file --config=/dev/null -e "$_HOOK_JS_PRELUDE"'gsMain(function () {
|
||||
GSTACK_SETTINGS_PATH="$SETTINGS_FILE" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "$_HOOK_JS_PRELUDE"'gsMain(function () {
|
||||
const settingsPath = process.env.GSTACK_SETTINGS_PATH;
|
||||
const loaded = gsLoadSettings(settingsPath);
|
||||
const settings = loaded.settings;
|
||||
@@ -505,7 +506,7 @@ case "$ACTION" in
|
||||
GSTACK_TIMEOUT="$TIMEOUT" \
|
||||
GSTACK_DIFF_ONLY="$DIFF_ONLY" \
|
||||
GSTACK_ENSURE="$ENSURE" \
|
||||
bun --no-env-file --config=/dev/null -e "$_HOOK_JS_PRELUDE"'gsMain(function () {
|
||||
bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "$_HOOK_JS_PRELUDE"'gsMain(function () {
|
||||
const settingsPath = process.env.GSTACK_SETTINGS_PATH;
|
||||
const event = process.env.GSTACK_EVENT;
|
||||
const cmd = process.env.GSTACK_COMMAND;
|
||||
@@ -660,7 +661,7 @@ case "$ACTION" in
|
||||
[ -f "$SETTINGS_FILE" ] || exit 0
|
||||
_acquire_lock || exit 5
|
||||
_mutation_env
|
||||
GSTACK_SETTINGS_PATH="$SETTINGS_FILE" GSTACK_SOURCE="$SOURCE" GSTACK_RS_EVENT="$RS_EVENT" bun --no-env-file --config=/dev/null -e "$_HOOK_JS_PRELUDE"'gsMain(function () {
|
||||
GSTACK_SETTINGS_PATH="$SETTINGS_FILE" GSTACK_SOURCE="$SOURCE" GSTACK_RS_EVENT="$RS_EVENT" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "$_HOOK_JS_PRELUDE"'gsMain(function () {
|
||||
const settingsPath = process.env.GSTACK_SETTINGS_PATH;
|
||||
const source = process.env.GSTACK_SOURCE;
|
||||
const onlyEvent = process.env.GSTACK_RS_EVENT || "";
|
||||
@@ -763,7 +764,7 @@ case "$ACTION" in
|
||||
GSTACK_PRUNE_ALL="$PRUNE_ALL" \
|
||||
GSTACK_PT_OPTOUT="$GSTACK_PT_OPTOUT" \
|
||||
GSTACK_SWEEP_EXCLUDE_SOURCES="${GSTACK_SWEEP_EXCLUDE_SOURCES:-}" \
|
||||
bun --no-env-file --config=/dev/null -e "$_HOOK_JS_PRELUDE"'gsMain(function () {
|
||||
bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "$_HOOK_JS_PRELUDE"'gsMain(function () {
|
||||
const settingsPath = process.env.GSTACK_SETTINGS_PATH;
|
||||
const root = (process.env.GSTACK_REPOINT_ROOT || "").replace(/\/+$/, "");
|
||||
const all = process.env.GSTACK_PRUNE_ALL === "1";
|
||||
@@ -902,7 +903,7 @@ case "$ACTION" in
|
||||
fi
|
||||
_mutation_env
|
||||
GSTACK_SETTINGS_PATH="$SETTINGS_FILE" GSTACK_RESTORE_PATH="$LATEST" \
|
||||
bun --no-env-file --config=/dev/null -e "$_HOOK_JS_PRELUDE"'gsMain(function () {
|
||||
bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "$_HOOK_JS_PRELUDE"'gsMain(function () {
|
||||
var fs = require("fs");
|
||||
var target = process.env.GSTACK_SETTINGS_PATH;
|
||||
var backup = process.env.GSTACK_RESTORE_PATH;
|
||||
@@ -944,7 +945,7 @@ case "$ACTION" in
|
||||
exit 1
|
||||
fi
|
||||
[ -f "$SETTINGS_FILE" ] || exit 0
|
||||
GSTACK_SETTINGS_PATH="$SETTINGS_FILE" GSTACK_LI_EVENT="$LI_EVENT" GSTACK_LI_OWNED_BY="$LI_OWNED_BY" GSTACK_LI_CMD_RE="$LI_CMD_RE" bun --no-env-file --config=/dev/null -e "$_HOOK_JS_PRELUDE"'gsMain(function () {
|
||||
GSTACK_SETTINGS_PATH="$SETTINGS_FILE" GSTACK_LI_EVENT="$LI_EVENT" GSTACK_LI_OWNED_BY="$LI_OWNED_BY" GSTACK_LI_CMD_RE="$LI_CMD_RE" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "$_HOOK_JS_PRELUDE"'gsMain(function () {
|
||||
const event = process.env.GSTACK_LI_EVENT;
|
||||
const ownedBy = process.env.GSTACK_LI_OWNED_BY || "";
|
||||
const reSrc = process.env.GSTACK_LI_CMD_RE || "";
|
||||
@@ -982,7 +983,7 @@ case "$ACTION" in
|
||||
|
||||
list-sources)
|
||||
[ -f "$SETTINGS_FILE" ] || { echo "(no settings file)"; exit 0; }
|
||||
GSTACK_SETTINGS_PATH="$SETTINGS_FILE" bun --no-env-file --config=/dev/null -e "$_HOOK_JS_PRELUDE"'gsMain(function () {
|
||||
GSTACK_SETTINGS_PATH="$SETTINGS_FILE" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "$_HOOK_JS_PRELUDE"'gsMain(function () {
|
||||
const fs = require("fs");
|
||||
let settings = {};
|
||||
try { settings = JSON.parse(fs.readFileSync(process.env.GSTACK_SETTINGS_PATH, "utf8")); }
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* step (field list 2, W1; docs/ship-policy.md#run-a-shared-merge-queue).
|
||||
*
|
||||
* gstack-ship-queue ready --pr <n> [--pregate <pregate.json>] [--gate-exit <rc> --gate-log <file>]
|
||||
* (refused with QUEUE_RECEIPT_MISSING|STALE unless the PR body carries /ship's receipt for HEAD)
|
||||
* gstack-ship-queue stamp --pr <n> --version <X> --expect-gated <sha> --expect-base <sha>
|
||||
* [--expect-order <pr,...>] [--allow-repo-commands] [--gate-exit <rc> --gate-log <file>]
|
||||
* gstack-ship-queue verify --pr <n> (--gated <sha> | --record <gate-ahead.json>) [--receipt <sha256>]
|
||||
@@ -33,7 +34,8 @@ import { attemptFile, listAttempts, prepareGateAhead, recordGateAhead, type Queu
|
||||
const HELP = `gstack-ship-queue — the shared merge queue's protocol steps (docs/ship-policy.md#run-a-shared-merge-queue)
|
||||
|
||||
ready --pr <n> [--pregate <file>] [--gate-exit <rc> --gate-log <file>]
|
||||
READY #<n> <head> STAMP_VERSION=<X|unstamped>, CI at that head, PREGATE, gate state
|
||||
READY #<n> <head> STAMP_VERSION=<X|unstamped>, CI at that head, PREGATE, gate state,
|
||||
SHIP_RECEIPT (refused unless the PR body carries /ship's receipt for that head)
|
||||
stamp --pr <n> --version <X> --expect-gated <sha> --expect-base <sha> [--expect-order <pr,...>] [--allow-repo-commands]
|
||||
[--gate-exit <rc> --gate-log <file>]
|
||||
restamp, commit and prove: QUEUE_STAMP: <pr> <X> head= gated= tree= receipt=
|
||||
|
||||
+80
-2
@@ -10,11 +10,16 @@
|
||||
* [--out <file>] [--body <pr-body.md>] [--json]
|
||||
* gstack-ship-receipt read --pr <n> [--repo <owner/repo>] [--json]
|
||||
* gstack-ship-receipt read --body <file> [--json]
|
||||
* gstack-ship-receipt result (--pr <n> [--repo <owner/repo>] | --body <file>) [--head <sha>] [--out <dir>] [--json]
|
||||
* gstack-ship-receipt census --repo <owner/repo> --since <tag|YYYY-MM-DD> [--cohort <file>] [--limit <n>] [--json]
|
||||
*
|
||||
* `write` validates against `gstack-artifact schema ship-receipt`, prints the
|
||||
* fenced block (and SHIP_RECEIPT: <identifiers> on stderr), writes it to --out,
|
||||
* and with --body makes it the first section of that PR body file.
|
||||
* `result` is /ship's terminal step: it binds the receipt to HEAD, writes the
|
||||
* run record under <state>/ship/runs/ (ship-run.json: a /ship record, not a
|
||||
* `run` v1 manifest, so `gstack-artifact validate` does not apply to it) and
|
||||
* prints GSTACK_RESULT: skill=ship. A gh failure prints status=incomplete.
|
||||
* Query commands: no GSTACK_RESULT line. Logic: lib/ship-receipt.ts.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
@@ -27,15 +32,20 @@ import { readPregateFile, verifyPregate } from '../lib/pregate/index';
|
||||
import { resolveBaseBranch } from '../lib/ship-policy';
|
||||
import { approvalForReceipt } from '../lib/approval-citations';
|
||||
import {
|
||||
computeCensus, extractReceipt, missingIssueLines, orderReceipt, placeReceiptFirst, receiptIssuesFrom, receiptSummaryLine, renderCensus, renderReceiptBlock,
|
||||
validateReceipt, type CensusPr,
|
||||
bindReceipt, computeCensus, extractReceipt, missingIssueLines, orderReceipt, placeReceiptFirst, receiptIssuesFrom, receiptSummaryLine, renderCensus,
|
||||
renderReceiptBlock, validateReceipt, type CensusPr,
|
||||
} from '../lib/ship-receipt';
|
||||
import { resolveStateRoot } from '../lib/state-root';
|
||||
|
||||
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
|
||||
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
|
||||
result --pr <n> [--repo <r>] | --body <file> [--head <sha>] [--out <dir>]
|
||||
/ship's terminal line: binds the receipt to HEAD (QUEUE_RECEIPT_MISSING / QUEUE_RECEIPT_STALE),
|
||||
writes <state>/ship/runs/<utc>-<head12>/{ship-run.json,ship-receipt.md} and prints
|
||||
GSTACK_RESULT: skill=ship status=complete run=<dir> (refused: status=refused run=-)
|
||||
census --repo <r> --since <tag|date> [--cohort <file>]
|
||||
the P7 adoption number over merged PRs (deduplicated by run and thread)
|
||||
|
||||
@@ -182,6 +192,73 @@ function cmdRead(rest: string[], json: boolean): number {
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* /ship's terminal line (docs/unattended.md#the-terminal-line). The receipt in
|
||||
* the PR body (or the handoff file) must bind to HEAD: then the run record
|
||||
* goes under <state>/ship/runs/ with the head, the PR and the receipt, and
|
||||
* the last stdout line is GSTACK_RESULT: skill=ship status=complete run=<dir>.
|
||||
* A missing or stale receipt is status=refused run=- (exit 3), so a parent
|
||||
* never has to parse the PR body to know whether /ship finished at this head.
|
||||
*/
|
||||
function cmdResult(rest: string[], json: boolean): number {
|
||||
const bodyFile = flag(rest, '--body');
|
||||
const prFlag = flag(rest, '--pr');
|
||||
if (prFlag !== undefined && !/^[1-9]\d*$/.test(prFlag)) { process.stderr.write(`gstack-ship-receipt result: --pr must be a PR number, got ${JSON.stringify(prFlag.slice(0, 40))}\n`); return EXIT.usage; }
|
||||
const pr = prFlag;
|
||||
if (prFlag !== undefined && flag(rest, '--body') !== undefined) { process.stderr.write('gstack-ship-receipt result: pass --pr <n> (the PR body, checked against its pushed head) or --body <file> (the handoff receipt), not both\n'); return EXIT.usage; }
|
||||
const repoRoot = git(['rev-parse', '--show-toplevel']);
|
||||
const head = (flag(rest, '--head') ?? (repoRoot ? git(['rev-parse', 'HEAD'], repoRoot) : undefined))?.toLowerCase();
|
||||
const refused = (code: 'QUEUE_RECEIPT_MISSING' | 'QUEUE_RECEIPT_STALE', message: string) => {
|
||||
process.stderr.write(resultLineWithDocs('gstack-ship-receipt result', code, message) + '\n');
|
||||
process.stdout.write(json ? JSON.stringify({ skill: 'ship', status: 'refused', run: '-', code, message }) + '\n' : 'GSTACK_RESULT: skill=ship status=refused run=-\n');
|
||||
return EXIT.refused;
|
||||
};
|
||||
if (!head) { process.stderr.write('gstack-ship-receipt result: --head <sha> is required outside a git repository\n'); return EXIT.usage; }
|
||||
if (!/^[0-9a-f]{40,64}$/i.test(head)) { process.stderr.write(`gstack-ship-receipt result: --head must be a full hex commit id, got ${JSON.stringify(head.slice(0, 40))}\n`); return EXIT.usage; }
|
||||
const incomplete = (why: string) => {
|
||||
process.stderr.write(`gstack-ship-receipt result: ${why}\n`);
|
||||
process.stdout.write(json ? JSON.stringify({ skill: 'ship', status: 'incomplete', run: '-', message: why }) + '\n' : 'GSTACK_RESULT: skill=ship status=incomplete run=-\n');
|
||||
return EXIT.fail;
|
||||
};
|
||||
let body: string;
|
||||
let source: string;
|
||||
if (bodyFile) {
|
||||
if (!fs.existsSync(bodyFile)) return refused('QUEUE_RECEIPT_MISSING', `${bodyFile} does not exist; /ship did not write its receipt`);
|
||||
try { body = fs.readFileSync(bodyFile, 'utf8'); } catch (e) { return incomplete(`cannot read ${bodyFile}: ${e instanceof Error ? e.message : String(e)}`); }
|
||||
source = path.resolve(bodyFile);
|
||||
} else if (pr) {
|
||||
const repo = flag(rest, '--repo');
|
||||
const r = gh(['pr', 'view', pr, ...(repo ? ['--repo', repo] : []), '--json', 'body,headRefOid']);
|
||||
if (!r.ok) return incomplete(`gh pr view failed: ${r.err.trim()}`);
|
||||
let pushed: unknown;
|
||||
try { const v = JSON.parse(r.out); body = v.body ?? ''; pushed = v.headRefOid; } catch { return incomplete('gh returned no JSON body'); }
|
||||
if (typeof pushed !== 'string' || !/^[0-9a-f]{12,64}$/i.test(pushed)) return incomplete(`gh pr view returned no headRefOid for PR #${pr}`);
|
||||
if (!pushed.toLowerCase().startsWith(head.toLowerCase())) {
|
||||
return refused('QUEUE_RECEIPT_STALE', `HEAD ${head.slice(0, 12)} is not PR #${pr}'s pushed head ${pushed.slice(0, 12)}; push it, then rerun gstack-ship-receipt result`);
|
||||
}
|
||||
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]);
|
||||
if (!bound.ok) return refused(bound.code, bound.message);
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const out = flag(rest, '--out');
|
||||
const dir = out ? path.resolve(out) : path.join(resolveStateRoot(), 'ship', 'runs', `${stamp}-${head.slice(0, 12)}`);
|
||||
const receiptPath = path.join(dir, 'ship-receipt.md');
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(receiptPath, renderReceiptBlock(bound.receipt));
|
||||
} catch (e) { return 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, pr: bound.receipt.pr ?? (pr ? Number(pr) : null), base: bound.receipt.base, version: bound.receipt.version ?? null,
|
||||
queue_mode: bound.receipt.queue_mode ?? null, session_kind: bound.receipt.session_kind ?? process.env.GSTACK_SESSION_KIND ?? null,
|
||||
receipt: receiptPath, receipt_source: source, branch: repoRoot ? git(['rev-parse', '--abbrev-ref', 'HEAD'], repoRoot) ?? null : null, created_at: new Date().toISOString(),
|
||||
};
|
||||
try { fs.writeFileSync(path.join(dir, 'ship-run.json'), JSON.stringify(run, null, 2) + '\n'); } catch (e) { return incomplete(`cannot write ${path.join(dir, 'ship-run.json')}: ${e instanceof Error ? e.message : String(e)}`); }
|
||||
process.stderr.write(bound.line + '\n');
|
||||
process.stdout.write(json ? JSON.stringify({ ...run, run: dir }) + '\n' : `GSTACK_RESULT: skill=ship status=complete run=${dir}\n`);
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
function sinceDate(repo: string, since: string): string | undefined {
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(since)) return since.slice(0, 10);
|
||||
const local = git(['log', '-1', '--format=%cI', since]);
|
||||
@@ -214,6 +291,7 @@ function main(argv: string[]): number {
|
||||
if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') { process.stdout.write(HELP + '\n'); return cmd ? EXIT.ok : EXIT.usage; }
|
||||
if (cmd === 'write') return cmdWrite(rest, json);
|
||||
if (cmd === 'read') return cmdRead(rest, json);
|
||||
if (cmd === 'result') return cmdResult(rest, json);
|
||||
if (cmd === 'census') return cmdCensus(rest, json);
|
||||
process.stderr.write(`gstack-ship-receipt: unknown command ${cmd}\n${HELP}\n`);
|
||||
return EXIT.usage;
|
||||
|
||||
@@ -322,7 +322,7 @@ echo "GSTACK_PLAN_MODE: $GSTACK_PLAN_MODE"
|
||||
# GSTACK_SESSION_KIND=spawned override must light this up too.
|
||||
[ "$_SESSION_KIND" = "spawned" ] && echo "SPAWNED_SESSION: true" || true
|
||||
[ "$_SESSION_KIND" = "unattended" ] && echo "UNATTENDED_SESSION: true" || true
|
||||
[ "$_SESSION_KIND" = "unattended" ] && [ "$SKILL_NAME" = "ship" ] && echo "UNATTENDED_SHIP: no push, no PR; Step 18 takes the handoff section; receipt at .gstack/tmp/ship-receipt.md" || true
|
||||
[ "$_SESSION_KIND" = "unattended" ] && [ "$SKILL_NAME" = "ship" ] && echo "UNATTENDED_SHIP: no push, no new PR; an open PR at HEAD gets its body updated (pr-body section, receipt first), else Step 18 takes the handoff section; receipt at .gstack/tmp/ship-receipt.md; end with gstack-ship-receipt result" || true
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Artifacts sync (the former "Artifacts Sync (skill start)" fence, verbatim
|
||||
@@ -648,13 +648,13 @@ if [ "$_SESSION_KIND" = "unattended" ]; then
|
||||
case "$SKILL_NAME" in
|
||||
autoplan) _UA_RECORD="This skill writes a run directory through gstack-autoplan (plan.md, review-record.md, tasks.jsonl, decisions.jsonl, findings.jsonl, timing.json, run.json, gate.json): append each recommended choice to decisions.jsonl with kind \"auto\" and each pending one with kind \"approval\" and status \"pending\". The final approval gate is written as a gate list (gstack-gate render) and this run ENDS there with \`GSTACK_RESULT: skill=autoplan status=gate_pending run=<dir>\`." ;;
|
||||
eval-plan) _UA_RECORD="This run ENDS at the Step 4 spend approval, left pending, with \`GSTACK_RESULT: skill=eval-plan status=gate_pending run=<outdir>\`, where <outdir> is the pilot's --out directory. No helper writes a gate.json for this skill yet, so the parent reads the pilot report and spend ledger in <outdir>." ;;
|
||||
ship) _UA_RECORD="$_UA_NONE The parent reads the ship receipt from .gstack/tmp/ship-receipt.md when nothing was pushed, and from the PR body (\`gstack-ship-receipt read --pr <n> --json\`) only once a PR exists." ;;
|
||||
ship) _UA_RECORD="This skill ends with \`gstack-ship-receipt result\` (Step 22), which writes <state root>/ship/runs/<utc>-<head12>/ship-run.json (a /ship record, not a run manifest) and prints \`GSTACK_RESULT: skill=ship status=complete run=<dir>\`; a run that stops earlier prints \`GSTACK_RESULT: skill=ship status=refused run=-\` itself. The one publication allowed here: when an open PR for this branch exists and HEAD is its pushed head, update that PR's body and title with the receipt through the pr-body section's existing-PR path (never pr-create, never a push, and never \`gh pr ready\`: the draft-state items there concern an early PR this run never opened); otherwise the handoff section writes the receipt to .gstack/tmp/ship-receipt.md and the parent reads it there." ;;
|
||||
review) _UA_RECORD="$_UA_NONE The parent reads the review-log rows (\`gstack-review-read\`)." ;;
|
||||
qa) _UA_RECORD="$_UA_NONE The parent reads the \`qa-report-*.md\` file." ;;
|
||||
*) _UA_RECORD="$_UA_NONE The parent reads this skill's own record (docs/unattended.md)." ;;
|
||||
esac
|
||||
_emit_block unattended-session <<EOI
|
||||
You are running unattended: a parent agent started this run and reads its record, not your prose (docs/unattended.md names each skill's record). Rules: do NOT call AskUserQuestion and do NOT render prose decision briefs. At every question take the option marked (recommended). A question with no recommendation, any consent, any destructive or irreversible option, and the final approval gate stay pending: never choose them. You never approve a gate, never mark a plan APPROVED, never publish, push, sync or send data off this machine, and never record a consent. $_UA_RECORD Skip every onboarding, upgrade, telemetry, routing and lake instruction. Host adaptation: there may be no Skill tool, no slash commands and no hooks here; read the skill file with your file-reading tool, run its bash blocks as written, treat each bash block's printed STATUS lines as the only source of session facts, and write results to files the parent names rather than relying on chat text being read.
|
||||
You are running unattended: a parent agent started this run and reads its record, not your prose (docs/unattended.md names each skill's record). Rules: do NOT call AskUserQuestion and do NOT render prose decision briefs. At every question take the option marked (recommended). A question with no recommendation, any consent, any destructive or irreversible option, and the final approval gate stay pending: never choose them. You never approve a gate, never mark a plan APPROVED, never publish, push, sync or send data off this machine, and never record a consent; the only exception is the one named in your skill's record rule below. $_UA_RECORD Skip every onboarding, upgrade, telemetry, routing and lake instruction. Host adaptation: there may be no Skill tool, no slash commands and no hooks here; read the skill file with your file-reading tool, run its bash blocks as written, treat each bash block's printed STATUS lines as the only source of session facts, and write results to files the parent names rather than relying on chat text being read.
|
||||
EOI
|
||||
fi
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
# Fix-range passes (scope "fix-range", #3339) are skipped: a narrowed dispatch
|
||||
# says nothing about a specialist's hit rate on a whole branch.
|
||||
set -euo pipefail
|
||||
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
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
|
||||
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
|
||||
@@ -33,7 +34,7 @@ if [ -z "$COMBINED" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
printf '%s' "$COMBINED" | bun --no-env-file --config=/dev/null -e "
|
||||
printf '%s' "$COMBINED" | bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
|
||||
const lines = (await Bun.stdin.text()).trim().split('\n').filter(Boolean);
|
||||
const NEVER_GATE = new Set(['security', 'data-migration', 'state-machines']);
|
||||
const stats = {};
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
BASH_COMPAT=50
|
||||
|
||||
set -euo pipefail
|
||||
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
|
||||
|
||||
MODE="${1:-}"
|
||||
|
||||
@@ -192,7 +193,7 @@ HOOK_EOF
|
||||
if ! command -v bun >/dev/null 2>&1; then
|
||||
echo " ! bun not found — manually add this PreToolUse hook (matcher Skill|skill, \"onFailure\": \"block\") to .claude/settings.json:"
|
||||
echo " $HOOK_COMMAND"
|
||||
elif GSTACK_SETTINGS_PATH="$SETTINGS" GSTACK_HOOK_COMMAND="$HOOK_COMMAND" bun --no-env-file --config=/dev/null -e "
|
||||
elif GSTACK_SETTINGS_PATH="$SETTINGS" GSTACK_HOOK_COMMAND="$HOOK_COMMAND" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
|
||||
const fs = require('fs');
|
||||
const settingsPath = process.env.GSTACK_SETTINGS_PATH;
|
||||
const handler = { type: 'command', command: process.env.GSTACK_HOOK_COMMAND, onFailure: 'block' };
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
#
|
||||
# NOTE: Uses set -uo pipefail (no -e) — telemetry must never exit non-zero
|
||||
set -uo pipefail
|
||||
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
|
||||
|
||||
GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
|
||||
SCRIPT_DIR="$GSTACK_DIR/bin"
|
||||
@@ -205,7 +206,7 @@ ERR_FIELD="null"
|
||||
# look like a JSON string, the whole message becomes null — never raw.
|
||||
ERR_MSG_FIELD="null"
|
||||
if [ -n "$ERROR_MESSAGE" ]; then
|
||||
ERR_MSG_FIELD="$(printf '%s' "$ERROR_MESSAGE" | GSTACK_LIB_DIR="$SCRIPT_DIR/../lib" bun --no-env-file --config=/dev/null -e "
|
||||
ERR_MSG_FIELD="$(printf '%s' "$ERROR_MESSAGE" | GSTACK_LIB_DIR="$SCRIPT_DIR/../lib" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
|
||||
const { redactFindingSpans } = await import(process.env.GSTACK_LIB_DIR + '/redact-engine.ts');
|
||||
const input = await Bun.stdin.text();
|
||||
const out = redactFindingSpans(input, { repoVisibility: 'private' });
|
||||
|
||||
+22
-9
@@ -10,6 +10,7 @@
|
||||
# Optional: branch, outcome, duration_s, session, ts.
|
||||
# Validation failure → skip silently (non-blocking).
|
||||
set -euo pipefail
|
||||
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
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
|
||||
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
|
||||
@@ -19,21 +20,33 @@ mkdir -p "$GSTACK_HOME/projects/$SLUG"
|
||||
|
||||
INPUT="$1"
|
||||
|
||||
# Validate: input must be parseable JSON with required fields
|
||||
if ! printf '%s' "$INPUT" | bun --no-env-file --config=/dev/null -e "
|
||||
const j = JSON.parse(await Bun.stdin.text());
|
||||
if (!j.skill || !j.event) process.exit(1);
|
||||
" 2>/dev/null; then
|
||||
exit 0 # skip silently, non-blocking
|
||||
# Validate: input must be parseable JSON with required fields. The validator
|
||||
# prints its verdict; a non-zero exit means Bun itself failed (#3415). An exit
|
||||
# code cannot carry the verdict: a Bun crash on Windows exits 3.
|
||||
VALIDATE_RC=0
|
||||
VALIDATE_OUT=$(printf '%s' "$INPUT" | bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
|
||||
const text = await Bun.stdin.text();
|
||||
let j;
|
||||
try { j = JSON.parse(text); } catch {}
|
||||
console.log(j && j.skill && j.event ? 'valid' : 'invalid');
|
||||
") || VALIDATE_RC=$?
|
||||
if [ "$VALIDATE_RC" -ne 0 ]; then
|
||||
echo "gstack-timeline-log: bun exited $VALIDATE_RC before the event was validated; nothing was logged" >&2
|
||||
exit "$VALIDATE_RC"
|
||||
fi
|
||||
case "$VALIDATE_OUT" in
|
||||
valid) ;;
|
||||
invalid) exit 0 ;; # skip silently, non-blocking
|
||||
*) echo "gstack-timeline-log: bun printed no verdict while validating the event; nothing was logged" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
# Inject timestamp if not present
|
||||
if ! printf '%s' "$INPUT" | bun --no-env-file --config=/dev/null -e "const j=JSON.parse(await Bun.stdin.text()); if(!j.ts) process.exit(1)" 2>/dev/null; then
|
||||
INPUT=$(printf '%s' "$INPUT" | bun --no-env-file --config=/dev/null -e "
|
||||
if ! printf '%s' "$INPUT" | bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "const j=JSON.parse(await Bun.stdin.text()); if(!j.ts) process.exit(1)" 2>/dev/null; then
|
||||
STAMPED=$(printf '%s' "$INPUT" | bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
|
||||
const j = JSON.parse(await Bun.stdin.text());
|
||||
j.ts = new Date().toISOString();
|
||||
console.log(JSON.stringify(j));
|
||||
" 2>/dev/null) || true
|
||||
" 2>/dev/null) && [ -n "$STAMPED" ] && INPUT=$STAMPED || true
|
||||
fi
|
||||
|
||||
echo "$INPUT" >> "$GSTACK_HOME/projects/$SLUG/timeline.jsonl"
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
# --branch, or an unknown argument exits 2 before any read (#1723); --help
|
||||
# prints the usage line and exits 0.
|
||||
set -euo pipefail
|
||||
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
|
||||
USAGE='Usage: gstack-timeline-read [--since "7 days ago"|7d|YYYY-MM-DD] [--limit N] [--branch NAME]'
|
||||
SINCE_FORMS='accepted: "<N> minute|hour|day|week|month[s] ago", "<N>h|d|w", or an ISO date YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ'
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
@@ -108,7 +109,7 @@ command -v bun >/dev/null 2>&1 || {
|
||||
exit 127
|
||||
}
|
||||
|
||||
cat "$TIMELINE_FILE" | GSTACK_TIMELINE_SINCE_MS="$SINCE_MS" GSTACK_TIMELINE_BRANCH="$BRANCH" GSTACK_TIMELINE_LIMIT="$LIMIT" bun --no-env-file --config=/dev/null -e "
|
||||
cat "$TIMELINE_FILE" | GSTACK_TIMELINE_SINCE_MS="$SINCE_MS" GSTACK_TIMELINE_BRANCH="$BRANCH" GSTACK_TIMELINE_LIMIT="$LIMIT" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
|
||||
const lines = (await Bun.stdin.text()).trim().split('\n').filter(Boolean);
|
||||
const branch = process.env.GSTACK_TIMELINE_BRANCH || '';
|
||||
const limit = Number(process.env.GSTACK_TIMELINE_LIMIT);
|
||||
|
||||
Executable
+88
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env bash
|
||||
# gstack-update-channel.sh — which branch of garrytan/gstack an install follows.
|
||||
#
|
||||
# Sourced by bin/gstack-session-update, bin/gstack-update-check and
|
||||
# bin/gstack-upgrade; runnable by the /gstack-upgrade skill. Bash 3.2 plus git.
|
||||
#
|
||||
# Channels (docs/releases.md):
|
||||
# main every merge to main (a version per merge, a dozen a day)
|
||||
# stable the weekly tagged release: refs/heads/stable on origin, moved only
|
||||
# forward along main's first-parent history by the release workflow
|
||||
#
|
||||
# The config key update_channel (bin/gstack-config) is auto | stable | main.
|
||||
# auto, the default, is stable when team_mode is true and main otherwise, so a
|
||||
# team install follows releases and a contributor install follows main with
|
||||
# nothing to migrate. GSTACK_UPDATE_CHANNEL overrides the config (tests, a
|
||||
# one-off command). Any other value counts as auto.
|
||||
#
|
||||
# Functions (never fail; a broken config means main, the behaviour before
|
||||
# channels existed):
|
||||
# gstack_update_channel <install dir> prints stable | main
|
||||
# gstack_update_channel_refspec <channel> prints the fetch refspec for
|
||||
# stable, "" for main (callers
|
||||
# keep their @{upstream} /
|
||||
# default-branch path)
|
||||
# gstack_update_channel_missing <git stderr> true when a fetch failed only
|
||||
# because origin has no such ref
|
||||
# (no release cut yet): callers
|
||||
# fall back to main
|
||||
#
|
||||
# CLI (the /gstack-upgrade skill): `bash bin/gstack-update-channel.sh [--fetch-ref] [<install dir>]`
|
||||
# prints the channel; --fetch-ref fetches the channel ref from origin and
|
||||
# prints the revision to fast-forward to (origin/stable or origin/main),
|
||||
# falling back to origin/main only when origin has no stable yet. Any other
|
||||
# fetch failure keeps origin/stable (the fast-forward then stops or finds
|
||||
# nothing new); a team install never moves to main because the network
|
||||
# blinked.
|
||||
|
||||
gstack_update_channel() {
|
||||
_uc_dir=${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}
|
||||
_uc_value=${GSTACK_UPDATE_CHANNEL:-}
|
||||
if [ -z "$_uc_value" ] && [ -x "$_uc_dir/bin/gstack-config" ]; then
|
||||
_uc_value=$("$_uc_dir/bin/gstack-config" get update_channel 2>/dev/null) || _uc_value=""
|
||||
fi
|
||||
case "$_uc_value" in
|
||||
stable|main) printf '%s\n' "$_uc_value"; return 0 ;;
|
||||
esac
|
||||
_uc_team=""
|
||||
[ -x "$_uc_dir/bin/gstack-config" ] && { _uc_team=$("$_uc_dir/bin/gstack-config" get team_mode 2>/dev/null) || _uc_team=""; }
|
||||
if [ "$_uc_team" = true ]; then printf 'stable\n'; else printf 'main\n'; fi
|
||||
}
|
||||
|
||||
gstack_update_channel_refspec() {
|
||||
case "${1:-}" in stable) printf '+refs/heads/stable:refs/remotes/origin/stable\n' ;; *) printf '\n' ;; esac
|
||||
}
|
||||
|
||||
gstack_update_channel_missing() {
|
||||
printf '%s' "${1:-}" | grep -qiE "couldn't find remote ref|no such ref was fetched|invalid refspec" 2>/dev/null
|
||||
}
|
||||
|
||||
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
|
||||
_uc_fetch=0 _uc_arg=""
|
||||
for _uc_a in "$@"; do
|
||||
case "$_uc_a" in
|
||||
--fetch-ref) _uc_fetch=1 ;;
|
||||
-h|--help) sed -n '2,36p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) _uc_arg=$_uc_a ;;
|
||||
esac
|
||||
done
|
||||
_uc_root=${_uc_arg:-$(cd "$(dirname "$0")/.." && pwd)}
|
||||
_uc_ch=$(gstack_update_channel "$_uc_root")
|
||||
if [ "$_uc_fetch" -eq 0 ]; then printf '%s\n' "$_uc_ch"; exit 0; fi
|
||||
# Egress receipts for the fetches below, fail-open like /gstack-upgrade's own.
|
||||
. "$(dirname "$0")/gstack-egress-lib.sh" 2>/dev/null || _receipted_git() { shift 5; "$@"; }
|
||||
_uc_host=$(git -C "$_uc_root" remote get-url origin 2>/dev/null); _uc_host=${_uc_host#*://}; _uc_host=${_uc_host#*@}; _uc_host=${_uc_host%%[/:]*}
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
if [ "$_uc_ch" = stable ]; then
|
||||
_uc_err=$(_receipted_git open gstack-upgrade "${_uc_host:-unknown}" gstack-self-update-pull "user ran /gstack-upgrade" git -C "$_uc_root" fetch -q origin "$(gstack_update_channel_refspec stable)" 2>&1 >/dev/null) \
|
||||
&& { printf 'origin/stable\n'; exit 0; }
|
||||
if gstack_update_channel_missing "$_uc_err"; then
|
||||
echo "gstack-update-channel: origin has no stable release yet; following origin/main until the first weekly release is cut" >&2
|
||||
else
|
||||
echo "gstack-update-channel: fetching origin/stable failed ($(printf '%s' "$_uc_err" | head -1 | head -c 200)); staying on origin/stable as last fetched" >&2
|
||||
printf 'origin/stable\n'; exit 0
|
||||
fi
|
||||
fi
|
||||
_receipted_git open gstack-upgrade "${_uc_host:-unknown}" gstack-self-update-pull "user ran /gstack-upgrade" git -C "$_uc_root" fetch -q origin 2>/dev/null || true
|
||||
printf 'origin/main\n'
|
||||
fi
|
||||
+31
-9
@@ -5,7 +5,9 @@
|
||||
# JUST_UPGRADED <old> <new> — marker found from recent upgrade
|
||||
# UPGRADE_AVAILABLE <old> <new> — remote VERSION differs from local
|
||||
# UPGRADE_AVAILABLE <ver> <ver>+<n> <sha>
|
||||
# — VERSION is equal but main moved <n>
|
||||
# — VERSION is equal but the channel branch
|
||||
# (main, or stable for a team install:
|
||||
# bin/gstack-update-channel.sh) moved <n>
|
||||
# commits past this checkout's HEAD (the
|
||||
# GitHub compare API says so); the snooze
|
||||
# is keyed by <sha>
|
||||
@@ -74,9 +76,15 @@ CACHE_FILE="$STATE_DIR/last-update-check"
|
||||
MARKER_FILE="$STATE_DIR/just-upgraded-from"
|
||||
SNOOZE_FILE="$STATE_DIR/update-snoozed"
|
||||
VERSION_FILE="$GSTACK_DIR/VERSION"
|
||||
REMOTE_URL="${GSTACK_REMOTE_URL:-https://raw.githubusercontent.com/garrytan/gstack/main/VERSION}"
|
||||
REMOTE_REPO="${GSTACK_REMOTE_REPO:-https://github.com/garrytan/gstack.git}"
|
||||
# Channel (bin/gstack-update-channel.sh, docs/releases.md): a stable install
|
||||
# compares with refs/heads/stable, the weekly release, so it is not told every
|
||||
# hour that main moved. A missing helper means main.
|
||||
. "$GSTACK_DIR/bin/gstack-update-channel.sh" 2>/dev/null || gstack_update_channel() { echo main; }
|
||||
CHANNEL=$(gstack_update_channel "$GSTACK_DIR" 2>/dev/null) || CHANNEL=main
|
||||
case "$CHANNEL" in stable|main) ;; *) CHANNEL=main ;; esac
|
||||
REMOTE_RAW_BASE="${GSTACK_REMOTE_RAW_BASE:-https://raw.githubusercontent.com/garrytan/gstack}"
|
||||
REMOTE_URL="${GSTACK_REMOTE_URL:-$REMOTE_RAW_BASE/$CHANNEL/VERSION}"
|
||||
REMOTE_REPO="${GSTACK_REMOTE_REPO:-https://github.com/garrytan/gstack.git}"
|
||||
COMPARE_API="${GSTACK_COMPARE_API:-https://api.github.com/repos/garrytan/gstack/compare}"
|
||||
|
||||
# ─── Force flag (busts cache + snooze for standalone /gstack-upgrade) ──
|
||||
@@ -99,6 +107,8 @@ if [ -e "$GSTACK_DIR/.git" ]; then
|
||||
if echo "$_H" | grep -qE '^[0-9a-f]{40}$'; then LOCAL_HEAD="$_H"; fi
|
||||
fi
|
||||
if [ -n "${GSTACK_REMOTE_URL:-}" ]; then UPSTREAM="$(_display_url "$REMOTE_URL")"; else UPSTREAM="$(_display_url "$REMOTE_REPO")"; fi
|
||||
# A channel switch is a new upstream: the cached outcome of the other channel never replays.
|
||||
[ "$CHANNEL" = stable ] && UPSTREAM="$UPSTREAM#stable"
|
||||
|
||||
# _identity_field <name> — a field of the cache's IDENTITY line (empty when absent).
|
||||
_identity_field() {
|
||||
@@ -309,12 +319,24 @@ if [ -z "${GSTACK_REMOTE_URL:-}" ]; then
|
||||
# Disable credential prompts and apply a 5-second low-speed timeout so a
|
||||
# flaky network or captive portal can't hang every skill preamble.
|
||||
_REPO_HOST="${REMOTE_REPO#*://}"; _REPO_HOST="${_REPO_HOST#*@}"; _REPO_HOST="${_REPO_HOST%%[/:]*}"
|
||||
_LSR_LINE="$(GSTACK_HOME="$STATE_DIR" _receipted_git open update-check "${_REPO_HOST:-unknown}" version-ls-remote "update_check!=false" \
|
||||
bash -c 'GIT_TERMINAL_PROMPT=0 GIT_HTTP_LOW_SPEED_LIMIT=1000 GIT_HTTP_LOW_SPEED_TIME=5 \
|
||||
git ls-remote "$1" refs/heads/main 2>/dev/null' _ "$REMOTE_REPO" || true)"
|
||||
_ls_remote_branch() { # <branch> — the tip line of refs/heads/<branch> on REMOTE_REPO; exit 0 and nothing when origin has no such branch
|
||||
GSTACK_HOME="$STATE_DIR" _receipted_git open update-check "${_REPO_HOST:-unknown}" version-ls-remote "update_check!=false" \
|
||||
bash -c 'GIT_TERMINAL_PROMPT=0 GIT_HTTP_LOW_SPEED_LIMIT=1000 GIT_HTTP_LOW_SPEED_TIME=5 \
|
||||
git ls-remote "$1" "refs/heads/$2" 2>/dev/null' _ "$REMOTE_REPO" "$1"
|
||||
}
|
||||
_LSR_LINE="$(_ls_remote_branch "$CHANNEL")" || _LSR_LINE=""
|
||||
if [ "$CHANNEL" = stable ] && [ -z "$_LSR_LINE" ] && _ls_remote_branch stable >/dev/null 2>&1; then
|
||||
# Before the first release origin has no stable (ls-remote answered, empty):
|
||||
# this check follows main, as bin/gstack-session-update (CHANNEL_FALLBACK)
|
||||
# and bin/gstack-upgrade do. The cache identity keeps #stable, so the
|
||||
# outcome replays within its TTL like any other.
|
||||
CHANNEL=main
|
||||
REMOTE_URL="$REMOTE_RAW_BASE/main/VERSION"
|
||||
_LSR_LINE="$(_ls_remote_branch main)" || _LSR_LINE=""
|
||||
fi
|
||||
_REMOTE_SHA="$(echo "$_LSR_LINE" | awk '{print $1}')"
|
||||
if echo "$_REMOTE_SHA" | grep -qE '^[0-9a-f]{40}$'; then
|
||||
# The same main SHA, HEAD and upstream as the cache: its outcome still holds.
|
||||
# The same channel SHA, HEAD and upstream as the cache: its outcome still holds.
|
||||
if [ "$(_identity_field remote)" = "$_REMOTE_SHA" ] && _identity_ok; then
|
||||
case "$CACHED" in
|
||||
UP_TO_DATE*)
|
||||
@@ -385,9 +407,9 @@ if [ "$LOCAL" = "$REMOTE" ]; then
|
||||
_write_cache "CHECK_FAILED $LOCAL $_CMP_SHOWN ${_WHY:-unreadable}"
|
||||
if [ "$FORCE" = "1" ]; then
|
||||
if [ "$_WHY" = "diverged" ]; then
|
||||
echo "CHECK_FAILED this checkout (${LOCAL_HEAD:0:12}) has diverged from main (${_REMOTE_SHA:0:12}) at the same VERSION $LOCAL — update status UNKNOWN, not up-to-date"
|
||||
echo "CHECK_FAILED this checkout (${LOCAL_HEAD:0:12}) has diverged from $CHANNEL (${_REMOTE_SHA:0:12}) at the same VERSION $LOCAL — update status UNKNOWN, not up-to-date"
|
||||
else
|
||||
echo "CHECK_FAILED could not compare this checkout (${LOCAL_HEAD:0:12}) with main (${_REMOTE_SHA:0:12}) at $_CMP_SHOWN — update status UNKNOWN, not up-to-date"
|
||||
echo "CHECK_FAILED could not compare this checkout (${LOCAL_HEAD:0:12}) with $CHANNEL (${_REMOTE_SHA:0:12}) at $_CMP_SHOWN — update status UNKNOWN, not up-to-date"
|
||||
fi
|
||||
fi
|
||||
exit 0
|
||||
|
||||
+43
-7
@@ -1,7 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# gstack-upgrade — move this gstack checkout to the tip of its remote default
|
||||
# branch, unattended and fast-forward only. The deterministic twin of the
|
||||
# /gstack-upgrade skill's git fast path, for parent agents and fix lines.
|
||||
# gstack-upgrade — move this gstack checkout to the tip of its update channel
|
||||
# (the remote default branch, or refs/heads/stable, the weekly release, for a
|
||||
# team install: bin/gstack-update-channel.sh), unattended and fast-forward
|
||||
# only. The deterministic twin of the /gstack-upgrade skill's git fast path,
|
||||
# for parent agents and fix lines.
|
||||
#
|
||||
# Usage:
|
||||
# gstack-upgrade --check fetch (refs only) and print `gstack-upgrade: current <ver>`
|
||||
@@ -76,7 +78,25 @@ BRANCH=$(git -C "$ROOT" symbolic-ref -q --short HEAD) \
|
||||
DEFAULT=$(_receipted_git open gstack-upgrade "${HOST:-unknown}" gstack-self-update-pull "$R" bounded git -C "$ROOT" ls-remote --symref origin HEAD 2>/dev/null | sed -n 's|^ref: refs/heads/\([^[:space:]]*\)[[:space:]]*HEAD$|\1|p' | head -1)
|
||||
[ -n "$DEFAULT" ] || fetch_failed "git ls-remote origin HEAD failed"
|
||||
[ "$BRANCH" = "$DEFAULT" ] || refuse "on branch $BRANCH, not the remote default branch $DEFAULT (a pin)" "git -C $ROOT checkout $DEFAULT, then re-run"
|
||||
_receipted_git open gstack-upgrade "${HOST:-unknown}" gstack-self-update-pull "$R" bounded git -C "$ROOT" fetch --quiet origin "$DEFAULT" 2>/dev/null || fetch_failed "git fetch origin $DEFAULT failed"
|
||||
# Channel (bin/gstack-update-channel.sh, docs/releases.md): a team install
|
||||
# follows refs/heads/stable, the weekly release, which is always a commit on
|
||||
# the default branch's history, so the fast-forward rules below are unchanged.
|
||||
# Before the first release is cut origin has no stable, and main is followed.
|
||||
# shellcheck source=bin/gstack-update-channel.sh
|
||||
. "$ROOT/bin/gstack-update-channel.sh" 2>/dev/null || gstack_update_channel() { echo main; }
|
||||
CHANNEL=$(gstack_update_channel "$ROOT" 2>/dev/null) || CHANNEL=main
|
||||
FOLLOW=$DEFAULT
|
||||
if [ "$CHANNEL" = stable ]; then
|
||||
if _receipted_git open gstack-upgrade "${HOST:-unknown}" gstack-self-update-pull "$R" bounded git -C "$ROOT" fetch --quiet origin stable 2>/dev/null; then
|
||||
FOLLOW=stable
|
||||
echo "gstack-upgrade: channel stable (the weekly release; gstack-config set update_channel main follows every merge)" >&2
|
||||
elif _LSR=$(_receipted_git open gstack-upgrade "${HOST:-unknown}" gstack-self-update-pull "$R" bounded git -C "$ROOT" ls-remote origin refs/heads/stable 2>/dev/null) && [ -z "$_LSR" ]; then
|
||||
echo "gstack-upgrade: channel stable has no release on origin yet; following $DEFAULT until the first weekly release is cut" >&2
|
||||
else
|
||||
fetch_failed "git fetch origin stable failed"
|
||||
fi
|
||||
fi
|
||||
[ "$FOLLOW" = stable ] || _receipted_git open gstack-upgrade "${HOST:-unknown}" gstack-self-update-pull "$R" bounded git -C "$ROOT" fetch --quiet origin "$DEFAULT" 2>/dev/null || fetch_failed "git fetch origin $DEFAULT failed"
|
||||
INCOMING=$(git -C "$ROOT" rev-parse FETCH_HEAD)
|
||||
REMOTE_VER=$(git -C "$ROOT" show FETCH_HEAD:VERSION 2>/dev/null | head -1)
|
||||
PENDING="$(git -C "$ROOT" rev-parse --absolute-git-dir)/gstack-upgrade.pending"
|
||||
@@ -148,7 +168,23 @@ reinstall() { # FROM — run the install step; a pending marker survives a failu
|
||||
echo "gstack-upgrade: the checkout is at $(head -1 "$ROOT/VERSION") but its reinstall failed (exit $_rc); fix: re-run $ROOT/bin/gstack-upgrade (it completes the pending reinstall)" >&2
|
||||
exit 1
|
||||
}
|
||||
if [ "$INCOMING" = "$(git -C "$ROOT" rev-parse HEAD)" ] || git -C "$ROOT" merge-base --is-ancestor FETCH_HEAD HEAD 2>/dev/null; then
|
||||
# ver_le A B — version A is not higher than B (numeric, part by part). A
|
||||
# depth-1 clone cannot see that an older release is its ancestor; main stamps
|
||||
# a higher VERSION at every merge and stable is always a main commit, so a
|
||||
# release whose VERSION is not higher is at or behind this install.
|
||||
ver_le() {
|
||||
_vl_i=1
|
||||
while :; do
|
||||
_vl_a=$(printf '%s' "$1" | cut -d. -f"$_vl_i"); _vl_b=$(printf '%s' "$2" | cut -d. -f"$_vl_i")
|
||||
[ -z "$_vl_a" ] && [ -z "$_vl_b" ] && return 0
|
||||
case "${_vl_a:-0}${_vl_b:-0}" in *[!0-9]*) return 1 ;; esac
|
||||
[ "${_vl_a:-0}" -lt "${_vl_b:-0}" ] && return 0
|
||||
[ "${_vl_a:-0}" -gt "${_vl_b:-0}" ] && return 1
|
||||
_vl_i=$((_vl_i + 1))
|
||||
done
|
||||
}
|
||||
if [ "$INCOMING" = "$(git -C "$ROOT" rev-parse HEAD)" ] || git -C "$ROOT" merge-base --is-ancestor FETCH_HEAD HEAD 2>/dev/null \
|
||||
|| { [ "$FOLLOW" = stable ] && ! git -C "$ROOT" merge-base --is-ancestor HEAD FETCH_HEAD 2>/dev/null && [ -n "$REMOTE_VER" ] && ver_le "$REMOTE_VER" "$INSTALLED"; }; then
|
||||
if [ -f "$PENDING" ]; then
|
||||
echo "gstack-upgrade: current $INSTALLED; reinstall pending from $(head -1 "$PENDING")"
|
||||
[ "$CHECK" -eq 1 ] && exit 1
|
||||
@@ -166,9 +202,9 @@ fi
|
||||
DIRTY=$(git -C "$ROOT" status --porcelain --untracked-files=no 2>/dev/null | cut -c4- | tr '\n' ' ' | sed 's/ $//')
|
||||
[ -z "$DIRTY" ] || refuse "tracked files changed: $DIRTY" "commit or move those changes (git -C $ROOT status --short), then re-run"
|
||||
if ! git -C "$ROOT" merge-base --is-ancestor HEAD FETCH_HEAD 2>/dev/null; then
|
||||
_receipted_git open gstack-upgrade "${HOST:-unknown}" gstack-self-update-pull "$R" git -C "$ROOT" fetch --quiet --unshallow origin "$DEFAULT" 2>/dev/null || true
|
||||
_receipted_git open gstack-upgrade "${HOST:-unknown}" gstack-self-update-pull "$R" git -C "$ROOT" fetch --quiet --unshallow origin "$FOLLOW" 2>/dev/null || true
|
||||
git -C "$ROOT" merge-base --is-ancestor HEAD FETCH_HEAD 2>/dev/null \
|
||||
|| refuse "diverged: $(git -C "$ROOT" rev-list --count FETCH_HEAD..HEAD 2>/dev/null || echo '?') local commits are not on origin/$DEFAULT" "git -C $ROOT log --oneline FETCH_HEAD..HEAD shows them; move them to another branch, then re-run"
|
||||
|| refuse "diverged: $(git -C "$ROOT" rev-list --count FETCH_HEAD..HEAD 2>/dev/null || echo '?') local commits are not on origin/$FOLLOW" "git -C $ROOT log --oneline FETCH_HEAD..HEAD shows them; move them to another branch, then re-run"
|
||||
fi
|
||||
# Workflow leases (bin/gstack-lease): sessions sharing this installation that
|
||||
# are alive and not this process's own chain. Moving the checkout under them
|
||||
|
||||
@@ -6,17 +6,22 @@ import { spawnSync } from 'child_process';
|
||||
const SCRIPT_PATH = path.join(import.meta.dir, '../../bin/gstack-learnings-search');
|
||||
const SCRIPT = fs.readFileSync(SCRIPT_PATH, 'utf-8');
|
||||
const BIN_DIR = path.join(import.meta.dir, '../../bin');
|
||||
const BUN_BLOCK_AT = SCRIPT.indexOf('bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e');
|
||||
|
||||
describe('gstack-learnings-search injection safety', () => {
|
||||
it('finds the bun -e block the checks below read', () => {
|
||||
expect(BUN_BLOCK_AT).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('must not interpolate variables into JS string literals', () => {
|
||||
const jsBlock = SCRIPT.slice(SCRIPT.indexOf('bun --no-env-file --config=/dev/null -e'));
|
||||
const jsBlock = SCRIPT.slice(BUN_BLOCK_AT);
|
||||
expect(jsBlock).not.toMatch(/const \w+ = '\$\{/);
|
||||
expect(jsBlock).not.toMatch(/= \$\{[A-Z_]+\};/);
|
||||
expect(jsBlock).not.toMatch(/'\$\{CROSS_PROJECT\}'/);
|
||||
});
|
||||
|
||||
it('must use process.env for parameters', () => {
|
||||
const jsBlock = SCRIPT.slice(SCRIPT.indexOf('bun --no-env-file --config=/dev/null -e'));
|
||||
const jsBlock = SCRIPT.slice(BUN_BLOCK_AT);
|
||||
expect(jsBlock).toContain('process.env');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"": {
|
||||
"name": "gstack",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.78.0",
|
||||
"@huggingface/transformers": "^4.3.1",
|
||||
"@ngrok/ngrok": "^1.7.0",
|
||||
"cross-spawn": "^7.0.6",
|
||||
@@ -15,15 +16,14 @@
|
||||
"playwright": "^1.62.1",
|
||||
"sharp": "^0.35.4",
|
||||
"socks": "^2.8.10",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-fit": "^0.8.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "0.2.141",
|
||||
"@anthropic-ai/sdk": "^0.78.0",
|
||||
"@types/bun": "1.4.3",
|
||||
"prettier": "3.9.10",
|
||||
"typescript": "7.0.2",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-fit": "^0.8.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -462,7 +462,8 @@ Now route based on what was found. Check these cases in order:
|
||||
|
||||
### Case A: approved.json exists (design-shotgun ran)
|
||||
|
||||
If `APPROVED` was found, read it for user feedback and screen name, and resolve the
|
||||
If `APPROVED` was found, read it for user feedback, screen name, and any `archetype`
|
||||
and `sections` (the craft section takes them as given), and resolve the
|
||||
approved PNG (never guess it from a letter or the newest variant):
|
||||
|
||||
```bash
|
||||
@@ -731,7 +732,7 @@ _DJ=$(mktemp "${TMPDIR:-/tmp}/gstack-detect.XXXXXX"); bun --no-env-file --config
|
||||
|
||||
Exit 2 → one surgical fix pass over the non-advisory rules in the `DETECT_TOP` block, then scan once more. Whatever remains, present the page with those findings listed as accepted-with-reason: a pattern the approved mockup contains, a value DESIGN.md's tokens bless or a pattern its Decisions Log or Do's and Don'ts records as intentional, or an inline `<!-- impeccable-disable <rule>: <reason> -->` the user agreed to. One pass, not a loop. Any other first line from the probe: skip, no ceremony.
|
||||
|
||||
Then, always: the craft section's check, `~/.claude/skills/gstack/bin/gstack-design-craft <path-to-finalized.html>`, one fix pass and one rerun, as above.
|
||||
Then, always: the craft section's check, `~/.claude/skills/gstack/bin/gstack-design-craft <path-to-finalized.html>` (plus `--accepted` per the section), one fix pass and one rerun, as above.
|
||||
|
||||
### Verification Screenshots
|
||||
|
||||
@@ -832,6 +833,7 @@ Write `finalized.json` alongside the HTML:
|
||||
"pretext_tier": "<selected tier>",
|
||||
"framework": "<vanilla|react|svelte|vue>",
|
||||
"iterations": <number of refinement iterations>,
|
||||
"craft_exceptions": [{"id": "<craft id>", "count": <n>, "reason": "<why accepted>"}],
|
||||
"date": "<ISO 8601>",
|
||||
"screen": "<screen name>",
|
||||
"branch": "<current branch>"
|
||||
|
||||
@@ -82,7 +82,8 @@ Now route based on what was found. Check these cases in order:
|
||||
|
||||
### Case A: approved.json exists (design-shotgun ran)
|
||||
|
||||
If `APPROVED` was found, read it for user feedback and screen name, and resolve the
|
||||
If `APPROVED` was found, read it for user feedback, screen name, and any `archetype`
|
||||
and `sections` (the craft section takes them as given), and resolve the
|
||||
approved PNG (never guess it from a letter or the newest variant):
|
||||
|
||||
```bash
|
||||
@@ -341,7 +342,7 @@ kill <PID> 2>/dev/null || true
|
||||
|
||||
{{DESIGN_DETECTOR:gate}}
|
||||
|
||||
Then, always: the craft section's check, `{{BIN_DIR}}/gstack-design-craft <path-to-finalized.html>`, one fix pass and one rerun, as above.
|
||||
Then, always: the craft section's check, `{{BIN_DIR}}/gstack-design-craft <path-to-finalized.html>` (plus `--accepted` per the section), one fix pass and one rerun, as above.
|
||||
|
||||
### Verification Screenshots
|
||||
|
||||
@@ -442,6 +443,7 @@ Write `finalized.json` alongside the HTML:
|
||||
"pretext_tier": "<selected tier>",
|
||||
"framework": "<vanilla|react|svelte|vue>",
|
||||
"iterations": <number of refinement iterations>,
|
||||
"craft_exceptions": [{"id": "<craft id>", "count": <n>, "reason": "<why accepted>"}],
|
||||
"date": "<ISO 8601>",
|
||||
"screen": "<screen name>",
|
||||
"branch": "<current branch>"
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
- who it is for, the problem they came with, their top three objections, what they already know
|
||||
- the proof the user can supply (named customers, attributed quotes, real numbers) and the risk terms the business offers
|
||||
|
||||
**Archetype.** Pick one and say why in the spec's layout type: **classic hero + sections** (default; the product reads from one screenshot), **long-form story** (the visitor needs educating or is skeptical), **minimal conversion** (high-intent traffic or a small offer), **comparison** (search intent is "X vs Y").
|
||||
**Archetype.** When approved.json carries an `archetype`, the user already chose it on the /design-shotgun board: take it and its `sections` outline as given, name both in the spec's layout type, and do not re-pick or re-ask. Otherwise pick one and say why there: **classic hero + sections** (`classic-hero`, default; the product reads from one screenshot), **long-form story** (`long-form-story`, the visitor needs educating or is skeptical), **minimal conversion** (`minimal-conversion`, high-intent traffic or a small offer), **comparison** (`comparison`, search intent is "X vs Y").
|
||||
|
||||
**Structure.** Above the fold at 375×667 and on desktop, inside the hero budget: a headline naming outcome and audience, one sentence on how, one primary CTA, one proof signal only when real proof exists (named logos or one attributed quote; a metric row stays banned), one product visual. Below it, choose modules and order from the archetype and the objections. The long-form menu: problem → solution, three to five benefits, how it works in three steps, proof, an FAQ of six to twelve real objections, the risk reversal, a final CTA identical to the top. A minimal page stays minimal; a comparison page leads with the comparison.
|
||||
|
||||
@@ -65,7 +65,9 @@ At Step 4, after the Slop Gate and before the screenshots, run the free checker
|
||||
~/.claude/skills/gstack/bin/gstack-design-craft <path-to-finalized.html>
|
||||
```
|
||||
|
||||
It prints one `CRAFT_CHECK: <id> <count> <fix>` line per tell (the realism rules above, `[Needs proof:` markers, scroll listeners, `100vh`, `transition: all`, `href="#"`, `alert()`, missing alt; on a full document a missing title, description, favicon, working skip link, or `aria-current` on route navigation), then `CRAFT_RESULT: clean` or `CRAFT_RESULT: <n> issue(s)`. No `CRAFT_RESULT` line: say the check was unavailable and continue. Same contract as the Slop Gate: one surgical fix pass, one rerun, the rest listed as accepted-with-reason (a real customer named Nexus, a supplied 50% figure). Rerun once when the refinement loop exits; report only new lines.
|
||||
It prints one `CRAFT_CHECK: <id> <count> <fix>` line per tell (the realism rules above, `[Needs proof:` markers, scroll listeners, `100vh`, `transition: all`, `href="#"`, `alert()`, missing alt; on a full document a missing title, description, favicon, working skip link, or `aria-current` on route navigation), then `CRAFT_RESULT: clean` or `CRAFT_RESULT: <n> issue(s)`, plus the accepted count when `--accepted` covers any. No `CRAFT_RESULT` line: say the check was unavailable and continue. Same contract as the Slop Gate: one surgical fix pass, one rerun, the rest listed as accepted-with-reason (a real customer named Nexus, a supplied 50% figure). Rerun once when the refinement loop exits; report only new lines.
|
||||
|
||||
**Accepted exceptions persist.** Step 5's finalized.json records each one in `craft_exceptions` as `{ id, count, reason }`, carrying forward earlier ones the check still reports at the count it reports now (`[]` when none, never the template's placeholder entry). When evolving a finalized page, or rerunning on the page this run finalized, append `--accepted <that finalized.json>` to the command; never after Start fresh or for a new page, whose old exceptions covered different content: a tell it covers prints `CRAFT_ACCEPTED: <id> <count> <reason>` and does not fail the check, so name those as accepted earlier instead of fixing or re-flagging them. A count above the accepted one stays a `CRAFT_CHECK` line; it is new, so fix it or accept the new count with a reason. A `cannot use exceptions` warning with exit 2 means that record is malformed: the page was still checked with none accepted, so treat every line as new and write `craft_exceptions` correctly at Step 5.
|
||||
|
||||
**Before this can ship.** Open the Step 4 hand-off, and repeat before Step 5's "Copy to project": every `[Needs proof: …]` marker, the dependencies the page cannot own (legal URLs, form endpoint, og:image, indexing, analytics and cookie consent where required), the accepted exceptions, and every claim the check made you change.
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
- who it is for, the problem they came with, their top three objections, what they already know
|
||||
- the proof the user can supply (named customers, attributed quotes, real numbers) and the risk terms the business offers
|
||||
|
||||
**Archetype.** Pick one and say why in the spec's layout type: **classic hero + sections** (default; the product reads from one screenshot), **long-form story** (the visitor needs educating or is skeptical), **minimal conversion** (high-intent traffic or a small offer), **comparison** (search intent is "X vs Y").
|
||||
**Archetype.** When approved.json carries an `archetype`, the user already chose it on the /design-shotgun board: take it and its `sections` outline as given, name both in the spec's layout type, and do not re-pick or re-ask. Otherwise pick one and say why there: **classic hero + sections** (`classic-hero`, default; the product reads from one screenshot), **long-form story** (`long-form-story`, the visitor needs educating or is skeptical), **minimal conversion** (`minimal-conversion`, high-intent traffic or a small offer), **comparison** (`comparison`, search intent is "X vs Y").
|
||||
|
||||
**Structure.** Above the fold at 375×667 and on desktop, inside the hero budget: a headline naming outcome and audience, one sentence on how, one primary CTA, one proof signal only when real proof exists (named logos or one attributed quote; a metric row stays banned), one product visual. Below it, choose modules and order from the archetype and the objections. The long-form menu: problem → solution, three to five benefits, how it works in three steps, proof, an FAQ of six to twelve real objections, the risk reversal, a final CTA identical to the top. A minimal page stays minimal; a comparison page leads with the comparison.
|
||||
|
||||
@@ -63,7 +63,9 @@ At Step 4, after the Slop Gate and before the screenshots, run the free checker
|
||||
{{BIN_DIR}}/gstack-design-craft <path-to-finalized.html>
|
||||
```
|
||||
|
||||
It prints one `CRAFT_CHECK: <id> <count> <fix>` line per tell (the realism rules above, `[Needs proof:` markers, scroll listeners, `100vh`, `transition: all`, `href="#"`, `alert()`, missing alt; on a full document a missing title, description, favicon, working skip link, or `aria-current` on route navigation), then `CRAFT_RESULT: clean` or `CRAFT_RESULT: <n> issue(s)`. No `CRAFT_RESULT` line: say the check was unavailable and continue. Same contract as the Slop Gate: one surgical fix pass, one rerun, the rest listed as accepted-with-reason (a real customer named Nexus, a supplied 50% figure). Rerun once when the refinement loop exits; report only new lines.
|
||||
It prints one `CRAFT_CHECK: <id> <count> <fix>` line per tell (the realism rules above, `[Needs proof:` markers, scroll listeners, `100vh`, `transition: all`, `href="#"`, `alert()`, missing alt; on a full document a missing title, description, favicon, working skip link, or `aria-current` on route navigation), then `CRAFT_RESULT: clean` or `CRAFT_RESULT: <n> issue(s)`, plus the accepted count when `--accepted` covers any. No `CRAFT_RESULT` line: say the check was unavailable and continue. Same contract as the Slop Gate: one surgical fix pass, one rerun, the rest listed as accepted-with-reason (a real customer named Nexus, a supplied 50% figure). Rerun once when the refinement loop exits; report only new lines.
|
||||
|
||||
**Accepted exceptions persist.** Step 5's finalized.json records each one in `craft_exceptions` as `{ id, count, reason }`, carrying forward earlier ones the check still reports at the count it reports now (`[]` when none, never the template's placeholder entry). When evolving a finalized page, or rerunning on the page this run finalized, append `--accepted <that finalized.json>` to the command; never after Start fresh or for a new page, whose old exceptions covered different content: a tell it covers prints `CRAFT_ACCEPTED: <id> <count> <reason>` and does not fail the check, so name those as accepted earlier instead of fixing or re-flagging them. A count above the accepted one stays a `CRAFT_CHECK` line; it is new, so fix it or accept the new count with a reason. A `cannot use exceptions` warning with exit 2 means that record is malformed: the page was still checked with none accepted, so treat every line as new and write `craft_exceptions` correctly at Step 5.
|
||||
|
||||
**Before this can ship.** Open the Step 4 hand-off, and repeat before Step 5's "Copy to project": every `[Needs proof: …]` marker, the dependencies the page cannot own (legal URLs, form endpoint, og:image, indexing, analytics and cookie consent where required), the accepted exceptions, and every claim the check made you change.
|
||||
|
||||
|
||||
@@ -634,9 +634,9 @@ Draw on DESIGN.md, taste memory, and the user's request to make each concept dis
|
||||
exists, it decides what varies: keep its fonts and palette and vary layout and
|
||||
composition. Without one, vary font family, color palette, and layout approach. If two
|
||||
variants look like siblings, rework the weaker one with a deliberately different direction.
|
||||
For a landing, pricing or campaign screen, also vary the page archetype (classic hero,
|
||||
long-form story, minimal conversion, comparison), keep the one primary action identical,
|
||||
and caption each variant with its section outline.
|
||||
For a landing, pricing or campaign screen, also vary the page archetype (`classic-hero`,
|
||||
`long-form-story`, `minimal-conversion`, `comparison`), keep the one primary action
|
||||
identical, and caption each variant with its archetype id and section outline.
|
||||
|
||||
Concrete test: if someone could swap the headline text between two variants without
|
||||
noticing, they're too similar. Variants should feel like separate design teams made
|
||||
@@ -880,7 +880,9 @@ Use AskUserQuestion to confirm before saving.
|
||||
|
||||
## Step 6: Save & Next Steps
|
||||
|
||||
Write `approved.json` to `$_DESIGN_DIR/` (handled by the loop above).
|
||||
Write `approved.json` to `$_DESIGN_DIR/` (handled by the loop above). When the variants
|
||||
varied archetype, then add the chosen variant's `archetype` id and `sections` (its outline
|
||||
as an array of section names) to it with the Edit tool; /design-html takes them as given.
|
||||
|
||||
If invoked from another skill: return the structured feedback for that skill to consume.
|
||||
The calling skill reads `approved.json` and resolves the approved image with
|
||||
|
||||
@@ -219,9 +219,9 @@ Draw on DESIGN.md, taste memory, and the user's request to make each concept dis
|
||||
exists, it decides what varies: keep its fonts and palette and vary layout and
|
||||
composition. Without one, vary font family, color palette, and layout approach. If two
|
||||
variants look like siblings, rework the weaker one with a deliberately different direction.
|
||||
For a landing, pricing or campaign screen, also vary the page archetype (classic hero,
|
||||
long-form story, minimal conversion, comparison), keep the one primary action identical,
|
||||
and caption each variant with its section outline.
|
||||
For a landing, pricing or campaign screen, also vary the page archetype (`classic-hero`,
|
||||
`long-form-story`, `minimal-conversion`, `comparison`), keep the one primary action
|
||||
identical, and caption each variant with its archetype id and section outline.
|
||||
|
||||
Concrete test: if someone could swap the headline text between two variants without
|
||||
noticing, they're too similar. Variants should feel like separate design teams made
|
||||
@@ -286,7 +286,9 @@ Use AskUserQuestion to confirm before saving.
|
||||
|
||||
## Step 6: Save & Next Steps
|
||||
|
||||
Write `approved.json` to `$_DESIGN_DIR/` (handled by the loop above).
|
||||
Write `approved.json` to `$_DESIGN_DIR/` (handled by the loop above). When the variants
|
||||
varied archetype, then add the chosen variant's `archetype` id and `sections` (its outline
|
||||
as an array of section names) to it with the Edit tool; /design-html takes them as given.
|
||||
|
||||
If invoked from another skill: return the structured feedback for that skill to consume.
|
||||
The calling skill reads `approved.json` and resolves the approved image with
|
||||
|
||||
+24
-9
@@ -120,15 +120,30 @@ lease …`). Stale needs proof: two well-formed identities from the same backend
|
||||
that differ (`ps` is read under `LC_ALL=C TZ=UTC0`). An identity that cannot be
|
||||
read, a malformed record or a backend change keeps the lease. `touch` (publish
|
||||
by rename, prune) and reclaim (reread, then delete only the record judged
|
||||
stale) run under one lock in the lease directory, a symlink naming its holder's
|
||||
pid and start identity, so a renewal is never deleted and a live lease never
|
||||
disappears from a concurrent scan. The lock is broken only when its holder is
|
||||
dead or its pid was reused, never by age, and breakers serialize on a second
|
||||
symlink and reread the lock before removing it. One killed gstack-lease process
|
||||
is always safe; two killed in a row, one holding the lock and the next while
|
||||
breaking it, can still let a reclaim delete a renewal. While a live process holds it,
|
||||
`touch` exits 3 without writing (`gstack-skill-start` reports that the
|
||||
session holds no lease) and a reclaim keeps the lease. `bin/gstack-lease list` shows `alive|dead|stale`, and
|
||||
stale) run under one lock in the lease directory, a `.lock` directory whose
|
||||
`owner` file names its holder's pid and start identity, so a renewal is never
|
||||
deleted and a live lease never disappears from a concurrent scan. The lock is a
|
||||
directory because `mkdir` is atomic on every host, while Git Bash on Windows
|
||||
cannot `ln -s` to a target that does not exist (#3330). A taker publishes a
|
||||
claim before its `mkdir` and renames it in as the owner after, so an ownerless
|
||||
lock is broken only once no claimant lives and the lock is at least 2 s old
|
||||
and unchanged while checked, which keeps a taker between its two steps safe.
|
||||
A claim that records no start identity is abandoned after 2 minutes, so a
|
||||
reused pid cannot keep an orphan forever; an owner that names no valid pid
|
||||
counts as a dead holder, and an owner file that cannot be read keeps the
|
||||
lock. A live holder that never lets go bounds the wait at 300 tries or 30 s. A lock that names a holder is broken only when that holder is dead or
|
||||
its pid was reused, never by age, and breakers serialize on a second lock and
|
||||
reread the lock before removing it. A symlink `.lock` left by an older gstack
|
||||
is honored the same way. Three known limits remain. An older gstack's `ln -s`
|
||||
onto a directory lock links inside it and succeeds, so a process that started
|
||||
before an upgrade of this checkout can share the lock with a newer one. And a
|
||||
breaker killed while holding the second lock, followed by two live breakers
|
||||
clearing it at once, can let a reclaim delete a renewal. On a host without start
|
||||
identities, a taker stalled over 2 minutes between its `mkdir` and its owner
|
||||
rename can be broken and then publish into its successor's lock. While a live process
|
||||
holds the lock, or it cannot be taken at all, `touch` exits 3 without writing
|
||||
(`gstack-skill-start` reports that the session holds no lease) and a reclaim
|
||||
keeps the lease. `bin/gstack-lease list` shows `alive|dead|stale`, and
|
||||
`GSTACK_UPGRADE_FORCE=1` overrides.
|
||||
|
||||
## What the installer does
|
||||
|
||||
@@ -87,6 +87,8 @@ After weighing Codex's argument, we chose to roll back CEO EXPANSION and ship an
|
||||
"inferred": {
|
||||
"values": {"scope_appetite": 0.72, "risk_tolerance": 0.58, "...": "..."},
|
||||
"sample_size": 47,
|
||||
"signal_events": 19,
|
||||
"contributing_events": {"scope_appetite": 9, "risk_tolerance": 6, "...": "..."},
|
||||
"diversity": {
|
||||
"skills_covered": 5,
|
||||
"question_ids_covered": 14,
|
||||
@@ -103,6 +105,8 @@ After weighing Codex's argument, we chose to roll back CEO EXPANSION and ship an
|
||||
}
|
||||
```
|
||||
|
||||
`signal_events` counts the logged answers that moved any dimension, and `contributing_events` counts, per dimension, the logged answers that moved it (an answer that writes two deltas to one dimension counts once), so a dimension with no evidence (0) is visible beside one whose evidence balances out; both print 0.5 (#2623). Each value is the sigmoid of its accumulated deltas, and each UTC day's sum per dimension (by the row's `ts`) is clamped to the largest single delta in the signal map (currently ±0.06), so one burst of answers can't pin a dimension; `--trace <dim>` lists every contributing answer, each clamped day and the total. The bound keys on the day, not on `session_id`: the PostToolUse hook and the agent log different ids for the same review (the host's conversation id and the preamble's run id), and an agent can leave the `SESSION_ID` placeholder unsubstituted, so no logged id reliably marks one review. The cost is that several reviews on one day count as one. `contributing_events` still counts an answer logged by both writers twice. Only an ISO 8601 date or date-time `ts` counts as dated (a time without a zone is read as UTC), every other row shares one undated bucket, and `diversity.days_span` counts the same UTC days. Nothing displays `contributing_events` yet (#3436).
|
||||
|
||||
**Diversity check** (Codex #13): `inferred` is considered "enough data" only when `sample_size >= 20 AND skills_covered >= 3 AND question_ids_covered >= 8 AND days_span >= 7`. Below this, `/plan-tune profile` shows "not enough observed data yet" instead of a potentially-misleading inferred value.
|
||||
|
||||
## Data flow (v1)
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
# Releases and update channels
|
||||
|
||||
gstack stamps a version at every merge to main, about a dozen a day. Since
|
||||
late October 2026 there is also a weekly tagged release, and team-mode
|
||||
installs follow it instead of every merge.
|
||||
|
||||
## What a release is
|
||||
|
||||
Every Monday at 07:00 Pacific (06:00 in winter) the **Weekly release**
|
||||
workflow (`.github/workflows/weekly-release.yml`, which runs
|
||||
`scripts/release-cut.ts`) takes the newest green commit on main and:
|
||||
|
||||
1. tags it `v<VERSION>` (the VERSION file at that commit, an annotated tag);
|
||||
2. publishes a GitHub Release whose notes are one line per CHANGELOG version
|
||||
since the previous release, with a compare link and a link to the full
|
||||
CHANGELOG at that tag;
|
||||
3. fast-forwards the `stable` branch to it.
|
||||
|
||||
A tag and its release never change. `stable` is a moving pointer: always a
|
||||
commit on main's first-parent history, moved only by this workflow, only
|
||||
forward.
|
||||
|
||||
**Green** means, on that exact commit: the `free-tests` check run succeeded
|
||||
(the status main requires), the `main-backstop` check run succeeded, every
|
||||
check run on it has completed and none failed, and `refs/notes/gstack-trains`
|
||||
holds no active freeze. The Main backstop gate workflow publishes
|
||||
`main-backstop` on each push to main from a job of its own: success when a
|
||||
green train record covers the tree or the backstop gated it green, neutral
|
||||
when main moved past the push before it was gated (superseded) or a freeze's
|
||||
own backstop run owns it (deferred), failure when it froze the queue or could
|
||||
not finish. Neutral is never green: a commit whose own tree was never gated is
|
||||
not released. A push that gets no backstop run at all (a dependabot push, a
|
||||
policy without a train block) has no `main-backstop` check run and is not
|
||||
green either; a cancelled check run counts as red. A commit with a check run still going is skipped and the next
|
||||
older one is considered, back through 7 days or 200 first-parent commits. The
|
||||
workflow runs no tests and no paid evals of its own; it only reads what the
|
||||
trains and the backstop already recorded. With nothing green in the window it
|
||||
fails and lists why each commit was skipped.
|
||||
|
||||
A cut that stopped half-way (tag and `stable` pushed, release not created) is
|
||||
finished by the next run, by cron or by hand: `stable` itself stays a
|
||||
candidate until its release exists, and its notes compare against the release
|
||||
tag before it.
|
||||
|
||||
## Channels
|
||||
|
||||
| Channel | Follows | Who |
|
||||
|---|---|---|
|
||||
| `stable` | `refs/heads/stable`: the weekly release, plus any hotfix cut off-cycle | team mode (`./setup --team`) by default |
|
||||
| `main` | every merge to main | plain `./setup`, contributors, Capy crew machines |
|
||||
|
||||
The config key is `update_channel` (`bin/gstack-config`):
|
||||
|
||||
```bash
|
||||
gstack-config get update_channel # auto (default) | stable | main
|
||||
gstack-config set update_channel main # a team install that wants every merge
|
||||
gstack-config set update_channel stable # a solo install that wants weekly releases
|
||||
gstack-config unset update_channel # back to auto
|
||||
```
|
||||
|
||||
`auto` resolves to `stable` when `team_mode` is true and to `main` otherwise,
|
||||
so existing team installs moved to weekly releases with nothing to migrate and
|
||||
contributor installs did not move at all. `GSTACK_UPDATE_CHANNEL=stable|main`
|
||||
overrides the key for one command. The resolution lives in one helper,
|
||||
`bin/gstack-update-channel.sh`, used by:
|
||||
|
||||
- `bin/gstack-session-update`, the team-mode session-start hook: it fetches
|
||||
the channel ref and fast-forwards to it. Its log
|
||||
(`<state root>/analytics/session-update.log`) carries `CHANNEL stable target=<sha>`
|
||||
when it follows a release.
|
||||
- `bin/gstack-update-check`, the "update available" line in every skill
|
||||
preamble: it reads `<channel>/VERSION` and compares with `refs/heads/<channel>`,
|
||||
so a stable install is not told every hour that main moved.
|
||||
- `bin/gstack-upgrade` and `/gstack-upgrade`: they fast-forward to the channel
|
||||
ref (`UPSTREAM_REF=origin/stable` in the skill's output) and say which channel
|
||||
they followed.
|
||||
|
||||
Two edge cases are handled on purpose:
|
||||
|
||||
- **Before the first release exists** origin has no `stable`. Every caller then
|
||||
follows main and says so (`CHANNEL_FALLBACK stable->main` in the hook log,
|
||||
`channel stable has no release on origin yet` from `gstack-upgrade`).
|
||||
- **An install already past the release** (it followed main until now, or a
|
||||
hotfix was cut below it) stays where it is until stable passes it. The
|
||||
updater never moves a checkout backwards. A depth-1 clone cannot see that an
|
||||
older release is its ancestor, so the VERSIONs decide: main stamps a higher
|
||||
version at every merge and stable is always a main commit.
|
||||
|
||||
The local branch stays `main` either way. A stable install is a `main`
|
||||
checkout parked at the last released commit, which is why fast-forward-only
|
||||
still holds and nothing about version stamping changed.
|
||||
|
||||
## Cutting a release by hand (hotfixes)
|
||||
|
||||
A hotfix is an ordinary PR: it merges to main through a train and gets its
|
||||
version like any other change. To put it on stable before Monday, run the
|
||||
workflow by hand with the merge commit's SHA:
|
||||
|
||||
```bash
|
||||
gh workflow run weekly-release.yml -f ref=<full sha on main> -f dry_run=false
|
||||
```
|
||||
|
||||
or Actions → **Weekly release** → Run workflow, on main (the cut job runs
|
||||
only from `refs/heads/main`, with main's tooling; `ref` must be the full
|
||||
40-hex SHA). The same green checks apply. The commit is tagged with its VERSION, the release notes say "hotfix release"
|
||||
and list every version since the previous release (everything merged in
|
||||
between ships with it), and stable fast-forwards. Team installs pick it up at
|
||||
their next hourly check.
|
||||
|
||||
There is no release branch and no cherry-picking: stable only ever names main
|
||||
commits. If main carries a change that must not ship yet, revert it on main
|
||||
first, then promote.
|
||||
|
||||
## Dry runs
|
||||
|
||||
Run the workflow with `dry_run=true` (the default for a manual run) to see the
|
||||
plan without changing anything: the candidate, its VERSION and tag, the stable
|
||||
move, how many version lines the notes would carry, every skipped commit with
|
||||
its reason, and the exact commands a real cut would run. The scheduled Monday
|
||||
run is never a dry run.
|
||||
|
||||
```bash
|
||||
gh workflow run weekly-release.yml -f dry_run=true
|
||||
gh run watch
|
||||
```
|
||||
|
||||
The step summary carries the same plan plus the full release notes.
|
||||
|
||||
A pull request that touches `.github/workflows/weekly-release.yml` or
|
||||
`scripts/release-cut.ts` runs the same dry run against main as its own job
|
||||
(`Dry run against main`, read-only token), so a change to the release tooling
|
||||
is exercised against real check runs before it merges.
|
||||
|
||||
## Outcomes
|
||||
|
||||
| Line | Meaning |
|
||||
|---|---|
|
||||
| `CANDIDATE: <sha> (<date>)` | the commit that will be (or was) released |
|
||||
| `NOTHING_NEW: origin/main <sha> is already released as v<X>` | nothing merged since the last release; exit 0 |
|
||||
| `NO_GREEN_COMMIT: ...` with `SKIPPED:` lines | nothing in the window passed the gate; exit 1 (a dry run exits 0: the skips are its answer) |
|
||||
| `REFUSED: non-fast-forward: ...` | the candidate is behind stable; stable only moves forward |
|
||||
| `REFUSED: queue frozen since train ...` | the main backstop froze the queue; clear it first (docs/ship-policy.md) |
|
||||
| `REFUSED: tag v<X> already names <other sha>` | a version was stamped twice; needs a look before anything is cut |
|
||||
| `TAG:`, `STABLE:`, `RELEASE:` | what the cut did; a re-run reports each step as already done and finishes a step an earlier run did not reach |
|
||||
+64
-10
@@ -194,11 +194,10 @@ which passes because the dispatched check runs attach to the commit; the
|
||||
`ci-hint:` line then says those non-pull_request runs count. The `ci-hint:` line is
|
||||
advisory text for the lane; nothing parses it, and a PR whose head is on a fork
|
||||
gets a hint that leaves the dispatch to the coordinator. `ready --screen` on such a
|
||||
PR refuses, because there is no merge ref to screen (`QUEUE_NOT_READY`, `<remote>
|
||||
has no refs/pull/<n>/merge`; #3438 names it `QUEUE_SCREEN_STALE_REF`); that
|
||||
refusal's "merge <base> into the branch" advice does not apply to a
|
||||
CHANGELOG-only conflict, so the lane READYs with `--no-screen` after the skill's
|
||||
own paid cases ran focused.
|
||||
PR refuses, because there is no merge ref to screen (`QUEUE_SCREEN_STALE_REF`,
|
||||
`<remote> has no refs/pull/<n>/merge`); that refusal's "merge <base> into the
|
||||
branch" advice does not apply to a CHANGELOG-only conflict, so the lane READYs
|
||||
with `--no-screen` after the skill's own paid cases ran focused.
|
||||
|
||||
`ci:` counts the latest run per check (one app's check name), as GitHub's
|
||||
rollup does: a check that was re-run or superseded on the same commit (a
|
||||
@@ -210,7 +209,10 @@ are counted one each.
|
||||
|
||||
It refuses (`QUEUE_NOT_READY`) when the working tree has tracked edits, HEAD
|
||||
is not the PR's pushed head, CI at that exact head is not green, or the pregate
|
||||
result is not a current pass. Send the block as printed.
|
||||
result is not a current pass, and (`QUEUE_RECEIPT_MISSING`,
|
||||
`QUEUE_RECEIPT_STALE`) when the PR body carries no /ship receipt for that
|
||||
head: /ship runs before every READY ([details](#ship-before-ready)). Send the
|
||||
block as printed.
|
||||
|
||||
**2. Lane behind another PR: gate ahead.** Gate the tree the queue will have
|
||||
once the predecessor lands, while it is still open:
|
||||
@@ -344,13 +346,53 @@ are mirrored to origin as git notes on `refs/notes/gstack-trains` before any
|
||||
remote effect, so a lost machine loses nothing: `train status` on a clean
|
||||
state root rebuilds them. `GSTACK_SHIP_QUEUE_NOW` pins the clock for tests.
|
||||
|
||||
<a id="ship-before-ready"></a>
|
||||
**0. Lane: /ship before READY.** Every READY follows a /ship run on the branch
|
||||
at the head being READY'd, including a re-READY after a push (Garry,
|
||||
2026-10-25: "we should really be using the gstack ship skill before merging
|
||||
PRs"). /ship runs the tests, both reviews, the gate-integrity list and the docs
|
||||
audit, and ends by writing its receipt into the PR body
|
||||
(`gstack-ship-receipt write --pr <n> … --body <file>`, posted with
|
||||
`gstack-post pr-body`). Under `queue_mode: stamp-at-merge` it bumps no version
|
||||
and never merges or merges main into the branch; the train still stamps at the
|
||||
slot. The queue enforces this at every step from the PR body alone:
|
||||
|
||||
- `ready` refuses with `QUEUE_RECEIPT_MISSING` when the body has no valid
|
||||
receipt and with `QUEUE_RECEIPT_STALE` when the receipt's `head` is not the
|
||||
head being READY'd (or its `pr` is another PR); the informational
|
||||
`SHIP_RECEIPT:` line still prints. There is no flag around it.
|
||||
- `train build` refuses a pin whose PR has no current receipt with the same
|
||||
codes, naming the PR, before anything is written, pushed or dispatched,
|
||||
`--dry-run` included.
|
||||
- `verify --train` refuses the slot with the same codes before printing
|
||||
`MERGE_CONDITION`.
|
||||
|
||||
**The stamp keeps the pin's receipt.** The slot's stamp commit changes only
|
||||
stamp files (and, from slot 2 on, merges main into the pin), so no /ship runs
|
||||
on it: the receipt written for the pinned head stays the binding one. `verify
|
||||
--train` accepts a receipt whose `head` is either the slot's pinned head or
|
||||
the current head, and the re-READY after the stamp push (`ready` at the stamp
|
||||
commit) accepts the receipt for the pin when HEAD is the pin plus stamp files,
|
||||
or (from slot 2 on) the pin merged with main plus stamp files on top of the
|
||||
merge; any other head is `QUEUE_RECEIPT_STALE`, including a merge of main with
|
||||
no stamp above it (a lane's own push under rule 14) and a merge whose pin side
|
||||
carries an edit made after /ship.
|
||||
|
||||
This is a process gate, not an authenticity check: the receipt is a block in
|
||||
the PR body, which the PR author writes, so the codes prove that /ship was run
|
||||
and recorded for this head, not that nobody typed the block by hand. Fork heads
|
||||
are already refused (`QUEUE_TRAIN_UNTRUSTED`). The `ship-run.json` that
|
||||
`gstack-ship-receipt result` writes under the lane's state root is a
|
||||
normalized copy of that same block bound to the head it was run at, not an
|
||||
authenticity proof either.
|
||||
|
||||
**1. Lane: READY with a screen choice.** Under a train policy READY needs an
|
||||
explicit choice:
|
||||
|
||||
```bash
|
||||
gstack-ship-queue ready --pr 42 # prints SCREEN: lines and the recommendation, refuses
|
||||
gstack-ship-queue ready --pr 42 --no-screen # READY receipt; the PR rides unscreened
|
||||
gstack-ship-queue ready --pr 42 --screen [--broad] # dispatches one paid screen on the PR's merge SHA
|
||||
gstack-ship-queue ready --pr 42 --screen [--broad] # dispatches one paid screen on the PR's merge SHA (QUEUE_SCREEN_STALE_REF when GitHub's merge ref lags the head)
|
||||
```
|
||||
|
||||
Without a choice it prints the `SCREEN:` selection (with
|
||||
@@ -366,7 +408,17 @@ dispatches `gate_workflow` with `screen_pr`, `screen_merge_sha` (the
|
||||
the policy, so a repeat follows the same run and a broad screen never adopts
|
||||
a narrow one. The READY block gains `screen: …`, and the receipt is written
|
||||
locally and to the notes ref. `--dry-run` prints the dispatch without sending
|
||||
it.
|
||||
it. Before dispatching, `ready` fetches the merge ref and requires its second
|
||||
parent to be the PR head: a missing or lagging merge ref (GitHub stops
|
||||
recomputing it while the PR conflicts with the base, CHANGELOG-only conflicts
|
||||
included) is `QUEUE_SCREEN_STALE_REF`, because a screen dispatched there plans
|
||||
no cases and reports success. A finished screen that planned no cases (no
|
||||
`paid-plan` artifact, which a superseded run never uploads, or a plan manifest
|
||||
with nothing planned) is never followed as a screen: `ready --screen`
|
||||
dispatches a fresh one and `train build` prints `SCREEN: #<n> run <id> planned
|
||||
no cases …; counted as not-run` and seats the slot unscreened. A plan the
|
||||
queue cannot read keeps the slot screened, with `SCREEN: #<n> <reason>;
|
||||
counted as screened, unverified`.
|
||||
|
||||
**2. Coordinator: build.** Name the pins exactly as READY reported them:
|
||||
|
||||
@@ -390,8 +442,10 @@ has waited `fill_hours` (`QUEUE_TRAIN_WAITING`); `--urgent` dispatches a
|
||||
one-PR train at once. The same pins on the same base return the live train
|
||||
instead of a second one; `train build --train <nonce>` resumes a train from
|
||||
its record. Refusals, all before any spend: `QUEUE_TRAIN_UNTRUSTED` (a fork
|
||||
head), `QUEUE_TRAIN_MOVED` (a head moved from its pin), `QUEUE_NOT_READY` (no
|
||||
READY receipt at that head), `QUEUE_TRAIN_CONFLICT` (the printed
|
||||
head), `QUEUE_TRAIN_MOVED` (a head moved from its pin),
|
||||
`QUEUE_RECEIPT_MISSING` and `QUEUE_RECEIPT_STALE` (the PR body has no /ship
|
||||
receipt for the pin; see [/ship before READY](#ship-before-ready)),
|
||||
`QUEUE_NOT_READY` (no READY receipt at that head), `QUEUE_TRAIN_CONFLICT` (the printed
|
||||
`QUEUE_TRAIN_CONFLICT: train <id> stopped at #<pr> (conflicts: <files>) after
|
||||
merging #<a>,#<b>; nothing dispatched, no branch pushed; fix: …` line names
|
||||
the build without that PR) and `QUEUE_TRAIN_FREE_RED` (free tests red,
|
||||
|
||||
+2
-2
@@ -462,7 +462,7 @@ You know the feeling. You have a feature, a page, a landing screen... and you're
|
||||
3. It generates 3 distinct design variants as PNGs
|
||||
4. A comparison board opens in your browser with all 3 side-by-side
|
||||
5. You click "Approve" on the one you like, or give feedback for another round
|
||||
6. The approved variant saves to `~/.gstack/projects/$SLUG/designs/` with an `approved.json`
|
||||
6. The approved variant saves to `~/.gstack/projects/$SLUG/designs/` with an `approved.json`; for a landing, pricing or campaign screen, it also records the page archetype you picked (`classic-hero`, `long-form-story`, `minimal-conversion` or `comparison`) and its section outline
|
||||
|
||||
That `approved.json` is one way to feed `/design-html`. The design pipeline chains: shotgun picks the direction, design-html renders it as working code. But `/design-html` also works with CEO plans, design reviews, or just a description.
|
||||
|
||||
@@ -512,7 +512,7 @@ Not every page needs the full Pretext engine. The skill reads the design and pic
|
||||
|
||||
### The refinement loop
|
||||
|
||||
1. Reads the approved mockup from `approved.json`
|
||||
1. Reads the approved mockup from `approved.json`, plus any page archetype and section outline you picked on the board, which it builds as given instead of asking again
|
||||
2. Uses GPT-4o vision to extract implementation spec (colors, typography, layout)
|
||||
3. Generates self-contained HTML with Pretext inlined (15KB, zero network dependency)
|
||||
4. Spins up a live-reload server so you see changes instantly
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# Issue triage
|
||||
|
||||
How gstack issues move between open, parked and closed, and what each state
|
||||
promises the reporter. The rule exists because a closed issue reads as "fixed"
|
||||
or "rejected" to the person who filed it. On 2026-10-11 a triage pass closed 75
|
||||
feature requests into TODOS.md themes with a "not rejected" note, and reporters
|
||||
still saw a closed issue with no fix. Those were reopened on 2026-10-25 under
|
||||
the `parked` label, and this page is the rule going forward.
|
||||
|
||||
## The three states
|
||||
|
||||
| State | Meaning | How it looks on GitHub |
|
||||
|-------|---------|------------------------|
|
||||
| **Open** | We intend to act on it and nothing is decided yet, or a lane is on it now. | Open, no `parked` label. `next-wave` marks issues accepted for the next fix wave. |
|
||||
| **Parked** | We intend to fix it later, not now. | Open, labelled `parked`, with one comment that says where it is tracked and what fixes it. |
|
||||
| **Closed** | Fixed, duplicate, or won't fix. Nothing else. | Closed with the matching reason (below). |
|
||||
|
||||
An issue we still intend to fix is never closed. Grouping it into a theme,
|
||||
folding it into a tracking issue, or deciding it waits for other work are all
|
||||
"parked", not "closed".
|
||||
|
||||
## Parking an issue
|
||||
|
||||
1. Add the `parked` label.
|
||||
2. Post one comment with two facts: **where it is tracked** (the TODOS.md
|
||||
theme, by link, or the tracking issue such as #3137, #3138 or #3139) and
|
||||
**what fixes it** (the work that, when it ships, closes this issue). One or
|
||||
two sentences. No other comment is needed.
|
||||
3. Add the issue number to the TODOS.md entry so the theme credits the reporter
|
||||
when it is picked up.
|
||||
|
||||
When the work ships, close the issue as fixed with the version or PR. Whatever
|
||||
the close reason, remove the `parked` label when the issue closes
|
||||
(`gh issue edit <n> --remove-label parked`), so a `label:parked` search lists
|
||||
only open parked issues. GitHub leaves labels alone when a merged PR's
|
||||
`Closes #<n>` closes the issue, so the lane that ships the fix removes the label
|
||||
itself. When a lane picks a parked issue up, it removes the label while it works
|
||||
on it (the issue is open again, with the lane on it); an issue the lane later
|
||||
drops goes back to parked with an updated comment.
|
||||
|
||||
## Closing an issue
|
||||
|
||||
Each close names its reason in the closing comment. GitHub's close reason and
|
||||
the labels match:
|
||||
|
||||
| Reason | Comment says | Close reason | Label |
|
||||
|--------|--------------|--------------|-------|
|
||||
| **Fixed** | the version, commit or PR that fixed it, and what changed | completed | — |
|
||||
| **Duplicate** | the issue that tracks it (and that issue is open or parked; if it was already fixed, close this one as fixed too, with the same version) | duplicate (`gh issue close <n> --duplicate-of <m>`) | `duplicate` |
|
||||
| **Won't fix** | why: out of scope, a different design was chosen, the premise no longer holds | not planned | `wontfix` |
|
||||
|
||||
`gh issue close` sets only the close reason; add the `duplicate` or `wontfix`
|
||||
label separately with `gh issue edit <n> --add-label <label>`.
|
||||
|
||||
A duplicate of a parked issue is still a duplicate: close it with the link,
|
||||
and the parked issue's comment or TODOS.md entry credits both reporters. A
|
||||
report that no longer reproduces on current main closes as fixed when a commit,
|
||||
PR or version explains why (cite it, as `/investigate --backlog` does with
|
||||
`already_fixed <commit>`); when nothing does, it closes as won't fix with the
|
||||
version it was checked against and an invitation to reopen with a repro.
|
||||
|
||||
## Labels this page uses
|
||||
|
||||
- `parked`: not fixed yet, not rejected; stays open, with a comment saying
|
||||
where it is tracked and what fixes it.
|
||||
- `tracking`: a tracking issue for a theme; stays open until the theme ships.
|
||||
- `next-wave`: accepted for the next fix wave.
|
||||
- `duplicate`, `wontfix`: the two non-fix close reasons.
|
||||
- `needs Windows verification`: a merged fix awaiting confirmation on real
|
||||
Windows hardware; the issue stays open until then.
|
||||
|
||||
## Where the themes live
|
||||
|
||||
Parked feature requests are grouped in TODOS.md under "feature themes from the
|
||||
Wave 9 triage": new hosts (#3137), provider pluralism (#3138), install
|
||||
footprint and layout (#3139), new skills, ship/review/safety, planning reviews,
|
||||
state root and project identity, browser and mobile QA, prompt cost and skill
|
||||
shape, customization and localization, retro, and CI supply chain. Parked bugs
|
||||
sit under "parked in Wave 9" in the same file. Pick a theme up as a whole and
|
||||
credit every linked reporter in the CHANGELOG entry.
|
||||
|
||||
Community pull requests follow the wave process in
|
||||
[CONTRIBUTING.md](../CONTRIBUTING.md#community-pr-triage-wave-process); a
|
||||
closed PR gets the same courtesy as a closed issue: a comment saying why and
|
||||
what supersedes it.
|
||||
+77
-4
@@ -1604,8 +1604,9 @@ message lists.
|
||||
|
||||
**Meaning.** On a Capy machine (`GSTACK_CAPY=1`, set by the `GSTACK_LAUNCH`
|
||||
line), an unattended or spawned workflow starts only with a passing install
|
||||
verdict (`<state root>/install-verdicts/<sha256 of the checkout path>.json`,
|
||||
named as `GSTACK_INSTALL_VERDICT`) for this checkout, its current HEAD and a
|
||||
verdict (`<state root>/install-verdicts/<sha256 of the checkout path>/<sha256
|
||||
of the project directory, or none>.json`, named as `GSTACK_INSTALL_VERDICT`)
|
||||
for this checkout, its current HEAD and a
|
||||
workflow at least as wide as the one starting: a verdict for `--for autoplan`
|
||||
does not cover `qa`'s browser, and a planning-only pins SKIP does not cover a
|
||||
workflow that runs the repo's tests. `gstack-skill-start` printed this row and
|
||||
@@ -1613,8 +1614,12 @@ workflow that runs the repo's tests. `gstack-skill-start` printed this row and
|
||||
an unattended run whose Bun on PATH is below the supported minimum
|
||||
(`INSTALL: fail runtime ... (RUNTIME_BELOW_MINIMUM)`) on any host.
|
||||
|
||||
**Fix.** Run the Capy skill installer with `--for <workflow>` on this machine
|
||||
and launch with the `GSTACK_LAUNCH` line it prints.
|
||||
**Fix.** Run the Capy skill installer (`scripts/install.sh --for <workflow>`)
|
||||
from the project directory the workflow will run in, since the verdict is
|
||||
keyed by that directory (GSTA-85), and launch with the `GSTACK_LAUNCH` line it
|
||||
prints. A verdict file that exists but cannot be read (`the install verdict at
|
||||
<file> is unreadable or has an unsupported schema_version`) is repaired the
|
||||
same way, by rerunning the installer.
|
||||
`GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1` admits anyway (printed as
|
||||
`INSTALL_VERDICT: overridden`); record it as a deviation.
|
||||
|
||||
@@ -4021,6 +4026,74 @@ trains and other trains' merges are refused until a repair lands.
|
||||
<n>@<sha>`). The freeze clears when verify finds main's tree after that merge
|
||||
in a green record; `NEXT:` then builds a train of the PRs still unmerged.
|
||||
|
||||
<a id="queue-receipt-missing"></a>
|
||||
### `QUEUE_RECEIPT_MISSING`
|
||||
|
||||
**Meaning.** The PR body carries no valid ```gstack-ship-receipt``` block, so
|
||||
/ship never ran on this branch (or its receipt was lost when the body was
|
||||
rewritten). `gstack-ship-queue ready` refuses to READY it, `train build`
|
||||
refuses the pin by PR number before any write, push or dispatch (dry run
|
||||
included), and `verify --train` refuses the slot. The block still prints the
|
||||
informational `SHIP_RECEIPT: none in the PR body` (or `invalid`) line. There
|
||||
is no flag that skips this check (Garry, 2026-10-25: use the gstack ship skill
|
||||
before merging PRs; [ship-policy.md](ship-policy.md#ship-before-ready)).
|
||||
|
||||
**Fix.** Run /ship on the branch. Unattended, it ends by writing the receipt
|
||||
into the PR body (`gstack-ship-receipt write --pr <n> … --body <file>`, then
|
||||
`gstack-post pr-body`) when an open PR exists for the branch at HEAD;
|
||||
otherwise it leaves the receipt in `.gstack/tmp/ship-receipt.md` and the
|
||||
parent posts it ([unattended.md](unattended.md)). Then rerun
|
||||
`gstack-ship-queue ready --pr <n> --screen|--no-screen` and send the new READY
|
||||
block.
|
||||
|
||||
<a id="queue-receipt-stale"></a>
|
||||
### `QUEUE_RECEIPT_STALE`
|
||||
|
||||
**Meaning.** The PR body's ship receipt is valid but names another head than
|
||||
the one being READY'd or merged (a push after /ship, a receipt copied from
|
||||
another PR, or a `pr` field for another PR), so the receipt does not cover
|
||||
what would merge. The one accepted difference is a train slot's stamp: when
|
||||
the current head is the pinned head's stamp commit (same tree modulo stamp
|
||||
files, or the pin with main merged in at slot 2 and later), the receipt
|
||||
written for the pinned head is the binding one and `verify --train` accepts it.
|
||||
|
||||
**Fix.** Rerun /ship on the current head so the receipt names it, then rerun
|
||||
`gstack-ship-queue ready --pr <n> --screen|--no-screen`. A lane never stamps
|
||||
to fix this: the stamp comes only when the slot is called.
|
||||
|
||||
<a id="queue-screen-stale-ref"></a>
|
||||
### `QUEUE_SCREEN_STALE_REF`
|
||||
|
||||
**Meaning.** `gstack-ship-queue ready --screen` dispatches the screen against
|
||||
GitHub's `refs/pull/<n>/merge`, and that ref is missing or still merges an
|
||||
older head than the PR's pushed head. GitHub stops recomputing the merge ref
|
||||
while the PR conflicts with the base (a CHANGELOG-only conflict after a stamp
|
||||
counts), so a screen dispatched there checks out the old merge, sees itself
|
||||
superseded, plans 0 cases and reports success. GSTA-95 hit this on #3431:
|
||||
`ready` printed `SCREEN_DISPATCH:` as if a screen ran. `ready` now compares
|
||||
the merge ref's second parent with the PR head before dispatching, and a
|
||||
finished screen that planned no cases is never adopted as a screen. A screen
|
||||
run uploads no train record, so the evidence is its plan: a superseded run
|
||||
skips `plan-slices` and uploads no `paid-plan` artifact, and a manifest with
|
||||
nothing planned counts the same. `ready --screen` dispatches a fresh one and
|
||||
`train build` prints `SCREEN: #<n> run <id> planned no cases (no paid-plan
|
||||
artifact; …)` or `… planned no cases (an empty plan manifest; …)`, each
|
||||
ending `; counted as not-run, the slot rides unscreened`. A plan it cannot
|
||||
read (`… paid-plan artifact has expired`, `… manifest.json is unreadable`, an
|
||||
unreadable run or artifact list, an unreadable repository slug, or a READY
|
||||
whose run id is not a number) keeps the slot screened and prints
|
||||
`SCREEN: #<n> <reason>; counted as screened, unverified`; `ready --screen`
|
||||
follows such a run. When `ready` cannot list the remote's refs or fetch the
|
||||
merge ref at all, it refuses with the same code and a `could not list …` or
|
||||
`could not fetch …` message whose NEXT line says to check the remote, not to
|
||||
resolve a conflict.
|
||||
|
||||
**Fix.** Resolve the conflict with the base branch (or wait for GitHub to
|
||||
recompute the merge ref), push, and run `gstack-ship-queue ready --pr <n>
|
||||
--screen` again. Under crew rule 14 a CHANGELOG-only conflict is left to the
|
||||
train, so a lane that cannot get a fresh merge ref READYs `--no-screen` only
|
||||
after its own focused paid cases passed (rule 6).
|
||||
|
||||
<a id="ready-screen-choice-required"></a>
|
||||
### `READY_SCREEN_CHOICE_REQUIRED`
|
||||
|
||||
|
||||
+40
-8
@@ -80,7 +80,36 @@ Not every workflow writes this line or a run directory today:
|
||||
record), not a run directory.
|
||||
- **A refused launch.** Any skill whose install check refuses it at startup
|
||||
prints `GSTACK_RESULT: skill=<name> status=refused run=-` and stops.
|
||||
- **Every other workflow skill** (`/ship`, `/review`, `/qa`, `/design-review`,
|
||||
- **`/ship`** ends with `gstack-ship-receipt result --pr <n>` (or `--body
|
||||
.gstack/tmp/ship-receipt.md` after the handoff section). It binds the
|
||||
receipt to the branch head, writes `<state root>/ship/runs/<utc>-<head12>/`
|
||||
(`ship-run.json` with `head`, `pr`, `base`, `version`, `queue_mode`,
|
||||
`session_kind`, `receipt` as the path of the copied `ship-receipt.md`, and
|
||||
`receipt_source`; it is a /ship record, not the `run` v1 manifest below, so
|
||||
read it directly: `gstack-artifact validate` does not apply) and prints
|
||||
`GSTACK_RESULT: skill=ship status=complete run=<dir>`. When gh cannot read
|
||||
the PR it prints `GSTACK_RESULT: skill=ship status=incomplete run=-` (exit 1). A receipt that is missing or names another head prints
|
||||
`GSTACK_RESULT: skill=ship status=refused run=-` with `QUEUE_RECEIPT_MISSING`
|
||||
or `QUEUE_RECEIPT_STALE` on stderr (exit 3); a run that stops before
|
||||
publication prints the same refused line itself. A parent's merge check is
|
||||
then mechanical: take `run=` from the last result line, read
|
||||
`ship-run.json`, and compare its `head` to the PR head. `gstack-ship-queue ready`,
|
||||
`train build` and `verify --train` make the same comparison from the PR body
|
||||
([ship-policy.md](ship-policy.md#ship-before-ready)), which is why an
|
||||
unattended /ship may update the body of a PR that already exists: when an
|
||||
open PR for the branch exists and HEAD is its pushed head, Step 18 takes the
|
||||
pr-body section's existing-PR path and posts the body (receipt first) and
|
||||
title through `gstack-post`. That is the one publication the unattended
|
||||
rules allow; it never runs `pr-create`, never pushes a commit, never runs
|
||||
`gh pr ready` (the existing-PR path's draft-state items concern an early PR
|
||||
an unattended run never opens), and a branch with no open PR, or whose PR
|
||||
head is not HEAD, takes the handoff section. `result --pr <n>` also refuses
|
||||
`QUEUE_RECEIPT_STALE` when HEAD is not the PR's pushed head.
|
||||
When the run committed anything (a CHANGELOG entry, generated tests), the
|
||||
parent pushes those commits itself, then reruns the receipt write, the body
|
||||
post and `result` at the pushed head: a receipt for an unpushed head is
|
||||
`QUEUE_RECEIPT_STALE` to the queue.
|
||||
- **Every other workflow skill** (`/review`, `/qa`, `/design-review`,
|
||||
`/spec`, `/land-and-deploy` and the rest) gets the session rules above (no
|
||||
prompts, approvals stay pending, nothing published), but has no step that
|
||||
writes a run directory or prints the line, so a parent should not wait for
|
||||
@@ -88,14 +117,15 @@ Not every workflow writes this line or a run directory today:
|
||||
helper writes its `gate.json` yet (`gstack-gate` renders and parses a gate
|
||||
list; it does not create one). `/cso` skips the shared startup, so these
|
||||
session rules do not reach it at all. Read each skill's own record instead:
|
||||
the review-log rows (`gstack-review-read`) for `/review`, the
|
||||
`qa-report-*.md` file for `/qa`, and the ship receipt for `/ship`. When
|
||||
the run did not push (an unattended run must not), read the receipt file in
|
||||
the child's checkout with `gstack-ship-receipt read --body .gstack/tmp/ship-receipt.md --json`;
|
||||
the review-log rows (`gstack-review-read`) for `/review` and the
|
||||
`qa-report-*.md` file for `/qa`. For a `/ship` run from before the result
|
||||
line, read the receipt instead: when the run did not push (an unattended run
|
||||
must not), read the receipt file in the child's checkout with
|
||||
`gstack-ship-receipt read --body .gstack/tmp/ship-receipt.md --json`;
|
||||
use `gstack-ship-receipt read --pr <n> --json` only once a PR exists. In both
|
||||
cases compare the receipt's `head` to the branch SHA (`git rev-parse HEAD` in
|
||||
the child's checkout): `read` does not check that the receipt is current, so
|
||||
an older PR's receipt reads as valid.
|
||||
an older PR's receipt reads as valid; `result` does.
|
||||
|
||||
Every workflow command shares one exit table: `0` ok, `1` fail, `2` usage,
|
||||
`3` refused or needs a flag. Error lines end with a result code in parentheses,
|
||||
@@ -125,8 +155,10 @@ JSON Schema for any of `findings`, `decisions`, `tasks`, `timing`, `run`,
|
||||
|
||||
## How a parent consumes a run
|
||||
|
||||
1. **Decide whether the run finished.** `gstack-artifact validate <dir>/run.json`
|
||||
is the deterministic completion check. It exits `0` and prints
|
||||
1. **Decide whether the run finished.** For an `/autoplan` run directory,
|
||||
`gstack-artifact validate <dir>/run.json`
|
||||
is the deterministic completion check (a `/ship` `run=` directory holds
|
||||
`ship-run.json`, read directly as described above). It exits `0` and prints
|
||||
`ARTIFACT_VALID: run=<id> status=<status>` only when the manifest parses and
|
||||
matches its schema, every required phase and each reviewer voice has a
|
||||
terminal outcome, every listed artifact exists inside the run directory with
|
||||
|
||||
+24
-28
@@ -110,7 +110,7 @@ echo "Install type: $INSTALL_TYPE at $INSTALL_DIR"
|
||||
```
|
||||
|
||||
The install type and the absolute directory printed above are used in all subsequent steps.
|
||||
Carry `INSTALL_TYPE`, `INSTALL_DIR`, `OLD_VERSION`, and later `NEW_VERSION` forward explicitly: tool calls may run in fresh shells, so start each later block by assigning them from the captured output (for example `INSTALL_DIR=/abs/path/printed/above`). Do not rely on a prior call's working directory or shell variables. Every block that changes files first checks `INSTALL_DIR` and stops before any git command when it is unset, empty, missing, unreadable, or not gstack's own checkout.
|
||||
Carry `INSTALL_TYPE`, `INSTALL_DIR`, `OLD_VERSION`, and later `UPSTREAM_REF` and `NEW_VERSION` forward explicitly: tool calls may run in fresh shells, so start each later block by assigning them from the captured output (for example `INSTALL_DIR=/abs/path/printed/above`). Do not rely on a prior call's working directory or shell variables. Every block that changes files first checks `INSTALL_DIR` and stops before any git command when it is unset, empty, missing, unreadable, or not gstack's own checkout.
|
||||
|
||||
### Step 3: Save old version
|
||||
|
||||
@@ -135,12 +135,19 @@ is saved in a rescue ref instead of being left as conflict markers:
|
||||
cd -- "${INSTALL_DIR:?INSTALL_DIR is not set: re-run Step 2 and substitute the printed path}" || exit 1
|
||||
{ [ -f VERSION ] && [ -f setup ] && [ -f bin/gstack-config ] && _p=$(git rev-parse --show-prefix 2>/dev/null) && [ -z "$_p" ] && [ "$(git rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; } || { echo "ERROR: $INSTALL_DIR is not a gstack checkout; nothing was changed. Re-run Step 2." >&2; exit 1; }
|
||||
git fetch origin
|
||||
# Channel ref (docs/releases.md): origin/stable for a team install, else origin/main
|
||||
_CH=$(bash bin/gstack-update-channel.sh --fetch-ref 2>&1) || _CH=origin/main
|
||||
echo "$_CH"; UPSTREAM_REF=$(printf '%s\n' "$_CH" | tail -1)
|
||||
echo "UPSTREAM_REF=${UPSTREAM_REF:=origin/main}"
|
||||
# Same pre-advance check as the auto-updater: the incoming release's Bun floor,
|
||||
# judged by bash whatever this tool shell is (zsh does not word-split).
|
||||
HOLD=$(bash -c '. bin/gstack-bun-version.sh && gstack_bun_incoming_hold . origin/main' 2>/dev/null) && { case "$HOLD" in bun-version-unreadable*) echo "BUN_VERSION_UNREADABLE: $HOLD; nothing was changed" >&2 ;; *) echo "BUN_TOO_OLD: $HOLD; nothing was changed" >&2 ;; esac; exit 1; }
|
||||
HOLD=$(UPSTREAM_REF="$UPSTREAM_REF" bash -c '. bin/gstack-bun-version.sh && gstack_bun_incoming_hold . "$UPSTREAM_REF"' 2>/dev/null) && { case "$HOLD" in bun-version-unreadable*) echo "BUN_VERSION_UNREADABLE: $HOLD; nothing was changed" >&2 ;; *) echo "BUN_TOO_OLD: $HOLD; nothing was changed" >&2 ;; esac; exit 1; }
|
||||
PRE_UPGRADE_COMMIT=$(git rev-parse HEAD)
|
||||
echo "PRE_UPGRADE_COMMIT=$PRE_UPGRADE_COMMIT"
|
||||
FF=$(bash bin/gstack-ff-update . origin/main); FF_RC=$?
|
||||
# Past the release (by ancestry, or by VERSION in a depth-1 clone): wait for stable; nothing moves back.
|
||||
if [ "$UPSTREAM_REF" = origin/stable ] && [ "$(git rev-parse origin/stable)" != "$PRE_UPGRADE_COMMIT" ] && ! git merge-base --is-ancestor HEAD origin/stable 2>/dev/null \
|
||||
&& { git merge-base --is-ancestor origin/stable HEAD 2>/dev/null || [ "$(printf '%s\n%s\n' "$(git show origin/stable:VERSION)" "$(cat VERSION)" | sort -V | tail -1)" = "$(cat VERSION)" ]; }; then echo "AHEAD_OF_STABLE"; exit 0; fi
|
||||
FF=$(bash bin/gstack-ff-update . "$UPSTREAM_REF"); FF_RC=$?
|
||||
echo "$FF"
|
||||
case "$FF_RC" in
|
||||
0) if ./setup --refresh-registered; then echo "FF_OK"; else echo "SETUP_FAILED: git update succeeded; stop and inspect setup output (previous commit: $PRE_UPGRADE_COMMIT)" >&2; exit 1; fi ;;
|
||||
@@ -164,6 +171,7 @@ their conflicting local edits were saved there and relay the `Inspect`,
|
||||
checkout with `git apply -3`, never `stash apply` in the install;
|
||||
`unmerged=1` means the rescue needs manual resolution).
|
||||
On `FF_STOPPED`, STOP and relay the reason: nothing was changed.
|
||||
On `AHEAD_OF_STABLE`, STOP: the checkout is already past the weekly release, so nothing changes until the next cut (`gstack-config set update_channel main` follows every merge). On a `fetching origin/stable failed` line above, STOP and relay it; nothing was changed.
|
||||
`ff-busy` means another gstack update is running (retry in a minute);
|
||||
`autostash-unrecoverable` names what is in the way (an untracked file the
|
||||
release adds, or another git command); `index-unreadable` means the checkout's
|
||||
@@ -176,7 +184,7 @@ On `SETUP_FAILED`, STOP; keep user changes and report the recovery commit. There
|
||||
--hard` DESTROYS things: a clean tree with unpushed local commits still loses
|
||||
those commits. Gate it:
|
||||
|
||||
1. Run `git status --porcelain` and `git rev-list origin/main..HEAD --oneline`
|
||||
1. Run `git status --porcelain` and `git rev-list "${UPSTREAM_REF:?}"..HEAD --oneline`
|
||||
in `$INSTALL_DIR`.
|
||||
2. If BOTH are empty, the reset is provably safe — run the fallback block
|
||||
below without asking.
|
||||
@@ -193,7 +201,7 @@ cd -- "${INSTALL_DIR:?INSTALL_DIR is not set: re-run Step 2 and substitute the p
|
||||
# Regenerable render-footprint dirt (setup re-renders it) never goes into the stash.
|
||||
git checkout -- 'SKILL.md' '*/SKILL.md' '*/sections/*.md' 2>/dev/null || true
|
||||
STASH_OUTPUT=$(git stash 2>&1)
|
||||
git reset --hard origin/main
|
||||
git reset --hard "${UPSTREAM_REF:?UPSTREAM_REF is not set: substitute the value Step 4 printed}"
|
||||
./setup --refresh-registered
|
||||
```
|
||||
If `$STASH_OUTPUT` contains "Saved working directory", warn the user: "Note: local changes were stashed (any modified generated SKILL.md/sections files were discarded first — they regenerate on setup). Run `git stash pop` in the skill directory to restore your own changes."
|
||||
@@ -280,40 +288,30 @@ Only on `LOCAL_SYNC_OK`, tell user: "Also updated vendored copy at `$LOCAL_GSTAC
|
||||
|
||||
### Step 4.75: Run version migrations
|
||||
|
||||
After `./setup` completes, run any migration scripts for versions between the old
|
||||
and new version. Migrations handle state fixes that `./setup` alone can't cover
|
||||
(stale config, orphaned files, directory structure changes).
|
||||
After `./setup`, run the migrations in `gstack-upgrade/migrations/` (idempotent
|
||||
`v{VERSION}.sh` scripts; CONTRIBUTING.md says how to add one) newer than the old
|
||||
version: they fix state `./setup` alone cannot (stale config, orphaned files).
|
||||
|
||||
```bash
|
||||
MIGRATIONS_DIR="${INSTALL_DIR:?INSTALL_DIR is not set: re-run Step 2 and substitute the printed path}/gstack-upgrade/migrations"
|
||||
: "${OLD_VERSION:?OLD_VERSION is not set: substitute the value Step 3 printed}"
|
||||
if [ -d "$MIGRATIONS_DIR" ]; then
|
||||
for migration in $(find "$MIGRATIONS_DIR" -maxdepth 1 -name 'v*.sh' -type f 2>/dev/null | sort -V); do
|
||||
# Extract version from filename: v0.15.2.0.sh → 0.15.2.0
|
||||
m_ver="$(basename "$migration" .sh | sed 's/^v//')"
|
||||
# Run if this migration version is newer than old version
|
||||
# (simple string compare works for dotted versions with same segment count)
|
||||
if [ "$OLD_VERSION" != "unknown" ] && [ "$(printf '%s\n%s' "$OLD_VERSION" "$m_ver" | sort -V | head -1)" = "$OLD_VERSION" ] && [ "$OLD_VERSION" != "$m_ver" ]; then
|
||||
echo "Running migration $m_ver..."
|
||||
# GSTACK_INSTALL_DIR: migrations that clean the INSTALL (not just
|
||||
# ~/.gstack state) default to ~/.claude/skills/gstack when unset —
|
||||
# a repo-local install would silently no-op without this.
|
||||
# Without GSTACK_INSTALL_DIR a migration that cleans the install assumes ~/.claude/skills/gstack.
|
||||
GSTACK_INSTALL_DIR="$INSTALL_DIR" bash "$migration" || echo " Warning: migration $m_ver had errors (non-fatal)"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
```
|
||||
|
||||
Migrations are idempotent bash scripts in `gstack-upgrade/migrations/`. Each is named
|
||||
`v{VERSION}.sh` and runs only when upgrading from an older version. See CONTRIBUTING.md
|
||||
for how to add new migrations.
|
||||
|
||||
### Step 4.8: Stop any stale daemon (unconditional)
|
||||
|
||||
A browse daemon started before the upgrade keeps serving the OLD binary's code
|
||||
until it is stopped — it survives `git reset --hard` and `./setup` because the
|
||||
running process holds the old executable. Always run this step, using
|
||||
the install directory detected in Step 2.
|
||||
A browse daemon started before the upgrade keeps running the old binary (the
|
||||
reset and `./setup` do not stop it), so always run this step with the install
|
||||
directory from Step 2.
|
||||
|
||||
```bash
|
||||
INSTALL_DIR_PLACEHOLDER="<install dir from Step 2>"
|
||||
@@ -338,8 +336,7 @@ else
|
||||
fi
|
||||
```
|
||||
|
||||
Replace `<install dir from Step 2>` with the actual install directory before
|
||||
running. Interpret the `DAEMON_CHECK` result:
|
||||
Substitute the install directory from Step 2, then act on `DAEMON_CHECK`:
|
||||
|
||||
1. **`stale-responsive` + `DAEMON_STOPPED=yes`:** Tell the user "Stopped the
|
||||
old browse daemon (binary {OLD_HASH} → {NEW_HASH}). The next browse command
|
||||
@@ -350,7 +347,7 @@ running. Interpret the `DAEMON_CHECK` result:
|
||||
busy right now. When it finishes, stop it with `browse stop` — or force it
|
||||
immediately with `browse --force-restart stop` (loses that session's
|
||||
tabs/cookies/logins)."
|
||||
3. **`none` / `dead` / `current`:** Nothing to do — say nothing.
|
||||
3. **`none` / `dead` / `current`:** say nothing.
|
||||
|
||||
### Step 5: Write marker + clear cache
|
||||
|
||||
@@ -364,7 +361,7 @@ rm -f "$GSTACK_STATE_ROOT"/update-snoozed
|
||||
|
||||
### Step 6: Show What's New
|
||||
|
||||
Read `$INSTALL_DIR/CHANGELOG.md`. Find all version entries between the old version and the new version. Summarize as 5-7 bullets grouped by theme. Don't overwhelm — focus on user-facing changes. Skip internal refactors unless they're significant.
|
||||
Read `$INSTALL_DIR/CHANGELOG.md`. Find all version entries between the old version and the new version. Summarize as 5-7 bullets grouped by theme, user-facing changes first.
|
||||
|
||||
Four fields: summarize `git -C "$INSTALL_DIR" log --oneline <PRE_UPGRADE_COMMIT>..HEAD` (≤7 bullets) as "gstack v{old}, main at {sha}".
|
||||
|
||||
@@ -382,7 +379,7 @@ Happy shipping!
|
||||
|
||||
### Step 7: Continue
|
||||
|
||||
After showing What's New, continue with whatever skill the user originally invoked. The upgrade is done — no further action needed.
|
||||
Continue with the skill the user originally invoked; the upgrade is done.
|
||||
|
||||
---
|
||||
|
||||
@@ -395,7 +392,6 @@ When invoked directly as `/gstack-upgrade` (not from a preamble):
|
||||
~/.claude/skills/gstack/bin/gstack-update-check --force 2>/dev/null || \
|
||||
.claude/skills/gstack/bin/gstack-update-check --force 2>/dev/null || true
|
||||
```
|
||||
Use the output to determine if an upgrade is available.
|
||||
|
||||
2. If `UPGRADE_AVAILABLE` (three or four fields): follow Steps 2-6 above.
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ echo "Install type: $INSTALL_TYPE at $INSTALL_DIR"
|
||||
```
|
||||
|
||||
The install type and the absolute directory printed above are used in all subsequent steps.
|
||||
Carry `INSTALL_TYPE`, `INSTALL_DIR`, `OLD_VERSION`, and later `NEW_VERSION` forward explicitly: tool calls may run in fresh shells, so start each later block by assigning them from the captured output (for example `INSTALL_DIR=/abs/path/printed/above`). Do not rely on a prior call's working directory or shell variables. Every block that changes files first checks `INSTALL_DIR` and stops before any git command when it is unset, empty, missing, unreadable, or not gstack's own checkout.
|
||||
Carry `INSTALL_TYPE`, `INSTALL_DIR`, `OLD_VERSION`, and later `UPSTREAM_REF` and `NEW_VERSION` forward explicitly: tool calls may run in fresh shells, so start each later block by assigning them from the captured output (for example `INSTALL_DIR=/abs/path/printed/above`). Do not rely on a prior call's working directory or shell variables. Every block that changes files first checks `INSTALL_DIR` and stops before any git command when it is unset, empty, missing, unreadable, or not gstack's own checkout.
|
||||
|
||||
### Step 3: Save old version
|
||||
|
||||
@@ -141,12 +141,19 @@ is saved in a rescue ref instead of being left as conflict markers:
|
||||
cd -- "${INSTALL_DIR:?INSTALL_DIR is not set: re-run Step 2 and substitute the printed path}" || exit 1
|
||||
{ [ -f VERSION ] && [ -f setup ] && [ -f bin/gstack-config ] && _p=$(git rev-parse --show-prefix 2>/dev/null) && [ -z "$_p" ] && [ "$(git rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; } || { echo "ERROR: $INSTALL_DIR is not a gstack checkout; nothing was changed. Re-run Step 2." >&2; exit 1; }
|
||||
git fetch origin
|
||||
# Channel ref (docs/releases.md): origin/stable for a team install, else origin/main
|
||||
_CH=$(bash bin/gstack-update-channel.sh --fetch-ref 2>&1) || _CH=origin/main
|
||||
echo "$_CH"; UPSTREAM_REF=$(printf '%s\n' "$_CH" | tail -1)
|
||||
echo "UPSTREAM_REF=${UPSTREAM_REF:=origin/main}"
|
||||
# Same pre-advance check as the auto-updater: the incoming release's Bun floor,
|
||||
# judged by bash whatever this tool shell is (zsh does not word-split).
|
||||
HOLD=$(bash -c '. bin/gstack-bun-version.sh && gstack_bun_incoming_hold . origin/main' 2>/dev/null) && { case "$HOLD" in bun-version-unreadable*) echo "BUN_VERSION_UNREADABLE: $HOLD; nothing was changed" >&2 ;; *) echo "BUN_TOO_OLD: $HOLD; nothing was changed" >&2 ;; esac; exit 1; }
|
||||
HOLD=$(UPSTREAM_REF="$UPSTREAM_REF" bash -c '. bin/gstack-bun-version.sh && gstack_bun_incoming_hold . "$UPSTREAM_REF"' 2>/dev/null) && { case "$HOLD" in bun-version-unreadable*) echo "BUN_VERSION_UNREADABLE: $HOLD; nothing was changed" >&2 ;; *) echo "BUN_TOO_OLD: $HOLD; nothing was changed" >&2 ;; esac; exit 1; }
|
||||
PRE_UPGRADE_COMMIT=$(git rev-parse HEAD)
|
||||
echo "PRE_UPGRADE_COMMIT=$PRE_UPGRADE_COMMIT"
|
||||
FF=$(bash bin/gstack-ff-update . origin/main); FF_RC=$?
|
||||
# Past the release (by ancestry, or by VERSION in a depth-1 clone): wait for stable; nothing moves back.
|
||||
if [ "$UPSTREAM_REF" = origin/stable ] && [ "$(git rev-parse origin/stable)" != "$PRE_UPGRADE_COMMIT" ] && ! git merge-base --is-ancestor HEAD origin/stable 2>/dev/null \
|
||||
&& { git merge-base --is-ancestor origin/stable HEAD 2>/dev/null || [ "$(printf '%s\n%s\n' "$(git show origin/stable:VERSION)" "$(cat VERSION)" | sort -V | tail -1)" = "$(cat VERSION)" ]; }; then echo "AHEAD_OF_STABLE"; exit 0; fi
|
||||
FF=$(bash bin/gstack-ff-update . "$UPSTREAM_REF"); FF_RC=$?
|
||||
echo "$FF"
|
||||
case "$FF_RC" in
|
||||
0) if {{SETUP_COMMAND}} --refresh-registered; then echo "FF_OK"; else echo "SETUP_FAILED: git update succeeded; stop and inspect setup output (previous commit: $PRE_UPGRADE_COMMIT)" >&2; exit 1; fi ;;
|
||||
@@ -170,6 +177,7 @@ their conflicting local edits were saved there and relay the `Inspect`,
|
||||
checkout with `git apply -3`, never `stash apply` in the install;
|
||||
`unmerged=1` means the rescue needs manual resolution).
|
||||
On `FF_STOPPED`, STOP and relay the reason: nothing was changed.
|
||||
On `AHEAD_OF_STABLE`, STOP: the checkout is already past the weekly release, so nothing changes until the next cut (`gstack-config set update_channel main` follows every merge). On a `fetching origin/stable failed` line above, STOP and relay it; nothing was changed.
|
||||
`ff-busy` means another gstack update is running (retry in a minute);
|
||||
`autostash-unrecoverable` names what is in the way (an untracked file the
|
||||
release adds, or another git command); `index-unreadable` means the checkout's
|
||||
@@ -182,7 +190,7 @@ On `SETUP_FAILED`, STOP; keep user changes and report the recovery commit. There
|
||||
--hard` DESTROYS things: a clean tree with unpushed local commits still loses
|
||||
those commits. Gate it:
|
||||
|
||||
1. Run `git status --porcelain` and `git rev-list origin/main..HEAD --oneline`
|
||||
1. Run `git status --porcelain` and `git rev-list "${UPSTREAM_REF:?}"..HEAD --oneline`
|
||||
in `$INSTALL_DIR`.
|
||||
2. If BOTH are empty, the reset is provably safe — run the fallback block
|
||||
below without asking.
|
||||
@@ -199,7 +207,7 @@ cd -- "${INSTALL_DIR:?INSTALL_DIR is not set: re-run Step 2 and substitute the p
|
||||
# Regenerable render-footprint dirt (setup re-renders it) never goes into the stash.
|
||||
git checkout -- 'SKILL.md' '*/SKILL.md' '*/sections/*.md' 2>/dev/null || true
|
||||
STASH_OUTPUT=$(git stash 2>&1)
|
||||
git reset --hard origin/main
|
||||
git reset --hard "${UPSTREAM_REF:?UPSTREAM_REF is not set: substitute the value Step 4 printed}"
|
||||
{{SETUP_COMMAND}} --refresh-registered
|
||||
```
|
||||
If `$STASH_OUTPUT` contains "Saved working directory", warn the user: "Note: local changes were stashed (any modified generated SKILL.md/sections files were discarded first — they regenerate on setup). Run `git stash pop` in the skill directory to restore your own changes."
|
||||
@@ -286,40 +294,30 @@ Only on `LOCAL_SYNC_OK`, tell user: "Also updated vendored copy at `$LOCAL_GSTAC
|
||||
|
||||
### Step 4.75: Run version migrations
|
||||
|
||||
After `./setup` completes, run any migration scripts for versions between the old
|
||||
and new version. Migrations handle state fixes that `./setup` alone can't cover
|
||||
(stale config, orphaned files, directory structure changes).
|
||||
After `./setup`, run the migrations in `gstack-upgrade/migrations/` (idempotent
|
||||
`v{VERSION}.sh` scripts; CONTRIBUTING.md says how to add one) newer than the old
|
||||
version: they fix state `./setup` alone cannot (stale config, orphaned files).
|
||||
|
||||
```bash
|
||||
MIGRATIONS_DIR="${INSTALL_DIR:?INSTALL_DIR is not set: re-run Step 2 and substitute the printed path}/gstack-upgrade/migrations"
|
||||
: "${OLD_VERSION:?OLD_VERSION is not set: substitute the value Step 3 printed}"
|
||||
if [ -d "$MIGRATIONS_DIR" ]; then
|
||||
for migration in $(find "$MIGRATIONS_DIR" -maxdepth 1 -name 'v*.sh' -type f 2>/dev/null | sort -V); do
|
||||
# Extract version from filename: v0.15.2.0.sh → 0.15.2.0
|
||||
m_ver="$(basename "$migration" .sh | sed 's/^v//')"
|
||||
# Run if this migration version is newer than old version
|
||||
# (simple string compare works for dotted versions with same segment count)
|
||||
if [ "$OLD_VERSION" != "unknown" ] && [ "$(printf '%s\n%s' "$OLD_VERSION" "$m_ver" | sort -V | head -1)" = "$OLD_VERSION" ] && [ "$OLD_VERSION" != "$m_ver" ]; then
|
||||
echo "Running migration $m_ver..."
|
||||
# GSTACK_INSTALL_DIR: migrations that clean the INSTALL (not just
|
||||
# ~/.gstack state) default to ~/.claude/skills/gstack when unset —
|
||||
# a repo-local install would silently no-op without this.
|
||||
# Without GSTACK_INSTALL_DIR a migration that cleans the install assumes ~/.claude/skills/gstack.
|
||||
GSTACK_INSTALL_DIR="$INSTALL_DIR" bash "$migration" || echo " Warning: migration $m_ver had errors (non-fatal)"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
```
|
||||
|
||||
Migrations are idempotent bash scripts in `gstack-upgrade/migrations/`. Each is named
|
||||
`v{VERSION}.sh` and runs only when upgrading from an older version. See CONTRIBUTING.md
|
||||
for how to add new migrations.
|
||||
|
||||
### Step 4.8: Stop any stale daemon (unconditional)
|
||||
|
||||
A browse daemon started before the upgrade keeps serving the OLD binary's code
|
||||
until it is stopped — it survives `git reset --hard` and `./setup` because the
|
||||
running process holds the old executable. Always run this step, using
|
||||
the install directory detected in Step 2.
|
||||
A browse daemon started before the upgrade keeps running the old binary (the
|
||||
reset and `./setup` do not stop it), so always run this step with the install
|
||||
directory from Step 2.
|
||||
|
||||
```bash
|
||||
INSTALL_DIR_PLACEHOLDER="<install dir from Step 2>"
|
||||
@@ -344,8 +342,7 @@ else
|
||||
fi
|
||||
```
|
||||
|
||||
Replace `<install dir from Step 2>` with the actual install directory before
|
||||
running. Interpret the `DAEMON_CHECK` result:
|
||||
Substitute the install directory from Step 2, then act on `DAEMON_CHECK`:
|
||||
|
||||
1. **`stale-responsive` + `DAEMON_STOPPED=yes`:** Tell the user "Stopped the
|
||||
old browse daemon (binary {OLD_HASH} → {NEW_HASH}). The next browse command
|
||||
@@ -356,7 +353,7 @@ running. Interpret the `DAEMON_CHECK` result:
|
||||
busy right now. When it finishes, stop it with `browse stop` — or force it
|
||||
immediately with `browse --force-restart stop` (loses that session's
|
||||
tabs/cookies/logins)."
|
||||
3. **`none` / `dead` / `current`:** Nothing to do — say nothing.
|
||||
3. **`none` / `dead` / `current`:** say nothing.
|
||||
|
||||
### Step 5: Write marker + clear cache
|
||||
|
||||
@@ -370,7 +367,7 @@ rm -f "$GSTACK_STATE_ROOT"/update-snoozed
|
||||
|
||||
### Step 6: Show What's New
|
||||
|
||||
Read `$INSTALL_DIR/CHANGELOG.md`. Find all version entries between the old version and the new version. Summarize as 5-7 bullets grouped by theme. Don't overwhelm — focus on user-facing changes. Skip internal refactors unless they're significant.
|
||||
Read `$INSTALL_DIR/CHANGELOG.md`. Find all version entries between the old version and the new version. Summarize as 5-7 bullets grouped by theme, user-facing changes first.
|
||||
|
||||
Four fields: summarize `git -C "$INSTALL_DIR" log --oneline <PRE_UPGRADE_COMMIT>..HEAD` (≤7 bullets) as "gstack v{old}, main at {sha}".
|
||||
|
||||
@@ -388,7 +385,7 @@ Happy shipping!
|
||||
|
||||
### Step 7: Continue
|
||||
|
||||
After showing What's New, continue with whatever skill the user originally invoked. The upgrade is done — no further action needed.
|
||||
Continue with the skill the user originally invoked; the upgrade is done.
|
||||
|
||||
---
|
||||
|
||||
@@ -401,7 +398,6 @@ When invoked directly as `/gstack-upgrade` (not from a preamble):
|
||||
~/.claude/skills/gstack/bin/gstack-update-check --force 2>/dev/null || \
|
||||
.claude/skills/gstack/bin/gstack-update-check --force 2>/dev/null || true
|
||||
```
|
||||
Use the output to determine if an upgrade is available.
|
||||
|
||||
2. If `UPGRADE_AVAILABLE` (three or four fields): follow Steps 2-6 above.
|
||||
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
# shared old renders are retired. This post-setup pass repairs missed upgrades.
|
||||
# Idempotent and non-fatal: foreign entries and failed replacements stay intact.
|
||||
set -u
|
||||
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
|
||||
INSTALL_DIR="${GSTACK_INSTALL_DIR:-$HOME/.claude/skills/gstack}"
|
||||
if [ -f "$INSTALL_DIR/bin/gstack-migrate-claude-code" ] && command -v bun >/dev/null 2>&1; then
|
||||
bun --no-env-file --config=/dev/null "$INSTALL_DIR/bin/gstack-migrate-claude-code" --install-dir "$INSTALL_DIR" || true
|
||||
bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$INSTALL_DIR/bin/gstack-migrate-claude-code" --install-dir "$INSTALL_DIR" || true
|
||||
fi
|
||||
exit 0
|
||||
@@ -3,5 +3,6 @@
|
||||
# wrapper makes the TypeScript hook executable via bun. Settings.json
|
||||
# references this file directly.
|
||||
set -e
|
||||
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
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
exec bun --no-env-file --config=/dev/null "$HERE/auq-error-fallback-hook.ts"
|
||||
exec bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$HERE/auq-error-fallback-hook.ts"
|
||||
@@ -152,8 +152,11 @@ export function directiveFor(kind: SessionKind): string {
|
||||
'SESSION_KIND=unattended — auto-choose the `(recommended)` option and append it to decisions.jsonl ' +
|
||||
'with kind "auto"; do not emit prose, do not BLOCK. A question with no recommendation, a consent, ' +
|
||||
'a destructive or irreversible option, or an approval gate is appended with kind "approval" and status ' +
|
||||
'"pending" — never chosen, never approved — and the skill ends at its next gate with ' +
|
||||
'GSTACK_RESULT status=gate_pending (Unattended session block).'
|
||||
'"pending" — never chosen, never approved — and the skill ends at its next gate. Only /autoplan and ' +
|
||||
'/eval-plan print `GSTACK_RESULT: skill=<name> status=gate_pending run=<dir>` there, and /ship prints its ' +
|
||||
'own line at Step 22 (`gstack-ship-receipt result`); every other skill prints no GSTACK_RESULT line of its own ' +
|
||||
'(only a refused install check prints `status=refused run=-` for any skill), so do not invent one ' +
|
||||
'(Unattended session block; docs/unattended.md names each skill\'s record).'
|
||||
);
|
||||
case 'headless':
|
||||
// #2733 review (multi-specialist): a spawned-marked subagent under a
|
||||
|
||||
@@ -12,8 +12,9 @@
|
||||
# vendor process running with the prompt on its stdin. Forwarded, the signal
|
||||
# reaches the .ts, which kills the vendor's process group on its way out.
|
||||
# `<&0` keeps stdin: bash hands background jobs /dev/null otherwise.
|
||||
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
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)" || exit 0
|
||||
bun --no-env-file --config=/dev/null "$HERE/memorable-user-prompt-hook.ts" <&0 &
|
||||
bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$HERE/memorable-user-prompt-hook.ts" <&0 &
|
||||
_child=$!
|
||||
trap 'kill -TERM "$_child" 2>/dev/null' TERM INT HUP
|
||||
wait "$_child" 2>/dev/null || wait "$_child" 2>/dev/null || true
|
||||
|
||||
@@ -3,5 +3,6 @@
|
||||
# wrapper makes the TypeScript hook executable via bun. Settings.json
|
||||
# references this file directly.
|
||||
set -e
|
||||
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
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
exec bun --no-env-file --config=/dev/null "$HERE/question-log-hook.ts"
|
||||
exec bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$HERE/question-log-hook.ts"
|
||||
@@ -3,5 +3,6 @@
|
||||
# wrapper makes the TypeScript hook executable via bun. Settings.json
|
||||
# references this file directly.
|
||||
set -e
|
||||
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
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
exec bun --no-env-file --config=/dev/null "$HERE/question-preference-hook.ts"
|
||||
exec bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$HERE/question-preference-hook.ts"
|
||||
@@ -5,6 +5,7 @@
|
||||
#
|
||||
# FAIL-OPEN (F5): a Stop-event telemetry repair must never block the session.
|
||||
# Every failure path — bun missing, script crash — still exits 0.
|
||||
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
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)" || exit 0
|
||||
bun --no-env-file --config=/dev/null "$HERE/timeline-stop-hook.ts" || true
|
||||
bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$HERE/timeline-stop-hook.ts" || true
|
||||
exit 0
|
||||
@@ -4,10 +4,12 @@
|
||||
* variables, and a `bunfig.toml` preload runs code before the entry point.
|
||||
* gstack runs inside the user's repository, so that directory belongs to the
|
||||
* code under review. Every Bun gstack starts passes --no-env-file and an empty
|
||||
* --config: shebangs and shell callers spell it
|
||||
* `bun --no-env-file --config=/dev/null`, TypeScript spawns use
|
||||
* bunIsolationArgs(). test/bun-entry-isolation.test.ts holds every entry point
|
||||
* to that form.
|
||||
* --config: shebangs and skill prose spell it
|
||||
* `bun --no-env-file --config=/dev/null`, shipped shell files spell
|
||||
* `--config="${GSTACK_BUN_CONFIG:-/dev/null}"` (NUL when OSTYPE is msys or
|
||||
* cygwin and cygpath is on PATH, #3415), TypeScript spawns use
|
||||
* bunIsolationArgs().
|
||||
* test/bun-entry-isolation.test.ts holds every entry point to that form.
|
||||
*
|
||||
* POSIX passes /dev/null. Native Windows has no /dev/null and `--config=NUL`
|
||||
* panicked Bun 1.3.10 while parsing the flag (#3085), so a TypeScript spawn on
|
||||
|
||||
+37
-3
@@ -103,8 +103,42 @@ export function checkCraft(html: string): CraftFinding[] {
|
||||
return findings;
|
||||
}
|
||||
|
||||
export function formatCraft(findings: CraftFinding[]): string {
|
||||
const lines = findings.map(f => `CRAFT_CHECK: ${f.id} ${f.count} ${f.fix}`);
|
||||
lines.push(findings.length === 0 ? 'CRAFT_RESULT: clean' : `CRAFT_RESULT: ${findings.length} issue(s)`);
|
||||
/** An accepted finding recorded in finalized.json's `craft_exceptions`. */
|
||||
export interface CraftException {
|
||||
id: string;
|
||||
count: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** Reads `craft_exceptions` from a finalized.json; a record without one has none. Throws on a malformed record. */
|
||||
export function parseCraftExceptions(json: string): CraftException[] {
|
||||
const record = JSON.parse(json);
|
||||
if (typeof record !== 'object' || record === null || Array.isArray(record)) throw new Error('not a finalized.json object');
|
||||
const list = record.craft_exceptions ?? [];
|
||||
const valid = (e: any) => typeof e?.id === 'string' && Number.isInteger(e.count) && e.count > 0 && typeof e.reason === 'string' && e.reason.trim() !== '';
|
||||
if (!Array.isArray(list) || !list.every(valid)) throw new Error('craft_exceptions must be a list of { id, count, reason } with a positive count and a reason');
|
||||
return list.map(({ id, count, reason }) => ({ id, count, reason: reason.replace(/\s+/g, ' ').trim() }));
|
||||
}
|
||||
|
||||
/** A finding is accepted when an exception names its id with at least its count; a higher count stays an issue and says how many were accepted. */
|
||||
export function acceptFindings(findings: CraftFinding[], exceptions: CraftException[]): { issues: CraftFinding[]; accepted: CraftException[] } {
|
||||
const issues: CraftFinding[] = [];
|
||||
const accepted: CraftException[] = [];
|
||||
for (const finding of findings) {
|
||||
const exception = exceptions.filter(e => e.id === finding.id).sort((a, b) => b.count - a.count)[0];
|
||||
if (!exception) issues.push(finding);
|
||||
else if (finding.count <= exception.count) accepted.push({ id: finding.id, count: finding.count, reason: exception.reason });
|
||||
else issues.push({ ...finding, fix: `${finding.fix} (${exception.count} accepted earlier: ${exception.reason})` });
|
||||
}
|
||||
return { issues, accepted };
|
||||
}
|
||||
|
||||
export function formatCraft(findings: CraftFinding[], accepted: CraftException[] = []): string {
|
||||
const lines = [
|
||||
...findings.map(f => `CRAFT_CHECK: ${f.id} ${f.count} ${f.fix}`),
|
||||
...accepted.map(a => `CRAFT_ACCEPTED: ${a.id} ${a.count} ${a.reason}`),
|
||||
];
|
||||
const note = accepted.length === 0 ? '' : findings.length === 0 ? ` (${accepted.length} accepted)` : `, ${accepted.length} accepted`;
|
||||
lines.push(`${findings.length === 0 ? 'CRAFT_RESULT: clean' : `CRAFT_RESULT: ${findings.length} issue(s)`}${note}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -141,7 +141,11 @@ export function verdictProblem(skill: string, gstackRoot: string, env: Env, bunP
|
||||
const file = findInstallVerdict(src, project, env);
|
||||
const v = readInstallVerdict(file);
|
||||
const problem = (detail: string): VerdictProblem => ({ detail, code: 'INSTALL_UNVERIFIED' });
|
||||
if (!v) return problem(`no install verdict at ${file} for ${src}`);
|
||||
if (!v && fs.existsSync(file)) return problem(`the install verdict at ${file} is unreadable or has an unsupported schema_version; rerun the installer`);
|
||||
if (!v) {
|
||||
return { ...problem(`no install verdict at ${file} for ${src}`),
|
||||
fix: `run the Capy skill installer (scripts/install.sh --for ${skill}) from ${project ?? 'this directory'}: the verdict is keyed by the project directory the installer runs in, so a run from the skill's own folder does not cover ${project ?? 'it'}; then launch with the GSTACK_LAUNCH line it prints` };
|
||||
}
|
||||
if (!v.ok) return problem(`the install verdict says: ${v.trailer}`);
|
||||
if (v.checkout !== src) return problem(`the install verdict is for checkout ${v.checkout}, not ${src}`);
|
||||
const head = spawnSync('git', ['-C', src, 'rev-parse', 'HEAD'], { encoding: 'utf8', timeout: 10_000 }).stdout?.trim() ?? '';
|
||||
|
||||
@@ -132,6 +132,22 @@ export const RELEASE_RESULT_CODES = {
|
||||
summary: 'a train stopped partway through publication, so main holds a prefix no gate covered on its own; ordinary trains are refused until a repair lands',
|
||||
fix: 'admit one repair PR at its exact head (train build --repair --prs <n>@<sha>); the freeze clears when verify finds main’s tree after that merge in a green record',
|
||||
},
|
||||
// CREW rule 22 (Garry, 2026-10-25): /ship runs before every READY; the queue refuses a PR whose body has no current receipt.
|
||||
QUEUE_RECEIPT_MISSING: {
|
||||
anchor: 'queue-receipt-missing',
|
||||
summary: 'the PR body carries no valid /ship receipt, so the PR was never shipped through gstack and cannot be READY’d, admitted to a train or merged',
|
||||
fix: 'run /ship on the branch (unattended it writes the receipt into an open PR at HEAD with gstack-ship-receipt write --pr <n> --body …, else leaves it in .gstack/tmp/ship-receipt.md for the parent to post), then gstack-ship-queue ready again; there is no flag to skip this',
|
||||
},
|
||||
QUEUE_RECEIPT_STALE: {
|
||||
anchor: 'queue-receipt-stale',
|
||||
summary: 'the PR body’s /ship receipt is for another head than the one being READY’d or merged (a push after /ship, or a receipt copied from another PR)',
|
||||
fix: 'rerun /ship on the current head so the receipt names it, then gstack-ship-queue ready again; a slot’s stamp commit keeps the receipt written for its pinned head',
|
||||
},
|
||||
QUEUE_SCREEN_STALE_REF: {
|
||||
anchor: 'queue-screen-stale-ref',
|
||||
summary: 'GitHub’s refs/pull/<n>/merge is missing or still merges an older head than the PR’s: the PR conflicts with the base (a CHANGELOG-only conflict counts), so a screen dispatched on it would plan no cases and report success',
|
||||
fix: 'resolve the conflict with the base branch (or wait for GitHub to recompute the merge ref), push, then gstack-ship-queue ready --pr <n> --screen again; a lane never READYs on a screen that planned nothing',
|
||||
},
|
||||
READY_SCREEN_CHOICE_REQUIRED: {
|
||||
anchor: 'ready-screen-choice-required',
|
||||
summary: 'the base policy turns trains on, so READY needs an explicit screen choice: --screen (pay for a diff-relevant screen now) or --no-screen',
|
||||
|
||||
@@ -5,11 +5,12 @@
|
||||
* full-gate check run. Dispatch itself goes through scripts/eval-bg.ts.
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import type { Exec, RunSummary } from '../scripts/eval-bg';
|
||||
import type { QueueContext } from './ship-queue';
|
||||
|
||||
export interface PrInfo { headRefOid: string; isCrossRepository: boolean; state: string; headRefName: string }
|
||||
export interface PrInfo { headRefOid: string; isCrossRepository: boolean; state: string; headRefName: string; body: string }
|
||||
export interface RunInfo { databaseId: number; status: string; conclusion: string | null; url: string; headSha: string; event: string; displayTitle: string }
|
||||
export interface CheckInfo { status: string; conclusion: string | null }
|
||||
|
||||
@@ -33,10 +34,10 @@ export function repoSlug(ctx: QueueContext): string | null {
|
||||
}
|
||||
|
||||
export function prInfo(ctx: QueueContext, pr: string): PrInfo | { error: string } {
|
||||
const r = ctx.gh(['pr', 'view', pr, '--json', 'headRefOid,isCrossRepository,state,headRefName']);
|
||||
const r = ctx.gh(['pr', 'view', pr, '--json', 'headRefOid,isCrossRepository,state,headRefName,body']);
|
||||
const p = json<Partial<PrInfo>>(r);
|
||||
if (!p?.headRefOid) return { error: `cannot read PR #${pr} through gh (${(r.err ?? r.out).trim().slice(0, 120) || 'no headRefOid'})` };
|
||||
return { headRefOid: p.headRefOid, isCrossRepository: p.isCrossRepository !== false, state: p.state ?? 'OPEN', headRefName: p.headRefName ?? '' };
|
||||
return { headRefOid: p.headRefOid, isCrossRepository: p.isCrossRepository !== false, state: p.state ?? 'OPEN', headRefName: p.headRefName ?? '', body: p.body ?? '' };
|
||||
}
|
||||
|
||||
const RUN_FIELDS = 'databaseId,status,conclusion,url,headSha,event,displayTitle';
|
||||
@@ -56,18 +57,49 @@ export function asRun(summary: RunSummary): { id: number; url: string } {
|
||||
return { id: summary.databaseId, url: summary.url };
|
||||
}
|
||||
|
||||
/** One JSON artifact of a run (the first .json file it holds); null when it is missing or unreadable. */
|
||||
export function downloadJson(ctx: QueueContext, slug: string, run: number, name: string, scratch: string): unknown {
|
||||
/** One JSON artifact of a run (the named file, else the first .json file it holds); null when it is missing or unreadable. */
|
||||
export function downloadJson(ctx: QueueContext, slug: string, run: number, name: string, scratch: string, file?: string): unknown {
|
||||
const dir = path.join(scratch, `${run}-${name}`);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
try {
|
||||
if (!ctx.gh(['run', 'download', String(run), '--repo', slug, '-n', name, '-D', dir]).ok) return null;
|
||||
const file = fs.readdirSync(dir).find(f => f.endsWith('.json'));
|
||||
return file ? JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8')) : null;
|
||||
const pick = file ?? fs.readdirSync(dir).find(f => f.endsWith('.json'));
|
||||
return pick && fs.existsSync(path.join(dir, pick)) ? JSON.parse(fs.readFileSync(path.join(dir, pick), 'utf8')) : null;
|
||||
} catch { return null; } finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
/**
|
||||
* A finished screen run that planned no cases is not a screen: when the merge
|
||||
* ref it was dispatched for was superseded, evals.yml skips plan-slices and
|
||||
* concludes success (GSTA-95 on #3431). A screen run uploads no train record,
|
||||
* so the evidence is the plan itself: no `paid-plan` artifact, or a manifest
|
||||
* with nothing planned. `hollow` carries the reason; `live` is a screen that
|
||||
* planned cases or is still running; `unknown` names what could not be read
|
||||
* (the caller decides, and says, how it counts).
|
||||
*/
|
||||
export type ScreenPlan = { kind: 'hollow'; reason: string } | { kind: 'live' } | { kind: 'unknown'; reason: string };
|
||||
export function hollowScreen(ctx: QueueContext, slug: string, run: number): ScreenPlan {
|
||||
if (!Number.isSafeInteger(run) || run <= 0) return { kind: 'unknown', reason: `screen run id ${String(run).slice(0, 40)} is not a run number` };
|
||||
const view = runView(ctx, slug, run);
|
||||
if (!view) return { kind: 'unknown', reason: `run ${run} is unreadable` };
|
||||
if (view.status !== 'completed') return { kind: 'live' };
|
||||
const list = json<{ artifacts?: Array<{ name?: string; expired?: boolean }> }>(ctx.gh(['api', `repos/${slug}/actions/runs/${run}/artifacts?name=${PLAN_ARTIFACT}&per_page=10`]));
|
||||
if (!Array.isArray(list?.artifacts)) return { kind: 'unknown', reason: `run ${run}'s artifact list is unreadable` };
|
||||
const plan = list.artifacts.find(a => a.name === PLAN_ARTIFACT);
|
||||
if (!plan) return { kind: 'hollow', reason: `run ${run} planned no cases (no ${PLAN_ARTIFACT} artifact; concluded ${view.conclusion ?? 'unknown'})` };
|
||||
if (plan.expired) return { kind: 'unknown', reason: `run ${run}'s ${PLAN_ARTIFACT} artifact has expired` };
|
||||
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-screen-'));
|
||||
let manifest: { entries?: Array<{ status?: unknown }> } | null;
|
||||
try { manifest = downloadJson(ctx, slug, run, PLAN_ARTIFACT, scratch, 'manifest.json') as typeof manifest; } finally { fs.rmSync(scratch, { recursive: true, force: true }); }
|
||||
if (!Array.isArray(manifest?.entries)) return { kind: 'unknown', reason: `run ${run}'s ${PLAN_ARTIFACT} manifest.json is unreadable` };
|
||||
const planned = manifest.entries.filter(e => e?.status === 'planned').length;
|
||||
return planned === 0 ? { kind: 'hollow', reason: `run ${run} planned no cases (an empty plan manifest; concluded ${view.conclusion ?? 'unknown'})` } : { kind: 'live' };
|
||||
}
|
||||
|
||||
/** The plan manifest every evals.yml run that planned anything uploads (plan-slices); a superseded screen never reaches that job. */
|
||||
const PLAN_ARTIFACT = 'paid-plan';
|
||||
|
||||
/** The named check run on a commit (the newest when several); null when absent or unreadable. */
|
||||
export function checkRun(ctx: QueueContext, sha: string, name: string): CheckInfo | null {
|
||||
const r = json<{ check_runs?: Array<{ name?: string; status?: string; conclusion?: string | null }> }>(
|
||||
|
||||
@@ -16,7 +16,7 @@ import { EXIT } from './headless-artifacts';
|
||||
import { ready, type QueueContext } from './ship-queue';
|
||||
import { classifyTrainHead } from './version-gate-train';
|
||||
import { git } from './ship-queue-gate';
|
||||
import { execFor, repoSlug } from './ship-queue-train-github';
|
||||
import { execFor, hollowScreen, repoSlug } from './ship-queue-train-github';
|
||||
import { loadReady, nowIso, persist, readyPath, type ReadyReceipt, type ScreenChoice } from './ship-queue-train-record';
|
||||
import { outOk, outPending, outRefused, trainEnv, type TrainEnv, type TrainOut } from './ship-queue-train';
|
||||
|
||||
@@ -85,10 +85,12 @@ async function dispatchScreen(env: TrainEnv, o: ReadyOptions, mergeSha: string,
|
||||
if (!slug) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: 'gh repo view could not name the repository' }, [], `${Q} ready --pr ${o.pr} --screen`, 'none');
|
||||
const exec = execFor(env.ctx);
|
||||
const existing = findDuplicateRun(listDispatchRuns(dispatch.workflow, exec, slug) ?? [], dedupeKey('pr', mergeSha, baseSha, scope));
|
||||
if (existing) return { choice: { choice: 'screen', policy, nonce: /eval-bg (\S+?)\]/.exec(existing.displayTitle)?.[1] ?? nonce, run: existing.databaseId, merge_sha: mergeSha }, line: `SCREEN_DISPATCH: following run ${existing.databaseId} (same merge SHA, base and policy; no duplicate)` };
|
||||
const plan = existing ? hollowScreen(env.ctx, slug, existing.databaseId) : null;
|
||||
const hollow = plan?.kind === 'hollow' ? plan.reason : null;
|
||||
if (existing && !hollow) return { choice: { choice: 'screen', policy, nonce: /eval-bg (\S+?)\]/.exec(existing.displayTitle)?.[1] ?? nonce, run: existing.databaseId, merge_sha: mergeSha }, line: `SCREEN_DISPATCH: following run ${existing.databaseId} (same merge SHA, base and policy; no duplicate)` };
|
||||
const found = dispatchWorkflow(dispatch, env.ctx.base, exec, slug) ? await awaitNonceRun(dispatch.workflow, nonce, exec, slug) : null;
|
||||
if (!found) return outPending({ code: 'QUEUE_TRAIN_DISPATCH_UNKNOWN', message: `no ${dispatch.workflow} run named ${nonce} appeared after the screen dispatch` }, [], `${Q} ready --pr ${o.pr} --screen${o.broad ? ' --broad' : ''} (adopts the run by its key once it appears)`, 'none');
|
||||
return { choice: { choice: 'screen', policy, nonce, run: found.databaseId, merge_sha: mergeSha }, line: `SCREEN_DISPATCH: ${dispatch.workflow} run ${found.databaseId} ${found.url} nonce ${nonce}` };
|
||||
return { choice: { choice: 'screen', policy, nonce, run: found.databaseId, merge_sha: mergeSha }, line: `SCREEN_DISPATCH: ${dispatch.workflow} run ${found.databaseId} ${found.url} nonce ${nonce}${hollow ? ` (not following ${hollow}: a screen that planned nothing is not a screen)` : ''}` };
|
||||
}
|
||||
|
||||
export async function trainReady(ctx: QueueContext, o: ReadyOptions): Promise<TrainOut> {
|
||||
@@ -113,8 +115,14 @@ export async function trainReady(ctx: QueueContext, o: ReadyOptions): Promise<Tr
|
||||
let choice: ScreenChoice = { choice: 'no-screen', policy: null, nonce: null, run: null, merge_sha: null };
|
||||
if (o.screen === 'screen') {
|
||||
const ls = git(ctx.repoRoot, ['ls-remote', env.remote, `refs/pull/${o.pr}/merge`]);
|
||||
const mergeSha = ls.ok ? ls.out.split(/\s/)[0] ?? '' : '';
|
||||
if (!/^[0-9a-f]{40,64}$/.test(mergeSha)) return outRefused({ code: 'QUEUE_NOT_READY', message: `${env.remote} has no refs/pull/${o.pr}/merge (the PR conflicts with ${ctx.base}, or GitHub has not computed it yet)` }, lines, `merge ${ctx.base} into the branch, push, then ${Q} ready --pr ${o.pr} --screen`, 'none');
|
||||
if (!ls.ok) return outRefused({ code: 'QUEUE_SCREEN_STALE_REF', message: `could not list ${env.remote}'s refs: ${(ls.err || ls.out).trim().replace(/\s+/g, ' ').slice(0, 200) || 'git ls-remote failed'}` }, lines, `check the remote, then ${Q} ready --pr ${o.pr} --screen`, 'none');
|
||||
const mergeSha = ls.out.split(/\s/)[0] ?? '';
|
||||
const again = `resolve the conflict with ${ctx.base} (or wait for GitHub to recompute refs/pull/${o.pr}/merge), push, then ${Q} ready --pr ${o.pr} --screen; a screen dispatched on a stale merge ref plans no cases`;
|
||||
if (!/^[0-9a-f]{40,64}$/.test(mergeSha)) return outRefused({ code: 'QUEUE_SCREEN_STALE_REF', message: `${env.remote} has no refs/pull/${o.pr}/merge (the PR conflicts with ${ctx.base}, or GitHub has not computed it yet)` }, lines, again, 'none');
|
||||
const fetched = git(ctx.repoRoot, ['fetch', '-q', env.remote, `refs/pull/${o.pr}/merge`]);
|
||||
if (!fetched.ok) return outRefused({ code: 'QUEUE_SCREEN_STALE_REF', message: `could not fetch refs/pull/${o.pr}/merge from ${env.remote}: ${(fetched.err || fetched.out).trim().replace(/\s+/g, ' ').slice(0, 200) || 'git fetch failed'}` }, lines, `check the remote, then ${Q} ready --pr ${o.pr} --screen`, 'none');
|
||||
const mergeParent = git(ctx.repoRoot, ['rev-parse', '--verify', '--quiet', `${mergeSha}^2`]).out;
|
||||
if (mergeParent !== head) return outRefused({ code: 'QUEUE_SCREEN_STALE_REF', message: `${env.remote}'s refs/pull/${o.pr}/merge (${mergeSha.slice(0, 12)}) merges ${mergeParent ? mergeParent.slice(0, 12) : 'no PR head'}, not the PR head ${head.slice(0, 12)}: GitHub has not recomputed it, which is what a conflicting PR looks like (CHANGELOG-only conflicts included)` }, lines, again, 'none');
|
||||
const d = await dispatchScreen(env, o, mergeSha, baseSha);
|
||||
if ('exit' in d) return { ...d, lines: [...lines, ...d.lines] };
|
||||
choice = d.choice;
|
||||
|
||||
@@ -17,7 +17,8 @@ import { randomBytes } from 'node:crypto';
|
||||
import { DISPATCH_INPUTS, awaitNonceRun, dispatchWorkflow, makeNonce } from '../scripts/eval-bg';
|
||||
import type { QueueContext } from './ship-queue';
|
||||
import { git } from './ship-queue-gate';
|
||||
import { checkRun, execFor, repoSlug } from './ship-queue-train-github';
|
||||
import { bindReceipt } from './ship-receipt';
|
||||
import { checkRun, execFor, prInfo, repoSlug } from './ship-queue-train-github';
|
||||
import { activeFreeze, attemptsOf, nowIso, persist, trainFile, type FreezeRecord, type TrainRecord } from './ship-queue-train-record';
|
||||
import {
|
||||
effect, fetchBase, fetchTrainBranch, findTrain, frozenLines, nextFor, outFailed, outOk, outRefused, pinList, prsArgs, save, trainEnv, type TrainEnv, type TrainOut,
|
||||
@@ -132,7 +133,11 @@ export async function verifyTrain(ctx: QueueContext, o: { pr: string; train: str
|
||||
const pinned = head === t.slots[i]!.head ? { same: true, detail: 'the pinned head' } : sameModuloStamps(env, t.slots[i]!.head, head);
|
||||
const stampOnly = pinned.same ? pinned : mergedIn(env, t, i, head, main);
|
||||
if (!stampOnly.same) return stopped(env, t, 'QUEUE_TRAIN_MOVED', `#${o.pr} moved past its pinned head ${t.slots[i]!.head.slice(0, 12)} and is not T${after.index} modulo stamps (${head.slice(0, 12)}: ${stampOnly.detail}; against the pin it ${pinned.detail})`, main, lines);
|
||||
return outOk([...lines, `main: ${main} = T${before.index} (${pre.detail})`, `head: ${head} (${stampOnly.detail})`,
|
||||
const info = prInfo(ctx, o.pr);
|
||||
if ('error' in info) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: info.error }, lines, `${Q} verify --pr ${o.pr} --train ${t.nonce}`, run);
|
||||
const shipped = bindReceipt(info.body, o.pr, [t.slots[i]!.head, head]);
|
||||
if (!shipped.ok) return outRefused({ code: shipped.code, message: shipped.message }, [...lines, shipped.line], `#${o.pr}'s lane runs /ship and READYs again; then ${Q} train build --prs <pins>`, run);
|
||||
return outOk([...lines, `main: ${main} = T${before.index} (${pre.detail})`, `head: ${head} (${stampOnly.detail})`, `${shipped.line} (binds ${shipped.head === t.slots[i]!.head ? 'the pinned head' : 'the current head'})`,
|
||||
`VERIFY_TRAIN: #${o.pr} may merge as slot ${i + 1} of train ${t.nonce} (green run ${gate.run?.id ?? '-'}, ${env.train.full_gate_check} success)`, `MERGE_CONDITION: sha=${head}`],
|
||||
`merge #${o.pr} with expected head ${head.slice(0, 12)} (gstack-restamp stamps at merge), then ${Q} verify --pr ${o.pr} --train ${t.nonce}`, run);
|
||||
}
|
||||
|
||||
+14
-3
@@ -21,8 +21,9 @@ import type { ResultStatus } from './headless-artifacts';
|
||||
import { versionAtRef, versionFiles } from './restamp';
|
||||
import type { LoadedPolicy, ShipPolicy, TrainPolicy } from './ship-policy';
|
||||
import { loadBasePolicy, type QueueContext } from './ship-queue';
|
||||
import { bindReceipt } from './ship-receipt';
|
||||
import { git, sha256, withIndex, type QueueFailure } from './ship-queue-gate';
|
||||
import { asRun, execFor, newestBranchRun, prInfo, remoteBranchSha, repoSlug, runView, checkRun } from './ship-queue-train-github';
|
||||
import { asRun, execFor, hollowScreen, newestBranchRun, prInfo, remoteBranchSha, repoSlug, runView, checkRun } from './ship-queue-train-github';
|
||||
import {
|
||||
TERMINAL_STATES, activeFreeze, attemptsOf, listTrains, loadBisect, loadReady, loadTrain, nowIso, persist, rebuildFromNotes, repoIdentity, trainFile,
|
||||
type AttemptPurpose, type AttemptRecord, type TrainRecord, type TrainSlot,
|
||||
@@ -284,6 +285,8 @@ function admitSlots(env: TrainEnv, pins: Array<{ pr: string; sha: string }>): {
|
||||
const f = git(ctx.repoRoot, ['fetch', '--no-tags', '-q', env.remote, ...specs]);
|
||||
if (!f.ok) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `git fetch of the PR heads failed: ${f.err.slice(0, 160)}` }, [], 'none', 'none');
|
||||
const slots: TrainSlot[] = [];
|
||||
const lines: string[] = [];
|
||||
let slug: string | null | undefined;
|
||||
for (const p of pins) {
|
||||
const info = prInfo(ctx, p.pr);
|
||||
if ('error' in info) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: info.error }, [], 'none', 'none');
|
||||
@@ -293,11 +296,18 @@ function admitSlots(env: TrainEnv, pins: Array<{ pr: string; sha: string }>): {
|
||||
if (!info.headRefOid.startsWith(p.sha) || fetched !== info.headRefOid) {
|
||||
return outRefused({ code: 'QUEUE_TRAIN_MOVED', message: `PR #${p.pr} moved: pinned ${p.sha.slice(0, 12)}, head is now ${info.headRefOid.slice(0, 12)}` }, [], `ask #${p.pr}'s lane for READY on ${info.headRefOid.slice(0, 12)}, then ${Q} train build --prs <pins>`, 'none');
|
||||
}
|
||||
const shipped = bindReceipt(info.body, p.pr, [info.headRefOid]);
|
||||
if (!shipped.ok) return outRefused({ code: shipped.code, message: shipped.message }, [shipped.line], `#${p.pr}'s lane runs /ship, then ${Q} ready --pr ${p.pr} --screen|--no-screen; then ${Q} train build --prs <pins>`, 'none');
|
||||
const ready = loadReady(env.repo.keys, p.pr, info.headRefOid);
|
||||
if (!ready) return outRefused({ code: 'QUEUE_NOT_READY', message: `no READY receipt for #${p.pr} at ${info.headRefOid.slice(0, 12)} (local state or ${'refs/notes/gstack-trains'})` }, [], `the lane runs ${Q} ready --pr ${p.pr} --screen|--no-screen`, 'none');
|
||||
slots.push({ pr: p.pr, head: info.headRefOid, ready_at: ready.ready_at, screen: ready.screen });
|
||||
let screen = ready.screen;
|
||||
if (screen.choice === 'screen' && screen.run && slug === undefined) slug = repoSlug(ctx);
|
||||
const plan = screen.choice === 'screen' && screen.run ? (slug ? hollowScreen(ctx, slug, screen.run) : { kind: 'unknown' as const, reason: 'the repository slug is unreadable' }) : null;
|
||||
if (plan?.kind === 'hollow') { lines.push(`SCREEN: #${p.pr} ${plan.reason}; counted as not-run, the slot rides unscreened`); screen = { ...screen, choice: 'no-screen' }; }
|
||||
else if (plan?.kind === 'unknown') lines.push(`SCREEN: #${p.pr} ${plan.reason}; counted as screened, unverified`);
|
||||
slots.push({ pr: p.pr, head: info.headRefOid, ready_at: ready.ready_at, screen });
|
||||
}
|
||||
return { slots, lines: [] };
|
||||
return { slots, lines };
|
||||
}
|
||||
|
||||
/** Start a new train from --prs, or find the live train with the same pins and base. */
|
||||
@@ -315,6 +325,7 @@ async function newTrain(env: TrainEnv, o: BuildOptions): Promise<{ t: TrainRecor
|
||||
if ('exit' in admitted) return admitted;
|
||||
const order = orderSlots(admitted.slots, o.repair ? 1 : env.train.max_size, explicit);
|
||||
const lines = [
|
||||
...admitted.lines,
|
||||
`ORDER: ${order.train.map((s, i) => `${i + 1}:#${s.pr}(${s.screen.choice === 'screen' ? 'screened' : 'unscreened'})`).join(' ')}${explicit ? ' (explicit --order)' : ''}`,
|
||||
...(order.held.length ? [`HELD: ${order.held.map(s => '#' + s.pr).join(',')} (${order.split ? 'unscreened PRs form their own train' : `over the train size ${env.train.max_size}`})`] : []),
|
||||
];
|
||||
|
||||
+38
-5
@@ -5,8 +5,9 @@
|
||||
* so every step ends in a line a coordinator can act on:
|
||||
*
|
||||
* ready READY #<pr> <head> STAMP_VERSION=<X|unstamped> plus head, base,
|
||||
* CI at that exact head, the PREGATE line and the gate state;
|
||||
* refused when the tree is not the pushed PR head.
|
||||
* CI at that exact head, the PREGATE line, the gate state and the
|
||||
* /ship receipt; refused when the tree is not the pushed PR head
|
||||
* or the PR body carries no receipt for it (QUEUE_RECEIPT_*).
|
||||
* stamp preflight (gated head equals HEAD modulo stamps, base, policy,
|
||||
* gate record under full_e2e), restamp, commit, tree receipt,
|
||||
* each stage journaled; QUEUE_STAMP: <pr> <version> head= gated=
|
||||
@@ -23,7 +24,7 @@ import * as path from 'node:path';
|
||||
import { readPregateFile, verifyPregate, PREGATE_OUT_REL } from './pregate/index';
|
||||
import { ensureTmpExcluded, restamp, versionAtRef } from './restamp';
|
||||
import type { ResultCodeName } from './result-codes';
|
||||
import { extractReceipt, receiptSummaryLine } from './ship-receipt';
|
||||
import { bindReceipt, extractReceipt } from './ship-receipt';
|
||||
import { isUnavailable, loadPolicyFromBase, type LoadedPolicy } from './ship-policy';
|
||||
import {
|
||||
QUEUE_DIR_REL, applyStampPatch, commitForTree, currentAttempt, git, listAttempts, recomputeMergeTree, sha256, writeJsonAtomic,
|
||||
@@ -59,6 +60,37 @@ function treeReceipt(ctx: QueueContext, policy: LoadedPolicy, gated: string, cur
|
||||
return 'error' in r ? { code: 'QUEUE_PROOF_MISSING', message: r.error } : r;
|
||||
}
|
||||
|
||||
/**
|
||||
* The heads a READY accepts a /ship receipt for: HEAD, plus the receipt's own
|
||||
* head when HEAD is that commit with only stamp files changed on top (the
|
||||
* slot's stamp commit, docs/ship-policy.md#ship-before-ready), or, from slot
|
||||
* 2 on, the stamp over a merge of the base into it: /ship ran on the pin, the
|
||||
* stamp changes nothing it checked, and verify --train binds the same pair
|
||||
* against the train's own prefix. A merge with no stamp on top is a lane's
|
||||
* own push (rule 14), so it reruns /ship like any other head. Any other
|
||||
* receipt head stays QUEUE_RECEIPT_STALE.
|
||||
*/
|
||||
function acceptedReceiptHeads(ctx: QueueContext, policy: LoadedPolicy, head: string, body: string): string[] {
|
||||
const ex = extractReceipt(body);
|
||||
const r = ex.receipt;
|
||||
if (!r || ex.errors.length || typeof r.head !== 'string' || !/^[0-9a-f]{12,64}$/i.test(r.head) || head.startsWith(r.head)) return [head];
|
||||
const root = ctx.repoRoot;
|
||||
const full = git(root, ['rev-parse', '--verify', '--quiet', `${r.head}^{commit}`]);
|
||||
if (!full.ok || !git(root, ['merge-base', '--is-ancestor', full.out, head]).ok) return [head];
|
||||
const stampOnly = (from: string, to: string) => { const t = from === to ? null : treeReceipt(ctx, policy, from, to); return !t || (!('message' in t) && t.tree === 'same-modulo-stamps'); };
|
||||
if (stampOnly(full.out, head)) return [full.out, head];
|
||||
const baseSha = git(root, ['rev-parse', `origin/${ctx.base}`]).out;
|
||||
const onBase = (sha: string) => !!sha && git(root, ['merge-base', '--is-ancestor', sha, baseSha]).ok;
|
||||
const merge = git(root, ['rev-parse', '--verify', '--quiet', `${head}^1`]).out;
|
||||
const stamp = merge && merge !== head ? treeReceipt(ctx, policy, merge, head) : null;
|
||||
if (!stamp || 'message' in stamp || stamp.tree !== 'same-modulo-stamps' || stamp.files.length === 0) return [head];
|
||||
const parents = git(root, ['rev-list', '--parents', '-n', '1', merge]).out.split(' ').slice(1);
|
||||
if (parents.length !== 2) return [head];
|
||||
const pinSide = onBase(parents[0]!) ? parents[1]! : onBase(parents[1]!) ? parents[0]! : '';
|
||||
if (pinSide && git(root, ['merge-base', '--is-ancestor', full.out, pinSide]).ok && stampOnly(full.out, pinSide)) return [full.out, head];
|
||||
return [head];
|
||||
}
|
||||
|
||||
const CI_PAGE = 100;
|
||||
const CI_MAX_PAGES = 10;
|
||||
|
||||
@@ -181,7 +213,7 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate
|
||||
pregateLine = `PREGATE: ${read.file.verdict} tree=${read.file.tree.slice(0, 12)} head=${(read.file.head ?? '').slice(0, 12)} checks=${read.file.checks.length}${v.ok ? '' : ` — ${v.message} (${v.code})`}`;
|
||||
} else pregateLine = `PREGATE: unreadable (${read.errors[0]?.message ?? 'invalid'})`;
|
||||
}
|
||||
const receipt = extractReceipt(info.body ?? '');
|
||||
const receipt = bindReceipt(info.body ?? '', o.pr, acceptedReceiptHeads(ctx, policy, head, info.body ?? ''));
|
||||
const attempt = currentAttempt(ctx.repoRoot, o.pr);
|
||||
const hint = conflictHint(info.mergeable, ci, info.headRefName, info.isCrossRepository === true, head);
|
||||
const lines = [
|
||||
@@ -194,13 +226,14 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate
|
||||
...(hint ? [hint] : []),
|
||||
pregateLine,
|
||||
gateLine(policy, attempt),
|
||||
receipt.found && receipt.receipt && !receipt.errors.length ? receiptSummaryLine(receipt.receipt) : `SHIP_RECEIPT: ${receipt.found ? 'invalid' : 'none'} in the PR body`,
|
||||
receipt.line,
|
||||
];
|
||||
const gate = policy.policy.full_e2e ? gateEvidence(ctx, o.pr, head, attempt, o.gateExit, o.gateLog) : null;
|
||||
if (gate) lines.push(gate.line);
|
||||
if (!ci.green) return fail({ code: 'QUEUE_NOT_READY', message: `CI is not green at ${head.slice(0, 12)}: ${ci.line}` }, lines, run);
|
||||
if (!pregateOk) return fail({ code: 'QUEUE_NOT_READY', message: `the pregate result is not a current pass: ${pregateLine}` }, lines, run);
|
||||
if (gate && !gate.ok) return fail({ code: 'QUEUE_GATE_MISSING', message: `full_e2e is set and no passing gate is bound to ${head.slice(0, 12)}: ${gate.line}` }, lines, run);
|
||||
if (!receipt.ok) return fail({ code: receipt.code, message: receipt.message }, lines, run);
|
||||
return { ok: true, lines, run };
|
||||
}
|
||||
|
||||
|
||||
+37
-1
@@ -49,6 +49,39 @@ export function extractReceipt(body: string): { found: boolean; receipt?: ShipRe
|
||||
}
|
||||
}
|
||||
|
||||
export type ReceiptBindingCode = 'QUEUE_RECEIPT_MISSING' | 'QUEUE_RECEIPT_STALE';
|
||||
export type ReceiptBinding =
|
||||
| { ok: true; receipt: ShipReceipt; head: string; line: string }
|
||||
| { ok: false; code: ReceiptBindingCode; message: string; line: string };
|
||||
|
||||
/**
|
||||
* The /ship receipt a PR body must carry before READY, train build or a train
|
||||
* merge (CREW rule 22; docs/ship-policy.md#ship-before-ready): a valid block
|
||||
* whose `head` is one of the accepted heads. `accepted` is the head being
|
||||
* READY'd, or the slot's pinned head beside the current head when the current
|
||||
* head is the pin's stamp commit (the stamp changes only stamp files, so the
|
||||
* receipt written for the pin stays the binding one). `line` is the
|
||||
* informational `SHIP_RECEIPT:` line the READY block prints either way.
|
||||
*/
|
||||
export function bindReceipt(body: string, pr: string | undefined, accepted: readonly string[]): ReceiptBinding {
|
||||
const subject = pr === undefined ? "the receipt's" : `PR #${pr}'s`;
|
||||
const ex = extractReceipt(body);
|
||||
if (!ex.found) return { ok: false, code: 'QUEUE_RECEIPT_MISSING', message: `${subject} body carries no ship receipt`, line: 'SHIP_RECEIPT: none in the PR body' };
|
||||
if (!ex.receipt || ex.errors.length) {
|
||||
const why = ex.errors[0] ? oneLine(`${ex.errors[0].message} at ${ex.errors[0].path}`).slice(0, 200) : 'invalid';
|
||||
return { ok: false, code: 'QUEUE_RECEIPT_MISSING', message: `${subject} ship receipt is invalid (${why})`, line: 'SHIP_RECEIPT: invalid in the PR body' };
|
||||
}
|
||||
const r = ex.receipt;
|
||||
const line = receiptSummaryLine(r);
|
||||
const head = accepted.find(h => h === r.head || (r.head.length >= 12 && h.startsWith(r.head)));
|
||||
if (!head) {
|
||||
const want = accepted.map(h => h.slice(0, 12)).join(' or ');
|
||||
return { ok: false, code: 'QUEUE_RECEIPT_STALE', message: `${subject} ship receipt is for ${oneLine(r.head.slice(0, 12))}, not the head ${want} being merged`, line };
|
||||
}
|
||||
if (pr !== undefined && r.pr !== undefined && String(r.pr) !== pr) return { ok: false, code: 'QUEUE_RECEIPT_STALE', message: `${subject} ship receipt names pr=${r.pr}, not this PR`, line };
|
||||
return { ok: true, receipt: r, head, line };
|
||||
}
|
||||
|
||||
/** The block becomes the first section of the body: an existing block is replaced in place at the top, prose follows. */
|
||||
export function placeReceiptFirst(body: string, receipt: ShipReceipt): string {
|
||||
const block = renderReceiptBlock(receipt);
|
||||
@@ -70,6 +103,9 @@ export function missingIssueLines(body: string, issues: NonNullable<ShipReceipt[
|
||||
return expected.filter(e => !have.some(l => l === e || l.startsWith(`${e} `)));
|
||||
}
|
||||
|
||||
/** Receipt fields come from the PR body, so anything printed from one stays on its own line, control characters included. */
|
||||
const oneLine = (s: string) => s.replace(/[\x00-\x1f\x7f\u0080-\u009f\u2028\u2029]+/g, ' ');
|
||||
|
||||
/** The first lines of the completion message to the coordinator: identifiers only. */
|
||||
export function receiptSummaryLine(r: ShipReceipt): string {
|
||||
const parts = [`pr=${r.pr ?? '-'}`, `head=${r.head.slice(0, 12)}`, `version=${r.version ?? '-'}`, `base=${r.base}`];
|
||||
@@ -80,7 +116,7 @@ export function receiptSummaryLine(r: ShipReceipt): string {
|
||||
if (r.session_kind) parts.push(`session_kind=${r.session_kind}`);
|
||||
if (r.artifacts_consumed) parts.push(`artifacts_consumed=${r.artifacts_consumed}`);
|
||||
if (r.approval) parts.push(`approval=${approvalSummary(r.approval)}`);
|
||||
return `SHIP_RECEIPT: ${parts.join(' ')}`;
|
||||
return `SHIP_RECEIPT: ${oneLine(parts.join(' '))}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+6
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.91.226",
|
||||
"version": "1.91.237",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -69,6 +69,7 @@
|
||||
"eval:bg:release": "bun run scripts/eval-bg.ts release"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.78.0",
|
||||
"@huggingface/transformers": "^4.3.1",
|
||||
"@ngrok/ngrok": "^1.7.0",
|
||||
"cross-spawn": "^7.0.6",
|
||||
@@ -78,7 +79,9 @@
|
||||
"marked": "^18.1.1",
|
||||
"playwright": "^1.62.1",
|
||||
"sharp": "^0.35.4",
|
||||
"socks": "^2.8.10"
|
||||
"socks": "^2.8.10",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-fit": "^0.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"bun": ">=1.4.2"
|
||||
@@ -95,12 +98,9 @@
|
||||
],
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "0.2.141",
|
||||
"@anthropic-ai/sdk": "^0.78.0",
|
||||
"@types/bun": "1.4.3",
|
||||
"prettier": "3.9.10",
|
||||
"typescript": "7.0.2",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-fit": "^0.8.0"
|
||||
"typescript": "7.0.2"
|
||||
},
|
||||
"overrides": {
|
||||
"@modelcontextprotocol/sdk": "1.31.0",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Bash shim — Claude Code runs hook commands through /bin/sh; this runs the
|
||||
# TypeScript hook with bun.
|
||||
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
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
exec bun --no-env-file --config=/dev/null "$HERE/mode-handoff-hook.ts"
|
||||
exec bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$HERE/mode-handoff-hook.ts"
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eu
|
||||
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
|
||||
|
||||
CSO_BUILD_ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
cd "$CSO_BUILD_ROOT"
|
||||
@@ -227,7 +228,7 @@ cso_build_evaluation_candidate() {
|
||||
cp -R lib scripts "$source_root/"
|
||||
cp -R test/helpers "$source_root/test/"
|
||||
cp "$candidate_catalog" "$source_root/lib/cso/runtime-catalog.json"
|
||||
(cd "$source_root" && "$BUN_CMD" --no-env-file --config=/dev/null scripts/cso-runtime-promotion.ts check-evaluation lib/cso/runtime-catalog.json)
|
||||
(cd "$source_root" && "$BUN_CMD" --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" scripts/cso-runtime-promotion.ts check-evaluation lib/cso/runtime-catalog.json)
|
||||
(cd "$source_root" && cso_compile_bun lib/cso/cli.ts "$CSO_EVALUATION_UNIT/gstack-cso-core")
|
||||
(cd "$source_root" && cso_compile_bun scripts/cso-eval-producer.ts "$CSO_EVALUATION_UNIT/cso-eval-producer")
|
||||
cso_compile_watchdog "$CSO_EVALUATION_UNIT/gstack-cso-watchdog"
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"scripts/lib/published-text.ts": {"blob":"3f2e4d616b5dd0204035710c66b64d49055c5714","since":6},
|
||||
"scripts/lib/shard-engine.ts": {"blob":"3cd0b1aaa84018b00bf9ea0ce48093f3c8492f66","since":6},
|
||||
"scripts/test-paid-shards.ts": {"blob":"5439d0a8f6e1569fa7c924af20d67c30c81d5d30","since":6,"nonBehavioral":"credential rejections are classed infra instead of assertion: no case's pass or fail changes; only failed trials whose provider rejected the run's key move class, so no rerun spends on a rejected key"},
|
||||
"scripts/test-pr-profile.ts": {"blob":"d334195b0d0393622e2c380dc3e2feebe099b6e6","since":6,"nonBehavioral":"classify the new evals-main-backstop.yml workflow as free-lane only: it dispatches evals.yml on main and runs inside no case, so no case's selection on a PR, input or verdict changes"},
|
||||
"scripts/test-pr-profile.ts": {"blob":"a80de69f1923afed67127640d68c99e1a8528c5b","since":6,"nonBehavioral":"classify the new weekly-release.yml workflow and scripts/release-cut.ts as free-lane only PR files: the release cut runs on a cron and manual dispatch after merge and inside no case, so no case's selection on a PR, input or verdict changes"},
|
||||
"scripts/test-strict-output.ts": {"blob":"01185d20dc9761b04b9b2f751ec98664fe0eaf93","since":6},
|
||||
"test/helpers/agent-sdk-runner.ts": {"blob":"210bba92d241a90df578b2c7fc943ac5755d0025","since":6},
|
||||
"test/helpers/auq-substance-panel.ts": {"blob":"4881260ef793668e4963a062db6d06a5e1f7a40f","since":6},
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
judgePanelMedian, judgePanelMajority,
|
||||
} from '../test/helpers/llm-judge';
|
||||
import { buildWorkflowJudgePrompt, WORKFLOW_JUDGE_RESPONSE_SCHEMA } from '../test/helpers/workflow-judge-input';
|
||||
import { COOKIE_WORKFLOW_JUDGE } from '../test/helpers/cookie-workflow-judge-input';
|
||||
import { CLAUDE_JUDGE_EVAL_MODEL } from '../lib/eval-model';
|
||||
import type { CalibrationConfig, Verdict } from './judge-calibration';
|
||||
|
||||
@@ -106,4 +107,11 @@ export const CALIBRATION_CONFIGS: CalibrationConfig[] = [
|
||||
'land-and-deploy/SKILL.md workflow', 'canary/SKILL.md monitoring loop', 'benchmark/SKILL.md perf collection',
|
||||
'setup-deploy/SKILL.md platform setup', 'sync-gbrain/SKILL.md read-only readiness', 'retro/SKILL.md instructions',
|
||||
'qa-only/SKILL.md workflow', 'gstack-upgrade/SKILL.md upgrade flow']),
|
||||
{
|
||||
id: 'cookie-workflow', priority: 8, cases: ['setup-browser-cookies/SKILL.md workflow'], model: COOKIE_WORKFLOW_JUDGE.model,
|
||||
build: inputs => buildWorkflowJudgePrompt(COOKIE_WORKFLOW_JUDGE, { files: [], text: inputs.bundle! }),
|
||||
oldOptions: { max_tokens: DEFAULT_JUDGE_MAX_TOKENS },
|
||||
newOptions: { max_tokens: DEFAULT_JUDGE_MAX_TOKENS, jsonSchema: WORKFLOW_JUDGE_RESPONSE_SCHEMA },
|
||||
...scoreJudge(COOKIE_WORKFLOW_JUDGE.thresholds), estimatedOutputTokens: 1200,
|
||||
},
|
||||
];
|
||||
@@ -314,6 +314,7 @@ export const KNOWN_WINDOWS_SAFE: Array<{ file: string; reason: string }> = [
|
||||
{ file: 'test/ship-hook-windows-paths.test.ts', reason: 'runs bin/ helpers through explicit bash and Bun argv with forward-slash paths; never executes a shebang; the path-spelling simulation is skipIf win32' },
|
||||
{ file: 'test/gstack-wtree-windows-paths.test.ts', reason: '#3101: runs bin/gstack-wtree through explicit bash in an os.tmpdir linked worktree with forward-slash paths; never executes a shebang; the drive-letter simulation is skipIf win32' },
|
||||
{ file: 'test/setup-hook-syntax-gate.test.ts', reason: '#3088/#3100: runs bin/gstack-hook-check and the setup hook sections through explicit bash with forward-slash paths; never executes a shebang. Its Bun drive-path spelling is the Windows bug it guards' },
|
||||
{ file: 'test/bun-config-shell-bins.test.ts', reason: '#3415: runs the record bins through lib/script-invocation.ts (Git Bash on Windows) with MSYS path conversion off, the case it guards; its bun and cygpath stand-ins and simulated-OSTYPE cases are skipIf win32' },
|
||||
{ file: 'test/path-spelling.test.ts', reason: 'sources bin/gstack-path-spelling.sh through explicit bash and checks lib/path-spelling.ts; on Windows it exercises the real Git Bash spellings' },
|
||||
{ file: 'test/state-root-parity.test.ts', reason: 'runs the bash twin and lib/state-root.ts over an env table with PATH empty; no shebang execution, raw-string comparison is platform-neutral' },
|
||||
{ file: 'test/generator-eexist.test.ts',
|
||||
|
||||
Executable
+330
@@ -0,0 +1,330 @@
|
||||
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
||||
/**
|
||||
* release-cut: tag the newest green commit on main as v<VERSION>, publish a
|
||||
* GitHub Release whose notes are that span's CHANGELOG headlines, and
|
||||
* fast-forward refs/heads/stable to it (docs/releases.md). Run by
|
||||
* .github/workflows/weekly-release.yml every Monday morning and by hand for a
|
||||
* hotfix (--ref <sha on main>). Dry runs print the plan and change nothing.
|
||||
*
|
||||
* bun scripts/release-cut.ts --repo owner/name [--ref <sha>] [--dry-run] [--summary <file>]
|
||||
*
|
||||
* The checkout needs origin's full history (fetch-depth 0). Green, for a
|
||||
* commit on main's first-parent history, means: its free-tests check run
|
||||
* succeeded (the status main requires), its main-backstop check run succeeded
|
||||
* (evals-main-backstop.yml publishes it: success only when a green train
|
||||
* record covers the tree or the backstop gated it green; a push main moved
|
||||
* past before it was gated is neutral, never green), every check run on it
|
||||
* has completed and none failed, and refs/notes/gstack-trains holds no active
|
||||
* freeze. The newest such commit in the window (7 days, 200 first-parent
|
||||
* commits) is the candidate; a commit with a check run still going is
|
||||
* skipped, not waited for.
|
||||
*
|
||||
* stable moves only forward: a candidate behind the current stable is refused
|
||||
* (REFUSED non-fast-forward), as is a tag v<VERSION> that already names another
|
||||
* commit. A cut that stopped half-way (tag and stable pushed, release not
|
||||
* created) is finished by the next run; a completed cut reports NOTHING_NEW
|
||||
* and exits 0.
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
export type Gh = (args: string[]) => { status: number | null; stdout: string; stderr: string };
|
||||
export type Git = (args: string[]) => { ok: boolean; out: string };
|
||||
|
||||
export const REQUIRED_CHECK = 'free-tests';
|
||||
export const BACKSTOP_CHECK = 'main-backstop';
|
||||
export const NOTES_REF = 'refs/notes/gstack-trains';
|
||||
const NOTES_LOCAL = 'refs/notes/gstack-trains-release';
|
||||
const RED = new Set(['failure', 'timed_out', 'cancelled', 'action_required', 'startup_failure', 'stale']);
|
||||
export const WINDOW_DAYS = 7;
|
||||
export const WINDOW_COMMITS = 200;
|
||||
export const NOTES_CAP = 60_000;
|
||||
|
||||
export const runGh: Gh = (args) => {
|
||||
const r = spawnSync('gh', args, { encoding: 'utf8', timeout: 120_000, maxBuffer: 64 * 1024 * 1024 });
|
||||
return { status: r.status, stdout: r.stdout ?? '', stderr: r.error ? String(r.error) : r.stderr ?? '' };
|
||||
};
|
||||
export const gitIn = (cwd: string): Git => (args) => {
|
||||
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 120_000, maxBuffer: 256 * 1024 * 1024 });
|
||||
return r.status === 0 ? { ok: true, out: (r.stdout ?? '').replace(/\n$/, '') } : { ok: false, out: (r.stderr || String(r.error ?? '')).trim() };
|
||||
};
|
||||
|
||||
function parseJson<T>(r: { status: number | null; stdout: string }): T | null {
|
||||
if (r.status !== 0) return null;
|
||||
try { return JSON.parse(r.stdout) as T; } catch { return null; }
|
||||
}
|
||||
|
||||
/** Four-part versions compare numerically, part by part. */
|
||||
export function compareVersions(a: string, b: string): number {
|
||||
const pa = a.split('.').map(Number), pb = b.split('.').map(Number);
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
||||
if (d !== 0) return d;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export type Verdict = { green: true } | { green: false; why: string; pending: boolean };
|
||||
interface CheckRun { name: string; status: string; conclusion: string | null }
|
||||
|
||||
/** Why a commit is not releasable, or green. Pending means a check run on it is still going. */
|
||||
export function gateVerdict(gh: Gh, repo: string, sha: string): Verdict {
|
||||
const paged = parseJson<unknown>(gh(['api', '--paginate', '--slurp', `repos/${repo}/commits/${sha}/check-runs?per_page=100`]));
|
||||
if (!paged) return { green: false, why: 'cannot read its check runs', pending: false };
|
||||
const pages = (Array.isArray(paged) ? paged : [paged]) as Array<{ check_runs?: CheckRun[] }>;
|
||||
const runs = pages.flatMap(p => p.check_runs ?? []);
|
||||
for (const name of [REQUIRED_CHECK, BACKSTOP_CHECK]) {
|
||||
const named = runs.filter(c => c.name === name);
|
||||
if (named.length === 0) return { green: false, why: `no ${name} check run`, pending: false };
|
||||
if (named.some(c => c.status !== 'completed')) return { green: false, why: `${name} is still running`, pending: true };
|
||||
if (!named.some(c => c.conclusion === 'success')) return { green: false, why: `${name} concluded ${named.map(c => c.conclusion).join(',')}`, pending: false };
|
||||
}
|
||||
const red = runs.filter(c => c.status === 'completed' && RED.has(c.conclusion ?? ''));
|
||||
if (red.length > 0) return { green: false, why: `red check runs: ${[...new Set(red.map(c => c.name))].join(', ')}`, pending: false };
|
||||
const running = runs.filter(c => c.status !== 'completed');
|
||||
if (running.length > 0) return { green: false, why: `check runs still going: ${[...new Set(running.map(c => c.name))].join(', ')}`, pending: true };
|
||||
return { green: true };
|
||||
}
|
||||
|
||||
/** An active freeze record (kind freeze, cleared_at null) in the trains notes ref, after fetching it. */
|
||||
export function activeFreeze(git: Git): string | null {
|
||||
const f = git(['fetch', '--no-tags', '-q', 'origin', `+${NOTES_REF}:${NOTES_LOCAL}`]);
|
||||
if (!f.ok) return /couldn't find remote ref|no such ref/i.test(f.out) ? null : `cannot fetch ${NOTES_REF}: ${f.out.slice(0, 160)}`;
|
||||
const listed = git(['notes', `--ref=${NOTES_LOCAL}`, 'list']);
|
||||
if (!listed.ok || !listed.out.trim()) return null;
|
||||
for (const line of listed.out.split('\n')) {
|
||||
const blob = line.split(' ')[0];
|
||||
if (!blob) continue;
|
||||
try {
|
||||
const r = JSON.parse(git(['cat-file', 'blob', blob]).out) as { kind?: string; cleared_at?: string | null; reason?: string; train?: string };
|
||||
if (r.kind === 'freeze' && !r.cleared_at) return `queue frozen since train ${r.train ?? '?'}: ${r.reason ?? 'red backstop'}`;
|
||||
} catch { /* not a record */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface ChangelogEntry { version: string; date: string; headline: string }
|
||||
|
||||
/** Every "## [X.Y.Z.W] - date" entry of a CHANGELOG with its first prose line, markdown stripped. */
|
||||
export function changelogEntries(text: string): ChangelogEntry[] {
|
||||
const out: ChangelogEntry[] = [];
|
||||
const lines = text.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const m = /^## \[(\d+(?:\.\d+)+)\](?: - (\S+))?/.exec(lines[i]!);
|
||||
if (!m) continue;
|
||||
let headline = '';
|
||||
for (let j = i + 1; j < lines.length && !lines[j]!.startsWith('## '); j++) {
|
||||
const l = lines[j]!.trim();
|
||||
if (!l || l.startsWith('#') || l.startsWith('|') || l.startsWith('-') || l.startsWith('```')) continue;
|
||||
const bold = /^\*\*(.+?)\*\*/.exec(l);
|
||||
headline = (bold ? bold[1]! : l.replace(/\*\*/g, '')).replace(/\s+/g, ' ');
|
||||
break;
|
||||
}
|
||||
out.push({ version: m[1]!, date: m[2] ?? '', headline });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface NotesInput { repo: string; version: string; date: string; prevVersion: string | null; prevTag: string | null; entries: ChangelogEntry[]; hotfix: boolean; first?: boolean }
|
||||
|
||||
/** Release notes: one headline per version in (prev, version], capped at NOTES_CAP. */
|
||||
export function releaseNotes(n: NotesInput): string {
|
||||
const span = n.entries.filter(e => compareVersions(e.version, n.version) <= 0 && (!n.prevVersion || compareVersions(e.version, n.prevVersion) > 0))
|
||||
.sort((a, b) => compareVersions(b.version, a.version));
|
||||
const plural = span.length === 1 ? '' : 's';
|
||||
const changelogUrl = 'https://github.com/' + n.repo + '/blob/v' + n.version + '/CHANGELOG.md';
|
||||
const compare = n.prevTag ? '[Compare](https://github.com/' + n.repo + '/compare/' + n.prevTag + '...v' + n.version + ') or read ' : 'Read ';
|
||||
const head = [
|
||||
'## gstack v' + n.version + (n.hotfix ? ' (hotfix release)' : ' (weekly release)'),
|
||||
'',
|
||||
n.first || !n.prevVersion
|
||||
? 'First tagged release: the ' + span.length + ' version' + plural + (n.prevVersion ? ' since v' + n.prevVersion + ' (the week before it)' : '') + ', one line each; the [full changelog](' + changelogUrl + ') has every earlier one.'
|
||||
: span.length + ' version' + plural + ' since v' + n.prevVersion + ', one line each. ' + compare + 'the [full changelog](' + changelogUrl + ').',
|
||||
'',
|
||||
'Team-mode installs follow the stable branch, which now points here; gstack-config set update_channel main follows every merge instead ([docs/releases.md](https://github.com/' + n.repo + '/blob/v' + n.version + '/docs/releases.md)).',
|
||||
'',
|
||||
].join('\n');
|
||||
const bullets = span.map(e => '- **' + e.version + '**' + (e.date ? ' (' + e.date + ')' : '') + ': ' + (e.headline || 'see CHANGELOG.md'));
|
||||
let body = head + bullets.join('\n') + '\n';
|
||||
if (body.length > NOTES_CAP) {
|
||||
const kept: string[] = [];
|
||||
let size = head.length;
|
||||
for (const b of bullets) { if (size + b.length + 1 > NOTES_CAP - 200) break; kept.push(b); size += b.length + 1; }
|
||||
body = head + kept.join('\n') + '\n- ...and ' + (bullets.length - kept.length) + ' more versions; see CHANGELOG.md\n';
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
export interface Plan {
|
||||
sha: string; version: string; tag: string; date: string; hotfix: boolean;
|
||||
prevStable: string | null; prevVersion: string | null; prevTag: string | null;
|
||||
tagExists: 'no' | 'same' | 'other'; stableAt: boolean; notes: string; skipped: string[];
|
||||
}
|
||||
export type PlanResult = { ok: true; plan: Plan } | { ok: false; code: 'NOTHING_NEW' | 'NO_GREEN_COMMIT' | 'REFUSED'; message: string; skipped?: string[] };
|
||||
|
||||
export interface PlanOptions { repo: string; ref?: string; git: Git; gh: Gh; now?: () => number; windowDays?: number; windowCommits?: number }
|
||||
|
||||
export function planRelease(o: PlanOptions): PlanResult {
|
||||
const { git, gh, repo } = o;
|
||||
const windowDays = o.windowDays ?? WINDOW_DAYS;
|
||||
const skipped: string[] = [];
|
||||
const mainHead = git(['rev-parse', '--verify', 'refs/remotes/origin/main^{commit}']);
|
||||
if (!mainHead.ok) return { ok: false, code: 'REFUSED', message: 'origin/main is not fetched: ' + mainHead.out };
|
||||
const stable = git(['rev-parse', '--verify', '-q', 'refs/remotes/origin/stable^{commit}']);
|
||||
const prevStable = stable.ok ? stable.out : null;
|
||||
const frozen = activeFreeze(git);
|
||||
if (frozen) return { ok: false, code: 'REFUSED', message: frozen };
|
||||
|
||||
let candidates: string[];
|
||||
if (o.ref) {
|
||||
const r = git(['rev-parse', '--verify', o.ref + '^{commit}']);
|
||||
if (!r.ok) return { ok: false, code: 'REFUSED', message: '--ref ' + o.ref + ' is not a commit here: ' + r.out };
|
||||
const fp = git(['rev-list', '--first-parent', 'refs/remotes/origin/main']);
|
||||
if (!fp.out.split('\n').includes(r.out)) return { ok: false, code: 'REFUSED', message: '--ref ' + r.out.slice(0, 12) + " is not on main's first-parent history; a release is always a merge commit of main" };
|
||||
candidates = [r.out];
|
||||
} else {
|
||||
const since = new Date((o.now?.() ?? Date.now()) - windowDays * 86_400_000).toISOString();
|
||||
const list = git(['rev-list', '--first-parent', '--max-count=' + (o.windowCommits ?? WINDOW_COMMITS), '--since=' + since, 'refs/remotes/origin/main']);
|
||||
candidates = list.ok && list.out ? list.out.split('\n') : [];
|
||||
if (candidates.length === 0) return { ok: false, code: 'NOTHING_NEW', message: 'no commit on origin/main in the last ' + windowDays + ' days' };
|
||||
}
|
||||
|
||||
// stable itself stays a candidate so a cut that stopped half-way (tag and
|
||||
// stable pushed, release missing) is finished; nothing older is looked at.
|
||||
let sha: string | null = null;
|
||||
for (const c of candidates) {
|
||||
const v = gateVerdict(gh, repo, c);
|
||||
if (v.green) { sha = c; break; }
|
||||
skipped.push(c.slice(0, 12) + ': ' + v.why);
|
||||
if (prevStable && c === prevStable) { skipped.push(c.slice(0, 12) + ': already stable; nothing newer is green'); break; }
|
||||
}
|
||||
if (!sha) {
|
||||
if (o.ref) return { ok: false, code: 'REFUSED', message: '--ref ' + candidates[0]!.slice(0, 12) + ' is not green: ' + (skipped[0] ?? '').split(': ').slice(1).join(': '), skipped };
|
||||
return { ok: false, code: 'NO_GREEN_COMMIT', message: 'no green commit among ' + candidates.length + ' on origin/main; see the skipped list', skipped };
|
||||
}
|
||||
if (prevStable && sha !== prevStable && !git(['merge-base', '--is-ancestor', prevStable, sha]).ok) {
|
||||
return { ok: false, code: 'REFUSED', message: 'non-fast-forward: stable ' + prevStable.slice(0, 12) + ' is not an ancestor of ' + sha.slice(0, 12) + '; stable only moves forward along main' };
|
||||
}
|
||||
|
||||
const version = git(['show', sha + ':VERSION']).out.trim();
|
||||
if (!/^\d+(\.\d+)+$/.test(version)) return { ok: false, code: 'REFUSED', message: 'VERSION at ' + sha.slice(0, 12) + " is not a version: '" + version + "'" };
|
||||
const tag = 'v' + version;
|
||||
const existing = git(['rev-parse', '--verify', '-q', 'refs/tags/' + tag + '^{commit}']);
|
||||
const tagExists: Plan['tagExists'] = !existing.ok ? 'no' : existing.out === sha ? 'same' : 'other';
|
||||
if (tagExists === 'other') return { ok: false, code: 'REFUSED', message: 'tag ' + tag + ' already names ' + existing.out.slice(0, 12) + ', not ' + sha.slice(0, 12) + '; a version is stamped once per merge, so this needs a look' };
|
||||
if (prevStable === sha && tagExists === 'same' && gh(['release', 'view', tag, '--repo', repo, '--json', 'tagName']).status === 0) {
|
||||
return { ok: false, code: 'NOTHING_NEW', message: 'origin/main ' + sha.slice(0, 12) + ' is already released as ' + tag + '; nothing newer is green' };
|
||||
}
|
||||
|
||||
// The release this one follows: stable, or when stable already sits here
|
||||
// (a cut that stopped half-way), the newest release tag behind it.
|
||||
let before = prevStable;
|
||||
if (prevStable === sha) {
|
||||
const prior = git(['describe', '--tags', '--abbrev=0', '--match', 'v[0-9]*', '--first-parent', sha + '^']);
|
||||
const resolved = prior.ok && prior.out ? git(['rev-parse', '--verify', '-q', prior.out + '^{commit}']) : null;
|
||||
before = resolved?.ok ? resolved.out : null;
|
||||
}
|
||||
let prevVersion: string | null = null, prevTag: string | null = null;
|
||||
if (before) {
|
||||
const shown = git(['show', before + ':VERSION']);
|
||||
prevVersion = shown.ok ? shown.out.trim() || null : null;
|
||||
if (prevVersion && git(['rev-parse', '--verify', '-q', 'refs/tags/v' + prevVersion + '^{commit}']).ok) prevTag = 'v' + prevVersion;
|
||||
} else {
|
||||
const date = git(['show', '-s', '--format=%cI', sha]).out;
|
||||
const before = new Date(new Date(date).getTime() - windowDays * 86_400_000).toISOString();
|
||||
const older = git(['rev-list', '--first-parent', '-1', '--before=' + before, sha]);
|
||||
if (older.ok && older.out) prevVersion = git(['show', older.out + ':VERSION']).out.trim() || null;
|
||||
}
|
||||
const changelog = git(['show', sha + ':CHANGELOG.md']);
|
||||
const date = git(['show', '-s', '--format=%cs', sha]).out;
|
||||
const notes = releaseNotes({ repo, version, date, prevVersion, prevTag, entries: changelog.ok ? changelogEntries(changelog.out) : [], hotfix: Boolean(o.ref), first: !before });
|
||||
return { ok: true, plan: { sha, version, tag, date, hotfix: Boolean(o.ref), prevStable, prevVersion, prevTag, tagExists, stableAt: prevStable === sha, notes, skipped } };
|
||||
}
|
||||
|
||||
export interface CutResult { ok: boolean; lines: string[] }
|
||||
|
||||
/** Tag, push stable and create the release for a plan; each step is skipped when already done. */
|
||||
export function cutRelease(p: Plan, o: { repo: string; git: Git; gh: Gh; notesDir?: string }): CutResult {
|
||||
const { git, gh } = o;
|
||||
const lines: string[] = [];
|
||||
const existing = git(['rev-parse', '--verify', '-q', 'refs/tags/' + p.tag + '^{commit}']);
|
||||
if (existing.ok && existing.out !== p.sha) return { ok: false, lines: ['TAG: ' + p.tag + ' already names ' + existing.out.slice(0, 12) + ', not ' + p.sha.slice(0, 12)] };
|
||||
if (!existing.ok) {
|
||||
const t = git(['tag', '-a', p.tag, p.sha, '-m', 'gstack ' + p.tag + (p.hotfix ? ' (hotfix release)' : ' (weekly release)')]);
|
||||
if (!t.ok) return { ok: false, lines: [...lines, 'TAG: failed: ' + t.out] };
|
||||
const push = git(['push', 'origin', 'refs/tags/' + p.tag]);
|
||||
if (!push.ok) return { ok: false, lines: [...lines, 'TAG: push failed: ' + push.out] };
|
||||
lines.push('TAG: ' + p.tag + ' -> ' + p.sha.slice(0, 12) + ' pushed');
|
||||
} else {
|
||||
lines.push('TAG: ' + p.tag + ' already names ' + p.sha.slice(0, 12));
|
||||
}
|
||||
if (!p.stableAt) {
|
||||
const push = git(['push', 'origin', p.sha + ':refs/heads/stable']);
|
||||
if (!push.ok) return { ok: false, lines: [...lines, 'STABLE: push failed: ' + push.out] };
|
||||
lines.push('STABLE: ' + (p.prevStable ? p.prevStable.slice(0, 12) : 'none') + ' -> ' + p.sha.slice(0, 12));
|
||||
} else {
|
||||
lines.push('STABLE: already at ' + p.sha.slice(0, 12));
|
||||
}
|
||||
const view = gh(['release', 'view', p.tag, '--repo', o.repo, '--json', 'tagName']);
|
||||
if (view.status === 0) {
|
||||
lines.push('RELEASE: ' + p.tag + ' already published');
|
||||
} else {
|
||||
const dir = o.notesDir ?? fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-release-'));
|
||||
const file = path.join(dir, p.tag + '.md');
|
||||
fs.writeFileSync(file, p.notes);
|
||||
const created = gh(['release', 'create', p.tag, '--repo', o.repo, '--verify-tag', '--title', 'gstack ' + p.tag, '--notes-file', file]);
|
||||
if (created.status !== 0) return { ok: false, lines: [...lines, 'RELEASE: create failed: ' + created.stderr.trim().slice(0, 300)] };
|
||||
lines.push(('RELEASE: ' + p.tag + ' published ' + created.stdout.trim()).trim());
|
||||
}
|
||||
return { ok: true, lines };
|
||||
}
|
||||
|
||||
export function describePlan(p: Plan, dryRun: boolean): string[] {
|
||||
const lines = [
|
||||
'CANDIDATE: ' + p.sha + ' (' + p.date + ')' + (p.hotfix ? ' [hotfix: --ref]' : ''),
|
||||
'VERSION: ' + p.version + ' -> tag ' + p.tag + (p.tagExists === 'same' ? ' (exists at this commit)' : ''),
|
||||
'STABLE: ' + (p.stableAt ? 'already at ' + p.sha.slice(0, 12) + ' (finishing an earlier cut)' : (p.prevStable ? p.prevStable.slice(0, 12) + ' (v' + (p.prevVersion ?? '?') + ')' : 'none yet') + ' -> ' + p.sha.slice(0, 12)),
|
||||
'NOTES: ' + p.notes.split('\n').filter(l => l.startsWith('- ')).length + ' version lines, ' + p.notes.length + ' chars',
|
||||
];
|
||||
for (const s of p.skipped) lines.push('SKIPPED: ' + s);
|
||||
if (dryRun) {
|
||||
lines.push('DRY_RUN: nothing pushed, nothing created. The cut would run:',
|
||||
' git tag -a ' + p.tag + ' ' + p.sha + ' && git push origin refs/tags/' + p.tag,
|
||||
' git push origin ' + p.sha + ':refs/heads/stable',
|
||||
' gh release create ' + p.tag + ' --verify-tag --title "gstack ' + p.tag + '" --notes-file <notes>');
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function arg(name: string): string | undefined {
|
||||
const i = process.argv.indexOf(name);
|
||||
return i >= 0 ? process.argv[i + 1] : undefined;
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const repo = arg('--repo');
|
||||
const dryRun = process.argv.includes('--dry-run');
|
||||
const summary = arg('--summary');
|
||||
if (!repo) { console.error('usage: release-cut.ts --repo owner/name [--ref <sha>] [--dry-run] [--summary <file>]'); process.exit(2); }
|
||||
const git = gitIn(process.cwd());
|
||||
const emit = (lines: string[]) => {
|
||||
for (const l of lines) console.log(l);
|
||||
if (summary) fs.appendFileSync(summary, lines.map(l => '- ' + l).join('\n') + '\n');
|
||||
};
|
||||
const planned = planRelease({ repo, ref: arg('--ref'), git, gh: runGh });
|
||||
if (!planned.ok) {
|
||||
emit([planned.code + ': ' + planned.message, ...(planned.skipped ?? []).map(s => 'SKIPPED: ' + s)]);
|
||||
// A dry run that finds nothing green has done its job (the skips say why);
|
||||
// the real cut goes red so someone looks.
|
||||
process.exit(planned.code === 'NOTHING_NEW' || (dryRun && planned.code === 'NO_GREEN_COMMIT') ? 0 : 1);
|
||||
}
|
||||
emit(describePlan(planned.plan, dryRun));
|
||||
if (summary) fs.appendFileSync(summary, '\n<details><summary>Release notes</summary>\n\n' + planned.plan.notes + '\n</details>\n');
|
||||
else console.log('--- notes ---\n' + planned.plan.notes);
|
||||
if (dryRun) process.exit(0);
|
||||
const cut = cutRelease(planned.plan, { repo, git, gh: runGh });
|
||||
emit(cut.lines);
|
||||
process.exit(cut.ok ? 0 : 1);
|
||||
}
|
||||
@@ -275,6 +275,8 @@ export const FREE_ONLY_PR_FILES = [
|
||||
'.github/workflows/test-health.yml',
|
||||
'.github/workflows/eval-sweep.yml', // The weekly off-ship sweep on main; no PR-gate case reads it, and it never gates a PR.
|
||||
'.github/workflows/evals-main-backstop.yml', // The merge-train backstop on main: it dispatches evals.yml after a push to main; no PR-gate case reads it, and it never gates a PR.
|
||||
'.github/workflows/weekly-release.yml', // The Monday release cut (tag, stable, GitHub Release): reads check runs on main; no paid case reads it, and it never gates a PR.
|
||||
'scripts/release-cut.ts', // The release cut's logic, run only by weekly-release.yml; covered by the free test/release-cut.test.ts.
|
||||
'.github/actionlint.yaml', // Runner labels for actionlint only.
|
||||
'scripts/ship-measure-sweep.ts', // The weekly sweep CLI; it launches paid cases, never runs inside one.
|
||||
'.github/workflows/version-gate.yml',
|
||||
|
||||
@@ -192,8 +192,9 @@ function freeze(o: BackstopOptions, c: { gh: GhRunner; git: Git; repoKey: string
|
||||
}
|
||||
|
||||
const USAGE = `usage: bun run scripts/train-backstop.ts run --repo <owner/name> --branch <main> --sha <sha>
|
||||
[--settle-minutes 60] [--poll-seconds 60] [--gate-deadline-minutes 270] [--summary <file>]
|
||||
Exit 0 when inert, superseded, covered, deferred to a freeze's own backstop, or gated green; 1 when it froze the queue or could not finish.`;
|
||||
[--settle-minutes 60] [--poll-seconds 60] [--gate-deadline-minutes 270] [--summary <file>] [--outputs <file>]
|
||||
Exit 0 when inert, superseded, covered, deferred to a freeze's own backstop, or gated green; 1 when it froze the queue or could not finish.
|
||||
--outputs appends action=<action> (the workflow reads it as a step output and publishes the main-backstop check run from it).`;
|
||||
|
||||
if (import.meta.main) {
|
||||
const [command, ...rest] = process.argv.slice(2);
|
||||
@@ -208,5 +209,7 @@ if (import.meta.main) {
|
||||
console.log(line);
|
||||
if (summary) fs.appendFileSync(summary, `- ${line}\n`);
|
||||
}
|
||||
const outputs = flag('outputs');
|
||||
if (outputs) fs.appendFileSync(outputs, `action=${action}\n`);
|
||||
process.exit(action === 'frozen' || action === 'failed' ? 1 : 0);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# gstack setup — build browser binary + register skills with Claude Code / Codex
|
||||
set -e
|
||||
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
|
||||
umask 077 # Restrict new files to owner-only (0o600 files, 0o700 dirs)
|
||||
|
||||
# Heredoc delivery guard. bash 5.2+ writes a heredoc body <=64KiB through a
|
||||
@@ -362,7 +363,7 @@ _runtime_root_refused() {
|
||||
# --claude-model is validated before anything is written, and persisted once
|
||||
# Claude is known to be selected (below, and in the --refresh-registered parent).
|
||||
if [ "$CLAUDE_MODEL_SET" -eq 1 ]; then
|
||||
if ! _CLAUDE_MODEL_RESOLVED="$(cd "$SOURCE_GSTACK_DIR" && bun --no-env-file --config=/dev/null run scripts/models.ts claude-overlay "$CLAUDE_MODEL_ARG" 2>&1)"; then
|
||||
if ! _CLAUDE_MODEL_RESOLVED="$(cd "$SOURCE_GSTACK_DIR" && bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" run scripts/models.ts claude-overlay "$CLAUDE_MODEL_ARG" 2>&1)"; then
|
||||
printf 'Error: --claude-model %s\n' "$_CLAUDE_MODEL_RESOLVED" | sed '2,$s/^/ /' >&2
|
||||
echo " Nothing was installed or changed." >&2
|
||||
exit 1
|
||||
@@ -1317,7 +1318,7 @@ if [ "$LOCAL_INSTALL" -eq 0 ]; then
|
||||
_CODEX_PREFLIGHT_SELECTED=1
|
||||
fi
|
||||
fi
|
||||
bun --no-env-file --config=/dev/null "$SOURCE_GSTACK_DIR/scripts/preflight-codex-overlap.ts" \
|
||||
bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$SOURCE_GSTACK_DIR/scripts/preflight-codex-overlap.ts" \
|
||||
--source "$SOURCE_GSTACK_DIR" --namespace "$CODEX_SKILLS" \
|
||||
--selected "$_CODEX_PREFLIGHT_SELECTED" --local "$CODEX_REPO_LOCAL" \
|
||||
--windows "$IS_WINDOWS" --relocation "$HOME/.gstack/repos/gstack" || exit 1
|
||||
@@ -1584,7 +1585,7 @@ ensure_playwright_browser() {
|
||||
echo "gstack setup failed: Node.js is required on Windows" >&2
|
||||
return 1
|
||||
else
|
||||
probe_cmd="bun --no-env-file --config=/dev/null --eval 'import { chromium } from \"playwright\"; const browser = await chromium.launch(); await browser.close();'"
|
||||
probe_cmd="bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" --eval 'import { chromium } from \"playwright\"; const browser = await chromium.launch(); await browser.close();'"
|
||||
fi
|
||||
(
|
||||
cd "$SOURCE_GSTACK_DIR"
|
||||
@@ -1711,6 +1712,24 @@ bun_cmd() {
|
||||
"$BUN_CMD" "$@"
|
||||
}
|
||||
|
||||
# Users get production dependencies only. devDependencies are the test and eval
|
||||
# toolchain (the Claude Agent SDK's native binaries alone are ~440 MB on Linux), and
|
||||
# nothing setup, the build or an installed skill runs needs them. Bun never
|
||||
# prunes packages that are already installed, so a contributor's full
|
||||
# `bun install` (CONTRIBUTING.md, bin/dev-setup, CI) survives a later ./setup.
|
||||
# Bun's --production implies a frozen lockfile, so when bun.lock is out of date
|
||||
# (a fork or a local edit) refresh it first, then install production-only again.
|
||||
# A checkout that already has the dev toolchain (node_modules/typescript) is a
|
||||
# contributor's: a production install would leave its dev packages at old
|
||||
# versions after bun.lock moves, so it gets the full install instead.
|
||||
install_runtime_deps() {
|
||||
if [ -d node_modules/typescript ]; then
|
||||
bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install
|
||||
return
|
||||
fi
|
||||
bun_cmd install --production --frozen-lockfile 2>/dev/null || { bun_cmd install --lockfile-only && bun_cmd install --production --frozen-lockfile; }
|
||||
}
|
||||
|
||||
cleanup_copied_bun() {
|
||||
local rc=$?
|
||||
if [ "${BUN_CMD_WAS_COPIED:-0}" -eq 1 ]; then
|
||||
@@ -1928,7 +1947,7 @@ if [ "$NEEDS_BUILD" -eq 1 ]; then
|
||||
log "Building browse binary..."
|
||||
(
|
||||
cd "$SOURCE_GSTACK_DIR"
|
||||
bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install
|
||||
install_runtime_deps
|
||||
if [ "$CSO_BUILD_AVAILABLE" -eq 0 ]; then
|
||||
export GSTACK_SETUP_SKIP_CSO_BUILD=1
|
||||
fi
|
||||
@@ -2079,7 +2098,7 @@ if [ "$NEEDS_AGENTS_GEN" -eq 1 ]; then
|
||||
log "Generating .agents/ skill docs..."
|
||||
(
|
||||
cd "$SOURCE_GSTACK_DIR"
|
||||
bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install
|
||||
install_runtime_deps
|
||||
bun_cmd run gen:skill-docs --host codex --model "$CODEX_GENERATION_MODEL" ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"}
|
||||
)
|
||||
_prune_stale_generated "$SOURCE_GSTACK_DIR" "$AGENTS_DIR" "$CODEX_SKILLS"
|
||||
@@ -2092,7 +2111,7 @@ if [ "$INSTALL_FACTORY" -eq 1 ] && { [ "$NEEDS_BUILD" -eq 0 ] || [ -n "$_PLAN_MO
|
||||
log "Generating .factory/ skill docs..."
|
||||
(
|
||||
cd "$SOURCE_GSTACK_DIR"
|
||||
bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install
|
||||
install_runtime_deps
|
||||
bun_cmd run gen:skill-docs --host factory ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"}
|
||||
)
|
||||
fi
|
||||
@@ -2102,7 +2121,7 @@ if [ "$INSTALL_OPENCODE" -eq 1 ] && { [ "$NEEDS_BUILD" -eq 0 ] || [ -n "$_PLAN_M
|
||||
log "Generating .opencode/ skill docs..."
|
||||
(
|
||||
cd "$SOURCE_GSTACK_DIR"
|
||||
bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install
|
||||
install_runtime_deps
|
||||
bun_cmd run gen:skill-docs --host opencode ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"}
|
||||
)
|
||||
fi
|
||||
@@ -2112,7 +2131,7 @@ if [ "$INSTALL_CURSOR" -eq 1 ] && { [ "$NEEDS_BUILD" -eq 0 ] || [ -n "$_PLAN_MOD
|
||||
log "Generating .cursor/ skill docs..."
|
||||
(
|
||||
cd "$SOURCE_GSTACK_DIR"
|
||||
bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install
|
||||
install_runtime_deps
|
||||
bun_cmd run gen:skill-docs --host cursor ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"}
|
||||
)
|
||||
fi
|
||||
@@ -2122,7 +2141,7 @@ if [ "$INSTALL_COPILOT" -eq 1 ] && { [ "$NEEDS_BUILD" -eq 0 ] || [ -n "$_PLAN_MO
|
||||
log "Generating .copilot/ skill docs..."
|
||||
(
|
||||
cd "$SOURCE_GSTACK_DIR"
|
||||
bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install
|
||||
install_runtime_deps
|
||||
bun_cmd run gen:skill-docs --host copilot ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"}
|
||||
)
|
||||
fi
|
||||
@@ -2132,7 +2151,7 @@ if [ "$INSTALL_AGY" -eq 1 ] && { [ "$NEEDS_BUILD" -eq 0 ] || [ -n "$_PLAN_MODE_A
|
||||
log "Generating .agy/ skill docs..."
|
||||
(
|
||||
cd "$SOURCE_GSTACK_DIR"
|
||||
bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install
|
||||
install_runtime_deps
|
||||
bun_cmd run gen:skill-docs --host agy ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"}
|
||||
)
|
||||
fi
|
||||
|
||||
+24
-12
@@ -1154,7 +1154,9 @@ Lookup failure or ambiguous matches **STOP** for resolution, never mean no PR.
|
||||
Save the result for Step 19's recheck.
|
||||
|
||||
The pr-body section prepares the title from that result, then publishes;
|
||||
`--no-pr` or an automation lane takes the handoff section.
|
||||
`--no-pr` or an automation lane takes the handoff section. Unattended (`UNATTENDED_SHIP:`
|
||||
echoed): an open PR at HEAD takes pr-body's existing-PR path (body and title only, no
|
||||
`pr-create`, no push); else handoff.
|
||||
|
||||
> **STOP.** Before preparing the title and creating or updating the PR/MR with the verified documentation outcome (Steps 18-19), Read `~/.claude/skills/gstack/ship/sections/pr-body.md` and execute it
|
||||
> in full. Do not work from memory — that section is the source of truth for this step.
|
||||
@@ -1172,13 +1174,12 @@ JSON validation, storage and sync and takes **no path argument**.
|
||||
```
|
||||
|
||||
Substitute from earlier steps:
|
||||
- **COVERAGE_PCT**: Step 7 diagram's integer percentage; encode null/undetermined as -1
|
||||
- **COVERAGE_PCT_VALUE**: Step 7's `coverage_pct_value` (the gate's X) as an integer, or `null` when missing or ignored
|
||||
- **WEAK_GAPS**, **TESTS_EXTENDED**, **TESTS_REJECTED**: counts of Step 7's `weak_gaps`, `tests_extended` and `tests_rejected` (0 when the key is missing or ignored)
|
||||
- **REGRESSION_PROOF**: `{"red_at_head":N,"base_green":N,"base_unavailable":N}` from Step 7, or `null` when missing
|
||||
- **PLAN_TOTAL**: total plan items extracted in Step 8 (0 if no plan file)
|
||||
- **PLAN_DONE**: count of DONE + CHANGED items from Step 8 (0 if no plan file)
|
||||
- **VERIFY_RESULT**: "pass", "fail", or "skipped", set after Step 9 executes Step 8.1's verification list
|
||||
- **COVERAGE_PCT**: Step 7's integer percentage; -1 when null or undetermined
|
||||
- **COVERAGE_PCT_VALUE**: Step 7's `coverage_pct_value` (the gate's X) as an integer; `null` when missing or ignored
|
||||
- **WEAK_GAPS**, **TESTS_EXTENDED**, **TESTS_REJECTED**: counts of Step 7's `weak_gaps`, `tests_extended`, `tests_rejected` (0 when missing or ignored)
|
||||
- **REGRESSION_PROOF**: Step 7's `{"red_at_head":N,"base_green":N,"base_unavailable":N}`; `null` when missing
|
||||
- **PLAN_TOTAL** / **PLAN_DONE**: Step 8's plan items, total and DONE + CHANGED (0 with no plan file)
|
||||
- **VERIFY_RESULT**: "pass", "fail" or "skipped", set when Step 9 runs Step 8.1's verification list
|
||||
- **VERSION**: `NEW_VERSION` from Step 12; `null` (unquoted) under NO_VERSION
|
||||
|
||||
The shell supplies the branch. Run it without confirmation.
|
||||
@@ -1194,10 +1195,7 @@ GSTACK_STATE_ROOT=$(~/.claude/skills/gstack/bin/gstack-paths --get GSTACK_STATE_
|
||||
_NUDGE_MARKER="$GSTACK_STATE_ROOT/.plan-tune-nudge-shown"
|
||||
_QT=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
if [ ! -f "$_NUDGE_MARKER" ] && [ "$_QT" = "false" ]; then
|
||||
echo ""
|
||||
echo "gstack can learn from your AskUserQuestion answers. Run /plan-tune to opt in"
|
||||
echo "— it captures which prompts you find valuable vs noisy and (with hooks installed)"
|
||||
echo "auto-decides your never-ask preferences."
|
||||
printf '\ngstack can learn from your AskUserQuestion answers. Run /plan-tune to opt in: it captures which prompts you find valuable vs noisy and (with hooks installed) auto-decides your never-ask preferences.\n'
|
||||
mkdir -p "$GSTACK_STATE_ROOT" && touch "$_NUDGE_MARKER"
|
||||
fi
|
||||
```
|
||||
@@ -1206,6 +1204,20 @@ The marker or enabled question_tuning suppresses it; remove the marker to re-ena
|
||||
|
||||
---
|
||||
|
||||
## Step 22: Terminal line (every run)
|
||||
|
||||
The last line printed is the result line a parent greps (docs/unattended.md):
|
||||
|
||||
```bash
|
||||
~/.claude/skills/gstack/bin/gstack-ship-receipt result --pr <pr-number> # handoff: --body .gstack/tmp/ship-receipt.md
|
||||
```
|
||||
|
||||
It binds the receipt to HEAD, writes the run record and prints `GSTACK_RESULT: skill=ship
|
||||
status=complete run=<dir>`; `QUEUE_RECEIPT_STALE` means HEAD moved after the receipt (rewrite,
|
||||
repost, rerun). A run that STOPs earlier ends with `GSTACK_RESULT: skill=ship status=refused run=-`.
|
||||
|
||||
---
|
||||
|
||||
## Section self-check (before you finish)
|
||||
|
||||
List the applicable Section index entries and confirm each Read. If you worked from
|
||||
|
||||
Loaded 100 of 173 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user