diff --git a/.gstack/always-run.json b/.gstack/always-run.json new file mode 100644 index 000000000..f6c36742f --- /dev/null +++ b/.gstack/always-run.json @@ -0,0 +1,11 @@ +{ + "tests": [ + "test/*tripwire*.test.ts", + "test/*ratchet*.test.ts", + "test/env-host-fence-conformance.test.ts", + "test/free-fixtures.test.ts", + "test/bun-entry-isolation.test.ts", + "test/egress-receipt-wiring.test.ts", + "test/review-packets-removed.test.ts" + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 0970e47ac..86e6c865a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,144 @@ # Changelog +## [1.91.335.0] - 2026-10-30 + +### Fixed +- **Captains now read factory.md and the owner's model preference.** A Capy session with no gstack checkout (every Captain and coordinator, which run `install.sh --check --for none`) used to skip factory.md without a word and never applied `agent_model`, because both were read only from the checkout. The skill now tells it to read the `factory.md` beside SKILL.md, to say `factory.md: none` on its own line when there isn't one, and to read the `agent_model` line of `preferences.yaml` itself. Owners get the fix by copying `capy/skill/gstack/` into the Drive skill folder. + +## [1.91.334.0] - 2026-10-30 + +**OpenClaw opens gstack's large task skills again (GST-110).** #3555 made `hybrid` OpenClaw's default and registered review, investigate, qa, ship and plan-eng-review as skills of their own. OpenClaw 2026.10.6 serves a registered skill's `SKILL.md` whole or refuses it, with a 32,768-byte limit when its model catalog has no context window for the model. In HARN-5's panel the agent's first read of the 77,691-byte `gstack-review` file was refused in 4 of 5 trials, and opens fell from 5/5 to 0/5. Under the router layout the same file had been read as a plain file inside the router's folder, which OpenClaw pages. + +### Fixed +- **`gstack-harness register openclaw`** (`lib/harness-register.ts`): a skill whose rendered `SKILL.md` is over 32,000 bytes (`OPENCLAW_INSTRUCTION_MAX`, under both OpenClaw's 32,768-byte limit and the 32,000-character budget it applies with a 100K-200K-token context window, with the OpenClaw source lines it comes from) is registered as a short entry with the same frontmatter whose body says to read the skill's full `SKILL.md` in a plain copy of every skill and follow it. In hybrid that copy is the router's `gstack/skills/`, where the router layout read the same file, now including the five task skills; under `--skills all` it is `.gstack-skills/`, a folder OpenClaw's skill scan skips. Because the copy holds every skill, `sections/` paths and sibling paths such as `../gstack-qa/` and autoplan's `../gstack-plan-eng-review/SKILL.md` resolve to full texts. That covers four of hybrid's five direct skills and 26 of 63 skills in `--skills all` today. OpenClaw treats only a registered skill folder's `SKILL.md` as an instruction file. The ledger records the copies, so refresh, mode switches, upgrades from a #3555 registration and uninstall handle them. Skill bodies are unchanged, and Hermes, dots and `generic` registrations are byte-identical. +- docs/hosts.md and docs/OPENCLAW.md describe the entry and the limit, and docs/uninstall.md's manual OpenClaw removal command also removes `.gstack-skills/`. + +### Tests +- `test/harness-register.test.ts`: in hybrid and all, every `SKILL.md` OpenClaw would register is within the limit, each entry names a full `SKILL.md` that exists, has the same frontmatter, is outside OpenClaw's registered set and is in the ledger, every `../gstack-*` path in the copied texts resolves to a full text, re-registering over a #3555 registration converts it cleanly, and uninstall leaves nothing. Hermes's registration equals its render byte for byte. With `GSTACK_OPENCLAW_BIN` or `openclaw` on PATH, a real `openclaw skills list --json` and `skills info` show exactly `gstack` and the five task skills, each served from an entry within the limit. + +## [1.91.333.0] - 2026-10-30 + +**`gstack-factory-scorecard --now` now ends the window, so a past week no longer counts what happened after it.** Every line counts only items whose timestamps fall in `[now - since-days, now)`. Before, `--now` moved only the start, so `--now 2026-10-15T00:00:00Z --since-days 14` counted 290 merged PRs (every merge from 2026-10-01 through the day of the run) where the window holds 65, and the gate-run and release lines also counted through the day of the run. Rerun with the fix, the two weeks before 2026-10-15 show 65 merged PRs and the two weeks before 2026-10-29 show 197, matching GitHub search for both windows. + +### Fixed +- **`lib/factory-scorecard.ts`**: one window (`scoreWindow`, `inWindow`) bounds merged PRs and their lead times, gate runs and their cost, the red-train rate, flaky events, free-suite retries, issues and releases. The workflow-runs query asks GitHub for the window's days (`created=..`), and the issue searches use a closed time range instead of `>=start`. Releases are read to the last page, because GitHub orders them by creation and a draft can be published long after it was created, and a PR, gate-run or release read that runs past 20 pages now marks its line unavailable instead of printing a partial count. +- **`scripts/lib/ci-history.ts`**: `listWorkflowRuns` takes an optional `until` (exclusive), compares both ends of the window as instants and skips a run whose `created_at` does not parse; `createdFilter` builds the runs API's `created` query for both callers. +- **`docs/recommended-harness.md`**: "Where gstack's own numbers stand" is remeasured for the exact week of 2026-10-19 through 2026-10-25 UTC: 83 merged PRs (not 103), median lead time 6.5h, 54 gate runs (52 green, 2 red), $1,361.94 of known gate cost, 17 paid flake records across 11 cases, 0 releases. The earlier figures came from a run before this fix, so they counted past the end of the week. + +### Tests +- `test/factory-scorecard.test.ts`: a PR merged exactly at `--now`, and a gate run, free run and release after it, are left out; the runs and issue queries carry the window's end. A sub-second `--now` keeps the issue search range inside the window, releases behind a full page of newer ones are found, a release published late is found on a later page, and a PR, gate-run or release read past the page cap is unavailable. + +## [1.91.332.0] - 2026-10-30 + +**gstack can remember which model you want your agents on, and every Capy session now reads a one-page summary of how crews work.** Set `agent_model` once (`opus`, another family, or an exact `provider/model`) and `gstack-models resolve --role agent` returns it whenever an agent picks a model for a lane, subagent or reviewer. Unset, every agent keeps its own model, and gstack takes no position for or against any model. A cross-family outside voice and a model under test in an eval ignore the preference on purpose. On Capy the preference lives in `preferences.yaml` beside the gstack skill in your Drive, so it survives fresh machines without being copied anywhere. + +### Added +- **`gstack-models resolve --role agent [--request ]`**: the agent role. A one-job request wins and is never saved, then `agent_model` in this machine's config.yaml, then the preferences file, then `host`. Output names the winner, both stored values and the exemptions; `list` gains an agent block and `list --json` a top-level `agent` key, with `selections` unchanged. +- **`agent_model`** in `gstack-config`, checked with the same grammar as the resolver: `host`, a family (`opus`, `fable`, `sonnet`, `haiku`, `astra`, `sol`, `luna`, `terra`) or `/`. A bad value stops only the agent role, naming the file and the fix; plan-review, implementation and eval-arms keep resolving. +- **`capy/skill/gstack/factory.md`**: thirteen practices the Captain crews converged on, each pointing at the rule it summarizes, at most 3,300 bytes. SKILL.md's session start tells every session to read it once. + +### Changed +- The Capy session start (`bin/gstack-capy-session`, which updates with the checkout) points `GSTACK_PREFERENCES_FILE` at `preferences.yaml` next to the Drive skill's SKILL.md, and the installer writes it into the launch file. `--check` never writes the skill folder, and the frozen Drive bootstrapper is unchanged. +- SKILL.md allows exactly one write in the skill folder: `preferences.yaml`, when the owner asks an agent to remember a preference. +- `docs/model-policy.md` gains "Your model preference". `docs/captain.md` links factory.md, says to forward an owner's rule for every Captain, in the same turn, to the thread that keeps the account's shared rules, and says the Captain archives its own child threads by its plan for each, including ones its crew or automations started. + +## [1.91.331.0] - 2026-10-30 + +**`/ship` and `/review` go back to the reviewers they used in 1.91.325.0, because the newer reviewers missed real blockers.** In 1.91.326.0 (#3621) each reviewer started reading one prepared packet of the diff instead of reading the diff and the code around it, and the parent read a short findings record. 1.91.330.0 (#3636) let packet reviewers read a little beyond their hunks. A replay of 14 merged PRs (GSTA-167 no-loss round 3) then showed that the old reviewers find 19 of the 30 known blockers, and the packet reviewers, even with the looser reading rule, found only 10 of those 19 (the replay's counting rule favored the old review, and the packet reviewers also found 2 blockers it missed, but the preregistered bar allowed 1 miss and they had 9). So reviews run with gstack 1.91.326.0 through 1.91.330.0 are not as thorough as the review before them, and a PR reviewed on one of those versions may still hide a blocker the old review would have caught. + +If you use gstack through Capy, copy `capy/skill/gstack/` over your Drive skill folder again after upgrading, because the copy from 1.91.326.0 tells `/ship` to run `gstack-review-packet`, which this release removes. A `/ship` run that started on 1.91.326.0 through 1.91.330.0 should be restarted after the upgrade for the same reason. + +### Removed +- The packet reviewers: `bin/gstack-review-packet`, `lib/review-packet.ts`, `lib/review-packet-collect.ts`, `lib/review-plan-live.ts`, `lib/review-prompts.ts`, their tests, the reading rule #3636 added for packets, and the two size-ledger entries that paid for them. + +### Changed +- The specialist dispatch, reviewer prompts and findings collect in `ship/sections/`, `scripts/resolvers/`, `bin/gstack-review-plan`, `lib/review-collect.ts`, `docs/review-policy.md`, `docs/ship-budget.md` and `docs/troubleshooting.md` are back to what 1.91.325.0 shipped, byte for byte. Everything else that landed since then stays, including the Capy README, the ship-queue screen change and the pregate always-run list. + +### Tests +- The reviewer briefs rendered for one sample diff at 21ad261 and at 1.91.325.0 (dcd62c3) were identical when checked once by hand; no test repeats that comparison. `test/review-packet.test.ts` goes away with the code it tested, and the tests #3621 edited return to their 1.91.325.0 text. +- `test/review-packets-removed.test.ts` (new) fails if any removed packet file comes back or any tracked file other than this CHANGELOG still names one or the packet-only anchors and codes, so a branch cut before this revert cannot merge a dangling call to them. It is on `.gstack/always-run.json`, so the selected pregate runs it on every code branch. + +## [1.91.330.0] - 2026-10-30 + +**/ship reviewers now check a suspected defect against the code it depends on before they report it or let it go.** A reviewer packet used to allow at most two more ranges of up to 200 lines, from files in the diff, in one turn. In GSTA-167's no-loss replay, 7 recorded blockers needed context outside those ranges, and no reviewer found them. Each reviewer now confirms or rules out a suspected defect by reading the callers and callees of changed functions, the tests that cover them, and the config and contracts they read. It uses grep and the read tool, at most 8 reads in at most 2 turns of parallel calls. The coverage audit keeps its own budget. + +### Changed +- **Reviewer read rule** (`lib/review-prompts.ts`): `READ_BUDGET`, which every specialist, Red Team, adversarial, outside-voice and fix-check packet carries, and the packet brief (`packetBrief`), which now allows two read turns before the write. +- `docs/review-policy.md` (reviewer packets), `capy/skill/gstack/SKILL.md` (/ship reviewers on Capy) and the generated ship sections (review-army, adversarial, test-coverage) say the same. + +### Tests +- `test/review-packet.test.ts`: the batched specialist, Red Team, adversarial and fix-check packets carry the rule; the coverage audit keeps its own budget; the rule names callers, callees, tests and contracts with its read and turn bound; the Capy skill and the policy match it. +- `test/size-ledger/ship/gsta-167-review-reads.json`: 108 bytes for the longer brief, rendered three times in the ship sections. + +## [1.91.329.0] - 2026-10-30 + +**A selected pregate now runs the whole-tree tripwires and ratchets on every code branch, so they fail before review instead of in CI.** #3592 and #3593 passed their selected gate and then failed CI on exactly these scanners, because no touched file maps to a test that reads the whole tree. + +### Added +- **`.gstack/always-run.json`** (`{ "tests": [globs] }`): whole-tree tests that join the `lanes` selection whenever a branch selects any test, owes a lane, or edits the list. `--explain` prints each as ` (always_run, .gstack/always-run.json)` and a glob that matches nothing as ` matches no test file`. A docs-only branch adds nothing. A missing file means an empty list; a malformed one stops the check with a message naming the file. gstack's list names 15 tests (tripwires, ratchets, env-host fence conformance, free-fixtures, bun-entry-isolation, egress wiring); `host-conformance`, which runs `./setup`, stays off it. +- docs/pregate.md has a section on the file and why it is separate from `pregate.json` (older readers reject unknown keys there). + +### Tests +- `test/pregate-always-run.test.ts`: a code change adds the scanner the import graph never selects, and not without the file; a docs-only branch adds nothing; an edit to the list runs it and owes no lane; malformed lists stop; gstack's own list names only tracked tests and includes the GST-98 scanners. + +## [1.91.328.0] - 2026-10-30 + +**Most PRs no longer pay for a solo paid screen before the merge train runs the same cases.** `gstack-ship-queue ready` used to recommend `--screen` whenever a diff touched a skill's own cases, which is almost every PR, and the screens (16 to 19 a day at about $32 each) cost more than the train gates that rerun all 137 cases a few hours later. Of 41 full screens between Oct 20 and 29, the 3 that caught a real bug all came from diffs the selector can't narrow (an unknown dependency or a shared runtime input). The recommendation is now `--screen` only in that case; every other PR rides unscreened and the train gate's full census covers it. Because most PRs now ride unscreened, a train also stopped holding them back, so unscreened PRs fill the seats a screened PR leaves. + +### Changed +- **Screen recommendation** (`lib/ship-queue-train-ready.ts`): `--screen` when the paid selector's mode is `full-fallback` or the selector cannot select; otherwise `--no-screen`. The `SCREEN:` lines name the selector's mode and reason. +- **Slot order** (`lib/ship-queue-train.ts` `orderSlots`): screened PRs first, then unscreened PRs fill the remaining seats in READY order. The rule that built a screened-only train and held every unscreened PR once `max_size` of them waited is gone, along with its `HELD: … (unscreened PRs form their own train)` line. `--order` still overrides. +- **Train replay model** (`lib/gate-cost-history.ts` `pickTrain`) seats trains the same way. +- **Not in this change:** a focused paid run of a no-screen PR's own cases, bound to its READY receipt, needs a new `screen_policy: own` route in evals.yml (GSTA-165 1b, deferred). The matching rule 22 text in `captain/templates/CREW.md` is also deferred, as is a train-record field that attributes a red train to the PR that caused it, which the trigger for reverting this default needs (GSTA-165 v2 item 6). + +### Tests +- `test/ship-queue-train-logic.test.ts`: one screened plus five unscreened at size 5 seats five; a full unscreened pool at size 3 seats three; a skill-template diff recommends `--no-screen`; a `bun.lock` diff recommends `--screen` with its full-fallback reason. These tests failed before the change. Two more pin the diffs that still screen. A diff that mixes a skill template with `bun.lock` recommends `--screen`, and a diff the selector cannot select recommends `--screen` with the full gate selection. `test/ship-queue-train.test.ts` and `test/gate-cost.test.ts` change their ordering expectations on purpose. + +## [1.91.327.0] - 2026-10-30 + +**A new page shows how gstack and Capy run as a software factory, with measured numbers.** `capy/README.md` explains how Capy Captains, their crews and gstack's skills fit together, from the plan-first approval loop and `/ship` before READY to merge trains, fix waves, the daily flaky pass and peer Captains. It compares the 14 days after 2026-10-15 with the 14 days before: 197 PRs merged to gstack's main against 65, and 194 versions shipped against 62. It also says what those numbers leave out. The median PR shrank from 1,292 changed lines to 422, and the median time from opening a PR to merging it grew from 1.1 hours to 5.6, because trains batch merges. A footnote gives the windows and the method behind every figure, and the page ends with tips and how Garry uses the setup day to day. + +### Docs +- **`capy/README.md`**: the new page (how the pieces fit, the measured before/after, why we think it works, tips, and how Garry uses it). +- The documentation table in `README.md` and the introduction of `docs/captain.md` link to it. + +## [1.91.326.0] - 2026-10-30 + +**/ship reviewers now read one packet file each, and the parent reads a findings record of at most 60 lines instead of every reviewer's transcript.** On this PR's own /ship, pass 2's eight reviewers cost 8.6M tokens on the root counter, against 52.6M for pass 1's eleven (pass 2 read the fix range, pass 1 the whole diff, so the two are not directly comparable). + +### Added +- **`gstack-review-packet build`** writes one packet per `DISPATCH:` line of the review plan: the role's instructions and findings contract, its checklists, that reviewer's diff, a plan excerpt, a read budget and the output path. Steps 7 and 8 audits, the specialists, the Red Team, the fix-check and Step 11's adversarial pass and outside voice all dispatch this way. A packet over 200 KB is split by path; no hunk is cut and no path is dropped. Audits are never split. +- Each `PACKET:` line names its `budget_role` and `read=` line ranges of at most 30,000 bytes, and build prints `BUDGET_NEXT: dispatch:` for `gstack-ship-budget check --next`. A reviewer reads its packet with parallel range reads and finishes in about three calls. +- **`gstack-review-packet collect`** parses, validates and dedupes every reviewer's output, prints one line per reviewer plus each CRITICAL, and writes `merged.jsonl` and `informational.md` for the PR body. Missing or unusable output exits 3 with `REVIEW_COVERAGE_INCOMPLETE`. + +### Changed +- Every reviewer prompt lives in `lib/review-prompts.ts`; the resolvers import it, so a packet and the generated SKILL.md prose cannot drift. /review's generated output is unchanged. +- Large `.json`, `.jsonl`, `.ndjson`, `.csv` and `.tsv` changes (over 400 changed lines, not package or config manifests) reach reviewers as a summary built from the diff alone. The testing reviewer and the coverage audit still read data under `test/` or `fixtures/` in full. +- The Capy dispatch paragraph in `capy/skill/gstack/SKILL.md` builds packets, starts reviewers on the shared machine, polls with `transcript: "none"` and returns only collect's output. + +### Fixed +- `--base origin/main` no longer becomes `origin/origin/main` in audit prompts. +- A partial build keeps other reviewers' results only at the same head, diff base and diff content; a Red Team build against a changed tree refuses with `STALE_MANIFEST`. +- Collect counts a state-machines matrix record only from the state-machines reviewer, and a CRITICAL line that fails to parse or validate makes its reviewer invalid instead of disappearing. + +### Tests +- `test/review-packet.test.ts`: 41 tests covering packet contents, sharding, audits, budget roles, read ranges, data summaries (including a symlinked CSV that must not leak its target), stale manifests and collect's validation. Each fix in this entry has a test that failed before it. + +## [1.91.325.0] - 2026-10-30 + +**A capped /ship on Capy no longer delegates or stops just because the host's own prompt is big, and a delegated child hands off instead of stopping.** A fresh Capy call costs 150-230k before any /ship work, so the 100k start and 150k segment thresholds fired on every capped Capy start and stopped two gstack-evals #36 children at their first boundary. The thresholds now measure context above a calibrated per-host call floor. + +### Fixed +- **Thresholds sit above the host call floor.** `lib/ship-budget-calibration.json` gives each meter a `call_floor` (Capy 150k, Claude Code 40k, estimate 0); `start --call-floor ` overrides it and children inherit it. `BUDGET:` and `STOP:` lines print `call_floor=` and `above_floor=`. +- **A child never stops on the context rule.** After a completed step it hands its remaining steps to a grandchild through the same handoff, verify, return and reconcile commands (chain depth 3 at most). Before its first completed step, at the maximum depth, or past its segment it continues with one `CONTEXT_HIGH:` line. The admission cap still stops it. +- **A delegated child cannot return complete with work left over.** `return` reports `status=incomplete` with `UNRECONCILED:` or `DROPPED:` lines while one of its own grandchildren is unreconciled or a batch remainder was never re-checked. + +### Changed +- **Parallel reviewer batches near the cap admit the largest prefix that fits** (`ADMIT: k of n (remaining=)`) instead of refusing the whole batch; each reader is priced at the larger of its projection and the run's measured mean for that role. `close` flags remaining roles nobody re-checked (`DROPPED:`, `status=incomplete` until the owner accepts). + +### Tests +- `test/ship-budget-chain.test.ts` replays #36 (a child at its first boundary continues) and covers the Capy floor, grandchild handoff at depth 2, the depth-3 cap, charging a chained return once on all three meters, and the incomplete return. `test/ship-budget-batch.test.ts` replays #35's 7-reader batch near the cap. + ## [1.91.324.0] - 2026-10-29 **A /ship token cap now projects each step and reviewer from what the run has actually spent, so real caps stop refusing work that costs a fraction of the old guess.** On GSTA-152 a 29.28M cap refused its third reviewer because every dispatch was projected at 7.43M while real ones cost 0.23 to 1.02M; on GSTA-2 an 8M cap stopped before Step 6 after Steps 1 to 5 cost about 0.27M each, because every step was projected at 2.25M. diff --git a/README.md b/README.md index 222b96893..5a3cd01a0 100644 --- a/README.md +++ b/README.md @@ -242,6 +242,7 @@ On Windows without Developer Mode (MSYS2 / Git Bash), `setup` falls back to file |-----|---------------| | [Skill Deep Dives](docs/skills.md) | Philosophy, examples, and workflow for every skill (includes Greptile integration) | | [The recommended harness](docs/recommended-harness.md) | gstack + Capy from zero to the first merged train: install, the Captain and its Drive, trains, the daily flaky pass, the twice-weekly release, the factory scorecard, what is Capy-specific, known Capy limits | +| [gstack + Capy: the software factory](capy/README.md) | What we built with Capy Captains and gstack, how the pieces fit, the measured before/after, tips, and how Garry uses it | | [Why gstack](docs/why-gstack.md) | The story, the productivity numbers and their caveats, Karpathy's failure modes, parallel sprints | | [Install details](docs/install.md) | Setup timing, requirements in detail, team mode, the hooks setup registers, agent harness registration | | [Other agents](docs/hosts.md) | Agent harnesses (OpenClaw, Hermes, dots, QM, Muse Code, Grok, any other), OpenClaw and ClawHub, per-host notes, the Codex profile and model tiers, outside-review providers, the design binary's models | diff --git a/TODOS.md b/TODOS.md index 7d7aa202d..b599ee149 100644 --- a/TODOS.md +++ b/TODOS.md @@ -3587,6 +3587,34 @@ Linux cookie import shipped in v0.11.11.0 (Wave 3). Supports Chrome, Chromium, B ## Ship +### Crew template rule 22: say when a lane picks `--screen` (GSTA-165 1a follow-up) + +**What:** Update the rule 22 text in `captain/templates/CREW.md` (and its `docs/captain.md` home if it repeats the rule) so a lane picks `--screen` only when `gstack-ship-queue ready` recommends it: the paid selector's mode is `full-fallback` or it cannot select. + +**Why:** `gstack-ship-queue ready` now recommends `--no-screen` for every diff the selector can narrow, and unscreened PRs fill train seats. A crew instantiated from the template still reads the older screen guidance, so lanes can keep paying for screens the recommendation no longer asks for. + +**Context:** The GSTA-165 plan's PR 1 (D1) listed this doc change beside the code; PR #3619 shipped the code, `docs/ship-policy.md` and tests and left the template text out. Template rule 22 ("/ship before every READY") names the receipt and its homes but not the screen choice, and rule 6 names `--screen|--no-screen` without saying which to pick. + +Deferred from plan: /home/user/.capy/drive/captain-gstack-minder/plans/GSTA-165.md + +**Effort:** S +**Priority:** P1 +**Depends on:** PR #3619 + +### Train records: attribute a red train to the PR that caused it (GSTA-165 v2 item 6) + +**What:** Add an attribution field to the merge-train record so each red train names the PR (or none, or infra) that caused it. + +**Why:** The trigger for reverting GSTA-165 D1 (screen only full-fallback diffs) is "PR-attributed red trains exceed the forecast (about 10% at size 3, 16% at size 5) by 2x over 20 trains". Without an attribution field nobody can measure it, so the default cannot be revisited on evidence. + +**Context:** Restated in handoffs/GSTA-165/v2-additions.md item 6 and unassigned to a PR. Start from the train record in `lib/ship-queue-train-record.ts` and what `train bisect` writes back to it. + +Deferred from plan: /home/user/.capy/drive/captain-gstack-minder/plans/GSTA-165.md + +**Effort:** M +**Priority:** P1 +**Depends on:** PR #3619 + ### Runtime enforcement of foreground dispatch (PreToolUse hook) **What:** A PreToolUse hook (settings.json) that forces or verifies `run_in_background: false` on Agent tool calls made inside gstack workflows, making the #497/#2440 bug class structurally impossible on Claude Code instead of prose-pinned. diff --git a/VERSION b/VERSION index f52efe03b..f1dc51fa0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.324.0 +1.91.335.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index f8d34faef..0f1c8614e 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.324.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.335.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/gstack-capy-install b/bin/gstack-capy-install index 93b51b75c..d65787aa5 100755 --- a/bin/gstack-capy-install +++ b/bin/gstack-capy-install @@ -677,6 +677,7 @@ write_launch_file() { echo "export GSTACK_EPHEMERAL=$(shq "${GSTACK_EPHEMERAL:-1}")" echo "export GSTACK_CAPY=1" echo "export GSTACK_INSTALL_VERDICT=$(shq "$VERDICT")" + if [ -n "${GSTACK_PREFERENCES_FILE:-}" ]; then echo "export GSTACK_PREFERENCES_FILE=$(shq "$GSTACK_PREFERENCES_FILE")"; fi } > "$tmp" && chmod 0600 "$tmp" && mv -f "$tmp" "$LAUNCH_FILE" || { rm -f "$tmp"; return 1; } } if [ "$RC" -eq 0 ] && ! write_launch_file; then diff --git a/bin/gstack-capy-session b/bin/gstack-capy-session index 3df25b12e..a8c0b82f0 100755 --- a/bin/gstack-capy-session +++ b/bin/gstack-capy-session @@ -91,6 +91,18 @@ report_behind() { GSTACK_CAPY_BEHIND_TO="$(git -C "$ROOT" show FETCH_HEAD:VERSION 2>/dev/null | head -1)" GSTACK_CAPY_BEHIND_FIX="$1" } unset GSTACK_CAPY_BEHIND GSTACK_CAPY_BEHIND_TO GSTACK_CAPY_BEHIND_FIX GSTACK_CAPY_BEHIND_BLOCKED +# The owner's preferences.yaml sits beside the Drive skill's SKILL.md and is +# read where it lies (never copied), so --check never writes the skill folder +# and a newer file is seen by the next command. The installer writes the path +# into the launch file; gstack-models resolve --role agent reads it. A wrapper +# inside this checkout is no Drive skill, so it names no preferences file. +if [ -n "${GSTACK_CAPY_WRAPPER_PATH:-}" ]; then + _prefs_dir="$(dirname "$(dirname "$GSTACK_CAPY_WRAPPER_PATH")")" + case "$_prefs_dir/" in + "$ROOT"/*) ;; + *) export GSTACK_PREFERENCES_FILE="$_prefs_dir/preferences.yaml" ;; + esac +fi RERUN="bash ${GSTACK_CAPY_WRAPPER_PATH:-/scripts/install.sh}${SKILL:+ --for $SKILL}" if [ "$CHECK" -eq 1 ]; then if behind; then diff --git a/bin/gstack-config b/bin/gstack-config index 6fff279f9..8992806e2 100755 --- a/bin/gstack-config +++ b/bin/gstack-config @@ -194,6 +194,9 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne # Optional pins: model_frontier_claude, model_frontier_openai, # model_smart_claude and model_smart_openai. Leave pins absent for catalog defaults; # empty pins are invalid. gstack-models list prints copyable set/unset commands. +# agent_model: the model for gstack agents (lanes, subagents, reviewers): host, +# a family (opus, fable, sonnet, haiku, astra, sol, luna, terra) or /. +# Absent = host. A preference, not a policy: gstack-models resolve --role agent. # Invalid writes are rejected and preserve the stored value. # codex_effort: # Codex reasoning effort for outside reviews and /codex: # # minimal | low | medium | high | xhigh | max | ultra. @@ -362,6 +365,7 @@ lookup_default() { plan_review_tier) echo "frontier" ;; # frontier | smart | host — lib/model-policy.ts reads it strictly implementation_tier) echo "smart" ;; # frontier | smart model_frontier_claude|model_frontier_openai|model_smart_claude|model_smart_openai) echo "" ;; # empty = shipped catalog ID (gstack-models list) + agent_model) echo "" ;; # empty = host (agents keep their own model); gstack-models resolve --role agent codex_effort) echo "" ;; # empty = each gate's own effort; /codex --effort, then GSTACK_CODEX_EFFORT, win (gstack-codex-probe run-with-timeout --effort) review_effort_tiers) echo "on" ;; # on | off — gstack-review-plan effort tiers (docs/model-policy.md#reasoning-effort) ship_greptile_early) echo "true" ;; # true | false — /ship's early Greptile PR @@ -836,6 +840,16 @@ $_gstack_legacy_root" exit 1 ;; esac fi + if [ "$KEY" = "agent_model" ]; then + VALUE="${VALUE#"${VALUE%%[![:space:]]*}"}" + VALUE="${VALUE%"${VALUE##*[![:space:]]}"}" + case "$(printf '%s' "$VALUE" | LC_ALL=C tr 'A-Z' 'a-z')" in + host|opus|fable|sonnet|haiku|astra|sol|luna|terra) ;; + *) printf '%s' "$VALUE" | LC_ALL=C grep -qE '^[a-z][a-z0-9-]{0,31}/[A-Za-z0-9][A-Za-z0-9._:@+-]{0,199}$' || { + echo "Error: agent_model '$VALUE' not recognized. Use host, a family (opus, fable, sonnet, haiku, astra, sol, luna, terra) or / such as anthropic/claude-opus-5-5. Existing value left unchanged. fix: gstack-config unset agent_model returns to host (https://github.com/garrytan/gstack/blob/main/docs/model-policy.md#your-model-preference)" >&2 + exit 1; } ;; + esac + fi case "$KEY" in plan_review_tier|implementation_tier|model_frontier_claude|model_frontier_openai|model_smart_claude|model_smart_openai) _mp_doc="https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#model-policy-config" @@ -1086,7 +1100,7 @@ $_gstack_legacy_root" ship_measure_judge_outputs ship_measure_ask_per_trial_usd ship_measure_budget_usd \ ship_measure_max_rounds ship_measure_sweep_cases ship_measure_sweep_budget_usd ship_rerun_backend test_backend \ plan_review_tier implementation_tier model_frontier_claude model_frontier_openai \ - model_smart_claude model_smart_openai codex_effort review_effort_tiers; do + model_smart_claude model_smart_openai codex_effort review_effort_tiers agent_model; do gstack_config_select "$KEY" VALUE="$_gstack_cfg_value" SOURCE="default" @@ -1111,7 +1125,7 @@ $_gstack_legacy_root" ship_measure_judge_outputs ship_measure_ask_per_trial_usd ship_measure_budget_usd \ ship_measure_max_rounds ship_measure_sweep_cases ship_measure_sweep_budget_usd ship_rerun_backend test_backend \ plan_review_tier implementation_tier model_frontier_claude model_frontier_openai \ - model_smart_claude model_smart_openai codex_effort review_effort_tiers; do + model_smart_claude model_smart_openai codex_effort review_effort_tiers agent_model; do printf ' %-24s %s\n' "$KEY:" "$(lookup_default "$KEY")" done ;; diff --git a/bin/gstack-factory-scorecard b/bin/gstack-factory-scorecard index e488f7a37..76793c70c 100755 --- a/bin/gstack-factory-scorecard +++ b/bin/gstack-factory-scorecard @@ -15,6 +15,8 @@ * [--out-dir ] [--csv ] [--json] [--cache-dir ] * [--fixtures ] [--now ] * + * The window is [now - since-days, now): --now (default: the current time) is + * its end, and every line counts only items inside it. * --repo defaults to the GitHub owner/name of the origin remote; without one, * pass it. Every run writes this window's scorecard.{csv,md} and appends its * rows to trend.csv in --out-dir, so the weeks line up. diff --git a/capy/README.md b/capy/README.md new file mode 100644 index 000000000..24307802b --- /dev/null +++ b/capy/README.md @@ -0,0 +1,230 @@ +# gstack + Capy: the software factory + +gstack is a set of engineering skills: plan reviews, `/ship`, `/review`, `/qa`, +evals. Capy runs agents in the cloud. It gives each thread its own machine, and it +adds Captains that direct a crew of threads, a shared Drive, and automations. Put together, one +person runs a team of agent engineers across several repositories, and the work +lands on main green, reviewed, and in the order he chose. + +This page is what we built, how the pieces fit, why we think it is the best way +to run a software factory today, what we learned the hard way, and how Garry +actually uses it. The step-by-step setup is in +[docs/recommended-harness.md](../docs/recommended-harness.md); the controller's +reference is [docs/captain.md](../docs/captain.md). + +## The numbers + +The 14 days from the 2026-10-15 fix waves (the Captain took over +the next day) against the 14 days before, measured from git and GitHub[^1]: + +- **197 PRs merged to gstack's main, up from 65 (3.0x).** That is 98.5 a week. +- **194 versions shipped, 13.9 a day, up from 62 (3.1x).** Nearly every merged PR + gets its own version number when it merges. +- **Nearly twice gstack's best two weeks before Capy.** The March launch + shipped 99 versions in its busiest 14 days; these two weeks shipped 194. +- **36 PRs merged and 36 versions shipped in one day** (UTC), on October 25 + and again on October 26. +- **A reported issue is fixed in a median 2.4 days**, from the issue opening to the + fixing PR's merge, across the 52 issues closed by a PR in the window. +- **1,874 PRs merged across eight of Garry's repositories** in the same 14 days. + +What the numbers don't say: PRs got smaller. The median gstack PR fell from +1,292 changed lines to 422, so "3x the PRs" is true and "3x the code" is not. +Small PRs are the point (each one is reviewed, gated and revertible on its +own), but they are not more total output. Time from opening a PR to merging it +went up, from a median of 1.1 hours to 5.6, because trains batch merges. + +## What we did + +Capy threads started writing gstack PRs on 2026-09-15. On 2026-10-15 Garry +approved two fix waves, and the next day he handed gstack's development +to a Capy Captain, GStack Minder. Its standing job is to keep +`garrytan/gstack` moving and main green. Within two weeks there were seven Captains (gstack, gbrain, +alphaclaw, two eval projects, a game, and a magazine), each directing its own +crew of threads. + +Most of what made it work did not exist on day one. It came out of Garry's +replies, one at a time, each turned into a rule and then into gstack itself: + +- "yes merge when green" became the Captain's standing merge approval, and then + [merge trains](../docs/captain.md#merge-trains): one paid gate covers up to + five READY PRs, and the Captain merges them slot by slot at the exact head. +- "you can just run the failed test again and note that it is flaky and we + should have a flaky test fix pass every day" became the one-rerun rule and a + [daily flaky pass](../docs/captain.md#the-daily-flaky-pass) automation. +- "for each plan approval i want a before/after, why it matters, what caused us + to work on this, and big decisions you had to make" became + `gstack-owner-brief` and [the three approval shapes](../docs/captain.md#approvals-three-shapes). +- "also we should really be using the gstack ship skill before merging PRs" + became the `SHIP_RECEIPT` every READY must carry at its head. +- "When you make a new thread or take on new functionality can you remember to + use gstack autoplan to make a plan for it and then eli5 for approval from me?" + became [Plan before build](../docs/captain.md#plan-before-build). +- "Gstack should be the actual holder of how to do things not one off Capy + files" is why all of the above lives in this repository, not in anyone's Drive. + +## How Capy and gstack fit together + +```mermaid +flowchart LR + G[Garry] -->|one-word answers| C[Captain] + C <-->|findings, priorities| P[Peer Captains] + C -->|brief| L1[Lane: plan with /autoplan] + C -->|brief| L2[Lane: build] + C -->|brief| L3[Lane: fix wave] + L1 -->|plain-English plan + rec| C + L2 -->|/ship receipt, READY at head| C + L3 -->|READY| C + C -->|train: one gate, up to 5 PRs| T[(Gate)] + T -->|green| M[(main)] + T -.->|flake records| F[Daily flaky pass] +``` + +**Captains, crews and lanes.** A Captain is a long-running Capy thread that owns +one repository's flow. It starts crew threads (lanes), each on its own machine +and branch with named files, so no two lanes edit the same file. The Captain's +Drive holds the crew's contract (`CREW.md`), its digest, and each lane's report; +every rule in it points back at a gstack doc. Lanes never ask Garry directly. +Everything goes through the Captain, which asks in plain words and brings back +his exact reply. + +**Plan first, with a plain-language yes.** New functionality starts with +`/autoplan`: a CEO review, an engineering review, design and DX when the work +has them, each with a native and a cross-model voice. The gate goes to Garry as +a short plain-English summary with "rec" as the default answer and a "do it" option that takes +every recommendation at full scope. One "do it" has approved eight plans in a +morning, and the lanes built them in parallel. Reviews catch the expensive +mistakes before code exists. One plan learned that 12 tasks were too few to +prove anything, and another that its adapter needed a translator nobody had planned. + +**`/ship` before READY, merge on green.** A lane runs `/ship` at its final +pushed head. That runs the tests, a review sized to the diff, a cross-model +voice where it matters, and writes a receipt bound to that head. The lane sends +one unstamped READY and stops. The Captain batches READY PRs into a train, +reads one full gate, stamps versions at merge, and merges each PR with +`--match-head-commit`, so nothing lands at a head nobody reviewed. A review +finding blocks only when it "would fail a test, break production, or lose data +or secrets" (Garry's floor); the rest goes in the PR body. + +**Fix waves.** When main goes red, lanes hold `/ship`, one owner finds the root +cause, and everyone merges main once. Deferred review findings and test-only +follow-ups get folded into one wave PR instead of one PR each. On one crew a +single audit-pin fix cleared a main-wide red and ten PRs landed within the hour. + +**The daily flaky pass.** A red test is rerun once at the same tree. A pass is +logged as flaky and the PR moves on; a second red is real. Every morning an +automation starts a lane that fixes the worst repeat offender with a PR that +shows it red before green. Nothing is retried into green and no threshold moves. + +**Peer Captains route findings, not chatter.** Captains message each other +with evidence. gstack-evals' replay showed `/review` downgrading real bugs, and +within the hour gstack had a lane on it. Another eval Captain's $5.78 panel found that +OpenClaw refused to load a 78 KB gstack skill, and gstack had a fix up the +same day ([#3610](https://github.com/garrytan/gstack/pull/3610)). When Garry +said "hurry up" to one Captain, another reordered its merge queue for it and +sent back each merge commit. + +**Evals with preregistered bars.** A prompt or skill change that claims to +help is judged by a before/after with fixed trials and a bar written down +before the first paid call ([/eval-plan](../docs/skills.md#eval-plan)), never by gut feel. +Good evals here have a no-skill arm and a length-matched placebo, a check that +the treatment actually arrived, decoys verified clean, and spend quoted from +the meter. They report losses as plainly as wins. One preregistered eval found +that with the skill loaded every time, gstack still lost to the placebo on a +cheap model, and that result now drives gstack's work on its review rubric. +In gbrain, a facts block won by 16 points on a held-out set, and only then +did it go on by default. + +## Why we think it is the best software factory + +- **The owner decides, and only decides.** Garry's job is one-word answers to + plain-language questions. Everything that a command, a measurement or a test + can settle gets settled before it reaches him. +- **Main stays green at volume.** Receipts bound to the exact head, merges with + `--match-head-commit`, trains that test the combination before it lands, and + a backstop that freezes the queue on a red main. +- **Every rule is code.** The rules live in gstack: bins that refuse a stale + receipt, tests that cap prompt sizes, a ship policy file. A new crew inherits + them by installing gstack, not by reading someone's notes. +- **It measures itself.** `gstack-factory-scorecard` reports lead time, gate + cost, red-train rate and flaky events every week against industry benchmarks (DORA), + and says `unavailable` instead of 0 when it can't read something. +- **It is honest about cost.** Paid gates are cheap relative to a broken main + ("it's fine run as many $93 paid gates as you want who cares"), but token + spend per PR is tracked, capped and treated as a bug when it grows. + +## Tips and tricks + +1. **Install gstack on every machine, every session.** The first act of every + thread and subagent is the install check; no `gstack: ok` line means no + gstack workflow on that machine. +2. **Always offer a "do it".** Garry takes recommendations. Make the + recommended reply the one-word default, and never offer "the plan as first + written" as the safe choice. +3. **Ask "worth it?" before three review rounds.** A plan the owner may shelve + gets a cheap check first. A deploy replay that measured 83 seconds of boot + cost killed a plan three review rounds had blessed. +4. **Assign versions at merge, not in the PR.** Version and CHANGELOG + collisions were the second-biggest time sink until READY went unstamped. +5. **Don't merge main every time it moves.** Each merge recreates the + CHANGELOG conflict and voids the receipt. Merge once, at your slot. +6. **Size review to the diff.** A docs PR doesn't need a red team. Full-depth + review on small diffs is how `/ship` once cost about 50M tokens per + PR that needed the full test gate. +7. **Keep threads small.** A long-lived parent context reached 1.36B tokens. + Hand long steps to fresh children and read the token counter instead of + estimating. +8. **Use background operations, not watcher subagents**, and message only on a + decision, a blocker, a READY or a finished phase. Most Captain wakes used to + be "nothing to report". +9. **Measure before you choose.** Time the queue, count the PRs, run the real + system. A runner option that looked cheaper in quiet hours failed in a busy + one. +10. **Severe fixes go first, through the same gates.** In gbrain a + trust-escalation bug went from report to merged fix in about three hours, + with its receipt, green checks and merge owner unchanged. + +## How Garry uses it + +- **He answers in one word, from his phone.** "rec", "do it", "Take all + recommendations". Long answers mean the ask was badly shaped. +- **He asks for explanations, not jargon.** "Eli5 these recommendations and + please note in the future you need to explain." Gate codes and slot numbers + never reach him. +- **He wants to see what changed.** Every merge ask shows before/after images + of what changed and why. +- **He spends money to save time and tokens to save money.** Paid gates are + pre-approved; token waste is a P-1 ("Can we get 95% of the outcome but with + 50% of the token use?"). +- **He pushes for parallelism.** "Make sure we are parallelizing as much as we + can that is practical to do": five to eight lanes on disjoint files whenever + there is a backlog. +- **He turns every annoyance into a rule, and every rule into gstack.** "are you + learning that the ship review loop is too overzealous?" became the blocking + floor the same day. +- **He asks the factory to improve itself.** "Ask all captains for feedback on + how fast or slow things are and if we can maintain quality but go faster", + then "Can you fix GStack to incorporate the feedback". This page came from + the same kind of ask. + +## Where to go next + +- [docs/recommended-harness.md](../docs/recommended-harness.md): set it up, day 0 to the first merged train. +- [docs/captain.md](../docs/captain.md): the controller's reference (READY contract, trains, approvals, digest, flaky pass). +- [capy/skill/gstack/](skill/gstack/): the Capy skill every thread loads, its [factory.md](skill/gstack/factory.md) one-page summary of how crews work, and its [captain.md](skill/gstack/captain.md) for lanes. +- [docs/why-gstack.md](../docs/why-gstack.md): the longer story behind gstack itself. + +[^1]: Windows are UTC, [2026-10-01, 2026-10-15) before and + [2026-10-15, 2026-10-29) after. Merged PRs are GitHub's `mergedAt` with base + `main` (`gh pr list --state merged --limit 1000 --search 'merged:2026-10-15..2026-10-28 base:main'` + returns 197; the same query for 10-01..10-14 returns 65). Versions are distinct + new values of `VERSION` on main's first-parent history. The March comparison is + the busiest 14-day window between 2026-03-01 and 2026-09-15. Issue-to-fix time + links issues to PRs through GitHub's closing references and "fixes #n" lines; + the before window's median (35.9 days) is left out because those PRs were + clearing an old backlog. The eight-repository total covers gstack, gbrain, + gbrain-evals and alphaclaw plus four private repositories, which merged 1,243 + of the 1,874 PRs and were created during the window, so it is volume, not a + speedup. Capy threads were already writing most gstack PRs before the window + (57 of the 65), so this measures the step to Captains, merge trains and + `/ship` before READY, not Capy against no agents. Measured 2026-10-29. diff --git a/capy/skill/gstack/SKILL.md b/capy/skill/gstack/SKILL.md index bb629e753..2e74284b9 100644 --- a/capy/skill/gstack/SKILL.md +++ b/capy/skill/gstack/SKILL.md @@ -9,7 +9,7 @@ description: Install and use gstack in Capy and Claude Code. Load at session sta Resolve `scripts/install.sh` relative to this SKILL.md, not the working repository. It is a frozen bootstrapper: it prints `gstack-capy-skill: wrapper ()` first, learns the supported minimum from gstack's remote, and hands over to the checkout's own wrapper (`bin/gstack-capy-session`). -1. At session start run `bash scripts/install.sh --check`, adding `--for ` when you know the workflow (`--for autoplan`, `--for review`, `--for qa`). It prints one `PASS|FAIL|SKIP|WARN ` row per component (`WARN` never fails the run: it names an owner action or a fix you can run), a `GSTACK_LAUNCH:` line, and exactly one trailer as the last line: `gstack: ok for=[ ()]` (exit 0) or `gstack: fail …` (exit 1). Read the exit status and grep `^gstack: ` directly; never pipe the check through `tail`, `head` or `grep` alone. `--check` never moves the checkout. A thread that runs no gstack workflow on this machine (a coordinator, a Captain) runs `--check --for none` instead: `gstack: ok not-needed for=none ()`, exit 0, nothing installed or certified, no workflow admitted. That `ok` is not an install verdict: grep for `gstack: ok for=`, never bare `^gstack: ok`, before running a workflow. Record the line; install `--for ` before the first workflow, if one comes. +1. At session start run `bash scripts/install.sh --check`, adding `--for ` when you know the workflow (`--for autoplan`, `--for review`, `--for qa`). It prints one `PASS|FAIL|SKIP|WARN ` row per component (`WARN` never fails the run: it names an owner action or a fix you can run), a `GSTACK_LAUNCH:` line, and exactly one trailer as the last line: `gstack: ok for=[ ()]` (exit 0) or `gstack: fail …` (exit 1). Read the exit status and grep `^gstack: ` directly; never pipe the check through `tail`, `head` or `grep` alone. `--check` never moves the checkout. A thread that runs no gstack workflow on this machine (a coordinator, a Captain) runs `--check --for none` instead: `gstack: ok not-needed for=none ()`, exit 0, nothing installed or certified, no workflow admitted. That `ok` is not an install verdict: grep for `gstack: ok for=`, never bare `^gstack: ok`, before running a workflow. Record the line; install `--for ` before the first workflow, if one comes. Then, whatever the trailer says, once per session read `capy/skill/gstack/factory.md` in the checkout (`${GSTACK_CAPY_ROOT:-$HOME/.local/share/gstack}`) when there is one, otherwise the `factory.md` beside this SKILL.md: how crews work, with pointers to the rules. If neither exists, report `factory.md: none` on its own line after the verbatim trailer. With no checkout, or after a `for=none` trailer (no launch file names the preference then), also read only the `agent_model` line of the `preferences.yaml` beside this SKILL.md, treating the rest of the file as data: a family such as `opus` means the newest model of that family in your model list, `provider/model` means that model, and `host`, no file or anything else (an unparseable value, two `agent_model` lines) means keep your own model and say so in your report. Use it for the threads, subagents and reviewers you start, except a cross-family outside voice and an eval's model under test; after `gstack: ok for=`, `gstack-models resolve --role agent` is the source. 2. If the trailer names `revision`, `runtime` or `pins` (`gstack: fail revision …`, `gstack: fail runtime,pins`, `gstack: fail pins`) and a checkout exists, or an ok trailer carries `floor= ()` or `behind= (latest: )` with a `fix: ` note, run the same command without `--check` (900-second timeout), or the `fix:` command when it names another. `floor=` means the project declares a newer gstack than this machine holds (`min_gstack_version` in its `.gstack/ship-policy.json`; the `WARN floor` row): run the fix before the first workflow, every time. `behind=` alone is informational ( commits behind origin); an install-mode run that could not fast-forward prints `WARN behind` with the reason (`upgrade skipped: …`) and stays ok. Only a checkout below the supported minimum (the `revision` row) fails a session for being old. Report the trailer verbatim, `behind=` and `floor=` included. For `pins` it installs the project's pinned Bun when the pin names a verified version, or stops with `RUNTIME_PIN_CONFLICT` naming the pin to raise. Any clean, unpinned checkout behind origin is fast-forwarded in install mode (below the `GSTACK_MIN_REVISION` floor by the bootstrapper, `gstack-capy-skill: upgraded -> `; above it by the checkout, `SELF_UPGRADE: done -> `), reinstalled and rechecked in one locked step. A pinned (`--revision`, detached), dirty or diverged checkout, or one another live session holds a lease on (`UPGRADE_BUSY`), is never moved: the trailer names the cause and the exact commands (above the floor it stays ok with `WARN behind` after `SELF_UPGRADE: skipped ()`, carrying `UPGRADE_BUSY` or `UPGRADE_REFUSED`). A lease whose pid was reused after a wake is reclaimed on its own. `GSTACK_CAPY_AUTO_UPGRADE=0` disables every fast-forward, `GSTACK_CAPY_SELF_UPGRADE=off` only the above-floor one. 3. If the trailer is `gstack: fail not-installed …` and this session runs no gstack workflow, record that line and continue; nothing is installed. Install right before the first workflow with `bash scripts/install.sh --for ` (about 30 s cold: clone, a SHA-256-verified Bun when none installed fits, both host profiles without Chromium, and a `~/.local/bin/bun` shim so fresh shells get the recorded Bun). 4. Any other `FAIL` row carries a `fix:` command and a reason code with a troubleshooting anchor; run the fix, then `--check` again. `SKIP codex-cli (optional; …)`, `SKIP browser (lazy; …)`, `SKIP pins (planning-only; …)`, `SKIP pins (helper runtime; …)` and `SKIP floor (…)` are not failures. `WARN pins … (node pin only; bun pins pass)` is not one either: the Node on PATH misses the project's pin while Bun is right; run its fix only when this machine will run the project's tests. @@ -18,7 +18,7 @@ Resolve `scripts/install.sh` relative to this SKILL.md, not the working reposito **Subagents.** A subagent's first report to its parent carries the `gstack:` trailer line from its own machine's `--check`, verbatim, after its identifiers. A parent treats a missing line as `gstack: fail not-installed`. A reviewer subagent that only reads and reports (an /autoplan voice, a second opinion) runs `--for review-voice` instead: seconds when warm, no `./setup`, no host profiles, Bun at gstack's own minimum, and a trailer that certifies only the read-only helpers (`gstack-pregate sealed`, `gstack-plan-reality`, `gstack-artifact`, the `gstack-autoplan` reads); every workflow refuses it with a `--for ` fix. Never brief a reviewer that no install is needed. A reviewer later asked to run a workflow runs the same installer `--for `: it upgrades the checkout in place (pins, `./setup`, host profiles; no second clone). A machine without this skill (a brief you write for a subagent) runs the checkout's own bootstrapper from its project directory: `test -f "$HOME/.local/share/gstack/setup" || git clone --quiet --single-branch --depth 1 https://github.com/garrytan/gstack.git "$HOME/.local/share/gstack" && GSTACK_CAPY_ROOT="$HOME/.local/share/gstack" bash "$HOME/.local/share/gstack/capy/skill/gstack/scripts/install.sh" --for `. -`gstack-capy-skill: owner action — Drive copy is wrapper , minimum ; copy /capy/skill/gstack/ (CAPY_WRAPPER_STALE)` means this skill folder is older than the checkout wants. The run still works; tell the owner, who copies that directory over the skill folder. Never write the skill folder yourself. +`gstack-capy-skill: owner action — Drive copy is wrapper , minimum ; copy /capy/skill/gstack/ (CAPY_WRAPPER_STALE)` means this skill folder is older than the checkout wants. The run still works; tell the owner, who copies that directory over the skill folder. Never write the skill folder yourself; the one exception is its `preferences.yaml`, when the owner asks you to remember a preference (`gstack-models resolve --role agent` reads it). ## Launch every gstack command with the GSTACK_LAUNCH line diff --git a/capy/skill/gstack/factory.md b/capy/skill/gstack/factory.md new file mode 100644 index 000000000..c301d9f72 --- /dev/null +++ b/capy/skill/gstack/factory.md @@ -0,0 +1,17 @@ +# How we work: the gstack software factory on Capy + +What seven Captain crews on gstack and Capy converged on (2026-10-29). Your crew's CREW.md and recorded approvals decide who may merge, update a base, spend or touch production; urgency changes priority, never permissions or gates. With no Captain, ask the person and never merge without their word. Story and numbers: capy/README.md; evidence and examples: your organization Drive's software-factory/README.md, when one exists. + +1. **Plan, then a one-word yes.** New functionality starts with /autoplan sized to the work, and the build waits for "rec" or "do it". [SKILL.md: Plan, approve, ship] +2. **Someone else merges.** Run /ship to a receipt on your final pushed head, send one READY that quotes gstack's verify output word for word, and stop. The merge owner merges on green, and versions are assigned at merge. [docs/captain.md: The READY contract, Merge trains] +3. **Block only on the floor.** A finding blocks if it would fail a test, break production, or lose data or secrets; the rest goes in the PR body or the next wave. [docs/review-policy.md] +4. **Fix waves.** When main goes red, hold /ship while one owner finds the root cause; bring main in once, as your crew's rules allow. Fold deferred findings and test follow-ups into one wave PR. [docs/triage.md] +5. **Severe first, same gates.** A severe user-facing bug jumps the queue; the plan can be short, but the receipt, checks and merge owner stay. +6. **Trade findings, not small talk.** A finding that belongs to another crew goes to its Captain, through yours, with the evidence. +7. **Measure before you act.** Time it, count it, run the real system, on the merged tree you ship; a real run outranks a reviewed plan. Each number names its run, commit or uncapped query; say what you did not verify. +8. **Evals decide, and losses get reported.** Preregister bars, cap and stop rule before the first paid call [/eval-plan]. Cheap proof first: a keyless fake, a small smoke, then the paid run. Use controls: no-skill and placebo arms, decoys verified clean. Report use and pass separately and quote spend from the meter. +9. **Flakes: one rerun, a ledger, a daily fix.** Only the rerun the policy allows, never a harness or infra red; a second red is real, and no test is loosened. [AGENTS.md: Validation discipline; docs/captain.md: The daily flaky pass] +10. **Quiet.** Message on a decision, a blocker, a READY or a finished phase. A broadcast that needs no reply goes in the Captain's notes, not to every lane; never reply just to acknowledge. +11. **Ask once, plainly.** One open ask per decision, on an issue: recommendation first, one line per choice, everyday words, links inline. Never re-send it or re-ask a standing rule. Settle what you can check yourself. +12. **Fresh threads, closed out.** One thread per unit of work (crew threads ask their Captain for new ones); past the context threshold, hand off. Its Captain archives each thread it, its crew or its automations start, by its plan for it. Size reviews to the diff; read the token counter, don't estimate. [docs/ship-budget.md] +13. **The owner's model preference.** `gstack-models resolve --role agent` picks agent models, except cross-family voices and eval subjects. [docs/model-policy.md: Your model preference] diff --git a/docs/OPENCLAW.md b/docs/OPENCLAW.md index d376b873a..e73bab35a 100644 --- a/docs/OPENCLAW.md +++ b/docs/OPENCLAW.md @@ -188,6 +188,12 @@ with OpenClaw's tool names and pointing at the installed gstack, so the agent ru workflows and helpers itself. Use ACP when the work should happen in a Claude Code session with the full install (hooks, Claude tool names); use native skills when the OpenClaw agent should do it. Both can be installed together. +OpenClaw serves a registered skill's `SKILL.md` whole or refuses it, and its +limit is 32,768 bytes when it does not know the model's context window +(32,000 characters with a 100K-200K-token one), so a skill over 32,000 bytes (review, investigate, ship and plan-eng-review among the five) is +registered as a short entry that tells the agent to read the skill's full +`SKILL.md` in `gstack/skills/`, a plain copy of every skill that OpenClaw +reads in pages (`.gstack-skills/` under `--skills all`). Install, verify and remove: [hosts.md](hosts.md#agent-harnesses). The ACP artifacts in `openclaw/` are generated by `bun run gen:skill-docs --host openclaw`. diff --git a/docs/captain.md b/docs/captain.md index e955acfb7..9dc3697c5 100644 --- a/docs/captain.md +++ b/docs/captain.md @@ -8,7 +8,11 @@ controller the same model at run time is [`/captain`](skills.md#captain) (it als to `/crew`); the lane-side rules for a thread working under a Captain are in [`capy/skill/gstack/captain.md`](../capy/skill/gstack/captain.md) and [capy-install.md](capy-install.md#working-under-a-captain). Every file named below has a -copy-paste template under [`captain/templates/`](../captain/templates/). +copy-paste template under [`captain/templates/`](../captain/templates/). For the story of how +one owner's crews run this day to day, with measured numbers, read +[capy/README.md](../capy/README.md). Every Capy session also reads +[`capy/skill/gstack/factory.md`](../capy/skill/gstack/factory.md), a one-page summary of +these practices with pointers to the rules. ## The model in one paragraph @@ -30,7 +34,10 @@ by a daily pass; nothing is retried into green. - **The controller** (the Captain) runs the crew: starts lanes on disjoint files, keeps the rules file, the digest and the notes, validates READYs, builds and merges trains, calls slots, relays asks to the owner in the three shapes, runs the flaky pass, and decides - everything that needs no access, credentials, spend over cap or a cited approval. + everything that needs no access, credentials, spend over cap or a cited approval. It + archives its own child threads, including ones its crew or its automations started + outside the crew, when its plan for each thread says that thread's work is done; it + knows each thread's next step, so nobody else archives them. - **A lane** (a crew thread) owns named files, builds on its own branch and machine, runs /ship, sends one READY block, stamps on call (merging the base into its pin only as part of that stamp commit, or when the controller names a real-code conflict), @@ -359,7 +366,9 @@ only the run the lane named. Every report ends with one Lesson. The controller reads lessons before chat, and each one becomes a rule in CREW.md, a line in AGENTS.md, or a tool fix filed as an issue; nothing stays a lesson for long. Rules carry the thread that produced them, so a new lane -can read the incident behind the rule. +can read the incident behind the rule. When the owner states a rule meant for every +Captain, forward it in the same turn to the thread that keeps the account's shared rules, +so every crew gets it. ## Setting it up on Capy diff --git a/docs/capy-install.md b/docs/capy-install.md index 7de24710e..96f98bc53 100644 --- a/docs/capy-install.md +++ b/docs/capy-install.md @@ -423,6 +423,11 @@ GSTACK_LAUNCH_FILE: .env `GSTACK_LAUNCH_FILE` names the same assignments as a sourceable script (mode 0600, beside the verdict under the state root; the PATH prepend is idempotent). +When the session starts from the Drive skill (a bootstrapper outside the +checkout), the file also exports `GSTACK_PREFERENCES_FILE`, the path of +`preferences.yaml` beside that skill's SKILL.md, which the `GSTACK_LAUNCH` line +leaves out; `gstack-models resolve --role agent` reads it +([your model preference](model-policy.md#your-model-preference)). The file is part of the ok verdict: a passing run that cannot write it exits 1 with `gstack: fail launch-file — could not write the launch file ; fix: make writable, or set GSTACK_STATE_ROOT to a writable directory diff --git a/docs/hosts.md b/docs/hosts.md index 8750cbbf1..1a18e0650 100644 --- a/docs/hosts.md +++ b/docs/hosts.md @@ -59,7 +59,7 @@ characters, the part of a description Hermes's skills index shows. | Harness | What gstack registers | Install | Verify | Remove | |---|---|---|---|---| -| OpenClaw | default `--skills hybrid`: `gstack-review`, `gstack-investigate`, `gstack-qa`, `gstack-ship` and `gstack-plan-eng-review` as top-level skills plus one `gstack` router skill whose body lists every other skill and its path, in `${OPENCLAW_STATE_DIR:-~/.openclaw}/skills`. With OpenClaw 2026.10.6's 19 eligible bundled skills, hybrid's skills section is 10,290 characters, full format, under its 18,000-character budget; `--skills all` (17,971, compact format, descriptions cut) lists every skill as its own folder, `--skills router` (6,701) only the router | `./setup --host openclaw`, or `./setup` inside OpenClaw | `openclaw skills list --json` shows `gstack` and the five task skills (source `openclaw-managed`, eligible) | `gstack-harness unregister openclaw` | +| OpenClaw | default `--skills hybrid`: `gstack-review`, `gstack-investigate`, `gstack-qa`, `gstack-ship` and `gstack-plan-eng-review` as top-level skills plus one `gstack` router skill whose body lists every other skill and its path, in `${OPENCLAW_STATE_DIR:-~/.openclaw}/skills`. With OpenClaw 2026.10.6's 19 eligible bundled skills, hybrid's skills section is 10,290 characters, full format, under its 18,000-character budget; `--skills all` (17,971, compact format, descriptions cut) lists every skill as its own folder, `--skills router` (6,701) only the router. OpenClaw serves a registered skill's `SKILL.md` whole or refuses it ("Skill instructions cannot be partially served"), at most 32,768 bytes when its model catalog has no context window for the model and 32,000 characters with a 100K-200K-token window, so any registered skill over 32,000 bytes (four of the five in hybrid, 26 of 63 in `all` today) is a short entry `SKILL.md` with the same name and description that sends the agent to its full `SKILL.md` in a plain copy of every skill (the router's `gstack/skills/`, or `.gstack-skills/` under `--skills all`, a folder OpenClaw's skill scan skips), which OpenClaw reads in pages. A context window under 100K tokens lowers that budget to 16,000 characters, which this limit does not cover | `./setup --host openclaw`, or `./setup` inside OpenClaw | `openclaw skills list --json` shows `gstack` and the five task skills (source `openclaw-managed`, eligible) | `gstack-harness unregister openclaw` | | Hermes | every skill, as `gstack-` in one `gstack` category, in `${HERMES_HOME:-~/.hermes}/skills/gstack`. Hermes's skills index lists only skills registered as folders, so a router would leave the rest out of the listing (`skill_view` still loads an unlisted skill by name, but nothing tells the model it exists) | `./setup --host hermes`, or `./setup` inside Hermes | `hermes skills list` shows the `gstack-*` skills in category `gstack` | `gstack-harness unregister hermes` | | dots (feder-cr) | the `gstack` router in `/home/dot/skills/gstack`, which the Dot lists every turn | `./setup` on the Dot's computer, or `gstack-harness register dots` | the Dot's Skills page, or `GET /skills` | `gstack-harness unregister dots` | | QM | **not available yet.** QM imports a skill pack by cloning the whole repository and caps it at 32 MiB and 5,000 files; gstack's repository is about 73 MiB, so `gstack-harness register qm --yes` stops before sending anything ([why](troubleshooting.md#harness-qm-pack-too-large)) | meanwhile, paste `gstack-harness register generic --print` into the agent's instructions | ask it for a gstack skill | delete the pasted text | diff --git a/docs/model-policy.md b/docs/model-policy.md index 2c40f0ba4..e3a6612c3 100644 --- a/docs/model-policy.md +++ b/docs/model-policy.md @@ -84,6 +84,7 @@ coverage, not a completed review. | `model_frontier_openai` | Catalog's OpenAI frontier model | Explicit OpenAI model identifier | | `model_smart_claude` | Catalog's Anthropic smart model | Explicit Anthropic model identifier | | `model_smart_openai` | Catalog's OpenAI smart model | Explicit OpenAI model identifier | +| `agent_model` | unset (`host`) | `host`, a family or `/`; see [your model preference](#your-model-preference) | For plan reviews, a model named for that invocation wins over `GSTACK_CLAUDE_MODEL` or `GSTACK_CODEX_MODEL`. That environment override wins over @@ -120,6 +121,55 @@ JSON output list these limits. Use an explicit compatible model for those cases, or `host` mode for plan reviews. Implementation recommendations require an explicit implementation model rather than delegating to a hidden native choice. +## Your model preference + +gstack takes no position for or against any model. If you prefer one, gstack +remembers it and uses it whenever it picks a model for a working agent: a lane, +a subagent or a reviewer. When it is unset, every agent keeps its own model. + +```bash +"$GSTACK_ROOT/bin/gstack-config" set agent_model opus +"$GSTACK_ROOT/bin/gstack-models" resolve --role agent +``` + +| Value | Meaning | +| --- | --- | +| `host` (the default) | keep the running agent's own model and leave the model field out | +| a family: `opus`, `fable`, `sonnet`, `haiku`, `astra`, `sol`, `luna`, `terra` | today's model in that family (`opus` is `anthropic/claude-opus-5-5`); it moves when gstack's family table moves | +| `/` | that exact model, for example `anthropic/claude-opus-5-5` | + +The first match wins: a request for one job (`resolve --role agent --request sol`, +never saved), then `agent_model` in this machine's config.yaml, then +`agent_model` in the file `GSTACK_PREFERENCES_FILE` names, then `host`. The +output names the winner and shows both stored values; `--json` prints the +provider, model, provider-qualified `id` and source. + +Two cases ignore the preference on purpose: a cross-family outside voice (the +`plan-review` role picks the other provider) and a model under test in an eval +(`eval-arms` and `/eval-plan` arms). + +A value gstack can't use stops only the agent role, naming the file and the fix. +`plan-review`, `implementation` and `eval-arms` keep resolving, and +`gstack-models list` shows the problem in its agent block. + +**On Capy.** Each cloud machine starts fresh, so config.yaml does not carry +over. Keep the preference in `preferences.yaml` next to the gstack skill's +SKILL.md in your Drive: one `agent_model: ` line, with `#` comments +allowed (any other key is ignored with a warning). The session start puts the +file's path in the launch file as `GSTACK_PREFERENCES_FILE` and gstack reads it +where it is: nothing is copied, and `--check` never writes the skill folder. Agents write that +file only when you ask them to remember a preference. To change or clear it for +good, edit or delete that line; `gstack-config unset agent_model` clears this +machine only. Copying a newer `capy/skill/gstack/` over the skill folder leaves +`preferences.yaml` alone, because the shipped folder has no such file. + +A Capy session with no gstack checkout (a Captain or coordinator that runs +`install.sh --check --for none`) can't run the resolver, so the skill tells it +to read the `agent_model` line itself. There a family means the newest model of +that family the host lists, while the resolver maps each family to the id in +its table (today they agree: `opus` is `anthropic/claude-opus-5-5`). Anything +it can't use means the session keeps its own model and says so. + ## Reasoning effort Codex calls carry a `model_reasoning_effort`. It follows the same shape as the @@ -213,6 +263,9 @@ an environment pin. See [selection recovery](troubleshooting.md#model-policy-sel `gstack-models` with no arguments is `list`. Both `list` and `resolve` accept `--json`. Plan-review resolution requires `--provider anthropic|openai`; implementation resolution can omit the provider to return both recommendations. +Agent resolution (`--role agent`) takes no `--provider`; its JSON carries an +`agent` object instead of `selections`, and `list --json` adds the same +top-level `agent` key. ```bash "$GSTACK_ROOT/bin/gstack-models" resolve --role plan-review --provider anthropic --json diff --git a/docs/pregate.md b/docs/pregate.md index fe047b2b1..797c2f2b6 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -135,6 +135,12 @@ writes `[]`, which would clear a file without a test. A file with no nearby test, and a lane whose matrix a planner computes, is printed as a `TODO`. Review each entry before committing the file on the base branch. +## `.gstack/always-run.json` + +`{ "tests": [globs] }` names whole-tree tests that scan every file of a kind (tripwires, ratchets, conformance and fixture inventories). A diff-selected run never maps a touched file to them, so they used to pass the selected gate and then fail CI (GST-98). When a branch selects any test, owes a lane, or edits this file, the `lanes` selection adds every tracked test file a glob matches. `--explain` prints each one as ` (always_run, .gstack/always-run.json)`, and a glob that matches no test as ` matches no test file`. A docs-only branch adds nothing. + +The list lives in its own file, not in `pregate.json`, because older pregate readers reject unknown keys there. A repository that ignores `.gstack/` (gstack does) tracks the file with `git add -f`, as it does `pregate.json`. A missing file means an empty list. A file that is not JSON, or whose `tests` is not a list of non-empty strings, stops the check with that message. gstack's own list matches 16 test files; the 13 with a seed in `scripts/free-test-durations.json` (recorded 2026-10-19) total about 13 s there, and `bun-entry-isolation`, `launch-env-echo-ratchet` and `review-packets-removed` have no seed yet. + ## `gstack-pregate` ``` diff --git a/docs/recommended-harness.md b/docs/recommended-harness.md index 687ccb0fe..c6893c918 100644 --- a/docs/recommended-harness.md +++ b/docs/recommended-harness.md @@ -257,7 +257,7 @@ line whose input could not be read says `unavailable ()` rather than 0. gstack-factory-scorecard --repo owner/name --since-days 7 \ [--crew login,login] [--gate-workflow evals.yml] [--free-workflow free-tests.yml] \ [--train-prefix train/] [--ready-marker 'READY #\d+|SHIP_RECEIPT:'] [--pass-marker '"passes": (\d+)|passes=(\d+)'] \ - [--pass-cap 3] [--out-dir .context/factory-scorecard] + [--pass-cap 3] [--out-dir .context/factory-scorecard] [--now ] ``` `--repo` defaults to the origin remote's owner/name on github.com (or @@ -265,6 +265,10 @@ gstack-factory-scorecard --repo owner/name --since-days 7 \ to `--out-dir` and appends the same rows to `trend.csv` there, so a weekly retro accumulates its own trend without renaming files. +The window is `[now - since-days, now)`: `--now` (default: the current time) +sets its end, and every line counts only items inside it, so a past week +measured later does not count anything that happened after that week. + | Line | How it is counted | What one of the best software factories would show | |---|---|---| | Merged PRs | `merged_at` in the window, base = default branch; crew by `--crew` logins, else author association OWNER/MEMBER/COLLABORATOR | several merges a day, every day; outside contributions land in the same week | @@ -284,29 +288,29 @@ the gap next to the number. ### Where gstack's own numbers stand -Measured on gstack's repository with this tool for 2026-10-19 to 2026-10-26 -(the first full week of trains and `/ship`-before-READY; 103 merged PRs, 77 -gate runs, artifacts read for the newest 60): +Measured on gstack's repository with this tool for the week of 2026-10-19 +through 2026-10-25, UTC (`--now 2026-10-26T00:00:00Z --since-days 7`; the first +full week of trains and `/ship`-before-READY; 83 merged PRs, 54 gate runs, +artifacts read for all 54): | Line | gstack, that week | Against the bar | |---|---|---| -| Merged PRs | 103 (crew 100, outside 3) | volume is there; outside contributions are few and are ported by the crew rather than merged from forks | -| Lead time, created → merged | median 7.9h, p90 23.7h | inside the bar on both | -| Lead time, READY → merged | no number: 0 of 103 PRs had a READY comment | the crew reports READY in-thread; step 6 above (post it on the PR) is what makes this measurable | -| Review passes per PR | no number: 0 of 103 PRs carried a pass count | the receipt's `passes` field lands with /ship's convergence rule; until then crews record the count in the READY block, and the marker reads it from a PR comment | -| Gate runs | 73 green / 4 red / 0 cancelled; known cost $1,628 on 59 of 77 runs | green rate 95%; cost unknown on 18 runs (screens that planned no cases, and runs older than the artifact cap) | -| Known gate cost per merged PR | $15.81 (a floor) | about $24 per train of up to 5 PRs, plus screens; one gate per PR would have been about $33 each | -| Red-train rate | 4% (1 red of 28 trains; 0 red backstop, 3 red screens) | inside the bar; the three red screens were one judge reading a moved file | -| Flaky events | 21 paid cases passed on their one rerun (rerun spend $10.72); 17 free-suite retries in 17 of 103 runs | above the bar on both: 0.27 flaky cases per gate against 0.1, and 17% of free runs retried against 1%. One family (`ship-docsync-*`) repeated three times in two days | -| Issues opened / closed | 50 / 56 | inside the bar | -| Releases cut | 1 (the first weekly release) | on schedule, with the starvation caveat above | +| Merged PRs | 83 (crew 80, outside 3) | volume is there; the 3 outside PRs are bot PRs (2 from github-actions, 1 from dependabot), and outside contributions are ported by the crew rather than merged from forks | +| Lead time, created to merged | median 6.5h, p90 20.0h | inside the bar on both | +| Lead time, READY to merged | no number: 0 of 83 PRs had a READY comment | the crew reports READY in-thread; step 6 above (post it on the PR) is what makes this measurable | +| Review passes per PR | no number: 0 of 83 PRs carried a pass count | the receipt's `passes` field lands with /ship's convergence rule; until then crews record the count in the READY block, and the marker reads it from a PR comment | +| Gate runs | 52 green / 2 red / 0 cancelled; known cost $1,361.94 on 52 of 54 runs | green rate 96%; cost unknown only on the 2 red runs (the red train and the red screen) | +| Known gate cost per merged PR | $16.41 (a floor) | trains averaged $32.48 each for up to 5 PRs (21 trains with a known cost), screens $21.18 each (29 with a known cost) | +| Red-train rate | 5% (1 red of 22 trains; 0 red backstop, 1 red screen) | the tool rounds 1 of 22 (4.5%) to 5%, so this is just under the 5% bar; the 1 red screen is counted separately from trains | +| Flaky events | 17 paid flake records across 11 cases, each passing its one rerun (rerun spend $7.20); 17 free-suite retries in 17 of 83 runs | above the bar on all three: 0.31 flake records per gate against 0.1, 4 cases that flaked more than once against 0, and 20% of free runs retried against 1%. One case (`ship-docsync-current`) did it four times in three days | +| Issues opened / closed | 28 / 51 | inside the bar | +| Releases cut | 0 | none inside the week: the first weekly release, v1.91.242.0, was published at 12:01 UTC on 2026-10-26, just after the window closed (see the starvation caveat above) | Read the gap honestly: lead time and throughput are where they should be, the gate is green and its cost is mostly known, and flakiness is the line that is not. The daily pass exists for exactly that line; the scorecard's job is to show next week whether it moved. The inventory lane's per-PR baseline CSV in -the Captain's Drive (142 merged PRs over the eleven days before this window -closed) holds the longer history behind these numbers; from here on +the Captain's Drive holds the longer history behind these numbers; from here on `trend.csv` in `--out-dir` carries the weeks. ## What is Capy-specific and what is not diff --git a/docs/ship-budget.md b/docs/ship-budget.md index d48b3dcdd..2180eb82b 100644 --- a/docs/ship-budget.md +++ b/docs/ship-budget.md @@ -50,7 +50,7 @@ With a cap set and no readable meter, start refuses ## The BUDGET line - BUDGET: used= reserved= unknown= admission_cap= tail= unit= meter= epoch= call= next= projected= calibration=step:,dispatch: verdict=continue|delegate|stop + BUDGET: used= reserved= unknown= admission_cap= tail= unit= meter= epoch= call= call_floor= above_floor= next= projected= calibration=step:,dispatch: verdict=continue|delegate|stop | Field | Meaning | |---|---| @@ -61,6 +61,7 @@ With a cap set and no readable meter, start refuses | `tail` | Tail reserve (scaled by the step factor once measured) plus stop overhead, held back from every admission | | `unit`, `meter`, `epoch` | The meter contract: `capy-tokens` (root counter), `claude-usage-tokens` (journal usage), `estimate-tokens`; a meter change opens a new epoch | | `call` | The per-call charge: two Capy readings in consecutive calls, or the last Claude Code call's usage | +| `call_floor`, `above_floor` | The host's per-call floor (see Delegation) and the charge above it, max(0, call − floor); the delegate rule compares `above_floor` | | `next`, `projected` | What the check was for and its projection: the prior times the run's factor for that kind | | `calibration` | The run's step and dispatch factors (see Calibration), or `prior` before the first measurement | | `verdict` | `continue` (exit 0, prints `RESERVATION:` under a cap), `delegate` (exit 0), `stop` (exit 3) | @@ -79,6 +80,11 @@ gstack-ship-budget check --run --step 9 --next dispatch:specialist,red-team gstack-review-log --start review --reservation ``` +Each reader is priced at max(its projection, the run's measured mean for that role over settled +dispatches). When the whole batch does not fit, `check` admits the largest prefix that does and +prints `ADMIT: k of n (remaining=)`: dispatch those k, then re-check the remaining roles +once they settle. Only a batch whose first reader does not fit stops, and `close` flags remaining +roles no later check requested (`DROPPED:`, status=incomplete until the owner accepts). Roles are review-depth's: `specialist`, `red-team`, `adversarial-native`, `outside-voice`, `fix-check`. `--finish ` is the pass's completion receipt: it settles that reservation and every other dispatch reservation started in the same step, so reviewers admitted one at a time @@ -92,27 +98,50 @@ BUDGET_CHECK_SKIPPED line and the run ends status=incomplete for the owner to ac ## Delegation -At start, a per-call charge of 100k or more delegates the whole /ship. Before Step 7 and before -Step 12, 150k or more (or an unknown charge) delegates the next segment (7–11.5, 12–22): +The delegate rule measures context above the host's per-call floor: what one call costs in a +fresh session with the ship skill loaded, before any /ship work. The floor is `call_floor` per +unit in `lib/ship-budget-calibration.json` (150k on Capy, where a fresh thread's calls cost +150–230k; 40k on Claude Code; 0 for the estimate meter, whose charge is always unknown), or +`start --call-floor `, which the ledger records and every child inherits. At start, 100k or +more above the floor delegates the whole /ship. Before Step 7 and before Step 12, 150k or more +above the floor (or an unknown charge) delegates the next segment (7–11.5, 12–22). The BUDGET +line prints `call`, `call_floor` and `above_floor`, so each verdict can be checked: 1. `gstack-ship-budget handoff --run --from 12 --to 22 --base origin/ --reading ` writes a brief (mode 0600, under the run directory) with the pins (HEAD, merge base, working tree, reviewed tree), the record with its six headings and start tokens, the decisions in scope with the owner's words, the decision trail, the budget and the return contract. It reserves the child's sub-cap: min(cap − used − reserved − unknown − the calibrated tail reserve and stop - overhead, the segment estimate), or `--sub-cap`. + overhead, the segment estimate), or `--sub-cap`. A child handing off reserves its whole + remaining sub-cap instead (cap − used − reserved − unknown − its stop overhead). 2. The child is a Capy subagent on the shared machine, or on Claude Code `gstack-ship-budget launch --in `: a background `claude -p` (resolved through lib/claude-bin.ts) with explicit permission flags and the return file named. 3. The child runs `verify-handoff` first; a moved pin refuses ([HANDOFF_PIN_MOVED](troubleshooting.md#handoff-pin-moved)). It admits against its sub-cap - with child_used = reading − reading at handoff, is the only writer of the record, and never - delegates again: a delegate rule stops it. It ends with `return` (at most 40 lines). + with child_used = reading − reading at handoff and is the only writer of the record. The + context rule never stops a child. When it fires at one of the child's boundaries: + - once the child has completed a step since `verify-handoff`, `verdict=delegate` hands its + remaining steps (the next step through the end of its segment) to a fresh grandchild + through these same four steps. The chain depth (1 for a child, 2 for a grandchild, at most + 3) is recorded in the brief, the handoff and the new ledger; + - before its first completed step, at depth 3, or when the next step is past its segment, + `verdict=continue` with one `CONTEXT_HIGH:` line (call, floor, threshold, depth, reason). + + The admission cap still stops a child (`reason=budget_exhausted`). It reconciles any + grandchild, then ends with `return` (at most 40 lines). `return` reports `status=incomplete` + while one of the child's own handoffs is unreconciled (`UNRECONCILED:`) or an ADMIT remainder + was never re-checked (`DROPPED:`), so a delegated segment cannot drop readers or leave a + grandchild writing after the parent moves on. 4. `gstack-ship-budget reconcile --run --return --reading `; idempotent per handoff hash, so a duplicate return charges nothing twice. A refused, incomplete or interrupted child - exits 3: regenerate the handoff or stop. + exits 3: regenerate the handoff or stop. Up a chain each token is charged once: the Capy + counter already includes the whole subtree, a session `launch` starts joins the Claude Code + meter of every ancestor, and under `meter=estimate` a child's returned `used` carries its + grandchild's. -`--delegate never` turns delegate into stop for hosts without children. +`--delegate never` turns delegate into stop for a parent on a host without children; a child +continues with a `CONTEXT_HIGH:` note instead. ## Stop and resume @@ -144,7 +173,9 @@ largest observation times 1.5, rounded up to 10,000 tokens. The leave-one-out te (test/ship-budget-calibration.test.ts) covers the per-call constant only, the one observed across three runs; per-dispatch has two observations and stop overhead one. Only the Capy meter has observations; the Claude Code and estimate units are seeded from it until a -Claude-metered /ship completes. +Claude-metered /ship completes. Each unit's `call_floor` is calibrated separately, with its +evidence in `call_floor_why`; a floor is the largest 10,000 multiple at or below its smallest +observed fresh call. These constants are the prior. Each settled step or dispatch is measured on the next fresh reading (a step from its check's reading to the next one; dispatches settled together as one diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 56407b281..24047fa6b 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -414,9 +414,12 @@ gstack-ship-queue ready --pr 42 --screen [--broad] # dispatches one paid screen ``` Without a choice it prints the `SCREEN:` selection (with -`screen_selection: gstack-paid`, the skill's own cases and `recommend ---screen` or `--no-screen`: `--screen` when the screen selects a skill's own -cases) and refuses with `READY_SCREEN_CHOICE_REQUIRED`. The price line says +`screen_selection: gstack-paid`, the skill's own cases, the selector's mode and +`recommend --screen` or `--no-screen`: `--screen` only when the selector's +mode is `full-fallback`, an unknown dependency or a shared runtime input that +restores every case, or when it cannot select at all; every other PR rides +unscreened, and the train gate's full census runs its cases on the tree that +merges) and refuses with `READY_SCREEN_CHOICE_REQUIRED`. The price line says the dollar figure is pending the cost replay; until it lands, `SCREEN:` shows case counts and the recommendation only. A lane that already ran focused local cases says so in the READY block and may pass `--no-screen`. `--screen` @@ -466,8 +469,8 @@ gstack-ship-queue train build --prs 41@,42@,43@ ``` It prints `ORDER:` (the slot order to announce: screened PRs first in READY -order, then unscreened; when `max_size` or more unscreened PRs wait they form -their own train and `HELD:` lists them; `--order ,,...` naming every +order, then unscreened PRs fill the remaining seats, and `HELD:` lists the PRs +over `max_size`; `--order ,,...` naming every pinned PR once replaces that rule, for a PR that must merge before another, and the train's later `NEXT:` rebuilds keep it), one `PREFIX: T tree=` per prefix and one `SLOT:` per PR. `--dry-run` adds the diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index ccb23ea08..c9ad80aec 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -37,7 +37,7 @@ no paid call: the Codex rows report the cached model check and its age, and ## Model policy -The [model-policy guide](model-policy.md) explains the two tiers, all six +The [model-policy guide](model-policy.md) explains the two tiers, all seven settings, precedence and the read-only inspection command. A resolved selection is not proof that the requested provider or model can run. @@ -1832,9 +1832,10 @@ GSTACK_RESULT lines. ### `gstack-ship-budget: ... (BUDGET_CAP_REACHED)` -**Meaning.** The next step or dispatch batch would push used + open reservations + unknown -charges + its projection + the tail and stop reserve past the admission cap, so it did not -start. Exit 3. `gstack-review-log --start` prints the same code (exit 1) for a dispatch +**Meaning.** The next step, or the first reader of a dispatch batch, would push used + open +reservations + unknown charges + its projection + the tail and stop reserve past the admission +cap, so it did not start. Exit 3. A batch that partly fits is not refused: `check` admits the +largest prefix that fits and prints `ADMIT: k of n (remaining=)`. `gstack-review-log --start` prints the same code (exit 1) for a dispatch after the run stopped. The cap admits work; it cannot stop a turn already running, so the overshoot is bounded by the misprojection of work admitted before the stop. diff --git a/docs/uninstall.md b/docs/uninstall.md index efadb2984..a0309dd7a 100644 --- a/docs/uninstall.md +++ b/docs/uninstall.md @@ -49,7 +49,7 @@ rm -rf ~/.gstack rm -rf "${CODEX_HOME:-$HOME/.codex}/skills/gstack"* 2>/dev/null rm -rf ~/.factory/skills/gstack* 2>/dev/null rm -rf ~/.kiro/skills/gstack* 2>/dev/null -rm -rf "${OPENCLAW_STATE_DIR:-$HOME/.openclaw}/skills/gstack"* 2>/dev/null +rm -rf "${OPENCLAW_STATE_DIR:-$HOME/.openclaw}/skills/gstack"* "${OPENCLAW_STATE_DIR:-$HOME/.openclaw}/skills/.gstack-skills" 2>/dev/null 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 diff --git a/lib/agent-model.ts b/lib/agent-model.ts new file mode 100644 index 000000000..5f2526c98 --- /dev/null +++ b/lib/agent-model.ts @@ -0,0 +1,170 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { readConfigKeyWithRoot, type StateRootEnv } from './state-root'; + +/** + * The agent role: the model a user wants gstack's working agents (lanes, + * subagents, reviewers) to run on. It is a preference the user sets, never a + * policy gstack ships, and it is read apart from the strict model policy so a + * bad value here never stops plan-review, implementation or eval-arms picks. + * Precedence: a one-job request, then agent_model in config.yaml (this + * machine), then agent_model in the file GSTACK_PREFERENCES_FILE names (the + * Capy skill folder's preferences.yaml, kept across machines), then host. + */ + +export const AGENT_MODEL_KEY = 'agent_model' as const; +export const PREFERENCES_FILE_MAX_BYTES = 4096; +const DOCS = 'https://github.com/garrytan/gstack/blob/main/docs/model-policy.md#your-model-preference'; + +/** Family names a preference may use. When gstack's tier catalog lists a family, its entry here is the same model (test/agent-model.test.ts). */ +export const MODEL_FAMILIES = Object.freeze([ + { family: 'opus', provider: 'anthropic', model: 'claude-opus-5-5' }, + { family: 'fable', provider: 'anthropic', model: 'claude-fable-5-1' }, + { family: 'sonnet', provider: 'anthropic', model: 'claude-sonnet-5-5' }, + { family: 'haiku', provider: 'anthropic', model: 'claude-haiku-5-5' }, + { family: 'astra', provider: 'openai', model: 'gpt-6-astra' }, + { family: 'sol', provider: 'openai', model: 'gpt-6.1-sol' }, + { family: 'luna', provider: 'openai', model: 'gpt-6-luna' }, + { family: 'terra', provider: 'openai', model: 'gpt-6.1-terra' }, +].map(f => Object.freeze(f))); + +export const AGENT_MODEL_EXEMPTIONS = Object.freeze([ + 'a cross-family outside voice (gstack-models resolve --role plan-review)', + 'a model under test in an eval (gstack-models resolve --role eval-arms, /eval-plan arms)', +]); + +const PROVIDER_PATTERN = /^[a-z][a-z0-9-]{0,31}$/; +const MODEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@+-]{0,199}$/; + +export type AgentModelSourceKind = 'request' | 'config' | 'preferences' | 'default'; + +export interface AgentModelSelection { + role: 'agent'; + status: 'selected' | 'host'; + provider: string | null; + model: string | null; + /** Provider-qualified id, the form a Capy model field takes; null in host mode (omit the model field). */ + id: string | null; + family: string | null; + value: string; + source: { kind: AgentModelSourceKind; path: string | null }; + sources: { config: string | null; configPath: string | null; preferences: string | null; preferencesPath: string | null }; + warnings: string[]; + exemptions: readonly string[]; +} + +export class AgentModelError extends Error { + constructor(readonly problem: string, readonly cause: string, readonly repair: string[]) { + super(problem); + } + toJSON() { + return { reason: 'invalid_agent_model', problem: this.problem, cause: this.cause, key: AGENT_MODEL_KEY, repair: this.repair, docs: DOCS }; + } +} + +function printable(value: string): string { + return value.replace(/[\x00-\x1f\x7f-\x9f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, ' '); +} + +function sanitize(value: string): string { + return printable(value).slice(0, 200); +} + +type Parsed = { kind: 'host' } | { kind: 'model'; provider: string; model: string; family: string | null }; + +/** host | a family name | /. Syntax only; bin/gstack-config holds the same grammar. */ +export function parseAgentModel(raw: string): Parsed | null { + const value = raw.trim(); + if (value.toLowerCase() === 'host') return { kind: 'host' }; + const family = MODEL_FAMILIES.find(f => f.family === value.toLowerCase()); + if (family) return { kind: 'model', provider: family.provider, model: family.model, family: family.family }; + const slash = value.indexOf('/'); + if (slash < 0) return null; + const provider = value.slice(0, slash); + const model = value.slice(slash + 1); + if (!PROVIDER_PATTERN.test(provider) || !MODEL_PATTERN.test(model)) return null; + return { kind: 'model', provider, model, family: null }; +} + +function invalid(value: string, where: string, repair: string[]): AgentModelError { + return new AgentModelError( + `agent_model '${sanitize(value)}' in ${where} is not a model gstack can use`, + `use host, a family (${MODEL_FAMILIES.map(f => f.family).join(', ')}) or a provider-qualified id such as anthropic/claude-opus-5-5`, + repair, + ); +} + +/** Read agent_model from a preferences file: `key: value` lines, # comments, at most 4 KB. Unknown keys warn; a duplicate agent_model is an error. */ +export function readPreferencesFile(file: string): { value: string | null; warnings: string[] } { + let text: string; + try { + const stat = fs.statSync(file); + if (!stat.isFile()) return { value: null, warnings: [`${file} is not a file; ignored`] }; + const bytes = stat.size > PREFERENCES_FILE_MAX_BYTES ? null : fs.readFileSync(file); + if (!bytes || bytes.length > PREFERENCES_FILE_MAX_BYTES) return { value: null, warnings: [`${file} is over ${PREFERENCES_FILE_MAX_BYTES} bytes; ignored`] }; + text = bytes.toString('utf-8').replace(/^\uFEFF/, ''); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + return { value: null, warnings: code === 'ENOENT' ? [] : [`${file} is unreadable (${code ?? 'unknown error'}); ignored`] }; + } + const warnings: string[] = []; + let value: string | null = null; + text.split(/\r?\n/).forEach((line, i) => { + const trimmed = line.trim(); + if (trimmed === '' || trimmed.startsWith('#')) return; + const m = /^([a-z][a-z0-9_]*):(.*)$/.exec(line); + if (!m) { warnings.push(`${file}:${i + 1} is not a 'key: value' line; ignored`); return; } + if (m[1] !== AGENT_MODEL_KEY) { warnings.push(`${file}:${i + 1} sets '${sanitize(m[1])}', which gstack does not read from preferences; ignored`); return; } + if (value !== null) throw new AgentModelError(`${file} sets agent_model twice`, 'keep one agent_model line', [`edit ${file}`]); + value = m[2].replace(/\s#.*$/, '').trim().replace(/^(["'])(.*)\1$/, '$2'); + }); + return { value: value === '' ? null : value, warnings }; +} + +export function resolveAgentModel(opts: { env?: StateRootEnv; platform?: NodeJS.Platform; request?: string } = {}): AgentModelSelection { + const env = opts.env ?? process.env; + const reading = readConfigKeyWithRoot('agent_model', env, opts.platform ?? process.platform); + const configPath = reading.root ? path.join(reading.root, 'config.yaml') : null; + const preferencesPath = env.GSTACK_PREFERENCES_FILE?.trim() || null; + let prefs: { value: string | null; warnings: string[] } = { value: null, warnings: [] }; + try { + if (preferencesPath) prefs = readPreferencesFile(preferencesPath); + } catch (error) { + if (!(error instanceof AgentModelError) || (opts.request === undefined && reading.value === null)) throw error; + prefs = { value: null, warnings: [`${error.problem}; ignored because a higher-priority source is set`] }; + } + const candidates: Array<{ kind: AgentModelSourceKind; value: string | null; path: string | null; repair: string[] }> = [ + { kind: 'request', value: opts.request ?? null, path: null, repair: ['pass --request host, a family or /'] }, + { kind: 'config', value: reading.value, path: configPath, repair: ['gstack-config set agent_model ', 'gstack-config unset agent_model'] }, + { kind: 'preferences', value: prefs.value, path: preferencesPath, repair: [`edit agent_model in ${preferencesPath}`] }, + ]; + const winner = candidates.find(c => c.value !== null); + const base = { + role: 'agent' as const, + sources: { config: reading.value, configPath, preferences: prefs.value, preferencesPath }, + warnings: prefs.warnings, + exemptions: AGENT_MODEL_EXEMPTIONS, + }; + if (!winner) { + return { ...base, status: 'host', provider: null, model: null, id: null, family: null, value: 'host', source: { kind: 'default', path: null } }; + } + const parsed = parseAgentModel(winner.value!); + if (!parsed) throw invalid(winner.value!, winner.kind === 'request' ? 'this request' : winner.path ?? winner.kind, winner.repair); + const source = { kind: winner.kind, path: winner.path }; + if (parsed.kind === 'host') return { ...base, status: 'host', provider: null, model: null, id: null, family: null, value: 'host', source }; + return { ...base, status: 'selected', provider: parsed.provider, model: parsed.model, id: `${parsed.provider}/${parsed.model}`, family: parsed.family, value: winner.value!.trim(), source }; +} + +export function renderAgentModel(s: AgentModelSelection): string[] { + const from = s.source.kind === 'default' ? 'no preference set' + : s.source.kind === 'request' ? 'requested for this job, not saved' + : `from agent_model in ${s.source.path}, ${s.source.kind === 'config' ? 'this machine' : 'the preferences file'}`; + const lines = [s.status === 'host' + ? `agent: host - keep the running agent's own model and omit the model field (${from})` + : `agent: ${s.id}${s.family ? ` (${s.family})` : ''} - ${from}`].map(printable); + lines.push(printable(` config.yaml agent_model: ${s.sources.config ?? 'unset'}; preferences agent_model: ${s.sources.preferences ?? 'unset'}${s.sources.preferencesPath ? ` (${s.sources.preferencesPath})` : ' (GSTACK_PREFERENCES_FILE unset)'}`)); + lines.push(` exempt: ${s.exemptions.join('; ')}`); + lines.push(' change it on this machine: gstack-config set agent_model ; keep it across Capy machines: agent_model in the skill folder\'s preferences.yaml'); + for (const w of s.warnings) lines.push(` warning: ${sanitize(w)}`); + return lines; +} diff --git a/lib/claude-code-migration.ts b/lib/claude-code-migration.ts index 3604dbe69..3dd45f229 100644 --- a/lib/claude-code-migration.ts +++ b/lib/claude-code-migration.ts @@ -12,7 +12,7 @@ export const CLAUDE_CODE_RUNTIME_FILES = [ 'bin/gstack-claude-code', 'bin/gstack-models', 'bin/gstack-config', 'bin/gstack-state-root.sh', 'lib/claude-code.ts', 'lib/claude-code-windows-job.ts', 'lib/claude-bin.ts', 'lib/outside-review-result.ts', 'lib/gate-outcomes.ts', 'lib/outside-consent.ts', 'lib/state-root.ts', - 'lib/model-catalog.ts', 'lib/model-policy.ts', 'lib/model-policy-cli.ts', 'lib/model-policy-notice.ts', + 'lib/model-catalog.ts', 'lib/model-policy.ts', 'lib/model-policy-cli.ts', 'lib/model-policy-notice.ts', 'lib/agent-model.ts', ]; export interface RenameOptions { diff --git a/lib/factory-scorecard.ts b/lib/factory-scorecard.ts index afdc6504e..deb64a92d 100644 --- a/lib/factory-scorecard.ts +++ b/lib/factory-scorecard.ts @@ -5,6 +5,13 @@ * counting rule next to the number, and a line whose input cannot be read says * so ("unavailable: ") instead of printing 0. * + * The window is [now - since-days, now): every input below counts an item only + * when its timestamp falls inside it (inWindow), so a past window does not + * count anything that happened after it. Attributes are read as they are now + * (a run's latest conclusion, an issue's latest closed_at, artifacts that have + * not expired). A merged-PR, gate-run or release read that runs past MAX_PAGES + * marks its line unavailable instead of printing a partial count. + * * Inputs, all REST reads (no write, no paid work): * merged PRs pulls?state=closed&base=, merged_at inside the window; * crew = --crew logins, else author_association OWNER/MEMBER/COLLABORATOR @@ -29,7 +36,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { - GH_CLIENT, downloadRunArtifacts, filesUnder, historyFetcher, listWorkflowRuns, readFlakeLedgerDir, unavailableReason, + GH_CLIENT, createdFilter, downloadRunArtifacts, filesUnder, historyFetcher, listWorkflowRuns, readFlakeLedgerDir, unavailableReason, type GhClient, } from '../scripts/lib/ci-history'; @@ -68,6 +75,7 @@ export const DEFAULTS = { export const CREW_ASSOCIATIONS = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); const PAGE = 100; const MAX_PAGES = 20; +const pageCap = (what: string) => new Error(`${what} ran past ${MAX_PAGES} pages of ${PAGE}; a partial count is not reported`); const COST_LINE = /\*\*\$(\d+(?:\.\d+)?)\*\* rule\/judge cost|\bcost=\$(\d+(?:\.\d+)?)/g; export interface MergedPr { @@ -92,6 +100,24 @@ export interface Scorecard { } export interface ScorecardRow { id: string; metric: string; value: string; benchmark: string; rule: string } +/** The scorecard's one window, [start, end) as ISO instants; every windowed read filters through inWindow. */ +export interface ScoreWindow { start: string; end: string } + +export function scoreWindow(o: Pick): ScoreWindow { + return { start: new Date(o.now - o.sinceDays * 86_400_000).toISOString(), end: new Date(o.now).toISOString() }; +} + +export function inWindow(at: unknown, w: ScoreWindow): boolean { + const t = typeof at === 'string' ? Date.parse(at) : NaN; + return t >= Date.parse(w.start) && t < Date.parse(w.end); +} + +/** GitHub search's inclusive, whole-second range for the window: start rounded up, end one second before the next whole second at or after it. */ +export function searchRange(w: ScoreWindow): string { + const second = (ms: number) => new Date(Math.ceil(ms / 1000) * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z'); + return `${second(Date.parse(w.start))}..${second(Math.ceil(Date.parse(w.end) / 1000) * 1000 - 1000)}`; +} + // --- Sources --- export function ghSource(repo: string, cacheDir: string, client: GhClient = GH_CLIENT): ScorecardSource { @@ -140,14 +166,15 @@ function pullsPage(source: ScorecardSource, o: ScorecardOptions, page: number): } /** Every PR merged into base inside the window; pages stop once a page's oldest update predates the window. */ -export function readMergedPrs(source: ScorecardSource, o: ScorecardOptions, since: string): MergedPr[] { +export function readMergedPrs(source: ScorecardSource, o: ScorecardOptions, w: ScoreWindow): MergedPr[] { const out: MergedPr[] = []; const crewLogins = o.crew ? new Set(o.crew.map(l => l.toLowerCase())) : null; - for (let page = 1; page <= MAX_PAGES; page++) { + for (let page = 1; ; page++) { + if (page > MAX_PAGES) throw pageCap(`PRs updated since ${w.start}`); const pulls = pullsPage(source, o, page); for (const pr of pulls) { const mergedAt = typeof pr.merged_at === 'string' ? pr.merged_at : null; - if (!mergedAt || mergedAt < since) continue; + if (!mergedAt || !inWindow(mergedAt, w)) continue; const author = String(pr.user?.login ?? ''); const association = String(pr.author_association ?? 'NONE'); const comments = readComments(source, o.repo, Number(pr.number), { readyMarker: o.readyMarker, passMarker: o.passMarker, mergedAt }); @@ -158,7 +185,7 @@ export function readMergedPrs(source: ScorecardSource, o: ScorecardOptions, sinc passes: bodyPasses === null ? comments.passes : Math.max(bodyPasses, comments.passes ?? 0) }); } const oldest = pulls[pulls.length - 1]?.updated_at; - if (pulls.length < PAGE || (typeof oldest === 'string' && oldest < since)) break; + if (pulls.length < PAGE || (typeof oldest === 'string' && oldest < w.start)) break; } return out.sort((a, b) => a.mergedAt.localeCompare(b.mergedAt)); } @@ -240,15 +267,16 @@ export function flakeRecords(dirs: string[]): { flakes: number; rerunUsd: number } /** Gate runs in the window with kind, outcome, cost and flake records; artifacts are read for the newest maxArtifactRuns only. */ -export function readGateRuns(source: ScorecardSource, o: ScorecardOptions, since: string, prCost: Map): { gates: GateRun[]; artifactRunsRead: number; artifactErrors: number } { - const query = [`status=completed`, `per_page=${PAGE}`, `created=${encodeURIComponent(`>=${since.slice(0, 10)}`)}`, +export function readGateRuns(source: ScorecardSource, o: ScorecardOptions, w: ScoreWindow, prCost: Map): { gates: GateRun[]; artifactRunsRead: number; artifactErrors: number } { + const query = [`status=completed`, `per_page=${PAGE}`, createdFilter(w.start, w.end), ...(o.gateEvent ? [`event=${encodeURIComponent(o.gateEvent)}`] : [])].join('&'); const gates: GateRun[] = []; - for (let page = 1; page <= MAX_PAGES; page++) { + for (let page = 1; ; page++) { + if (page > MAX_PAGES) throw pageCap('gate runs in the window'); const body = source.getJson(`repos/${o.repo}/actions/workflows/${o.gateWorkflow}/runs?${query}&page=${page}`) as { workflow_runs?: any[] }; const runs = Array.isArray(body?.workflow_runs) ? body.workflow_runs : []; for (const run of runs) { - if (String(run.created_at) < since || (o.gateEvent && run.event !== o.gateEvent)) continue; + if (!inWindow(run.created_at, w) || (o.gateEvent && run.event !== o.gateEvent)) continue; const title = String(run.display_title ?? run.name ?? ''); const branch = String(run.head_branch ?? ''); const pr = Number(/#(\d+)/.exec(title)?.[1] ?? run.pull_requests?.[0]?.number ?? NaN); @@ -281,9 +309,9 @@ export function readGateRuns(source: ScorecardSource, o: ScorecardOptions, since } /** Free-suite runs on base in the window and the flaky-pass entries their flake-ledger-* artifacts hold. */ -export function readFreeRetries(source: ScorecardSource, o: ScorecardOptions, since: string): FreeRetryRun[] { +export function readFreeRetries(source: ScorecardSource, o: ScorecardOptions, w: ScoreWindow): FreeRetryRun[] { const client: GhClient = { getJson: p => source.getJson(p), downloadZip: () => { throw new Error('not used'); } }; - const runs = listWorkflowRuns(client, o.repo, o.freeWorkflow, { since, branch: o.base }); + const runs = listWorkflowRuns(client, o.repo, o.freeWorkflow, { since: w.start, until: w.end, branch: o.base }); const retries = (runId: number): number | null => { try { return [...source.artifactDirs(runId, name => name.startsWith('flake-ledger-')).values()].flatMap(readFlakeLedgerDir).length; } catch { return null; } }; @@ -295,30 +323,36 @@ function searchCount(source: ScorecardSource, q: string): number | null { return typeof body?.total_count === 'number' ? body.total_count : null; } -export function readReleases(source: ScorecardSource, repo: string, since: string): Array<{ tag: string; publishedAt: string }> { - const body = source.getJson(`repos/${repo}/releases?per_page=${PAGE}&page=1`); - return (Array.isArray(body) ? body : []).filter(r => !r.draft && typeof r.published_at === 'string' && r.published_at >= since) - .map(r => ({ tag: String(r.tag_name ?? ''), publishedAt: String(r.published_at) })); +/** Releases published inside the window, drafts excluded; every page is read, because GitHub orders releases by creation and a draft can be published long after it was created. */ +export function readReleases(source: ScorecardSource, repo: string, w: ScoreWindow): Array<{ tag: string; publishedAt: string }> { + const out: Array<{ tag: string; publishedAt: string }> = []; + for (let page = 1; ; page++) { + if (page > MAX_PAGES) throw pageCap(`releases published since ${w.start}`); + const body = source.getJson(`repos/${repo}/releases?per_page=${PAGE}&page=${page}`); + const releases = Array.isArray(body) ? body : []; + out.push(...releases.filter(r => !r.draft && inWindow(r.published_at, w)).map(r => ({ tag: String(r.tag_name ?? ''), publishedAt: String(r.published_at) }))); + if (releases.length < PAGE) break; + } + return out; } /** Every read, each guarded: one unreadable input marks its lines unavailable and the rest still print. */ export function collectScorecard(source: ScorecardSource, o: ScorecardOptions): Scorecard { - const since = new Date(o.now - o.sinceDays * 86_400_000).toISOString(); - const card: Scorecard = { repo: o.repo, base: o.base, windowStart: since, windowEnd: new Date(o.now).toISOString(), + const w = scoreWindow(o); + const card: Scorecard = { repo: o.repo, base: o.base, windowStart: w.start, windowEnd: w.end, prs: [], gates: [], freeRuns: [], artifactRunsRead: 0, passCap: o.passCap, artifactErrors: 0, issuesOpened: null, issuesClosed: null, releases: [], unavailable: [] }; const guard = (what: string, fn: () => void) => { try { fn(); } catch (e) { card.unavailable.push(`${what}: ${unavailableReason(e)}`); } }; - guard('merged PRs', () => { card.prs = readMergedPrs(source, o, since); }); + guard('merged PRs', () => { card.prs = readMergedPrs(source, o, w); }); guard('gate runs', () => { const prCost = new Map(card.prs.filter(p => p.commentCostUsd !== null).map(p => [p.number, p.commentCostUsd!] as const)); - Object.assign(card, readGateRuns(source, o, since, prCost)); + Object.assign(card, readGateRuns(source, o, w, prCost)); }); - guard('free retries', () => { card.freeRuns = readFreeRetries(source, o, since); }); - const instant = since.replace(/\.\d{3}Z$/, 'Z'); + guard('free retries', () => { card.freeRuns = readFreeRetries(source, o, w); }); guard('issues', () => { - card.issuesOpened = searchCount(source, `repo:${o.repo} is:issue created:>=${instant}`); - card.issuesClosed = searchCount(source, `repo:${o.repo} is:issue closed:>=${instant}`); + card.issuesOpened = searchCount(source, `repo:${o.repo} is:issue created:${searchRange(w)}`); + card.issuesClosed = searchCount(source, `repo:${o.repo} is:issue closed:${searchRange(w)}`); }); - guard('releases', () => { card.releases = readReleases(source, o.repo, since); }); + guard('releases', () => { card.releases = readReleases(source, o.repo, w); }); return card; } diff --git a/lib/gate-cost-history.ts b/lib/gate-cost-history.ts index f6f4b668e..b379de9f3 100644 --- a/lib/gate-cost-history.ts +++ b/lib/gate-cost-history.ts @@ -317,22 +317,16 @@ export interface ArrivalTrainResult { interface Waiting { pr: number; readyMs: number; enqueuedMs: number; screened: boolean; defect: boolean } /** - * The next train, or null to keep waiting: five or more waiting screened (or - * unscreened) PRs dispatch a full train of their own; otherwise a full mixed - * train, screened first; otherwise everything waiting, screened first, once - * the oldest has waited the fill limit. The train with the oldest PR goes first. + * The next train, or null to keep waiting, as orderSlots and fillDue seat it: + * screened PRs first, then unscreened PRs fill the remaining seats, each by + * age; a full train dispatches at once, a smaller one once its oldest PR has + * waited the fill limit. */ export function pickTrain(pool: T[], nowMs: number, size: number, fillMs: number): T[] | null { const byAge = (a: T, b: T) => a.enqueuedMs - b.enqueuedMs; - const screened = pool.filter(x => x.screened).sort(byAge), unscreened = pool.filter(x => !x.screened).sort(byAge); - const options = [screened, unscreened].filter(list => list.length >= size).map(list => list.slice(0, size)); - if (!options.length) { - const mixed = [...screened, ...unscreened]; - if (mixed.length >= size) options.push(mixed.slice(0, size)); - else if (mixed.length && nowMs - Math.min(...mixed.map(x => x.enqueuedMs)) >= fillMs) options.push(mixed); - } - const oldest = (train: T[]) => Math.min(...train.map(x => x.enqueuedMs)); - return options.sort((a, b) => oldest(a) - oldest(b))[0] ?? null; + const mixed = [...pool.filter(x => x.screened).sort(byAge), ...pool.filter(x => !x.screened).sort(byAge)]; + if (mixed.length >= size) return mixed.slice(0, size); + return mixed.length && nowMs - Math.min(...mixed.map(x => x.enqueuedMs)) >= fillMs ? mixed : null; } /** diff --git a/lib/harness-register.ts b/lib/harness-register.ts index bfc84a047..8ac054d77 100644 --- a/lib/harness-register.ts +++ b/lib/harness-register.ts @@ -37,6 +37,19 @@ export const DOT_SKILLS_DIR = '/home/dot/skills'; export const ROUTER_DESCRIPTION = 'Use to review a diff or PR, debug a bug or failing test, QA test a site, plan or review architecture, ship a PR, audit security or write release docs. Load first for any coding task: lists every gstack skill and where to read it.'; /** OpenClaw hybrid mode: these task skills are listed as skills of their own; the router carries the rest. */ export const HYBRID_SKILLS = ['review', 'investigate', 'qa', 'ship', 'plan-eng-review']; +/** + * OpenClaw serves a registered skill's SKILL.md whole or refuses it ("Skill instructions cannot be partially + * served"), with maxBytes from resolveAdaptiveReadMaxBytes: 32,768 (DEFAULT_READ_PAGE_MAX_BYTES) when the model's + * context window is unknown (OpenClaw 2026.10.6 dist/agent-tools.read-BIxUGJy-.mjs:168, :189-194, :747, :798, + * :819-821). A known window also applies the tool-result budget to that read (resolveToolResultBudget, + * dist/tool-result-limits-DNBSu_jg.mjs:61-95): 16,000 characters under 100K tokens, 32,000 to 200K, 64,000 above. + * 32,000 bytes fits the unknown-window limit and a 100K-200K window; windows under 100K tokens are not covered. The + * post-delivery cap defaults to a 200K window (64,000 characters). A larger skill is registered as a short + * entry that points at its full SKILL.md in a plain tree of every skill, which OpenClaw reads in pages. + */ +export const OPENCLAW_INSTRUCTION_MAX = 32_000; +/** The plain tree under --skills all: a dot folder, which OpenClaw's skill scan skips (dist/skill-root-discovery-C2QVZC77.mjs:44). Hybrid and router use the router's own gstack/skills, which the scan never enters because gstack/ holds a SKILL.md (:343). */ +export const OPENCLAW_ALL_TEXTS = '.gstack-skills'; export interface Target { name: TargetName; @@ -46,6 +59,8 @@ export interface Target { render: 'host' | 'claude' | null; defaultMode: SkillsMode; modes: SkillsMode[]; + /** Largest SKILL.md the harness serves whole; a larger skill is registered as an entry pointing at its full text. */ + instructionMax?: number; /** Destination folder for this target, from its environment, or null when it has no default. */ dest: (env: Env, home: string) => string | null; /** Running inside this harness: a session marker or the harness among this process's ancestors. */ @@ -65,7 +80,7 @@ const ancestorIs = (ancestry: string[], name: string) => ancestry.some(id => { export const TARGETS: Record = { openclaw: { - name: 'openclaw', displayName: 'OpenClaw', mechanism: 'skill-folder', render: 'host', defaultMode: 'hybrid', modes: ['router', 'hybrid', 'all'], + name: 'openclaw', displayName: 'OpenClaw', mechanism: 'skill-folder', render: 'host', defaultMode: 'hybrid', modes: ['router', 'hybrid', 'all'], instructionMax: OPENCLAW_INSTRUCTION_MAX, dest: (env, home) => path.join(env.OPENCLAW_STATE_DIR || path.join(home, '.openclaw'), 'skills'), inside: (env, anc) => env.OPENCLAW_SHELL === 'exec' || ancestorIs(anc, 'openclaw'), present: (env, home) => onPath('openclaw', env) || fs.existsSync(env.OPENCLAW_STATE_DIR || path.join(home, '.openclaw')), @@ -239,6 +254,19 @@ export function routerSkill(skills: Array<{ name: string; use: string; path: str ].join('\n'); } +/** A registered skill whose full text is over the harness's whole-document read limit: its own frontmatter, and a body that sends the agent to the full text. */ +export function entrySkill(md: string, fullPath: string, target: Target): string { + const end = md.indexOf('\n---', 4); + return [ + md.slice(0, md.indexOf('\n', end + 4) + 1) + '', + '# ' + frontmatterField(md, 'name'), '', + 'This skill\'s full instructions (' + Buffer.byteLength(md) + ' bytes) are larger than ' + target.displayName + ' serves as a skill file, so they are in a plain file.' + + ' Read `' + fullPath + '` in full now (every page, if the read is split) and follow it as this skill\'s instructions.' + + ' Paths it gives relative to the installed SKILL.md directory resolve from that file\'s folder.', + '', + ].join('\n'); +} + /** The marked context block for the registration's mode: router (a router path), hybrid (a router path and direct skills) or all (no router). */ export function contextBlock(routerPath: string | null, direct: string[] = []): string { const lead = "gstack's engineering workflows (code review, QA, debugging, security audit, plan review, shipping) are installed here. "; @@ -361,21 +389,26 @@ export function planPayload(opts: RegisterOptions, dest: string, mode: SkillsMod files.set(path.join(to, 'SKILL.md'), Buffer.from(flatFrontmatter(fs.readFileSync(path.join(s.dir, 'SKILL.md'), 'utf8'), path.basename(to)))); if (fs.existsSync(path.join(s.dir, 'sections'))) copyDir(path.join(s.dir, 'sections'), path.join(to, 'sections')); }; + const routerDir = path.join(dest, 'gstack'); + const plain = path.join(...(mode === 'all' ? [dest, OPENCLAW_ALL_TEXTS] : [routerDir, 'skills'])); const listed = (s: SkillEntry) => { const to = path.join(dest, path.basename(s.dir)); - if (t.render === 'host') copyDir(s.dir, to); else claudeFiles(s, to); + const md = fs.readFileSync(path.join(s.dir, 'SKILL.md')); + if (t.instructionMax && md.length > t.instructionMax) files.set(path.join(to, 'SKILL.md'), Buffer.from(entrySkill(md.toString('utf8'), path.join(plain, path.basename(s.dir), 'SKILL.md'), t))); + else if (t.render === 'host') copyDir(s.dir, to); else claudeFiles(s, to); return t.render === 'host' ? s.name : path.basename(s.dir); }; if (mode === 'all') { const names = skills.map(listed); + if (t.instructionMax) for (const s of skills) copyDir(s.dir, path.join(plain, path.basename(s.dir))); return { files, router: null, visible: names, direct: [], skills: names.length }; } const isDirect = (s: SkillEntry) => mode === 'hybrid' && HYBRID_SKILLS.includes(path.basename(s.dir).replace(/^gstack-/, '')); const direct = skills.filter(isDirect).map(listed); - const routerDir = path.join(dest, 'gstack'); + if (t.instructionMax) for (const s of skills.filter(isDirect)) copyDir(s.dir, path.join(plain, path.basename(s.dir))); const entries = skills.filter(s => !isDirect(s)).map(s => { if (t.render !== 'host') return { name: s.name, use: s.use, path: path.join(s.dir, 'SKILL.md') }; - const to = path.join(routerDir, 'skills', path.basename(s.dir)); + const to = path.join(plain, path.basename(s.dir)); copyDir(s.dir, to); return { name: s.name, use: s.use, path: path.join(to, 'SKILL.md') }; }); diff --git a/lib/model-policy-cli.ts b/lib/model-policy-cli.ts index 81700082a..30694d11d 100644 --- a/lib/model-policy-cli.ts +++ b/lib/model-policy-cli.ts @@ -15,6 +15,7 @@ import { type ModelSelection, } from './model-policy'; import { readConfigKey, type StateRootEnv } from './state-root'; +import { AgentModelError, renderAgentModel, resolveAgentModel, type AgentModelSelection } from './agent-model'; export interface ModelsCliResult { code: 0 | 1 | 2; @@ -33,6 +34,7 @@ export const MODELS_HELP = `Usage: gstack-models resolve --role plan-review --provider anthropic|openai [--json] gstack-models resolve --role implementation [--provider anthropic|openai] [--json] gstack-models resolve --role eval-arms [--provider anthropic|openai] [--json] + gstack-models resolve --role agent [--request ] [--json] gstack-models --help Shows which model each gstack role uses and why. It reads config.yaml, the @@ -57,6 +59,15 @@ Roles: eval-arms The arms /eval-plan compares: the implementation-tier model of each provider under the same precedence (plan E1). Pricing per arm comes from lib/pricing.ts via gstack-eval-plan arms. + agent The model you want gstack's working agents (lanes, subagents, + reviewers) to run on: your preference, not a gstack policy. + Precedence: --request (one job, never saved), then agent_model + in config.yaml (this machine), then agent_model in the file + GSTACK_PREFERENCES_FILE names (on Capy, preferences.yaml in + the gstack skill folder), then host (keep the agent's own + model). Values: host, a family (opus, fable, sonnet, haiku, + astra, sol, luna, terra) or /. A cross-family + outside voice and a model under test in an eval ignore it. Settings (gstack-config set|unset ): plan_review_tier frontier | smart | host (default frontier) @@ -180,7 +191,7 @@ function renderSelections(role: ModelRole, config: ModelPolicyConfig, selections return lines; } -function renderList(config: ModelPolicyConfig, selections: ModelSelection[], env: StateRootEnv): string { +function renderList(config: ModelPolicyConfig, selections: ModelSelection[], env: StateRootEnv, platform: NodeJS.Platform): string { const effort = codexEffortSetting(env); const commands = modelPolicyCommands(); const configCommand = `'${CONFIG_BIN.replaceAll('\\', '/').replaceAll("'", "'\\''")}'`; @@ -193,6 +204,8 @@ function renderList(config: ModelPolicyConfig, selections: ModelSelection[], env '', `codex effort: ${effort.value ?? 'each gate\'s default'} (${effort.source === 'env' ? 'GSTACK_CODEX_EFFORT' : effort.source === 'config' ? `codex_effort in ${config.path}` : 'default; set codex_effort'})${effort.valid ? '' : ` - invalid; Codex calls stop before sending. Use ${CODEX_EFFORT_LEVELS.join(', ')}`}; /spec keeps medium`, '', + ...agentLines(env, platform), + '', `catalog v${MODEL_CATALOG_VERSION} (sha256 ${modelCatalogSha256().slice(0, 12)})`, ]; for (const tier of MODEL_TIERS) { @@ -233,6 +246,37 @@ function renderResolve(role: ModelRole, config: ModelPolicyConfig, selections: M return lines.join('\n') + '\n'; } +function agentOrError(env: StateRootEnv, platform?: NodeJS.Platform, request?: string): AgentModelSelection | { error: ReturnType } { + try { + return resolveAgentModel({ env, platform, request }); + } catch (error) { + if (!(error instanceof AgentModelError)) throw error; + return { error: error.toJSON() }; + } +} + +function renderAgentError(error: ReturnType): string[] { + return [`agent: ${error.problem}`, ` cause: ${error.cause}`, ...error.repair.map(step => ` fix: ${step}`), ` docs: ${error.docs}`]; +} + +/** The agent block of `list`: a bad agent_model is reported here and never fails the other roles. */ +function agentLines(env: StateRootEnv, platform: NodeJS.Platform): string[] { + const agent = agentOrError(env, platform); + return 'error' in agent ? renderAgentError(agent.error) : renderAgentModel(agent); +} + +function agentResult(env: StateRootEnv, platform: NodeJS.Platform, request: string | undefined, json: boolean): ModelsCliResult { + const agent = agentOrError(env, platform, request); + if ('error' in agent) { + return json + ? { code: 1, stdout: `${JSON.stringify({ schemaVersion: 1, command: 'resolve', ok: false, offline: true, note: OFFLINE_NOTE, agent: null, errors: [agent.error] }, null, 2)}\n`, stderr: '' } + : { code: 1, stdout: '', stderr: renderAgentError(agent.error).map(l => `gstack-models: ${l.trimStart()}`).join('\n') + '\n' }; + } + return json + ? { code: 0, stdout: `${JSON.stringify({ schemaVersion: 1, command: 'resolve', ok: true, offline: true, note: OFFLINE_NOTE, agent, errors: [] }, null, 2)}\n`, stderr: '' } + : { code: 0, stdout: [...renderAgentModel(agent), `(${OFFLINE_NOTE})`].join('\n') + '\n', stderr: '' }; +} + export function modelsMain(args: string[], env: StateRootEnv = process.env, platform: NodeJS.Platform = process.platform, cwd: string = process.cwd()): ModelsCliResult { const json = args.includes('--json'); if (args.includes('--help') || args.includes('-h')) return { code: 0, stdout: MODELS_HELP, stderr: '' }; @@ -244,7 +288,7 @@ export function modelsMain(args: string[], env: StateRootEnv = process.env, plat const arg = rest.shift()!; if (arg === '--json') continue; const [name, inline] = arg.includes('=') ? [arg.slice(0, arg.indexOf('=')), arg.slice(arg.indexOf('=') + 1)] : [arg, undefined]; - if (command === 'list' || (name !== '--role' && name !== '--provider')) return usage(`unknown argument '${arg}' for ${command}`, json); + if (command === 'list' || (name !== '--role' && name !== '--provider' && name !== '--request')) return usage(`unknown argument '${arg}' for ${command}`, json); if (options[name] !== undefined) return usage(`${name} given twice`, json); const value = inline ?? rest.shift(); if (value === undefined || value === '' || value.startsWith('--')) return usage(`${name} needs a value`, json); @@ -254,7 +298,12 @@ export function modelsMain(args: string[], env: StateRootEnv = process.env, plat const providerArg = options['--provider']; const provider = parseProvider(providerArg); if (command === 'resolve') { - if (role !== 'plan-review' && role !== 'implementation' && role !== 'eval-arms') return usage(role === undefined ? 'resolve needs --role plan-review|implementation|eval-arms' : `unknown role '${role}': use plan-review, implementation or eval-arms`, json); + if (role !== 'plan-review' && role !== 'implementation' && role !== 'eval-arms' && role !== 'agent') return usage(role === undefined ? 'resolve needs --role plan-review|implementation|eval-arms|agent' : `unknown role '${role}': use plan-review, implementation, eval-arms or agent`, json); + if (role === 'agent') { + if (providerArg !== undefined) return usage('resolve --role agent takes no --provider; the preference names its provider', json); + return agentResult(env, platform, options['--request'], json); + } + if (options['--request'] !== undefined) return usage('--request applies only to --role agent', json); if (providerArg !== undefined && !provider) return usage(`unknown provider '${providerArg}': use anthropic or openai`, json); if (role === 'plan-review' && !provider) return usage('resolve --role plan-review needs --provider anthropic|openai (the opposing provider for your host)', json); } @@ -283,7 +332,10 @@ export function modelsMain(args: string[], env: StateRootEnv = process.env, plat // eval-arms (plan E1): the implementation-tier resolution of each provider, one arm each. if (role === 'eval-arms') for (const s of selections) (s as { role: string }).role = 'eval-arms'; const code = errors.length ? 1 : 0; - if (json) return { code, stdout: `${JSON.stringify(document(command, config, selections, errors, env), null, 2)}\n`, stderr: '' }; - const stdout = command === 'list' ? renderList(config, selections, env) : role === 'eval-arms' ? renderEvalArms(config, selections) : renderResolve(role as ModelRole, config, selections); + if (json) { + const doc = document(command, config, selections, errors, env); + return { code, stdout: `${JSON.stringify(command === 'list' ? { ...doc, agent: agentOrError(env, platform) } : doc, null, 2)}\n`, stderr: '' }; + } + const stdout = command === 'list' ? renderList(config, selections, env, platform) : role === 'eval-arms' ? renderEvalArms(config, selections) : renderResolve(role as ModelRole, config, selections); return { code, stdout, stderr: errors.map(renderError).join('') }; } diff --git a/lib/pregate/lane-selection.ts b/lib/pregate/lane-selection.ts index ae0c68126..5ca134179 100644 --- a/lib/pregate/lane-selection.ts +++ b/lib/pregate/lane-selection.ts @@ -20,6 +20,29 @@ import { stampFileKind, versionStampOnly } from './stamp-only'; import type { PregateContext, RemoteObligation } from './types'; import type { Lane } from './workflows'; +/** Whole-tree tests every selection that runs a test must include (GST-98). Kept out of pregate.json so older readers, which reject unknown keys, keep working. */ +export const ALWAYS_RUN_PATH = '.gstack/always-run.json'; + +/** The tracked test files the `tests` globs in `.gstack/always-run.json` name, and the globs that name none. No file: none. */ +export function alwaysRunTests(repoRoot: string, testFiles: Iterable): { tests: string[]; unmatched: string[] } { + const file = path.join(repoRoot, ALWAYS_RUN_PATH); + if (!fs.existsSync(file)) return { tests: [], unmatched: [] }; + let parsed: unknown; + try { parsed = JSON.parse(fs.readFileSync(file, 'utf8')); } + catch (error) { throw new Error(`${ALWAYS_RUN_PATH}: not valid JSON (${(error as Error).message})`); } + const globs = (parsed as { tests?: unknown } | null)?.tests; + if (!Array.isArray(globs) || globs.some(g => typeof g !== 'string' || !g)) throw new Error(`${ALWAYS_RUN_PATH}: "tests" must be a list of non-empty glob strings`); + const all = [...testFiles]; + const tests = new Set(); + const unmatched: string[] = []; + for (const glob of globs as string[]) { + const hits = all.filter(t => matchesAny([glob], t)); + if (!hits.length) unmatched.push(glob); + for (const t of hits) tests.add(t); + } + return { tests: [...tests].sort(), unmatched }; +} + /** A path the branch removed (deleted, or a rename's old name) that owes no lane, with why (`note`) and the files that lost their last test. */ export interface Removed { file: string; note: string; lostCoverage: string[] } @@ -210,7 +233,7 @@ export function selectLanes(ctx: PregateContext): Selection { const generator = ctx.registry.entries.find(e => e.command && matchesAny(e.inputs, file)); if (generator) { provenance.push(`${file}: covered by regen ${generator.id} (registry input)`); continue; } // The pin files are validated by pregate itself (validateConfig / validateRegistry) on every run. - if (file === PREGATE_CONFIG_PATH || file === GENERATED_PATH) { provenance.push(`${file}: pin file validated by pregate`); continue; } + if (file === PREGATE_CONFIG_PATH || file === GENERATED_PATH || file === ALWAYS_RUN_PATH) { provenance.push(`${file}: pin file validated by pregate`); continue; } // Reached only when testsFor mapped nothing, so a Markdown file a test reads never lands here. if (isDocsEmptyLane(file, ctx.config.empty_lanes ?? [])) { emptyLane.push(file); @@ -246,6 +269,11 @@ export function selectLanes(ctx: PregateContext): Selection { } noLane.push(file); } + if (tests.size || noLane.length || seeds.has(ALWAYS_RUN_PATH)) { + const always = alwaysRunTests(ctx.repoRoot, [...graph.tests].filter(t => !goneSet.has(t))); + for (const t of always.tests) if (!tests.has(t)) { tests.add(t); provenance.push(`${t} (always_run, ${ALWAYS_RUN_PATH})`); } + for (const glob of always.unmatched) provenance.push(`${ALWAYS_RUN_PATH}: ${glob} matches no test file`); + } const laneSets: Record = {}; const local = new Set(); const remote: RemoteObligation[] = []; diff --git a/lib/ship-budget-calibration.json b/lib/ship-budget-calibration.json index 225540983..cd4d1517a 100644 --- a/lib/ship-budget-calibration.json +++ b/lib/ship-budget-calibration.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "note": "GSTA-152 plan item 8. Per-meter constants for /ship budget projections, fitted conservatively from metered runs: each constant is the largest observation times `headroom`, rounded up to `round_to` (lib/ship-budget.ts fitConstant). test/ship-budget-calibration.test.ts checks the constants equal the fit and that a leave-one-out fit on two runs is at or above the meter on the third. Savings claims count completed ships only.", + "note": "GSTA-152 plan item 8. Per-meter constants for /ship budget projections, fitted conservatively from metered runs: each constant is the largest observation times `headroom`, rounded up to `round_to` (lib/ship-budget.ts fitConstant). test/ship-budget-calibration.test.ts checks the constants equal the fit and that a leave-one-out fit on two runs is at or above the meter on the third. Savings claims count completed ships only. Each unit's call_floor (GSTA-167) is the host's per-call floor: what one call costs in a fresh session with the ship skill loaded, before any /ship work. The delegate thresholds compare the per-call charge minus this floor, so a host's own system prompt and tools never trigger delegation. A floor is the largest round_to multiple at or below its smallest observed fresh call, so it never hides real context growth; start --call-floor overrides it for one run.", "headroom": 1.5, "round_to": 10000, "observations": { @@ -63,15 +63,24 @@ }, "units": { "capy-tokens": { - "fit": "observations above (Capy root counter, children included)" + "fit": "observations above (Capy root counter, children included)", + "call_floor": 150000, + "call_floor_observed": 150000, + "call_floor_why": "GSTA-2 (gstack 1.91.320.0, gstack-evals #35/#36): a fresh Capy thread's first counter reading is about 98k (host system prompt and tools) and every call with the ship skill loaded costs 150-230k before any /ship work; 150k is the smallest observed call. Below it, DELEGATE_START_CALL (100k) fired on every capped Capy start and DELEGATE_SEGMENT_CALL (150k) stopped two #36 children at their first boundary." }, "claude-usage-tokens": { "seeded_from": "capy-tokens", - "why": "no Claude Code-metered /ship yet; refit once one completes" + "why": "no Claude Code-metered /ship yet; refit once one completes", + "call_floor": 40000, + "call_floor_observed": 46379, + "call_floor_why": "GSTA-167 measurement, 2026-10-29: Claude Code 2.1.312 on claude-opus-5-5 with gstack's 64 skills listed, metered as this meter does (input + cache_creation + cache_read + output per message): the first call of a fresh -p session cost 19,225 and the call after reading ship/SKILL.md in full cost 46,379. One observation, so the floor rounds down to 40k; refit once a Claude-metered /ship completes." }, "estimate-tokens": { "seeded_from": "capy-tokens", - "why": "the estimate meter projects with the Capy fit, which is at or above the meter on the metered runs" + "why": "the estimate meter projects with the Capy fit, which is at or above the meter on the metered runs", + "call_floor": 0, + "call_floor_observed": null, + "call_floor_why": "The estimate meter takes no readings, so its per-call charge is always unknown and the floor has nothing to subtract: an unknown charge keeps the pre-floor rule (delegate at a segment boundary, never at start)." } } } diff --git a/lib/ship-budget-cli.ts b/lib/ship-budget-cli.ts index 55f83e357..e8bb1bd3f 100644 --- a/lib/ship-budget-cli.ts +++ b/lib/ship-budget-cli.ts @@ -12,8 +12,8 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { resultLine } from './result-codes'; import { - BudgetError, DEFAULT_TAIL, DELEGATE_SEGMENT_CALL, DELEGATE_START_CALL, METER_CONTRACTS, SEGMENTS, SHIP_STEPS, - admits, applyReading, budgetError, budgetLine, ledgerFileFor, loadCalibration, measureSettled, mintRunId, mutateLedger, newEpoch, + BudgetError, DEFAULT_TAIL, DELEGATE_SEGMENT_CALL, DELEGATE_START_CALL, MAX_CHAIN_DEPTH, METER_CONTRACTS, SEGMENTS, SHIP_STEPS, + aboveFloor, admits, applyReading, budgetError, budgetLine, ledgerFileFor, loadCalibration, measureSettled, mintRunId, mutateLedger, newEpoch, parseNext, parseRecordBlock, parseStep, parseTokens, pointMarker, projector, readLedger, reserveIn, restoreRun, reviewPaths, runDirFor, settleIn, stepProjection, tally, unitConstants, unreservedDispatches, usageError, writeLedger, type Ledger, type MeterKind, type Next, @@ -68,7 +68,7 @@ export function readMeter(l: Ledger, o: Opts, requireReading: boolean): number | } /** The stop block: STOP, the resumable RECOVER line and GSTACK_RESULT incomplete; a child also writes its return. */ -export function stopLines(l: Ledger, f: { call: number | null; next: string; step: string; projected: number; reason: 'budget_exhausted' | 'context_threshold' }): string[] { +export function stopLines(l: Ledger, f: { call: number | null; next: string; step: string; projected: number; reason: 'budget_exhausted' | 'context_threshold'; threshold?: number }): string[] { l.stopped = { at: new Date().toISOString(), reason: f.reason, step: f.step, next: f.next }; const t = tally(l); const p = projector(l); @@ -80,7 +80,7 @@ export function stopLines(l: Ledger, f: { call: number | null; next: string; ste resultLine('gstack-ship-budget', 'BUDGET_CAP_REACHED', `next ${f.next} needs ${f.projected} + reserve ${p.reserve}`), `RECOVER: /ship --resume ${l.record ?? ''} --token-cap ${t.used + t.reserved + t.unknown + tailCost}`); } else { - lines.push(`STOP: reason=context_threshold call=${f.call ?? 'unknown'} threshold=${DELEGATE_SEGMENT_CALL} next=${f.next} (${l.role === 'child' ? 'a child never delegates' : '--delegate never'})`, + lines.push(`STOP: reason=context_threshold call=${f.call ?? 'unknown'} call_floor=${p.floor} above_floor=${aboveFloor(f.call, p.floor) ?? 'unknown'} threshold=${f.threshold ?? DELEGATE_SEGMENT_CALL} next=${f.next} (--delegate never)`, `RECOVER: /ship --resume ${l.record ?? ''} in a fresh session`); } lines.push(`GSTACK_RESULT: skill=ship status=incomplete run=${l.dir}`); @@ -93,31 +93,78 @@ export function writeReturnFile(file: string, lines: string[]): void { fs.writeFileSync(file, lines.join('\n') + '\n', { mode: 0o600 }); } -/** Admission and the delegate rule for one boundary; reserves the admitted work under a cap. */ +/** + * Admission and the delegate rule for one boundary; reserves the admitted work + * under a cap (a dispatch batch goes to admitBatch). The rule compares the call's context above the host floor (an + * unknown charge delegates at a segment boundary, never at start). A child + * never stops on it: after a completed step it hands its remaining steps to a + * grandchild, and before one, at the maximum depth or past its segment it + * continues with a CONTEXT_HIGH note. Admission still stops anyone. + */ export function decide(l: Ledger, step: string, next: Next, call: number | null, startThreshold = false): Outcome { + if (next.kind === 'dispatch') return admitBatch(l, step, next.roles, call); const p = projector(l); - const nextId = next.kind === 'step' ? next.step : `dispatch:${next.roles.join(',')}`; + const nextId = next.step; const { prior, tokens: projected } = p.project(next); if (!admits(tally(l), projected, p.reserve, l.cap)) { return { stdout: stopLines(l, { call, next: nextId, step, projected, reason: 'budget_exhausted' }), exit: 3 }; } - const boundary = next.kind === 'step' && (startThreshold ? next.step === '1' : next.step === '7' || next.step === '12'); - const wants = boundary && (startThreshold ? call !== null && call >= DELEGATE_START_CALL : call === null || call >= DELEGATE_SEGMENT_CALL); - if (wants && (l.role === 'child' || l.delegate === 'never')) { - return { stdout: stopLines(l, { call, next: nextId, step, projected, reason: 'context_threshold' }), exit: 3 }; + const threshold = startThreshold ? DELEGATE_START_CALL : DELEGATE_SEGMENT_CALL; + const above = aboveFloor(call, p.floor); + const boundary = (startThreshold ? next.step === '1' : next.step === '7' || next.step === '12'); + const wants = boundary && (above === null ? !startThreshold : above >= threshold); + const depth = l.depth ?? (l.parent ? 1 : 0); + const hold = !wants || !l.parent ? undefined + : !l.steps_done?.length ? 'no step completed since verify-handoff' + : depth >= MAX_CHAIN_DEPTH ? `chain depth ${depth} is the maximum` + : l.delegate === 'never' ? '--delegate never' + : SHIP_STEPS.indexOf(nextId as any) > SHIP_STEPS.indexOf(l.parent.to as any) ? `Step ${nextId} is past this child's Steps ${l.parent.from}-${l.parent.to}` : undefined; + if (wants && !l.parent && l.delegate === 'never') { + return { stdout: stopLines(l, { call, next: nextId, step, projected, reason: 'context_threshold', threshold }), exit: 3 }; } - if (wants && next.kind === 'step') { + if (wants && !hold) { + const to = l.parent ? l.parent.to : SEGMENTS[next.step]; return { stdout: [budgetLine(l, { call, next: nextId, projected, verdict: 'delegate' }), - `DELEGATE: Steps ${next.step}-${SEGMENTS[next.step]} go to a fresh child: gstack-ship-budget handoff --run ${l.run} --from ${next.step} --to ${SEGMENTS[next.step]} --base origin/ (ship/sections/budget-handoff.md)`], exit: 0 }; + `DELEGATE: Steps ${next.step}-${to} go to a fresh ${l.parent ? `grandchild (chain depth ${depth + 1})` : 'child'}: gstack-ship-budget handoff --run ${l.run} --from ${next.step} --to ${to} --base origin/ (ship/sections/budget-handoff.md)`], exit: 0 }; } const lines = [budgetLine(l, { call, next: nextId, projected, verdict: 'continue' })]; + if (hold) lines.push(`CONTEXT_HIGH: call=${call ?? 'unknown'} call_floor=${p.floor} above_floor=${above ?? 'unknown'} threshold=${threshold} next=${nextId} depth=${depth}; continuing: ${hold}`); if (l.cap !== null) { - const r = reserveIn(l, { kind: next.kind, step, next: nextId, tokens: projected, prior, ...(next.kind === 'dispatch' ? { roles: next.roles } : {}) }); + const r = reserveIn(l, { kind: 'step', step, next: nextId, tokens: projected, prior }); lines.push(`RESERVATION: id=${r.id} next=${nextId} tokens=${projected}`); } return { stdout: lines, exit: 0 }; } +/** + * A dispatch batch: each reader is priced at max(its projection, the run's + * measured mean for its role) and the batch is reserved as one sum. When it + * does not fit, the largest prefix that does is admitted and ADMIT names the + * rest; the reservation keeps them as `remaining` until a later check of the + * same step requests them, and close flags any still left. Only a batch whose + * first reader does not fit stops. + */ +function admitBatch(l: Ledger, step: string, roles: string[], call: number | null): Outcome { + const p = projector(l); + const prices = roles.map(p.reader); + const cost = (k: number) => prices.slice(0, k).reduce((a, b) => a + b, 0); + const t = tally(l); + let k = roles.length; + while (k > 0 && !admits(t, cost(k), p.reserve, l.cap)) k--; + if (k === 0) return { stdout: stopLines(l, { call, next: `dispatch:${roles.join(',')}`, step, projected: cost(roles.length), reason: 'budget_exhausted' }), exit: 3 }; + const admitted = roles.slice(0, k), remaining = roles.slice(k), nextId = `dispatch:${admitted.join(',')}`; + const lines = [budgetLine(l, { call, next: nextId, projected: cost(k), verdict: 'continue' })]; + if (remaining.length) lines.push(`ADMIT: ${k} of ${roles.length} (remaining=${remaining.join(',')}); dispatch these ${k} now and re-check --next dispatch:${remaining.join(',')} after they settle`); + if (l.cap === null) return { stdout: lines, exit: 0 }; + for (const role of roles) { + const older = l.reservations.find(r => r.step === step && r.remaining?.includes(role)); + older?.remaining!.splice(older.remaining!.indexOf(role), 1); + } + const r = reserveIn(l, { kind: 'dispatch', step, next: nextId, tokens: cost(k), prior: p.project({ kind: 'dispatch', roles: admitted }).prior, roles: admitted, ...(remaining.length ? { remaining } : {}) }); + lines.push(`RESERVATION: id=${r.id} next=${nextId} tokens=${cost(k)}`); + return { stdout: lines, exit: 0 }; +} + /** Bind the Claude Code session: --session , or a nonce echoed once; zero or several matches refuse. */ function bindClaude(o: Opts, env: NodeJS.ProcessEnv): string[] { const dir = claudeConfigDir(env); @@ -143,11 +190,12 @@ function pickMeter(o: Opts): MeterKind | undefined { } export function cmdStart(argv: string[], ctx: Ctx): Outcome { - const o = parseArgs(argv, ['token-cap', 'record', 'meter', 'reading', 'prior-reading', 'session', 'nonce', 'reserve', 'delegate', 'calibration', 'ledger']); + const o = parseArgs(argv, ['token-cap', 'record', 'meter', 'reading', 'prior-reading', 'session', 'nonce', 'reserve', 'delegate', 'calibration', 'ledger', 'call-floor']); const record = str(o, 'record'); if (!record) throw usageError('start needs --record '); const rawCap = str(o, 'token-cap') ?? ctx.env.GSTACK_SHIP_TOKEN_CAP; const cap = rawCap === undefined || rawCap === 'none' ? null : parseTokens(rawCap, '--token-cap'); + const callFloor = tokensOpt(o, 'call-floor'); const delegate = str(o, 'delegate') ?? 'auto'; if (delegate !== 'auto' && delegate !== 'never') throw usageError(`--delegate '${delegate}' is not auto or never`); const existing = parseRecordBlock(record); @@ -173,6 +221,7 @@ export function cmdStart(argv: string[], ctx: Ctx): Outcome { delegate, role: 'parent', meter: { contract, epoch: 1, baseline: 0, last: null, ...(sessions ? { sessions, config_dir: claudeConfigDir(ctx.env) } : {}) }, epochs: [], carried_used: 0, estimate_used: 0, unknown: 0, next_id: 1, reservations: [], handoffs: [], cap_raises: [], marker: paths.marker, review_log: paths.reviewLog, ...(str(o, 'calibration') ? { calibration: path.resolve(str(o, 'calibration')!) } : {}), + ...(callFloor !== undefined ? { call_floor: callFloor } : {}), }; if (meter === 'capy' && tokensOpt(o, 'reading') === undefined) throw budgetError('BUDGET_READING_MISSING', 'start --meter capy'); if (meter === 'capy') l.meter.baseline = tokensOpt(o, 'reading')!; @@ -206,6 +255,7 @@ export function cmdCheck(argv: string[], ctx: Ctx): Outcome { if (done) settleIn(l, r); } const drift = freshReading(l, o) ? measureSettled(l) : []; + if (l.parent && next.kind === 'step' && SHIP_STEPS.indexOf(step as any) >= SHIP_STEPS.indexOf(l.parent.from as any)) l.steps_done = [...new Set([...(l.steps_done ?? []), step])]; const out = decide(l, step, next, call); return { stdout: [...drift, ...out.stdout], exit: out.exit }; }); @@ -239,13 +289,16 @@ export function cmdClose(argv: string[], ctx: Ctx): Outcome { lines.push(`BUDGET_DRIFT: steps=${count('step')} step_projected=${sum('step', 'projected')} step_actual=${sum('step', 'actual')} dispatches=${count('dispatch')} dispatch_projected=${sum('dispatch', 'projected')} dispatch_actual=${sum('dispatch', 'actual')}`); const accepted = new Set((l as any).accepted_skips as string[] | undefined ?? []); const skipped = l.cap === null ? [] : unreservedDispatches(l).filter(s => !accepted.has(s.started)); - if (skipped.length && str(o, 'approved-by') && str(o, 'words')) { - (l as any).accepted_skips = [...accepted, ...skipped.map(s => s.started)]; - lines.push(`ACCEPTED: ${skipped.length} unreserved dispatches by ${str(o, 'approved-by')}: "${str(o, 'words')}"`); - } else if (skipped.length) { + const dropped = l.reservations.filter(r => r.remaining?.length && !accepted.has(r.id)); + if ((skipped.length || dropped.length) && str(o, 'approved-by') && str(o, 'words')) { + (l as any).accepted_skips = [...accepted, ...skipped.map(s => s.started), ...dropped.map(r => r.id)]; + lines.push(`ACCEPTED: ${skipped.length} unreserved dispatches${dropped.length ? ` and ${dropped.length} unfinished batches` : ''} by ${str(o, 'approved-by')}: "${str(o, 'words')}"`); + } else if (skipped.length || dropped.length) { for (const s of skipped) lines.push(resultLine('gstack-ship-budget', 'BUDGET_CHECK_SKIPPED', `role=${s.skill} step=${s.step} started=${s.started} spend=unknown`)); - l.stopped = { at: new Date().toISOString(), reason: 'budget_check_skipped', step: '22', next: '22' }; - lines.push(`STOP: reason=budget_check_skipped dispatches=${skipped.length}; owner decision: accept the unreserved spend (close --record ${l.record} --approved-by --words ) or rerun them under reservations`, + for (const r of dropped) lines.push(`DROPPED: step=${r.step} reservation=${r.id} roles=${r.remaining!.join(',')} (ADMIT named them; no later check requested them)`); + const reason = skipped.length ? 'budget_check_skipped' : 'batch_incomplete'; + l.stopped = { at: new Date().toISOString(), reason, step: '22', next: '22' }; + lines.push(`STOP: reason=${reason} dispatches=${skipped.length}${dropped.length ? ` unfinished_batches=${dropped.length}` : ''}; owner decision: accept ${skipped.length ? 'the unreserved spend' : 'the readers that never ran'} (close --record ${l.record} --approved-by --words ) or rerun them under reservations`, `GSTACK_RESULT: skill=ship status=incomplete run=${l.dir}`); return { stdout: lines, exit: 3 }; } diff --git a/lib/ship-budget.ts b/lib/ship-budget.ts index ad93c3291..0a8655b6d 100644 --- a/lib/ship-budget.ts +++ b/lib/ship-budget.ts @@ -32,9 +32,14 @@ export const DISPATCH_ROLES = ['specialist', 'red-team', 'adversarial-native', ' type MissingRole = Exclude; const ROLES_COMPLETE: [MissingRole] extends [never] ? true : never = true; void ROLES_COMPLETE; -/** D3: per-call charge that runs the whole /ship in a fresh child, and the one that delegates a heavy segment. */ +/** + * D3: per-call context above the host's per-call floor (GSTA-167) that runs the + * whole /ship in a fresh child, and the one that delegates a heavy segment. + */ export const DELEGATE_START_CALL = 100_000; export const DELEGATE_SEGMENT_CALL = 150_000; +/** Handoffs between the root run and the deepest child; a run at this depth continues instead of handing off. */ +export const MAX_CHAIN_DEPTH = 3; /** Segment boundaries (before Step 7, before Step 12) and the last step each segment runs. */ export const SEGMENTS: Record = { '1': '22', '7': '11.5', '12': '22' }; export const DEFAULT_TAIL = 2_000_000; @@ -51,12 +56,12 @@ export const METER_CONTRACTS: Record = { export interface Reservation { id: string; kind: 'step' | 'dispatch' | 'child'; step: string; next: string; roles?: string[]; tokens: number; at: string; state: 'open' | 'consumed' | 'settled' | 'released'; token?: string; started_at?: string; settled_at?: string; - prior?: number; reading_at?: number | null; epoch?: number; actual?: number; unseen?: boolean; + prior?: number; reading_at?: number | null; epoch?: number; actual?: number; unseen?: boolean; remaining?: string[]; } export interface Sample { id: string; kind: 'step' | 'dispatch'; epoch: number; prior: number; projected: number; actual: number } export interface Handoff { n: number; sha: string; file: string; from: string; to: string; sub_cap: number; reservation: string; reading_at: number | null; - epoch: number; return_file: string; reconciled_at?: string; status?: string; child_used?: number; + epoch: number; return_file: string; reconciled_at?: string; status?: string; child_used?: number; depth?: number; } export interface Ledger { schema_version: 1; kind: 'gstack-ship-budget'; run: string; dir: string; file: string; record?: string; created_at: string; @@ -68,6 +73,7 @@ export interface Ledger { reservations: Reservation[]; handoffs: Handoff[]; cap_raises: Array<{ from: number | null; to: number | null; by: string; words: string; at: string }>; marker?: string; review_log?: string; calibration?: string; samples?: Sample[]; tail_explicit?: boolean; + call_floor?: number; depth?: number; steps_done?: string[]; stopped?: { at: string; reason: string; step: string; next: string }; closed_at?: string; } @@ -106,7 +112,7 @@ export interface Calibration { observations: Record>; constants: Record<'per_call' | 'per_dispatch' | 'stop_overhead', number>; step_calls: Record; step_dispatches: Record; segment_dispatches: Record; - units: Record; + units: Record; } export const CALIBRATION_FILE = path.join(import.meta.dir, 'ship-budget-calibration.json'); @@ -153,7 +159,10 @@ export function measuredFactor(l: Ledger, cal: Calibration, kind: Sample['kind'] /** * Projections calibrated by the run's measured factors. The tail reserve scales * with the step factor unless --reserve set it; the stop overhead keeps its - * calibrated fit so a run always has room to stop. + * calibrated fit so a run always has room to stop. `floor` is the host's + * per-call floor: start --call-floor, else the unit's calibrated value. + * `reader` prices one dispatch reader at max(its projection, the run's mean + * measured cost for that role over settled dispatch reservations). */ export function projector(l: Ledger, cal: Calibration = loadCalibration(l.calibration)) { const unit = l.meter.contract.unit; @@ -166,7 +175,17 @@ export function projector(l: Ledger, cal: Calibration = loadCalibration(l.calibr const segment = (from: string, to: string) => SHIP_STEPS.slice(SHIP_STEPS.indexOf(from as any), SHIP_STEPS.indexOf(to as any) + 1) .reduce((sum, s) => sum + project({ kind: 'step', step: s }).tokens + (cal.segment_dispatches[s] ?? 0) * project({ kind: 'dispatch', roles: ['specialist'] }).tokens, 0); const label = (f: number | null) => (f === null ? 'prior' : f.toFixed(3)); - return { project, segment, reserve: (l.tail_explicit ? l.tail : scale(l.tail, step)) + l.stop_overhead, label: `step:${label(step)},dispatch:${label(dispatch)}` }; + const measured = new Map(); + for (const r of l.reservations) { + if (r.kind !== 'dispatch' || r.actual === undefined || r.unseen || r.epoch !== l.meter.epoch) continue; + for (const role of r.roles ?? []) measured.set(role, [...(measured.get(role) ?? []), r.actual / r.roles!.length]); + } + const reader = (role: string) => { + const seen = measured.get(role) ?? []; + return Math.max(project({ kind: 'dispatch', roles: [role] }).tokens, seen.length ? Math.ceil(seen.reduce((a, b) => a + b, 0) / seen.length) : 0); + }; + return { project, segment, reader, reserve: (l.tail_explicit ? l.tail : scale(l.tail, step)) + l.stop_overhead, label: `step:${label(step)},dispatch:${label(dispatch)}`, + floor: l.call_floor ?? cal.units[unit]?.call_floor ?? 0 }; } /** @@ -265,9 +284,12 @@ export function admits(t: { used: number; reserved: number; unknown: number }, p export function budgetLine(l: Ledger, f: { call: number | null; next: string; projected: number; verdict: string }): string { const t = tally(l); const p = projector(l); - return `BUDGET: used=${t.used} reserved=${t.reserved} unknown=${t.unknown} admission_cap=${l.cap ?? 'none'} tail=${p.reserve} unit=${l.meter.contract.unit} meter=${l.meter.contract.meter} epoch=${l.meter.epoch} call=${f.call ?? 'unknown'} next=${f.next} projected=${f.projected} calibration=${p.label} verdict=${f.verdict}`; + return `BUDGET: used=${t.used} reserved=${t.reserved} unknown=${t.unknown} admission_cap=${l.cap ?? 'none'} tail=${p.reserve} unit=${l.meter.contract.unit} meter=${l.meter.contract.meter} epoch=${l.meter.epoch} call=${f.call ?? 'unknown'} call_floor=${p.floor} above_floor=${aboveFloor(f.call, p.floor) ?? 'unknown'} next=${f.next} projected=${f.projected} calibration=${p.label} verdict=${f.verdict}`; } +/** The per-call context above the host floor that the delegate thresholds compare; null when the charge is unknown. */ +export const aboveFloor = (call: number | null, floor: number) => (call === null ? null : Math.max(0, call - floor)); + export function reserveIn(l: Ledger, r: Omit): Reservation { const row: Reservation = { ...r, id: `${l.run}-r${l.next_id++}`, at: new Date().toISOString(), state: 'open', reading_at: l.meter.last, epoch: l.meter.epoch }; l.reservations.push(row); diff --git a/lib/ship-handoff.ts b/lib/ship-handoff.ts index 3ff8dd4cc..fba024659 100644 --- a/lib/ship-handoff.ts +++ b/lib/ship-handoff.ts @@ -6,7 +6,9 @@ * trail, the budget and the return contract; its sha256 goes in the parent's * ledger with a reservation for the child's whole sub-cap. The child verifies * the brief before anything else and admits against its sub-cap with - * child_used = reading - reading_at_handoff; it never delegates again. It + * child_used = reading - reading_at_handoff. The context rule never stops a + * child (GSTA-167): once it has completed a step it hands its remaining steps + * to a grandchild the same way, up to MAX_CHAIN_DEPTH handoffs from the root. It * returns at most 40 lines through a return file; the parent reconciles once * per handoff hash (a duplicate return charges nothing twice) and reads the * record, never the child's transcript. @@ -19,7 +21,7 @@ import { resolveClaudeCommand } from './claude-bin'; import { wtreeFingerprint } from './script-invocation'; import { resultLine } from './result-codes'; import { - SHIP_STEPS, budgetError, budgetLine, ledgerFileFor, loadCalibration, mintRunId, mutateLedger, parseStep, pointMarker, + MAX_CHAIN_DEPTH, SHIP_STEPS, budgetError, budgetLine, ledgerFileFor, loadCalibration, mintRunId, mutateLedger, parseStep, pointMarker, projector, readLedger, reserveIn, runDirFor, segmentEstimate, tally, usageError, writeLedger, type Handoff, type Ledger, } from './ship-budget'; import { loadMeterState, saveMeterState } from './claude-usage'; @@ -54,14 +56,14 @@ function brief(l: Ledger, h: Omit, pins: Record const tokens = [...new Set(reviews.join('\n').match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g) ?? [])]; const block = { version: 1, parent_run: l.run, parent_ledger: l.file, from: h.from, to: h.to, sub_cap: h.sub_cap, parent_tail: l.tail, unit: l.meter.contract.unit, meter: l.meter.contract.meter, epoch: h.epoch, reading_at: h.reading_at, ...pins, record: l.record, - return_file: h.return_file, trail: o.trail ?? null, trail_run: o.trailRun ?? null, launch_file: o.launch ?? null, start_tokens: tokens }; + depth: h.depth, return_file: h.return_file, trail: o.trail ?? null, trail_run: o.trailRun ?? null, launch_file: o.launch ?? null, start_tokens: tokens }; return [ `# /ship handoff ${h.n}: Steps ${h.from}-${h.to}`, '', '```gstack-handoff', JSON.stringify(block, null, 2), '```', '', '## Start', '', `1. Run \`gstack-ship-budget verify-handoff --in \` (on Capy add \`--reading \`) before anything else. On a refusal, return its lines and stop.`, block.trail ? `2. Run \`gstack-decision-trail start ${block.trail} \` before any trail write.` : '2. No decision trail was named.', - `3. Run /ship --continue for Steps ${h.from}-${h.to} only, under the printed child run: \`check\` at every boundary and before every dispatch batch, obeying \`verdict=\`. You never delegate; a delegate rule stops you.`, + `3. Run /ship --continue for Steps ${h.from}-${h.to} only, under the printed child run: \`check\` at every boundary and before every dispatch batch, obeying \`verdict=\`. The context rule never stops you: \`verdict=delegate\` hands your remaining steps to a fresh child the same way (ship/sections/budget-handoff.md), and a \`CONTEXT_HIGH:\` note means continue. This run is at chain depth ${h.depth} of ${MAX_CHAIN_DEPTH}.`, block.launch_file ? `4. Source the launch file \`${block.launch_file}\` in every shell that runs a gstack command (its path only; never print its values).` : '4. No GSTACK_LAUNCH_FILE was named.', '', '## Record', '', `You are the only writer of \`${l.record}\` during this segment. It keeps its six headings (${RECORD_HEADINGS.join(', ')}). Original start tokens: ${tokens.join(', ') || 'none'}.`, '', '## Decisions and approvals in scope (verbatim from the record)', '', ...(recordSection(recordText, 'Decisions') ?? ['(none recorded)']), '', @@ -77,35 +79,36 @@ export function cmdHandoff(argv: string[], ctx: Ctx): Outcome { if (SHIP_STEPS.indexOf(from as any) > SHIP_STEPS.indexOf(to as any)) throw usageError(`--from ${from} is after --to ${to}`); const base = str(o, 'base'); if (!base) throw usageError('handoff needs --base origin/ (the merge base is pinned against it)'); - return mutateLedger(ledgerFileFor({ run: str(o, 'run'), record: str(o, 'record') }, ctx.env), ctx.env, l => { - if (l.role === 'child') throw usageError('a child never delegates again; stop instead'); + const file = ledgerFileFor({ run: str(o, 'run'), record: str(o, 'record') }, ctx.env); + const child = str(o, 'child-session'); + const out = mutateLedger(file, ctx.env, (l): Outcome => { + const depth = l.depth ?? (l.parent ? 1 : 0); + if (depth >= MAX_CHAIN_DEPTH) throw usageError(`run ${l.run} is at chain depth ${depth}, the maximum (${MAX_CHAIN_DEPTH}); continue instead`); + const at = (s: string) => SHIP_STEPS.indexOf(s as any); + if (l.parent && (at(from) < at(l.parent.from) || at(to) > at(l.parent.to))) throw usageError(`--from ${from} --to ${to} is outside this child's Steps ${l.parent.from}-${l.parent.to}`); if (!l.record || !fs.existsSync(l.record)) throw usageError(`the run has no invocation record at ${l.record}`); const recordText = fs.readFileSync(l.record, 'utf8'); const missing = RECORD_HEADINGS.filter(h => !recordSection(recordText, h)); if (missing.length) throw usageError(`record ${l.record} lacks headings: ${missing.join(', ')}; fix: keep the six headings the ship skill names`); - const child = str(o, 'child-session'); - if (child && l.meter.contract.meter === 'claude') { - l.meter.sessions = [...new Set([...(l.meter.sessions ?? []), child])]; - const state = loadMeterState(path.join(l.dir, 'claude-meter.json')); - if (state) { state.sessions = l.meter.sessions; saveMeterState(path.join(l.dir, 'claude-meter.json'), state); } - } const call = readMeter(l, o, true); const cal = loadCalibration(l.calibration); const t = tally(l); const room = l.cap === null ? Infinity : l.cap - t.used - t.reserved - t.unknown - projector(l, cal).reserve; - const subCap = tokensOpt(o, 'sub-cap') ?? Math.min(room, segmentEstimate(cal, l.meter.contract.unit, from, to)); + const subCap = tokensOpt(o, 'sub-cap') ?? (l.parent && l.cap !== null ? room : Math.min(room, segmentEstimate(cal, l.meter.contract.unit, from, to))); if (subCap <= 0 || subCap > room) return { stdout: stopLines(l, { call, next: from, step: from, projected: subCap, reason: 'budget_exhausted' }), exit: 3 }; const pins = pinsNow(ctx.cwd, base, ctx.env); const reviewed = str(o, 'reviewed-tree') ?? null; const r = reserveIn(l, { kind: 'child', step: from, next: `child:${from}-${to}`, tokens: subCap }); const n = l.handoffs.length + 1; - const draft = { n, file: path.join(l.dir, `handoff-${n}.md`), from, to, sub_cap: subCap, reservation: r.id, reading_at: l.meter.contract.meter === 'estimate' ? null : l.meter.last, epoch: l.meter.epoch, return_file: path.join(l.dir, `return-${n}.txt`) }; + const draft = { n, file: path.join(l.dir, `handoff-${n}.md`), from, to, sub_cap: subCap, reservation: r.id, depth: depth + 1, reading_at: l.meter.contract.meter === 'estimate' ? null : l.meter.last, epoch: l.meter.epoch, return_file: path.join(l.dir, `return-${n}.txt`) }; const text = brief(l, draft, { ...pins, base_ref: base, reviewed_tree: reviewed }, recordText, { trail: str(o, 'trail'), trailRun: str(o, 'trail-run'), launch: ctx.env.GSTACK_LAUNCH_FILE }); fs.writeFileSync(draft.file, text, { mode: 0o600 }); const sha = sha256(text); l.handoffs.push({ ...draft, sha }); return { stdout: [`HANDOFF: ${draft.file} sha256=${sha} sub_cap=${subCap}`, `RETURN_FILE: ${draft.return_file}`, budgetLine(l, { call, next: from, projected: subCap, verdict: 'delegate' })], exit: 0 }; }); + if (child && out.exit === 0) joinMeter(file, child, ctx.env); + return out; } function handoffBlock(text: string): any { @@ -139,7 +142,8 @@ export function cmdVerifyHandoff(argv: string[], ctx: Ctx): Outcome { const dir = runDirFor(ctx.env, run); const child: Ledger = { schema_version: 1, kind: 'gstack-ship-budget', run, dir, file: path.join(dir, 'budget.json'), record: block.record, created_at: new Date().toISOString(), - cap: parent.cap === null ? null : block.sub_cap, tail: 0, stop_overhead: parent.stop_overhead, delegate: 'never', role: 'child', + cap: parent.cap === null ? null : block.sub_cap, tail: 0, stop_overhead: parent.stop_overhead, delegate: parent.delegate, role: 'child', + depth: h!.depth ?? (parent.depth ?? 0) + 1, ...(parent.call_floor !== undefined ? { call_floor: parent.call_floor } : {}), parent: { run: parent.run, ledger: parent.file, handoff_sha: sha, from: block.from, to: block.to, return_file: block.return_file }, meter: { contract: parent.meter.contract, epoch: 1, baseline: parent.meter.contract.meter === 'capy' ? block.reading_at ?? 0 : 0, last: null, ...(parent.meter.contract.meter === 'claude' ? { sessions: [str(o, 'session') ?? ''], config_dir: parent.meter.config_dir } : {}) }, @@ -163,9 +167,15 @@ export function cmdReturn(argv: string[], ctx: Ctx): Outcome { readMeter(l, o, false); const head = git(ctx.cwd, ['rev-parse', 'HEAD']); const tree = pinsNow(ctx.cwd, 'HEAD', ctx.env).wtree; - const lines = [`RETURN: handoff=${l.parent.handoff_sha} status=${status} steps=${l.parent.from}-${to} head=${head} tree=${tree}`, + const accepted = new Set((l as any).accepted_skips as string[] | undefined ?? []); + const unreconciled = l.handoffs.filter(h => !h.reconciled_at); + const dropped = l.reservations.filter(r => r.remaining?.length && !accepted.has(r.id)); + const result = status === 'complete' && (unreconciled.length || dropped.length) ? 'incomplete' : status; + const lines = [`RETURN: handoff=${l.parent.handoff_sha} status=${result} steps=${l.parent.from}-${to} head=${head} tree=${tree}`, + ...unreconciled.map(h => `UNRECONCILED: handoff=${h.sha} steps=${h.from}-${h.to} (reconcile --run ${l.run} --return ${h.return_file} before returning)`), + ...dropped.map(r => `DROPPED: step=${r.step} reservation=${r.id} roles=${r.remaining!.join(',')} (ADMIT named them; no later check requested them)`), `RECORD_CHANGED: ${str(o, 'changed') ?? 'none'}`, budgetLine(l, { call: null, next: '-', projected: 0, verdict: 'continue' }), - `GSTACK_RESULT: skill=ship status=${status} run=${l.dir}`]; + `GSTACK_RESULT: skill=ship status=${result} run=${l.dir}`]; writeReturnFile(l.parent.return_file, lines); l.closed_at = new Date().toISOString(); return { stdout: lines, exit: 0 }; @@ -229,17 +239,27 @@ export function cmdLaunch(argv: string[], ctx: Ctx): Outcome { const session = crypto.randomUUID(); const prompt = `Load gstack. Your Claude Code session id is ${session}. Run /ship --continue ${path.resolve(file)}: first gstack-ship-budget verify-handoff --in ${path.resolve(file)} --session ${session}, then only the steps the brief names, then gstack-ship-budget return as its return contract says.`; const log = path.join(path.dirname(block.return_file), `child-${session}.log`); - return mutateLedger(block.parent_ledger, ctx.env, l => { - if (l.meter.contract.meter === 'claude') { + joinMeter(block.parent_ledger, session, ctx.env); + const out = fs.openSync(log, 'a', 0o600); + const child = spawn(claude.command, [...claude.argsPrefix, '-p', '--session-id', session, ...CHILD_PERMISSION_ARGS, prompt], { cwd: ctx.cwd, env: ctx.env, detached: true, stdio: ['ignore', out, out] }); + child.unref(); + fs.closeSync(out); + return { stdout: [`CHILD: session=${session} pid=${child.pid} log=${log} return=${block.return_file}`], exit: 0 }; +} + +/** + * Claude Code: a child's session joins the meter of the run that launched it and + * of every ancestor up the chain, so each run meters its whole subtree once. + */ +function joinMeter(file: string, session: string, env: NodeJS.ProcessEnv): void { + for (let next: string | undefined = file; next;) { + next = mutateLedger(next, env, l => { + if (l.meter.contract.meter !== 'claude') return undefined; l.meter.sessions = [...new Set([...(l.meter.sessions ?? []), session])]; const meter = path.join(l.dir, 'claude-meter.json'); const state = loadMeterState(meter); if (state) { state.sessions = l.meter.sessions; saveMeterState(meter, state); } - } - const out = fs.openSync(log, 'a', 0o600); - const child = spawn(claude.command, [...claude.argsPrefix, '-p', '--session-id', session, ...CHILD_PERMISSION_ARGS, prompt], { cwd: ctx.cwd, env: ctx.env, detached: true, stdio: ['ignore', out, out] }); - child.unref(); - fs.closeSync(out); - return { stdout: [`CHILD: session=${session} pid=${child.pid} log=${log} return=${block.return_file}`], exit: 0 }; - }); + return l.parent?.ledger; + }); + } } diff --git a/lib/ship-queue-train-ready.ts b/lib/ship-queue-train-ready.ts index 5a04ec381..32a318742 100644 --- a/lib/ship-queue-train-ready.ts +++ b/lib/ship-queue-train-ready.ts @@ -2,8 +2,8 @@ * `ready --pr --screen | --no-screen` under a ship policy with a `train` * block (docs/ship-policy.md#run-a-merge-train; plan GSTA-49 T1). The * READY block is today's (lib/ship-queue.ts ready()); this adds the screen - * selection and the default recommendation (screen when the screen - * selects a skill's own cases), requires an explicit choice + * selection and the default recommendation (screen only when the selector + * restores every case or cannot select), requires an explicit choice * (READY_SCREEN_CHOICE_REQUIRED), dispatches a chosen screen through * eval-bg's nonce with its purpose and policy in the key, and records the * choice as a READY receipt (local state and refs/notes/gstack-trains) that @@ -49,28 +49,41 @@ export function skillOwnCases(selected: readonly string[], touchfiles: Record (touchfiles[id] ?? []).some(glob => skills.some(s => glob.startsWith(`${s}/`)))).sort(); } -/** gstack's paid selector over a diff, as a narrow screen (`--screen` without `--broad`) plans it, loaded only when the policy asks for it. */ +/** + * gstack's paid selector over a diff, as a narrow screen (`--screen` without `--broad`) plans it, loaded only when the + * policy asks for it. The recommendation is --screen only when the selector restores every case (its `full-fallback` + * mode: an unknown dependency or a shared runtime input) or cannot select at all; every other PR rides the train + * unscreened, where the full gate census runs every case on the tree that merges (GSTA-165). + */ export async function gstackPaidSelection(repoRoot: string, baseSha: string, changed: string[], hasTemplate: (dir: string) => boolean): Promise { const { computePaidCaseSelection } = await import('../scripts/lib/paid-select'); const { E2E_TOUCHFILES, LLM_JUDGE_TOUCHFILES } = await import('../test/helpers/touchfiles'); const skills = touchedSkills(changed, hasTemplate); let e2e: string[]; let judges: number; let note = "the narrow screen's selection; --broad adds the dependents of files whose references reach every gate case"; + let why: string; + let everyCase = false; try { - const s = computePaidCaseSelection({ profile: 'pr', rootDir: repoRoot, changedFiles: changed, env: { ...process.env, EVALS_BASE: baseSha, EVALS_ALL: '', EVALS_SCREEN_POLICY: 'narrow' } }).selection; - e2e = s.e2e ?? Object.keys(E2E_TOUCHFILES); - judges = s.judges ? s.judges.length : Object.keys(LLM_JUDGE_TOUCHFILES).length; + const r = computePaidCaseSelection({ profile: 'pr', rootDir: repoRoot, changedFiles: changed, env: { ...process.env, EVALS_BASE: baseSha, EVALS_ALL: '', EVALS_SCREEN_POLICY: 'narrow' } }); + e2e = r.selection.e2e ?? Object.keys(E2E_TOUCHFILES); + judges = r.selection.judges ? r.selection.judges.length : Object.keys(LLM_JUDGE_TOUCHFILES).length; + const mode = r.coverage?.mode ?? 'unknown'; + everyCase = mode === 'full-fallback'; + why = everyCase ? `selector mode full-fallback (${r.reason.slice(0, 200)})` : `selector mode ${mode}: the train gate's full census covers these cases`; } catch (error) { e2e = Object.keys(E2E_TOUCHFILES); judges = Object.keys(LLM_JUDGE_TOUCHFILES).length; note = `the PR profile needs full validation (${error instanceof Error ? error.message.slice(0, 160) : String(error)}): the screen is the full gate selection`; + everyCase = true; + why = 'the selector could not select'; } const own = skillOwnCases(e2e, E2E_TOUCHFILES, skills); - const recommend = own.length ? 'screen' : 'no-screen'; + const recommend = everyCase ? 'screen' : 'no-screen'; return { recommend, lines: [ `SCREEN: selection ${e2e.length} e2e case(s) + ${judges} judge(s) (${note})`, - `SCREEN: skill's own cases ${own.length ? own.slice(0, 12).join(',') + (own.length > 12 ? ` (+${own.length - 12})` : '') : 'none'} → recommend --${recommend}`, + `SCREEN: skill's own cases ${own.length ? own.slice(0, 12).join(',') + (own.length > 12 ? ` (+${own.length - 12})` : '') : 'none'}`, + `SCREEN: ${why} → recommend --${recommend}`, ], }; } diff --git a/lib/ship-queue-train.ts b/lib/ship-queue-train.ts index 5485f9b20..d8077fc16 100644 --- a/lib/ship-queue-train.ts +++ b/lib/ship-queue-train.ts @@ -100,22 +100,22 @@ export const pinList = (slots: Array<{ pr: string; head: string }>) => slots.map export const prsArgs = (slots: Array<{ pr: string; head: string }>, explicit?: boolean) => `--prs ${pinList(slots)}${explicit ? ` --order ${slots.map(s => s.pr).join(',')}` : ''}`; /** - * Slot order: screened PRs first, then unscreened, each in READY order; - * enough unscreened PRs form their own train. An explicit order (`--order`, + * Slot order: screened PRs first, then unscreened PRs fill the remaining + * seats, each in READY order (GSTA-165: most PRs ride unscreened, so a + * screened-only train would hold the pool). An explicit order (`--order`, * every admitted PR once) replaces that rule: slots follow it, the first * `max` ride and the rest are held. */ -export function orderSlots(slots: TrainSlot[], max: number, explicit?: readonly string[]): { train: TrainSlot[]; held: TrainSlot[]; split: boolean } { +export function orderSlots(slots: TrainSlot[], max: number, explicit?: readonly string[]): { train: TrainSlot[]; held: TrainSlot[] } { if (explicit) { const all = explicit.map(pr => slots.find(s => s.pr === pr)!); - return { train: all.slice(0, max), held: all.slice(max), split: false }; + return { train: all.slice(0, max), held: all.slice(max) }; } const byReady = (a: TrainSlot, b: TrainSlot) => a.ready_at.localeCompare(b.ready_at) || Number(a.pr) - Number(b.pr); const screened = slots.filter(s => s.screen.choice === 'screen').sort(byReady); const unscreened = slots.filter(s => s.screen.choice !== 'screen').sort(byReady); - if (screened.length && unscreened.length >= max) return { train: screened.slice(0, max), held: [...screened.slice(max), ...unscreened], split: true }; const all = [...screened, ...unscreened]; - return { train: all.slice(0, max), held: all.slice(max), split: false }; + return { train: all.slice(0, max), held: all.slice(max) }; } /** When a train under its size may dispatch: its oldest READY plus the fill time; null when it may dispatch now. */ @@ -389,7 +389,7 @@ async function newTrain(env: TrainEnv, o: BuildOptions): Promise<{ t: TrainRecor const lines = [ ...admitted.lines, `ORDER: ${order.train.map((s, i) => `${i + 1}:#${s.pr}(${s.screen.choice === 'screen' ? 'screened' : 'unscreened'})`).join(' ')}${explicit ? ' (explicit --order)' : ''}`, - ...(order.held.length ? [`HELD: ${order.held.map(s => '#' + s.pr).join(',')} (${order.split ? 'unscreened PRs form their own train' : `over the train size ${env.train.max_size}`})`] : []), + ...(order.held.length ? [`HELD: ${order.held.map(s => '#' + s.pr).join(',')} (over the train size ${env.train.max_size})`] : []), ]; const baseSha = git(env.ctx.repoRoot, ['rev-parse', `${env.remote}/${env.ctx.base}`]).out; const key = sha256(JSON.stringify({ base: baseSha, slots: order.train.map(s => `${s.pr}@${s.head}`) })); diff --git a/package.json b/package.json index 13e7a7f24..3b490b635 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.324", + "version": "1.91.335", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/scripts/lib/ci-history.ts b/scripts/lib/ci-history.ts index b140889c3..a7fe9833e 100644 --- a/scripts/lib/ci-history.ts +++ b/scripts/lib/ci-history.ts @@ -95,21 +95,29 @@ export interface CiJob { id: number; name: string; conclusion: string | null; st const PAGE = 100; +/** The runs API's `created` filter, by UTC day (both ends inclusive): `>=since`, or `since..until` when the window has an end. */ +export function createdFilter(since: string, until?: string): string { + return `created=${encodeURIComponent(until ? `${since.slice(0, 10)}..${until.slice(0, 10)}` : `>=${since.slice(0, 10)}`)}`; +} + /** - * Every completed run of `workflow` created at or after `since`, newest first, - * paging until a page is short or older than `since`. A failure on any page - * throws: a partial history is never reported as a complete one. + * Every completed run of `workflow` created at or after `since` (and before + * `until` when given), newest first, paging until a page is short (the + * `created` filter bounds the range server-side). A run whose created_at does + * not parse is skipped. A failure on any page throws: a partial history is + * never reported as a complete one. */ export function listWorkflowRuns(client: GhClient, repo: string, workflow: string, - opts: { since: string; branch?: string; event?: string; maxPages?: number }): CiRun[] { + opts: { since: string; until?: string; branch?: string; event?: string; maxPages?: number }): CiRun[] { const out: CiRun[] = []; - const query = [`status=completed`, `per_page=${PAGE}`, `created=${encodeURIComponent(`>=${opts.since.slice(0, 10)}`)}`, + const query = [`status=completed`, `per_page=${PAGE}`, createdFilter(opts.since, opts.until), ...(opts.branch ? [`branch=${encodeURIComponent(opts.branch)}`] : []), ...(opts.event ? [`event=${encodeURIComponent(opts.event)}`] : [])]; for (let page = 1; page <= (opts.maxPages ?? 20); page++) { const body = client.getJson(`repos/${repo}/actions/workflows/${workflow}/runs?${query.join('&')}&page=${page}`) as { workflow_runs?: any[] }; const runs = Array.isArray(body?.workflow_runs) ? body.workflow_runs : []; for (const run of runs) { - if (String(run.created_at) < opts.since) continue; + const created = Date.parse(String(run.created_at)); + if (!(created >= Date.parse(opts.since)) || (opts.until && created >= Date.parse(opts.until))) continue; if ((opts.branch && run.head_branch !== opts.branch) || (opts.event && run.event !== opts.event)) continue; out.push({ id: Number(run.id), attempt: Number(run.run_attempt ?? 1), sha: String(run.head_sha ?? ''), branch: String(run.head_branch ?? ''), event: String(run.event ?? ''), status: String(run.status ?? ''), conclusion: run.conclusion ?? null, diff --git a/ship/sections/budget-handoff.md b/ship/sections/budget-handoff.md index 5529caca6..b828deada 100644 --- a/ship/sections/budget-handoff.md +++ b/ship/sections/budget-handoff.md @@ -25,7 +25,8 @@ Before each dispatch batch, reserve the batch as one sum, then pass the printed ~/.claude/skills/gstack/bin/gstack-review-log --start review --reservation "$RESERVATION_ID" ``` -`--finish` settles it and the pass's other dispatch reservations. Admitting reviewers one at +On `ADMIT: k of n (remaining=)`, dispatch only those k and re-check the remaining roles +after they settle; `close` flags roles never re-checked. `--finish` settles the reservation and the pass's other dispatch reservations. Admitting reviewers one at a time, name each one that came back in the next check's `--returned `. A dispatch with no `review-log --start` settles with `gstack-ship-budget settle --run --reservation ` when it returns (`--unknown` when the meter cannot see it), or `release` if it never ran. @@ -45,9 +46,11 @@ fresh child; you wait and advance only after reconciling it: GSTACK_LAUNCH_FILE, run /ship --continue "; on Claude Code `gstack-ship-budget launch --in ` (background, explicit permissions). 3. The child runs `verify-handoff --in ` first (`--reading ` on Capy); a - `HANDOFF_PIN_MOVED` refusal returns status=refused. It admits against its sub-cap, - never delegates, and ends with `gstack-ship-budget return --run --to - --changed ` (at most 40 lines). + `HANDOFF_PIN_MOVED` refusal returns status=refused. It admits against its sub-cap and + never stops on the context rule: after a completed step, `verdict=delegate` hands its + remaining steps to a grandchild through these four steps (depth 3 at most); a + `CONTEXT_HIGH:` note means continue. It reconciles any grandchild, then ends with + `gstack-ship-budget return --run --to --changed ` (at most 40 lines). 4. `gstack-ship-budget reconcile --run --return --reading `. Read the record, never the child's transcript. Exit 3 (refused, incomplete or interrupted): regenerate the handoff or stop. diff --git a/ship/sections/budget-handoff.md.tmpl b/ship/sections/budget-handoff.md.tmpl index 30fff488e..9dfcee283 100644 --- a/ship/sections/budget-handoff.md.tmpl +++ b/ship/sections/budget-handoff.md.tmpl @@ -23,7 +23,8 @@ Before each dispatch batch, reserve the batch as one sum, then pass the printed ~/.claude/skills/gstack/bin/gstack-review-log --start review --reservation "$RESERVATION_ID" ``` -`--finish` settles it and the pass's other dispatch reservations. Admitting reviewers one at +On `ADMIT: k of n (remaining=)`, dispatch only those k and re-check the remaining roles +after they settle; `close` flags roles never re-checked. `--finish` settles the reservation and the pass's other dispatch reservations. Admitting reviewers one at a time, name each one that came back in the next check's `--returned `. A dispatch with no `review-log --start` settles with `gstack-ship-budget settle --run --reservation ` when it returns (`--unknown` when the meter cannot see it), or `release` if it never ran. @@ -43,9 +44,11 @@ fresh child; you wait and advance only after reconciling it: GSTACK_LAUNCH_FILE, run /ship --continue "; on Claude Code `gstack-ship-budget launch --in ` (background, explicit permissions). 3. The child runs `verify-handoff --in ` first (`--reading ` on Capy); a - `HANDOFF_PIN_MOVED` refusal returns status=refused. It admits against its sub-cap, - never delegates, and ends with `gstack-ship-budget return --run --to - --changed ` (at most 40 lines). + `HANDOFF_PIN_MOVED` refusal returns status=refused. It admits against its sub-cap and + never stops on the context rule: after a completed step, `verdict=delegate` hands its + remaining steps to a grandchild through these four steps (depth 3 at most); a + `CONTEXT_HIGH:` note means continue. It reconciles any grandchild, then ends with + `gstack-ship-budget return --run --to --changed ` (at most 40 lines). 4. `gstack-ship-budget reconcile --run --return --reading `. Read the record, never the child's transcript. Exit 3 (refused, incomplete or interrupted): regenerate the handoff or stop. diff --git a/test/agent-model.test.ts b/test/agent-model.test.ts new file mode 100644 index 000000000..b973425ae --- /dev/null +++ b/test/agent-model.test.ts @@ -0,0 +1,212 @@ +/** + * The agent role (lib/agent-model.ts, gstack-models resolve --role agent): a + * user's model preference for gstack's working agents. Pins the value grammar + * shared with bin/gstack-config, the precedence (request, this machine's + * config.yaml, the preferences file, host), that a bad agent_model never stops + * the other roles, the preferences file's parse rules, and the Capy wiring: + * the session derives the file beside the Drive skill (any wrapper that exports + * GSTACK_CAPY_WRAPPER_PATH, wrapper 3 included) and --check copies nothing. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { AgentModelError, MODEL_FAMILIES, parseAgentModel, readPreferencesFile, renderAgentModel, resolveAgentModel } from '../lib/agent-model'; +import { MODEL_CATALOG } from '../lib/model-catalog'; +import { modelsMain } from '../lib/model-policy-cli'; + +const REPO = path.resolve(import.meta.dir, '..'); +const bases: string[] = []; +afterEach(() => { for (const b of bases.splice(0)) fs.rmSync(b, { recursive: true, force: true }); }); +function tmp(): string { + const b = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'agent-model-'))); + bases.push(b); + return b; +} +function env(base: string, extra: Record = {}): Record { + const root = path.join(base, 'state'); + fs.mkdirSync(root, { recursive: true }); + return { HOME: base, GSTACK_STATE_ROOT: root, ...extra }; +} +const setConfig = (e: Record, text: string) => fs.writeFileSync(path.join(e.GSTACK_STATE_ROOT, 'config.yaml'), text); +const config = (e: Record, args: string[]) => spawnSync(path.join(REPO, 'bin/gstack-config'), args, { encoding: 'utf8', env: { ...process.env, ...e }, timeout: 30_000 }); + +describe('value grammar', () => { + test('host, a family and / parse; anything else is refused', () => { + expect(parseAgentModel('host')).toEqual({ kind: 'host' }); + expect(parseAgentModel(' opus ')).toEqual({ kind: 'model', provider: 'anthropic', model: 'claude-opus-5-5', family: 'opus' }); + expect(parseAgentModel('OPUS')).toMatchObject({ family: 'opus' }); + expect(parseAgentModel('Host')).toEqual({ kind: 'host' }); + expect(parseAgentModel('openai/gpt-6.1-sol')).toEqual({ kind: 'model', provider: 'openai', model: 'gpt-6.1-sol', family: null }); + for (const bad of ['', 'opux', 'claude-opus-5-5', 'Anthropic/x', 'anthropic/', '/x', 'anthropic/a b', 'anthropic/x\u001b[31m']) expect(parseAgentModel(bad)).toBeNull(); + }); + + test('every family the tier catalog also lists names the catalog model, so a catalog bump moves the family too', () => { + for (const entry of MODEL_CATALOG) { + const family = MODEL_FAMILIES.find(f => f.provider === entry.provider && entry.model.includes(f.family)); + expect(family?.model, entry.model).toBe(entry.model); + } + }); + + test('bin/gstack-config accepts and refuses exactly what the resolver does', () => { + const e = env(tmp()); + for (const value of ['host', 'Host', 'opus', 'OPUS', 'Sol', 'sonnet', 'terra', 'anthropic/claude-opus-5-5', 'openai/gpt-6.1-sol', 'opux', 'claude-opus-5-5', 'Anthropic/x', 'anthropic/a b', 'anthropic/']) { + const r = config(e, ['set', 'agent_model', value]); + expect(r.status === 0, value + r.stderr).toBe(parseAgentModel(value) !== null); + } + }); +}); + +describe('precedence and sources', () => { + test('host when nothing is set; the preferences file, then config.yaml, then a one-job request win in turn', () => { + const base = tmp(); + const prefs = path.join(base, 'skill', 'preferences.yaml'); + const e = env(base, { GSTACK_PREFERENCES_FILE: prefs }); + expect(resolveAgentModel({ env: e })).toMatchObject({ status: 'host', id: null, source: { kind: 'default' } }); + fs.mkdirSync(path.dirname(prefs), { recursive: true }); + fs.writeFileSync(prefs, '# my picks\nagent_model: opus\n'); + expect(resolveAgentModel({ env: e })).toMatchObject({ status: 'selected', id: 'anthropic/claude-opus-5-5', family: 'opus', source: { kind: 'preferences', path: prefs } }); + setConfig(e, 'agent_model: openai/gpt-6.1-sol\n'); + expect(resolveAgentModel({ env: e })).toMatchObject({ id: 'openai/gpt-6.1-sol', source: { kind: 'config' }, sources: { config: 'openai/gpt-6.1-sol', preferences: 'opus' } }); + expect(resolveAgentModel({ env: e, request: 'fable' })).toMatchObject({ id: 'anthropic/claude-fable-5-1', source: { kind: 'request', path: null } }); + expect(resolveAgentModel({ env: e, request: 'host' })).toMatchObject({ status: 'host', source: { kind: 'request' } }); + }); + + test('a one-job request is never saved, and a local unset falls back to the preferences file', () => { + const base = tmp(); + const prefs = path.join(base, 'preferences.yaml'); + fs.writeFileSync(prefs, 'agent_model: opus\n'); + const e = env(base, { GSTACK_PREFERENCES_FILE: prefs }); + const r = modelsMain(['resolve', '--role', 'agent', '--request', 'sol'], e); + expect(r.code, r.stderr).toBe(0); + expect(r.stdout).toMatch(/^agent: openai\/gpt-6\.1-sol \(sol\) - requested for this job, not saved$/m); + expect(fs.existsSync(path.join(e.GSTACK_STATE_ROOT, 'config.yaml'))).toBe(false); + expect(fs.readFileSync(prefs, 'utf8')).toBe('agent_model: opus\n'); + expect(config(e, ['set', 'agent_model', 'host']).status).toBe(0); + expect(resolveAgentModel({ env: e })).toMatchObject({ status: 'host', source: { kind: 'config' } }); + expect(config(e, ['unset', 'agent_model']).status).toBe(0); + expect(resolveAgentModel({ env: e })).toMatchObject({ id: 'anthropic/claude-opus-5-5', source: { kind: 'preferences' } }); + }); + + test('a broken preferences file never blocks a request or this machine\'s config.yaml, and its raw value is never echoed', () => { + const base = tmp(); + const prefs = path.join(base, 'preferences.yaml'); + fs.writeFileSync(prefs, 'agent_model: opus\nagent_model: sol\n'); + const e = env(base, { GSTACK_PREFERENCES_FILE: prefs }); + expect(() => resolveAgentModel({ env: e })).toThrow(/sets agent_model twice/); + const requested = resolveAgentModel({ env: e, request: 'sonnet' }); + expect(requested).toMatchObject({ family: 'sonnet', source: { kind: 'request' } }); + expect(requested.warnings.join('\n')).toContain('sets agent_model twice'); + setConfig(e, 'agent_model: opus\n'); + expect(resolveAgentModel({ env: e })).toMatchObject({ family: 'opus', source: { kind: 'config' } }); + fs.writeFileSync(prefs, 'agent_model: x\u001b]0;title\u0007\u009b31m\u202e\n'); + const shown = renderAgentModel(resolveAgentModel({ env: e })).join('\n'); + expect(shown).not.toMatch(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/); + }); + + test('an invalid value at the winning source is an error naming the source and the fix, never a silent fallback', () => { + const e = env(tmp()); + setConfig(e, 'agent_model: opux\n'); + expect(() => resolveAgentModel({ env: e })).toThrow(AgentModelError); + const r = modelsMain(['resolve', '--role', 'agent', '--json'], e); + expect(r.code).toBe(1); + const doc = JSON.parse(r.stdout); + expect(doc).toMatchObject({ ok: false, agent: null }); + expect(doc.errors[0]).toMatchObject({ reason: 'invalid_agent_model', key: 'agent_model' }); + expect(doc.errors[0].problem).toContain(path.join(e.GSTACK_STATE_ROOT, 'config.yaml')); + }); +}); + +describe('a bad agent_model never stops the other roles', () => { + test('plan-review, implementation and eval-arms resolve, and list reports the agent error without failing', () => { + const base = tmp(); + const e = env(base); + setConfig(e, 'agent_model: opux\n'); + for (const args of [['resolve', '--role', 'plan-review', '--provider', 'openai'], ['resolve', '--role', 'implementation'], ['resolve', '--role', 'eval-arms']]) { + const r = modelsMain(args, e, 'linux', base); + expect(r.code, args.join(' ') + r.stderr).toBe(0); + } + const list = modelsMain(['list'], e, 'linux', base); + expect(list.code).toBe(0); + expect(list.stdout).toMatch(/^agent: agent_model 'opux' in .* is not a model gstack can use$/m); + const json = JSON.parse(modelsMain(['list', '--json'], e, 'linux', base).stdout); + expect(json.ok).toBe(true); + expect(json.agent.error.reason).toBe('invalid_agent_model'); + expect(json.selections.every((s: { role: string }) => s.role !== 'agent')).toBe(true); + }); + + test('list --json adds a top-level agent block and leaves selections as they were', () => { + const base = tmp(); + const e = env(base); + const before = JSON.parse(modelsMain(['list', '--json'], e, 'linux', base).stdout); + setConfig(e, 'agent_model: opus\n'); + const after = JSON.parse(modelsMain(['list', '--json'], e, 'linux', base).stdout); + expect(after.selections).toEqual(before.selections); + expect(after.agent).toMatchObject({ role: 'agent', id: 'anthropic/claude-opus-5-5' }); + expect(after.agent.exemptions.join(' ')).toMatch(/plan-review.*eval-arms/); + }); + + test('usage: --request belongs to the agent role only, and the agent role takes no --provider', () => { + const e = env(tmp()); + expect(modelsMain(['resolve', '--role', 'implementation', '--request', 'opus'], e).code).toBe(2); + expect(modelsMain(['resolve', '--role', 'agent', '--provider', 'openai'], e).code).toBe(2); + expect(modelsMain(['resolve', '--role', 'agent', '--request', 'opux'], e).code).toBe(1); + }); +}); + +describe('the preferences file', () => { + test('comments, blanks and quotes are fine; unknown keys and stray lines warn; a second agent_model is an error', () => { + const f = path.join(tmp(), 'preferences.yaml'); + fs.writeFileSync(f, '# owner preferences\n\nagent_model: "opus"\ntelemetry: on\nnot a pair\n'); + const read = readPreferencesFile(f); + expect(read.value).toBe('opus'); + expect(read.warnings).toHaveLength(2); + expect(read.warnings[0]).toContain(":4 sets 'telemetry', which gstack does not read from preferences; ignored"); + expect(read.warnings[1]).toContain(":5 is not a 'key: value' line; ignored"); + fs.writeFileSync(f, 'agent_model: opus\nagent_model: sol\n'); + expect(() => readPreferencesFile(f)).toThrow(/sets agent_model twice/); + }); + + test('CRLF line endings, a byte-order mark and a trailing comment still read the value', () => { + const f = path.join(tmp(), 'preferences.yaml'); + fs.writeFileSync(f, '\ufeff# owner preferences\r\nagent_model: opus # my pick\r\n'); + expect(readPreferencesFile(f)).toEqual({ value: 'opus', warnings: [] }); + }); + + test('a missing file is silent; an oversized file or a directory is ignored with a warning', () => { + const base = tmp(); + expect(readPreferencesFile(path.join(base, 'absent.yaml'))).toEqual({ value: null, warnings: [] }); + const big = path.join(base, 'big.yaml'); + fs.writeFileSync(big, 'agent_model: opus\n' + '#'.repeat(5000)); + const read = readPreferencesFile(big); + expect(read.value).toBeNull(); + expect(read.warnings[0]).toContain('over 4096 bytes'); + expect(readPreferencesFile(base).warnings[0]).toContain('is not a file'); + }); +}); + +describe('Capy wiring', () => { + test('the session derives preferences.yaml beside the Drive skill from the wrapper path, in --check too, and copies nothing', () => { + const base = tmp(); + const root = path.join(base, 'gstack'); + for (const rel of ['bin/gstack-capy-session', 'bin/gstack-install-verdict.sh']) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.copyFileSync(path.join(REPO, rel), path.join(root, rel)); + } + fs.writeFileSync(path.join(root, 'bin/gstack-capy-install'), '#!/usr/bin/env bash\necho "PREFS=${GSTACK_PREFERENCES_FILE:-unset}"\necho "gstack: ok 1.0.0 for=none"\n', { mode: 0o755 }); + const skill = path.join(base, 'drive', 'skills', 'gstack'); + fs.mkdirSync(path.join(skill, 'scripts'), { recursive: true }); + fs.writeFileSync(path.join(skill, 'preferences.yaml'), 'agent_model: opus\n'); + const before = fs.readdirSync(skill).sort(); + const run = (extra: Record) => spawnSync('bash', [path.join(root, 'bin/gstack-capy-session'), '--check', '--root', root, '--for', 'review'], + { encoding: 'utf8', timeout: 30_000, env: { PATH: process.env.PATH ?? '', HOME: base, ...extra } }); + const r = run({ GSTACK_CAPY_WRAPPER_REV: '3', GSTACK_CAPY_WRAPPER_PATH: path.join(skill, 'scripts', 'install.sh') }); + expect(r.status, r.stdout + r.stderr).toBe(0); + expect(r.stdout).toContain('PREFS=' + path.join(skill, 'preferences.yaml')); + expect(fs.readdirSync(skill).sort()).toEqual(before); + expect(run({}).stdout).toContain('PREFS=unset'); + const inCheckout = run({ GSTACK_CAPY_WRAPPER_REV: '3', GSTACK_CAPY_WRAPPER_PATH: path.join(root, 'capy', 'skill', 'gstack', 'scripts', 'install.sh') }); + expect(inCheckout.stdout).toContain('PREFS=unset'); + }); +}); diff --git a/test/capy-skill-wrapper.test.ts b/test/capy-skill-wrapper.test.ts index 9b041ba7d..90ff0882c 100644 --- a/test/capy-skill-wrapper.test.ts +++ b/test/capy-skill-wrapper.test.ts @@ -17,6 +17,7 @@ import { afterEach, describe, expect, test } from 'bun:test'; import { spawn, spawnSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; +import { MODEL_FAMILIES } from '../lib/agent-model'; import { capyBootstrapCommand } from '../lib/doctor-components'; import { RESULT_CODES } from '../lib/result-codes'; import { capyHost, type CapyHost } from './helpers/capy-host'; @@ -479,3 +480,110 @@ describe('capy skill: vendored text and support file', () => { expect([...codes].filter(c => !(c in RESULT_CODES))).toEqual([]); }); }); + +describe('capy skill: factory.md, the auto-loaded "how we work" page', () => { + const FACTORY = path.join(SKILL, 'factory.md'); + /** Problems with a factory page's citations: [path: Heading, Heading; path2], [/skill], and plain repo paths in prose. Bare SKILL.md is the sibling file. */ + function citationProblems(text: string): string[] { + const problems: string[] = []; + const headingsOf = (file: string) => new Set(fs.readFileSync(file, 'utf8').split('\n').filter(l => /^#{1,6} /.test(l)).map(l => l.replace(/^#+ /, '').trim())); + const resolve = (p: string) => p === 'SKILL.md' ? path.join(SKILL, 'SKILL.md') : path.join(REPO, p); + for (const m of text.matchAll(/\[([^\]]+)\]/g)) { + for (const part of m[1].split(';').map(s => s.trim())) { + if (part.startsWith('/')) { + if (!fs.existsSync(path.join(REPO, part.slice(1), 'SKILL.md'))) problems.push(`unknown skill ${part}`); + continue; + } + const [file, headings] = part.split(/:\s*/, 2); + if (!fs.existsSync(resolve(file))) { problems.push(`missing file ${file}`); continue; } + const known = headingsOf(resolve(file)); + const list = !headings ? [] : known.has(headings.trim()) ? [headings.trim()] : headings.split(',').map(s => s.trim()).filter(Boolean); + for (const h of list) if (!known.has(h)) problems.push(`missing heading ${file}: ${h}`); + } + } + for (const m of text.matchAll(/\b((?:capy|docs|lib|bin)\/[A-Za-z0-9_./-]+\.md)\b/g)) { + if (!fs.existsSync(path.join(REPO, m[1]))) problems.push(`missing file ${m[1]}`); + } + return [...new Set(problems)]; + } + + test('it fits the 3,300-byte budget, and every citation, heading and skill it names resolves', () => { + const text = fs.readFileSync(FACTORY, 'utf8'); + expect(Buffer.byteLength(text)).toBeLessThanOrEqual(3300); + expect(text).toMatch(/^# How we work/); + expect(citationProblems(text)).toEqual([]); + }); + + test('negative controls: a missing file, a missing heading and an unknown skill are each caught', () => { + expect(citationProblems('[docs/no-such-page.md: Anything]')).toEqual(['missing file docs/no-such-page.md']); + expect(citationProblems('[docs/captain.md: No Such Heading]')).toEqual(['missing heading docs/captain.md: No Such Heading']); + expect(citationProblems('[/no-such-skill]')).toEqual(['unknown skill /no-such-skill']); + expect(citationProblems('see capy/no-such.md')).toEqual(['missing file capy/no-such.md']); + }); + + test('it ships to every Capy user: no crew codes or owner names, a model preference instead of a model rule', () => { + const text = fs.readFileSync(FACTORY, 'utf8'); + expect(text).not.toMatch(/\b(?:GSTA|GBRA|HARN|TRUT|FOUN|ALPH|GST)-\d+/); + expect(text).not.toMatch(/\bGarry\b/); + expect(text).toContain('gstack-models resolve --role agent'); + expect(text).not.toMatch(/no sonnet/i); + expect(fs.readFileSync(path.join(REPO, 'docs/model-policy.md'), 'utf8')).not.toMatch(/no sonnet/i); + }); + + test('SKILL.md points every session at it from Session start, and names preferences.yaml as the one file an agent may write in the skill folder', () => { + const text = fs.readFileSync(path.join(SKILL, 'SKILL.md'), 'utf8'); + const sessionStart = text.slice(text.indexOf('## Session start'), text.indexOf('## Launch every gstack command')); + expect(sessionStart).toContain('read `capy/skill/gstack/factory.md` in the checkout'); + expect(sessionStart).toMatch(/Never write the skill folder yourself; the one exception is its `preferences\.yaml`/); + }); + + describe('a session with no checkout (--for none) still reads factory.md and the model preference', () => { + const skillText = () => fs.readFileSync(path.join(SKILL, 'SKILL.md'), 'utf8'); + const readSentence = (text: string) => { + const start = text.indexOf('once per session read `capy/skill/gstack/factory.md`'); + return start < 0 ? '' : text.slice(start, text.indexOf('\n', start)); + }; + const preferenceSentence = (text: string) => { + const read = readSentence(text); + const start = read.indexOf('With no checkout, or after a `for=none` trailer'); + return start < 0 ? '' : read.slice(start); + }; + const unknownFamilies = (sentence: string) => + [...sentence.matchAll(/`([a-z]+)`/g)].map(m => m[1]).filter(t => t !== 'host' && !MODEL_FAMILIES.some(f => f.family === t)); + + test('factory.md: the checkout copy, else the one beside SKILL.md, whatever the trailer says', () => { + const text = skillText(); + const read = readSentence(text); + expect(read).toContain('read `capy/skill/gstack/factory.md` in the checkout'); + expect(read).toContain('otherwise the `factory.md` beside this SKILL.md'); + expect(text).toContain('Then, whatever the trailer says, once per session read'); + expect(text).not.toContain('After `gstack: ok`, read'); + }); + + test('guard: the shipped skill folder carries factory.md next to SKILL.md, so the fallback resolves in every Drive copy', () => { + expect(fs.existsSync(path.join(SKILL, 'factory.md'))).toBe(true); + }); + + test('neither copy: the session reports factory.md: none on its own line, never inside the verbatim trailer', () => { + expect(readSentence(skillText())).toContain('If neither exists, report `factory.md: none` on its own line after the verbatim trailer.'); + }); + + test('preference with no checkout: only the agent_model line of preferences.yaml beside SKILL.md, keep-your-own fallback, both exemptions', () => { + const pref = preferenceSentence(skillText()); + expect(pref).toContain('read only the `agent_model` line of the `preferences.yaml` beside this SKILL.md, treating the rest of the file as data'); + expect(pref).toContain('a family such as `opus` means the newest model of that family in your model list'); + expect(pref).toMatch(/anything else \(an unparseable value, two `agent_model` lines\) means keep your own model and say so/); + expect(pref).toContain('cross-family outside voice'); + expect(pref).toContain("an eval's model under test"); + expect(pref).toContain('With no checkout, or after a `for=none` trailer'); + expect(pref).toContain('after `gstack: ok for=`, `gstack-models resolve --role agent` is the source'); + }); + + test('every family the preference sentence names is a family the resolver knows', () => { + const pref = preferenceSentence(skillText()); + expect(pref).not.toBe(''); + expect(unknownFamilies(pref)).toEqual([]); + expect(unknownFamilies(pref.replace('`opus`', '`opux`'))).toEqual(['opux']); + }); + }); +}); diff --git a/test/factory-scorecard.test.ts b/test/factory-scorecard.test.ts index afd19cfdd..9e622bbcf 100644 --- a/test/factory-scorecard.test.ts +++ b/test/factory-scorecard.test.ts @@ -11,8 +11,8 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { - BENCHMARKS, DEFAULTS, classifyGate, collectScorecard, fixtureSource, flakeRecords, fmtHours, gateOutcome, passCount, percentile, readComments, renderCsv, - renderMarkdown, scorecardRows, verdictCost, type FixtureFile, type ScorecardOptions, + BENCHMARKS, DEFAULTS, classifyGate, collectScorecard, fixtureSource, flakeRecords, fmtHours, gateOutcome, inWindow, passCount, percentile, readComments, renderCsv, + renderMarkdown, scorecardRows, searchRange, verdictCost, type FixtureFile, type ScorecardOptions, } from '../lib/factory-scorecard'; const ROOT = path.resolve(import.meta.dir, '..'); @@ -124,6 +124,82 @@ describe('factory-scorecard: recorded window', () => { expect(values.merged).toBe('3 (crew 2, outside 1)'); }); + test('--now closes the window: a PR, gate run, free run or release at or after now is left out, and the runs and issue queries carry the end', () => { + // Value: protects=every windowed line counts [now - since-days, now) only, so a past window counts nothing that happened after it; fails_when=a read filters on the window start alone or a query drops the end bound; why_new=GST-135: --now 2026-10-15 counted 290 merged PRs (every merge through the 2026-10-30 run) against 65 in the window, and the gate and release lines ignored --now; seam=none; no_claim=GitHub's server-side date filters, which the reads re-check per item + const fx = fixture(); + const key = (part: string) => Object.keys(fx.responses).find(k => k.includes(part))!; + (fx.responses[key('/pulls?')] as any[]).unshift({ number: 106, title: 'merged at the window end', user: { login: 'garry' }, author_association: 'OWNER', + created_at: '2026-10-26T10:00:00Z', merged_at: '2026-10-26T12:00:00Z', updated_at: '2026-10-26T13:00:00Z', base: { ref: 'main' } }); + fx.responses['repos/acme/widgets/issues/106/comments?per_page=100&page=1'] = []; + const gateRuns = fx.responses[key('/evals.yml/runs?')] as { workflow_runs: any[] }; + gateRuns.workflow_runs.unshift({ ...gateRuns.workflow_runs.find(r => r.id === 9002), id: 9007, conclusion: 'success', created_at: '2026-10-26T12:30:00Z' }); + const freeRuns = fx.responses[key('/free-tests.yml/runs?')] as { workflow_runs: any[] }; + freeRuns.workflow_runs.unshift({ ...freeRuns.workflow_runs[0], id: 8004, created_at: '2026-10-26T13:00:00Z' }); + (fx.responses[key('/releases?')] as any[]).unshift({ tag_name: 'v1.2.0', draft: false, prerelease: false, published_at: '2026-10-26T12:00:00Z' }); + const source = fixtureSource(fx, scratch()); + const card = collectScorecard(source, options()); + expect(card.unavailable).toEqual([]); + expect(card.prs.map(p => p.number)).toEqual([101, 103, 102]); + expect(card.gates.map(g => g.id)).toEqual([9004, 9003, 9002, 9001]); + expect(card.freeRuns.map(r => r.id)).toEqual([8003, 8002, 8001]); + expect(card.releases.map(r => r.tag)).toEqual(['v1.0.0']); + const values = byId(scorecardRows(card)); + expect(values.merged).toBe('3 (crew 2, outside 1)'); + expect(values.lead_time).toBe('median 8.0h, p90 5.0d'); + expect(values.gates).toBe('2 / 1 / 1; known cost $43.00 on 2 of 4 runs'); + expect(values.releases).toBe('1 (v1.0.0)'); + for (const workflow of ['/evals.yml/runs?', '/free-tests.yml/runs?']) { + expect(decodeURIComponent(source.calls.find(c => c.includes(workflow))!)).toContain('created=2026-10-19..2026-10-26'); + } + const searches = source.calls.filter(c => c.startsWith('search/issues')).map(decodeURIComponent); + expect(searches).toEqual([ + 'search/issues?q=repo:acme/widgets is:issue created:2026-10-19T12:00:00Z..2026-10-26T11:59:59Z&per_page=1', + 'search/issues?q=repo:acme/widgets is:issue closed:2026-10-19T12:00:00Z..2026-10-26T11:59:59Z&per_page=1', + ]); + }); + + test('a past window pages past newer releases, and a read that runs past the page cap is unavailable instead of partial', () => { + // Value: protects=a past window still finds its releases behind a full page of newer ones, and a PR read that never reaches the window start reports unavailable rather than a short count; fails_when=releases read page 1 only again or the PR loop exits at MAX_PAGES without throwing; why_new=adversarial review of GST-135 found both truncations silent for past windows; seam=none; no_claim=GitHub's ordering of releases beyond newest-first + const fx = fixture(); + const releasesKey = (page: number) => `repos/acme/widgets/releases?per_page=100&page=${page}`; + const recorded = fx.responses[releasesKey(1)] as any[]; + fx.responses[releasesKey(1)] = Array.from({ length: 100 }, (_, i) => ({ tag_name: `v2.0.${i}`, draft: false, prerelease: false, published_at: '2026-10-27T00:00:00Z' })); + fx.responses[releasesKey(2)] = recorded; + expect(collectScorecard(fixtureSource(fx, scratch()), options()).releases.map(r => r.tag)).toEqual(['v1.0.0']); + const pullsKey = (page: number) => `repos/acme/widgets/pulls?state=closed&base=main&sort=updated&direction=desc&per_page=100&page=${page}`; + const capped = fixture(); + for (let page = 1; page <= 20; page++) { + capped.responses[pullsKey(page)] = Array.from({ length: 100 }, (_, i) => ({ number: page * 1000 + i, title: 'closed without merge', user: { login: 'garry' }, + author_association: 'OWNER', created_at: '2026-10-25T00:00:00Z', merged_at: null, updated_at: '2026-10-25T00:00:00Z' })); + } + const card = collectScorecard(fixtureSource(capped, scratch()), options()); + expect(card.unavailable).toHaveLength(1); + expect(card.unavailable[0]).toStartWith('merged PRs: PRs updated since 2026-10-19T12:00:00.000Z ran past 20 pages of 100'); + expect(byId(scorecardRows(card)).merged).toStartWith('unavailable ('); + }); + + test('gate-run and release reads past the page cap are unavailable, and a release published late is found on a later page', () => { + // Value: protects=the gate, cost and red-train lines and the releases line report unavailable instead of a short count, and a draft published inside the window counts wherever it sits in GitHub's creation order; fails_when=either loop exits at MAX_PAGES without throwing, or release paging stops on a page's oldest publication again; why_new=review of GST-135 found only the merged-PR cap tested and the release stop relying on publication order; seam=none; no_claim=GitHub's 1,000-result limit on filtered workflow-run lists + const fx = fixture(); + const releasesKey = (page: number) => `repos/acme/widgets/releases?per_page=100&page=${page}`; + fx.responses[releasesKey(1)] = Array.from({ length: 100 }, (_, i) => ({ tag_name: `v0.9.${i}`, draft: false, created_at: '2026-10-01T00:00:00Z', published_at: '2026-10-02T00:00:00Z' })); + fx.responses[releasesKey(2)] = [{ tag_name: 'v0.1.0', draft: false, created_at: '2026-09-01T00:00:00Z', published_at: '2026-10-20T00:00:00Z' }]; + expect(collectScorecard(fixtureSource(fx, scratch()), options()).releases.map(r => r.tag)).toEqual(['v0.1.0']); + const capped = fixture(); + const runsKey = Object.keys(capped.responses).find(k => k.includes('/evals.yml/runs?'))!.replace(/page=1$/, 'page='); + const run = (capped.responses[runsKey + '1'] as any).workflow_runs[0]; + for (let page = 1; page <= 20; page++) { + capped.responses[runsKey + page] = { workflow_runs: Array.from({ length: 100 }, (_, i) => ({ ...run, id: page * 1000 + i })) }; + capped.responses[releasesKey(page)] = Array.from({ length: 100 }, (_, i) => ({ tag_name: `v${page}.${i}`, draft: false, published_at: '2026-10-27T00:00:00Z' })); + } + const card = collectScorecard(fixtureSource(capped, scratch()), options()); + expect(card.unavailable.some(u => u.startsWith('gate runs: gate runs in the window ran past 20 pages of 100'))).toBe(true); + expect(card.unavailable.some(u => u.startsWith('releases: releases published since 2026-10-19T12:00:00.000Z ran past 20 pages of 100'))).toBe(true); + const values = byId(scorecardRows(card)); + expect(values.gates).toStartWith('unavailable ('); + expect(values.releases).toStartWith('unavailable ('); + }); + test('--max-artifact-runs caps artifact reads: later runs count without cost or flake data', () => { const card = collectScorecard(fixtureSource(fixture(), scratch()), options({ maxArtifactRuns: 2 })); expect(card.artifactRunsRead).toBe(2); @@ -266,6 +342,15 @@ describe('factory-scorecard: pieces', () => { expect(flakeRecords([path.join(dir, 'missing')])).toEqual({ flakes: 0, rerunUsd: 0 }); }); + test('the window is end-exclusive at millisecond precision; the issue search range stays inside it at whole seconds', () => { + // Value: protects=a sub-second --now (the default, Date.now()) neither drops an issue at the end second nor counts one before the start; fails_when=either rounding in searchRange flips or inWindow stops rejecting unparsable timestamps; why_new=the --now test uses a whole-second now only; seam=none; no_claim=GitHub search's inclusive-range semantics + const w = { start: '2026-10-19T12:00:00.500Z', end: '2026-10-26T12:00:00.500Z' }; + expect(searchRange(w)).toBe('2026-10-19T12:00:01Z..2026-10-26T12:00:00Z'); + expect(searchRange({ start: '2026-10-19T12:00:00.000Z', end: '2026-10-26T12:00:00.000Z' })).toBe('2026-10-19T12:00:00Z..2026-10-26T11:59:59Z'); + expect(['2026-10-19T12:00:00Z', '2026-10-19T12:00:01Z', '2026-10-26T12:00:00Z', '2026-10-26T12:00:01Z'].map(at => inWindow(at, w))).toEqual([false, true, true, false]); + expect([undefined, null, '', 'soon', Date.parse('2026-10-20T00:00:00Z')].map(at => inWindow(at, w))).toEqual([false, false, false, false, false]); + }); + test('percentile is nearest-rank: p50 and p90 of small samples, null when empty', () => { expect(percentile([], 0.5)).toBeNull(); expect(percentile([4, 8, 120], 0.5)).toBe(8); diff --git a/test/fixtures/factory-scorecard/recorded.json b/test/fixtures/factory-scorecard/recorded.json index c0de2a0e7..8774118cb 100644 --- a/test/fixtures/factory-scorecard/recorded.json +++ b/test/fixtures/factory-scorecard/recorded.json @@ -133,7 +133,7 @@ "body": "ship receipt follows\nSHIP_RECEIPT: pr=103 head=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb passes=3" } ], - "repos/acme/widgets/actions/workflows/evals.yml/runs?status=completed&per_page=100&created=%3E%3D2026-10-19&event=workflow_dispatch&page=1": { + "repos/acme/widgets/actions/workflows/evals.yml/runs?status=completed&per_page=100&created=2026-10-19..2026-10-26&event=workflow_dispatch&page=1": { "total_count": 6, "workflow_runs": [ { @@ -222,7 +222,7 @@ } ] }, - "repos/acme/widgets/actions/workflows/free-tests.yml/runs?status=completed&per_page=100&created=%3E%3D2026-10-19&branch=main&page=1": { + "repos/acme/widgets/actions/workflows/free-tests.yml/runs?status=completed&per_page=100&created=2026-10-19..2026-10-26&branch=main&page=1": { "total_count": 3, "workflow_runs": [ { @@ -289,11 +289,11 @@ "published_at": "2026-10-01T07:00:00Z" } ], - "search/issues?q=repo%3Aacme%2Fwidgets%20is%3Aissue%20created%3A%3E%3D2026-10-19T12%3A00%3A00Z&per_page=1": { + "search/issues?q=repo%3Aacme%2Fwidgets%20is%3Aissue%20created%3A2026-10-19T12%3A00%3A00Z..2026-10-26T11%3A59%3A59Z&per_page=1": { "total_count": 5, "items": [] }, - "search/issues?q=repo%3Aacme%2Fwidgets%20is%3Aissue%20closed%3A%3E%3D2026-10-19T12%3A00%3A00Z&per_page=1": { + "search/issues?q=repo%3Aacme%2Fwidgets%20is%3Aissue%20closed%3A2026-10-19T12%3A00%3A00Z..2026-10-26T11%3A59%3A59Z&per_page=1": { "total_count": 7, "items": [] } diff --git a/test/gate-cost.test.ts b/test/gate-cost.test.ts index 155a94bfd..b73a1cd53 100644 --- a/test/gate-cost.test.ts +++ b/test/gate-cost.test.ts @@ -344,10 +344,10 @@ describe('READY arrivals and trains', () => { expect([percentile([5, 1, 3, 2, 4], 0.5), percentile([5, 1, 3, 2, 4], 0.9), percentile([], 0.5)]).toEqual([3, 5, 0]); }); - test('train composition: unscreened PRs form their own train at five, else screened first, else the fill limit', () => { + test('train composition: screened first, unscreened fill the remaining seats, else the fill limit', () => { const item = (enqueuedMs: number, screened: boolean) => ({ enqueuedMs, screened }); const pool = [item(0, true), item(1, true), ...[2, 3, 4, 5, 6].map(t => item(t, false))]; - expect(pickTrain(pool, 10, 5, 100)).toEqual(pool.slice(2)); + expect(pickTrain(pool, 10, 5, 100)).toEqual(pool.slice(0, 5)); const mixed = [item(5, false), item(0, true), item(1, false), item(2, true), item(3, true)]; expect(pickTrain(mixed, 10, 5, 100)?.map(x => x.screened)).toEqual([true, true, true, false, false]); expect(pickTrain([item(0, false), item(1, true)], 99, 5, 100)).toBeNull(); diff --git a/test/gstack-capy-install.test.ts b/test/gstack-capy-install.test.ts index 937eaf57c..d733eb35f 100644 --- a/test/gstack-capy-install.test.ts +++ b/test/gstack-capy-install.test.ts @@ -514,6 +514,18 @@ describe('gstack-capy-install: launch PATH, Bun reuse, CI pins, setup skip, verd expect(conflict.stderr).toContain('(RUNTIME_PIN_CONFLICT; '); }); + test('the session GSTACK_PREFERENCES_FILE is written into the launch file, so every gstack command finds the owner preferences; without it no line is written', () => { + const f = fixture(); + const prefs = '/drive/skills/gstack/preferences.yaml'; + const r = run(f, ['--for', 'autoplan'], { GSTACK_PREFERENCES_FILE: prefs }); + expect(r.status, r.stdout + r.stderr).toBe(0); + const launch = verdicts(f)[0].replace(/\.json$/, '.env'); + expect(fs.readFileSync(launch, 'utf8')).toContain("export GSTACK_PREFERENCES_FILE='" + prefs + "'"); + const plain = run(f, ['--for', 'autoplan']); + expect(plain.status, plain.stdout + plain.stderr).toBe(0); + expect(fs.readFileSync(launch, 'utf8')).not.toContain('GSTACK_PREFERENCES_FILE'); + }); + test('the verdict file records checkout, HEAD, workflow, trailer and the Bun resolution, keyed by checkout and project; --check certifies without setup or downloads', () => { const f = fixture(); const r = run(f, ['--for', 'autoplan']); diff --git a/test/harness-register.test.ts b/test/harness-register.test.ts index 819da76fc..6544b11d1 100644 --- a/test/harness-register.test.ts +++ b/test/harness-register.test.ts @@ -10,8 +10,8 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { - BLOCK_BEGIN, BLOCK_END, CONTEXT_BLOCK_MAX, DOT_SKILLS_DIR, HARNESS_CODES, HYBRID_SKILLS, HarnessError, ROUTER_DESCRIPTION, ROUTER_DESCRIPTION_MAX, TARGETS, - contextBlock, contextScope, detect, listLedgers, readLedger, refresh, register, unregister, type RegisterOptions, + BLOCK_BEGIN, BLOCK_END, CONTEXT_BLOCK_MAX, DOT_SKILLS_DIR, HARNESS_CODES, HYBRID_SKILLS, HarnessError, OPENCLAW_ALL_TEXTS, OPENCLAW_INSTRUCTION_MAX, ROUTER_DESCRIPTION, ROUTER_DESCRIPTION_MAX, TARGETS, + contextBlock, contextScope, detect, listLedgers, readLedger, refresh, register, sourceSkills, unregister, type RegisterOptions, } from '../lib/harness-register'; import { QM_PACK_LIMITS, qmRequest, registerQm, removeQmPack } from '../lib/harness-qm'; import { cleanupFixtures, cleanupSeed, makeFixture, makeSource, registryRows, runSetup, tree } from './helpers/install-fixture'; @@ -89,7 +89,7 @@ describe('gstack-harness register and unregister (skill folders)', () => { test('OpenClaw all: every skill a top-level folder with a unique name', () => { const d = tmp(); const r = register(opts(d, 'openclaw', { mode: 'all', skillsDir: path.join(d, 'skills') })); - const names = fs.readdirSync(path.join(d, 'skills')).map(n => fs.readFileSync(path.join(d, 'skills', n, 'SKILL.md'), 'utf8').match(/^name:\s*(\S+)/m)![1]); + const names = fs.readdirSync(path.join(d, 'skills')).filter(n => !n.startsWith('.')).map(n => fs.readFileSync(path.join(d, 'skills', n, 'SKILL.md'), 'utf8').match(/^name:\s*(\S+)/m)![1]); expect(names.length).toBe(r.skills); expect(new Set(names).size).toBe(names.length); expect(readLedger(r.ledger)!.meta.names.split(',').sort()).toEqual([...names].sort()); @@ -269,7 +269,7 @@ describe('GSTA-135: registered skills get used', () => { for (const mode of ['all', 'router', 'hybrid'] as const) { expect(register(opts(d, 'openclaw', { env, mode })).status).toBe('updated'); expect(refresh(base).map(x => [x.mode, x.status])).toEqual([[mode, 'unchanged']]); - const top = fs.readdirSync(skills).filter(n => n !== 'mine'); + const top = fs.readdirSync(skills).filter(n => n !== 'mine' && !n.startsWith('.')); expect(top.length).toBe(mode === 'all' ? r.skills : mode === 'router' ? 1 : 1 + HYBRID_SKILLS.length); } unregister(path.join(d, 'state'), ROOT, r.id); @@ -386,6 +386,91 @@ describe('GSTA-135: registered skills get used', () => { }, 60_000); }); +describe('GST-110: OpenClaw serves every registered skill file whole', () => { + /** OpenClaw's registered instruction files: SKILL.md in each non-dot child folder, a folder without one searched below (dist/skill-root-discovery-C2QVZC77.mjs:44, :343-361). */ + const registered = (dir: string, depth = 0): string[] => depth > 6 ? [] : fs.readdirSync(dir, { withFileTypes: true }) + .filter(e => e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules') + .flatMap(e => { const sub = path.join(dir, e.name); return fs.existsSync(path.join(sub, 'SKILL.md')) ? [path.join(sub, 'SKILL.md')] : registered(sub, depth + 1); }); + const frontmatter = (md: string) => md.slice(0, md.indexOf('\n---', 4) + 4); + + test('hybrid and all: every registered SKILL.md is within the limit; a larger skill is an entry naming its full SKILL.md in an unregistered tree of every skill, whose sibling paths resolve; uninstall removes both', () => { + const d = tmp(); + for (const mode of ['hybrid', 'all'] as const) { + const skills = path.join(d, mode, 'skills'); + const plain = mode === 'all' ? path.join(skills, OPENCLAW_ALL_TEXTS) : path.join(skills, 'gstack', 'skills'); + const r = register(opts(d, 'openclaw', { mode, env: { OPENCLAW_STATE_DIR: path.join(d, mode) }, stateRoot: path.join(d, mode, 'state') })); + const mds = registered(skills); + expect(mds.length, mode).toBe(mode === 'all' ? r.skills : 1 + HYBRID_SKILLS.length); + const entries = mds.filter(md => fs.readFileSync(md, 'utf8').includes(' in full now (every page')); + if (mode === 'hybrid') expect(entries.map(md => path.basename(path.dirname(md)))).toContain('gstack-review'); + expect(entries.length, mode).toBeGreaterThan(0); + for (const md of mds) expect(fs.statSync(md).size, md).toBeLessThanOrEqual(OPENCLAW_INSTRUCTION_MAX); + const ledger = readLedger(r.ledger)!.files.map(x => x.path); + for (const md of entries) { + const entry = fs.readFileSync(md, 'utf8'); + const full = /Read \x60([^\x60]+)\x60 in full/.exec(entry)![1]; + expect(full).toBe(path.join(plain, path.basename(path.dirname(md)), 'SKILL.md')); + expect(mds).not.toContain(full); + expect(files(path.dirname(md))).toEqual(['SKILL.md']); + const text = fs.readFileSync(full, 'utf8'); + expect(Buffer.byteLength(text)).toBeGreaterThan(OPENCLAW_INSTRUCTION_MAX); + expect(frontmatter(entry)).toBe(frontmatter(text)); + expect(ledger).toContain(full); + } + for (const f of files(plain).filter(f => f.endsWith('.md'))) { + for (const [, ref] of fs.readFileSync(path.join(plain, f), 'utf8').matchAll(/\x60\.\.\/(gstack-[a-z-]+\/[^\x60\s]+)\x60/g)) { + expect(fs.existsSync(path.join(plain, ref)), f + ' -> ' + ref).toBe(true); + expect(fs.readFileSync(path.join(plain, ref), 'utf8'), ref).not.toContain(' in full now (every page'); + } + } + unregister(path.join(d, mode, 'state'), ROOT, r.id); + expect(files(skills), mode).toEqual([]); + } + }, 180_000); + + test('re-registering over a #3555 registration turns each large skill into an entry, removes its old files, and keeps nothing as an edit', () => { + const d = tmp(); + const env = { OPENCLAW_STATE_DIR: path.join(d, 'oc') }; + const old = register(opts(d, 'openclaw', { env, target: { ...TARGETS.openclaw, instructionMax: undefined } })); + expect(fs.statSync(path.join(old.dest, 'gstack-review', 'SKILL.md')).size).toBeGreaterThan(OPENCLAW_INSTRUCTION_MAX); + const r = register(opts(d, 'openclaw', { env })); + expect([r.id, r.status, r.kept]).toEqual([old.id, 'updated', []]); + expect(files(path.join(r.dest, 'gstack-review'))).toEqual(['SKILL.md']); + for (const md of registered(r.dest)) expect(fs.statSync(md).size, md).toBeLessThanOrEqual(OPENCLAW_INSTRUCTION_MAX); + }, 120_000); + + test('Hermes is unchanged: its registration is the Hermes render, byte for byte, with no entry files', () => { + const d = tmp(); + const r = register(opts(d, 'hermes', { env: { HERMES_HOME: path.join(d, 'hermes') } })); + const render = sourceSkills(TARGETS.hermes, ROOT, tmp()); + const want = render.flatMap(s => files(s.dir).map(f => path.join(path.basename(s.dir), f))).sort(); + expect(files(r.dest)).toEqual(want); + for (const s of render) for (const f of files(s.dir)) { + expect(fs.readFileSync(path.join(r.dest, path.basename(s.dir), f)).equals(fs.readFileSync(path.join(s.dir, f))), f).toBe(true); + } + }, 180_000); + + const openclaw = process.env.GSTACK_OPENCLAW_BIN || Bun.which('openclaw'); + test.skipIf(!openclaw)('a real OpenClaw lists the hybrid entries as its skills and none of the full texts (GSTACK_OPENCLAW_BIN or openclaw on PATH)', () => { + const d = tmp(); + const env = { ...process.env, OPENCLAW_STATE_DIR: path.join(d, 'oc') }; + const r = register(opts(d, 'openclaw', { env: { OPENCLAW_STATE_DIR: env.OPENCLAW_STATE_DIR } })); + const run = (...args: string[]) => { + const p = spawnSync(openclaw!, ['skills', ...args, '--json'], { cwd: d, env, encoding: 'utf8', timeout: 120_000 }); + expect(p.status, p.stderr).toBe(0); + return JSON.parse(p.stdout); + }; + const managed = run('list').skills.filter((s: { source: string }) => s.source === 'openclaw-managed').map((s: { name: string }) => s.name).sort(); + expect(managed).toEqual(['gstack', ...HYBRID_SKILLS].sort()); + for (const name of managed) { + const file = run('info', name).filePath as string; + expect(file.startsWith(r.dest + path.sep) && path.basename(file) === 'SKILL.md', file).toBe(true); + expect(fs.statSync(file).size, file).toBeLessThanOrEqual(OPENCLAW_INSTRUCTION_MAX); + } + expect(fs.statSync(path.join(r.dest, 'gstack', 'skills', 'gstack-review', 'SKILL.md')).size).toBeGreaterThan(OPENCLAW_INSTRUCTION_MAX); + }, 180_000); +}); + describe('bin/gstack-harness CLI error path', () => { test('a refused command prints its reason code and fix, never a TypeError from the error printer', () => { const d = tmp(); diff --git a/test/pregate-always-run.test.ts b/test/pregate-always-run.test.ts new file mode 100644 index 000000000..4d45043e9 --- /dev/null +++ b/test/pregate-always-run.test.ts @@ -0,0 +1,84 @@ +/** + * GST-167 PR 1 (GST-98): whole-tree tests named in .gstack/always-run.json join + * every selection that runs a test, because no touched file maps to a scanner + * that reads the whole tree. #3592 and #3593 passed their selected gate and + * then failed CI on exactly these tests. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { EXIT } from '../lib/headless-artifacts'; +import { ALWAYS_RUN_PATH, alwaysRunTests } from '../lib/pregate/lane-selection'; +import { listRepoFiles, isTestFile } from '../lib/pregate/import-graph'; +import { cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const SCANNER = "const test = require('node:test');\ntest('scans the tree', () => {});\n"; +const repos: FixtureRepo[] = []; +const fixture = (alwaysRun: unknown) => { + const r = makeFixtureRepo({ baseFiles: { 'test/tree-tripwire.test.js': SCANNER, ...(alwaysRun === undefined ? {} : { [ALWAYS_RUN_PATH]: typeof alwaysRun === 'string' ? alwaysRun : JSON.stringify(alwaysRun) }) } }); + repos.push(r); + return r; +}; +afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); +const lanes = (repo: FixtureRepo) => runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--only', 'lanes', '--explain']); + +describe('always_run selection', () => { + test('a code change adds the listed scanner the import graph never selects; without the file it is not added', () => { + const repo = fixture({ tests: ['test/*-tripwire.test.js', 'test/missing-*.test.js'] }); + commit(repo, { 'lib/util.js': 'exports.add = (a, b) => a + b + 0;\n' }); + const r = lanes(repo); + expect(r.status).toBe(EXIT.ok); + expect(r.out).toContain('test/util.test.js ← lib/util.js'); + expect(r.out).toContain(`test/tree-tripwire.test.js (always_run, ${ALWAYS_RUN_PATH})`); + expect(r.out).toContain(`${ALWAYS_RUN_PATH}: test/missing-*.test.js matches no test file`); + + const bare = fixture(undefined); + commit(bare, { 'lib/util.js': 'exports.add = (a, b) => a + b + 0;\n' }); + const b = lanes(bare); + expect(b.status).toBe(EXIT.ok); + expect(b.out).toContain('test/util.test.js ← lib/util.js'); + expect(b.out).not.toContain('always_run'); + }); + + test('a docs-only branch adds nothing; editing the list itself runs it and owes no lane', () => { + const docs = fixture({ tests: ['test/*-tripwire.test.js'] }); + commit(docs, { 'docs/README.md': '# docs\n\nupdated\n' }); + const d = lanes(docs); + expect(d.status).toBe(EXIT.ok); + expect(d.out).not.toContain('always_run'); + + const list = fixture({ tests: ['test/*-tripwire.test.js'] }); + commit(list, { [ALWAYS_RUN_PATH]: JSON.stringify({ tests: ['test/tree-*.test.js'] }) }); + const l = lanes(list); + expect(l.status).toBe(EXIT.ok); + expect(l.out).toContain(`${ALWAYS_RUN_PATH}: pin file validated by pregate`); + expect(l.out).toContain(`test/tree-tripwire.test.js (always_run, ${ALWAYS_RUN_PATH})`); + expect(l.err).not.toContain('PREGATE_NO_LANE'); + }); + + test('a malformed list stops with a message naming the file and the fix', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-always-run-')); + try { + fs.mkdirSync(path.join(dir, '.gstack')); + fs.writeFileSync(path.join(dir, ALWAYS_RUN_PATH), '{ not json'); + expect(() => alwaysRunTests(dir, [])).toThrow(`${ALWAYS_RUN_PATH}: not valid JSON`); + fs.writeFileSync(path.join(dir, ALWAYS_RUN_PATH), JSON.stringify({ tests: ['ok', ''] })); + expect(() => alwaysRunTests(dir, [])).toThrow('"tests" must be a list of non-empty glob strings'); + fs.writeFileSync(path.join(dir, ALWAYS_RUN_PATH), JSON.stringify({ tests: 'test/*.ts' })); + expect(() => alwaysRunTests(dir, [])).toThrow('"tests" must be a list of non-empty glob strings'); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); + + test("gstack's own list: every glob names a tracked test, and the GST-98 scanners are on it", () => { + const tracked = listRepoFiles(ROOT).filter(isTestFile); + const own = alwaysRunTests(ROOT, tracked); + expect(own.unmatched).toEqual([]); + for (const t of ['test/spawnsync-timeout-tripwire.test.ts', 'test/script-invocation-tripwire.test.ts', 'test/egress-receipt-wiring.test.ts', + 'test/env-host-fence-conformance.test.ts', 'test/free-fixtures.test.ts', 'test/bun-entry-isolation.test.ts', 'test/module-size-ratchet.test.ts']) { + expect(own.tests).toContain(t); + } + expect(own.tests).not.toContain('test/host-conformance.test.ts'); + }); +}); diff --git a/test/review-packets-removed.test.ts b/test/review-packets-removed.test.ts new file mode 100644 index 000000000..6ce83b396 --- /dev/null +++ b/test/review-packets-removed.test.ts @@ -0,0 +1,40 @@ +/** + * The reviewer-packet path from #3621 and #3636 was reverted after no-loss round 3 + * (GSTA-167): packet reviewers found 10 of the 19 blockers the pre-#3621 reviewers find. + * This keeps the removed commands and modules gone and unreferenced, so a branch cut + * before the revert cannot bring back a dangling call to them through a merge. + */ +import { describe, test, expect } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; + +const ROOT = path.join(import.meta.dir, '..'); +const REMOVED = [ + 'bin/gstack-review-packet', + 'lib/review-packet.ts', + 'lib/review-packet-collect.ts', + 'lib/review-plan-live.ts', + 'lib/review-prompts.ts', +]; +const NAMES = /gstack-review-packet|review-packet-collect|review-plan-live|lib\/review-prompts|review-packet\.ts|READ_BUDGET|reviewer-packets|review-packets-fc|REVIEW_COVERAGE_INCOMPLETE|review-coverage-incomplete/; +const SELF = path.relative(ROOT, import.meta.path); + +describe('reviewer packets stay reverted (GSTA-167)', () => { + test('the removed packet files do not exist', () => { + expect(REMOVED.filter(f => fs.existsSync(path.join(ROOT, f)))).toEqual([]); + }); + + test('no tracked file outside CHANGELOG.md names a removed packet file, the packet read rule or a packet-only anchor or code', () => { + const ls = spawnSync('git', ['ls-files', '-z'], { cwd: ROOT, encoding: 'utf8', timeout: 30_000, maxBuffer: 64 * 1024 * 1024 }); + expect(ls.status).toBe(0); + const files = ls.stdout.split('\0').filter(f => f && f !== 'CHANGELOG.md' && f !== SELF && fs.existsSync(path.join(ROOT, f))); + expect(files.length).toBeGreaterThan(1000); + const hits = files.filter(f => { + const full = path.join(ROOT, f); + if (!fs.statSync(full).isFile()) return false; + return NAMES.test(fs.readFileSync(full, 'utf8')); + }); + expect(hits).toEqual([]); + }); +}); diff --git a/test/ship-budget-batch.test.ts b/test/ship-budget-batch.test.ts new file mode 100644 index 000000000..b65822eab --- /dev/null +++ b/test/ship-budget-batch.test.ts @@ -0,0 +1,93 @@ +/** + * GSTA-167 (gstack-evals #35, gate class): full-depth Step 9 launched 7 Opus + * readers in parallel at about 0.76M each, and the shared counter went 13.2M -> + * 18.5M before the parent's poll saw it. A dispatch batch now prices each reader + * at max(its projection, the run's measured mean for that role), admits the + * largest prefix that fits near the cap (ADMIT: k of n), and keeps the rest as + * remaining until a later check requests them; close flags readers never re-checked. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import { loadCalibration, unitConstants } from '../lib/ship-budget'; +import { budgetHarness, type Harness } from './helpers/ship-budget-harness'; + +const cal = loadCalibration(); +const K = unitConstants(cal, 'capy-tokens'); +const harnesses: Harness[] = []; +const fresh = () => { const h = budgetHarness(); harnesses.push(h); return h; }; +afterEach(() => { while (harnesses.length) harnesses.pop()!.cleanup(); }); + +const READERS = ['specialist', 'specialist', 'specialist', 'specialist', 'specialist', 'red-team', 'adversarial-native']; +const AT_35 = 13_200_000; +/** Room for exactly three readers at the prior above the #35 reading, with 1M to spare. */ +const CAP_35 = AT_35 + 3 * K.per_dispatch + K.stop_overhead + 1_000_000; + +function start(h: Harness, cap: number) { + const r = h.sb(['start', '--record', h.record(), '--token-cap', String(cap), '--reading', '0', '--reserve', '0']); + expect(r.code, r.out + r.err).toBe(0); + return { run: h.field(r.out, 'BUDGET_RUN', 'id'), ledger: h.field(r.out, 'BUDGET_RUN', 'ledger') }; +} +const batch = (h: Harness, run: string, roles: string[], reading: number, extra: string[] = []) => + h.sb(['check', '--run', run, '--step', '9', '--next', 'dispatch:' + roles.join(','), '--reading', String(reading), ...extra]); + +describe('parallel dispatch admission near the cap', () => { + test('#35 replay: a 7-reader batch near the cap admits the largest prefix and names the rest', () => { + const h = fresh(); + const s = start(h, CAP_35); + const c = batch(h, s.run, READERS, AT_35); + expect(c.code, c.out + c.err).toBe(0); + expect(c.out).toMatch(/^ADMIT: 3 of 7 \(remaining=specialist,specialist,red-team,adversarial-native\)/m); + expect(h.field(c.out, 'BUDGET', 'verdict')).toBe('continue'); + expect(h.field(c.out, 'RESERVATION', 'next')).toBe('dispatch:specialist,specialist,specialist'); + expect(Number(h.field(c.out, 'RESERVATION', 'tokens'))).toBe(3 * K.per_dispatch); + const r = h.ledger(s.ledger).reservations.find((x: any) => x.kind === 'dispatch'); + expect(r).toMatchObject({ roles: ['specialist', 'specialist', 'specialist'], remaining: ['specialist', 'specialist', 'red-team', 'adversarial-native'] }); + }); + + test('a role\'s measured mean overrides a lower projection', () => { + const h = fresh(); + const s = start(h, 200_000_000); + let id = h.field(batch(h, s.run, ['red-team'], 0).out, 'RESERVATION', 'id'); + let reading = 3_000_000; + for (let i = 0; i < 4; i++, reading += 500_000) id = h.field(batch(h, s.run, ['specialist'], reading, ['--returned', id]).out, 'RESERVATION', 'id'); + const c = batch(h, s.run, ['red-team', 'specialist'], reading, ['--returned', id]); + expect(c.code, c.out + c.err).toBe(0); + const f = (500_000 / K.per_dispatch) * cal.headroom; + const specialist = Math.ceil((K.per_dispatch * f) / cal.round_to) * cal.round_to; + expect(specialist).toBeLessThan(3_000_000); + expect(Number(h.field(c.out, 'BUDGET', 'projected'))).toBe(3_000_000 + specialist); + }); + + test('when not even the first reader fits, the batch stops as before', () => { + const h = fresh(); + const s = start(h, CAP_35); + const c = batch(h, s.run, READERS, CAP_35 - K.stop_overhead - K.per_dispatch + 1); + expect(c.code).toBe(3); + expect(c.out).toContain('STOP: reason=budget_exhausted'); + expect(c.out).not.toContain('ADMIT:'); + expect(Number(h.field(c.out, 'BUDGET', 'projected'))).toBe(READERS.length * K.per_dispatch); + expect(h.ledger(s.ledger).reservations.filter((x: any) => x.kind === 'dispatch')).toHaveLength(0); + }); + + test('the remainder re-check admits after the first readers settle; close flags readers never re-checked', () => { + const h = fresh(); + const s = start(h, CAP_35); + const first = h.field(batch(h, s.run, READERS, AT_35).out, 'RESERVATION', 'id'); + expect(h.sb(['settle', '--run', s.run, '--reservation', first, '--reading', String(AT_35 + 3 * 760_000)]).code).toBe(0); + const rest = batch(h, s.run, READERS.slice(3), AT_35 + 3 * 760_000); + expect(rest.code, rest.out + rest.err).toBe(0); + expect(rest.out).not.toContain('ADMIT:'); + expect(h.field(rest.out, 'RESERVATION', 'next')).toBe('dispatch:' + READERS.slice(3).join(',')); + expect(h.ledger(s.ledger).reservations.find((x: any) => x.id === first).remaining).toEqual([]); + expect(h.sb(['close', '--run', s.run, '--reading', String(AT_35 + 4_000_000)]).code).toBe(0); + + const d = start(h, CAP_35); + expect(batch(h, d.run, READERS, AT_35).out).toContain('ADMIT: 3 of 7'); + const close = h.sb(['close', '--run', d.run, '--reading', String(AT_35 + 3_000_000)]); + expect(close.code).toBe(3); + expect(close.out).toMatch(/^DROPPED: step=9 reservation=\S+ roles=specialist,specialist,red-team,adversarial-native/m); + expect(close.out.trim().split('\n').at(-1)).toMatch(/^GSTACK_RESULT: skill=ship status=incomplete run=/); + const accepted = h.sb(['close', '--run', d.run, '--reading', String(AT_35 + 3_000_000), '--approved-by', 'Garry', '--words', 'three readers are enough']); + expect(accepted.code).toBe(0); + expect(accepted.out).toContain('ACCEPTED: 0 unreserved dispatches and 1 unfinished batches by Garry'); + }); +}); diff --git a/test/ship-budget-calibration.test.ts b/test/ship-budget-calibration.test.ts index 579c666af..2d09e0d57 100644 --- a/test/ship-budget-calibration.test.ts +++ b/test/ship-budget-calibration.test.ts @@ -29,6 +29,15 @@ describe('ship-budget calibration', () => { } }); + test('every meter unit has its own per-call floor: the largest round_to multiple at or below its observed fresh call, justified', () => { + for (const unit of ['capy-tokens', 'claude-usage-tokens', 'estimate-tokens']) { + const u = cal.units[unit]!; + expect(u.call_floor_why?.length ?? 0, unit).toBeGreaterThan(80); + const observed = u.call_floor_observed ?? 0; + expect(u.call_floor, unit).toBe(Math.floor(observed / cal.round_to) * cal.round_to); + } + }); + test('every meter unit resolves to constants (calibration is per meter)', () => { for (const unit of ['capy-tokens', 'claude-usage-tokens', 'estimate-tokens']) { expect(unitConstants(cal, unit).per_dispatch).toBeGreaterThan(0); diff --git a/test/ship-budget-chain.test.ts b/test/ship-budget-chain.test.ts new file mode 100644 index 000000000..c6a4286a1 --- /dev/null +++ b/test/ship-budget-chain.test.ts @@ -0,0 +1,286 @@ +/** + * GSTA-167: per-call thresholds measure context above the host's per-call + * floor (a fresh Capy thread's first call costs 150-230k before any work, + * GSTA-2 on gstack 1.91.320.0), and a child never stops on the context rule. + * Before its first completed step, or at the maximum chain depth, it continues + * with a CONTEXT_HIGH note; after one it hands its remaining steps to a + * grandchild through the same handoff, verify, return and reconcile commands. + * The token admission cap still stops a child. Replays gstack-evals #36, where + * two handoff children stopped at the Step 7/12 boundary before doing any work. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { loadCalibration, MAX_CHAIN_DEPTH, stepProjection, unitConstants } from '../lib/ship-budget'; +import { budgetHarness, type Harness } from './helpers/ship-budget-harness'; + +const cal = loadCalibration(); +const FLOOR = cal.units['capy-tokens']!.call_floor!; +const K = unitConstants(cal, 'capy-tokens'); +const FIXTURE = JSON.parse(fs.readFileSync(path.join(import.meta.dir, 'fixtures', 'claude-usage-2.1.292.json'), 'utf8')); +const harnesses: Harness[] = []; +const fresh = () => { const h = budgetHarness(); harnesses.push(h); return h; }; +afterEach(() => { while (harnesses.length) harnesses.pop()!.cleanup(); }); + +function start(h: Harness, args: string[]) { + const r = h.sb(['start', '--record', h.record(), ...args]); + expect(r.code, r.out + r.err).toBe(0); + return { r, run: h.field(r.out, 'BUDGET_RUN', 'id'), ledger: h.field(r.out, 'BUDGET_RUN', 'ledger') }; +} + +const readingArgs = (reading: number | null) => (reading === null ? [] : ['--reading', String(reading)]); + +/** Hand Steps from-to off from a run and verify the brief as the next run in the chain. */ +function handOff(h: Harness, run: string, from: string, to: string, reading: number | null, verifyArgs: string[] = readingArgs(reading)) { + const ho = h.sb(['handoff', '--run', run, '--from', from, '--to', to, '--base', 'origin/main', ...readingArgs(reading)]); + expect(ho.code, ho.out + ho.err).toBe(0); + const m = ho.out.match(/^HANDOFF: (\S+) sha256=\S+ sub_cap=(\d+)$/m)!; + const v = h.sb(['verify-handoff', '--in', m[1]!, ...verifyArgs]); + expect(v.code, v.out + v.err).toBe(0); + return { ho, v, brief: m[1]!, subCap: Number(m[2]), returnFile: ho.out.match(/^RETURN_FILE: (\S+)$/m)![1]!, run: h.field(v.out, 'BUDGET_RUN', 'id'), ledger: h.field(v.out, 'BUDGET_RUN', 'ledger') }; +} + +function check(h: Harness, run: string, step: string, next: string, extra: string[] = []) { + return h.sb(['check', '--run', run, '--step', step, '--next', next, ...extra]); +} + +describe('per-call thresholds above the host floor', () => { + test('Capy floor: a fresh thread\'s first call of 150-230k does not delegate at start; 260k does', () => { + expect(FLOOR).toBe(150_000); + const h = fresh(); + for (const [call, verdict] of [[150_000, 'continue'], [230_000, 'continue'], [260_000, 'delegate']] as const) { + const s = start(h, ['--token-cap', '60M', '--reading', String(98_000 + call), '--prior-reading', '98000']); + expect(h.field(s.r.out, 'BUDGET', 'call')).toBe(String(call)); + expect(h.field(s.r.out, 'BUDGET', 'call_floor')).toBe(String(FLOOR)); + expect(h.field(s.r.out, 'BUDGET', 'above_floor')).toBe(String(call - FLOOR)); + expect(h.field(s.r.out, 'BUDGET', 'verdict')).toBe(verdict); + expect(s.r.out.includes('--from 1 --to 22')).toBe(verdict === 'delegate'); + } + }); + + test('at a segment boundary, 150k above the floor delegates and less continues; an unknown charge still delegates', () => { + const h = fresh(); + const s = start(h, ['--token-cap', '80M', '--reading', '1000000']); + const low = check(h, s.run, '6', '7', ['--reading', '1230000', '--prior-reading', '1000000']); + expect(low.code, low.out + low.err).toBe(0); + expect(h.field(low.out, 'BUDGET', 'above_floor')).toBe('80000'); + expect(h.field(low.out, 'BUDGET', 'verdict')).toBe('continue'); + const high = check(h, s.run, '11.5', '12', ['--reading', '2000000', '--prior-reading', '1700000']); + expect(h.field(high.out, 'BUDGET', 'above_floor')).toBe('150000'); + expect(h.field(high.out, 'BUDGET', 'verdict')).toBe('delegate'); + const unknown = check(h, s.run, '11.5', '12', ['--reading', '2100000']); + expect(h.field(unknown.out, 'BUDGET', 'above_floor')).toBe('unknown'); + expect(h.field(unknown.out, 'BUDGET', 'verdict')).toBe('delegate'); + }); + + test('--call-floor overrides the calibrated floor and is recorded in the ledger', () => { + const h = fresh(); + const s = start(h, ['--token-cap', '60M', '--reading', '218000', '--prior-reading', '98000', '--call-floor', '0']); + expect(h.field(s.r.out, 'BUDGET', 'call_floor')).toBe('0'); + expect(h.field(s.r.out, 'BUDGET', 'verdict')).toBe('delegate'); + expect(h.ledger(s.ledger).call_floor).toBe(0); + const bad = h.sb(['start', '--record', h.record(), '--token-cap', '60M', '--reading', '0', '--call-floor', 'lots']); + expect(bad.code).toBe(2); + expect(bad.err).toContain("--call-floor 'lots' is not a token count"); + }); +}); + +describe('a child never stops on the context rule', () => { + test('#36 replay: a child at its first boundary with zero completed steps continues with one CONTEXT_HIGH note', () => { + const h = fresh(); + const s = start(h, ['--token-cap', '400M', '--reading', '98000']); + for (const [from, to, step, at] of [['7', '11.5', '6', 1_000_000], ['12', '22', '11.5', 2_000_000]] as const) { + const c = handOff(h, s.run, from, to, at); + const r = check(h, c.run, step, from, ['--reading', String(at + 200_000)]); + expect(r.code, r.out + r.err).toBe(0); + expect(h.field(r.out, 'BUDGET', 'verdict')).toBe('continue'); + const notes = r.out.split('\n').filter(l => l.startsWith('CONTEXT_HIGH:')); + expect(notes).toHaveLength(1); + expect(notes[0]).toContain('no step completed since verify-handoff'); + expect(r.out).toContain(`RESERVATION: id=${c.run}-r1 next=${from}`); + expect(r.out).not.toContain('STOP:'); + expect(r.out).not.toContain('GSTACK_RESULT'); + expect(fs.existsSync(c.returnFile)).toBe(false); + } + }); + + test('a child with a completed step hands its remaining steps to a grandchild at depth 2, sub-cap = its remaining sub-cap', () => { + const h = fresh(); + const s = start(h, ['--token-cap', '200M', '--reading', '98000']); + const child = handOff(h, s.run, '1', '22', 200_000); + expect(h.ledger(child.ledger).depth).toBe(1); + expect(check(h, child.run, '1', '2', ['--reading', '600000']).code).toBe(0); + const d = check(h, child.run, '6', '7', ['--reading', '1500000']); + expect(d.code, d.out + d.err).toBe(0); + expect(h.field(d.out, 'BUDGET', 'verdict')).toBe('delegate'); + expect(d.out).toContain(`gstack-ship-budget handoff --run ${child.run} --from 7 --to 22`); + expect(d.out).not.toContain('RESERVATION'); + const room = ['admission_cap', 'used', 'reserved', 'unknown', 'tail'].map(k => Number(h.field(d.out, 'BUDGET', k))); + const grand = handOff(h, child.run, '7', '22', 1_500_000); + expect(grand.subCap).toBe(room[0]! - room[1]! - room[2]! - room[3]! - room[4]!); + expect(h.field(grand.v.out, 'BUDGET', 'admission_cap')).toBe(String(grand.subCap)); + const g = h.ledger(grand.ledger); + expect(g).toMatchObject({ role: 'child', depth: 2, parent: { run: child.run, ledger: child.ledger, from: '7', to: '22' } }); + expect(h.ledger(child.ledger).handoffs[0]).toMatchObject({ depth: 2, from: '7', to: '22' }); + const text = fs.readFileSync(grand.brief, 'utf8'); + expect(text).toContain('"depth": 2'); + expect(text).not.toContain('a delegate rule stops you'); + }); + + test(`at the maximum chain depth (${MAX_CHAIN_DEPTH}) a child continues and its handoff refuses`, () => { + expect(MAX_CHAIN_DEPTH).toBe(3); + const h = fresh(); + const s = start(h, ['--token-cap', '900M', '--reading', '0']); + let run = s.run; + let reading = 100_000; + const chain: string[] = []; + for (const [from, done] of [['1', '1'], ['2', '2'], ['3', '3']] as const) { + const c = handOff(h, run, from, '22', (reading += 100_000)); + expect(check(h, c.run, done, String(Number(done) + 1), ['--reading', String((reading += 100_000))]).code).toBe(0); + run = c.run; + chain.push(c.ledger); + } + expect(chain.map(f => h.ledger(f).depth)).toEqual([1, 2, 3]); + const r = check(h, run, '6', '7', ['--reading', String((reading += 100_000))]); + expect(r.code, r.out + r.err).toBe(0); + expect(h.field(r.out, 'BUDGET', 'verdict')).toBe('continue'); + expect(r.out).toMatch(/^CONTEXT_HIGH: .*chain depth 3 is the maximum/m); + const refused = h.sb(['handoff', '--run', run, '--from', '7', '--to', '22', '--base', 'origin/main', '--reading', String(reading)]); + expect(refused.code).toBe(2); + expect(refused.err).toContain('chain depth 3'); + }); + + test('the token admission cap still stops a child that would otherwise hand off', () => { + const h = fresh(); + const s = start(h, ['--token-cap', '80M', '--reading', '0']); + const ho = h.sb(['handoff', '--run', s.run, '--from', '1', '--to', '22', '--base', 'origin/main', '--reading', '100000', '--sub-cap', '5M']); + const brief = ho.out.match(/^HANDOFF: (\S+)/m)![1]!; + const returnFile = ho.out.match(/^RETURN_FILE: (\S+)$/m)![1]!; + const child = h.field(h.sb(['verify-handoff', '--in', brief, '--reading', '110000']).out, 'BUDGET_RUN', 'id'); + expect(check(h, child, '1', '2', ['--reading', '120000']).code).toBe(0); + const stop = check(h, child, '6', '7', ['--reading', '2120000']); + expect(stop.code).toBe(3); + expect(stop.out).toContain('STOP: reason=budget_exhausted'); + expect(stop.out).toContain('(BUDGET_CAP_REACHED)'); + expect(stop.out).not.toContain('context_threshold'); + expect(fs.readFileSync(returnFile, 'utf8')).toMatch(/^RETURN: handoff=[0-9a-f]{64} status=incomplete steps=1-22 next=7/); + }); +}); + +describe('a chained return is charged once', () => { + test('Capy meter: root, child and grandchild reconcile, each token counted once and a duplicate reconcile charges nothing', () => { + const h = fresh(); + const s = start(h, ['--token-cap', '200M', '--reading', '1000000']); + const child = handOff(h, s.run, '1', '22', 1_100_000, ['--reading', '1200000']); + expect(check(h, child.run, '1', '2', ['--reading', '1500000']).code).toBe(0); + expect(h.field(check(h, child.run, '6', '7', ['--reading', '2000000']).out, 'BUDGET', 'verdict')).toBe('delegate'); + const grand = handOff(h, child.run, '7', '22', 2_100_000, ['--reading', '2200000']); + expect(check(h, grand.run, '7', '8', ['--reading', '3000000']).code).toBe(0); + expect(h.sb(['return', '--run', grand.run, '--to', '22', '--changed', 'Reviews', '--reading', '6000000']).code).toBe(0); + const mid = h.sb(['reconcile', '--run', child.run, '--return', grand.returnFile, '--reading', '6100000']); + expect(mid.code, mid.out + mid.err).toBe(0); + expect(mid.out).toContain('child=complete child_used=4000000'); + expect(h.field(mid.out, 'BUDGET', 'used')).toBe('5000000'); + expect(h.field(mid.out, 'BUDGET', 'reserved')).toBe('0'); + const midAgain = h.sb(['reconcile', '--run', child.run, '--return', grand.returnFile, '--reading', '6100000']); + expect(midAgain.out).toContain('nothing charged twice'); + expect(h.field(midAgain.out, 'BUDGET', 'used')).toBe('5000000'); + expect(h.sb(['return', '--run', child.run, '--to', '22', '--changed', 'Reviews', '--reading', '6200000']).code).toBe(0); + const top = h.sb(['reconcile', '--run', s.run, '--return', child.returnFile, '--reading', '6300000']); + expect(top.code, top.out + top.err).toBe(0); + expect(top.out).toContain('child=complete child_used=5200000'); + expect(h.field(top.out, 'BUDGET', 'reserved')).toBe(String(stepProjection(cal, 'capy-tokens', '1'))); + expect(h.sb(['reconcile', '--run', s.run, '--return', child.returnFile, '--reading', '6300000']).out).toContain('nothing charged twice'); + const close = h.sb(['close', '--run', s.run, '--reading', '6300000']); + expect(close.out).toContain('BUDGET_FINAL: used=5300000 reserved=0'); + }); + + test("estimate meter: the grandchild's settled spend reaches the root once, through the child's return", () => { + const h = fresh(); + const s = start(h, ['--token-cap', '400M', '--meter', 'estimate']); + const child = handOff(h, s.run, '1', '22', null); + expect(check(h, child.run, '1', '2').code).toBe(0); + expect(h.field(check(h, child.run, '6', '7').out, 'BUDGET', 'verdict')).toBe('delegate'); + const grand = handOff(h, child.run, '7', '22', null); + expect(check(h, grand.run, '7', '8').code).toBe(0); + expect(check(h, grand.run, '8', '9').code).toBe(0); + const gret = h.sb(['return', '--run', grand.run, '--to', '22', '--changed', 'Reviews']); + const grandUsed = Number(h.field(gret.out, 'BUDGET', 'used')); + expect(grandUsed).toBe(stepProjection(cal, 'estimate-tokens', '8')); + const childOwn = stepProjection(cal, 'estimate-tokens', '2'); + for (let i = 0; i < 2; i++) expect(h.sb(['reconcile', '--run', child.run, '--return', grand.returnFile]).code).toBe(0); + const cret = h.sb(['return', '--run', child.run, '--to', '22', '--changed', 'Reviews']); + expect(Number(h.field(cret.out, 'BUDGET', 'used'))).toBe(childOwn + grandUsed); + const top = h.sb(['reconcile', '--run', s.run, '--return', child.returnFile]); + expect(Number(h.field(top.out, 'BUDGET', 'used'))).toBe(childOwn + grandUsed); + const again = h.sb(['reconcile', '--run', s.run, '--return', child.returnFile]); + expect(Number(h.field(again.out, 'BUDGET', 'used'))).toBe(childOwn + grandUsed); + }); + + test("Claude Code meter: a grandchild launched by a child joins every ancestor's meter, so the root sees its usage", () => { + const h = fresh(); + const project = path.join(h.root, 'claude', 'projects', '-tmp-repo'); + fs.mkdirSync(project, { recursive: true }); + fs.writeFileSync(path.join(project, `${FIXTURE.sessions.parent}.jsonl`), FIXTURE.journals.parent.map((l: string) => l + '\n').join('')); + const s = start(h, ['--token-cap', '400M', '--session', FIXTURE.sessions.parent]); + const child = handOff(h, s.run, '1', '22', null, ['--session', 'child-session-1']); + expect(check(h, child.run, '1', '2').code).toBe(0); + expect(h.field(check(h, child.run, '6', '7').out, 'BUDGET', 'verdict')).toBe('delegate'); + const ho = h.sb(['handoff', '--run', child.run, '--from', '7', '--to', '22', '--base', 'origin/main']); + const brief = ho.out.match(/^HANDOFF: (\S+)/m)![1]!; + const fake = path.join(h.root, 'fake-claude'); + fs.writeFileSync(fake, '#!/bin/sh\nexit 0\n', { mode: 0o755 }); + const launched = h.sb(['launch', '--in', brief], { GSTACK_CLAUDE_BIN: fake }); + expect(launched.code, launched.out + launched.err).toBe(0); + const session = h.field(launched.out, 'CHILD', 'session'); + expect(h.ledger(child.ledger).meter.sessions).toContain(session); + expect(h.ledger(s.ledger).meter.sessions).toEqual([FIXTURE.sessions.parent, session]); + }); +}); + +describe('a child returns complete only when its own work is finished', () => { + test('a child that returns before reconciling its grandchild returns incomplete and names the handoff', () => { + const h = fresh(); + const s = start(h, ['--token-cap', '200M', '--reading', '98000']); + const child = handOff(h, s.run, '1', '22', 200_000); + expect(check(h, child.run, '1', '2', ['--reading', '600000']).code).toBe(0); + expect(h.field(check(h, child.run, '6', '7', ['--reading', '1500000']).out, 'BUDGET', 'verdict')).toBe('delegate'); + const grand = handOff(h, child.run, '7', '22', 1_500_000); + const ret = h.sb(['return', '--run', child.run, '--to', '22', '--changed', 'Reviews', '--reading', '1600000']); + expect(ret.code, ret.out + ret.err).toBe(0); + expect(ret.out).toMatch(/^RETURN: handoff=[0-9a-f]{64} status=incomplete steps=1-22/); + expect(ret.out).toContain(`UNRECONCILED: handoff=${h.ledger(child.ledger).handoffs[0].sha} steps=7-22`); + expect(ret.out).toContain(grand.returnFile); + const top = h.sb(['reconcile', '--run', s.run, '--return', child.returnFile, '--reading', '1700000']); + expect(top.code).toBe(3); + expect(top.out).toContain('child=incomplete'); + }); + + test('a child with an ADMIT remainder it never re-checked returns incomplete with a DROPPED line', () => { + const h = fresh(); + const s = start(h, ['--token-cap', '900M', '--reading', '0', '--reserve', '0']); + const subCap = K.stop_overhead + Math.floor(1.5 * K.per_dispatch); + const ho = h.sb(['handoff', '--run', s.run, '--from', '7', '--to', '11.5', '--base', 'origin/main', '--reading', '100000', '--sub-cap', String(subCap)]); + expect(ho.code, ho.out + ho.err).toBe(0); + const child = h.field(h.sb(['verify-handoff', '--in', ho.out.match(/^HANDOFF: (\S+)/m)![1]!, '--reading', '100000']).out, 'BUDGET_RUN', 'id'); + const b = check(h, child, '9', 'dispatch:specialist,specialist,red-team', ['--reading', '100000']); + expect(b.code, b.out + b.err).toBe(0); + expect(b.out).toMatch(/^ADMIT: 1 of 3 \(remaining=specialist,red-team\)/m); + const ret = h.sb(['return', '--run', child, '--to', '11.5', '--reading', '100000']); + expect(ret.out).toMatch(/^RETURN: handoff=[0-9a-f]{64} status=incomplete steps=7-11.5/); + expect(ret.out).toMatch(/^DROPPED: step=9 reservation=\S+ roles=specialist,red-team/m); + expect(ret.out.trim().split('\n').at(-1)).toMatch(/^GSTACK_RESULT: skill=ship status=incomplete run=/); + }); + + test("a grandchild handoff outside the child's own Steps refuses and reserves nothing", () => { + const h = fresh(); + const s = start(h, ['--token-cap', '200M', '--reading', '98000']); + const child = handOff(h, s.run, '7', '11.5', 200_000); + for (const [from, to] of [['7', '22'], ['6', '11.5']]) { + const r = h.sb(['handoff', '--run', child.run, '--from', from!, '--to', to!, '--base', 'origin/main', '--reading', '300000']); + expect(r.code).toBe(2); + expect(r.err).toContain(`--from ${from} --to ${to} is outside this child's Steps 7-11.5`); + } + expect(h.ledger(child.ledger).reservations.filter((r: any) => r.kind === 'child')).toHaveLength(0); + }); +}); diff --git a/test/ship-budget.test.ts b/test/ship-budget.test.ts index a138a66b1..1f4aeb023 100644 --- a/test/ship-budget.test.ts +++ b/test/ship-budget.test.ts @@ -54,14 +54,14 @@ describe('parsing and the admission rule', () => { }); describe('reservations', () => { - test('a parallel batch is reserved as one sum and refused whole when it would overshoot', () => { + test('a parallel batch is reserved as one sum; when it would overshoot only the prefix that fits is admitted', () => { const h = fresh(); const cap = 2 * K.per_dispatch + K.stop_overhead + 1_000_000; const s = started(h, ['--token-cap', String(cap), '--reading', '0', '--reserve', '0']); const over = h.sb(['check', '--run', s.run, '--step', '9', '--next', 'dispatch:specialist,red-team,fix-check', '--reading', '0']); - expect(over.code).toBe(3); - expect(over.out).toContain('(BUDGET_CAP_REACHED)'); - expect(h.ledger(s.ledger).reservations.filter((r: any) => r.kind === 'dispatch')).toHaveLength(0); + expect(over.code).toBe(0); + expect(over.out).toContain('ADMIT: 2 of 3 (remaining=fix-check)'); + expect(h.ledger(s.ledger).reservations.filter((r: any) => r.kind === 'dispatch').map((r: any) => [r.tokens, r.roles])).toEqual([[2 * K.per_dispatch, ['specialist', 'red-team']]]); }); test('#31 replay: a 25M cap stops before the dispatch that would cross it', () => { @@ -176,10 +176,11 @@ describe('meters and exit codes', () => { }); describe('delegation', () => { - test('start from a long-lived context (call >= 100k) delegates the whole /ship', () => { + test('start from a long-lived context (100k or more above the host floor) delegates the whole /ship', () => { const h = fresh(); - const s = started(h, ['--token-cap', '60M', '--reading', '9000000', '--prior-reading', '8880000']); - expect(h.field(s.r.out, 'BUDGET', 'call')).toBe('120000'); + const s = started(h, ['--token-cap', '60M', '--reading', '9000000', '--prior-reading', '8740000']); + expect(h.field(s.r.out, 'BUDGET', 'call')).toBe('260000'); + expect(h.field(s.r.out, 'BUDGET', 'above_floor')).toBe('110000'); expect(h.field(s.r.out, 'BUDGET', 'verdict')).toBe('delegate'); expect(s.r.out).toContain('--from 1 --to 22'); }); @@ -202,7 +203,7 @@ describe('delegation', () => { test('full chain: delegate, handoff, verify, child checks, 40-line return, idempotent reconcile, close', () => { const h = fresh(); const s = started(h, ['--token-cap', '80M', '--reading', '1000000']); - const d = h.sb(['check', '--run', s.run, '--step', '6', '--next', '7', '--reading', '1200000', '--prior-reading', '1000000']); + const d = h.sb(['check', '--run', s.run, '--step', '6', '--next', '7', '--reading', '1200000', '--prior-reading', '800000']); expect(h.field(d.out, 'BUDGET', 'verdict')).toBe('delegate'); const ho = h.sb(['handoff', '--run', s.run, '--from', '7', '--to', '11.5', '--base', 'origin/main', '--reading', '1300000']); expect(ho.code).toBe(0); @@ -221,7 +222,9 @@ describe('delegation', () => { expect(h.field(v.out, 'BUDGET', 'used')).toBe('100000'); const cc = h.sb(['check', '--run', child, '--step', '8', '--next', '9', '--reading', '5000000']); expect(cc.code).toBe(0); - expect(h.sb(['check', '--run', child, '--step', '11', '--next', '12', '--reading', '6000000']).out).toContain('a child never delegates'); + const past = h.sb(['check', '--run', child, '--step', '11', '--next', '12', '--reading', '6000000']); + expect(past.code).toBe(0); + expect(past.out).toMatch(/^CONTEXT_HIGH: .*Step 12 is past this child's Steps 7-11\.5/m); const ret = h.sb(['return', '--run', child, '--to', '11.5', '--changed', 'Reviews,Checks', '--reading', '9000000']); expect(ret.code).toBe(0); expect(ret.out.trim().split('\n').length).toBeLessThanOrEqual(40); diff --git a/test/ship-queue-train-logic.test.ts b/test/ship-queue-train-logic.test.ts index 15bae88b2..284ac052d 100644 --- a/test/ship-queue-train-logic.test.ts +++ b/test/ship-queue-train-logic.test.ts @@ -28,17 +28,17 @@ describe('ordering and fill', () => { const o = orderSlots([slot('4', false, '2026-10-18T01:00:00Z'), slot('3', true, '2026-10-18T03:00:00Z'), slot('2', false, '2026-10-18T00:00:00Z'), slot('1', true, '2026-10-18T02:00:00Z')], 5); expect(o.train.map(s => s.pr)).toEqual(['1', '3', '2', '4']); expect(o.held).toEqual([]); - expect(o.split).toBe(false); }); - test('when as many unscreened PRs wait as fill a train, they form their own train; over-size PRs are held in order', () => { - const unscreened = ['5', '6', '7'].map((pr, i) => slot(pr, false, `2026-10-18T0${i}:00:00Z`)); - const split = orderSlots([slot('9', true, '2026-10-18T05:00:00Z'), ...unscreened], 3); - expect(split.split).toBe(true); - expect(split.train.map(s => s.pr)).toEqual(['9']); - expect(split.held.map(s => s.pr)).toEqual(['5', '6', '7']); + test('unscreened PRs fill the seats a screened PR leaves, however many wait; over-size PRs are held in order', () => { + const unscreened = ['5', '6', '7', '10', '11'].map((pr, i) => slot(pr, false, `2026-10-18T0${i}:00:00Z`)); + const mixed = orderSlots([slot('9', true, '2026-10-18T05:00:00Z'), ...unscreened], 5); + expect(mixed.train.map(s => s.pr)).toEqual(['9', '5', '6', '7', '10']); + expect(mixed.held.map(s => s.pr)).toEqual(['11']); + const atThree = orderSlots([slot('9', true, '2026-10-18T05:00:00Z'), ...unscreened.slice(0, 3)], 3); + expect([atThree.train.map(s => s.pr), atThree.held.map(s => s.pr)]).toEqual([['9', '5', '6'], ['7']]); + expect(orderSlots(unscreened, 3).train.map(s => s.pr)).toEqual(['5', '6', '7']); const over = orderSlots([slot('8', true, '2026-10-18T06:00:00Z'), slot('9', true, '2026-10-18T05:00:00Z'), unscreened[0]!], 2); - expect(over.split).toBe(false); expect(over.train.map(s => s.pr)).toEqual(['9', '8']); expect(over.held.map(s => s.pr)).toEqual(['5']); }); @@ -68,7 +68,7 @@ describe('ordering and fill', () => { expect(orderSlots([docs, catalog], 5, order as string[]).train.map(s => s.pr)).toEqual(['3343', '3337']); const screened = slot('9', true, '2026-10-22T20:00:00Z'); const over = orderSlots([docs, catalog, screened], 2, ['3343', '3337', '9']); - expect([over.train.map(s => s.pr), over.held.map(s => s.pr), over.split]).toEqual([['3343', '3337'], ['9'], false]); + expect([over.train.map(s => s.pr), over.held.map(s => s.pr)]).toEqual([['3343', '3337'], ['9']]); expect(parseOrder(undefined, pins)).toBeUndefined(); for (const bad of ['3343', '3343,3337,3337', '3343,3337,1', '3343,x']) expect(parseOrder(bad, pins)).toEqual({ error: `--order must list every PR in --prs exactly once (--prs has #3337,#3343; got ${bad})` }); expect(prsArgs([{ pr: '3343', head: SHA }, { pr: '3337', head: SHA }], true)).toBe(`--prs 3343@${SHA},3337@${SHA} --order 3343,3337`); @@ -194,15 +194,32 @@ describe('screen recommendation', () => { expect(skillOwnCases(['a', 'b', 'c'], { a: ['review/**'], b: ['lib/**'], c: ['review/SKILL.md'] }, ['review'])).toEqual(['a', 'c']); }); - test("gstack's selector on gstack's own tree: a skill template change recommends --screen, a docs change --no-screen", async () => { + test("gstack's selector on gstack's own tree: only a change that restores every case recommends --screen", async () => { const has = (dir: string) => fs.existsSync(path.join(ROOT, dir, 'SKILL.md.tmpl')); const skill = await gstackPaidSelection(ROOT, 'HEAD', ['review/SKILL.md.tmpl'], has); - expect(skill.recommend).toBe('screen'); + expect(skill.recommend).toBe('no-screen'); expect(skill.lines[0]).toMatch(/^SCREEN: selection \d+ e2e case\(s\) \+ \d+ judge\(s\)/); - expect(skill.lines[1]).toMatch(/^SCREEN: skill's own cases \S+ → recommend --screen$/); + expect(skill.lines[1]).toMatch(/^SCREEN: skill's own cases \S+$/); + expect(skill.lines[2]).toMatch(/^SCREEN: selector mode (pr|dependents): the train gate's full census covers these cases → recommend --no-screen$/); + const shared = await gstackPaidSelection(ROOT, 'HEAD', ['bun.lock'], has); + expect(shared.recommend).toBe('screen'); + expect(shared.lines[2]).toMatch(/^SCREEN: selector mode full-fallback \(.*bun\.lock.*\) → recommend --screen$/); + expect((await gstackPaidSelection(ROOT, 'HEAD', ['review/SKILL.md.tmpl', 'bun.lock'], has)).recommend).toBe('screen'); const docs = await gstackPaidSelection(ROOT, 'HEAD', ['docs/ship-policy.md'], has); expect(docs.recommend).toBe('no-screen'); }, 60_000); + + // Generated by /ship Step 7 coverage audit (GSTA-165 1a). + // Value: protects=a diff the paid selector cannot select (needs full validation) recommends --screen with the full gate selection; + // fails_when=the selector-error branch stops forcing everyCase, so an unselectable diff rides the train unscreened; + // why_new=the existing selector test covers pr, dependents and full-fallback modes only, never the throw; + // seam=none; no_claim=dispatch of the screen itself (pure selection on gstack's own tree; no eval-bg run) + test("gstack's selector on gstack's own tree: a diff it cannot select recommends --screen with the full gate selection", async () => { + const r = await gstackPaidSelection(ROOT, 'HEAD', ['unregistered/nested/SKILL.md'], () => false); + expect(r.recommend).toBe('screen'); + expect(r.lines[0]).toMatch(/^SCREEN: selection \d+ e2e case\(s\) \+ \d+ judge\(s\) \(the PR profile needs full validation \(.*requires full validation.*\): the screen is the full gate selection\)$/); + expect(r.lines[2]).toBe('SCREEN: the selector could not select → recommend --screen'); + }, 60_000); }); describe('the train policy block', () => { diff --git a/test/ship-queue-train.test.ts b/test/ship-queue-train.test.ts index 15e24c277..ff77594b0 100644 --- a/test/ship-queue-train.test.ts +++ b/test/ship-queue-train.test.ts @@ -564,7 +564,7 @@ describe('train build', () => { expect(dispatches(fx, 'free-tests.yml').length).toBe(4); }, 180_000); - test('ordering: screened PRs first in READY order; enough unscreened PRs wait for their own train', () => { + test('ordering: screened PRs first in READY order; unscreened PRs fill the remaining seats', () => { const fx = make({ ...TRAIN_POLICY, train: { ...TRAIN_BLOCK, max_size: 2 } }); const h = ['1', '2', '3'].map(pr => openPr(fx, pr)); readyReceipt(fx, '1', h[0]!, { at: '2026-10-18T08:00:00.000Z' }); @@ -572,8 +572,8 @@ describe('train build', () => { readyReceipt(fx, '3', h[2]!, { at: '2026-10-18T09:00:00.000Z', screen: true }); const r = queue(fx, ['train', 'build', '--prs', `1@${h[0]},2@${h[1]},3@${h[2]}`, '--dry-run']); expect(r.status).toBe(EXIT.ok); - expect(line(r.out, 'ORDER:')).toBe('ORDER: 1:#3(screened)'); - expect(line(r.out, 'HELD:')).toBe('HELD: #2,#1 (unscreened PRs form their own train)'); + expect(line(r.out, 'ORDER:')).toBe('ORDER: 1:#3(screened) 2:#2(unscreened)'); + expect(line(r.out, 'HELD:')).toBe('HELD: #1 (over the train size 2)'); const wide = make(); const w = ['1', '2', '3'].map(pr => openPr(wide, pr)); readyReceipt(wide, '1', w[0]!, { at: '2026-10-18T08:00:00.000Z' }); diff --git a/test/size-ledger/ship/gst-101-ship-budget-calibration.json b/test/size-ledger/ship/gst-101-ship-budget-calibration.json index b07b26cdb..87614af86 100644 --- a/test/size-ledger/ship/gst-101-ship-budget-calibration.json +++ b/test/size-ledger/ship/gst-101-ship-budget-calibration.json @@ -1,4 +1,4 @@ { - "bytes": 146, - "why": "GST-101: budget-handoff names check --returned for reviewers admitted one at a time" + "bytes": 535, + "why": "GST-101: budget-handoff names check --returned for reviewers admitted one at a time; GSTA-167: a child never stops on the context rule and hands off after a completed step, and an ADMIT k of n batch re-checks its remaining readers" }