diff --git a/.github/docker/Dockerfile.ci b/.github/docker/Dockerfile.ci index efe5aca83..841291cca 100644 --- a/.github/docker/Dockerfile.ci +++ b/.github/docker/Dockerfile.ci @@ -60,6 +60,21 @@ RUN curl --retry 5 --retry-delay 5 --retry-connrefused -fsSL \ && rm -rf /var/lib/apt/lists/* \ && git --no-lazy-fetch --version +# Partial clone: the eval jobs check out with `filter: blob:none` and read +# older files through on-demand blob fetches (.github/scripts/history-read.sh). +# Prove this Git makes a blobless clone and fetches a missing blob, so the image +# fails here instead of in a slice's history read. +RUN set -e; d=$(mktemp -d); \ + git init -q -b main "$d/src"; echo one > "$d/src/f"; git -C "$d/src" add f; \ + git -C "$d/src" -c user.name=ci -c user.email=ci@localhost commit -qm one; \ + echo two > "$d/src/f"; git -C "$d/src" -c user.name=ci -c user.email=ci@localhost commit -qam two; \ + git -C "$d/src" config uploadpack.allowFilter true; \ + git clone -q --filter=blob:none --no-local "file://$d/src" "$d/clone"; \ + test "$(git -C "$d/clone" config remote.origin.promisor)" = true; \ + if GIT_NO_LAZY_FETCH=1 git -C "$d/clone" cat-file -e HEAD~1:f 2>/dev/null; then echo "blob present: not a partial clone" >&2; exit 1; fi; \ + test "$(git -C "$d/clone" show HEAD~1:f)" = one; \ + rm -rf "$d"; echo "partial clone: ok ($(git --version))" + RUN python3 -m venv /tmp/gstack-ci-venv \ && /tmp/gstack-ci-venv/bin/python -m pip --version \ && rm -rf /tmp/gstack-ci-venv diff --git a/.github/scripts/history-read.sh b/.github/scripts/history-read.sh new file mode 100755 index 000000000..cb3c094d1 --- /dev/null +++ b/.github/scripts/history-read.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Read one file at a commit in a CI checkout that may be a blobless clone +# (actions/checkout `filter: blob:none`), where git fetches an old file's +# contents on demand and that fetch can fail. lib/history-read.ts is the +# TypeScript twin. +# +# Usage: history-read.sh [--absent ] +# +# A read that `git show` answers is the file. When it fails, existence comes +# from the ref's tree (`git ls-tree --full-tree`, local data only): a path the +# tree does not list is absent, so this prints (default: nothing) and +# exits 0. A listed path is retried, 3 tries in all in a partial clone (only a +# fetch can recover) and none otherwise, waiting HISTORY_READ_BACKOFF_SECONDS +# (default 2) times the attempt between tries. Anything else (a ref that does +# not resolve, a fetch that keeps failing) exits 1 with HISTORY_READ_FAILED +# naming the ref, the path and the git version, and never reads as absent. +# docs/troubleshooting.md#history-read-failed +set -euo pipefail + +if [ "$#" -ne 2 ] && { [ "$#" -ne 4 ] || [ "$3" != --absent ]; }; then + echo "usage: history-read.sh [--absent ]" >&2 + exit 2 +fi +ref=$1 file=$2 absent=${4:-} +out=$(mktemp) err=$(mktemp) +trap 'rm -f "$out" "$err"' EXIT + +fail() { + echo "::error::cannot read ${file} at ${ref}. $1 ($(git --version)). Fix: see docs/troubleshooting.md#history-read-failed (HISTORY_READ_FAILED)" >&2 + exit 1 +} + +if git show "${ref}:${file}" >"$out" 2>"$err"; then cat "$out"; exit 0; fi +listed=$(git ls-tree --full-tree "$ref" -- "$file" 2>"$err") || fail "git ls-tree failed: $(head -n 1 "$err")" +if [ -z "$listed" ]; then + printf '%s' "$absent" + exit 0 +fi + +tries=1 +if git config --get-regexp '^(remote\..+\.promisor|extensions\.partialclone)$' | grep -qvi ' false$'; then tries=3; fi +try=1 +while [ "$try" -lt "$tries" ]; do + sleep "$(( try * ${HISTORY_READ_BACKOFF_SECONDS:-2} ))" + if git show "${ref}:${file}" >"$out" 2>"$err"; then cat "$out"; exit 0; fi + try=$(( try + 1 )) +done +fail "git show failed ${tries} time(s): $(head -n 1 "$err")" diff --git a/.github/workflows/evals-main-backstop.yml b/.github/workflows/evals-main-backstop.yml index 2826bf353..2c3f30303 100644 --- a/.github/workflows/evals-main-backstop.yml +++ b/.github/workflows/evals-main-backstop.yml @@ -49,7 +49,17 @@ jobs: - name: Read the train policy (main) id: policy run: | - train=$(git show "${GITHUB_SHA}:.gstack/ship-policy.json" 2>/dev/null | jq -c 'if (.train | type) == "object" then .train else null end' 2>/dev/null || true) + ref="$GITHUB_SHA" file=.gstack/ship-policy.json policy=null + read_failed() { echo "::error::cannot read ${file} at ${ref}. $1 ($(git --version)). Fix: see docs/troubleshooting.md#history-read-failed (HISTORY_READ_FAILED)" >&2; exit 1; } + listed=$(git ls-tree --full-tree "$ref" -- "$file") || read_failed "git ls-tree failed" + if [ -n "$listed" ]; then + for try in 1 2 3; do + policy=$(git show "${ref}:${file}") && break + if [ "$try" = 3 ]; then read_failed "git show failed 3 times"; fi + sleep $((try * 2)) + done + fi + train=$(jq -c 'if (.train | type) == "object" then .train else null end' <<<"$policy") || { echo "::error::${GITHUB_SHA}:.gstack/ship-policy.json is not valid JSON; fix the policy file" >&2; exit 1; } if [ -z "$train" ] || [ "$train" = null ]; then echo "train=false" >> "$GITHUB_OUTPUT" echo "- Backstop inert: main's .gstack/ship-policy.json declares no train block; per-PR gates cover main" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/evals.yml b/.github/workflows/evals.yml index e7b81e55b..fd5ed557c 100644 --- a/.github/workflows/evals.yml +++ b/.github/workflows/evals.yml @@ -179,6 +179,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 + filter: blob:none persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 @@ -193,9 +194,19 @@ jobs: DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} run: | base="${BASE_SHA:-origin/${DEFAULT_BRANCH}}" - train=$(git show "${base}:.gstack/ship-policy.json" 2>/dev/null | jq -c 'if (.train | type) == "object" then .train else null end' 2>/dev/null || true) - artifact=$(jq -r '.record_artifact // ""' <<<"${train:-null}" 2>/dev/null || true) - check=$(jq -r '.full_gate_check // ""' <<<"${train:-null}" 2>/dev/null || true) + ref="$base" file=.gstack/ship-policy.json policy=null + read_failed() { echo "::error::cannot read ${file} at ${ref}. $1 ($(git --version)). Fix: see docs/troubleshooting.md#history-read-failed (HISTORY_READ_FAILED)" >&2; exit 1; } + listed=$(git ls-tree --full-tree "$ref" -- "$file") || read_failed "git ls-tree failed" + if [ -n "$listed" ]; then + for try in 1 2 3; do + policy=$(git show "${ref}:${file}") && break + if [ "$try" = 3 ]; then read_failed "git show failed 3 times"; fi + sleep $((try * 2)) + done + fi + train=$(jq -c 'if (.train | type) == "object" then .train else null end' <<<"$policy") || { echo "::error::${base}:.gstack/ship-policy.json is not valid JSON; fix the policy file" >&2; exit 1; } + artifact=$(jq -r '.record_artifact // ""' <<<"${train:-null}") + check=$(jq -r '.full_gate_check // ""' <<<"${train:-null}") if [ -z "$train" ] || [ "$train" = null ]; then echo "train=false" >> "$GITHUB_OUTPUT" echo "- Train policy: none at ${base}; this run routes as before" >> "$GITHUB_STEP_SUMMARY" @@ -470,6 +481,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 + filter: blob:none persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 @@ -528,6 +540,7 @@ jobs: # Preserve full history for merge-base diff selection. Moving the # planner off the eval image must not change its selection inputs. fetch-depth: 0 + filter: blob:none persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 @@ -670,6 +683,7 @@ jobs: # shards on the lane's first live run ("ambiguous argument # 'main...HEAD'"). The manifest still governs WHICH shards run. fetch-depth: 0 + filter: blob:none persist-credentials: false - name: Fix bun temp @@ -903,6 +917,7 @@ jobs: with: ref: ${{ inputs.screen_merge_sha }} fetch-depth: 0 + filter: blob:none persist-credentials: false - name: Fix bun temp @@ -1029,6 +1044,7 @@ jobs: with: ref: ${{ fromJSON(needs.route.outputs.bisect_refs)[matrix.slice] }} fetch-depth: 0 + filter: blob:none persist-credentials: false - name: Verify the prefix checkout @@ -1173,6 +1189,7 @@ jobs: # history of its prefix commits T1..Tk for the train record. ref: ${{ inputs.screen_merge_sha }} fetch-depth: ${{ needs.plan-slices.outputs.mode == 'train' && '0' || '1' }} + filter: blob:none persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 diff --git a/.github/workflows/free-tests.yml b/.github/workflows/free-tests.yml index 569b858fe..4af58829f 100644 --- a/.github/workflows/free-tests.yml +++ b/.github/workflows/free-tests.yml @@ -65,8 +65,10 @@ jobs: with: persist-credentials: false # Full history: the seed ratchet compares the allowlist with its - # merge-base copy, which a depth-1 checkout cannot read. + # merge-base copy, which a depth-1 checkout cannot read. Blobless: + # that copy is fetched on demand, and a failed read fails the ratchet. fetch-depth: 0 + filter: blob:none - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.4.2 diff --git a/.github/workflows/version-gate.yml b/.github/workflows/version-gate.yml index 1be24d4e6..b64ec092a 100644 --- a/.github/workflows/version-gate.yml +++ b/.github/workflows/version-gate.yml @@ -23,6 +23,9 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 + # Blobless: full history with file contents fetched on demand; the + # reads below fail loudly when a fetch fails (.github/scripts/history-read.sh). + filter: blob:none ref: ${{ github.event.pull_request.head.sha }} # The head's release_tool runs inside the gate (lib/version-gate-train.ts); # no token may sit in the checkout it stages. The base fetch below is public. @@ -49,8 +52,12 @@ jobs: set -euo pipefail PR_VERSION=$(cat VERSION | tr -d '[:space:]') BASE_REF="${{ github.event.pull_request.base.ref }}" - git fetch origin "$BASE_REF" --quiet || true - BASE_VERSION=$(git show "origin/$BASE_REF:VERSION" 2>/dev/null | tr -d '[:space:]' || echo "0.0.0.0") + for try in 1 2 3; do + git fetch origin "$BASE_REF" --quiet && break + if [ "$try" = 3 ]; then echo "::error::git fetch origin $BASE_REF failed 3 times ($(git --version)). Fix: see docs/troubleshooting.md#history-read-failed (HISTORY_READ_FAILED)"; exit 1; fi + sleep $((try * 2)) + done + BASE_VERSION=$(bash .version-gate/.github/scripts/history-read.sh "origin/$BASE_REF" VERSION --absent 0.0.0.0 | tr -d '[:space:]') { echo "pr_version=$PR_VERSION" echo "base_version=$BASE_VERSION" diff --git a/.gitignore b/.gitignore index 6a3ae8915..b042b9ded 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ .env node_modules/ +node_modules.gstack-migrate/ +node_modules.gstack-migrate.lock/ dist/ browse/dist/ design/dist/ @@ -40,6 +42,7 @@ lib/cso/images/gstack-cso-preparation .cursor/ .copilot/ .agy/ +.gemini-cli/ .openclaw/ .hermes/ .gbrain/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 078111f89..4b27a46ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,84 @@ # Changelog +## [1.92.23.0] - 2026-11-01 + +- **OpenClaw and Hermes: opt-in start hook.** `gstack-harness register openclaw|hermes --start-hook` (or `gstack-config set harness_start_hook on`, or `GSTACK_HARNESS_START_HOOK=1`) adds a `gstack-start` plugin that puts a one-line note on every user turn telling the agent to run a gstack skill's start command as written. Agents in these harnesses read gstack's skills but ran that command on 1 of 29 chances. gstack turns the plugin on through each harness's own CLI, checks that it is active, and exits 4 when it is not. It is off by default, the choice is saved across upgrades, a plugin you disable in the harness stays off, and `--no-start-hook`, unregister and uninstall take it out. See docs/hosts.md#start-hook. + +## [1.92.22.0] - 2026-11-01 + +**gstack installs into Gemini CLI, and installs made before v1.91.231.0 finally drop the test and eval packages they never needed.** `./setup --host gemini` puts every gstack skill in `~/.gemini/skills`, where Gemini CLI 0.66.0 lists them (#1247). Separately, `./setup` used to treat any checkout that had TypeScript installed as a contributor's, so every install made before v1.91.231.0 (which ran a full `bun install`) kept about 510 MB of dev packages in `node_modules` forever (#3447). + +### Added +- **Gemini CLI host** (experimental): `./setup --host gemini` (alias `gemini-cli`; `--host auto` picks it only when `gemini` is on PATH) links `gstack-` directories into `~/.gemini/skills` plus the `~/.gemini/skills/gstack` runtime root. The render goes to `.gemini-cli/` with frontmatter names that match their directories, `CLAUDE.md` read as `GEMINI.md`, and a tool glossary for Gemini CLI's tool names and for Antigravity CLI, which can read the same folder. Ask for a skill by name (`gstack-office-hours`); Gemini CLI loads it with `activate_skill`. `gstack-uninstall` removes only gstack-managed entries there, so your own skills stay. `--local --host gemini` is refused with a note that Gemini CLI loads workspace skills only in trusted folders. docs/ADDING_A_HOST.md lists the verified facts. +- **An explicit contributor marker**: `bin/dev-setup` runs `git config gstack.contributor true`, and `GSTACK_CONTRIBUTOR=1` marks a single run. A marked clone keeps the full install on every `./setup`. + +### Changed +- **An unmarked clone that still has the dev toolchain moves to production dependencies once.** setup takes a lock in the checkout, moves `node_modules` aside, installs production dependencies, checks every runtime package resolves, then deletes the old copy. If the install fails, the full `node_modules` goes back and setup prints `SETUP_DEPS_MIGRATE_FAILED` with the cause; nothing is removed and the next `./setup` tries again. CI, and a checkout whose git config cannot be read, never migrate. On a real install `node_modules` went from 1079 MB to 569 MB. Bun's shared package cache is left alone, because other projects can read it through symlinks. + +### Tests +- test/setup-production-deps.test.ts: the marker (git config, env, CI, unreadable git config), the one-time migration and its no-op rerun, restore on a failed or unverified install, recovery of an interrupted move, the lock, an untouched Bun cache, and a real-Bun fixture for a marked and an unmarked clone. test/uninstall.test.ts: Gemini CLI entries removed, the user's own kept. Host config, conformance, install-registry, render-prune and runtime-root tests cover the new host. + +## [1.92.21.0] - 2026-11-01 + +**The PR and train path check out blobless clones, and the history reads they gate on fail loudly instead of reading a failed fetch as "absent" (GSTA-203 PR 5).** Free Tests' plan job, the Version Gate and E2E Evals' route, screen summary, planner, slices, reruns, bisect and report checked out every file of every commit (about 232 MB) to read a handful of old files. They now check out with `filter: blob:none` (about 17 MB), and git fetches an old file's contents the first time something reads it. + +### Changed +- **Blobless checkouts** (`filter: blob:none`) in `free-tests.yml` (free-plan), `version-gate.yml` and `evals.yml` (route, screen-summary, plan-slices, eval-slices, eval-rerun, train-bisect, slices-report). The release, CSO, periodic, marathon, sweep and recorder workflows and the main backstop keep full clones. `test/ci-history-read.test.ts` names each with its reason and fails on any other full-history checkout without the filter, printing the file and line to fix. +- **History reads fail loudly.** The train-policy reads in the route and backstop jobs stay inline (git and jq only, so no repository code decides whether a PR push starts paid work). The Version Gate's base `VERSION` read goes through `.github/scripts/history-read.sh` from the gate's merge-commit checkout, and its train classification through the TypeScript twin, `lib/history-read.ts`, which also lists the merge base's changed files without rename detection so that diff needs no file contents. Each one checks existence with `git ls-tree`, reads with `git show` (3 tries in a partial clone) and fails with `HISTORY_READ_FAILED` naming the ref, the path and the git version (docs/troubleshooting.md#history-read-failed). A file that is really absent keeps its old meaning. A ship policy that is not valid JSON now fails the route and backstop jobs instead of reading as "no train". The Version Gate goes red when `git fetch origin ` fails 3 times instead of carrying on. `gstack-restamp` and `gstack-ship-queue land` read the base branch's CHANGELOG the same way, so a read that fails stops them with `HISTORY_READ_FAILED` instead of treating the base as having no released sections. +- **The eval image asserts partial clone support.** `.github/docker/Dockerfile.ci` makes a blobless clone and fetches a missing blob at build time, so an image whose git cannot do that fails to build. + +## [1.92.20.0] - 2026-11-01 + +**`/retro global` now sees Kimi Code, OMP and Antigravity sessions (#2917, #1977).** `gstack-global-discover` scanned only Claude Code, Codex and Gemini. A week spent in other agent CLIs went uncounted, and nothing showed a zero to notice. + +### Added +- **Kimi Code** (`~/.kimi-code/session_index.jsonl`, window by `state.json` updatedAt, repo by workDir) and **OMP** (`~/.omp/agent/sessions/*/*.jsonl`, window by file mtime, repo by the cwd in the session file; `~/.omp/logs` is never counted) are counted per repo. +- **Antigravity** (`~/.gemini/antigravity-cli/conversations/*.db`) is counted by the later of each database's and its `-wal` file's mtime. Its sessions count in `total_sessions` but belong to no repo, because the CLI stores the working directory inside an undocumented blob. + +### Changed +- **One scanner registry** drives the JSON, the summary and the stderr line. Every registered tool appears in `tools`, zeros included, with a `label`. Antigravity's entry carries `unattributed: true`. Existing keys (`claude_code`, `codex`, `gemini`) keep their paths, and each repo's `sessions` lists every attributed tool. +- **/retro global** builds its session counts and Tool Usage Analysis from that list. A tool missing from an older saved retro counts as 0. + +Thanks to @richardiitse (#2917) and @Akagilnc (#1977) for the reports and the store layouts. + +## [1.92.19.0] - 2026-11-01 + +**On Linux and Windows the renderer goes straight to gstack's own browser instead of asking for Aside (#2864).** `probeAside()` in `lib/aside-render.ts`, used by /make-pdf, /diagram and `gstack-render`, used to run `aside --version` on any platform. When that failed it told the user to install a browser that only ships for macOS. Off macOS it now answers `NEEDS_ASIDE` with "Aside is macOS-only (this is linux)", without starting a process, and the renderer moves on to gstack's own browser. macOS behavior and `GSTACK_SKIP_ASIDE=1` are unchanged. + +### Changed +- **`probeAside(timeoutMs, platform)`** takes the platform, which defaults to `process.platform`. The fake-CLI probe tests pass `darwin`, so they still run on Linux CI. + +Thanks to @furkankoykiran for the report. The bash readiness block inside browsing skills already prints `NEEDS_ASIDE: Linux` and is unchanged. + +## [1.92.18.0] - 2026-11-01 + +- `install.sh --project` (through `gstack-capy-install`) resolves the directory it names to its git toplevel, which is where pregate admission looks for the install verdict. `--project /` now writes, and reads pins from, the same place as `--project `, so a Bun pin declared only in a subpackage no longer applies. A value that isn't a directory, such as a repo slug or a directory this shell can't enter, is a usage error that asks for the checkout's path, and it stops before anything is cloned or installed. Before this change, a slug or a subdirectory keyed the verdict somewhere admission never looks, so a later pregate admission check read a stale verdict (GST-743). + +## [1.92.17.0] - 2026-11-01 + +- `gstack-review-dispatch pass` takes the readers' diff base from the merge base with `origin/`, the same base the review plan scores and the targeted reader checks. Before, a local `` branch that lagged `origin/` gave every brief the stale merge base, so reviewers read upstream commits the branch only inherited (GST-716). + +## [1.92.16.0] - 2026-11-01 + +**/ship no longer pays for a second review loop when only main moved, a test file changed, or the docs audit's own requested fix landed.** Each of these used to count as a product change: a base merge after review (`ROUTE: … behavior=86` on gbrain #7739), main rewording the line next to a reviewed hunk (#3631, founderstar#441), a test-only fix (gbrain #8084), and a one-line index link the audit asked for (gbrain #8087). Each one cost another ~$19 loop or an owner's documentation exception. A real product change still sends the PR back through the full loop (GST-407, GST-384, GST-391). + +### Changed +- **`gstack-ship-release finish --reviewed-base `** (the merge base /ship now saves with the reviewed tree) compares the branch's own patch per path. Paths only the base moved count as `base=`. A test code edit that only adds lines, with no gate edit (a skipped test, a snapshot or a relaxed gate value), counts as `tests=`: one fix-check reviewer reads its test hunks in the scoped-review packet, and the lanes rerun it, instead of the full loop. No test edit skips review. Name-only test conventions (`test_*.py`, `*Test.java`, `*Tests.swift`) count only under a test directory, so a product file named like a test takes the full loop. A removed or changed line, such as a weakened assertion, or a test file the scan cannot read still goes through the full loop. Without the flag the route is unchanged and prints `base=unknown`. +- **A version stamp after review counts as release metadata.** `finish` used to read a slot stamp in `stamp_files` paths (a plugin.json `"version"` line, a stamp comment in a doc) as a behavior and docs change, so it stopped for the loop (`ROUTE: … behavior=8 docs=5` on gbrain #8095). A `stamp_files` path whose only edit swaps the old version for the new one, under a built-in form or a `stamp_patterns` template, is now metadata, the same rule the tree receipt and carry use. Any other edit to that path still stops. +- **Adjacent base edits get a scoped review, not the full loop.** When the branch's +/- lines are the reviewed ones in the same places and only context lines the base edited differ, `finish` stops with `context=N` and `gstack-ship-receipt carry` with `RECEIPT_CARRY_SCOPED_REVIEW`. Both write a packet with just those hunks. A packet section over 200 lines is too large for one reviewer, so that path takes the full loop instead. After one fix-check reviewer comes back clean, `--context-reviewed ` or `carry --scoped-review ` continues, and the carry reason names the reviewer. The same lines moved somewhere else are still a product change. +- **Docs audit freshness** ignores test code and paths only the base moved. `--docs-requested --docs-result ` carries a fix the audit's own result asked for when its blockers or decisions name that exact path, the edit is at most 5 lines, and the doc caps still pass. +- **`gstack-docs-candidate attempt --state `** says whether an audit launch counts against Step 14.5's budget. A launch the child's install check refused is not counted, once per invocation. A second refusal stops for the install fix. + +### Tests +- `test/ship-rereview-triggers.test.ts` reproduces each trigger and runs the negative controls: a product edit, a changed conflict resolution, the same line moved to another place (with and without a base change), a skipped test, and product files named `café.ts` and `[id].ts`. + +## [1.92.15.0] - 2026-11-01 + +bump: minor + +- Plan reviews check facts and keep one task list. `gstack-plan-reality check` now opens every `file:line` receipt and reports a missing file or out-of-range line as `REALITY_RECEIPT_UNRESOLVED` (#973). The /office-hours spec reviewer reads the repository, checks command flags through `gstack-office-hours-review probe --help ` (gstack bins that handle `--help` and a short list of common CLIs with their built-in subcommands only, run without your keys or tokens and never from a repository-local path; everything else is refused with `OFFICE_HOURS_PROBE_REFUSED`) and writes out the arithmetic behind any number it questions (#973). A plan-review rerun reconciles the last task list through the new `gstack-tasks` (`prior`, `add`, `supersede`), with task text written to a file rather than a command line, an empty list now means "ran, no findings", and `add` refuses while an earlier task is neither carried nor closed, so /autoplan's aggregate no longer drops an open task or shows a stale one (#1791). +- /office-hours saves the approved wireframe beside the design doc and names it on a `Wireframe:` line, and /plan-design-review finds the design doc and rates the plan against that image (#340). /plan-eng-review and /plan-ceo-review tie storage to the data's queries, constraints and update boundaries rather than file count (#1048). The office-hours closing names the founder signals it counted before the note (#513). + ## [1.92.14.0] - 2026-11-01 **A repository inside another repository can now be its own project: create an empty `.gstack-project` file in it.** gstack files each project's checkpoints, learnings and decisions under the outermost repository's slug, so a repo checked out inside a workspace repo filed its memory under the workspace and `/context-restore` couldn't find it later (#2649: 5 of one user's 16 nested repos, one with 73 checkpoints). The same marker in a plain folder above several repos gives them one shared memory, so a plan made in the umbrella carries into the repo where you build it (#1923). Nothing changes for anyone who doesn't create the file, and `GSTACK_PROJECT_SLUG` still wins. diff --git a/CLAUDE.md b/CLAUDE.md index d425dc6e7..f2a630b7b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,7 +129,7 @@ and commit both; never edit the generated file (`test/design-checklist-sync.test.ts` fails on drift). Generation uses each host's `defaultModel` (`claude` for most hosts, `gpt` -for Codex, `gemini` for Antigravity's `agy`) unless `--model` is explicit. Codex installs additionally read the +for Codex, `gemini` for Antigravity's `agy` and Gemini CLI) unless `--model` is explicit. Codex installs additionally read the top-level model from `${CODEX_HOME:-~/.codex}/config.toml`; rerun `./setup --host codex` after changing that model. Note: `bun run build` and a bare `gen:skill-docs --host codex` render the host default (gpt) — if your diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 715592905..6132d56ac 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,7 +14,7 @@ bun install # install dependencies bin/dev-setup # activate dev mode ``` -`./setup` on its own installs production dependencies only. `bun install` (and `bin/dev-setup`, which runs it) adds the dev toolchain the tests and evals need: the Claude Agent SDK, TypeScript and Prettier. Once they are installed, a later `./setup` runs the full install too, so they stay at `bun.lock`'s versions. +`./setup` on its own installs production dependencies only. `bin/dev-setup` adds the dev toolchain the tests and evals need (the Claude Agent SDK, TypeScript and Prettier) and marks the clone as a contributor's with `git config gstack.contributor true`. In a marked clone a later `./setup` runs the full install too, so the toolchain stays at `bun.lock`'s versions. In an unmarked clone `./setup` moves back to production dependencies, which is how installs made before v1.91.231.0 drop the toolchain they never needed. If you ran plain `bun install` instead of `bin/dev-setup`, run `git config gstack.contributor true` once, or set `GSTACK_CONTRIBUTOR=1` for a single run. > **Full clone vs shallow.** The README's user-facing install uses `--depth 1` for speed. As a contributor, use a full clone (no `--depth` flag) — you'll need history for `git log`, `git blame`, `git bisect`, and reviewing PRs against earlier versions. If you already have a `--depth 1` clone from following the README, promote it to a full clone with `git fetch --unshallow`. @@ -736,11 +736,11 @@ worth the review overhead. ## Multi-host development -gstack generates SKILL.md files for 10 hosts from one set of `.tmpl` templates. +gstack generates SKILL.md files for 13 hosts from one set of `.tmpl` templates. Each host is a typed config in `hosts/*.ts`. The generator reads these configs to produce host-appropriate output (different frontmatter, paths, tool names). -**Supported hosts:** Claude (primary), Codex, Factory, Kiro, OpenCode, Slate, Cursor, OpenClaw, Hermes, GBrain. +**Supported hosts:** Claude (primary), Codex, Factory, Kiro, OpenCode, Slate, Cursor, OpenClaw, Hermes, GBrain, Copilot, Antigravity, Gemini CLI. ### Generating for all hosts @@ -749,7 +749,7 @@ to produce host-appropriate output (different frontmatter, paths, tool names). bun run gen:skill-docs # Claude (default) bun run gen:skill-docs --host codex # Codex bun run gen:skill-docs --host opencode # OpenCode -bun run gen:skill-docs --host all # All 10 hosts +bun run gen:skill-docs --host all # All 13 hosts # Or use build, which does all hosts + compiles binaries bun run build diff --git a/README.md b/README.md index f140ad1c6..011832a81 100644 --- a/README.md +++ b/README.md @@ -77,12 +77,13 @@ installs and passes the conformance tests but has no certification run yet; | Kiro | experimental | `--host kiro` → `~/.kiro/skills/gstack-*/` | ask for `gstack-office-hours` | advisory, not blocked | Codex CLI, signed in | `./setup --host kiro` | | GitHub Copilot CLI | experimental | `--host copilot` → `~/.copilot/skills/gstack-*/` | `/gstack-office-hours` | advisory, not blocked | Codex CLI, signed in | `./setup --host copilot` | | Antigravity CLI | experimental | `--host agy` (or `--host antigravity`) → `~/.gemini/antigravity-cli/skills/gstack-*/` | `/gstack-office-hours` | advisory, not blocked | Codex CLI, signed in | `./setup --host agy` | +| Gemini CLI | experimental | `--host gemini` (or `--host gemini-cli`) → `~/.gemini/skills/gstack-*/` | ask for `gstack-office-hours` | advisory, not blocked | Codex CLI, signed in | `./setup --host gemini` | | Slate | instruction-only | `--host slate` (points at the Claude install; Slate reads `.claude/skills`) | `/office-hours` via the Claude install | advisory, not blocked | — | `./setup --host claude` | | OpenClaw | experimental | `--host openclaw` (five task skills and one `gstack` router skill in OpenClaw's skills folder; automatic when setup runs inside OpenClaw; ACP route in [docs/OPENCLAW.md](docs/OPENCLAW.md)) | ask for the task; the agent opens the matching task skill or the `gstack` router | advisory, not blocked | — | `./setup --host openclaw` | | Hermes | experimental | `--host hermes` (every skill in `$HERMES_HOME/skills/gstack`; automatic when setup runs inside Hermes) | ask for the task, or `/gstack-review` | advisory, not blocked | — | `./setup --host hermes` | | GBrain (mod) | instruction-only | `--host gbrain` (brain-aware variants ship from the GBrain repo) | — | advisory, not blocked | — | — | -Per-host notes (agent harnesses, Copilot, Antigravity, OpenClaw and ClawHub, the Codex behavioral profile and model tiers, outside-review providers, the design binary's models) are in [docs/hosts.md](docs/hosts.md). Any rules-reading agent (Zed, Amp, Jules) can use the 2KB [agents digest](agents-digest/gstack-AGENTS.md) with no install. Adding a host: [docs/ADDING_A_HOST.md](docs/ADDING_A_HOST.md). +Per-host notes (agent harnesses, Copilot, Antigravity, Gemini CLI, OpenClaw and ClawHub, the Codex behavioral profile and model tiers, outside-review providers, the design binary's models) are in [docs/hosts.md](docs/hosts.md). Any rules-reading agent (Zed, Amp, Jules) can use the 2KB [agents digest](agents-digest/gstack-AGENTS.md) with no install. Adding a host: [docs/ADDING_A_HOST.md](docs/ADDING_A_HOST.md). ## See it work @@ -167,7 +168,7 @@ Each skill feeds into the next. `/office-hours` writes a design doc that `/plan- | `/eval-plan` | **Eval Planner** | Preregister a paid eval before the first priced call: bars, arms, decoys and stop rules, a $0 stub-model dry run over the real inputs, and a three-item priced pilot whose extrapolation and spend table gate the approval. | | `/document-release` | **Technical Writer** | Audit changed behavior against project docs on every ship, before final verification and publication. Also runs standalone. Shows updated, reviewed/current or blocked docs and any remaining gaps. | | `/document-generate` | **Documentation Author** | Generate missing docs from scratch using the Diataxis framework. Researches the codebase first, then writes reference / how-to / tutorial / explanation docs that actually match the code. Invokable standalone or chained from `/document-release` when the coverage map finds gaps. Learn more: [tutorial](docs/tutorial-document-generate.md) • [how-to](docs/howto-document-a-shipped-feature.md) • [why Diataxis](docs/explanation-diataxis-in-gstack.md). | -| `/retro` | **Eng Manager** | Team-aware weekly retro. Per-person breakdowns, shipping streaks, test health trends, growth opportunities. `/retro global` runs across all your projects and AI tools (Claude Code, Codex, Gemini). | +| `/retro` | **Eng Manager** | Team-aware weekly retro. Per-person breakdowns, shipping streaks, test health trends, growth opportunities. `/retro global` runs across all your projects and AI tools (Claude Code, Codex, Gemini, Kimi Code, OMP, Antigravity). | | `/browse` | **QA Engineer** | Give the agent eyes. Drives your [Aside](https://aside.com) browser first — your real sessions, real clicks, real screenshots — through deterministic `aside repl` scripts. No Aside? It falls back to gstack's own Chromium: real clicks, ~100ms per command, and `/open-gstack-browser` shows it headed with sidebar, anti-bot stealth, and auto model routing. Every other browser skill stands on it. | | `/scrape` | **Data Extractor** | Pull structured data off a web page — tables, lists, prices — in your Aside browser with the page's real logged-in state. On the fallback browser, `/skillify` turns the flow into a permanent browser-skill that runs in ~200ms next time. | | `/setup-browser-cookies` | **Session Manager** | Copy selected cookies from Chrome, Chromium, Brave, Edge, Windows-only Opera and Opera GX, or macOS-only Comet and Arc into gstack's bundled browser. Choose your profile and domains; copying and sign-in verification are separate. Only needed on the fallback path — Aside already has your sessions. | diff --git a/TODOS.md b/TODOS.md index 99cef6db2..cdbabf43e 100644 --- a/TODOS.md +++ b/TODOS.md @@ -82,7 +82,7 @@ Each item was decided in the Wave 9 plan and parked with its reason. Credit the The 73 open feature requests (75 with #3061 and #3122, which the themes below also name), grouped by theme with counted demand (issues in the theme). Each issue stays open under the `parked` label with a link to its theme here (the rule is in docs/triage.md); the three largest cross-cutting themes (new hosts, provider pluralism, install footprint) also have tracking issues. Pick a theme up as a whole and credit every linked reporter. -- **New hosts (8 issues, plus #3061 above and 9 PRs parked on Oct 8)** — Kimi CLI (#1072), Gemini CLI (#1247), Pi Agent (#1288), Claude Desktop Cowork (#1345), Letta Code (#2117), team mode for non-Claude hosts (#890), `/retro global` discovery of Antigravity (#1977) and Kimi Code / OMP sessions (#2917). Waits for the host-config work; see "Parked contributor ideas (Oct 8 triage)" for the parked host PRs. **Priority:** P3. +- **New hosts (7 issues, plus #3061 above and 9 PRs parked on Oct 8)** — Kimi CLI (#1072), Gemini CLI (#1247), Pi Agent (#1288), Claude Desktop Cowork (#1345), Letta Code (#2117), team mode for non-Claude hosts (#890), `/retro global` repo attribution for Antigravity sessions (#1977: they are counted now, but the working directory sits in an undocumented blob). Kimi Code and OMP discovery (#2917) shipped with the scanner registry in `bin/gstack-global-discover.ts`. Waits for the host-config work; see "Parked contributor ideas (Oct 8 triage)" for the parked host PRs. **Priority:** P3. - **Provider pluralism (7 issues, plus #3122)** — Gemini CLI as an outside-voice backend (#619), more image providers in `design` beyond OpenAI and Gemini (Gemini shipped for #990), Codex-native `image_gen` in the `design` binary's own generate, evolve and prototype calls (#2174; design-shotgun itself already uses `$imagegen` on Codex since #2175), GPT-6 Sol/Luna tiers in Codex call routing (#2943), a `/grok` review port (#2947), Codex review token usage (#2948), Codex refresh-token recovery docs (#1542), and a configurable PR review bot instead of hard-coded Greptile in /review, /ship and /retro (#3122, folded here by decision D4). One provider seam serves all of them. **Priority:** P3. - **Install footprint and layout (7 issues)** — skills.sh distribution (#113), `~/.gstack` as the global install path (#753), a repo-local install that keeps state in the project (#1235), five compiled binaries each bundling Bun (#1245), Windows junctions instead of copies (#1877), the 1.1 GB `~/.claude/skills` footprint (#2199), and a skill-only mode (#2528). Decision D6: setup keeps the staged copy on Windows, because it works today and links change what every cleanup deletes; #1943 (@JonasFocus, junctions) and #2129 (@TTmo123, symlinks with the privilege check) are closed with credit and their designs stay here as the starting point. See also "install footprint follow-ups from the October fix wave". **Priority:** P3. - **New skills (12 issues)** — GUI kanban for plan reviews (#247), journey maps (#729), UI-doc review (#851), assumption verification `/avl` (#1058), `/a11y-check` (#1104), `/env-check` (#1106), native plan writing (#1170), `/plan-experiment` and `/review-experiment` (#1195), `/plan-status` (#1343), language-specific reviewers (#1753), `/benchmark-mobile` (#1754), session start/end skills (#2088). **Priority:** P3. diff --git a/VERSION b/VERSION index 74e0b064a..ee815512e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.14.0 +1.92.23.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 8f00864f7..aacc58cd9 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.14.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.23.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/dev-setup b/bin/dev-setup index f77026715..9a8f6f6f6 100755 --- a/bin/dev-setup +++ b/bin/dev-setup @@ -22,9 +22,12 @@ if [ ! -f "$REPO_ROOT/.env" ]; then fi fi -# 2. Install dependencies, devDependencies included. Always run: a checkout -# first installed by ./setup has production dependencies only, and an -# up-to-date install is a no-op. +# 2. Install dependencies, devDependencies included, and mark this clone as a +# contributor's (#3447) so ./setup keeps the dev toolchain instead of moving it +# to the production-only install. Always run: a checkout first installed by +# ./setup has production dependencies only, and an up-to-date install is a no-op. +git -C "$REPO_ROOT" config --local gstack.contributor true 2>/dev/null \ + || echo "Warning: could not set git config gstack.contributor in $REPO_ROOT; export GSTACK_CONTRIBUTOR=1 before ./setup to keep the dev toolchain." >&2 echo "Installing dependencies..." (cd "$REPO_ROOT" && bun install) diff --git a/bin/gstack-capy-install b/bin/gstack-capy-install index 4afc9a153..9bad871a7 100755 --- a/bin/gstack-capy-install +++ b/bin/gstack-capy-install @@ -70,6 +70,7 @@ while [ $# -gt 0 ]; do done [ -n "$FOR" ] && [ -n "$REQUIRE" ] && { echo "gstack-capy-install: --for and --require are exclusive" >&2; exit 2; } [ -z "$FOR" ] && [ -z "$REQUIRE" ] && REQUIRE="claude,codex,patch" +[ -z "$PROJECT" ] || { _given=$PROJECT; PROJECT=$(CDPATH= cd -- "$_given" 2>/dev/null && { git rev-parse --show-toplevel 2>/dev/null || pwd -P; }) || { echo "gstack-capy-install: --project takes a directory path; '$_given' is not a directory (pass the project checkout's path, not a repo slug)" >&2; exit 2; }; } say() { echo "gstack-capy-install: $*"; } stop() { # CODE MESSAGE FIX — print and exit 3 before ./setup @@ -148,7 +149,7 @@ STORE=$(gstack_bun_store "$GSTACK_STATE_ROOT") done INSTALL_VERSION=$(gstack_bun_install_version) || stop BUN_INSTALL_VERSION_INVALID "GSTACK_BUN_INSTALL_VERSION=$INSTALL_VERSION is not a verified Bun at or above gstack's supported minimum $GSTACK_BUN_TESTED (verified archives: $GSTACK_BUN_VERIFIED)" "unset GSTACK_BUN_INSTALL_VERSION, or set it to one of: $GSTACK_BUN_VERIFIED" [ -n "$PROJECT" ] || { PROJECT=$(cd "$ORIG_PWD" && git rev-parse --show-toplevel 2>/dev/null || true); [ "$(cd "${PROJECT:-/nonexistent}" 2>/dev/null && pwd -P)" = "$ROOT_REAL" ] && PROJECT=""; } -PROJECT_REAL=""; [ -n "$PROJECT" ] && PROJECT_REAL=$(cd "$PROJECT" 2>/dev/null && pwd -P) +PROJECT_REAL=""; [ -n "$PROJECT" ] && PROJECT_REAL=$(cd "$PROJECT" && pwd -P) && [ "$PROJECT_REAL" = "$ROOT_REAL" ] && PROJECT_REAL="" VERDICT=$(gstack_install_verdict_path "$GSTACK_STATE_ROOT" "$ROOT_REAL" "$PROJECT_REAL") REC_BUN="" REC_VER="" REC_RULE="" REC_BUN_INSTALL="" REC_FOR="" if [ -f "$VERDICT" ]; then diff --git a/bin/gstack-docs-candidate b/bin/gstack-docs-candidate index 7208f13a4..a5728877f 100755 --- a/bin/gstack-docs-candidate +++ b/bin/gstack-docs-candidate @@ -6,6 +6,7 @@ * gstack-docs-candidate snapshot --out --audit-id --mode edit|read-only * --base [--select ]... [--docs ]... * gstack-docs-candidate compare + * gstack-docs-candidate attempt --state * gstack-docs-candidate [snapshot|compare] --help * * snapshot saves, NUL-safely and without writing to the repository: base SHA, @@ -20,6 +21,13 @@ * the index changed, which recorded paths' content changed or disappeared, and * which paths are newly dirty or untracked. It never decides whether a change * was permitted; the caller checks those paths against the child's report. + * + * attempt reads one audit child's output and says whether the launch counts + * against Step 14.5's attempt budget. A launch the child's install check refused + * (an `INSTALL: fail` row, its last GSTACK_RESULT line a document-release + * refusal, and no audit JSON) never audited, so it is not counted, once per + * invocation (--state, the invocation's record); a second refusal is counted + * and stops for the install fix (GST-391, gbrain #8084). */ import { spawnSync } from 'child_process'; import * as fs from 'fs'; @@ -83,7 +91,8 @@ function one(args: string[], name: string): string { } const USAGE = `usage: gstack-docs-candidate snapshot --out --audit-id --mode edit|read-only --base [--select ]... [--docs ]... - gstack-docs-candidate compare `; + gstack-docs-candidate compare + gstack-docs-candidate attempt --state `; /** Each field compare needs that the record lacks or types wrongly; empty for a valid record. */ function recordProblems(record: any): string[] { @@ -98,7 +107,7 @@ function recordProblems(record: any): string[] { function main(argv: string[]): void { const [command, ...args] = argv; const topHelp = (command === '--help' || command === '-h') && !args.length; - const commandHelp = (command === 'snapshot' || command === 'compare') && args.length === 1 && args[0] === '--help'; + const commandHelp = (command === 'snapshot' || command === 'compare' || command === 'attempt') && args.length === 1 && args[0] === '--help'; if (topHelp || commandHelp) { console.log(USAGE); return; @@ -160,6 +169,33 @@ function main(argv: string[]): void { })); return; } + if (command === 'attempt') { + const INSTALL_FIX = 'run the Capy skill installer (scripts/install.sh --for document-release), then launch with its GSTACK_LAUNCH line'; + const [file, ...rest] = args; + if (!file || file.startsWith('--')) throw new Error('attempt takes the child output file, then --state '); + const state = one(rest, '--state'); + const lines = fs.readFileSync(file, 'utf8').split('\n').map(l => l.trim()).filter(Boolean); + const last = lines.filter(l => l.startsWith('GSTACK_RESULT: ')).pop(); + const install = lines.find(l => /^INSTALL: fail\b/.test(l)); + const audited = lines.some(l => { try { const v = JSON.parse(l); return !!v && typeof v === 'object' && 'schema_version' in v; } catch { return false; } }); + if (!last || !/^GSTACK_RESULT: skill=document-release status=refused\b/.test(last) || !install || audited) { console.log('ATTEMPT: counted'); return; } + let seen = 0; + try { seen = Number(JSON.parse(fs.readFileSync(state, 'utf8')).not_counted) || 0; } catch { seen = fs.existsSync(state) ? 1 : 0; } + if (seen >= 1) { + console.log('ATTEMPT: counted (a second install refusal in this invocation)'); + console.log(`STOP: ${INSTALL_FIX} and confirm its gstack: ok trailer before another launch`); + process.exit(3); + } + fs.mkdirSync(path.dirname(path.resolve(state)), { recursive: true }); + try { fs.writeFileSync(state, JSON.stringify({ not_counted: 1 }) + '\n', { mode: 0o600, flag: 'wx' }); } catch (e) { + if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e; + console.log('ATTEMPT: counted (another launch in this invocation already used the allowance)'); + console.log(`STOP: ${INSTALL_FIX} and confirm its gstack: ok trailer before another launch`); + process.exit(3); + } + console.log('ATTEMPT: not-counted (the audit child\'s install check refused document-release)'); + return; + } throw new Error(USAGE); } diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index fa6811817..e592bae08 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -141,6 +141,7 @@ gstack_result_anchor() { BROWSER_UNAVAILABLE) printf '%s' 'browser-ensure-failed' ;; INSTALL_PREFLIGHT_FAILED) printf '%s' 'capy-install-preflight-failed' ;; SETUP_PS1_EMPTY_ARGUMENT) printf '%s' 'setup-ps1-empty-argument' ;; + SETUP_DEPS_MIGRATE_FAILED) printf '%s' 'setup-deps-migrate-failed' ;; POLICY_SOURCE_UNAVAILABLE) printf '%s' 'policy-source-unavailable' ;; POLICY_INVALID) printf '%s' 'policy-invalid' ;; POLICY_CONTAINMENT) printf '%s' 'policy-containment' ;; @@ -330,6 +331,7 @@ gstack_result_anchor() { REVIEW_LOOP_EXCEPTION_INVALID) printf '%s' 'review-loop-exception-invalid' ;; RECEIPT_CARRY_TREE_MISMATCH) printf '%s' 'receipt-carry-tree-mismatch' ;; RECEIPT_CARRY_PRODUCT_CHANGED) printf '%s' 'receipt-carry-product-changed' ;; + RECEIPT_CARRY_SCOPED_REVIEW) printf '%s' 'receipt-carry-scoped-review' ;; DOCS_AUDIT_BLOCKED) printf '%s' 'docs-audit-blocked' ;; RECEIPT_PROSE_MISMATCH) printf '%s' 'receipt-prose-mismatch' ;; RECEIPT_CARRY_PREGATE_STALE) printf '%s' 'receipt-carry-pregate-stale' ;; @@ -369,6 +371,9 @@ gstack_result_anchor() { PREREG_NO_GSTACK_ARM) printf '%s' 'prereg-no-gstack-arm' ;; EVAL_REPORT_NO_GSTACK_ROW) printf '%s' 'eval-report-no-gstack-row' ;; EVAL_ARM_DUPLICATE) printf '%s' 'eval-arm-duplicate' ;; + OFFICE_HOURS_PROBE_REFUSED) printf '%s' 'office-hours-probe-refused' ;; + TASKS_SUPERSEDE_REFUSED) printf '%s' 'tasks-supersede-refused' ;; + REALITY_RECEIPT_UNRESOLVED) printf '%s' 'reality-receipt-unresolved' ;; *) return 1 ;; esac } diff --git a/bin/gstack-global-discover.ts b/bin/gstack-global-discover.ts index 88cfbfdbe..3fdf815c9 100644 --- a/bin/gstack-global-discover.ts +++ b/bin/gstack-global-discover.ts @@ -10,7 +10,7 @@ */ import { existsSync, readdirSync, statSync, readFileSync, openSync, readSync, closeSync } from "fs"; -import { join, basename, dirname, resolve } from "path"; +import { join, basename, dirname, resolve, isAbsolute } from "path"; import { spawnSync } from "child_process"; import { homedir } from "os"; import { canonicalRemote } from "../lib/remote-identity"; @@ -20,8 +20,11 @@ import { canonicalRemote } from "../lib/remote-identity"; type CodexOriginator = "cli" | "desktop" | "exec" | "claude_code" | "other"; type CodexOriginatorCounts = Record; +type ToolId = "claude_code" | "codex" | "gemini" | "kimi_code" | "omp"; +type ToolCounts = Record; + interface Session { - tool: "claude_code" | "codex" | "gemini"; + tool: ToolId; cwd: string; codexOriginator?: CodexOriginator; } @@ -30,19 +33,24 @@ interface Repo { name: string; remote: string; paths: string[]; - sessions: { claude_code: number; codex: number; gemini: number }; + sessions: ToolCounts; codex_originators: CodexOriginatorCounts; } +interface ToolSummary { + label: string; + total_sessions: number; + repos: number; + originators?: CodexOriginatorCounts; + /** Sessions counted without a recorded cwd: they count in total_sessions, like sessions outside any repo, but belong to no repo. */ + unattributed?: true; +} + interface DiscoveryResult { window: string; start_date: string; repos: Repo[]; - tools: { - claude_code: { total_sessions: number; repos: number }; - codex: { total_sessions: number; repos: number; originators: CodexOriginatorCounts }; - gemini: { total_sessions: number; repos: number }; - }; + tools: Record; total_sessions: number; total_repos: number; } @@ -521,6 +529,128 @@ function scanGemini(since: Date): Session[] { return sessions; } +/** + * Kimi Code: ~/.kimi-code/session_index.jsonl lists { sessionId, sessionDir, workDir }; + * the session's state.json carries updatedAt (epoch ms). workDir is the absolute cwd. + */ +function scanKimiCode(since: Date): Session[] { + const root = join(homedir(), ".kimi-code"); + let index: string; + try { + index = readFileSync(join(root, "session_index.jsonl"), { encoding: "utf-8" }); + } catch { + return []; + } + const sessions: Session[] = []; + for (const line of index.split(/\r?\n/)) { + if (!line.trim()) continue; + let entry: any; + try { + entry = JSON.parse(line); + } catch { + continue; + } + if (typeof entry?.sessionDir !== "string" || !entry.sessionDir) continue; + const dir = isAbsolute(entry.sessionDir) ? entry.sessionDir : join(root, entry.sessionDir); + let state: any = {}; + try { + state = JSON.parse(readFileSync(join(dir, "state.json"), { encoding: "utf-8" })); + } catch {} + let updated = typeof state?.updatedAt === "number" ? state.updatedAt : NaN; + if (Number.isNaN(updated)) { + try { + updated = statSync(dir).mtimeMs; + } catch { + continue; + } + } + if (updated < since.getTime()) continue; + const cwd = typeof entry.workDir === "string" && entry.workDir ? entry.workDir : typeof state?.cwd === "string" ? state.cwd : null; + if (cwd) sessions.push({ tool: "kimi_code", cwd }); + } + return sessions; +} + +/** + * OMP: one _.jsonl per session under ~/.omp/agent/sessions//. + * The workspace directory name is a lossy encoding of the path, so the cwd comes from + * the session file itself. A session counts when its file was written inside the + * window, like the other scanners; ~/.omp/logs holds per-launch logs and is never read. + */ +function scanOmp(since: Date, stats: CwdScanStats): Session[] { + const sessionsDir = join(homedir(), ".omp", "agent", "sessions"); + let workspaces: string[]; + try { + workspaces = readdirSync(sessionsDir); + } catch { + return []; + } + const sessions: Session[] = []; + for (const workspace of workspaces) { + const dir = join(sessionsDir, workspace); + let files: string[]; + try { + files = readdirSync(dir).filter((f) => f.endsWith(".jsonl")); + } catch { + continue; + } + for (const file of files) { + const path = join(dir, file); + try { + if (statSync(path).mtime < since) continue; + } catch { + continue; + } + const cwd = extractCwdFromJsonl(path, stats); + if (cwd) sessions.push({ tool: "omp", cwd }); + } + } + return sessions; +} + +/** + * Antigravity CLI (agy) keeps each conversation as SQLite under + * ~/.gemini/antigravity-cli/conversations/*.db, with the cwd inside an undocumented + * blob (#1977). Sessions are counted by the later of the .db and its -wal file's + * mtime and are not attributed to a repo. + */ +function countAntigravity(since: Date): number { + const dir = join(homedir(), ".gemini", "antigravity-cli", "conversations"); + let files: string[]; + try { + files = readdirSync(dir).filter((f) => f.endsWith(".db")); + } catch { + return 0; + } + let count = 0; + for (const file of files) { + let latest = 0; + for (const path of [join(dir, file), join(dir, `${file}-wal`)]) { + try { + latest = Math.max(latest, statSync(path).mtimeMs); + } catch {} + } + if (latest >= since.getTime()) count++; + } + return count; +} + +const SCANNERS: ReadonlyArray<{ id: ToolId; label: string; scan: (since: Date, stats: CwdScanStats) => Session[] }> = [ + { id: "claude_code", label: "CC", scan: scanClaudeCode }, + { id: "codex", label: "Codex", scan: (since) => scanCodex(since) }, + { id: "gemini", label: "Gemini", scan: (since) => scanGemini(since) }, + { id: "kimi_code", label: "Kimi Code", scan: (since) => scanKimiCode(since) }, + { id: "omp", label: "OMP", scan: scanOmp }, +]; + +function emptyCounts(): ToolCounts { + return Object.fromEntries(SCANNERS.map((s) => [s.id, 0])) as ToolCounts; +} + +function countTotal(counts: ToolCounts): number { + return SCANNERS.reduce((n, s) => n + counts[s.id], 0); +} + // ── Deduplication ────────────────────────────────────────────────────────── async function resolveAndDeduplicate(sessions: Session[]): Promise { @@ -555,7 +685,7 @@ async function resolveAndDeduplicate(sessions: Session[]): Promise { } } - const sessionCounts = { claude_code: 0, codex: 0, gemini: 0 }; + const sessionCounts = emptyCounts(); for (const s of data.sessions) { sessionCounts[s.tool]++; } @@ -570,11 +700,7 @@ async function resolveAndDeduplicate(sessions: Session[]): Promise { } // Sort by total sessions descending - repos.sort( - (a, b) => - b.sessions.claude_code + b.sessions.codex + b.sessions.gemini - - (a.sessions.claude_code + a.sessions.codex + a.sessions.gemini) - ); + repos.sort((a, b) => countTotal(b.sessions) - countTotal(a.sessions)); return repos; } @@ -588,21 +714,19 @@ async function main() { // Run all scanners const cwdStats: CwdScanStats = { oversizedLines: 0 }; - const ccSessions = scanClaudeCode(sinceDate, cwdStats); - const codexSessions = scanCodex(sinceDate); - const geminiSessions = scanGemini(sinceDate); - - const allSessions = [...ccSessions, ...codexSessions, ...geminiSessions]; + const byTool = SCANNERS.map((s) => ({ ...s, sessions: s.scan(sinceDate, cwdStats) })); + const agySessions = countAntigravity(sinceDate); + const allSessions = byTool.flatMap((t) => t.sessions); + const codexSessions = byTool.find((t) => t.id === "codex")!.sessions; + const toolCounts = byTool.map((t) => `${t.label}: ${t.sessions.length}`).join(", "); // Summary to stderr - console.error( - `Discovered: ${ccSessions.length} CC sessions, ${codexSessions.length} Codex sessions, ${geminiSessions.length} Gemini sessions` - ); + console.error(`Discovered: ${toolCounts}; Antigravity: ${agySessions} (repo unattributed)`); if (cwdStats.oversizedLines > 0) { console.error( - `Skipped ${cwdStats.oversizedLines} Claude Code JSONL line(s) over 1 MiB while reading session cwds; ` + - `a session whose cwd appears only after such lines counts with its project directory's other sessions.`, + `Skipped ${cwdStats.oversizedLines} session JSONL line(s) over 1 MiB while reading session cwds; ` + + `a Claude Code session whose cwd appears only after such lines counts with its project directory's other sessions.`, ); } @@ -611,23 +735,25 @@ async function main() { console.error(`→ ${repos.length} unique repos`); - // Count per-tool repo counts - const ccRepos = new Set(repos.filter((r) => r.sessions.claude_code > 0).map((r) => r.remote)).size; - const codexRepos = new Set(repos.filter((r) => r.sessions.codex > 0).map((r) => r.remote)).size; - const geminiRepos = new Set(repos.filter((r) => r.sessions.gemini > 0).map((r) => r.remote)).size; - const codexOriginators = countCodexOriginators(codexSessions); + const tools = Object.fromEntries( + byTool.map((t) => [ + t.id, + { + label: t.label, + total_sessions: t.sessions.length, + repos: repos.filter((r) => r.sessions[t.id] > 0).length, + ...(t.id === "codex" ? { originators: codexOriginators } : {}), + }, + ]), + ) as Record; const result: DiscoveryResult = { window: since, start_date: startDate, repos, - tools: { - claude_code: { total_sessions: ccSessions.length, repos: ccRepos }, - codex: { total_sessions: codexSessions.length, repos: codexRepos, originators: codexOriginators }, - gemini: { total_sessions: geminiSessions.length, repos: geminiRepos }, - }, - total_sessions: allSessions.length, + tools: { ...tools, agy: { label: "Antigravity", total_sessions: agySessions, repos: 0, unattributed: true } }, + total_sessions: allSessions.length + agySessions, total_repos: repos.length, }; @@ -636,17 +762,16 @@ async function main() { } else { // Summary format console.log(`Window: ${since} (since ${startDate})`); - console.log(`Sessions: ${allSessions.length} total (CC: ${ccSessions.length}, Codex: ${codexSessions.length}, Gemini: ${geminiSessions.length})`); + console.log(`Sessions: ${allSessions.length + agySessions} total (${toolCounts}, Antigravity: ${agySessions})`); if (codexSessions.length > 0) console.log(` Codex originators: ${formatCodexOriginators(codexOriginators)}`); + if (agySessions > 0) console.log(` Antigravity sessions are not attributed to a repo`); console.log(`Repos: ${repos.length} unique`); console.log(""); for (const repo of repos) { - const total = repo.sessions.claude_code + repo.sessions.codex + repo.sessions.gemini; - const tools = []; - if (repo.sessions.claude_code > 0) tools.push(`CC:${repo.sessions.claude_code}`); - if (repo.sessions.codex > 0) tools.push(`Codex:${repo.sessions.codex} (${formatCodexOriginators(repo.codex_originators)})`); - if (repo.sessions.gemini > 0) tools.push(`Gemini:${repo.sessions.gemini}`); - console.log(` ${repo.name} (${total} sessions) — ${tools.join(", ")}`); + const parts = byTool + .filter((t) => repo.sessions[t.id] > 0) + .map((t) => (t.id === "codex" ? `Codex:${repo.sessions.codex} (${formatCodexOriginators(repo.codex_originators)})` : `${t.label}:${repo.sessions[t.id]}`)); + console.log(` ${repo.name} (${countTotal(repo.sessions)} sessions) — ${parts.join(", ")}`); console.log(` Remote: ${repo.remote}`); console.log(` Paths: ${repo.paths.join(", ")}`); } diff --git a/bin/gstack-host-renders.sh b/bin/gstack-host-renders.sh index 83e667c87..b0295a6fb 100644 --- a/bin/gstack-host-renders.sh +++ b/bin/gstack-host-renders.sh @@ -24,7 +24,7 @@ # host:render-dir for every host whose render lives in its own checkout dir. # Claude renders into the tracked tree. test/host-renders.test.ts keeps this # equal to hostSubdir in hosts/*.ts. -GSTACK_HOST_RENDER_DIRS="codex:.agents kiro:.kiro factory:.factory opencode:.opencode cursor:.cursor copilot:.copilot agy:.agy slate:.slate openclaw:.openclaw hermes:.hermes gbrain:.gbrain" +GSTACK_HOST_RENDER_DIRS="codex:.agents kiro:.kiro factory:.factory opencode:.opencode cursor:.cursor copilot:.copilot agy:.agy gemini:.gemini-cli slate:.slate openclaw:.openclaw hermes:.hermes gbrain:.gbrain" gstack_render_hosts_file() { printf '%s/.gstack-installed-hosts\n' "$1"; } diff --git a/bin/gstack-install-registry.sh b/bin/gstack-install-registry.sh index 0fe5e9e91..27ed91c57 100644 --- a/bin/gstack-install-registry.sh +++ b/bin/gstack-install-registry.sh @@ -184,7 +184,7 @@ gstack_install_render_for() { gstack_host_tier() { case "$1" in claude) echo full ;; - codex|kiro|factory|opencode|cursor|copilot|agy|openclaw|hermes) echo experimental ;; + codex|kiro|factory|opencode|cursor|copilot|agy|gemini|openclaw|hermes) echo experimental ;; slate|gbrain) echo instruction-only ;; *) echo unknown ;; esac @@ -256,6 +256,7 @@ gstack_install_known_roots() { printf 'cursor\tglobal\t%s\t%s\n' "$HOME/.cursor/skills" "$HOME/.cursor/skills/gstack" printf 'copilot\tglobal\t%s\t%s\n' "$HOME/.copilot/skills" "$HOME/.copilot/skills/gstack" printf 'agy\tglobal\t%s\t%s\n' "$HOME/.gemini/antigravity-cli/skills" "$HOME/.gemini/antigravity-cli/skills/gstack" + printf 'gemini\tglobal\t%s\t%s\n' "$HOME/.gemini/skills" "$HOME/.gemini/skills/gstack" proj="$(git rev-parse --show-toplevel 2>/dev/null || true)" if [ -n "$proj" ] && [ "$proj" != "$HOME" ]; then printf 'claude\tproject\t%s\t%s\n' "$proj/.claude/skills" "$proj/.claude/skills/gstack" diff --git a/bin/gstack-office-hours-review b/bin/gstack-office-hours-review index 6ceaf2a64..67b8b164c 100755 --- a/bin/gstack-office-hours-review +++ b/bin/gstack-office-hours-review @@ -4,12 +4,32 @@ import * as path from 'node:path'; import { atomicWriteSync } from '../lib/fs-atomic.ts'; import { assessOfficeHoursReviews, extractOfficeHoursReviewBlock, loadOfficeHoursReviews, officeHoursDesignChanges, officeHoursMaxRounds, officeHoursSnapshotChanges, officeHoursVerdictReceipt, parseOfficeHoursVerdictReceipt, renderOfficeHoursReview, renderOfficeHoursReviewerPrompt, replaceOfficeHoursReviewBlock, verifyOfficeHoursCitations, verifyOfficeHoursVerdictReceipt } from '../lib/office-hours-review.ts'; import { parseSpecFixes, verifySpecFixesAtCap } from '../lib/spec-review-cap.ts'; +import { repoRootOf, resolveProbe, runProbe } from '../lib/office-hours-probe.ts'; +import { resultLine } from '../lib/result-codes.ts'; const shellQuote = value => `'${value.replaceAll("'", `'\\''`)}'`; +// The reviewer's one execution right: --help for a gstack bin or a listed read-only CLI (lib/office-hours-probe.ts). +function probe(args) { + if (args.length < 2 || args.length > 4 || args[0] !== '--help') throw new Error('usage: gstack-office-hours-review probe --help [ []]'); + const named = args.slice(1).join(' '); + const where = { pathEnv: process.env.PATH ?? '', repoRoot: repoRootOf(process.cwd()) }; + const target = resolveProbe(args[1], { ...where, gstackBin: path.dirname(fs.realpathSync(process.argv[1])) }, args.slice(2)); + if ('refused' in target) { + console.error(resultLine('gstack-office-hours-review probe', 'OFFICE_HOURS_PROBE_REFUSED', `${named}: ${target.refused}`)); + process.exitCode = 3; + return; + } + const r = runProbe(target.exe, args.slice(2), where); + console.log(`PROBE: ${named} ${r.timedOut ? 'timeout' : `exit=${r.status}`}`); + if (r.output) console.log(r.output); + if (r.timedOut) process.exitCode = 1; +} + function main(args) { const command = args.shift(); - if (!['check', 'prepare', 'seal', 'finalize'].includes(command)) throw new Error('usage: gstack-office-hours-review check --receipt | prepare --design --out-dir [round.json...] | seal --design --verdict [preceding round.json...] | finalize --design [--report ] [--unreviewed ] [--fixes ] (each also takes --max-rounds 1|2|3, default 3)'); + if (command === 'probe') return probe(args); + if (!['check', 'prepare', 'seal', 'finalize'].includes(command)) throw new Error('usage: gstack-office-hours-review probe --help [] | check --receipt | prepare --design --out-dir [round.json...] | seal --design --verdict [preceding round.json...] | finalize --design [--report ] [--unreviewed ] [--fixes ] (each also takes --max-rounds 1|2|3, default 3)'); const options = {}; const files = []; while (args.length) { @@ -82,7 +102,7 @@ function main(args) { if (!captured) atomicWriteSync(snapshotPath, design, { mode: 0o600 }); if (!existing) atomicWriteSync(promptPath, prompt, { mode: 0o600 }); const seal = [shellQuote(fs.realpathSync(process.argv[1])), 'seal', ...(maxRounds === 3 ? [] : [`--max-rounds ${maxRounds}`]), '--design', shellQuote(designPath), '--verdict', shellQuote(verdictPath), ...files.map(file => shellQuote(fs.realpathSync(file)))].join(' '); - const dispatch = `Read ${JSON.stringify(promptPath)} and execute the independent spec review as instructed.\nDocument: ${designPath}\nPrompt: ${promptPath}\nVerdict: ${verdictPath}\nSeal: ${seal}`; + const dispatch = `Read ${JSON.stringify(promptPath)} and execute the independent spec review as instructed.\nDocument: ${designPath}\nPrompt: ${promptPath}\nVerdict: ${verdictPath}\nProbe: ${shellQuote(fs.realpathSync(process.argv[1]))} probe --help []\nSeal: ${seal}`; console.log(JSON.stringify({ round, promptPath, verdictPath, dispatch })); return; } diff --git a/bin/gstack-plan-reality b/bin/gstack-plan-reality index 99c44800a..9ecc29bd9 100755 --- a/bin/gstack-plan-reality +++ b/bin/gstack-plan-reality @@ -3,26 +3,30 @@ * gstack-plan-reality — the reality rows of a plan review (plan D1). * * gstack-plan-reality rows --phase [--scope ] [--deep] [--json] - * gstack-plan-reality check --phase

[--scope ] [--session-kind ] [--plan ] [--json] + * gstack-plan-reality check --phase

[--scope ] [--session-kind ] [--plan ] [--root

]... [--json] * - * Logic: lib/plan-reality.ts. Codes: lib/result-codes.ts (REALITY_ROW_MISSING, REALITY_RECEIPT_MISSING). + * Logic: lib/plan-reality.ts, lib/plan-reality-receipts.ts. Codes: lib/result-codes.ts (REALITY_ROW_MISSING, REALITY_RECEIPT_MISSING, REALITY_RECEIPT_UNRESOLVED). */ import * as fs from 'node:fs'; import { EXIT, exitTableHelp } from '../lib/headless-artifacts'; -import { checkRealityRows, parseScopes, REALITY_LINE_SHAPE, REALITY_SCOPES, renderRows, requiredRows } from '../lib/plan-reality'; +import { checkRealityRows, parseRealityLines, parseScopes, REALITY_LINE_SHAPE, REALITY_SCOPES, renderRows, requiredRows } from '../lib/plan-reality'; +import { defaultRoots, resolveReceipts } from '../lib/plan-reality-receipts'; import { resultLine } from '../lib/result-codes'; const HELP = `gstack-plan-reality — reality rows for plan reviews rows --phase

[--scope ] [--deep] [--json] the rows this review must carry (tier 1 always, tier 2 by scope) - check --phase

[--scope ] [--session-kind ] [--plan ] [--json] + check --phase

[--scope ] [--session-kind ] [--plan ] [--root

]... [--json] parse the review's REALITY: lines; exit 1 with the missing rows --plan: a number inside a "quoted" span copied verbatim from it needs no receipt + --root: where file:line receipts must exist (repeatable; default the git top level) Row line: \`${REALITY_LINE_SHAPE}\` (receipt: path:line, path:line-line or a commit sha). Scopes: ${REALITY_SCOPES.filter(s => s !== 'always').join(', ')}. Session kind unattended → verdict=incomplete; else verdict=repair. -Prints \`PLAN_REALITY: phase=

rows=/ missing= [unreceipted=] [unreceipted_numbers=] [unquoted=] [unverified=] verdict=complete|incomplete|repair\`, -then one \`NUMBER_UNRECEIPTED: \` per measured number (unit or measured/costs/took) without its own receipt. +Prints \`PLAN_REALITY: phase=

rows=/ missing= [unreceipted=] [unreceipted_numbers=] [unquoted=] [unverified=] [unresolved=] verdict=complete|incomplete|repair\`, +then one \`NUMBER_UNRECEIPTED: \` per measured number (unit or measured/costs/took) without its own receipt, +one \`RECEIPT_UNRESOLVED: \` per receipt that is false in the roots (missing file, line past the end; blocks), +and, on stderr, one \`RECEIPT_UNVERIFIABLE: \` per receipt the roots cannot judge (reported, never blocks). A line claiming a decision rule must quote it (\`"..."\`, a fenced or > block after it) or link it (file:line). ${exitTableHelp()}`; @@ -50,7 +54,7 @@ function main(argv: string[]): number { return EXIT.ok; } if (cmd === 'check') { - const valueFlags = new Set(['--phase', '--scope', '--session-kind', '--plan']); + const valueFlags = new Set(['--phase', '--scope', '--session-kind', '--plan', '--root']); const file = rest.find((a, i) => !a.startsWith('--') && !(i > 0 && valueFlags.has(rest[i - 1]!))); if (!file) return usage('a review file (or - for stdin) is required'); let text: string; @@ -60,14 +64,23 @@ function main(argv: string[]): number { let planText: string | undefined; try { planText = planFile ? fs.readFileSync(planFile, 'utf8') : undefined; } catch (e: any) { process.stderr.write(`gstack-plan-reality: ${planFile}: ${e.message}\n`); return EXIT.fail; } const result = checkRealityRows(text, { phase, scopes, sessionKind, planText }); - if (json) process.stdout.write(JSON.stringify(result) + '\n'); - else process.stdout.write([result.line, ...result.numberLines].join('\n') + '\n'); + const rootFlags = rest.flatMap((a, i) => (a === '--root' && rest[i + 1] ? [rest[i + 1]!] : [])); + const receipts = resolveReceipts(parseRealityLines(text), { roots: rootFlags.length ? rootFlags : defaultRoots(process.cwd()) }); + const unresolved = receipts.filter(r => r.outcome === 'unresolved'); + const unverifiable = receipts.filter(r => r.outcome === 'unverifiable'); + const unresolvedRows = [...new Set(unresolved.map(r => r.row))]; + const verdict = result.verdict === 'complete' && unresolved.length ? (sessionKind === 'unattended' ? 'incomplete' : 'repair') : result.verdict; + const line = unresolvedRows.length ? result.line.replace(/ verdict=\S+$/, ` unresolved=${unresolvedRows.join(',')} verdict=${verdict}`) : result.line; + if (json) process.stdout.write(JSON.stringify({ ...result, verdict, line, receipts }) + '\n'); + else process.stdout.write([line, ...result.numberLines, ...unresolved.map(r => `RECEIPT_UNRESOLVED: ${r.row} ${r.receipt} ${r.reason}`)].join('\n') + '\n'); + for (const r of unverifiable) process.stderr.write(`RECEIPT_UNVERIFIABLE: ${r.row} ${r.receipt} ${r.reason}\n`); for (const id of result.unknownRows) process.stderr.write(`gstack-plan-reality: ${file}: REALITY row ${JSON.stringify(id)} is not a known row; it was ignored\n`); if (result.missing.length) process.stderr.write(resultLine('gstack-plan-reality check', 'REALITY_ROW_MISSING', `phase ${phase}: ${result.missing.join(', ')}`) + '\n'); if (result.unreceipted.length) process.stderr.write(resultLine('gstack-plan-reality check', 'REALITY_RECEIPT_MISSING', `phase ${phase}: ${result.unreceipted.join(', ')}`) + '\n'); if (result.unreceipted_numbers.length) process.stderr.write(resultLine('gstack-plan-reality check', 'REALITY_RECEIPT_MISSING', `phase ${phase}: ${result.unreceipted_numbers.length} measured number(s) share or lack a receipt; give each clause its own file:line, script path or command`) + '\n'); for (const claim of result.unquoted) process.stderr.write(resultLine('gstack-plan-reality check', 'REALITY_RECEIPT_MISSING', `phase ${phase}: a decision rule is claimed without its text; quote it or link its file:line: ${claim}`) + '\n'); - return result.verdict === 'complete' ? EXIT.ok : EXIT.fail; + if (unresolved.length) process.stderr.write(resultLine('gstack-plan-reality check', 'REALITY_RECEIPT_UNRESOLVED', `phase ${phase}: ${unresolved.map(r => `${r.row} ${r.receipt} (${r.reason})`).join('; ')}`) + '\n'); + return verdict === 'complete' ? EXIT.ok : EXIT.fail; } return usage(`unknown command ${cmd}`); } diff --git a/bin/gstack-review-dispatch b/bin/gstack-review-dispatch index 827ba0c5b..eb618a348 100755 --- a/bin/gstack-review-dispatch +++ b/bin/gstack-review-dispatch @@ -99,7 +99,7 @@ function render(): number { const brief = role === 'targeted' ? readFile(checklist, 'targeted brief') : undefined; if (role === 'targeted') { const base = resolveBaseBranch(process.cwd(), flag('--base')); - const mb = [`origin/${base}`, base].map(ref => spawnSync('git', ['merge-base', ref, 'HEAD'], { encoding: 'utf8', timeout: 30_000 })).find(r => r.status === 0)?.stdout.trim(); + const mb = mergeBaseWith(base); const full = diffBase ? spawnSync('git', ['rev-parse', '--verify', '--quiet', `${diffBase}^{commit}`], { encoding: 'utf8', timeout: 30_000 }).stdout?.trim() : undefined; if (!full || full !== mb) throw new BriefError(`the targeted reader reads the whole diff on every pass: --diff-base must be the merge base with ${base} (${mb ?? 'unresolved'}), got ${diffBase ?? 'none'}`); } @@ -138,6 +138,7 @@ const wrongTree = (reader: string, t: TreeCheck) => `${t.moved ? 'COLLECT_TREE_M const PROGRAM_FILE = path.join(ROOT, 'lib', 'capy-review-pass.js'); const PROGRAM_VERSION = 1; const git = (a: string[]) => { const r = spawnSync('git', a, { encoding: 'utf8', timeout: 60_000, maxBuffer: 256 * 1024 * 1024 }); return r.status === 0 ? r.stdout : null; }; +const mergeBaseWith = (base: string) => [`origin/${base}`, base].map(ref => git(['merge-base', ref, 'HEAD'])?.trim()).find(Boolean); const readerLine = (m: Manifest, r: PassReader) => `READER: ${r.reader} stage=${r.stage} state=${r.state} title="${r.title}"${r.sha256 ? ` sha256=${r.sha256} bytes=${r.bytes} chunks=${r.chunks}` : ''}${r.path ? ` path=${r.path}` : ''}`; const branchDiff = (base: string) => { const d = git(['-c', 'core.quotePath=false', 'diff', '--no-color', '--no-ext-diff', base]); @@ -168,7 +169,7 @@ function pass(): number { if (step !== 'full' && step !== 'scoped') { process.stdout.write([...planLines, `PASS: none next=${step} (no reviewer pass; follow NEXT)`].join('\n') + '\n'); return EXIT.ok; } const rangeBase = step === 'scoped' ? git(['rev-parse', '--verify', '--quiet', `${p.fix_range?.base ?? ''}^{commit}`])?.trim() : undefined; if (step === 'scoped' && !rangeBase) throw new BriefError('a scoped plan names no readable fix range base'); - const mergeBase = git(['merge-base', base, 'HEAD'])?.trim(); + const mergeBase = mergeBaseWith(base); const diffBase = flag('--diff-base') ?? (planArgs.includes('--adversarial-ran') ? rangeBase : undefined) ?? mergeBase; if (!diffBase) throw new BriefError(`no merge base between ${base} and HEAD`); const root = git(['rev-parse', '--show-toplevel'])?.trim() ?? process.cwd(); diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt index 3abb2714a..093b50196 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -45,6 +45,7 @@ import { receiptIssuesFrom, receiptSummaryLine, renderCensus, renderReceiptBlock, validateReceipt, type CensusPr, } from '../lib/ship-receipt'; import { checkDocsCaps, docsCapLine, docsExceptionLine, parseReceiptDocs } from '../lib/ship-receipt-docs'; +import { comparePath, contextPacket, PACKET_SECTION_CAP, readScopedReview, touchedPaths } from '../lib/ship-patch'; import { deeper, depthBelow, exceptionProblems, parseReviewLoopException, parseReviewTargeted, receiptDepthAtHead, reviewLoopExceptionLine } from '../lib/ship-receipt-depth'; import { checkReceiptProse, proseLine } from '../lib/ship-receipt-prose'; import { resolveStateRoot } from '../lib/state-root'; @@ -506,7 +507,7 @@ function cmdCarry(rest: string[], json: boolean): number { if (!repoRoot) { process.stderr.write('gstack-ship-receipt carry: run it inside the git checkout whose head carries the receipt\n'); return EXIT.usage; } const head = (flag(rest, '--head') ?? git(['rev-parse', 'HEAD'], repoRoot))?.toLowerCase(); if (!head || !/^[0-9a-f]{40,64}$/.test(head)) { process.stderr.write('gstack-ship-receipt carry: cannot resolve HEAD\n'); return EXIT.usage; } - const refused = (code: 'QUEUE_RECEIPT_MISSING' | 'QUEUE_RECEIPT_CONDITIONAL' | 'RECEIPT_CARRY_REFUSED' | 'RECEIPT_CARRY_PRODUCT_CHANGED' | 'RECEIPT_CARRY_TREE_MISMATCH' | 'RECEIPT_GIT_TOO_OLD' | 'RECEIPT_CARRY_PREGATE_STALE', message: string) => { + const refused = (code: 'QUEUE_RECEIPT_MISSING' | 'QUEUE_RECEIPT_CONDITIONAL' | 'RECEIPT_CARRY_REFUSED' | 'RECEIPT_CARRY_PRODUCT_CHANGED' | 'RECEIPT_CARRY_SCOPED_REVIEW' | 'RECEIPT_CARRY_TREE_MISMATCH' | 'RECEIPT_GIT_TOO_OLD' | 'RECEIPT_CARRY_PREGATE_STALE', message: string) => { process.stderr.write(resultLineWithDocs('gstack-ship-receipt carry', code, message) + '\n'); process.stdout.write(json ? JSON.stringify({ skill: 'ship', status: 'refused', run: '-', code, message }) + '\n' : `CARRY: refused (${code})\nGSTACK_RESULT: skill=ship status=refused run=-\n`); return EXIT.refused; @@ -601,25 +602,58 @@ function cmdCarry(rest: string[], json: boolean): number { } const current = retarget ? headPatch : productHead; const changedProduct: string[] = []; + const contextPaths: string[] = []; if (retarget && reviewed !== current) changedProduct.push(`HEAD's product diff against ${base} is not the one reviewed against ${reviewedBase} (the parent has not fully landed on ${base}, or the branch changed since)`); else if (productFrom !== productHead && !retarget) { - // planCarry names at most CARRY_NAMED_PATHS differing paths, so the per-path comparison (two patch-ids each) stops there, - // and it walks only the paths the branch itself touched against the base at either head: a base merge brings in every - // path the base changed, none of which can differ between the two base diffs. - const touched = new Set([...(git(['diff', '--name-only', fromBase, from], repoRoot) ?? '').split('\n'), ...(git(['diff', '--name-only', headBase, head], repoRoot) ?? '').split('\n')].filter(Boolean)); + // planCarry names at most CARRY_NAMED_PATHS differing paths, so the walk stops there, and it reads only the paths the + // branch itself touched against the base at either head: a base merge brings in every path the base changed, none of + // which can differ between the two base diffs. A path whose only difference is base context next to unchanged hunks + // (lib/ship-patch.ts) needs a scoped review instead of the loop. + const sides = { reviewedBase: fromBase, reviewed: from, currentBase: headBase, current: head }; + const listed = [touchedPaths(repoRoot, fromBase, from), touchedPaths(repoRoot, headBase, head)]; + if (!listed[0] || !listed[1]) return refused('RECEIPT_CARRY_REFUSED', `git could not list the paths the branch touched against ${base}; run /ship on ${head.slice(0, 12)}`); + const touched = new Set([...listed[0], ...listed[1]]); for (const p of [...touched].sort()) { if (changedProduct.length >= CARRY_NAMED_PATHS) break; const cls = changed.find(c => c.path === p)?.class ?? classifyCarryPath(p, rules.extra); if (cls !== 'product') continue; - if (asId(patchId(repoRoot, fromBase, from, [p])) !== asId(patchId(repoRoot, headBase, head, [p]))) changedProduct.push(p); + const verdict = comparePath(repoRoot, sides, p); + if (verdict === 'changed') changedProduct.push(p); + else if (verdict === 'context') contextPaths.push(p); } - if (!changedProduct.length) changedProduct.push('(a product path outside the branch\'s touched files; compare the two base diffs)'); + if (!changedProduct.length && !contextPaths.length) changedProduct.push('(a product path outside the branch\'s touched files; compare the two base diffs)'); + else if (!changedProduct.length) { + const rest = [...spec.product, ...contextPaths.map(p => `:(exclude,literal)${p}`)]; + const restFrom = patchId(repoRoot, fromBase, from, rest); + const restHead = patchId(repoRoot, headBase, head, rest); + if (typeof restFrom !== 'string' || typeof restHead !== 'string' || restFrom !== restHead) changedProduct.push(`(a product path outside ${contextPaths.join(', ')}; compare the two base diffs without them)`); + } + } + let scopedReview: string | undefined; + const packetBody = !changedProduct.length && contextPaths.length ? contextPacket(repoRoot, { reviewedBase: fromBase, reviewed: from, currentBase: headBase, current: head }, contextPaths) : null; + if (packetBody?.over.length) changedProduct.push(...packetBody.over.map(p => `${p} (too large for the scoped review: over ${PACKET_SECTION_CAP} lines in a section)`)); + if (packetBody && !changedProduct.length) { + const record = flag(rest, '--scoped-review'); + if (!record) { + const gitPath = git(['rev-parse', '--git-path', `gstack/scoped-review-${head.slice(0, 12)}.md`], repoRoot); + const packet = path.resolve(repoRoot, gitPath ?? `.git/gstack/scoped-review-${head.slice(0, 12)}.md`); + try { + fs.mkdirSync(path.dirname(packet), { recursive: true }); + fs.writeFileSync(packet, packetBody.text); + } catch (e) { + return refused('RECEIPT_CARRY_REFUSED', `base context moved next to the reviewed hunks in ${contextPaths.join(', ')}, but the scoped-review packet ${packet} cannot be written (${(e as Error).message.split('\n')[0]}); make its directory writable, then rerun carry`); + } + return refused('RECEIPT_CARRY_SCOPED_REVIEW', `base context moved next to the reviewed hunks in ${contextPaths.join(', ')}: one fix-check reviewer reads ${packet}, then rerun carry with --scoped-review ({"head":"${head}","paths":[…],"reviewer":"","verdict":"clean"}); a blocking finding means /ship on ${head.slice(0, 12)}`); + } + const review = readScopedReview(record, { head }, contextPaths); + if (!review.ok) return refused('RECEIPT_CARRY_REFUSED', `--scoped-review: ${review.message}`); + scopedReview = `base context moved (scoped review by ${review.review.reviewer}: ${contextPaths.join(', ')})`; } const mergeCommits = (git(['rev-list', '--merges', '--count', `${from}..${head}`], repoRoot) ?? '0').trim(); // Evidence for the record, never the decision: a slow range-diff is reported unavailable rather than waited for. const rangeRaw = spawnSync('git', ['range-diff', '--no-color', `${fromBase}..${from}`, `${headBase}..${head}`], { cwd: repoRoot, encoding: 'utf8', timeout: 15_000, maxBuffer: 64 * 1024 * 1024 }); const rangeDiff = rangeRaw.status === 0 ? (rangeRaw.stdout.trim().split('\n').slice(0, 20).join('\n') || '(empty: identical ranges)') : `unavailable: ${(rangeRaw.stderr ?? '').trim().split('\n')[0] || (rangeRaw.signal ? `range-diff killed by ${rangeRaw.signal} after 15 s` : 'range-diff failed')}`; - plan = planCarry({ from, head, changed, productPatchId: { from: reviewed as string, head: current as string }, testPatchId: { from: testFrom, head: testHead }, changedProduct, mergeCommits: Number(mergeCommits) || 0, rangeDiff, ...(retarget ? { retargetedFrom: reviewedBase } : {}) }); + plan = planCarry({ from, head, changed, productPatchId: { from: reviewed as string, head: current as string }, testPatchId: { from: testFrom, head: testHead }, changedProduct, mergeCommits: Number(mergeCommits) || 0, rangeDiff, ...(retarget ? { retargetedFrom: reviewedBase } : {}), ...(scopedReview ? { scopedReview } : {}) }); } if (!plan.ok) return refused(plan.code, plan.message); // Eligibility is not publication: a receipt that recorded a pregate is rebound only with a pregate current for HEAD, diff --git a/bin/gstack-tasks b/bin/gstack-tasks new file mode 100755 index 000000000..cea8bbe8d --- /dev/null +++ b/bin/gstack-tasks @@ -0,0 +1,108 @@ +#!/usr/bin/env -S bun --no-env-file --config=/dev/null +/** + * gstack-tasks — a plan review's task list, and its reconciliation across reruns (G2, #1791). + * + * gstack-tasks prior --phase

[--branch ] [--dir ] [--json] + * gstack-tasks add --phase

[--branch ] [--dir ] + * gstack-tasks supersede --phase

--keep [--branch ] [--dir ] [--json] + * + * --dir defaults to /projects/, --branch to the current git branch. + * Logic: lib/tasks-prior.ts. Codes: lib/result-codes-plan.ts (TASKS_SUPERSEDE_REFUSED). + */ +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { EXIT, exitTableHelp } from '../lib/headless-artifacts'; +import { resultLine } from '../lib/result-codes'; +import { runScriptSync } from '../lib/script-invocation'; +import { resolveStateRoot } from '../lib/state-root'; +import { SupersedeRefused, TASK_PHASES, addRows, finishPending, priorSet, readTaskFiles, rowsFileName, supersede, type TaskPhase } from '../lib/tasks-prior'; + +const HELP = `gstack-tasks — a plan review's task list across reruns + + prior --phase

[--json] the newest earlier list for this phase and branch, and how to reconcile it; + deletes any rows file an unfinished run left + add --phase

validate the rows file, write this run's list (empty: ran, no findings) and its + dispositions to task-dispositions-

-.jsonl, then supersede this branch's earlier lists + supersede --phase

--keep [--json] move this branch's reconciled earlier lists out of the aggregate + +Every command takes [--dir ] (default /projects/) and [--branch ] (default the current git branch). +Rows file /.gstack/tmp/tasks-

-rows.jsonl, one JSON object per line, written with a file-write tool +(free text never goes on a command line; a missing file means zero rows): + task {"id","priority","component","title","source_finding","effort_human","effort_cc"} strings, + "files" an array of strings, optional "carried_from": "/" + disposition {"prior": "/", "disposition": "done" | "dropped", "reason": ""} +prior prints \`PRIOR_TASKS: none\` or \`PRIOR_TASKS: run= tasks=\`, one JSON row per task, then a RECONCILE: line; +\`PRIOR_TASKS_MALFORMED: :\` goes to stderr for a line that is not a JSON object (skipped, never edited). +supersede renames each earlier tasks-

-*.jsonl whose rows are all this branch and not newer than --keep to +superseded- and prints \`SUPERSEDED: \` or \`KEPT: ()\`; --keep must validate first. +${exitTableHelp()}`; + +function flag(args: string[], name: string): string | undefined { + const i = args.indexOf(name); + return i >= 0 ? args[i + 1] : undefined; +} +function usage(message: string): number { + process.stderr.write(`gstack-tasks: ${message}\n${HELP}\n`); + return EXIT.usage; +} +const git = (...args: string[]) => { const r = spawnSync('git', args, { encoding: 'utf8', timeout: 10_000 }); return r.status === 0 ? r.stdout.trim() : ''; }; +function slug(name: 'SLUG' | 'BRANCH'): string { + const r = runScriptSync(path.join(import.meta.dir, 'gstack-slug'), ['--get', name], { encoding: 'utf8', timeout: 10_000 }); + return (r.status === 0 && r.stdout.trim()) || 'unknown'; +} + +/** The repository's git-excluded .gstack/tmp, where a host's file-write tool works without a permission card; the tasks directory outside a repository. */ +function rowsDir(fallback: string): string { + const top = git('rev-parse', '--show-toplevel'); + if (!top) return fallback; + const exclude = git('rev-parse', '--git-path', 'info/exclude'); + if (exclude) { + const file = path.resolve(exclude); + fs.mkdirSync(path.dirname(file), { recursive: true }); + if (!(fs.existsSync(file) && fs.readFileSync(file, 'utf8').split('\n').includes('/.gstack/tmp/'))) fs.appendFileSync(file, '/.gstack/tmp/\n'); + } + return path.join(top, '.gstack', 'tmp'); +} + +function main(argv: string[]): number { + const [cmd, ...rest] = argv; + if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') { process.stdout.write(HELP + '\n'); return cmd ? EXIT.ok : EXIT.usage; } + if (!['prior', 'add', 'supersede'].includes(cmd)) return usage(`unknown command ${cmd}`); + const phase = flag(rest, '--phase') as TaskPhase | undefined; + if (!phase || !TASK_PHASES.includes(phase)) return usage(`--phase must be one of ${TASK_PHASES.join(', ')}`); + const dir = path.resolve(flag(rest, '--dir') ?? path.join(resolveStateRoot(), 'projects', slug('SLUG'))); + const branch = flag(rest, '--branch') ?? (git('rev-parse', '--git-dir') ? git('branch', '--show-current') : 'unknown'); + const json = rest.includes('--json'); + try { + if (cmd === 'add') { + const r = addRows(dir, phase, branch, flag(rest, '--branch') ?? slug('BRANCH'), git('rev-parse', 'HEAD') || 'unknown', path.join(rowsDir(dir), rowsFileName(phase))); + process.stdout.write([`ADDED: tasks=${r.tasks} dispositions=${r.dispositions} ${r.file}`, ...r.superseded.map(f => `SUPERSEDED: ${f}`)].join('\n') + '\n'); + return EXIT.ok; + } + if (cmd === 'prior') { + finishPending(dir, phase); + fs.rmSync(path.join(rowsDir(dir), rowsFileName(phase)), { force: true }); + const files = readTaskFiles(dir, phase); + for (const f of files) for (const n of f.malformed) process.stderr.write(`PRIOR_TASKS_MALFORMED: ${f.file}:${n}\n`); + const prior = priorSet(files, phase, branch); + if (json) { process.stdout.write(JSON.stringify(prior ?? { file: null, run_id: null, rows: [] }) + '\n'); return EXIT.ok; } + if (!prior) { process.stdout.write('PRIOR_TASKS: none\n'); return EXIT.ok; } + process.stdout.write([`PRIOR_TASKS: ${prior.file} run=${prior.run_id} tasks=${prior.rows.length}`, ...prior.rows.map(r => JSON.stringify(r)), + `RECONCILE: decide each task above in this run's rows file (.gstack/tmp/tasks-${phase}-rows.jsonl). Still needed: list it again with "carried_from": "${prior.run_id}/". Shipped: a disposition row {"prior": "${prior.run_id}/", "disposition": "done", "reason": ""}. Not wanted: the same with "dropped" and why. gstack-tasks add then retires this list.`].join('\n') + '\n'); + return EXIT.ok; + } + const keep = flag(rest, '--keep'); + if (!keep) return usage('supersede needs --keep '); + const r = supersede(dir, phase, branch, keep); + if (json) process.stdout.write(JSON.stringify(r) + '\n'); + else process.stdout.write([...r.moved.map(f => `SUPERSEDED: ${f}`), ...r.skipped.map(s => `KEPT: ${s.file} (${s.reason})`)].join('\n') + (r.moved.length || r.skipped.length ? '\n' : '')); + return EXIT.ok; + } catch (e) { + if (!(e instanceof SupersedeRefused)) throw e; + process.stderr.write(resultLine(`gstack-tasks ${cmd}`, 'TASKS_SUPERSEDE_REFUSED', e.message) + '\n'); + return EXIT.refused; + } +} + +process.exitCode = main(process.argv.slice(2)); diff --git a/bin/gstack-team-init b/bin/gstack-team-init index d284ad04c..f27cfe7dd 100755 --- a/bin/gstack-team-init +++ b/bin/gstack-team-init @@ -82,7 +82,7 @@ else ```bash _GS="" -for _D in "${GSTACK_ROOT:-}" "$HOME/.claude/skills/gstack" "$HOME/.codex/skills/gstack" "$HOME/.factory/skills/gstack" "$HOME/.kiro/skills/gstack" "$HOME/.config/opencode/skills/gstack" "$HOME/.slate/skills/gstack" "$HOME/.cursor/skills/gstack" "$HOME/.openclaw/skills/gstack" "$HOME/.hermes/skills/gstack" "$HOME/.gbrain/skills/gstack" "$HOME/.copilot/skills/gstack" "$HOME/.gemini/antigravity-cli/skills/gstack" "$HOME/.gstack/repos/gstack"; do +for _D in "${GSTACK_ROOT:-}" "$HOME/.claude/skills/gstack" "$HOME/.codex/skills/gstack" "$HOME/.factory/skills/gstack" "$HOME/.kiro/skills/gstack" "$HOME/.config/opencode/skills/gstack" "$HOME/.slate/skills/gstack" "$HOME/.cursor/skills/gstack" "$HOME/.openclaw/skills/gstack" "$HOME/.hermes/skills/gstack" "$HOME/.gbrain/skills/gstack" "$HOME/.copilot/skills/gstack" "$HOME/.gemini/antigravity-cli/skills/gstack" "$HOME/.gemini/skills/gstack" "$HOME/.gstack/repos/gstack"; do [ -z "$_GS" ] && [ -n "$_D" ] && [ -d "$_D/bin" ] && _GS="$_D" done [ -n "$_GS" ] && echo "GSTACK_OK: $_GS" || echo "GSTACK_MISSING" @@ -153,7 +153,7 @@ const home = process.env.HOME || process.env.USERPROFILE || os.homedir(); const roots = [process.env.GSTACK_ROOT || ''].concat([ '.claude/skills/gstack', '.codex/skills/gstack', '.factory/skills/gstack', '.kiro/skills/gstack', '.config/opencode/skills/gstack', '.slate/skills/gstack', '.cursor/skills/gstack', '.openclaw/skills/gstack', - '.hermes/skills/gstack', '.gbrain/skills/gstack', '.copilot/skills/gstack', '.gemini/antigravity-cli/skills/gstack', '.gstack/repos/gstack', + '.hermes/skills/gstack', '.gbrain/skills/gstack', '.copilot/skills/gstack', '.gemini/antigravity-cli/skills/gstack', '.gemini/skills/gstack', '.gstack/repos/gstack', ].map((rel) => path.join(home, rel))); const gstackRoot = roots.find((root) => { try { return root !== '' && fs.statSync(path.join(root, 'bin')).isDirectory(); } catch { return false; } diff --git a/bin/gstack-uninstall b/bin/gstack-uninstall index 5bbc12222..c88a53568 100755 --- a/bin/gstack-uninstall +++ b/bin/gstack-uninstall @@ -24,6 +24,7 @@ # ~/.config/opencode/skills/gstack* — OpenCode skill install + per-skill symlinks # ~/.copilot/skills/gstack* — GitHub Copilot CLI skill install + per-skill symlinks # ~/.gemini/antigravity-cli/skills/gstack* — Antigravity CLI skill install + per-skill symlinks +# ~/.gemini/skills/gstack* — Gemini CLI skill install + per-skill symlinks (gstack-managed only) # harness registrations — every file gstack-harness wrote into OpenClaw, # Hermes, a Dot or another harness (from its # ledger; edited files move to a backup), and @@ -141,6 +142,7 @@ if [ "$FORCE" -eq 0 ]; then [ -d "$HOME/.config/opencode/skills" ] && echo " ~/.config/opencode/skills/gstack*" [ -d "$HOME/.copilot/skills" ] && echo " ~/.copilot/skills/gstack*" [ -d "$HOME/.gemini/antigravity-cli/skills" ] && echo " ~/.gemini/antigravity-cli/skills/gstack*" + [ -d "$HOME/.gemini/skills" ] && echo " ~/.gemini/skills/gstack*" [ "$KEEP_STATE" -eq 0 ] && [ -d "$STATE_DIR" ] && echo " $STATE_DIR" [ -n "$_LEAVE_ROOT" ] && echo " (kept) $_LEAVE_ROOT: state root selected by $RESOLVED_VAR; left in place" @@ -546,6 +548,22 @@ if [ -d "$ANTIGRAVITY_SKILLS" ]; then done fi +# ─── Remove Gemini CLI skills ─────────────────────────────── +# Same provenance gate. ~/.gemini/skills also holds the user's own skills, so +# only gstack-managed gstack* entries go. +GEMINI_SKILLS="$HOME/.gemini/skills" +if [ -d "$GEMINI_SKILLS" ]; then + for _ITEM in "$GEMINI_SKILLS"/gstack*; do + [ -e "$_ITEM" ] || [ -L "$_ITEM" ] || continue + if _cursor_item_is_gstack_managed "$_ITEM"; then + rm -rf "$_ITEM" + REMOVED+=("gemini/$(basename "$_ITEM")") + else + _SKIPPED_DIRS+=("$_ITEM") + fi + done +fi + # ─── Reconcile the install registry ───────────────────────── # A registry that survives (--keep-state, or a state root selected by an env # var and left in place) must forget the installs removed above, or the next diff --git a/changelog.d/gst-718-start-hook.md b/changelog.d/gst-718-start-hook.md deleted file mode 100644 index c02c368e0..000000000 --- a/changelog.d/gst-718-start-hook.md +++ /dev/null @@ -1 +0,0 @@ -- **OpenClaw and Hermes: opt-in start hook.** `gstack-harness register openclaw|hermes --start-hook` (or `gstack-config set harness_start_hook on`, or `GSTACK_HARNESS_START_HOOK=1`) adds a `gstack-start` plugin that puts a one-line note on every user turn telling the agent to run a gstack skill's start command as written. Agents in these harnesses read gstack's skills but ran that command on 1 of 29 chances. gstack turns the plugin on through each harness's own CLI, checks that it is active, and exits 4 when it is not. It is off by default, the choice is saved across upgrades, a plugin you disable in the harness stays off, and `--no-start-hook`, unregister and uninstall take it out. See docs/hosts.md#start-hook. diff --git a/docs/ADDING_A_HOST.md b/docs/ADDING_A_HOST.md index 07e12b395..f58fffe83 100644 --- a/docs/ADDING_A_HOST.md +++ b/docs/ADDING_A_HOST.md @@ -2,7 +2,7 @@ gstack uses a declarative host config system. Each supported AI coding agent (Claude, Codex, Factory, Kiro, OpenCode, Slate, Cursor, OpenClaw, Hermes, -GBrain, Copilot, Antigravity) is defined as a typed TypeScript config object built by the +GBrain, Copilot, Antigravity, Gemini CLI) is defined as a typed TypeScript config object built by the `defineHost()` factory. Rendering a new host means creating one file and re-exporting it: the generator, tests, and validation pick it up. Installing it is not free: setup still has one install arm per installable host (see @@ -76,13 +76,13 @@ Inventory of every setup side effect, by phase (line numbers are approximate): |---|---|---| | Build | `browse/dist`, `design/dist`, `make-pdf/dist`, `bin/gstack-cso-*` in the source checkout | source | | Hosts record | `.gstack-installed-hosts` in the source checkout: every host setup installed from it, additive (#1694) | source | -| Generation | renders for the recorded hosts only (`.agents/`, `.kiro/`, `.factory/`, `.opencode/`, `.cursor/`, `.copilot/`, `.agy/`) in the source checkout; `gstack-patch-names` rewrites Claude `name:` fields in place | source | +| Generation | renders for the recorded hosts only (`.agents/`, `.kiro/`, `.factory/`, `.opencode/`, `.cursor/`, `.copilot/`, `.agy/`, `.gemini-cli/`) in the source checkout; `gstack-patch-names` rewrites Claude `name:` fields in place | source | | Render prune | renders of hosts the record does not name: links into the checkout removed, generated files moved to `$GSTACK_STATE_ROOT/backups/host-renders/-/`, other files kept | source / state root | | Chromium | Playwright cache (`~/.cache/ms-playwright`), lock under the state root | machine | | Config | `skill_prefix`, `timeline_stop_hook`, team-mode keys in `$GSTACK_STATE_ROOT/config.yaml` | state root | | Claude arm | `~/.claude/skills/gstack` link, one dir per skill with a `SKILL.md` link, alias copies | selected host | | Codex arm | `${CODEX_HOME:-~/.codex}/skills/gstack` runtime root, `gstack-*` links into `.agents/skills`, `.agents/skills/gstack` sidecar | selected host | -| Kiro, Factory, OpenCode, Cursor, Copilot, Antigravity arms | the host's `skills/gstack` runtime root (built beside the live one and swapped in) and `gstack-*` links | selected host | +| Kiro, Factory, OpenCode, Cursor, Copilot, Antigravity, Gemini CLI arms | the host's `skills/gstack` runtime root (built beside the live one and swapped in) and `gstack-*` links | selected host | | OpenCode commands | `~/.config/opencode/commands/gstack-*.md` with a managed marker (#2629); user command files are never touched | selected host | | Registry | one row per activated install in `installs.tsv` | state root | | Migrations | whatever each `v*.sh` repairs; marker in the state root | state root | @@ -245,12 +245,15 @@ which Google's docs do not state, so the rendered text names no number. **Shared `~/.gemini`.** Antigravity CLI and Gemini CLI both live under `~/.gemini`. Gemini CLI reads `~/.gemini/skills` (and `~/.agents/skills`); -Antigravity CLI reads `~/.gemini/antigravity-cli/skills`. Setup writes only -gstack entries in the latter, uninstall removes only gstack-managed entries -there, and `--host auto` selects Antigravity only for `agy` on `PATH` or an -existing `~/.gemini/antigravity-cli`, never for a bare `~/.gemini`. -`test/setup-install-registry.test.ts` holds both directions. Gemini CLI as a -host is not supported. Two overlaps remain: workspace `.agents/skills` is also +Antigravity CLI reads `~/.gemini/antigravity-cli/skills`, and Google's +migration table lists `~/.gemini/skills` as an Antigravity global path too. +Each host's arm writes only gstack entries in its own directory, uninstall +removes only gstack-managed entries in each, and `--host auto` selects +Antigravity only for `agy` on `PATH` or an existing +`~/.gemini/antigravity-cli`, and Gemini CLI only for `gemini` on `PATH`, +never either for a bare `~/.gemini`. `test/setup-install-registry.test.ts` +holds every direction, including both hosts installed together. Gemini CLI is +its own host ("Gemini CLI" below). Two overlaps remain: workspace `.agents/skills` is also where a project-local Codex install puts its Codex-flavored `gstack-*` skills, which Antigravity will list in that repo, and the Antigravity 2.0 app and IDE read `~/.gemini/config/skills`, which this host does not install @@ -277,6 +280,46 @@ root's extra markdown files (`ETHOS.md`, `review/*.md`) as skills, the partial-read behavior of `view_file` on long skills, `ask_question` option handling, and an upgrade from an existing install. +## Gemini CLI (#1247) + +Shipped as `experimental` (`hosts/gemini.ts`, `./setup --host gemini`, alias +`--host gemini-cli`). Verified against Gemini CLI 0.66.0 (its bundled +`docs/cli/skills.md` and `SkillManager.discoverSkills`) on 2026-10-31: + +| Fact | Value | Source | +|---|---|---| +| User skills | `~/.gemini/skills//SKILL.md`, and the `~/.agents/skills` alias, which wins inside the user tier | [Agent Skills](https://geminicli.com/docs/cli/skills/), `packages/core/src/skills/skillManager.ts` | +| Workspace skills | `.gemini/skills` and the `.agents/skills` alias, loaded only in a trusted folder (`/trust`) | same | +| Precedence | built-in < extension < user < workspace; same name: the higher tier wins | same | +| Discovery | `

//SKILL.md`, one level deep; symlinked skill dirs are followed; the name comes from frontmatter `name` | same | +| Invocation | the model calls `activate_skill` after you approve; `/skills list` and `gemini skills list` show them | same | +| Tool names | `run_shell_command`, `read_file`, `read_many_files`, `write_file`, `replace`, `grep_search`, `glob`, `list_directory`, `ask_user`, `invoke_agent`, `activate_skill`, `enter_plan_mode`, `exit_plan_mode`, `write_todos`, `web_fetch`, `google_web_search` | 0.66.0 bundle | +| Context file | `GEMINI.md` | 0.66.0 bundle | + +What the host does: skills in `~/.gemini/skills/gstack-*`, runtime root +`~/.gemini/skills/gstack` with `.source-path` for `/gstack-upgrade`, `name:` +equal to the `gstack-` directory, frontmatter limited to `name` and +`description` (1024-character limit), the Gemini model overlay, and a +one-paragraph tool glossary ahead of the preamble's STATUS rules. The glossary +also names Antigravity's `run_command`, `view_file` and `ask_question`, +because an Antigravity CLI that reads `~/.gemini/skills` sees these skills. +The render directory is `.gemini-cli/` (gitignored), not `.gemini/`, so this +repo's own trusted workspace never lists render output as skills. `--local` +is refused: it installs only Claude, and Gemini CLI reads workspace skills only +in trusted folders. + +Overlaps: inside a trusted repo, a project-local Codex install's `.agents/skills` +(Codex-flavored `gstack-*` skills) is the workspace tier, which outranks +`~/.gemini/skills`, so Gemini CLI uses those there. gstack never writes +`~/.agents/skills`: that alias is shared with other tools and outranks +`~/.gemini/skills`. Antigravity's own install stays in +`~/.gemini/antigravity-cli/skills`. + +Unverified without a Gemini API key on the build machines (why it is not +`full`): a live model run of any skill, `ask_user` option handling, and +plan mode with gstack's plan-review skills. Discovery is verified: +`gemini skills list` after `./setup --host gemini` lists the `gstack-*` skills. + ## Instruction-only hosts and agent harnesses Slate and GBrain have no install arm. OpenClaw and Hermes are harness hosts: @@ -313,6 +356,7 @@ hosts/ ├── gbrain.ts # GBrain ├── copilot.ts # GitHub Copilot CLI ├── agy.ts # Google Antigravity CLI +├── gemini.ts # Google Gemini CLI └── index.ts # Registry: imports all, derives Host type ``` diff --git a/docs/PROJECT_STRUCTURE.md b/docs/PROJECT_STRUCTURE.md index cc71ce742..58532d09d 100644 --- a/docs/PROJECT_STRUCTURE.md +++ b/docs/PROJECT_STRUCTURE.md @@ -20,6 +20,7 @@ gstack/ │ ├── codex.ts, factory.ts, kiro.ts # Existing hosts │ ├── opencode.ts, slate.ts, cursor.ts, openclaw.ts # IDE hosts │ ├── hermes.ts, gbrain.ts # Agent runtime hosts +│ ├── copilot.ts, agy.ts, gemini.ts # GitHub Copilot CLI, Antigravity CLI, Gemini CLI │ └── index.ts # Registry: exports all, derives Host type ├── scripts/ # Build + DX tooling │ ├── gen-skill-docs.ts # Template → SKILL.md generator (config-driven) diff --git a/docs/capy-install.md b/docs/capy-install.md index 22a3481b0..1c1a1f0b3 100644 --- a/docs/capy-install.md +++ b/docs/capy-install.md @@ -241,7 +241,12 @@ keeps the lease. `bin/gstack-lease list` shows `alive|dead|stale`, and clone too, through `GSTACK_CAPY_CLONED`). 2. Preflight: `git`, `curl`, `node`, `jq`, then Bun. The project's pins (`--project `, default the git toplevel of the current directory) are - read with the doctor's own parser (`bin/gstack-runtime-pins.ts`: engines, + read (a `--project` inside a repository resolves to that repository's git + toplevel, so `--project /` means ``; outside a repository + it resolves to the directory's real path, and a value that is not a + directory, such as a repo slug, exits 2 with a usage error before anything + runs; the pins, the doctor and the install verdict all use the resolved + directory) with the doctor's own parser (`bin/gstack-runtime-pins.ts`: engines, packageManager, `.tool-versions`, `.bun-version` and CI `bun-version:` lanes for this platform). CI exact pins for this platform are alternatives: a repository may test several Bun versions (gstack's own CI pins 1.4.2 and diff --git a/docs/hosts.md b/docs/hosts.md index 887a156f0..3482674db 100644 --- a/docs/hosts.md +++ b/docs/hosts.md @@ -163,6 +163,14 @@ Antigravity only when `agy` is on `PATH` or `~/.gemini/antigravity-cli` exists, never for a Gemini-CLI-only `~/.gemini`. The Antigravity 2.0 app and IDE read `~/.gemini/config/skills`, which this host does not install. +Gemini CLI loads gstack skills itself when a task matches (`activate_skill`, +after you approve); ask for `gstack-office-hours`, or list them with +`/skills list`. Setup writes only `~/.gemini/skills/gstack*` and never +`~/.agents/skills`, and `--host auto` selects Gemini CLI only when `gemini` is on +`PATH`. Inside a repo with a project-local Codex install, Gemini CLI uses the +Codex skills in `.agents/skills` (the workspace tier wins), and workspace skills +load only in trusted folders (`/trust`). + Outside reviews require the selected CLI to be installed and authenticated: Claude Code when using gstack in Codex, or Codex on other harnesses. External harnesses discover these commands as `/gstack-claude-code` and `/gstack-codex`; each harness omits its own wrapper. Explicit provider requests keep that provider. The existing `codex_reviews` setting controls automatic outside reviews where supported, regardless of the provider selected. `/claude` has been renamed to `/claude-code`. Re-run `./setup --host ` to migrate managed installations, including other harnesses sharing the checkout. Setup preserves the previous installation if replacement generation or installation fails and prints repair instructions. diff --git a/docs/review-policy.md b/docs/review-policy.md index 283671003..d05ecdda8 100644 --- a/docs/review-policy.md +++ b/docs/review-policy.md @@ -376,7 +376,8 @@ The receipt's summary line prints `passes= blocking_found= deferred=`, which is what a scorecard tracks (target: a median of 1 pass per PR). A receipt carried across a base merge or a test-only fix ([ship-policy.md#receipt-carry](ship-policy.md#receipt-carry)) keeps these -counters, because the reviewed product diff is unchanged. +counters, because the reviewed product diff is unchanged or its only +difference passed a scoped review. ## The scope knob diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 20920e124..64cd2cf4b 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -1299,7 +1299,8 @@ receipt when the base moves: | What changed since /ship | The receipt | |---|---| | a merge of the base, product diff unchanged (tests, release metadata, registered outputs the pregate's regen check proved) | carries; no review reruns | -| a merge of the base whose resolution changed product code | a fresh /ship (`RECEIPT_CARRY_PRODUCT_CHANGED`) | +| a merge of the base that edited lines next to the branch's unchanged hunks (the branch's +/- lines identical and in the same places) | carries after one fix-check reviewer reads the hunks and comes back clean (`RECEIPT_CARRY_SCOPED_REVIEW`, then `carry --scoped-review `) | +| a merge of the base whose resolution changed product code, or moved the branch's lines elsewhere | a fresh /ship (`RECEIPT_CARRY_PRODUCT_CHANGED`) | | a rebase or force-push onto a tree the receipt covers, product diff against the new base unchanged | carries (`carry.ancestry: rewritten`) | | any other rebase or force-push | a fresh /ship (`RECEIPT_CARRY_TREE_MISMATCH`) | | a stacked PR whose parent landed (squash or merge), retargeted to the base with the base merged in, HEAD's product diff exactly the one reviewed against the parent branch | carries (reason `stack parent landed`) | @@ -1375,7 +1376,10 @@ skill=ship status=complete` line, and its summary line ends with A changed product path, or a product patch-id that differs, is [`RECEIPT_CARRY_PRODUCT_CHANGED`](troubleshooting.md#receipt-carry-product-changed) -(exit 3; the refusal names the paths), and a working tree with uncommitted +(exit 3; the refusal names the paths), except when the only difference is +base context next to the branch's unchanged hunks, which is +[`RECEIPT_CARRY_SCOPED_REVIEW`](troubleshooting.md#receipt-carry-scoped-review) +until one fix-check reviewer clears the packet; a working tree with uncommitted changes is [`RECEIPT_CARRY_REFUSED`](troubleshooting.md#receipt-carry-refused). **Rewritten history.** `write` records `head_tree` (the reviewed head's tree) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 62c8453ed..c5cdb50ef 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1239,8 +1239,8 @@ git -C ~/.claude/skills/gstack update-ref -d 'refs/gstack/rescue/' ### `gstack setup: refusing to replace for : this runtime directory contains the source checkout setup runs from (), so replacing it would delete the checkout. Nothing was changed.` -**Meaning.** Setup installs Codex, Factory, OpenCode, Cursor, Copilot and -Antigravity by building a runtime root (such as `~/.cursor/skills/gstack`) and +**Meaning.** Setup installs Codex, Factory, OpenCode, Cursor, Copilot, +Antigravity and Gemini CLI by building a runtime root (such as `~/.cursor/skills/gstack`) and swapping it in for the old one, and Kiro by writing into `~/.kiro/skills/gstack`. Your gstack checkout lives at that root or inside it, so the swap would delete the checkout (#3084), and Kiro would link the checkout into itself. Setup refuses before @@ -2206,6 +2206,25 @@ anything. --- + +### `gstack: WARN kept the full dependency install (SETUP_DEPS_MIGRATE_FAILED; ...): ` + +**Meaning.** Your install was made before v1.91.231.0, so it still carries the +test and eval packages (about 520 MB, mostly the Claude Agent SDK). setup moved +`node_modules` aside to install production dependencies only, that install +failed, and setup put the full `node_modules` back. Nothing was removed, and +gstack keeps working on the full install. + +**Fix.** Fix the cause the warning names (no network, a proxy, a full disk), +then run `./setup` again. Developing gstack in this checkout? Mark it, and +setup keeps the dev packages from then on: + +```bash +git config gstack.contributor true +``` + +--- + ### `Chromium bootstrap: chromium-install — x playwright install chromium exited 127 (command not found); install Bun from bun.sh (winget Bun ships no bunx), or skip with ./setup --no-browser (Git Bash) or .\setup.ps1 --no-browser (PowerShell)` @@ -2896,6 +2915,34 @@ fetching it cannot create that ref, so pass a branch that exists on `origin` (`git branch -r --list 'origin/*'` lists them). `gstack-pregate` says so in its fix instead of suggesting the fetch. + +### `cannot read at . (git version ). Fix: see docs/troubleshooting.md#history-read-failed (HISTORY_READ_FAILED)` + +**Meaning.** A CI job or a local release tool read a file at an older commit +and git could not produce it. The PR and train path jobs (Free Tests' plan, the Version Gate, +and E2E Evals' route, screen summary, planner, slices, reruns, bisect and +report) check out blobless clones (`filter: blob:none`): every commit and +tree is there, but an older file's contents are fetched from GitHub the first +time something reads them. When that fetch fails three times (a GitHub outage, +a network drop), or the ref does not resolve at all, the read fails here +instead of counting as "the file is absent". A file the commit really does not +have still reads as absent, as before. No ship policy at the base means no +train, and no VERSION means `0.0.0.0`. The route and backstop policy reads (inline +in their workflows), `.github/scripts/history-read.sh` (the Version Gate's base +`VERSION`) and `lib/history-read.ts` (the Version Gate's train classification) +print this line. The Version Gate prints the same code when +`git fetch origin ` fails three times. `gstack-restamp` and +`gstack-ship-queue land` print it too when they cannot read the base branch's +CHANGELOG, which they check fragments against (`lib/changelog-fragments.ts` +reads it through `lib/history-read.ts`); outside a partial clone they do not +retry. + +**Fix.** Rerun the failed job once GitHub is reachable. If it fails the same +way on a healthy network, check that the ref exists on `origin` and that the +runner's git supports partial clone (`git --version` 2.22 or newer; the eval +image asserts it at build time in `.github/docker/Dockerfile.ci`). For a local +tool, run `git fetch origin ` and rerun it. + ### `POLICY_INVALID` @@ -3307,6 +3354,54 @@ makes the row a check rather than a claim. `REALITY: already-done pass 0 hits since 2026-10-09 for bin/gstack-doctor 91bbd9e` or `REALITY: surface-check finding bun upgrade --version absent on 1.3.14 bin/gstack-capy-install:52`. + +### `OFFICE_HOURS_PROBE_REFUSED` + +**Meaning.** The office-hours spec reviewer may check a cited flag with +`gstack-office-hours-review probe --help []`, and nothing +else runs. The command must be a gstack bin or one of the listed read-only CLIs +(git, gh, bun, node, npm, pnpm, yarn, jq, rg, python3, uv, go, cargo, make, +docker, claude, codex, gemini), found on PATH outside the repository; a gstack +bin must handle `--help`. Subcommand words are allowed for git, gh, +docker, cargo, go, npm and uv (one word for git, cargo and npm; up to two for gh, docker, go and uv), each from that CLI's list of built-in names in +`lib/office-hours-probe.ts`; words that run a named program, script, image or +extension (`uv run`, `docker run`, `go test`, `npm exec`, a gh extension) are +refused. The probe never runs a shell or a repository-local program (PATH +entries inside the repository are dropped), closes stdin, runs in an empty +temporary directory, passes no keys or tokens in its environment and stops after +10 seconds. + +**Fix.** Nothing to repair. The claim stays unverified, not disproved; check it +from the command's source or docs in the repository instead. + + +### `TASKS_SUPERSEDE_REFUSED` + +**Meaning.** `gstack-tasks` refused a task list. Either `add` found a line in +the rows file (`.gstack/tmp/tasks--rows.jsonl`) that is not a valid +task or disposition, or it leaves a task from the branch's earlier list neither +carried nor closed (the error names each `/`), so nothing was written; or `supersede` was asked to retire the earlier lists +but the new list (`--keep`) is missing, outside the tasks directory, has a +malformed line, or holds a row for another phase or branch, so nothing was +moved and both lists stay live. + +**Fix.** Fix the line the error names in the rows file (`gstack-tasks --help` +shows both row shapes), and rerun `gstack-tasks add --phase `. + + +### `REALITY_RECEIPT_UNRESOLVED` + +**Meaning.** `gstack-plan-reality check` opened a receipt and it is false in +the checkout: the file is missing, it is a directory or a dangling link, the +line is 0 or the range runs backward, or the cited line is past its end. The `RECEIPT_UNRESOLVED: ` line names each +one. A receipt the checkout cannot judge (a path outside every root, a bare +file name no root has, a sha a shallow clone lacks) prints +`RECEIPT_UNVERIFIABLE:` instead and never blocks. + +**Fix.** Open the file and cite the line that holds the evidence. When the +evidence lives in another checkout, run `check` with `--root ` for it +(repeatable; the default is the git top level of the working directory). + ## Pre-gate: generated-file registry, gstack-pregate, issue links Both tools read their pin file (`.gstack/generated.json`, `.gstack/pregate.json`) @@ -5481,11 +5576,37 @@ retargeted stacked PR the parent branch and the base. **Fix.** Run /ship on the new head. The receipt carries only across a conflict-only base merge, a test-only change, release metadata, a registered output the pregate's regen check proved, a rebase onto a covered tree with -the recorded product diff, or a stacked PR's retarget once its parent fully -landed on the base, which the +the recorded product diff, a stacked PR's retarget once its parent fully +landed on the base, or base context next to unchanged hunks after a clean +scoped review ([`RECEIPT_CARRY_SCOPED_REVIEW`](#receipt-carry-scoped-review)), which the `carry` object in the carried receipt records with the product patch-id and the range-diff evidence ([unattended.md](unattended.md#the-terminal-line)). + +### `RECEIPT_CARRY_SCOPED_REVIEW` + +**Meaning.** Every product path the branch touches has the reviewed +/- lines +in the same places, but the base edited context lines next to some of them, so +the 3-line patches differ (GST-384: main reworded the line above the PR's +hunk). The merge may have spliced the two, as in a JSON entry where the branch +changed one key and the base the next, so the receipt does not carry silently. +The refusal names the paths and a packet file with the hunks that differ and +the base's own change. The same lines moved somewhere else are +`RECEIPT_CARRY_PRODUCT_CHANGED`, not this code. + +**Fix.** Dispatch one fix-check reviewer +([review-policy.md](review-policy.md)) on the packet. When it comes back clean, +write `{"head":"","paths":[...],"reviewer":"","verdict":"clean"}` +and rerun `gstack-ship-receipt carry ... --scoped-review `; the +carry reason records the reviewer and the paths. A blocking finding means /ship +on the new head. `gstack-ship-release finish` stops the same way with +`context=N` and takes `--context-reviewed ` bound to the tree; +write that record beside the packet, outside the worktree, so it does not change +the tree it is bound to. Finish also stops this way for test code changed since +review with no gate edit (`tests=N`): the packet holds those test hunks, and the +reviewer checks that each test still tests what it did. A packet that cannot be +written (an unwritable `.git`) is a STOP or `RECEIPT_CARRY_REFUSED` naming the path. + ### `RECEIPT_PROSE_MISMATCH` diff --git a/docs/unattended.md b/docs/unattended.md index d450cf63e..be924d334 100644 --- a/docs/unattended.md +++ b/docs/unattended.md @@ -237,8 +237,12 @@ and reruns carry with `--pregate .gstack/tmp/pregate.json`. `CARRY: carried from `GSTACK_RESULT: skill=ship status=complete run=` line means the receipt now names HEAD with a `carry` object holding the `git range-diff` evidence, and `ship-run.json` gains `carried_from` and `carry_reason`; the `review_loop` -counters are kept, because the reviewed product diff is unchanged. `CARRY: -current` means nothing moved. `CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)`, +counters are kept, because the reviewed product diff is unchanged or its only +difference passed a scoped review. `CARRY: current` means nothing moved. +`CARRY: refused (RECEIPT_CARRY_SCOPED_REVIEW)` means the base edited context +next to reviewed hunks: one fix-check reviewer reads the packet the refusal +names, and a clean record reruns carry with `--scoped-review ` +([troubleshooting](troubleshooting.md#receipt-carry-scoped-review)). `CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)`, `(RECEIPT_CARRY_TREE_MISMATCH)` or `(RECEIPT_CARRY_REFUSED)` with the refused result line means the product diff changed, or history was rewritten onto a tree no review saw, so /ship runs again; diff --git a/docs/uninstall.md b/docs/uninstall.md index d7b9f362f..c0d044329 100644 --- a/docs/uninstall.md +++ b/docs/uninstall.md @@ -54,6 +54,7 @@ rm -rf "${HERMES_HOME:-$HOME/.hermes}/skills/gstack" 2>/dev/null rm -rf ~/.cursor/skills/gstack* 2>/dev/null rm -rf ~/.config/opencode/skills/gstack* 2>/dev/null rm -rf ~/.gemini/antigravity-cli/skills/gstack* 2>/dev/null +rm -rf ~/.gemini/skills/gstack* 2>/dev/null # 6. Remove temp files rm -f /tmp/gstack-* 2>/dev/null diff --git a/hosts/gemini.ts b/hosts/gemini.ts new file mode 100644 index 000000000..a9f5417ae --- /dev/null +++ b/hosts/gemini.ts @@ -0,0 +1,59 @@ +import { defineHost, GBRAIN_RESOLVERS, preambleToolGlossary } from './define-host'; + +// Google Gemini CLI (#1247). Paths: user skills in ~/.gemini/skills, workspace +// skills in .gemini/skills (trusted folders only), found at //SKILL.md +// and indexed by frontmatter name (geminicli.com/docs/cli/skills). Tool names: +// Gemini CLI 0.66.0. Google's migration table lists ~/.gemini/skills as an +// Antigravity CLI global path too, so the glossary maps agy's names as well. +// Verified facts in docs/ADDING_A_HOST.md "Gemini CLI". +const GEMINI_TOOL_GLOSSARY = '**Gemini CLI tool names:** `AskUserQuestion` means your `ask_user` tool; there is no `mcp__*__AskUserQuestion` variant. The Bash tool means `run_shell_command`, the Read tool `read_file`, the Write tool `write_file`, the Edit tool `replace`, Grep `grep_search`, Glob `glob`, the Agent tool `invoke_agent`, and `ExitPlanMode` means `exit_plan_mode`. The Skill tool means `activate_skill`, or reading the skill\'s SKILL.md with `read_file`. In Antigravity CLI, which can read these skills too, use `run_command`, `view_file` and `ask_question` instead.'; + +const gemini = defineHost({ + name: 'gemini', + displayName: 'Gemini CLI', + cliAliases: ['gemini-cli'], + defaultModel: 'gemini', + tier: 'experimental', + capabilities: { toolExecution: true, questions: 'native', planMode: true, delegation: true, browser: true, safetyHooks: 'advisory' }, + + globalRoot: '.gemini/skills/gstack', + localSkillRoot: '.gemini/skills/gstack', + // Render output only: .gemini is Gemini CLI's own workspace directory, so a + // render there would be listed as workspace skills in this repo. + hostSubdir: '.gemini-cli', + + // Gemini CLI indexes skills by frontmatter name, so names match their + // gstack- directories. 1024 is the Agent Skills description limit. + frontmatter: { + mode: 'allowlist', + keepFields: ['name', 'description'], + descriptionLimit: 1024, + descriptionLimitBehavior: 'error', + nameMatchesDirectory: true, + }, + + // Literal ~/.gemini/skills paths (the agy model); .source-path (written by + // setup) lets /gstack-upgrade find the checkout behind the runtime root. + pathRewrites: [ + { from: 'if [ -d "$HOME/.claude/skills/gstack/.git" ]', to: 'if [ -d "$(cat "$HOME/.gemini/skills/gstack/.source-path" 2>/dev/null)/.git" ]' }, + { from: 'INSTALL_DIR="$HOME/.claude/skills/gstack"', to: 'INSTALL_DIR="$(cat "$HOME/.gemini/skills/gstack/.source-path")"' }, + { from: '$HOME/.claude/skills/gstack', to: '$HOME/.gemini/skills/gstack' }, + { from: '~/.claude/skills/gstack', to: '~/.gemini/skills/gstack' }, + { from: '.claude/skills/gstack', to: '.gemini/skills/gstack' }, + { from: '.claude/skills/review', to: '.gemini/skills/gstack/review' }, + { from: '.claude/skills', to: '.gemini/skills' }, + { from: 'CLAUDE.md', to: 'GEMINI.md' }, + ], + toolRewrites: preambleToolGlossary(GEMINI_TOOL_GLOSSARY), + + suppressedResolvers: ['REVIEW_ARMY', ...GBRAIN_RESOLVERS], + + runtimeRoot: { + globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'design/dist', 'make-pdf/dist', 'freeze/bin', 'careful/bin', 'gstack-upgrade', 'ETHOS.md'], + globalFiles: { + 'review': ['checklist.md', 'design-checklist.md', 'greptile-triage.md', 'TODOS-format.md'], + }, + }, +}); + +export default gemini; diff --git a/hosts/index.ts b/hosts/index.ts index 9477f76f3..f7064fecd 100644 --- a/hosts/index.ts +++ b/hosts/index.ts @@ -18,9 +18,10 @@ import hermes from './hermes'; import gbrain from './gbrain'; import copilot from './copilot'; import agy from './agy'; +import gemini from './gemini'; /** All registered host configs. Add new hosts here. */ -export const ALL_HOST_CONFIGS: HostConfig[] = [claude, codex, factory, kiro, opencode, slate, cursor, openclaw, hermes, gbrain, copilot, agy]; +export const ALL_HOST_CONFIGS: HostConfig[] = [claude, codex, factory, kiro, opencode, slate, cursor, openclaw, hermes, gbrain, copilot, agy, gemini]; /** Map from host name to config. */ export const HOST_CONFIG_MAP: Record = Object.fromEntries( @@ -44,7 +45,7 @@ export function getHostConfig(name: string): HostConfig { /** * Resolve a host name from a CLI argument, handling aliases. - * e.g., 'agents' → 'codex', 'droid' → 'factory', 'antigravity' → 'agy' + * e.g., 'agents' → 'codex', 'droid' → 'factory', 'antigravity' → 'agy', 'gemini-cli' → 'gemini' */ export function resolveHostArg(arg: string): string { // Direct name match @@ -67,4 +68,4 @@ export function getExternalHosts(): HostConfig[] { } // Re-export individual configs for direct import -export { claude, codex, factory, kiro, opencode, slate, cursor, openclaw, hermes, gbrain, copilot, agy }; +export { claude, codex, factory, kiro, opencode, slate, cursor, openclaw, hermes, gbrain, copilot, agy, gemini }; diff --git a/lib/aside-render.ts b/lib/aside-render.ts index 3ffde814a..f5e2b6f1a 100644 --- a/lib/aside-render.ts +++ b/lib/aside-render.ts @@ -76,11 +76,14 @@ export type AsideProbe = | { ok: true; version: string } | { ok: false; reason: 'NEEDS_ASIDE' | 'ASIDE_NOT_RUNNING'; detail: string }; -/** Same probe the skills run in BROWSER SETUP: binary present, app answering. */ -export function probeAside(timeoutMs = 30_000): AsideProbe { +/** Same probe the skills run in BROWSER SETUP: binary present, app answering. Aside ships only for macOS, so any other platform answers NEEDS_ASIDE without spawning. */ +export function probeAside(timeoutMs = 30_000, platform: NodeJS.Platform = process.platform): AsideProbe { if (process.env.GSTACK_SKIP_ASIDE === '1') { return { ok: false, reason: 'NEEDS_ASIDE', detail: 'GSTACK_SKIP_ASIDE=1 — Aside skipped by request' }; } + if (platform !== 'darwin') { + return { ok: false, reason: 'NEEDS_ASIDE', detail: `Aside is macOS-only (this is ${platform})` }; + } const which = spawnSync('aside', ['--version'], { encoding: 'utf8', timeout: 10_000 }); if (which.error) { return { ok: false, reason: 'NEEDS_ASIDE', detail: 'the `aside` CLI is not on PATH — install the Aside browser (macOS 15+) from aside.com' }; diff --git a/lib/changelog-fragments.ts b/lib/changelog-fragments.ts index 3f5404cf8..3d21af17b 100644 --- a/lib/changelog-fragments.ts +++ b/lib/changelog-fragments.ts @@ -9,6 +9,7 @@ */ import { spawnSync } from 'node:child_process'; import { headingVersion, parseChangelog } from './changelog-check'; +import { readHistoryFile } from './history-read'; import type { Bump } from './version-source'; export const FRAGMENT_README = 'README.md'; @@ -75,8 +76,8 @@ export function readFragments(cwd: string, treeish: string | null, dir: string, return out; }; const excluded = new Map((exclude ? list(exclude) : []).map(e => [e.path, e.sha])); - const shown = exclude && changelog ? git(cwd, ['show', `${exclude}:${changelog}`]) : null; - const released = shown?.ok ? parseChangelog(shown.out.replace(/\r\n/g, '\n')).sections.filter(s => !s.unreleased).map(s => s.lines) : []; + const shown = exclude && changelog ? readHistoryFile(cwd, exclude, changelog) : null; + const released = shown !== null ? parseChangelog(shown.replace(/\r\n/g, '\n')).sections.filter(s => !s.unreleased).map(s => s.lines) : []; const isReleased = (body: string) => { const want = body.split('\n'); return released.some(lines => lines.some((_, i) => want.every((w, k) => lines[i + k] === w))); diff --git a/lib/history-read.ts b/lib/history-read.ts new file mode 100644 index 000000000..f1fb39f9f --- /dev/null +++ b/lib/history-read.ts @@ -0,0 +1,43 @@ +/** + * Read one file at a commit when the checkout may be a blobless clone + * (`filter: blob:none`), where git fetches an old file's contents on demand + * and that fetch can fail. The TypeScript twin of + * .github/scripts/history-read.sh. A read that `git show` answers is the + * file. When it fails, existence comes from the ref's tree (`git ls-tree + * --full-tree`, local data only): a path the tree does not list is absent, and + * a listed one is retried, 3 tries in all in a partial clone (only a fetch can + * recover) and none otherwise, waiting HISTORY_READ_BACKOFF_SECONDS (default 2) + * times the attempt between tries. Every other failure throws + * HISTORY_READ_FAILED instead of reading as absent + * (docs/troubleshooting.md#history-read-failed). + */ +import { spawnSync } from 'node:child_process'; + +export const HISTORY_READ_TRIES = 3; + +export class HistoryReadError extends Error { + readonly code = 'HISTORY_READ_FAILED'; +} + +/** The file's text at `ref`, or null when the ref's tree does not list it. */ +export function readHistoryFile(repoRoot: string, ref: string, file: string): string | null { + const git = (args: string[]) => spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8', timeout: 60_000, maxBuffer: 64 * 1024 * 1024 }); + const fail = (cause: string) => { + const version = git(['--version']).stdout?.trim() || 'git version unknown'; + return new HistoryReadError(`cannot read ${file} at ${ref}. ${cause} (${version}). Fix: see docs/troubleshooting.md#history-read-failed (HISTORY_READ_FAILED)`); + }; + const firstLine = (r: ReturnType) => (r.stderr ?? '').split('\n')[0] || r.error?.message || `exit ${r.status}`; + let shown = git(['show', `${ref}:${file}`]); + if (shown.status === 0) return shown.stdout; + const listed = git(['ls-tree', '--full-tree', ref, '--', file]); + if (listed.status !== 0) throw fail(`git ls-tree failed: ${firstLine(listed)}`); + if (!listed.stdout.trim()) return null; + const promisors = (git(['config', '--get-regexp', '^(remote\\..+\\.promisor|extensions\\.partialclone)$']).stdout ?? '').split('\n'); + const tries = promisors.some(line => line && !/ false$/i.test(line)) ? HISTORY_READ_TRIES : 1; + for (let attempt = 1; attempt < tries; attempt++) { + Bun.sleepSync(attempt * 1000 * Number(process.env.HISTORY_READ_BACKOFF_SECONDS ?? 2)); + shown = git(['show', `${ref}:${file}`]); + if (shown.status === 0) return shown.stdout; + } + throw fail(`git show failed ${tries === 1 ? 'once (not a partial clone, so no retry)' : `${tries} times`}: ${firstLine(shown)}`); +} diff --git a/lib/office-hours-probe.ts b/lib/office-hours-probe.ts new file mode 100644 index 000000000..e35987aa5 --- /dev/null +++ b/lib/office-hours-probe.ts @@ -0,0 +1,98 @@ +/** + * The office-hours spec reviewer's one execution right (G1, #973): `--help` + * for a named command, so a design that cites a flag can be checked. The bin + * enforces the limits; the prompt only names the command. + * + * allowed gstack's own bins that handle --help (resolved inside the bin + * directory running this probe) and a fixed list of common CLIs, + * each found on PATH at a real path outside the repository + * checkout; up to two subcommand words, each from that CLI's own + * list of built-in names (`git log`, `gh pr create`), never one + * that runs a program, script, image or extension named by a word + * (`uv run`, `docker run`, `go test`, `npm exec`) + * refused anything else: unlisted names, paths, shell syntax, a listed + * name that resolves into the repository + * run no shell, stdin closed, an empty temporary working directory, an + * environment holding only HOME, locale, toolchain locations, pagers + * set to cat and a PATH without relative or in-repository entries + * (no keys or tokens), `--help` (then + * `-h` for a listed CLI whose output has no usage line, as with a + * missing man page; never for a gstack bin), 10 s timeout, output capped + */ +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +export const PROBE_CLIS = ['git', 'gh', 'bun', 'node', 'npm', 'pnpm', 'yarn', 'jq', 'rg', 'python3', 'uv', 'go', 'cargo', 'make', 'docker', 'claude', 'codex', 'gemini'] as const; +const words = (s: string) => s.split(' '); +export const PROBE_SUBCOMMANDS: Record = { + git: { first: words('add am apply bisect blame branch checkout cherry-pick clean clone commit config describe diff fetch format-patch grep init log ls-files ls-remote merge mv notes pull push range-diff rebase reflog remote reset restore revert rev-list rev-parse rm shortlog show sparse-checkout stash status submodule switch tag worktree'), second: [] }, + gh: { first: words('api auth browse cache config gist issue label pr project release repo ruleset run search secret status variable workflow'), second: 'any' }, + docker: { first: words('build buildx compose container image images info inspect logs network ps pull push system tag version volume'), second: words('build config down history inspect logs ls prune ps pull push rm tag up') }, + cargo: { first: words('add bench build check doc init metadata new publish remove search tree update'), second: [] }, + go: { first: words('build clean doc env fmt get install list mod version work'), second: words('download edit graph init sync tidy use vendor verify why') }, + npm: { first: words('audit ci config init install ls outdated pack publish uninstall update version view'), second: [] }, + uv: { first: words('add build cache export init lock pip publish python remove self sync tree venv version'), second: words('clean compile dir find freeze install list pin prune show sync tree uninstall update') }, +}; +export const PROBE_TIMEOUT_MS = 10_000; +export const PROBE_OUTPUT_CAP = 16_000; + +export type ProbeTarget = { exe: string } | { refused: string }; + +const inside = (root: string, p: string) => p === root || p.startsWith(root.endsWith(path.sep) ? root : root + path.sep); +const real = (p: string) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } }; +/** PATH entries a probe may use: absolute, and outside the repository under review. */ +export function probePath(pathEnv: string, repoRoot?: string): string[] { + const root = repoRoot && real(repoRoot); + return pathEnv.split(path.delimiter).filter(d => path.isAbsolute(d) && !(root && inside(root, real(d)))); +} +/** The enclosing repository's top level, found without running git (a repository-local git must never run). */ +export function repoRootOf(dir: string): string | undefined { + for (let d = path.resolve(dir); ; d = path.dirname(d)) { + if (fs.existsSync(path.join(d, '.git'))) return d; + if (path.dirname(d) === d) return undefined; + } +} +const isExecutable = (p: string) => { try { fs.accessSync(p, fs.constants.X_OK); return fs.statSync(p).isFile(); } catch { return false; } }; + +export function resolveProbe(command: string, o: { pathEnv: string; gstackBin: string; repoRoot?: string }, subcommands: readonly string[] = []): ProbeTarget { + if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(command)) return { refused: 'not a bare command name' }; + if (subcommands.length > 2 || subcommands.some(w => !/^[a-z][a-z0-9-]*$/.test(w))) return { refused: 'subcommands are at most two lowercase words' }; + if (subcommands.length) { + const allowed = PROBE_SUBCOMMANDS[command]; + if (!allowed) return { refused: `${command} takes no subcommand words (only ${Object.keys(PROBE_SUBCOMMANDS).join(', ')})` }; + const [first, second] = subcommands; + if (!allowed.first.includes(first!) || (second !== undefined && allowed.second !== 'any' && !allowed.second.includes(second))) return { refused: `${command} ${subcommands.join(' ')} is not a listed built-in subcommand of ${command}` }; + } + if (command.startsWith('gstack-')) { + const exe = path.join(o.gstackBin, command); + if (!isExecutable(exe) || !inside(fs.realpathSync(o.gstackBin), fs.realpathSync(exe))) return { refused: `${command} is not a gstack bin in ${o.gstackBin}` }; + if (!fs.readFileSync(exe, 'latin1').includes('--help')) return { refused: `${command} does not handle --help` }; + return { exe }; + } + if (!(PROBE_CLIS as readonly string[]).includes(command)) return { refused: `not on the probe allowlist (gstack bins and ${PROBE_CLIS.join(', ')})` }; + const exe = probePath(o.pathEnv, o.repoRoot).map(d => path.join(d, command)).find(isExecutable); + if (!exe) return { refused: `${command} is not on PATH` }; + if (o.repoRoot && inside(fs.realpathSync(o.repoRoot), fs.realpathSync(exe))) return { refused: `${command} resolves inside the repository` }; + return { exe }; +} + +const PROBE_ENV_KEEP = words('HOME USER LOGNAME LANG LC_ALL USERPROFILE APPDATA LOCALAPPDATA SYSTEMROOT PATHEXT COMSPEC XDG_CONFIG_HOME XDG_DATA_HOME RUSTUP_HOME RUSTUP_TOOLCHAIN CARGO_HOME GOROOT GOPATH ASDF_DIR ASDF_DATA_DIR MISE_DATA_DIR PYENV_ROOT NVM_DIR VOLTA_HOME BUN_INSTALL DOCKER_CONFIG'); + +export function runProbe(exe: string, subcommands: readonly string[] = [], o: { pathEnv?: string; repoRoot?: string } = {}): { status: number | null; timedOut: boolean; output: string } { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-probe-')); + const keep = PROBE_ENV_KEEP.filter(k => process.env[k] !== undefined); + const env = { ...Object.fromEntries(keep.map(k => [k, process.env[k]!])), PATH: probePath(o.pathEnv ?? process.env.PATH ?? '', o.repoRoot).join(path.delimiter), TMPDIR: cwd, PAGER: 'cat', MANPAGER: 'cat', GIT_PAGER: 'cat', GH_PAGER: 'cat', NO_COLOR: '1' }; + const run = (flag: string) => { + const r = spawnSync(exe, [...subcommands, flag], { cwd, env, stdio: ['ignore', 'pipe', 'pipe'], timeout: PROBE_TIMEOUT_MS, maxBuffer: 1024 * 1024, encoding: 'utf8' }); + const timedOut = (r.error as NodeJS.ErrnoException | undefined)?.code === 'ETIMEDOUT' || r.signal === 'SIGTERM'; + return { status: r.status, timedOut, output: `${r.stdout ?? ''}${r.stderr ?? ''}`.slice(0, PROBE_OUTPUT_CAP) }; + }; + try { + const help = run('--help'); + return help.timedOut || /usage/i.test(help.output) || path.basename(exe).startsWith('gstack-') ? help : run('-h'); + } finally { + fs.rmSync(cwd, { recursive: true, force: true }); + } +} diff --git a/lib/office-hours-review.ts b/lib/office-hours-review.ts index 017a76266..b010ccc7a 100644 --- a/lib/office-hours-review.ts +++ b/lib/office-hours-review.ts @@ -246,7 +246,7 @@ export function renderOfficeHoursReviewerPrompt({ document, verdictPath, previou Document: ${document} Verdict: ${verdictPath} -Use only Read, Write, and the one Bash seal command for this review. Read the design at ${JSON.stringify(document)} with Read and ${previous +Use only Read, Grep and Glob (on files inside the repository checkout, never elsewhere), Write, and Bash for the seal and probe commands in your dispatch message. Read the design at ${JSON.stringify(document)} with Read and ${previous ? `perform a delta re-review against the preceding verdict and the exact design changes captured below.` : 'review all 5 dimensions independently, including new defects.'} Never use Edit on any file, your verdict file included, and do not change the design. Use Write to save your complete verdict as JSON to ${JSON.stringify(verdictPath)}. Then run the \`Seal:\` command from your dispatch message with Bash, exactly as given; it validates the saved file and prints one receipt line. If it reports an error, Write the complete corrected JSON to the same path (never Edit, even for a one-field fix) and run the same command again. Use Bash for nothing else. @@ -299,7 +299,7 @@ Use status unverified with the missing evidence and a current finding ID when re 2. **Consistency** — Do parts of the document agree with each other? Contradictions? 3. **Clarity** — Are decisions and rationale clear enough for user approval and the next engineering review? Are open discovery questions distinguished from committed behavior? Flag ambiguous or missing behavior in the chosen approach. 4. **Scope** — Does the document creep beyond the original problem? YAGNI violations? -5. **Feasibility** — Can this actually be built with the stated approach? Hidden complexity? +5. **Feasibility** — Can this actually be built with the stated approach? Hidden complexity? Check the concrete claims the approach depends on: Read or Grep each cited repository file or function; check each cited command flag with the dispatch's \`Probe:\` command (a refused or failed probe leaves the claim unverified, never disproved); for each number, write its inputs, operation and result into the finding instead of running code. A wrong path, flag or number is blocking. ## Complete preceding verdict diff --git a/lib/plan-reality-receipts.ts b/lib/plan-reality-receipts.ts new file mode 100644 index 000000000..f5d396a6b --- /dev/null +++ b/lib/plan-reality-receipts.ts @@ -0,0 +1,109 @@ +/** + * Receipt resolution for plan reality rows (G1, #973). + * + * `checkRealityRows` (lib/plan-reality.ts) stays pure: it only asks whether a + * row carries a receipt. This module asks whether each receipt points at + * something real, against explicit roots (the checkout a review is about), so + * only `gstack-plan-reality check` reads the filesystem. + * + * resolved the file exists inside a root and the cited lines exist, + * or the sha names a commit in a root's repository + * unresolved a relative path into a root's tree that is missing, a + * directory, a dangling link, line 0, a reversed range or a + * line past the end: the claim is false here + * unverifiable this checkout cannot judge it (outside every root, a bare + * name no root has, a symlink leaving the root, a file over + * the size cap, a sha a shallow clone lacks): reported only + * + * Reads are bounded: only regular files whose real path stays inside the + * root, at most `maxBytes`, and no file content ever appears in a result. + */ +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { RECEIPT_RE, type RealityLine } from './plan-reality'; + +export type ReceiptOutcome = 'resolved' | 'unresolved' | 'unverifiable'; +export interface ReceiptResult { row: string; receipt: string; outcome: ReceiptOutcome; reason: string } +export interface ResolveOptions { roots: readonly string[]; maxBytes?: number } + +const MAX_BYTES = 8 * 1024 * 1024; +const PATH_TOKEN_RE = /^(?.+):(?\d+)(?:-(?\d+))?$/; + +/** Every receipt token on a row; a `host:port` (no `/` and no `.` before the colon) is not a file receipt. */ +export function receiptTokens(rest: string): string[] { + const re = new RegExp(RECEIPT_RE.source, 'g'); + return [...rest.matchAll(re)].map(m => m[0].replace(/^`|`$/g, '').replace(/^commit /, '')) + .filter(t => { const m = PATH_TOKEN_RE.exec(t); return !m || /[./]/.test(m.groups!.file!); }); +} + +const inside = (root: string, p: string) => p === root || p.startsWith(root.endsWith(path.sep) ? root : root + path.sep); + +function lineCount(file: string, size: number): number { + if (size === 0) return 0; + const buf = fs.readFileSync(file); + let n = 0; + for (const b of buf) if (b === 10) n++; + return buf[buf.length - 1] === 10 ? n : n + 1; +} + +function resolvePath(token: string, roots: readonly string[], maxBytes: number): { outcome: ReceiptOutcome; reason: string } { + const m = PATH_TOKEN_RE.exec(token)!; + const file = m.groups!.file!.replace(/^\.\//, ''); + const first = Number(m.groups!.from), last = Number(m.groups!.to ?? m.groups!.from); + if (first < 1 || last < first) return { outcome: 'unresolved', reason: `line range ${first}-${last} is not a range of lines` }; + if (!roots.length) return { outcome: 'unverifiable', reason: 'no root to resolve against (not in a git checkout; pass --root)' }; + if (/^[~$]/.test(file)) return { outcome: 'unverifiable', reason: 'path outside every root' }; + let intoTree = false; + for (const root of roots) { + const candidate = path.resolve(root, file); + if (!inside(root, candidate)) continue; + const top = file.includes('/') ? path.join(root, file.split('/')[0]!) : candidate; + if (path.isAbsolute(file) || fs.existsSync(top)) intoTree = true; + let real: string; + try { + if (!fs.lstatSync(candidate, { throwIfNoEntry: false })) continue; + real = fs.realpathSync(candidate); + } catch (e) { + return { outcome: 'unresolved', reason: `cannot open (${(e as NodeJS.ErrnoException).code ?? 'error'})` }; + } + if (!inside(fs.realpathSync(root), real)) return { outcome: 'unverifiable', reason: 'symlink leaves the root' }; + const target = fs.statSync(real); + if (!target.isFile()) return { outcome: 'unresolved', reason: 'not a regular file' }; + if (target.size > maxBytes) return { outcome: 'unverifiable', reason: `file over ${maxBytes} bytes` }; + const lines = lineCount(real, target.size); + if (last > lines) return { outcome: 'unresolved', reason: `line ${last} is past the end (${lines} lines)` }; + return { outcome: 'resolved', reason: path.relative(root, candidate) }; + } + return intoTree ? { outcome: 'unresolved', reason: 'no such file' } : { outcome: 'unverifiable', reason: 'path outside every root' }; +} + +/** One `git cat-file --batch-check` per root for every sha token; a sha is resolved when any root's repository has the commit. */ +function resolveShas(shas: string[], roots: readonly string[]): Map { + const out = new Map(); + for (const sha of shas) out.set(sha, { outcome: 'unverifiable', reason: 'no root repository has this commit (shallow or another repository)' }); + if (!shas.length) return out; + for (const root of roots) { + const r = spawnSync('git', ['cat-file', '--batch-check'], { cwd: root, input: shas.map(s => `${s}^{commit}`).join('\n') + '\n', encoding: 'utf8', timeout: 10_000 }); + if (r.status !== 0) continue; + r.stdout.split('\n').filter(Boolean).forEach((line, i) => { + if (!/ missing$| ambiguous$/.test(line) && shas[i]) out.set(shas[i]!, { outcome: 'resolved', reason: 'commit' }); + }); + } + return out; +} + +export function resolveReceipts(lines: readonly RealityLine[], o: ResolveOptions): ReceiptResult[] { + const maxBytes = o.maxBytes ?? MAX_BYTES; + const roots = o.roots.map(r => path.resolve(r)); + const tokens = lines.filter(l => l.status !== 'n/a').flatMap(l => receiptTokens(l.rest).map(receipt => ({ row: l.id, receipt }))); + const shas = [...new Set(tokens.filter(t => !PATH_TOKEN_RE.test(t.receipt)).map(t => t.receipt))]; + const bySha = resolveShas(shas, roots); + return tokens.map(t => ({ ...t, ...(PATH_TOKEN_RE.test(t.receipt) ? resolvePath(t.receipt, roots, maxBytes) : bySha.get(t.receipt)!) })); +} + +/** The git top level of `cwd`, or none outside a checkout. */ +export function defaultRoots(cwd: string): string[] { + const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', timeout: 10_000 }); + return r.status === 0 && r.stdout.trim() ? [r.stdout.trim()] : []; +} diff --git a/lib/result-codes-plan.ts b/lib/result-codes-plan.ts new file mode 100644 index 000000000..bc98123ef --- /dev/null +++ b/lib/result-codes-plan.ts @@ -0,0 +1,26 @@ +/** + * Result codes for the plan reviews' fact checks and task lists: the REALITY: + * receipt check (gstack-plan-reality), the office-hours reviewer's --help probe + * and gstack-tasks. Kept beside lib/result-codes.ts, which spreads this table + * into RESULT_CODES; the same rules apply: one stable docs/troubleshooting.md + * anchor, a summary and a fix clause each. + */ +import type { ResultCode } from './result-codes'; + +export const PLAN_RESULT_CODES = { + OFFICE_HOURS_PROBE_REFUSED: { + anchor: 'office-hours-probe-refused', + summary: 'the office-hours spec reviewer asked to run `--help` for a command outside the probe allowlist', + fix: 'leave the claim unverified and check it from source (Read or Grep in the repository); only gstack bins and the listed read-only CLIs on PATH, outside the repository, can be probed', + }, + TASKS_SUPERSEDE_REFUSED: { + anchor: 'tasks-supersede-refused', + summary: 'gstack-tasks refused a task list that did not validate (a supersede --keep, or the rows file add reads), so no list was moved or written', + fix: 'fix the reported line of .gstack/tmp/tasks--rows.jsonl (gstack-tasks --help shows the row shapes), then rerun `gstack-tasks add`', + }, + REALITY_RECEIPT_UNRESOLVED: { + anchor: 'reality-receipt-unresolved', + summary: 'a `REALITY:` receipt names a file the checkout does not have, or a line past its end', + fix: 'open the cited file and cite the line that holds the evidence; when the evidence lives in another checkout, pass `--root ` for it', + }, +} as const satisfies Record; diff --git a/lib/result-codes-release.ts b/lib/result-codes-release.ts index fdb569a31..09a36afe4 100644 --- a/lib/result-codes-release.ts +++ b/lib/result-codes-release.ts @@ -385,6 +385,11 @@ export const RELEASE_RESULT_CODES = { summary: 'the product diff against the base changed between the receipt\'s head and HEAD (a product file differs beyond a base merge, a test-only fix, release metadata or a regenerated output), so the reviewed verdict does not cover HEAD', fix: 'run /ship on the new head; the receipt carries only when every changed path is a test, release metadata, a registered output the pregate regen check proved, or the base merge, and the product patch-id is unchanged', }, + RECEIPT_CARRY_SCOPED_REVIEW: { + anchor: 'receipt-carry-scoped-review', + summary: 'the branch\'s own +/- lines are the reviewed ones, but the base edited context next to them, so the merge may have spliced the two (GST-384); the receipt carries only after a scoped review of those hunks', + fix: 'dispatch one fix-check reviewer on the packet the refusal names, write its clean verdict as { head, paths, reviewer, verdict } and rerun carry with --scoped-review ; a blocking finding means /ship on the new head', + }, DOCS_AUDIT_BLOCKED: { anchor: 'docs-audit-blocked', summary: 'the documentation audit (/ship Step 14.5) is blocked: gstack-ship-receipt docs-caps found a declared doc cap over, or gstack-ship-receipt write was given a blocked audit with no owner exception and refused to record the ship as audited', diff --git a/lib/result-codes.ts b/lib/result-codes.ts index a7ac73c3d..2dece5f82 100644 --- a/lib/result-codes.ts +++ b/lib/result-codes.ts @@ -14,6 +14,7 @@ import { RELEASE_RESULT_CODES } from './result-codes-release'; import { CAPY_RESULT_CODES } from './result-codes-capy'; import { BUDGET_RESULT_CODES } from './result-codes-budget'; import { EVAL_RESULT_CODES } from './result-codes-eval'; +import { PLAN_RESULT_CODES } from './result-codes-plan'; export interface ResultCode { /** Stable `` in docs/troubleshooting.md. */ @@ -165,6 +166,11 @@ export const RESULT_CODES = { summary: 'an argument given to .\\setup.ps1 is an empty string, which its Git Bash hand-off cannot carry, so nothing ran', fix: 'drop the empty argument or give it a value, then run .\\setup.ps1 again', }, + SETUP_DEPS_MIGRATE_FAILED: { + anchor: 'setup-deps-migrate-failed', + summary: 'setup tried to move an install made before v1.91.231.0 to production-only dependencies, the production install failed, and setup put the full install back', + fix: 'read the cause on the warning line (offline, proxy, disk full), fix it, then re-run ./setup; the full install keeps working meanwhile', + }, // C-stamp (plan C1, C2, C3, C5, C7): ship policy, restamp, tree receipt, ship receipt. POLICY_SOURCE_UNAVAILABLE: { anchor: 'policy-source-unavailable', @@ -763,6 +769,7 @@ export const RESULT_CODES = { ...CAPY_RESULT_CODES, ...BUDGET_RESULT_CODES, ...EVAL_RESULT_CODES, + ...PLAN_RESULT_CODES, } as const satisfies Record; export type ResultCodeName = keyof typeof RESULT_CODES; diff --git a/lib/ship-bin-common.ts b/lib/ship-bin-common.ts index f7f8efc1e..2702f251f 100644 --- a/lib/ship-bin-common.ts +++ b/lib/ship-bin-common.ts @@ -9,6 +9,9 @@ import { spawn, spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { fragmentSlug } from './changelog-fragments'; +import { parseUnifiedDiff, stampOnlyLine, versionAtRef } from './restamp'; +import { matchesAny } from './ship-policy'; +import { extractVersion } from './version-source'; import { BIN_DIR, runBashSync, runScriptSync, scriptInvocation } from './script-invocation'; export interface Run { status: number | null; stdout: string; stderr: string } @@ -96,7 +99,27 @@ export function releasePaths(policy: Record | null, cwd: string, re const list = (v: unknown) => (Array.isArray(v) ? v.filter((s): s is string => typeof s === 'string') : []); const fragment = fragmentPath(policy, cwd); return [...new Set(['VERSION', 'CHANGELOG.md', 'TODOS.md', 'agents-digest/gstack-AGENTS.md', ...VERSIONED_MANIFESTS.filter(p => versionOnlyEdit(cwd, ref, p)), - ...(typeof policy?.changelog === 'string' ? [policy.changelog] : []), ...(fragment ? [fragment] : []), ...list(policy?.release_outputs), ...list(policy?.stamp_paths)])]; + ...(typeof policy?.changelog === 'string' ? [policy.changelog] : []), ...(fragment ? [fragment] : []), ...list(policy?.release_outputs), ...list(policy?.stamp_paths), + ...stampedFiles(policy, cwd, ref)])]; +} + +/** + * `stamp_files` paths whose working-tree edit against `ref` is a version stamp alone: every changed line swaps the + * old version for the new one under a built-in form or a `stamp_patterns` template (lib/tree-receipt.ts classifyFile). + */ +export function stampedFiles(policy: Record | null, cwd: string, ref: string): string[] { + const globs = Array.isArray(policy?.stamp_files) ? policy.stamp_files.filter((s: unknown): s is string => typeof s === 'string') : []; + if (!globs.length) return []; + const before = versionAtRef(cwd, ref); + if (!('version' in before)) return []; + let after = ''; + try { after = extractVersion(fs.readFileSync(path.join(cwd, before.rel), 'utf8'), before.rel); } catch { return []; } + if (!after || after === before.version) return []; + const templates = Array.isArray(policy?.stamp_patterns) ? policy.stamp_patterns.filter((s: unknown): s is string => typeof s === 'string') : []; + const diff = git(cwd, ['diff', '--unified=0', '--no-color', '--no-ext-diff', '--no-textconv', '--no-renames', ref]); + if (diff.status !== 0) return []; + return parseUnifiedDiff(diff.stdout).filter(f => f.status === 'M' && !f.binary && !f.modeChanged && matchesAny(globs, f.path) && f.hunks.length > 0 && + f.hunks.every(h => h.removed.length === h.added.length && h.removed.every((line, i) => stampOnlyLine(line, h.added[i]!, before.version, after, templates)))).map(f => f.path); } /** Under `changelog_fragments`, the branch's own Step 13 entry `/.md` (docs/ship-policy.md#changelog-fragments); null without the key. */ diff --git a/lib/ship-patch.ts b/lib/ship-patch.ts new file mode 100644 index 000000000..d069dc5cc --- /dev/null +++ b/lib/ship-patch.ts @@ -0,0 +1,200 @@ +/** + * ship-patch: whether the branch's own change to a path is the one a review saw, when the base + * moved under it (GST-407, GST-384). /ship's finish route and the receipt carry both ask it. + * + * Each side is a branch patch: reviewedBase -> reviewed (what the review read) and + * currentBase -> current (what ships), where each base is that side's merge base with the + * target branch. A path is + * - same: the 3-line-context patch-ids match and every hunk sits where the base's change puts it (a + * base merge only shifted line numbers; patch-ids ignore positions, so a block moved to an identical + * context elsewhere is changed); + * - context: the +/- lines match hunk for hunk, and each hunk sits where the base's own + * change (reviewedBase -> currentBase) puts it, so only context lines the base edited differ. + * The merge may still have spliced the two (a JSON entry where the branch changed one key and + * the base the next), so a context path needs a scoped review of its hunks, never a silent pass; + * - changed: anything else, including the same lines moved to another place, a file-mode change, and any path whose + * patches cannot be read (an empty patch on both sides of a path the branch touches). A binary, empty or mode-only + * patch has no hunks to place, so it is same when its patch-id matches and changed otherwise. + * Diffs skip external drivers and textconv, so a configured filter cannot hide a line. + * Paths reach git as literal pathspecs from NUL-separated listings, so quoted names and glob + * characters cannot widen or empty a comparison. + */ +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import { EMPTY_PROJECT_CONFIG, scanCandidate } from './gate-diff/index'; +import { parseUnifiedDiff } from './gate-diff/git'; +import { CARRY_TEST_CODE_GLOBS } from './ship-receipt'; +import { matchesAny } from './ship-policy'; + +export interface PatchSides { reviewedBase: string; reviewed: string; currentBase: string; current: string } +export type PathVerdict = 'same' | 'context' | 'changed'; + +function git(repoRoot: string, args: string[], input?: string): string | null { + const r = spawnSync('git', ['-c', 'core.quotepath=off', ...args], { cwd: repoRoot, encoding: 'utf8', input, timeout: 120_000, maxBuffer: 256 * 1024 * 1024 }); + return r.status === 0 ? r.stdout : null; +} + +/** Every path the diff from..to touches (renames as a delete and an add); null when git cannot diff them. */ +export function touchedPaths(repoRoot: string, from: string, to: string): Set | null { + const out = git(repoRoot, ['diff', '-z', '--name-only', '--no-renames', from, to]); + return out === null ? null : new Set(out.split('\0').filter(Boolean)); +} + +function pathDiff(repoRoot: string, from: string, to: string, rel: string, context: number): string | null { + return git(repoRoot, ['--literal-pathspecs', 'diff', '--no-color', '--no-ext-diff', '--no-textconv', '--no-renames', '--binary', '--full-index', `-U${context}`, from, to, '--', rel]); +} + +function patchId(repoRoot: string, diff: string): string | null { + if (!diff.trim()) return ''; + const out = git(repoRoot, ['patch-id', '--verbatim'], diff); + return out === null ? null : out.trim().split(/\s+/)[0] ?? ''; +} + +interface ZeroHunk { oldStart: number; oldCount: number; newStart: number; newCount: number; body: string } + +/** The hunks of a zero-context diff; null when it has none to read (binary, mode-only) but is not empty. */ +function zeroHunks(diff: string): ZeroHunk[] | null { + const hunks: ZeroHunk[] = []; + const lines = diff.split('\n'); + for (let i = 0; i < lines.length; i++) { + const m = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(lines[i]!); + if (!m) continue; + const h: ZeroHunk = { oldStart: Number(m[1]), oldCount: m[2] === undefined ? 1 : Number(m[2]), newStart: Number(m[3]), newCount: m[4] === undefined ? 1 : Number(m[4]), body: '' }; + const body: string[] = []; + let left = h.oldCount + h.newCount; + while (left > 0 || lines[i + 1]?.startsWith('\\')) { + const line = lines[++i]; + if (line === undefined) return null; + if (!line.startsWith('\\')) left--; + body.push(line); + } + h.body = body.join('\n'); + hunks.push(h); + } + return hunks.length || !diff.trim() ? hunks : null; +} + +/** Where a line of the reviewed base sits in the current base, or null when the base changed it. */ +function mapper(base: ZeroHunk[]): (line: number) => number | null { + return line => { + let shift = 0; + for (const h of base) { + if (h.oldCount > 0 && line >= h.oldStart && line < h.oldStart + h.oldCount) return null; + const before = h.oldCount > 0 ? h.oldStart + h.oldCount - 1 < line : h.oldStart < line; + if (before) shift += h.newCount - h.oldCount; + } + return line + shift; + }; +} + +/** Whether a branch hunk at the reviewed base's position lands where the current branch hunk sits. */ +function sameSpot(r: ZeroHunk, c: ZeroHunk, map: (line: number) => number | null): boolean { + if (r.oldCount > 0) { + for (let k = 0; k < r.oldCount; k++) if (map(r.oldStart + k) !== c.oldStart + k) return false; + return true; + } + let below = r.oldStart; + while (below > 0 && map(below) === null) below--; + let above = r.oldStart + 1; + for (let guard = 0; map(above) === null && guard < 100_000; guard++) above++; + const low = below > 0 ? map(below)! : 0; + return c.oldStart >= low && c.oldStart <= map(above)! - 1; +} + +/** The verdict for one path the branch touches on either side (module header). */ +export function comparePath(repoRoot: string, s: PatchSides, rel: string): PathVerdict { + const r3 = pathDiff(repoRoot, s.reviewedBase, s.reviewed, rel, 3); + const c3 = pathDiff(repoRoot, s.currentBase, s.current, rel, 3); + if (r3 === null || c3 === null || (!r3.trim() && !c3.trim())) return 'changed'; + const modes = (d: string) => d.split('\n').filter(l => /^(old mode|new mode|new file mode|deleted file mode) /.test(l)).join('\n'); + if (modes(r3) !== modes(c3)) return 'changed'; + const ids = [patchId(repoRoot, r3), patchId(repoRoot, c3)]; + if (ids[0] === null || ids[1] === null) return 'changed'; + const r0 = zeroHunks(pathDiff(repoRoot, s.reviewedBase, s.reviewed, rel, 0) ?? ''); + const c0 = zeroHunks(pathDiff(repoRoot, s.currentBase, s.current, rel, 0) ?? ''); + if (ids[0] === ids[1] && r0 === null) return 'same'; + const b0 = zeroHunks(pathDiff(repoRoot, s.reviewedBase, s.currentBase, rel, 0) ?? ''); + if (!r0?.length || !c0 || !b0 || r0.length !== c0.length || r0.some((h, i) => h.body !== c0[i]!.body)) return 'changed'; + const map = mapper(b0); + if (!r0.every((h, i) => sameSpot(h, c0[i]!, map))) return 'changed'; + return ids[0] === ids[1] ? 'same' : 'context'; +} + +function hunkTexts(diff: string): string[] { + return diff.split(/^(?=@@ )/m).slice(1).map(h => h.replace(/^@@ [^@]*@@/, '@@')); +} + +/** The most lines one packet section may hold; a path with a longer section takes the full loop instead. */ +export const PACKET_SECTION_CAP = 200; + +/** + * The scoped-review packet: per context path, the hunks that differ between the two branch patches and the base's own + * change; per test path, its test hunks. `over` names every path with a section past PACKET_SECTION_CAP: one reviewer + * cannot be shown all of it, so the caller sends it to the full loop. + */ +export function contextPacket(repoRoot: string, s: PatchSides, paths: readonly string[], tests: readonly string[] = []): { text: string; over: string[] } { + const over = new Set(); + let current = ''; + const fence = (label: string, body: string) => { + if (body.split('\n').length > PACKET_SECTION_CAP) over.add(current); + return `${label}\n\`\`\`diff\n${body.trimEnd()}\n\`\`\`\n`; + }; + const parts = [`# Scoped review: base context moved next to the reviewed hunks\n\nreviewed ${s.reviewed.slice(0, 12)} against ${s.reviewedBase.slice(0, 12)}; current ${s.current.slice(0, 12)} against ${s.currentBase.slice(0, 12)}. The branch's +/- lines are unchanged; read whether the merge kept their meaning next to the base's edit. Report clean, or findings at the blocking floor.\n`]; + if (tests.length) parts.push(`Test code changed since review in ${tests.join(', ')}: read whether each test still checks what it did (no weakened or bypassed assertion, early return, new exemption, skip or mock). Report clean, or findings at the blocking floor.\n`); + for (const rel of [...paths, ...tests]) { + current = rel; + const r = hunkTexts(pathDiff(repoRoot, s.reviewedBase, s.reviewed, rel, 3) ?? ''); + const c = hunkTexts(pathDiff(repoRoot, s.currentBase, s.current, rel, 3) ?? ''); + parts.push(`## ${rel}\n`, fence('Reviewed hunks', r.filter(h => !c.includes(h)).join('')), fence('Current hunks', c.filter(h => !r.includes(h)).join('')), + fence("The base's change", hunkTexts(pathDiff(repoRoot, s.reviewedBase, s.currentBase, rel, 3) ?? '').join(''))); + } + return { text: parts.join('\n'), over: [...over] }; +} + +/** + * A test code file outside shared helpers and fixtures: `*.test.*`, `*.spec.*` and `*_test.go` anywhere, and the + * name-only conventions (`test_*.py`, `*Test.java`, `*Tests.swift`, …) only under a test directory, since product + * files can carry those names (`src/main/java/ABTest.java`). A change to one gets the scoped review, not the full loop. + */ +export function isTestCode(rel: string): boolean { + if (!matchesAny(CARRY_TEST_CODE_GLOBS, rel) || /(^|\/)(helpers|fixtures|__fixtures__|__snapshots__)\//.test(rel)) return false; + return /\.(test|spec)\.[^/]+$|_test\.go$/.test(rel) || /(^|\/)(test|tests|spec|__tests__|src\/test|[^/]+Tests)\//.test(rel); +} + +/** + * Test paths whose change from..to needs review: any removed or changed line (a weakened assertion or a changed + * expected value carries no gate tag), a gate edit lib/gate-diff tags (a skipped test, a snapshot, a relaxed gate + * value), or a diff the scan cannot read. The rest still get the scoped fix-check of their test hunks (lib/ship-release.ts route). + */ +export function gateEditedPaths(repoRoot: string, from: string, to: string, paths: readonly string[]): Set | null { + if (!paths.length) return new Set(); + const diff = git(repoRoot, ['--literal-pathspecs', 'diff', '--no-color', '--no-ext-diff', '--no-textconv', '--no-renames', from, to, '--', ...paths]); + if (diff === null) return null; + const files = parseUnifiedDiff(diff); + const scan = scanCandidate({ files, commits: [], project: EMPTY_PROJECT_CONFIG, specIsTestPath: true, candidate: 'route', artifact: '' }); + const removed = paths.filter(p => { + const d = pathDiff(repoRoot, from, to, p, 0); + if (d === null || /^(Binary files |GIT binary patch)/m.test(d)) return true; + const at = d.search(/^@@ /m); + return at >= 0 && d.slice(at).split('\n').some(l => l.startsWith('-')); + }); + return new Set([...scan.hunks.filter(h => h.tags.length > 0).map(h => h.path), ...scan.unsupportedPaths.map(u => u.path), ...removed]); +} + +export interface ScopedReview { paths: string[]; reviewer: string; verdict: 'clean'; head?: string; tree?: string } + +/** A clean scoped review that covers every context path at this head or tree, or why it does not stand. */ +export function readScopedReview(file: string, bind: { head?: string; tree?: string }, paths: readonly string[]): { ok: true; review: ScopedReview } | { ok: false; message: string } { + let v: any; + try { v = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) { return { ok: false, message: `${file} is not readable JSON (${(e as Error).message.split('\n')[0]})` }; } + if (!v || typeof v !== 'object' || Array.isArray(v)) return { ok: false, message: `${file} must be { paths, reviewer, verdict, head|tree }` }; + if (v.verdict !== 'clean') return { ok: false, message: `the scoped review is ${JSON.stringify(v.verdict)}, not clean: fix its findings and run the full loop` }; + if (typeof v.reviewer !== 'string' || !v.reviewer.trim()) return { ok: false, message: 'the scoped review names no reviewer' }; + for (const key of ['head', 'tree'] as const) { + if (bind[key] && v[key] !== bind[key]) return { ok: false, message: `the scoped review is for ${key} ${String(v[key] ?? 'none').slice(0, 12)}, not ${bind[key]!.slice(0, 12)}` }; + } + const covered = Array.isArray(v.paths) ? v.paths.filter((p: unknown) => typeof p === 'string') : []; + const missing = paths.filter(p => !covered.includes(p)); + if (missing.length) return { ok: false, message: `the scoped review does not cover ${missing.slice(0, 5).join(', ')}` }; + return { ok: true, review: { paths: covered, reviewer: v.reviewer.trim(), verdict: 'clean', ...(v.head ? { head: v.head } : {}), ...(v.tree ? { tree: v.tree } : {}) } }; +} diff --git a/lib/ship-receipt.ts b/lib/ship-receipt.ts index 503e20936..70462347b 100644 --- a/lib/ship-receipt.ts +++ b/lib/ship-receipt.ts @@ -151,11 +151,11 @@ export function receiptSummaryLine(r: ShipReceipt): string { * such as `openapi.spec.yaml` is product. */ const TEST_CODE_EXTENSIONS = ['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'mts', 'cts', 'rb', 'py', 'go', 'rs', 'java', 'kt', 'swift', 'dart', 'php', 'cs']; -export const CARRY_TEST_GLOBS: readonly string[] = [ - 'test/**', 'tests/**', 'spec/**', '__tests__/**', 'e2e/**', 'cypress/**', '**/__tests__/**', +export const CARRY_TEST_CODE_GLOBS: readonly string[] = [ ...TEST_CODE_EXTENSIONS.flatMap(ext => [`**/*.test.${ext}`, `**/*.spec.${ext}`]), '**/*_test.go', '**/test_*.py', '**/*_test.py', '**/*_spec.rb', '**/*Test.java', '**/*Tests.swift', ]; +export const CARRY_TEST_GLOBS: readonly string[] = ['test/**', 'tests/**', 'spec/**', '__tests__/**', 'e2e/**', 'cypress/**', '**/__tests__/**', ...CARRY_TEST_CODE_GLOBS]; export const CARRY_METADATA_GLOBS: readonly string[] = ['VERSION', 'CHANGELOG.md', 'package.json', 'agents-digest/**']; /** How many differing product paths a RECEIPT_CARRY_PRODUCT_CHANGED refusal names, and how far the per-path patch-id walk goes. */ export const CARRY_NAMED_PATHS = 20; @@ -176,6 +176,8 @@ export interface CarryPlanInput { rangeDiff: string; /** The base the receipt was reviewed against when it differs from the carry's (a stack parent that landed, #3570): productPatchId.from is then the recorded diff against it. */ retargetedFrom?: string; + /** A clean scoped review of product paths whose only difference is base context next to unchanged hunks (lib/ship-patch.ts, GST-384): the carry reason it adds. */ + scopedReview?: string; } export interface CarryRecord { from: string; reason: string; product_patch_id: string; changed_paths: string[]; merge_commits: number; range_diff: string; covers: string[]; reruns: string[]; trees?: string[]; ancestry?: 'linear' | 'rewritten' } export type CarryPlan = { ok: true; carry: CarryRecord } | { ok: false; code: 'RECEIPT_CARRY_PRODUCT_CHANGED' | 'RECEIPT_CARRY_REFUSED' | 'RECEIPT_CARRY_TREE_MISMATCH'; message: string }; @@ -237,15 +239,17 @@ export function classifyCarryPath(rel: string, extra: { metadata?: readonly stri * against the base has the same patch-id at both heads, which is what a * conflict-only base merge, a test-only fix or a release-metadata change * leaves behind. A product patch that differs is RECEIPT_CARRY_PRODUCT_CHANGED, - * whatever else changed. + * whatever else changed, unless every difference is base context next to unchanged hunks + * and a scoped review of those hunks came back clean (`scopedReview`). */ export function planCarry(input: CarryPlanInput): CarryPlan { - if (input.productPatchId.from !== input.productPatchId.head) { + if (input.productPatchId.from !== input.productPatchId.head && (input.changedProduct.length || !input.scopedReview)) { const named = input.changedProduct.slice(0, CARRY_NAMED_PATHS); return { ok: false, code: 'RECEIPT_CARRY_PRODUCT_CHANGED', message: `product diff changed between ${input.from.slice(0, 12)} and ${input.head.slice(0, 12)}: ${named.join(', ') || 'patch-id differs'}${input.changedProduct.length > named.length ? ` (+${input.changedProduct.length - named.length} more)` : ''}` }; } const reasons: string[] = input.retargetedFrom ? [`stack parent ${input.retargetedFrom} landed`] : []; if (input.mergeCommits > 0) reasons.push(input.mergeCommits === 1 ? 'base merge' : `${input.mergeCommits} base merges`); + if (input.scopedReview && input.productPatchId.from !== input.productPatchId.head) reasons.push(input.scopedReview); if (input.testPatchId.from !== input.testPatchId.head) reasons.push('test-only change'); if (input.changed.some(c => c.class === 'metadata')) reasons.push('release metadata'); if (input.changed.some(c => c.class === 'generated')) reasons.push('regenerated outputs'); diff --git a/lib/ship-release.ts b/lib/ship-release.ts index a1bf533fc..b8509cd04 100644 --- a/lib/ship-release.ts +++ b/lib/ship-release.ts @@ -12,19 +12,22 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { classifyPath } from './review-depth'; +import { comparePath, contextPacket, gateEditedPaths, isTestCode, PACKET_SECTION_CAP, readScopedReview, touchedPaths, type PatchSides } from './ship-patch'; import { resolveStateRoot } from './state-root'; import { bumpVersion, cmpVersion, fmtVersion, parseVersion, versionWidth, type Bump } from './version-source'; -import { bin, evidenceExempt, firstLine, flagReader, fragmentPath, gh, git, hostedOrigin, laneLine, lanes, releasePaths, Report, runLanes, sh, shipPolicy, short } from './ship-bin-common'; +import { bin, evidenceExempt, firstLine, flagReader, fragmentPath, gh, git, hostedOrigin, laneLine, lanes, releasePaths, Report, runLanes, sh, shipPolicy, short, stampedFiles } from './ship-bin-common'; export const RELEASE_HELP = `gstack-ship-release — /ship Steps 12 and 15-17 (ship/SKILL.md) version --base [--bump micro|patch|minor|major] [--version ] [--keep-current] [--accept-incomplete] [--why ] [--answer ] Step 12: classify, pick the queue-aware version, write it with --regen-digest --changelog, log the decision; --keep-current (or --version ) keeps VERSION; under bump ask a session no person answers needs --answer before a bump is written - finish --base --reviewed-tree --docs-post [--lane `. Under a merge queue, `gstack-ship-queue ready --pr [--screen|--no-screen]` at the pushed head; paste its output. +- Step 22: `gstack-ship-budget close --reading R --credits C` (`BUDGET_FINAL:` and `BUDGET_TIMELINE:` carry tokens and credits; report both), then `gstack-ship-receipt result --pr `: `GSTACK_RESULT: skill=ship status=complete run=`. Under a merge queue, `gstack-ship-queue ready --pr [--screen|--no-screen]` at the pushed head; paste its output. ## Where each STOP sends you (paths under `/ship/`) diff --git a/ship/DRIVER.md.tmpl b/ship/DRIVER.md.tmpl index 2751871bf..5922c4599 100644 --- a/ship/DRIVER.md.tmpl +++ b/ship/DRIVER.md.tmpl @@ -61,7 +61,7 @@ The install check and this file. `gstack-ship-preflight --base --lane `. Under a merge queue, `gstack-ship-queue ready --pr [--screen|--no-screen]` at the pushed head; paste its output. +- Step 22: `gstack-ship-budget close --reading R --credits C` (`BUDGET_FINAL:` and `BUDGET_TIMELINE:` carry tokens and credits; report both), then `gstack-ship-receipt result --pr `: `GSTACK_RESULT: skill=ship status=complete run=`. Under a merge queue, `gstack-ship-queue ready --pr [--screen|--no-screen]` at the pushed head; paste its output. ## Where each STOP sends you (paths under `/ship/`) diff --git a/ship/SKILL.md b/ship/SKILL.md index 307938c82..76c60c15c 100644 --- a/ship/SKILL.md +++ b/ship/SKILL.md @@ -508,7 +508,7 @@ repeat Step 2 (decisions included) on the merged content, then Step 4. does not mean failed or unrun probes passed. Keep Step 9.4's incomplete flags and the user's exception. 4. **Save the evidence.** Save both records and matching **reviewed tree** for - Step 16. Continue to Step 12. + Step 16, with its merge base. Continue to Step 12. ## Step 12: Version bump (auto-decide) @@ -666,7 +666,7 @@ Run stages 1–5 in order; recovery notes below name where to resume. Content changes during or after verification restart at stage 1 and complete all five stages before Step 17. Content-preserving commits keep evidence valid. -**One call for stages 1–4:** `~/.claude/skills/gstack/bin/gstack-ship-release finish --base --reviewed-tree --docs-post -post.json --lane '); + }); + + test('a ref that does not resolve fails instead of reading as absent', () => { + const fx = fixture('bad-ref', ONE, { VERSION: '1.0.0.1\n' }); + const r = helper(fx.clone, 'no-such-ref', 'VERSION', '--absent', '0.0.0.0'); + expect(r.status).toBe(1); + expect(r.stderr).toContain('cannot read VERSION at no-such-ref. git ls-tree failed'); + expect(() => readHistoryFile(fx.clone, 'no-such-ref', 'VERSION')).toThrow('(HISTORY_READ_FAILED)'); + expect(helper(fx.clone, 'only-one-arg').status).toBe(2); + }); + + test('the Version Gate classifies a head whose base policy cannot be fetched as invalid; an absent base policy stays off', () => { + const unreadable = fixture('gate-unreadable', ONE, { [POLICY]: JSON.stringify({ bump: 'patch', train: TRAIN }) }); + unreadable.cut(); + const kind = classifyTrainHead({ repoRoot: unreadable.clone, baseRef: 'main', head: unreadable.one }); + expect(kind.kind).toBe('invalid'); + expect(kind.kind === 'invalid' && kind.reason).toContain(`cannot read ${POLICY} at origin/main.`); + expect(kind.kind === 'invalid' && kind.reason).toContain('(HISTORY_READ_FAILED)'); + const absent = fixture('gate-absent', ONE, { [POLICY]: null }); + absent.cut(); + expect(classifyTrainHead({ repoRoot: absent.clone, baseRef: 'main', head: absent.one })).toEqual({ kind: 'off' }); + }); + + test('the fragment reader fails loudly when the base CHANGELOG cannot be fetched, and treats an absent one as nothing released', () => { + const fx = fixture('fragments', { ...ONE, 'CHANGELOG.md': '# Changelog\n\n## [1.0.0.0]\n\n- one\n', 'changelog.d/pr.md': '- the PR entry\n' }, { 'CHANGELOG.md': '# Changelog\n\n## [1.0.0.1]\n\n- two\n\n## [1.0.0.0]\n\n- one\n' }); + fx.cut(); + expect(() => readFragments(fx.clone, fx.one, 'changelog.d', fx.two, 'CHANGELOG.md', 'skip')).toThrow(HistoryReadError); + expect(readFragments(fx.clone, fx.one, 'changelog.d', fx.two, 'NO-CHANGELOG.md', 'skip').errors).toEqual([]); + }); +}); + +type Step = { name?: string; uses?: string; run?: string; with?: Record }; +type Workflow = { jobs: Record }; +const workflow = (file: string) => Bun.YAML.parse(fs.readFileSync(path.join(WORKFLOWS, file), 'utf8')) as Workflow; +const stepOf = (file: string, job: string, name: string) => workflow(file).jobs[job]!.steps!.find(st => st.name === name)!; + +describe.skipIf(!Bun.which('jq') || !Bun.which('bash'))('the workflow steps that read history fail loudly', () => { + function runStep(script: string, fx: Fixture, env: Record, helperAt = '.github/scripts') { + fs.mkdirSync(path.join(fx.clone, helperAt), { recursive: true }); + fs.copyFileSync(HELPER, path.join(fx.clone, helperAt, 'history-read.sh')); + const bin = path.join(fx.clone, '.stub-bin'); + fs.mkdirSync(bin, { recursive: true }); + fs.writeFileSync(path.join(bin, 'sleep'), '#!/bin/sh\nexit 0\n', { mode: 0o755 }); + const out = path.join(fx.clone, '.github-output'); + fs.writeFileSync(out, ''); + const r = spawnSync('bash', ['-e', '-c', script], { cwd: fx.clone, encoding: 'utf8', timeout: 60_000, + env: { PATH: `${bin}:${process.env.PATH ?? ''}`, HOME: fx.clone, HISTORY_READ_BACKOFF_SECONDS: '0', GITHUB_OUTPUT: out, GITHUB_STEP_SUMMARY: path.join(fx.clone, '.summary'), ...env } }); + const outputs = Object.fromEntries(fs.readFileSync(out, 'utf8').split('\n').filter(Boolean).map(line => [line.slice(0, line.indexOf('=')), line.slice(line.indexOf('=') + 1)])); + return { status: r.status, log: r.stdout + r.stderr, outputs }; + } + const policySteps = [ + { file: 'evals.yml', job: 'route', name: 'Read the train policy (base revision)', env: (fx: Fixture) => ({ BASE_SHA: fx.two, DEFAULT_BRANCH: 'main' }) }, + { file: 'evals-main-backstop.yml', job: 'backstop', name: 'Read the train policy (main)', env: (fx: Fixture) => ({ GITHUB_SHA: fx.two }) }, + ]; + + for (const { file, job, name, env } of policySteps) { + test(`${file} ${job}: an unfetchable policy fails, a malformed one fails, an absent one routes as before`, () => { + const script = stepOf(file, job, name).run!; + expect(script, 'the policy read runs no repository code: git and jq only').not.toMatch(/\.github\/scripts|\bbun\b/); + expect(script).not.toContain('|| true'); + expect(script).not.toContain('2>/dev/null'); + + const trained = { [POLICY]: JSON.stringify({ bump: 'patch', train: TRAIN }) }; + const reachable = fixture(`${job}-reachable`, ONE, trained); + expect(runStep(script, reachable, env(reachable))).toMatchObject({ status: 0, outputs: { train: 'true' } }); + const unreadable = fixture(`${job}-unreadable`, ONE, trained); + unreadable.cut(); + const failed = runStep(script, unreadable, env(unreadable)); + expect(failed.status).not.toBe(0); + expect(failed.log).toContain('(HISTORY_READ_FAILED)'); + expect(failed.outputs).toEqual({}); + + const malformed = fixture(`${job}-malformed`, ONE, { [POLICY]: '{not json' }); + const bad = runStep(script, malformed, env(malformed)); + expect(bad.status).not.toBe(0); + expect(bad.log).toContain(`${malformed.two}:${POLICY} is not valid JSON`); + + const absent = fixture(`${job}-absent`, ONE, { [POLICY]: null }); + absent.cut(); + expect(runStep(script, absent, env(absent))).toMatchObject({ status: 0, outputs: { train: 'false' } }); + }); + } + + test('version-gate.yml Read versions: the base VERSION is read, an absent one is 0.0.0.0, a failed fetch fails', () => { + const script = stepOf('version-gate.yml', 'check', 'Read versions').run! + .replace('${{ github.event.pull_request.base.ref }}', 'main'); + expect(script).not.toContain('|| true'); + expect(script).not.toContain('2>/dev/null'); + + const bumped = fixture('gate-bumped', ONE, { VERSION: '1.0.0.1\n' }); + expect(runStep(script, bumped, {}, '.version-gate/.github/scripts')).toMatchObject({ status: 0, outputs: { pr_version: '1.0.0.0', base_version: '1.0.0.1', base_ref: 'main' } }); + + const unversioned = fixture('gate-unversioned', ONE, { VERSION: null }); + expect(runStep(script, unversioned, {}, '.version-gate/.github/scripts').outputs.base_version).toBe('0.0.0.0'); + + bumped.cut(); + const failed = runStep(script, bumped, {}, '.version-gate/.github/scripts'); + expect(failed.status).not.toBe(0); + expect(failed.log).toContain('git fetch origin main failed 3 times'); + expect(failed.log).toContain('(HISTORY_READ_FAILED)'); + }); +}); + +describe('checkout mode on the PR and train path', () => { + const files = fs.readdirSync(WORKFLOWS).filter(f => /\.ya?ml$/.test(f)).sort(); + + test('every full-history checkout is blobless unless its workflow keeps full clones for a named reason', () => { + const problems: string[] = []; + const fullHistory = new Set(); + for (const file of files) { + const text = fs.readFileSync(path.join(WORKFLOWS, file), 'utf8'); + const checkoutLines = text.split('\n').flatMap((line, i) => (/^\s*(-\s+)?uses:\s*actions\/checkout@/.test(line) ? [i + 1] : [])); + const steps = Object.entries((Bun.YAML.parse(text) as Workflow).jobs ?? {}).flatMap(([job, j]) => (j.steps ?? []).filter(st => st.uses?.startsWith('actions/checkout@')).map(st => ({ job, st }))); + expect(steps.length, file).toBe(checkoutLines.length); + steps.forEach(({ job, st }, i) => { + const depth = st.with?.['fetch-depth']; + if (!(depth === 0 || depth === '0' || (typeof depth === 'string' && depth.includes("'0'")))) return; + fullHistory.add(file); + const at = `.github/workflows/${file}:${checkoutLines[i]}`; + if (FULL_CLONE_WORKFLOWS[file]) { + if (st.with?.filter !== undefined) problems.push(`${at}: job ${job} sets filter but ${file} is in FULL_CLONE_WORKFLOWS; remove one or the other`); + return; + } + if (st.with?.filter !== 'blob:none') problems.push(`${at}: job ${job} checks out full history without a blobless filter. Add \`filter: blob:none\` under its \`with:\`, or name ${file} in FULL_CLONE_WORKFLOWS (test/ci-history-read.test.ts) with the reason it needs every blob.`); + }); + } + expect(problems).toEqual([]); + for (const [file, reason] of Object.entries(FULL_CLONE_WORKFLOWS)) { + expect(reason.length, file).toBeGreaterThan(20); + expect(fullHistory.has(file), `${file} has no full-history checkout left; drop it from FULL_CLONE_WORKFLOWS`).toBe(true); + } + }); + + test('the PR and train path jobs check out blobless full history', () => { + for (const [file, jobs] of Object.entries(BLOBLESS_JOBS)) { + for (const job of jobs) { + const checkout = workflow(file).jobs[job]!.steps!.find(st => st.uses?.startsWith('actions/checkout@'))!; + expect(checkout.with?.filter, `${file} ${job}`).toBe('blob:none'); + } + } + }); + + test('the eval image asserts its Git makes a blobless clone and fetches a missing blob', () => { + const dockerfile = fs.readFileSync(path.join(ROOT, '.github', 'docker', 'Dockerfile.ci'), 'utf8'); + for (const part of ['git clone -q --filter=blob:none --no-local', 'config remote.origin.promisor)" = true', 'GIT_NO_LAZY_FETCH=1 git -C "$d/clone" cat-file -e HEAD~1:f', 'show HEAD~1:f)" = one']) expect(dockerfile).toContain(part); + }); +}); diff --git a/test/eng-finding-retry-budget.test.ts b/test/eng-finding-retry-budget.test.ts index c390233f7..d547a0b8e 100644 --- a/test/eng-finding-retry-budget.test.ts +++ b/test/eng-finding-retry-budget.test.ts @@ -141,7 +141,7 @@ test('live periodic census fits the declared CI wall including setup', () => { test('registered allocation is deterministic and preserves every discovered file', () => { const files = collectPaidTestFiles(); - expect(files).toHaveLength(113); // + skill-e2e-model-panel (GSTA-173); + skill-e2e-write-skill (GSTA-133); + skill-e2e-ship-frozen-scope (frozen review scope, GSTA-113 PR 2); + skill-e2e-failure-routing (#3157/#3158); + skill-e2e-dx-error-contract (#3226); + skill-e2e-gate-integrity (HONEST_WORK_GATE_INTEGRITY W8); + skill-e2e-autoplan-schema-canary, -guard-pty and -long-session (Oct 7 wave, CEO-13, ENG-18, CEO-15); + skill-e2e-ship-measure-seeded-flake (v1.91.32.0); W5a: 15 carve wrappers became one case-sharded file; + codex-e2e-multiblock-live and skill-e2e-design-model-smoke (v1.91.30.0); + expect(files).toHaveLength(114); // + skill-e2e-planning-wave (GSTA-228); + skill-e2e-model-panel (GSTA-173); + skill-e2e-write-skill (GSTA-133); + skill-e2e-ship-frozen-scope (frozen review scope, GSTA-113 PR 2); + skill-e2e-failure-routing (#3157/#3158); + skill-e2e-dx-error-contract (#3226); + skill-e2e-gate-integrity (HONEST_WORK_GATE_INTEGRITY W8); + skill-e2e-autoplan-schema-canary, -guard-pty and -long-session (Oct 7 wave, CEO-13, ENG-18, CEO-15); + skill-e2e-ship-measure-seeded-flake (v1.91.32.0); W5a: 15 carve wrappers became one case-sharded file; + codex-e2e-multiblock-live and skill-e2e-design-model-smoke (v1.91.30.0); expect(files).toContain('test/skill-e2e-ship-skip.test.ts'); const m = livePlan(files); expect(livePlan([...files].reverse())).toEqual(m); diff --git a/test/evals-train-workflow.test.ts b/test/evals-train-workflow.test.ts index cf3c8b71e..4e098f78e 100644 --- a/test/evals-train-workflow.test.ts +++ b/test/evals-train-workflow.test.ts @@ -392,7 +392,7 @@ describe.skipIf(!Bun.which('jq') || !Bun.which('bash'))('the route job reads the const block = { gate_workflow: 'evals.yml', free_workflow: 'free-tests.yml', full_gate_check: 'gstack/full-gate', record_artifact: 'gstack-train-record', max_size: 5, fill_hours: 3, screen_selection: 'none', trial_reservation_usd: 1.5 }; test('no policy, no train block, or an invalid one: train=false, and a PR push routes pr', () => { - for (const policy of [undefined, { bump: 'patch' }, { bump: 'patch', train: null }, { train: { ...block, record_artifact: 'bad name!' } }, '{not json']) { + for (const policy of [undefined, { bump: 'patch' }, { bump: 'patch', train: null }, { train: { ...block, record_artifact: 'bad name!' } }]) { const fx = fixture(policy); const out = run(policyStep.run!, fx.dir, { BASE_SHA: fx.base, DEFAULT_BRANCH: 'main' }); expect(out, JSON.stringify(policy)).toEqual({ train: 'false' }); @@ -401,6 +401,12 @@ describe.skipIf(!Bun.which('jq') || !Bun.which('bash'))('the route job reads the } }); + test('a policy that is not JSON fails the route instead of routing as before (GSTA-203 H1)', () => { + const fx = fixture('{not json'); + expect(() => run(policyStep.run!, fx.dir, { BASE_SHA: fx.base, DEFAULT_BRANCH: 'main' })).toThrow('.gstack/ship-policy.json is not valid JSON'); + fs.rmSync(fx.dir, { recursive: true, force: true }); + }); + test('a train block names the record artifact and check, and a PR push routes pr-summary', () => { const fx = fixture({ bump: 'patch', train: block }); const out = run(policyStep.run!, fx.dir, { BASE_SHA: fx.base, DEFAULT_BRANCH: 'main' }); diff --git a/test/fixtures/golden/agy-ship-SKILL.md b/test/fixtures/golden/agy-ship-SKILL.md index 49706f47c..df7239269 100644 --- a/test/fixtures/golden/agy-ship-SKILL.md +++ b/test/fixtures/golden/agy-ship-SKILL.md @@ -508,7 +508,7 @@ repeat Step 2 (decisions included) on the merged content, then Step 4. does not mean failed or unrun probes passed. Keep Step 9.4's incomplete flags and the user's exception. 4. **Save the evidence.** Save both records and matching **reviewed tree** for - Step 16. Continue to Step 12. + Step 16, with its merge base. Continue to Step 12. ## Step 12: Version bump (auto-decide) @@ -666,7 +666,7 @@ Run stages 1–5 in order; recovery notes below name where to resume. Content changes during or after verification restart at stage 1 and complete all five stages before Step 17. Content-preserving commits keep evidence valid. -**One call for stages 1–4:** `~/.gemini/antigravity-cli/skills/gstack/bin/gstack-ship-release finish --base --reviewed-tree --docs-post -post.json --lane