v1.91.79.0 feat(eval-plan): /eval-plan tier 1, lane-check, evidence verify, coordinator contract, owner brief, contributor-PR mode, banned terms (multi-agent wave PR E) (#3123)

* v1.91.79.0 feat(eval-plan): /eval-plan tier 1, lane-check, evidence verify, coordinator contract, owner brief, contributor-PR mode, banned terms (multi-agent wave PR E)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(spec,review): fail closed when the coordinator contract cannot be read; restore the omitted-Army sentence

The spec --execute spawn captures `gstack-autoplan contract` before piping
the archive, so a lane never starts without the contract; the C9 fixture
installs its gstack-like checkout (bin, lib, a contract stub) at every
host's global root. Review Step 5.8 keeps the pinned
'without claiming specialist coverage' wording.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-11 03:10:06 +00:00
1 parent ab79e55119
commit 5d647455f2
87 files changed
+4137 -762

No files matched your search

+9 -1
View File
@@ -38,8 +38,16 @@ import { skillCensus } from './helpers/skill-census';
* New-skill ratchet: previous ceiling 1,171 + ceil(92 / 4) = 1,194
* token-equivalents (4,776 bytes), leaving 4 bytes. Existing descriptions
* are unchanged.
* ref eval-plan addition on base 7552362 (2026-10-10)
* result pre-addition aggregate 4,776 bytes; eval-plan adds 10 bytes
* (its description is a `|` block scalar, counted as the other
* block-scalar skills are), yielding 4,786 bytes = 1,197
* token-equivalents including the root router alias.
* New-skill ratchet: previous ceiling 1,194 + ceil(10 / 4) = 1,197
* token-equivalents (4,788 bytes), leaving 2 bytes. Existing descriptions
* are unchanged.
*/
const CATALOG_BUDGET_TOKEN_EQUIVALENTS = 1_194;
const CATALOG_BUDGET_TOKEN_EQUIVALENTS = 1_197;
// Largest today: design-consultation at 229 bytes. A description that needs
// more than 260 bytes is a body paragraph, not a catalog entry.
+4
View File
@@ -94,6 +94,10 @@ const MODULE_SINKS = [
'lib/gbrain-supabase-provision.ts',
// consent-gated engine download (install verb): receipt before the fetch, fail-closed
'bin/gstack-design-detect.ts',
// Multi-agent wave E3: contributor-mode asks GitHub for the PR author's
// repository permission (`gh api .../collaborators/<user>/permission`); the
// author login and repo slug leave the machine, sink 'contributor-mode'.
'lib/contributor-mode.ts',
// The Memorable bridge hook: gstack-owned code that hands each prompt to a
// vendor CLI. hosts/ has no curl/fetch for the scanner to see, so the
// receipt wiring is pinned here explicitly.
+48 -47
View File
@@ -1,67 +1,68 @@
{
"_comment": "Context-budget ratchet ceilings (~tokens). Regenerate: bun test/helpers/capture-context-budget.ts. Headroom: alwaysOnTotal x1.05, eagerPerInvocation x1.1. Graded by test/context-budget-ratchet.test.ts via lib/context-bill.ts checkBudget.",
"alwaysOnTotal": 7283,
"alwaysOnTotal": 7396,
"eagerPerInvocation": {
"autoplan": 16899,
"benchmark": 7850,
"autoplan": 17055,
"benchmark": 7849,
"benchmark-models": 4231,
"browse": 8962,
"browse": 8961,
"browser-skills/hackernews-frontpage": 371,
"canary": 13522,
"canary": 13520,
"careful": 1328,
"codex": 15599,
"context-restore": 10996,
"context-save": 10589,
"codex": 15598,
"context-restore": 10995,
"context-save": 10588,
"cso": 4702,
"design-consultation": 16273,
"design-html": 14556,
"design-review": 35821,
"design-shotgun": 14981,
"design-consultation": 16277,
"design-html": 14555,
"design-review": 35819,
"design-shotgun": 14980,
"deslop-shared-libs": 3448,
"devex-review": 20206,
"devex-review": 20204,
"diagram": 4336,
"document-generate": 11810,
"document-release": 10174,
"document-generate": 11809,
"document-release": 10235,
"eval-plan": 9323,
"freeze": 1194,
"gstack": 4267,
"gstack": 4289,
"gstack-upgrade": 5793,
"guard": 1082,
"health": 11523,
"investigate": 12584,
"ios-clean": 8282,
"ios-design-review": 8478,
"ios-fix": 8171,
"ios-qa": 11744,
"ios-sync": 8297,
"land-and-deploy": 19005,
"landing-report": 8994,
"learn": 8709,
"health": 11521,
"investigate": 12583,
"ios-clean": 8280,
"ios-design-review": 8477,
"ios-fix": 8170,
"ios-qa": 11742,
"ios-sync": 8295,
"land-and-deploy": 19003,
"landing-report": 8993,
"learn": 8708,
"make-pdf": 5348,
"office-hours": 24915,
"open-gstack-browser": 4928,
"office-hours": 24914,
"open-gstack-browser": 4981,
"openclaw/skills/gstack-openclaw-ceo-review": 2764,
"openclaw/skills/gstack-openclaw-investigate": 1429,
"openclaw/skills/gstack-openclaw-office-hours": 4433,
"openclaw/skills/gstack-openclaw-retro": 2542,
"pair-agent": 11104,
"plan-ceo-review": 20885,
"plan-design-review": 22326,
"plan-devex-review": 17600,
"plan-eng-review": 13484,
"plan-tune": 14775,
"qa": 13498,
"qa-only": 11545,
"retro": 19034,
"review": 21044,
"scrape": 7355,
"setup-browser-cookies": 3575,
"setup-deploy": 11415,
"setup-gbrain": 15697,
"ship": 21164,
"skillify": 11649,
"spec": 14923,
"sync-gbrain": 15478,
"test-audit": 10335,
"pair-agent": 11156,
"plan-ceo-review": 20974,
"plan-design-review": 22321,
"plan-devex-review": 17595,
"plan-eng-review": 13479,
"plan-tune": 14773,
"qa": 13497,
"qa-only": 11543,
"retro": 19033,
"review": 21059,
"scrape": 7354,
"setup-browser-cookies": 3628,
"setup-deploy": 11413,
"setup-gbrain": 15696,
"ship": 21161,
"skillify": 11648,
"spec": 14922,
"sync-gbrain": 15477,
"test-audit": 10333,
"unfreeze": 457
}
}
+60 -60
View File
@@ -427,7 +427,7 @@ Routine authorization never waives those gates or their required user decisions.
**Routine work needs no confirmation:** include uncommitted changes, choose MICRO/PATCH
under Step 12, draft CHANGELOG and commits, mark completed TODOs and auto-fix findings.
When Step 7 coverage meets its target, report remaining gaps and verify generated
tests without another permission question. Step 15 commits those tests.
tests without asking again. Step 15 commits those tests.
**Route:** integrate (1–3) → test and review (4–11.5) → prepare the release
(12–15) → verify frozen content (16) → push and publish (17–21).
@@ -437,10 +437,10 @@ Steps 12, 17 and 19 prevent duplicate bumps, pushes and PRs, never verification.
### Keep state between steps
Keep one private Markdown **invocation record** outside the product tree and save
its absolute path. Use these headings so a paused run can resume:
its absolute path, with these headings so a paused run can resume:
- **Release:** versions, `BUMP_LEVEL`, reviewed tree and attempt counts.
- **Decisions:** each approval's finding, files and authorized action. Reuse it only
for that same scope; a repair never resets approvals or expands them.
for that same scope; a repair never resets or expands approvals.
- **Reviews:** handles, original start tokens, terminal states, outputs and queued fixes.
- **Checks:** command/label, result/counts, timestamp, log and consumed inputs.
- **Documentation:** candidate/id, attempts used, accepted hashes or named blocked exception.
@@ -462,18 +462,18 @@ permission to proceed. Follow the saved work list:
1. Start with Steps 1–21 in order, including 6.5, 11.5 and 14.5. Advance only after
the current item's gates clear.
2. Expand a repair into individual steps and insert them before the still-pending
work. This replaces the current item, whose actual result stays in the record.
work. This replaces the current item, whose result stays in the record.
Add its destination only if not already the next pending step.
3. For another repair, repeat rule 2 without discarding pending work.
The saved list takes precedence over ordinary next-step
sentences inside a repair. A range never adds unlisted steps.
The saved list outranks ordinary next-step sentences inside a repair.
A range never adds unlisted steps.
**Example:** Step 11 fixes insert `9 → 10 → 11` before 11.5. A further Step 9 fix
affecting 6–8 makes the list `5 → 6 → 7 → 8 → 9 → 10 → 11 → 11.5`.
The unchanged release steps follow. STOP and AskUserQuestion gates still apply during repairs.
Keep the same attempt counts throughout the invocation. A range ending at Step 14
does not enter Step 14.5. A range that includes Step 14.5 enters its existing audit
does not enter Step 14.5. A range including Step 14.5 enters its existing audit
decision, not an unconditional new launch; its initial-plus-ONE limit never resets.
Permitted repairs continue in this invocation without restarting /ship.
@@ -500,6 +500,7 @@ sections. Read a section in full before doing its step; do not work from memory.
| writing the CHANGELOG entry (Step 13) | `sections/changelog.md` |
| auditing docs before final commit/verification (Step 14.5), on every ship | `sections/documentation.md` |
| preparing the title and creating or updating the PR/MR with the verified documentation outcome (Steps 18-19) | `sections/pr-body.md` |
| handing the branch to a coordinator instead of opening a PR (--no-pr or an automation lane; Step 18) | `sections/handoff.md` |
---
@@ -551,7 +552,7 @@ branch name wherever the instructions say "the base branch" or `<default>`.
If the ask is App Store/TestFlight distribution, look for an `.xcodeproj`,
`.xcworkspace`, or Swift app product. Read `Package.swift` and its entrypoint to
distinguish an app from a library/CLI. If unclear, use AskUserQuestion to identify
distinguish an app from a library/CLI. If unclear, AskUserQuestion to identify
the target and wait before choosing a release path.
For a confirmed app, **STOP and Read
`sections/apple-release.md` relative to the installed `gstack-ship` SKILL.md directory FIRST**. Store distribution proceeds
@@ -563,10 +564,10 @@ repository-landing asks, including on Apple repos.
1. Save the current branch as `<branch-name>`. If on the base branch or the repo's default branch, **abort**: "You're on the base branch. Ship from a feature branch."
2. Run `git status` (never use `-uall`). Uncommitted changes are always included — no need to ask.
2. Run `git status` (never `-uall`). Uncommitted changes are always included; do not ask.
3. Run `git fetch origin <base>` before inspecting the diff. If fetch fails, STOP:
report the error and restore access before continuing. Then inspect
report the error and restore access first. Then inspect
`git diff origin/<base> --stat`, untracked files from status, and
`git log origin/<base>..HEAD --oneline`.
@@ -658,7 +659,7 @@ VERDICT: {CLEARED or NOT CLEARED} — {reason}
An outside review with status `unverified` or `unavailable` is missing coverage, never a pass.
For diffs >200 lines (`git diff origin/<base> --stat | tail -1`), recommend
`/plan-eng-review` or `/autoplan` for architecture review.
`/plan-eng-review` or `/autoplan`.
For Design Review: run `source <(~/.gemini/antigravity-cli/skills/gstack/bin/gstack-diff-scope <base> 2>/dev/null)`. If `SCOPE_FRONTEND=true` and no design review exists, mention: "Design Review not run — Step 9 includes the lite check; consider /design-review for a full visual audit."
@@ -669,16 +670,16 @@ Continue to Step 2 without asking; Step 9 applies the review gates.
## Step 2: Distribution Pipeline Check
Check distribution for new standalone artifacts (CLI binaries, packages, tools),
not web services with existing deployment.
not web services already deployed.
1. List candidate distribution paths:
```bash
git diff origin/<base> --diff-filter=A --name-only | grep -E '(^|/)(cmd/[^/]+/main\.go|bin/[^/]+|Cargo\.toml|setup\.py|package\.json)$' | head -5
```
Also inspect matching untracked files from Step 1's status. Read each match:
a new `package.json` or `Cargo.toml` alone does not establish a publishable
artifact. Also inspect existing manifests for newly declared binaries or
package exports. Apply the pipeline gate only when a new distributable is present.
Include matching untracked files from Step 1's status. Read each match:
a new `package.json` or `Cargo.toml` alone is not a publishable artifact;
also check existing manifests for newly declared binaries or package
exports. Gate only when a new distributable is present.
2. If new artifact detected, check for a release workflow:
```bash
@@ -696,7 +697,7 @@ not web services with existing deployment.
Ask for unknown targets, registries or access first; never invent credentials.
Recheck against the artifact and include the workflow in tests and review.
Do not publish a release during `/ship`.
5. Otherwise, continue without adding a pipeline.
5. Otherwise continue without a pipeline.
---
@@ -708,7 +709,7 @@ Merge the base ref fetched in Step 1 so tests and reviews cover the integrated c
git merge origin/<base> --no-edit
```
**If there are merge conflicts:** Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution decision, and wait for the answer before editing or continuing.
**Merge conflicts:** Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution, and wait for the answer before editing or continuing.
**If already up to date:** Continue silently.
@@ -746,7 +747,7 @@ repeat Step 2 (decisions included) on the merged content, then Step 4.
1. **Select the two reviews.** Run `~/.gemini/antigravity-cli/skills/gstack/bin/gstack-review-read`.
Select this invocation's final Step 9.4 record (`skill:"review"`, `via:"ship"`)
and Step 11 native record (`skill:"adversarial-review"`). Match each to its saved
handle, original token and source; reject outside-provider or older invocation records.
handle, original token and source; reject outside-provider or older records.
2. **Compare their content.** Require the native record's `review_binding.state`
to be `verified`. All three snapshots must match: its `wtree`, Step 9.4's
`review_binding.start_wtree` and `review_binding.end_wtree`. A mismatch or missing
@@ -755,7 +756,7 @@ repeat Step 2 (decisions included) on the merged content, then Step 4.
Never attach new tokens to old work.
3. **Preserve any QA exception.** A named probe-risk exception may leave Step 9.4's
root `wtree` absent; item 2 still compares its start/end snapshots. Matching content
does not mean the failed or unrun probes passed. Keep Step 9.4's incomplete flags
does not mean failed or unrun probes passed. Keep Step 9.4's incomplete flags
and the user's exception.
4. **Save the evidence.** Save both records and matching **reviewed tree** for
Step 16. Continue to Step 12.
@@ -792,9 +793,8 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
- **MICRO**: <50 lines, trivial tweaks/config. **PATCH**: 50+ lines, no feature signal.
- **MINOR**: ask for any feature signal (new route/page, migration, module) or 500+ lines.
**MAJOR**: ask for milestones or breaking changes (`ship: bump patch` never asks).
Use AskUserQuestion: recommended
level with rationale, smaller level, or cancel. Wait; cancel stops before release
writes or push and preserves existing work.
AskUserQuestion: recommended level with rationale, smaller level, or cancel.
Wait; cancel stops before release writes or push and preserves existing work.
Save lowercase `BUMP_LEVEL`. A claimed version may move the next available number
forward, but cannot change the chosen MICRO/PATCH/MINOR/MAJOR level.
@@ -806,7 +806,7 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
**Qualify first:** require successful utility output and a nonempty valid version.
`offline:false` qualifies; `offline:true` qualifies only with `fallback:"git"`.
Offline output without that fallback, failure, malformed output or an empty version
is unusable, even if it contains a version-looking string.
is unusable, even when it contains a version-looking string.
- **Usable candidate:** print warnings and claimed queue. FRESH sets `NEW_VERSION=CANDIDATE_VERSION`.
ALREADY_BUMPED compares it with `currentVersion`: if different, ask to rebump
@@ -821,7 +821,7 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
```bash
bun run ~/.gemini/antigravity-cli/skills/gstack/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest
```
The CLI validates `MAJOR.MINOR.PATCH.MICRO` (or pinned 3-digit semver) and writes
The CLI validates `MAJOR.MINOR.PATCH.MICRO` (or pinned 3-digit semver), writes
VERSION, the manifest (`--package-json-path` → `.gstack/package-json-path` →
`./package.json`) and existing npm lockfiles, never creating one, with the 3-digit
translation (`1.67.0.0` → `1.67.0`); VERSION is authoritative. Exit 3 is a half-write:
@@ -837,7 +837,7 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
```bash
~/.gemini/antigravity-cli/skills/gstack/bin/gstack-decision-log '{"decision":"Ship NEW_VERSION (BUMP_LEVEL)","rationale":"WHY","scope":"repo","source":"skill","confidence":9}' 2>/dev/null || true
```
Substitute `NEW_VERSION`, `BUMP_LEVEL` and a one-line `WHY` (scope or breaking-change signal); best-effort, non-blocking.
Substitute `NEW_VERSION`, `BUMP_LEVEL` and a one-line `WHY` (scope or breaking-change signal); best-effort.
> **STOP.** Before writing the CHANGELOG entry (Step 13), Read `sections/changelog.md` relative to the installed `gstack-ship` SKILL.md directory and execute it
> in full. Do not work from memory — that section is the source of truth for this step.
@@ -858,7 +858,7 @@ at the bottom. If disorganized, ask: A) Reorganize preserving all content
- Step 2: add the approved distribution follow-up as P1 with the missing pipeline and affected artifact.
- Step 8: add each approved P1 plan deferral with `Deferred from plan: {plan file path}` and the missing work.
- Step 5: retain P0 test-failure entries already written; deduplicate by failure and source, adding missing approved entries with error output and branch.
Never turn dropped scope into TODOs or invent unapproved follow-ups. Reuse matching existing entries rather than duplicating them.
Never turn dropped scope into TODOs or invent unapproved follow-ups. Reuse matching entries instead of duplicating them.
**4. Detect completed TODOs:** Compare titles, files and behavior with
`git diff origin/<base>`, untracked files and `git log origin/<base>..HEAD --oneline`.
@@ -866,9 +866,9 @@ Move proven completions to `## Completed` with `**Completed:** vX.Y.Z (YYYY-MM-D
leave uncertain items open.
**5. Save the summary:** Report additions, deferrals, completions, remaining count and
creation/reorganization. If creation was declined or a write failed, warn and retain
unsaved follow-ups in Step 19's PR summary. Never claim they were saved;
TODO write failures are non-blocking.
creation/reorganization. If creation was declined or a write failed, warn and carry
unsaved follow-ups in Step 19's PR summary; never claim they were saved. TODO
write failures are non-blocking.
---
@@ -887,9 +887,9 @@ Make bisectable commits; if already committed, continue to Step 16. Never create
1. Group changes with their tests, config/routes, views and Step 14.5 docs.
Migrations may stand alone or accompany their model.
Under 50 lines across fewer than 4 files may use one commit.
Under 50 lines across fewer than 4 files may be one commit.
2. Order dependencies first: infrastructure → models/services → controllers/views.
Each commit must work independently, without broken imports or missing code.
Each commit must work on its own, without broken imports or missing code.
Group VERSION + CHANGELOG + TODOS.md after the feature commits.
3. Use `<type>: <summary>` (feat/fix/chore/refactor/docs) and a brief body.
Only the final VERSION/CHANGELOG commit gets the release version and co-author
@@ -912,7 +912,7 @@ EOF
Run stages 1–5 in order; recovery notes below name where to resume. Content
changes during or after verification restart at stage 1 and complete all five
stages before Step 17. Content-preserving commits keep valid evidence.
stages before Step 17. Content-preserving commits keep evidence valid.
### 1. Finish writers and prepare outputs
@@ -925,7 +925,7 @@ files and CI. Run them and save results. If none exists, record not applicable a
the inspected sources. A missing prerequisite or failed build stops shipping:
report **Build failed or prerequisite missing**, with the command, error and needed
repair. Never invent a substitute command.
**If blocked:** Repair the prerequisite or build, then repeat stage 1. After it passes, continue
**If blocked:** Repair it, then repeat stage 1. After it passes, continue
to stage 2; treat any content repair as a behavioral change there.
### 2. Choose the change route
@@ -965,8 +965,7 @@ Report changed inputs, blockers and attempts used:
- **An attempt remains, with changed inputs or an available repair:** insert
`14.5 → 15 → 16` before Step 17. Use Blocked recovery with the existing count.
Validate the outcome before Step 15,
then restart Step 16 stage 1 to regenerate and compare again.
Validate the outcome before Step 15, then restart Step 16 stage 1.
- **Otherwise:** STOP unless the user accepts
the specific named documentation risk and all unwaivable gates clear, under
Step 14.5's Blocked recovery rules. Unchanged approved content goes to stage 4;
@@ -977,7 +976,7 @@ Never run a third audit. Child return is not acceptance.
### 4. Verify the frozen candidate
Freeze inputs through verification and push. Run declared docs/link/generated-file
checks; report unavailable checks.
checks; report any that are unavailable.
**Reuse a check when its inputs match.** Compare hashes or complete bytes of its
saved and current consumed files, fixtures, dependencies and execution parameters.
@@ -990,8 +989,8 @@ command, result/counts, timestamp and log, never resample. Mandatory reviews sti
`--allow-paths` exempts only release metadata. A `package.json` version-only edit
can qualify; scripts, dependencies and runtime configuration require live tests.
Uncertain edits cannot be exempted. Docs, TODO edits, new/generated tests and fixes
make evidence STALE even without a new code review. Use this example only after
confirming that every allowed edit is release metadata:
make evidence STALE even without a new code review. Use this example only once
every allowed edit is release metadata:
```bash
~/.gemini/antigravity-cli/skills/gstack/bin/gstack-evidence check --label tests --expect-cmd '<tests>' --label vitest --expect-cmd '<vitest>' --max-age 24 --allow-paths CHANGELOG.md,VERSION,package.json,agents-digest/gstack-AGENTS.md
@@ -1017,7 +1016,7 @@ keeps its existing audit count; it does not authorize a third attempt.
### 5. Report, then push
Commit only approved, verified release changes left uncommitted after Step 15,
including generated outputs; use its grouping rules and never create an empty commit.
including generated outputs; use its grouping rules; never create an empty commit.
Preserve unrelated user files.
Paste build/docs/test results. Reuse waivers only for the same verified
@@ -1031,8 +1030,8 @@ its records:
`reason` and logger-stamped `actor`. If there are none, the coverage line
(`none detected (...)`, `partial (...)` or `UNAVAILABLE — ...`).
- Each test lane's `tests_ran` (N, ZERO-RUN or `count unavailable`).
Write this report in the auditor's posture: describe this session's own gate edits
as evidence for the reader to examine, not as positions to defend; a kept gate
Write this report in the auditor's posture: this session's own gate edits are
evidence for the reader to examine, not positions to defend; a kept gate
states its reason and nothing more. Otherwise continue to Step 17.
---
@@ -1079,7 +1078,7 @@ Branch on the echoed values:
`pre-push.local`; otherwise request manual integration. Dangling
symlinks are unmanaged. Never overwrite either policy.
2. **`REDACT_PREPUSH` not true AND `PREPUSH_PROMPTED: no`** — one-time
offer (fires once EVER, machine-wide). AskUserQuestion:
offer (once ever, machine-wide). AskUserQuestion:
> gstack can install a per-repo git pre-push hook that blocks pushes
> containing credentials (API keys, tokens, private keys). It's a
@@ -1093,16 +1092,15 @@ Branch on the echoed values:
If A: run `~/.gemini/antigravity-cli/skills/gstack/bin/gstack-config set redact_prepush_hook true`
then re-run the block and apply the same directory and unmanaged-hook rules above.
If B: run `~/.gemini/antigravity-cli/skills/gstack/bin/gstack-config set redact_prepush_hook false`.
ALWAYS (after either answer, but NOT if the question itself failed to
render — a failed AskUserQuestion must re-offer next time):
ALWAYS after either answer (never after a failed question; it must re-offer
next time):
```bash
GSTACK_STATE_ROOT=$(~/.gemini/antigravity-cli/skills/gstack/bin/gstack-paths --get GSTACK_STATE_ROOT); : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
touch "$GSTACK_STATE_ROOT/.redact-prepush-prompted"
```
3. **Declined earlier** — continue
without comment.
3. **Declined earlier** — continue without comment.
**Open gate findings against an existing ready PR/MR:** when Step 9 left any gate
**Open gate findings against an existing ready PR/MR:** when Step 9 left a gate
finding `open`, look up an open PR/MR for `<branch-name>` before pushing
(`gh pr list --head <branch-name> --state open --json number,isDraft`; GitLab
`glab mr list --source-branch <branch-name> --output json`, reading `draft`). A lookup
@@ -1116,7 +1114,7 @@ failure STOPs here. If one exists and is not a draft:
the `Gate edits: N open` banner. Record B in the invocation record as a
human-authorised publication; Step 19 reads it. Never convert a ready PR to a draft.
A new PR, or an existing draft, needs no question: Step 19 creates or keeps the draft.
A new PR or an existing draft needs no question: Step 19 creates or keeps the draft.
**Idempotency check:** Check if the branch is already pushed and up to date.
@@ -1132,7 +1130,7 @@ echo "LOCAL: $LOCAL REMOTE: $REMOTE"
[ "$LOCAL" = "$REMOTE" ] && echo "ALREADY_PUSHED" || echo "PUSH_NEEDED"
```
If `ALREADY_PUSHED`, skip the push but continue to Step 18. Otherwise push with upstream tracking:
`ALREADY_PUSHED`: skip the push, continue to Step 18. Otherwise push with upstream tracking:
```bash
git push -u origin <branch-name>
@@ -1142,9 +1140,8 @@ git push -u origin <branch-name>
- **Non-fast-forward push:** fetch and inspect the remote, then merge under Step 3's
conflict rules. Run Steps 5–16 before returning to Step 17. Never rewrite history.
- **Authentication, hook or network failure:** repair the cause, then repeat Step 16
even if content is unchanged before returning to Step 17. Never bypass failed guards.
Never force-push.
Only a successful push or verified `ALREADY_PUSHED` proceeds.
even with unchanged content before returning to Step 17. Never bypass failed guards.
Never force-push. Only a successful push or verified `ALREADY_PUSHED` proceeds.
Continue to Step 18. No documentation writer runs after push.
@@ -1157,19 +1154,23 @@ First look up open PRs/MRs for `<branch-name>` on the detected platform:
- GitHub: `gh pr list --head <branch-name> --state open --json number,title,url`
- GitLab: `glab mr list --source-branch <branch-name> --output json` (defaults to open).
A successful empty array means new; one match supplies the existing title/identity.
An empty array means new; one match supplies the existing title/identity.
Lookup failure or ambiguous matches **STOP** for resolution, never mean no PR.
Save the result for Step 19's recheck.
The pr-body section prepares the title from that result, then publishes.
The pr-body section prepares the title from that result, then publishes;
`--no-pr` or an automation lane takes the handoff section.
> **STOP.** Before preparing the title and creating or updating the PR/MR with the verified documentation outcome (Steps 18-19), Read `sections/pr-body.md` relative to the installed `gstack-ship` SKILL.md directory and execute it
> in full. Do not work from memory — that section is the source of truth for this step.
> **STOP.** Before handing the branch to a coordinator instead of opening a PR (--no-pr or an automation lane; Step 18), Read `sections/handoff.md` relative to the installed `gstack-ship` SKILL.md directory and execute it
> in full. Do not work from memory — that section is the source of truth for this step.
## Step 20: Persist ship metrics
Log metrics for `/retro` through `gstack-review-log`; it handles project/branch paths,
JSON validation, storage and sync. It takes **no path argument**; do not build one.
JSON validation, storage and sync and takes **no path argument**.
```bash
~/.gemini/antigravity-cli/skills/gstack/bin/gstack-review-log '{"skill":"ship","timestamp":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'","coverage_pct":COVERAGE_PCT,"coverage_schema":2,"coverage_pct_value":COVERAGE_PCT_VALUE,"weak_gaps":WEAK_GAPS,"tests_extended":TESTS_EXTENDED,"tests_rejected":TESTS_REJECTED,"regression_proof":REGRESSION_PROOF,"plan_items_total":PLAN_TOTAL,"plan_items_done":PLAN_DONE,"verification_result":"VERIFY_RESULT","version":"VERSION","branch":"'"$(git rev-parse --abbrev-ref HEAD)"'"}'
@@ -1185,7 +1186,7 @@ Substitute from earlier steps:
- **VERIFY_RESULT**: "pass", "fail", or "skipped", set after Step 9 executes Step 8.1's verification list
- **VERSION**: `NEW_VERSION` from Step 12; `null` (unquoted) under NO_VERSION
The shell supplies the branch. Run this automatically, without confirmation.
The shell supplies the branch. Run it without confirmation.
---
@@ -1206,15 +1207,14 @@ if [ ! -f "$_NUDGE_MARKER" ] && [ "$_QT" = "false" ]; then
fi
```
The marker or enabled question_tuning suppresses it. To re-enable, remove
`$GSTACK_STATE_ROOT/.plan-tune-nudge-shown` before the next ship.
The marker or enabled question_tuning suppresses it; remove the marker to re-enable.
---
## Section self-check (before you finish)
List the applicable Section index entries and confirm each Read. If you worked from
memory, STOP, Read the section and redo that step. Use `gstack-version-bump`, never
memory, STOP, Read the section and redo that step. Use `gstack-version-bump`; never
hand-roll VERSION/package.json writes.
---
+60 -60
View File
@@ -432,7 +432,7 @@ Routine authorization never waives those gates or their required user decisions.
**Routine work needs no confirmation:** include uncommitted changes, choose MICRO/PATCH
under Step 12, draft CHANGELOG and commits, mark completed TODOs and auto-fix findings.
When Step 7 coverage meets its target, report remaining gaps and verify generated
tests without another permission question. Step 15 commits those tests.
tests without asking again. Step 15 commits those tests.
**Route:** integrate (1–3) → test and review (4–11.5) → prepare the release
(12–15) → verify frozen content (16) → push and publish (17–21).
@@ -442,10 +442,10 @@ Steps 12, 17 and 19 prevent duplicate bumps, pushes and PRs, never verification.
### Keep state between steps
Keep one private Markdown **invocation record** outside the product tree and save
its absolute path. Use these headings so a paused run can resume:
its absolute path, with these headings so a paused run can resume:
- **Release:** versions, `BUMP_LEVEL`, reviewed tree and attempt counts.
- **Decisions:** each approval's finding, files and authorized action. Reuse it only
for that same scope; a repair never resets approvals or expands them.
for that same scope; a repair never resets or expands approvals.
- **Reviews:** handles, original start tokens, terminal states, outputs and queued fixes.
- **Checks:** command/label, result/counts, timestamp, log and consumed inputs.
- **Documentation:** candidate/id, attempts used, accepted hashes or named blocked exception.
@@ -467,18 +467,18 @@ permission to proceed. Follow the saved work list:
1. Start with Steps 1–21 in order, including 6.5, 11.5 and 14.5. Advance only after
the current item's gates clear.
2. Expand a repair into individual steps and insert them before the still-pending
work. This replaces the current item, whose actual result stays in the record.
work. This replaces the current item, whose result stays in the record.
Add its destination only if not already the next pending step.
3. For another repair, repeat rule 2 without discarding pending work.
The saved list takes precedence over ordinary next-step
sentences inside a repair. A range never adds unlisted steps.
The saved list outranks ordinary next-step sentences inside a repair.
A range never adds unlisted steps.
**Example:** Step 11 fixes insert `9 → 10 → 11` before 11.5. A further Step 9 fix
affecting 6–8 makes the list `5 → 6 → 7 → 8 → 9 → 10 → 11 → 11.5`.
The unchanged release steps follow. STOP and AskUserQuestion gates still apply during repairs.
Keep the same attempt counts throughout the invocation. A range ending at Step 14
does not enter Step 14.5. A range that includes Step 14.5 enters its existing audit
does not enter Step 14.5. A range including Step 14.5 enters its existing audit
decision, not an unconditional new launch; its initial-plus-ONE limit never resets.
Permitted repairs continue in this invocation without restarting /ship.
@@ -505,6 +505,7 @@ sections. Read a section in full before doing its step; do not work from memory.
| writing the CHANGELOG entry (Step 13) | `sections/changelog.md` |
| auditing docs before final commit/verification (Step 14.5), on every ship | `sections/documentation.md` |
| preparing the title and creating or updating the PR/MR with the verified documentation outcome (Steps 18-19) | `sections/pr-body.md` |
| handing the branch to a coordinator instead of opening a PR (--no-pr or an automation lane; Step 18) | `sections/handoff.md` |
---
@@ -556,7 +557,7 @@ branch name wherever the instructions say "the base branch" or `<default>`.
If the ask is App Store/TestFlight distribution, look for an `.xcodeproj`,
`.xcworkspace`, or Swift app product. Read `Package.swift` and its entrypoint to
distinguish an app from a library/CLI. If unclear, use AskUserQuestion to identify
distinguish an app from a library/CLI. If unclear, AskUserQuestion to identify
the target and wait before choosing a release path.
For a confirmed app, **STOP and Read
`~/.claude/skills/gstack/ship/sections/apple-release.md` FIRST**. Store distribution proceeds
@@ -568,10 +569,10 @@ repository-landing asks, including on Apple repos.
1. Save the current branch as `<branch-name>`. If on the base branch or the repo's default branch, **abort**: "You're on the base branch. Ship from a feature branch."
2. Run `git status` (never use `-uall`). Uncommitted changes are always included — no need to ask.
2. Run `git status` (never `-uall`). Uncommitted changes are always included; do not ask.
3. Run `git fetch origin <base>` before inspecting the diff. If fetch fails, STOP:
report the error and restore access before continuing. Then inspect
report the error and restore access first. Then inspect
`git diff origin/<base> --stat`, untracked files from status, and
`git log origin/<base>..HEAD --oneline`.
@@ -663,7 +664,7 @@ VERDICT: {CLEARED or NOT CLEARED} — {reason}
An outside review with status `unverified` or `unavailable` is missing coverage, never a pass.
For diffs >200 lines (`git diff origin/<base> --stat | tail -1`), recommend
`/plan-eng-review` or `/autoplan` for architecture review.
`/plan-eng-review` or `/autoplan`.
For Design Review: run `source <(~/.claude/skills/gstack/bin/gstack-diff-scope <base> 2>/dev/null)`. If `SCOPE_FRONTEND=true` and no design review exists, mention: "Design Review not run — Step 9 includes the lite check; consider /design-review for a full visual audit."
@@ -674,16 +675,16 @@ Continue to Step 2 without asking; Step 9 applies the review gates.
## Step 2: Distribution Pipeline Check
Check distribution for new standalone artifacts (CLI binaries, packages, tools),
not web services with existing deployment.
not web services already deployed.
1. List candidate distribution paths:
```bash
git diff origin/<base> --diff-filter=A --name-only | grep -E '(^|/)(cmd/[^/]+/main\.go|bin/[^/]+|Cargo\.toml|setup\.py|package\.json)$' | head -5
```
Also inspect matching untracked files from Step 1's status. Read each match:
a new `package.json` or `Cargo.toml` alone does not establish a publishable
artifact. Also inspect existing manifests for newly declared binaries or
package exports. Apply the pipeline gate only when a new distributable is present.
Include matching untracked files from Step 1's status. Read each match:
a new `package.json` or `Cargo.toml` alone is not a publishable artifact;
also check existing manifests for newly declared binaries or package
exports. Gate only when a new distributable is present.
2. If new artifact detected, check for a release workflow:
```bash
@@ -701,7 +702,7 @@ not web services with existing deployment.
Ask for unknown targets, registries or access first; never invent credentials.
Recheck against the artifact and include the workflow in tests and review.
Do not publish a release during `/ship`.
5. Otherwise, continue without adding a pipeline.
5. Otherwise continue without a pipeline.
---
@@ -713,7 +714,7 @@ Merge the base ref fetched in Step 1 so tests and reviews cover the integrated c
git merge origin/<base> --no-edit
```
**If there are merge conflicts:** Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution decision, and wait for the answer before editing or continuing.
**Merge conflicts:** Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution, and wait for the answer before editing or continuing.
**If already up to date:** Continue silently.
@@ -751,7 +752,7 @@ repeat Step 2 (decisions included) on the merged content, then Step 4.
1. **Select the two reviews.** Run `~/.claude/skills/gstack/bin/gstack-review-read`.
Select this invocation's final Step 9.4 record (`skill:"review"`, `via:"ship"`)
and Step 11 native record (`skill:"adversarial-review"`). Match each to its saved
handle, original token and source; reject outside-provider or older invocation records.
handle, original token and source; reject outside-provider or older records.
2. **Compare their content.** Require the native record's `review_binding.state`
to be `verified`. All three snapshots must match: its `wtree`, Step 9.4's
`review_binding.start_wtree` and `review_binding.end_wtree`. A mismatch or missing
@@ -760,7 +761,7 @@ repeat Step 2 (decisions included) on the merged content, then Step 4.
Never attach new tokens to old work.
3. **Preserve any QA exception.** A named probe-risk exception may leave Step 9.4's
root `wtree` absent; item 2 still compares its start/end snapshots. Matching content
does not mean the failed or unrun probes passed. Keep Step 9.4's incomplete flags
does not mean failed or unrun probes passed. Keep Step 9.4's incomplete flags
and the user's exception.
4. **Save the evidence.** Save both records and matching **reviewed tree** for
Step 16. Continue to Step 12.
@@ -797,9 +798,8 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
- **MICRO**: <50 lines, trivial tweaks/config. **PATCH**: 50+ lines, no feature signal.
- **MINOR**: ask for any feature signal (new route/page, migration, module) or 500+ lines.
**MAJOR**: ask for milestones or breaking changes (`ship: bump patch` never asks).
Use AskUserQuestion: recommended
level with rationale, smaller level, or cancel. Wait; cancel stops before release
writes or push and preserves existing work.
AskUserQuestion: recommended level with rationale, smaller level, or cancel.
Wait; cancel stops before release writes or push and preserves existing work.
Save lowercase `BUMP_LEVEL`. A claimed version may move the next available number
forward, but cannot change the chosen MICRO/PATCH/MINOR/MAJOR level.
@@ -811,7 +811,7 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
**Qualify first:** require successful utility output and a nonempty valid version.
`offline:false` qualifies; `offline:true` qualifies only with `fallback:"git"`.
Offline output without that fallback, failure, malformed output or an empty version
is unusable, even if it contains a version-looking string.
is unusable, even when it contains a version-looking string.
- **Usable candidate:** print warnings and claimed queue. FRESH sets `NEW_VERSION=CANDIDATE_VERSION`.
ALREADY_BUMPED compares it with `currentVersion`: if different, ask to rebump
@@ -826,7 +826,7 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
```bash
bun run ~/.claude/skills/gstack/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest
```
The CLI validates `MAJOR.MINOR.PATCH.MICRO` (or pinned 3-digit semver) and writes
The CLI validates `MAJOR.MINOR.PATCH.MICRO` (or pinned 3-digit semver), writes
VERSION, the manifest (`--package-json-path` → `.gstack/package-json-path` →
`./package.json`) and existing npm lockfiles, never creating one, with the 3-digit
translation (`1.67.0.0` → `1.67.0`); VERSION is authoritative. Exit 3 is a half-write:
@@ -842,7 +842,7 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
```bash
~/.claude/skills/gstack/bin/gstack-decision-log '{"decision":"Ship NEW_VERSION (BUMP_LEVEL)","rationale":"WHY","scope":"repo","source":"skill","confidence":9}' 2>/dev/null || true
```
Substitute `NEW_VERSION`, `BUMP_LEVEL` and a one-line `WHY` (scope or breaking-change signal); best-effort, non-blocking.
Substitute `NEW_VERSION`, `BUMP_LEVEL` and a one-line `WHY` (scope or breaking-change signal); best-effort.
> **STOP.** Before writing the CHANGELOG entry (Step 13), Read `~/.claude/skills/gstack/ship/sections/changelog.md` and execute it
> in full. Do not work from memory — that section is the source of truth for this step.
@@ -863,7 +863,7 @@ at the bottom. If disorganized, ask: A) Reorganize preserving all content
- Step 2: add the approved distribution follow-up as P1 with the missing pipeline and affected artifact.
- Step 8: add each approved P1 plan deferral with `Deferred from plan: {plan file path}` and the missing work.
- Step 5: retain P0 test-failure entries already written; deduplicate by failure and source, adding missing approved entries with error output and branch.
Never turn dropped scope into TODOs or invent unapproved follow-ups. Reuse matching existing entries rather than duplicating them.
Never turn dropped scope into TODOs or invent unapproved follow-ups. Reuse matching entries instead of duplicating them.
**4. Detect completed TODOs:** Compare titles, files and behavior with
`git diff origin/<base>`, untracked files and `git log origin/<base>..HEAD --oneline`.
@@ -871,9 +871,9 @@ Move proven completions to `## Completed` with `**Completed:** vX.Y.Z (YYYY-MM-D
leave uncertain items open.
**5. Save the summary:** Report additions, deferrals, completions, remaining count and
creation/reorganization. If creation was declined or a write failed, warn and retain
unsaved follow-ups in Step 19's PR summary. Never claim they were saved;
TODO write failures are non-blocking.
creation/reorganization. If creation was declined or a write failed, warn and carry
unsaved follow-ups in Step 19's PR summary; never claim they were saved. TODO
write failures are non-blocking.
---
@@ -892,9 +892,9 @@ Make bisectable commits; if already committed, continue to Step 16. Never create
1. Group changes with their tests, config/routes, views and Step 14.5 docs.
Migrations may stand alone or accompany their model.
Under 50 lines across fewer than 4 files may use one commit.
Under 50 lines across fewer than 4 files may be one commit.
2. Order dependencies first: infrastructure → models/services → controllers/views.
Each commit must work independently, without broken imports or missing code.
Each commit must work on its own, without broken imports or missing code.
Group VERSION + CHANGELOG + TODOS.md after the feature commits.
3. Use `<type>: <summary>` (feat/fix/chore/refactor/docs) and a brief body.
Only the final VERSION/CHANGELOG commit gets the release version and co-author
@@ -917,7 +917,7 @@ EOF
Run stages 1–5 in order; recovery notes below name where to resume. Content
changes during or after verification restart at stage 1 and complete all five
stages before Step 17. Content-preserving commits keep valid evidence.
stages before Step 17. Content-preserving commits keep evidence valid.
### 1. Finish writers and prepare outputs
@@ -930,7 +930,7 @@ files and CI. Run them and save results. If none exists, record not applicable a
the inspected sources. A missing prerequisite or failed build stops shipping:
report **Build failed or prerequisite missing**, with the command, error and needed
repair. Never invent a substitute command.
**If blocked:** Repair the prerequisite or build, then repeat stage 1. After it passes, continue
**If blocked:** Repair it, then repeat stage 1. After it passes, continue
to stage 2; treat any content repair as a behavioral change there.
### 2. Choose the change route
@@ -970,8 +970,7 @@ Report changed inputs, blockers and attempts used:
- **An attempt remains, with changed inputs or an available repair:** insert
`14.5 → 15 → 16` before Step 17. Use Blocked recovery with the existing count.
Validate the outcome before Step 15,
then restart Step 16 stage 1 to regenerate and compare again.
Validate the outcome before Step 15, then restart Step 16 stage 1.
- **Otherwise:** STOP unless the user accepts
the specific named documentation risk and all unwaivable gates clear, under
Step 14.5's Blocked recovery rules. Unchanged approved content goes to stage 4;
@@ -982,7 +981,7 @@ Never run a third audit. Child return is not acceptance.
### 4. Verify the frozen candidate
Freeze inputs through verification and push. Run declared docs/link/generated-file
checks; report unavailable checks.
checks; report any that are unavailable.
**Reuse a check when its inputs match.** Compare hashes or complete bytes of its
saved and current consumed files, fixtures, dependencies and execution parameters.
@@ -995,8 +994,8 @@ command, result/counts, timestamp and log, never resample. Mandatory reviews sti
`--allow-paths` exempts only release metadata. A `package.json` version-only edit
can qualify; scripts, dependencies and runtime configuration require live tests.
Uncertain edits cannot be exempted. Docs, TODO edits, new/generated tests and fixes
make evidence STALE even without a new code review. Use this example only after
confirming that every allowed edit is release metadata:
make evidence STALE even without a new code review. Use this example only once
every allowed edit is release metadata:
```bash
~/.claude/skills/gstack/bin/gstack-evidence check --label tests --expect-cmd '<tests>' --label vitest --expect-cmd '<vitest>' --max-age 24 --allow-paths CHANGELOG.md,VERSION,package.json,agents-digest/gstack-AGENTS.md
@@ -1022,7 +1021,7 @@ keeps its existing audit count; it does not authorize a third attempt.
### 5. Report, then push
Commit only approved, verified release changes left uncommitted after Step 15,
including generated outputs; use its grouping rules and never create an empty commit.
including generated outputs; use its grouping rules; never create an empty commit.
Preserve unrelated user files.
Paste build/docs/test results. Reuse waivers only for the same verified
@@ -1036,8 +1035,8 @@ its records:
`reason` and logger-stamped `actor`. If there are none, the coverage line
(`none detected (...)`, `partial (...)` or `UNAVAILABLE — ...`).
- Each test lane's `tests_ran` (N, ZERO-RUN or `count unavailable`).
Write this report in the auditor's posture: describe this session's own gate edits
as evidence for the reader to examine, not as positions to defend; a kept gate
Write this report in the auditor's posture: this session's own gate edits are
evidence for the reader to examine, not positions to defend; a kept gate
states its reason and nothing more. Otherwise continue to Step 17.
---
@@ -1084,7 +1083,7 @@ Branch on the echoed values:
`pre-push.local`; otherwise request manual integration. Dangling
symlinks are unmanaged. Never overwrite either policy.
2. **`REDACT_PREPUSH` not true AND `PREPUSH_PROMPTED: no`** — one-time
offer (fires once EVER, machine-wide). AskUserQuestion:
offer (once ever, machine-wide). AskUserQuestion:
> gstack can install a per-repo git pre-push hook that blocks pushes
> containing credentials (API keys, tokens, private keys). It's a
@@ -1098,16 +1097,15 @@ Branch on the echoed values:
If A: run `~/.claude/skills/gstack/bin/gstack-config set redact_prepush_hook true`
then re-run the block and apply the same directory and unmanaged-hook rules above.
If B: run `~/.claude/skills/gstack/bin/gstack-config set redact_prepush_hook false`.
ALWAYS (after either answer, but NOT if the question itself failed to
render — a failed AskUserQuestion must re-offer next time):
ALWAYS after either answer (never after a failed question; it must re-offer
next time):
```bash
GSTACK_STATE_ROOT=$(~/.claude/skills/gstack/bin/gstack-paths --get GSTACK_STATE_ROOT); : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
touch "$GSTACK_STATE_ROOT/.redact-prepush-prompted"
```
3. **Declined earlier** — continue
without comment.
3. **Declined earlier** — continue without comment.
**Open gate findings against an existing ready PR/MR:** when Step 9 left any gate
**Open gate findings against an existing ready PR/MR:** when Step 9 left a gate
finding `open`, look up an open PR/MR for `<branch-name>` before pushing
(`gh pr list --head <branch-name> --state open --json number,isDraft`; GitLab
`glab mr list --source-branch <branch-name> --output json`, reading `draft`). A lookup
@@ -1121,7 +1119,7 @@ failure STOPs here. If one exists and is not a draft:
the `Gate edits: N open` banner. Record B in the invocation record as a
human-authorised publication; Step 19 reads it. Never convert a ready PR to a draft.
A new PR, or an existing draft, needs no question: Step 19 creates or keeps the draft.
A new PR or an existing draft needs no question: Step 19 creates or keeps the draft.
**Idempotency check:** Check if the branch is already pushed and up to date.
@@ -1137,7 +1135,7 @@ echo "LOCAL: $LOCAL REMOTE: $REMOTE"
[ "$LOCAL" = "$REMOTE" ] && echo "ALREADY_PUSHED" || echo "PUSH_NEEDED"
```
If `ALREADY_PUSHED`, skip the push but continue to Step 18. Otherwise push with upstream tracking:
`ALREADY_PUSHED`: skip the push, continue to Step 18. Otherwise push with upstream tracking:
```bash
git push -u origin <branch-name>
@@ -1147,9 +1145,8 @@ git push -u origin <branch-name>
- **Non-fast-forward push:** fetch and inspect the remote, then merge under Step 3's
conflict rules. Run Steps 5–16 before returning to Step 17. Never rewrite history.
- **Authentication, hook or network failure:** repair the cause, then repeat Step 16
even if content is unchanged before returning to Step 17. Never bypass failed guards.
Never force-push.
Only a successful push or verified `ALREADY_PUSHED` proceeds.
even with unchanged content before returning to Step 17. Never bypass failed guards.
Never force-push. Only a successful push or verified `ALREADY_PUSHED` proceeds.
Continue to Step 18. No documentation writer runs after push.
@@ -1162,19 +1159,23 @@ First look up open PRs/MRs for `<branch-name>` on the detected platform:
- GitHub: `gh pr list --head <branch-name> --state open --json number,title,url`
- GitLab: `glab mr list --source-branch <branch-name> --output json` (defaults to open).
A successful empty array means new; one match supplies the existing title/identity.
An empty array means new; one match supplies the existing title/identity.
Lookup failure or ambiguous matches **STOP** for resolution, never mean no PR.
Save the result for Step 19's recheck.
The pr-body section prepares the title from that result, then publishes.
The pr-body section prepares the title from that result, then publishes;
`--no-pr` or an automation lane takes the handoff section.
> **STOP.** Before preparing the title and creating or updating the PR/MR with the verified documentation outcome (Steps 18-19), Read `~/.claude/skills/gstack/ship/sections/pr-body.md` and execute it
> in full. Do not work from memory — that section is the source of truth for this step.
> **STOP.** Before handing the branch to a coordinator instead of opening a PR (--no-pr or an automation lane; Step 18), Read `~/.claude/skills/gstack/ship/sections/handoff.md` and execute it
> in full. Do not work from memory — that section is the source of truth for this step.
## Step 20: Persist ship metrics
Log metrics for `/retro` through `gstack-review-log`; it handles project/branch paths,
JSON validation, storage and sync. It takes **no path argument**; do not build one.
JSON validation, storage and sync and takes **no path argument**.
```bash
~/.claude/skills/gstack/bin/gstack-review-log '{"skill":"ship","timestamp":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'","coverage_pct":COVERAGE_PCT,"coverage_schema":2,"coverage_pct_value":COVERAGE_PCT_VALUE,"weak_gaps":WEAK_GAPS,"tests_extended":TESTS_EXTENDED,"tests_rejected":TESTS_REJECTED,"regression_proof":REGRESSION_PROOF,"plan_items_total":PLAN_TOTAL,"plan_items_done":PLAN_DONE,"verification_result":"VERIFY_RESULT","version":"VERSION","branch":"'"$(git rev-parse --abbrev-ref HEAD)"'"}'
@@ -1190,7 +1191,7 @@ Substitute from earlier steps:
- **VERIFY_RESULT**: "pass", "fail", or "skipped", set after Step 9 executes Step 8.1's verification list
- **VERSION**: `NEW_VERSION` from Step 12; `null` (unquoted) under NO_VERSION
The shell supplies the branch. Run this automatically, without confirmation.
The shell supplies the branch. Run it without confirmation.
---
@@ -1211,15 +1212,14 @@ if [ ! -f "$_NUDGE_MARKER" ] && [ "$_QT" = "false" ]; then
fi
```
The marker or enabled question_tuning suppresses it. To re-enable, remove
`$GSTACK_STATE_ROOT/.plan-tune-nudge-shown` before the next ship.
The marker or enabled question_tuning suppresses it; remove the marker to re-enable.
---
## Section self-check (before you finish)
List the applicable Section index entries and confirm each Read. If you worked from
memory, STOP, Read the section and redo that step. Use `gstack-version-bump`, never
memory, STOP, Read the section and redo that step. Use `gstack-version-bump`; never
hand-roll VERSION/package.json writes.
---
+60 -60
View File
@@ -447,7 +447,7 @@ Routine authorization never waives those gates or their required user decisions.
**Routine work needs no confirmation:** include uncommitted changes, choose MICRO/PATCH
under Step 12, draft CHANGELOG and commits, mark completed TODOs and auto-fix findings.
When Step 7 coverage meets its target, report remaining gaps and verify generated
tests without another permission question. Step 15 commits those tests.
tests without asking again. Step 15 commits those tests.
**Route:** integrate (1–3) → test and review (4–11.5) → prepare the release
(12–15) → verify frozen content (16) → push and publish (17–21).
@@ -457,10 +457,10 @@ Steps 12, 17 and 19 prevent duplicate bumps, pushes and PRs, never verification.
### Keep state between steps
Keep one private Markdown **invocation record** outside the product tree and save
its absolute path. Use these headings so a paused run can resume:
its absolute path, with these headings so a paused run can resume:
- **Release:** versions, `BUMP_LEVEL`, reviewed tree and attempt counts.
- **Decisions:** each approval's finding, files and authorized action. Reuse it only
for that same scope; a repair never resets approvals or expands them.
for that same scope; a repair never resets or expands approvals.
- **Reviews:** handles, original start tokens, terminal states, outputs and queued fixes.
- **Checks:** command/label, result/counts, timestamp, log and consumed inputs.
- **Documentation:** candidate/id, attempts used, accepted hashes or named blocked exception.
@@ -482,18 +482,18 @@ permission to proceed. Follow the saved work list:
1. Start with Steps 1–21 in order, including 6.5, 11.5 and 14.5. Advance only after
the current item's gates clear.
2. Expand a repair into individual steps and insert them before the still-pending
work. This replaces the current item, whose actual result stays in the record.
work. This replaces the current item, whose result stays in the record.
Add its destination only if not already the next pending step.
3. For another repair, repeat rule 2 without discarding pending work.
The saved list takes precedence over ordinary next-step
sentences inside a repair. A range never adds unlisted steps.
The saved list outranks ordinary next-step sentences inside a repair.
A range never adds unlisted steps.
**Example:** Step 11 fixes insert `9 → 10 → 11` before 11.5. A further Step 9 fix
affecting 6–8 makes the list `5 → 6 → 7 → 8 → 9 → 10 → 11 → 11.5`.
The unchanged release steps follow. STOP and AskUserQuestion gates still apply during repairs.
Keep the same attempt counts throughout the invocation. A range ending at Step 14
does not enter Step 14.5. A range that includes Step 14.5 enters its existing audit
does not enter Step 14.5. A range including Step 14.5 enters its existing audit
decision, not an unconditional new launch; its initial-plus-ONE limit never resets.
Permitted repairs continue in this invocation without restarting /ship.
@@ -520,6 +520,7 @@ sections. Read a section in full before doing its step; do not work from memory.
| writing the CHANGELOG entry (Step 13) | `sections/changelog.md` |
| auditing docs before final commit/verification (Step 14.5), on every ship | `sections/documentation.md` |
| preparing the title and creating or updating the PR/MR with the verified documentation outcome (Steps 18-19) | `sections/pr-body.md` |
| handing the branch to a coordinator instead of opening a PR (--no-pr or an automation lane; Step 18) | `sections/handoff.md` |
---
@@ -571,7 +572,7 @@ branch name wherever the instructions say "the base branch" or `<default>`.
If the ask is App Store/TestFlight distribution, look for an `.xcodeproj`,
`.xcworkspace`, or Swift app product. Read `Package.swift` and its entrypoint to
distinguish an app from a library/CLI. If unclear, use AskUserQuestion to identify
distinguish an app from a library/CLI. If unclear, AskUserQuestion to identify
the target and wait before choosing a release path.
For a confirmed app, **STOP and Read
`sections/apple-release.md` relative to the installed `gstack-ship` SKILL.md directory FIRST**. Store distribution proceeds
@@ -583,10 +584,10 @@ repository-landing asks, including on Apple repos.
1. Save the current branch as `<branch-name>`. If on the base branch or the repo's default branch, **abort**: "You're on the base branch. Ship from a feature branch."
2. Run `git status` (never use `-uall`). Uncommitted changes are always included — no need to ask.
2. Run `git status` (never `-uall`). Uncommitted changes are always included; do not ask.
3. Run `git fetch origin <base>` before inspecting the diff. If fetch fails, STOP:
report the error and restore access before continuing. Then inspect
report the error and restore access first. Then inspect
`git diff origin/<base> --stat`, untracked files from status, and
`git log origin/<base>..HEAD --oneline`.
@@ -679,7 +680,7 @@ VERDICT: {CLEARED or NOT CLEARED} — {reason}
An outside review with status `unverified` or `unavailable` is missing coverage, never a pass.
For diffs >200 lines (`git diff origin/<base> --stat | tail -1`), recommend
`/plan-eng-review` or `/autoplan` for architecture review.
`/plan-eng-review` or `/autoplan`.
For Design Review: run `source <($GSTACK_ROOT/bin/gstack-diff-scope <base> 2>/dev/null)`. If `SCOPE_FRONTEND=true` and no design review exists, mention: "Design Review not run — Step 9 includes the lite check; consider /design-review for a full visual audit."
@@ -690,16 +691,16 @@ Continue to Step 2 without asking; Step 9 applies the review gates.
## Step 2: Distribution Pipeline Check
Check distribution for new standalone artifacts (CLI binaries, packages, tools),
not web services with existing deployment.
not web services already deployed.
1. List candidate distribution paths:
```bash
git diff origin/<base> --diff-filter=A --name-only | grep -E '(^|/)(cmd/[^/]+/main\.go|bin/[^/]+|Cargo\.toml|setup\.py|package\.json)$' | head -5
```
Also inspect matching untracked files from Step 1's status. Read each match:
a new `package.json` or `Cargo.toml` alone does not establish a publishable
artifact. Also inspect existing manifests for newly declared binaries or
package exports. Apply the pipeline gate only when a new distributable is present.
Include matching untracked files from Step 1's status. Read each match:
a new `package.json` or `Cargo.toml` alone is not a publishable artifact;
also check existing manifests for newly declared binaries or package
exports. Gate only when a new distributable is present.
2. If new artifact detected, check for a release workflow:
```bash
@@ -717,7 +718,7 @@ not web services with existing deployment.
Ask for unknown targets, registries or access first; never invent credentials.
Recheck against the artifact and include the workflow in tests and review.
Do not publish a release during `/ship`.
5. Otherwise, continue without adding a pipeline.
5. Otherwise continue without a pipeline.
---
@@ -729,7 +730,7 @@ Merge the base ref fetched in Step 1 so tests and reviews cover the integrated c
git merge origin/<base> --no-edit
```
**If there are merge conflicts:** Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution decision, and wait for the answer before editing or continuing.
**Merge conflicts:** Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution, and wait for the answer before editing or continuing.
**If already up to date:** Continue silently.
@@ -767,7 +768,7 @@ repeat Step 2 (decisions included) on the merged content, then Step 4.
1. **Select the two reviews.** Run `$GSTACK_ROOT/bin/gstack-review-read`.
Select this invocation's final Step 9.4 record (`skill:"review"`, `via:"ship"`)
and Step 11 native record (`skill:"adversarial-review"`). Match each to its saved
handle, original token and source; reject outside-provider or older invocation records.
handle, original token and source; reject outside-provider or older records.
2. **Compare their content.** Require the native record's `review_binding.state`
to be `verified`. All three snapshots must match: its `wtree`, Step 9.4's
`review_binding.start_wtree` and `review_binding.end_wtree`. A mismatch or missing
@@ -776,7 +777,7 @@ repeat Step 2 (decisions included) on the merged content, then Step 4.
Never attach new tokens to old work.
3. **Preserve any QA exception.** A named probe-risk exception may leave Step 9.4's
root `wtree` absent; item 2 still compares its start/end snapshots. Matching content
does not mean the failed or unrun probes passed. Keep Step 9.4's incomplete flags
does not mean failed or unrun probes passed. Keep Step 9.4's incomplete flags
and the user's exception.
4. **Save the evidence.** Save both records and matching **reviewed tree** for
Step 16. Continue to Step 12.
@@ -814,9 +815,8 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
- **MICRO**: <50 lines, trivial tweaks/config. **PATCH**: 50+ lines, no feature signal.
- **MINOR**: ask for any feature signal (new route/page, migration, module) or 500+ lines.
**MAJOR**: ask for milestones or breaking changes (`ship: bump patch` never asks).
Use AskUserQuestion: recommended
level with rationale, smaller level, or cancel. Wait; cancel stops before release
writes or push and preserves existing work.
AskUserQuestion: recommended level with rationale, smaller level, or cancel.
Wait; cancel stops before release writes or push and preserves existing work.
Save lowercase `BUMP_LEVEL`. A claimed version may move the next available number
forward, but cannot change the chosen MICRO/PATCH/MINOR/MAJOR level.
@@ -829,7 +829,7 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
**Qualify first:** require successful utility output and a nonempty valid version.
`offline:false` qualifies; `offline:true` qualifies only with `fallback:"git"`.
Offline output without that fallback, failure, malformed output or an empty version
is unusable, even if it contains a version-looking string.
is unusable, even when it contains a version-looking string.
- **Usable candidate:** print warnings and claimed queue. FRESH sets `NEW_VERSION=CANDIDATE_VERSION`.
ALREADY_BUMPED compares it with `currentVersion`: if different, ask to rebump
@@ -845,7 +845,7 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
[ -d "${GSTACK_ROOT:-/-}/bin" ]&&[ -d "$GSTACK_ROOT/lib" ]||{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/.agents/skills/gstack;[ -d "$_r/bin" ]||_r=${CODEX_HOME:-~/.codex}/skills/gstack;[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). Fix: ./setup --host codex from your gstack checkout; ./setup --status shows it.">&2;exit 1;};GSTACK_ROOT=$_r;}
bun run $GSTACK_ROOT/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest
```
The CLI validates `MAJOR.MINOR.PATCH.MICRO` (or pinned 3-digit semver) and writes
The CLI validates `MAJOR.MINOR.PATCH.MICRO` (or pinned 3-digit semver), writes
VERSION, the manifest (`--package-json-path` → `.gstack/package-json-path` →
`./package.json`) and existing npm lockfiles, never creating one, with the 3-digit
translation (`1.67.0.0` → `1.67.0`); VERSION is authoritative. Exit 3 is a half-write:
@@ -862,7 +862,7 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
[ -d "${GSTACK_ROOT:-/-}/bin" ]&&[ -d "$GSTACK_ROOT/lib" ]||{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/.agents/skills/gstack;[ -d "$_r/bin" ]||_r=${CODEX_HOME:-~/.codex}/skills/gstack;[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). Fix: ./setup --host codex from your gstack checkout; ./setup --status shows it.">&2;exit 1;};GSTACK_ROOT=$_r;}
$GSTACK_ROOT/bin/gstack-decision-log '{"decision":"Ship NEW_VERSION (BUMP_LEVEL)","rationale":"WHY","scope":"repo","source":"skill","confidence":9}' 2>/dev/null || true
```
Substitute `NEW_VERSION`, `BUMP_LEVEL` and a one-line `WHY` (scope or breaking-change signal); best-effort, non-blocking.
Substitute `NEW_VERSION`, `BUMP_LEVEL` and a one-line `WHY` (scope or breaking-change signal); best-effort.
> **STOP.** Before writing the CHANGELOG entry (Step 13), Read `sections/changelog.md` relative to the installed `gstack-ship` SKILL.md directory and execute it
> in full. Do not work from memory — that section is the source of truth for this step.
@@ -883,7 +883,7 @@ at the bottom. If disorganized, ask: A) Reorganize preserving all content
- Step 2: add the approved distribution follow-up as P1 with the missing pipeline and affected artifact.
- Step 8: add each approved P1 plan deferral with `Deferred from plan: {plan file path}` and the missing work.
- Step 5: retain P0 test-failure entries already written; deduplicate by failure and source, adding missing approved entries with error output and branch.
Never turn dropped scope into TODOs or invent unapproved follow-ups. Reuse matching existing entries rather than duplicating them.
Never turn dropped scope into TODOs or invent unapproved follow-ups. Reuse matching entries instead of duplicating them.
**4. Detect completed TODOs:** Compare titles, files and behavior with
`git diff origin/<base>`, untracked files and `git log origin/<base>..HEAD --oneline`.
@@ -891,9 +891,9 @@ Move proven completions to `## Completed` with `**Completed:** vX.Y.Z (YYYY-MM-D
leave uncertain items open.
**5. Save the summary:** Report additions, deferrals, completions, remaining count and
creation/reorganization. If creation was declined or a write failed, warn and retain
unsaved follow-ups in Step 19's PR summary. Never claim they were saved;
TODO write failures are non-blocking.
creation/reorganization. If creation was declined or a write failed, warn and carry
unsaved follow-ups in Step 19's PR summary; never claim they were saved. TODO
write failures are non-blocking.
---
@@ -912,9 +912,9 @@ Make bisectable commits; if already committed, continue to Step 16. Never create
1. Group changes with their tests, config/routes, views and Step 14.5 docs.
Migrations may stand alone or accompany their model.
Under 50 lines across fewer than 4 files may use one commit.
Under 50 lines across fewer than 4 files may be one commit.
2. Order dependencies first: infrastructure → models/services → controllers/views.
Each commit must work independently, without broken imports or missing code.
Each commit must work on its own, without broken imports or missing code.
Group VERSION + CHANGELOG + TODOS.md after the feature commits.
3. Use `<type>: <summary>` (feat/fix/chore/refactor/docs) and a brief body.
Only the final VERSION/CHANGELOG commit gets the release version and co-author
@@ -937,7 +937,7 @@ EOF
Run stages 1–5 in order; recovery notes below name where to resume. Content
changes during or after verification restart at stage 1 and complete all five
stages before Step 17. Content-preserving commits keep valid evidence.
stages before Step 17. Content-preserving commits keep evidence valid.
### 1. Finish writers and prepare outputs
@@ -950,7 +950,7 @@ files and CI. Run them and save results. If none exists, record not applicable a
the inspected sources. A missing prerequisite or failed build stops shipping:
report **Build failed or prerequisite missing**, with the command, error and needed
repair. Never invent a substitute command.
**If blocked:** Repair the prerequisite or build, then repeat stage 1. After it passes, continue
**If blocked:** Repair it, then repeat stage 1. After it passes, continue
to stage 2; treat any content repair as a behavioral change there.
### 2. Choose the change route
@@ -990,8 +990,7 @@ Report changed inputs, blockers and attempts used:
- **An attempt remains, with changed inputs or an available repair:** insert
`14.5 → 15 → 16` before Step 17. Use Blocked recovery with the existing count.
Validate the outcome before Step 15,
then restart Step 16 stage 1 to regenerate and compare again.
Validate the outcome before Step 15, then restart Step 16 stage 1.
- **Otherwise:** STOP unless the user accepts
the specific named documentation risk and all unwaivable gates clear, under
Step 14.5's Blocked recovery rules. Unchanged approved content goes to stage 4;
@@ -1002,7 +1001,7 @@ Never run a third audit. Child return is not acceptance.
### 4. Verify the frozen candidate
Freeze inputs through verification and push. Run declared docs/link/generated-file
checks; report unavailable checks.
checks; report any that are unavailable.
**Reuse a check when its inputs match.** Compare hashes or complete bytes of its
saved and current consumed files, fixtures, dependencies and execution parameters.
@@ -1015,8 +1014,8 @@ command, result/counts, timestamp and log, never resample. Mandatory reviews sti
`--allow-paths` exempts only release metadata. A `package.json` version-only edit
can qualify; scripts, dependencies and runtime configuration require live tests.
Uncertain edits cannot be exempted. Docs, TODO edits, new/generated tests and fixes
make evidence STALE even without a new code review. Use this example only after
confirming that every allowed edit is release metadata:
make evidence STALE even without a new code review. Use this example only once
every allowed edit is release metadata:
```bash
[ -d "${GSTACK_ROOT:-/-}/bin" ]&&[ -d "$GSTACK_ROOT/lib" ]||{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/.agents/skills/gstack;[ -d "$_r/bin" ]||_r=${CODEX_HOME:-~/.codex}/skills/gstack;[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). Fix: ./setup --host codex from your gstack checkout; ./setup --status shows it.">&2;exit 1;};GSTACK_ROOT=$_r;}
@@ -1043,7 +1042,7 @@ keeps its existing audit count; it does not authorize a third attempt.
### 5. Report, then push
Commit only approved, verified release changes left uncommitted after Step 15,
including generated outputs; use its grouping rules and never create an empty commit.
including generated outputs; use its grouping rules; never create an empty commit.
Preserve unrelated user files.
Paste build/docs/test results. Reuse waivers only for the same verified
@@ -1057,8 +1056,8 @@ its records:
`reason` and logger-stamped `actor`. If there are none, the coverage line
(`none detected (...)`, `partial (...)` or `UNAVAILABLE — ...`).
- Each test lane's `tests_ran` (N, ZERO-RUN or `count unavailable`).
Write this report in the auditor's posture: describe this session's own gate edits
as evidence for the reader to examine, not as positions to defend; a kept gate
Write this report in the auditor's posture: this session's own gate edits are
evidence for the reader to examine, not positions to defend; a kept gate
states its reason and nothing more. Otherwise continue to Step 17.
---
@@ -1106,7 +1105,7 @@ Branch on the echoed values:
`pre-push.local`; otherwise request manual integration. Dangling
symlinks are unmanaged. Never overwrite either policy.
2. **`REDACT_PREPUSH` not true AND `PREPUSH_PROMPTED: no`** — one-time
offer (fires once EVER, machine-wide). AskUserQuestion:
offer (once ever, machine-wide). AskUserQuestion:
> gstack can install a per-repo git pre-push hook that blocks pushes
> containing credentials (API keys, tokens, private keys). It's a
@@ -1120,17 +1119,16 @@ Branch on the echoed values:
If A: run `$GSTACK_ROOT/bin/gstack-config set redact_prepush_hook true`
then re-run the block and apply the same directory and unmanaged-hook rules above.
If B: run `$GSTACK_ROOT/bin/gstack-config set redact_prepush_hook false`.
ALWAYS (after either answer, but NOT if the question itself failed to
render — a failed AskUserQuestion must re-offer next time):
ALWAYS after either answer (never after a failed question; it must re-offer
next time):
```bash
[ -d "${GSTACK_ROOT:-/-}/bin" ]&&[ -d "$GSTACK_ROOT/lib" ]||{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/.agents/skills/gstack;[ -d "$_r/bin" ]||_r=${CODEX_HOME:-~/.codex}/skills/gstack;[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). Fix: ./setup --host codex from your gstack checkout; ./setup --status shows it.">&2;exit 1;};GSTACK_ROOT=$_r;}
GSTACK_STATE_ROOT=$($GSTACK_ROOT/bin/gstack-paths --get GSTACK_STATE_ROOT); : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
touch "$GSTACK_STATE_ROOT/.redact-prepush-prompted"
```
3. **Declined earlier** — continue
without comment.
3. **Declined earlier** — continue without comment.
**Open gate findings against an existing ready PR/MR:** when Step 9 left any gate
**Open gate findings against an existing ready PR/MR:** when Step 9 left a gate
finding `open`, look up an open PR/MR for `<branch-name>` before pushing
(`gh pr list --head <branch-name> --state open --json number,isDraft`; GitLab
`glab mr list --source-branch <branch-name> --output json`, reading `draft`). A lookup
@@ -1144,7 +1142,7 @@ failure STOPs here. If one exists and is not a draft:
the `Gate edits: N open` banner. Record B in the invocation record as a
human-authorised publication; Step 19 reads it. Never convert a ready PR to a draft.
A new PR, or an existing draft, needs no question: Step 19 creates or keeps the draft.
A new PR or an existing draft needs no question: Step 19 creates or keeps the draft.
**Idempotency check:** Check if the branch is already pushed and up to date.
@@ -1160,7 +1158,7 @@ echo "LOCAL: $LOCAL REMOTE: $REMOTE"
[ "$LOCAL" = "$REMOTE" ] && echo "ALREADY_PUSHED" || echo "PUSH_NEEDED"
```
If `ALREADY_PUSHED`, skip the push but continue to Step 18. Otherwise push with upstream tracking:
`ALREADY_PUSHED`: skip the push, continue to Step 18. Otherwise push with upstream tracking:
```bash
git push -u origin <branch-name>
@@ -1170,9 +1168,8 @@ git push -u origin <branch-name>
- **Non-fast-forward push:** fetch and inspect the remote, then merge under Step 3's
conflict rules. Run Steps 5–16 before returning to Step 17. Never rewrite history.
- **Authentication, hook or network failure:** repair the cause, then repeat Step 16
even if content is unchanged before returning to Step 17. Never bypass failed guards.
Never force-push.
Only a successful push or verified `ALREADY_PUSHED` proceeds.
even with unchanged content before returning to Step 17. Never bypass failed guards.
Never force-push. Only a successful push or verified `ALREADY_PUSHED` proceeds.
Continue to Step 18. No documentation writer runs after push.
@@ -1185,19 +1182,23 @@ First look up open PRs/MRs for `<branch-name>` on the detected platform:
- GitHub: `gh pr list --head <branch-name> --state open --json number,title,url`
- GitLab: `glab mr list --source-branch <branch-name> --output json` (defaults to open).
A successful empty array means new; one match supplies the existing title/identity.
An empty array means new; one match supplies the existing title/identity.
Lookup failure or ambiguous matches **STOP** for resolution, never mean no PR.
Save the result for Step 19's recheck.
The pr-body section prepares the title from that result, then publishes.
The pr-body section prepares the title from that result, then publishes;
`--no-pr` or an automation lane takes the handoff section.
> **STOP.** Before preparing the title and creating or updating the PR/MR with the verified documentation outcome (Steps 18-19), Read `sections/pr-body.md` relative to the installed `gstack-ship` SKILL.md directory and execute it
> in full. Do not work from memory — that section is the source of truth for this step.
> **STOP.** Before handing the branch to a coordinator instead of opening a PR (--no-pr or an automation lane; Step 18), Read `sections/handoff.md` relative to the installed `gstack-ship` SKILL.md directory and execute it
> in full. Do not work from memory — that section is the source of truth for this step.
## Step 20: Persist ship metrics
Log metrics for `/retro` through `gstack-review-log`; it handles project/branch paths,
JSON validation, storage and sync. It takes **no path argument**; do not build one.
JSON validation, storage and sync and takes **no path argument**.
```bash
[ -d "${GSTACK_ROOT:-/-}/bin" ]&&[ -d "$GSTACK_ROOT/lib" ]||{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/.agents/skills/gstack;[ -d "$_r/bin" ]||_r=${CODEX_HOME:-~/.codex}/skills/gstack;[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). Fix: ./setup --host codex from your gstack checkout; ./setup --status shows it.">&2;exit 1;};GSTACK_ROOT=$_r;}
@@ -1214,7 +1215,7 @@ Substitute from earlier steps:
- **VERIFY_RESULT**: "pass", "fail", or "skipped", set after Step 9 executes Step 8.1's verification list
- **VERSION**: `NEW_VERSION` from Step 12; `null` (unquoted) under NO_VERSION
The shell supplies the branch. Run this automatically, without confirmation.
The shell supplies the branch. Run it without confirmation.
---
@@ -1236,15 +1237,14 @@ if [ ! -f "$_NUDGE_MARKER" ] && [ "$_QT" = "false" ]; then
fi
```
The marker or enabled question_tuning suppresses it. To re-enable, remove
`$GSTACK_STATE_ROOT/.plan-tune-nudge-shown` before the next ship.
The marker or enabled question_tuning suppresses it; remove the marker to re-enable.
---
## Section self-check (before you finish)
List the applicable Section index entries and confirm each Read. If you worked from
memory, STOP, Read the section and redo that step. Use `gstack-version-bump`, never
memory, STOP, Read the section and redo that step. Use `gstack-version-bump`; never
hand-roll VERSION/package.json writes.
---
+60 -60
View File
@@ -428,7 +428,7 @@ Routine authorization never waives those gates or their required user decisions.
**Routine work needs no confirmation:** include uncommitted changes, choose MICRO/PATCH
under Step 12, draft CHANGELOG and commits, mark completed TODOs and auto-fix findings.
When Step 7 coverage meets its target, report remaining gaps and verify generated
tests without another permission question. Step 15 commits those tests.
tests without asking again. Step 15 commits those tests.
**Route:** integrate (1–3) → test and review (4–11.5) → prepare the release
(12–15) → verify frozen content (16) → push and publish (17–21).
@@ -438,10 +438,10 @@ Steps 12, 17 and 19 prevent duplicate bumps, pushes and PRs, never verification.
### Keep state between steps
Keep one private Markdown **invocation record** outside the product tree and save
its absolute path. Use these headings so a paused run can resume:
its absolute path, with these headings so a paused run can resume:
- **Release:** versions, `BUMP_LEVEL`, reviewed tree and attempt counts.
- **Decisions:** each approval's finding, files and authorized action. Reuse it only
for that same scope; a repair never resets approvals or expands them.
for that same scope; a repair never resets or expands approvals.
- **Reviews:** handles, original start tokens, terminal states, outputs and queued fixes.
- **Checks:** command/label, result/counts, timestamp, log and consumed inputs.
- **Documentation:** candidate/id, attempts used, accepted hashes or named blocked exception.
@@ -463,18 +463,18 @@ permission to proceed. Follow the saved work list:
1. Start with Steps 1–21 in order, including 6.5, 11.5 and 14.5. Advance only after
the current item's gates clear.
2. Expand a repair into individual steps and insert them before the still-pending
work. This replaces the current item, whose actual result stays in the record.
work. This replaces the current item, whose result stays in the record.
Add its destination only if not already the next pending step.
3. For another repair, repeat rule 2 without discarding pending work.
The saved list takes precedence over ordinary next-step
sentences inside a repair. A range never adds unlisted steps.
The saved list outranks ordinary next-step sentences inside a repair.
A range never adds unlisted steps.
**Example:** Step 11 fixes insert `9 → 10 → 11` before 11.5. A further Step 9 fix
affecting 6–8 makes the list `5 → 6 → 7 → 8 → 9 → 10 → 11 → 11.5`.
The unchanged release steps follow. STOP and AskUserQuestion gates still apply during repairs.
Keep the same attempt counts throughout the invocation. A range ending at Step 14
does not enter Step 14.5. A range that includes Step 14.5 enters its existing audit
does not enter Step 14.5. A range including Step 14.5 enters its existing audit
decision, not an unconditional new launch; its initial-plus-ONE limit never resets.
Permitted repairs continue in this invocation without restarting /ship.
@@ -501,6 +501,7 @@ sections. Read a section in full before doing its step; do not work from memory.
| writing the CHANGELOG entry (Step 13) | `sections/changelog.md` |
| auditing docs before final commit/verification (Step 14.5), on every ship | `sections/documentation.md` |
| preparing the title and creating or updating the PR/MR with the verified documentation outcome (Steps 18-19) | `sections/pr-body.md` |
| handing the branch to a coordinator instead of opening a PR (--no-pr or an automation lane; Step 18) | `sections/handoff.md` |
---
@@ -552,7 +553,7 @@ branch name wherever the instructions say "the base branch" or `<default>`.
If the ask is App Store/TestFlight distribution, look for an `.xcodeproj`,
`.xcworkspace`, or Swift app product. Read `Package.swift` and its entrypoint to
distinguish an app from a library/CLI. If unclear, use AskUserQuestion to identify
distinguish an app from a library/CLI. If unclear, AskUserQuestion to identify
the target and wait before choosing a release path.
For a confirmed app, **STOP and Read
`sections/apple-release.md` relative to the installed `gstack-ship` SKILL.md directory FIRST**. Store distribution proceeds
@@ -564,10 +565,10 @@ repository-landing asks, including on Apple repos.
1. Save the current branch as `<branch-name>`. If on the base branch or the repo's default branch, **abort**: "You're on the base branch. Ship from a feature branch."
2. Run `git status` (never use `-uall`). Uncommitted changes are always included — no need to ask.
2. Run `git status` (never `-uall`). Uncommitted changes are always included; do not ask.
3. Run `git fetch origin <base>` before inspecting the diff. If fetch fails, STOP:
report the error and restore access before continuing. Then inspect
report the error and restore access first. Then inspect
`git diff origin/<base> --stat`, untracked files from status, and
`git log origin/<base>..HEAD --oneline`.
@@ -660,7 +661,7 @@ VERDICT: {CLEARED or NOT CLEARED} — {reason}
An outside review with status `unverified` or `unavailable` is missing coverage, never a pass.
For diffs >200 lines (`git diff origin/<base> --stat | tail -1`), recommend
`/plan-eng-review` or `/autoplan` for architecture review.
`/plan-eng-review` or `/autoplan`.
For Design Review: run `source <($GSTACK_ROOT/bin/gstack-diff-scope <base> 2>/dev/null)`. If `SCOPE_FRONTEND=true` and no design review exists, mention: "Design Review not run — Step 9 includes the lite check; consider /design-review for a full visual audit."
@@ -671,16 +672,16 @@ Continue to Step 2 without asking; Step 9 applies the review gates.
## Step 2: Distribution Pipeline Check
Check distribution for new standalone artifacts (CLI binaries, packages, tools),
not web services with existing deployment.
not web services already deployed.
1. List candidate distribution paths:
```bash
git diff origin/<base> --diff-filter=A --name-only | grep -E '(^|/)(cmd/[^/]+/main\.go|bin/[^/]+|Cargo\.toml|setup\.py|package\.json)$' | head -5
```
Also inspect matching untracked files from Step 1's status. Read each match:
a new `package.json` or `Cargo.toml` alone does not establish a publishable
artifact. Also inspect existing manifests for newly declared binaries or
package exports. Apply the pipeline gate only when a new distributable is present.
Include matching untracked files from Step 1's status. Read each match:
a new `package.json` or `Cargo.toml` alone is not a publishable artifact;
also check existing manifests for newly declared binaries or package
exports. Gate only when a new distributable is present.
2. If new artifact detected, check for a release workflow:
```bash
@@ -698,7 +699,7 @@ not web services with existing deployment.
Ask for unknown targets, registries or access first; never invent credentials.
Recheck against the artifact and include the workflow in tests and review.
Do not publish a release during `/ship`.
5. Otherwise, continue without adding a pipeline.
5. Otherwise continue without a pipeline.
---
@@ -710,7 +711,7 @@ Merge the base ref fetched in Step 1 so tests and reviews cover the integrated c
git merge origin/<base> --no-edit
```
**If there are merge conflicts:** Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution decision, and wait for the answer before editing or continuing.
**Merge conflicts:** Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution, and wait for the answer before editing or continuing.
**If already up to date:** Continue silently.
@@ -748,7 +749,7 @@ repeat Step 2 (decisions included) on the merged content, then Step 4.
1. **Select the two reviews.** Run `$GSTACK_ROOT/bin/gstack-review-read`.
Select this invocation's final Step 9.4 record (`skill:"review"`, `via:"ship"`)
and Step 11 native record (`skill:"adversarial-review"`). Match each to its saved
handle, original token and source; reject outside-provider or older invocation records.
handle, original token and source; reject outside-provider or older records.
2. **Compare their content.** Require the native record's `review_binding.state`
to be `verified`. All three snapshots must match: its `wtree`, Step 9.4's
`review_binding.start_wtree` and `review_binding.end_wtree`. A mismatch or missing
@@ -757,7 +758,7 @@ repeat Step 2 (decisions included) on the merged content, then Step 4.
Never attach new tokens to old work.
3. **Preserve any QA exception.** A named probe-risk exception may leave Step 9.4's
root `wtree` absent; item 2 still compares its start/end snapshots. Matching content
does not mean the failed or unrun probes passed. Keep Step 9.4's incomplete flags
does not mean failed or unrun probes passed. Keep Step 9.4's incomplete flags
and the user's exception.
4. **Save the evidence.** Save both records and matching **reviewed tree** for
Step 16. Continue to Step 12.
@@ -795,9 +796,8 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
- **MICRO**: <50 lines, trivial tweaks/config. **PATCH**: 50+ lines, no feature signal.
- **MINOR**: ask for any feature signal (new route/page, migration, module) or 500+ lines.
**MAJOR**: ask for milestones or breaking changes (`ship: bump patch` never asks).
Use AskUserQuestion: recommended
level with rationale, smaller level, or cancel. Wait; cancel stops before release
writes or push and preserves existing work.
AskUserQuestion: recommended level with rationale, smaller level, or cancel.
Wait; cancel stops before release writes or push and preserves existing work.
Save lowercase `BUMP_LEVEL`. A claimed version may move the next available number
forward, but cannot change the chosen MICRO/PATCH/MINOR/MAJOR level.
@@ -810,7 +810,7 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
**Qualify first:** require successful utility output and a nonempty valid version.
`offline:false` qualifies; `offline:true` qualifies only with `fallback:"git"`.
Offline output without that fallback, failure, malformed output or an empty version
is unusable, even if it contains a version-looking string.
is unusable, even when it contains a version-looking string.
- **Usable candidate:** print warnings and claimed queue. FRESH sets `NEW_VERSION=CANDIDATE_VERSION`.
ALREADY_BUMPED compares it with `currentVersion`: if different, ask to rebump
@@ -826,7 +826,7 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
[ -d "${GSTACK_ROOT:-/-}/bin" ]&&[ -d "$GSTACK_ROOT/lib" ]||{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/.factory/skills/gstack;[ -d "$_r/bin" ]||_r=~/.factory/skills/gstack;[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). Fix: ./setup --host factory from your gstack checkout; ./setup --status shows it.">&2;exit 1;};GSTACK_ROOT=$_r;}
bun run $GSTACK_ROOT/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest
```
The CLI validates `MAJOR.MINOR.PATCH.MICRO` (or pinned 3-digit semver) and writes
The CLI validates `MAJOR.MINOR.PATCH.MICRO` (or pinned 3-digit semver), writes
VERSION, the manifest (`--package-json-path` → `.gstack/package-json-path` →
`./package.json`) and existing npm lockfiles, never creating one, with the 3-digit
translation (`1.67.0.0` → `1.67.0`); VERSION is authoritative. Exit 3 is a half-write:
@@ -843,7 +843,7 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1.
[ -d "${GSTACK_ROOT:-/-}/bin" ]&&[ -d "$GSTACK_ROOT/lib" ]||{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/.factory/skills/gstack;[ -d "$_r/bin" ]||_r=~/.factory/skills/gstack;[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). Fix: ./setup --host factory from your gstack checkout; ./setup --status shows it.">&2;exit 1;};GSTACK_ROOT=$_r;}
$GSTACK_ROOT/bin/gstack-decision-log '{"decision":"Ship NEW_VERSION (BUMP_LEVEL)","rationale":"WHY","scope":"repo","source":"skill","confidence":9}' 2>/dev/null || true
```
Substitute `NEW_VERSION`, `BUMP_LEVEL` and a one-line `WHY` (scope or breaking-change signal); best-effort, non-blocking.
Substitute `NEW_VERSION`, `BUMP_LEVEL` and a one-line `WHY` (scope or breaking-change signal); best-effort.
> **STOP.** Before writing the CHANGELOG entry (Step 13), Read `sections/changelog.md` relative to the installed `gstack-ship` SKILL.md directory and execute it
> in full. Do not work from memory — that section is the source of truth for this step.
@@ -864,7 +864,7 @@ at the bottom. If disorganized, ask: A) Reorganize preserving all content
- Step 2: add the approved distribution follow-up as P1 with the missing pipeline and affected artifact.
- Step 8: add each approved P1 plan deferral with `Deferred from plan: {plan file path}` and the missing work.
- Step 5: retain P0 test-failure entries already written; deduplicate by failure and source, adding missing approved entries with error output and branch.
Never turn dropped scope into TODOs or invent unapproved follow-ups. Reuse matching existing entries rather than duplicating them.
Never turn dropped scope into TODOs or invent unapproved follow-ups. Reuse matching entries instead of duplicating them.
**4. Detect completed TODOs:** Compare titles, files and behavior with
`git diff origin/<base>`, untracked files and `git log origin/<base>..HEAD --oneline`.
@@ -872,9 +872,9 @@ Move proven completions to `## Completed` with `**Completed:** vX.Y.Z (YYYY-MM-D
leave uncertain items open.
**5. Save the summary:** Report additions, deferrals, completions, remaining count and
creation/reorganization. If creation was declined or a write failed, warn and retain
unsaved follow-ups in Step 19's PR summary. Never claim they were saved;
TODO write failures are non-blocking.
creation/reorganization. If creation was declined or a write failed, warn and carry
unsaved follow-ups in Step 19's PR summary; never claim they were saved. TODO
write failures are non-blocking.
---
@@ -893,9 +893,9 @@ Make bisectable commits; if already committed, continue to Step 16. Never create
1. Group changes with their tests, config/routes, views and Step 14.5 docs.
Migrations may stand alone or accompany their model.
Under 50 lines across fewer than 4 files may use one commit.
Under 50 lines across fewer than 4 files may be one commit.
2. Order dependencies first: infrastructure → models/services → controllers/views.
Each commit must work independently, without broken imports or missing code.
Each commit must work on its own, without broken imports or missing code.
Group VERSION + CHANGELOG + TODOS.md after the feature commits.
3. Use `<type>: <summary>` (feat/fix/chore/refactor/docs) and a brief body.
Only the final VERSION/CHANGELOG commit gets the release version and co-author
@@ -918,7 +918,7 @@ EOF
Run stages 1–5 in order; recovery notes below name where to resume. Content
changes during or after verification restart at stage 1 and complete all five
stages before Step 17. Content-preserving commits keep valid evidence.
stages before Step 17. Content-preserving commits keep evidence valid.
### 1. Finish writers and prepare outputs
@@ -931,7 +931,7 @@ files and CI. Run them and save results. If none exists, record not applicable a
the inspected sources. A missing prerequisite or failed build stops shipping:
report **Build failed or prerequisite missing**, with the command, error and needed
repair. Never invent a substitute command.
**If blocked:** Repair the prerequisite or build, then repeat stage 1. After it passes, continue
**If blocked:** Repair it, then repeat stage 1. After it passes, continue
to stage 2; treat any content repair as a behavioral change there.
### 2. Choose the change route
@@ -971,8 +971,7 @@ Report changed inputs, blockers and attempts used:
- **An attempt remains, with changed inputs or an available repair:** insert
`14.5 → 15 → 16` before Step 17. Use Blocked recovery with the existing count.
Validate the outcome before Step 15,
then restart Step 16 stage 1 to regenerate and compare again.
Validate the outcome before Step 15, then restart Step 16 stage 1.
- **Otherwise:** STOP unless the user accepts
the specific named documentation risk and all unwaivable gates clear, under
Step 14.5's Blocked recovery rules. Unchanged approved content goes to stage 4;
@@ -983,7 +982,7 @@ Never run a third audit. Child return is not acceptance.
### 4. Verify the frozen candidate
Freeze inputs through verification and push. Run declared docs/link/generated-file
checks; report unavailable checks.
checks; report any that are unavailable.
**Reuse a check when its inputs match.** Compare hashes or complete bytes of its
saved and current consumed files, fixtures, dependencies and execution parameters.
@@ -996,8 +995,8 @@ command, result/counts, timestamp and log, never resample. Mandatory reviews sti
`--allow-paths` exempts only release metadata. A `package.json` version-only edit
can qualify; scripts, dependencies and runtime configuration require live tests.
Uncertain edits cannot be exempted. Docs, TODO edits, new/generated tests and fixes
make evidence STALE even without a new code review. Use this example only after
confirming that every allowed edit is release metadata:
make evidence STALE even without a new code review. Use this example only once
every allowed edit is release metadata:
```bash
[ -d "${GSTACK_ROOT:-/-}/bin" ]&&[ -d "$GSTACK_ROOT/lib" ]||{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/.factory/skills/gstack;[ -d "$_r/bin" ]||_r=~/.factory/skills/gstack;[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). Fix: ./setup --host factory from your gstack checkout; ./setup --status shows it.">&2;exit 1;};GSTACK_ROOT=$_r;}
@@ -1024,7 +1023,7 @@ keeps its existing audit count; it does not authorize a third attempt.
### 5. Report, then push
Commit only approved, verified release changes left uncommitted after Step 15,
including generated outputs; use its grouping rules and never create an empty commit.
including generated outputs; use its grouping rules; never create an empty commit.
Preserve unrelated user files.
Paste build/docs/test results. Reuse waivers only for the same verified
@@ -1038,8 +1037,8 @@ its records:
`reason` and logger-stamped `actor`. If there are none, the coverage line
(`none detected (...)`, `partial (...)` or `UNAVAILABLE — ...`).
- Each test lane's `tests_ran` (N, ZERO-RUN or `count unavailable`).
Write this report in the auditor's posture: describe this session's own gate edits
as evidence for the reader to examine, not as positions to defend; a kept gate
Write this report in the auditor's posture: this session's own gate edits are
evidence for the reader to examine, not positions to defend; a kept gate
states its reason and nothing more. Otherwise continue to Step 17.
---
@@ -1087,7 +1086,7 @@ Branch on the echoed values:
`pre-push.local`; otherwise request manual integration. Dangling
symlinks are unmanaged. Never overwrite either policy.
2. **`REDACT_PREPUSH` not true AND `PREPUSH_PROMPTED: no`** — one-time
offer (fires once EVER, machine-wide). AskUserQuestion:
offer (once ever, machine-wide). AskUserQuestion:
> gstack can install a per-repo git pre-push hook that blocks pushes
> containing credentials (API keys, tokens, private keys). It's a
@@ -1101,17 +1100,16 @@ Branch on the echoed values:
If A: run `$GSTACK_ROOT/bin/gstack-config set redact_prepush_hook true`
then re-run the block and apply the same directory and unmanaged-hook rules above.
If B: run `$GSTACK_ROOT/bin/gstack-config set redact_prepush_hook false`.
ALWAYS (after either answer, but NOT if the question itself failed to
render — a failed AskUserQuestion must re-offer next time):
ALWAYS after either answer (never after a failed question; it must re-offer
next time):
```bash
[ -d "${GSTACK_ROOT:-/-}/bin" ]&&[ -d "$GSTACK_ROOT/lib" ]||{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/.factory/skills/gstack;[ -d "$_r/bin" ]||_r=~/.factory/skills/gstack;[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). Fix: ./setup --host factory from your gstack checkout; ./setup --status shows it.">&2;exit 1;};GSTACK_ROOT=$_r;}
GSTACK_STATE_ROOT=$($GSTACK_ROOT/bin/gstack-paths --get GSTACK_STATE_ROOT); : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
touch "$GSTACK_STATE_ROOT/.redact-prepush-prompted"
```
3. **Declined earlier** — continue
without comment.
3. **Declined earlier** — continue without comment.
**Open gate findings against an existing ready PR/MR:** when Step 9 left any gate
**Open gate findings against an existing ready PR/MR:** when Step 9 left a gate
finding `open`, look up an open PR/MR for `<branch-name>` before pushing
(`gh pr list --head <branch-name> --state open --json number,isDraft`; GitLab
`glab mr list --source-branch <branch-name> --output json`, reading `draft`). A lookup
@@ -1125,7 +1123,7 @@ failure STOPs here. If one exists and is not a draft:
the `Gate edits: N open` banner. Record B in the invocation record as a
human-authorised publication; Step 19 reads it. Never convert a ready PR to a draft.
A new PR, or an existing draft, needs no question: Step 19 creates or keeps the draft.
A new PR or an existing draft needs no question: Step 19 creates or keeps the draft.
**Idempotency check:** Check if the branch is already pushed and up to date.
@@ -1141,7 +1139,7 @@ echo "LOCAL: $LOCAL REMOTE: $REMOTE"
[ "$LOCAL" = "$REMOTE" ] && echo "ALREADY_PUSHED" || echo "PUSH_NEEDED"
```
If `ALREADY_PUSHED`, skip the push but continue to Step 18. Otherwise push with upstream tracking:
`ALREADY_PUSHED`: skip the push, continue to Step 18. Otherwise push with upstream tracking:
```bash
git push -u origin <branch-name>
@@ -1151,9 +1149,8 @@ git push -u origin <branch-name>
- **Non-fast-forward push:** fetch and inspect the remote, then merge under Step 3's
conflict rules. Run Steps 5–16 before returning to Step 17. Never rewrite history.
- **Authentication, hook or network failure:** repair the cause, then repeat Step 16
even if content is unchanged before returning to Step 17. Never bypass failed guards.
Never force-push.
Only a successful push or verified `ALREADY_PUSHED` proceeds.
even with unchanged content before returning to Step 17. Never bypass failed guards.
Never force-push. Only a successful push or verified `ALREADY_PUSHED` proceeds.
Continue to Step 18. No documentation writer runs after push.
@@ -1166,19 +1163,23 @@ First look up open PRs/MRs for `<branch-name>` on the detected platform:
- GitHub: `gh pr list --head <branch-name> --state open --json number,title,url`
- GitLab: `glab mr list --source-branch <branch-name> --output json` (defaults to open).
A successful empty array means new; one match supplies the existing title/identity.
An empty array means new; one match supplies the existing title/identity.
Lookup failure or ambiguous matches **STOP** for resolution, never mean no PR.
Save the result for Step 19's recheck.
The pr-body section prepares the title from that result, then publishes.
The pr-body section prepares the title from that result, then publishes;
`--no-pr` or an automation lane takes the handoff section.
> **STOP.** Before preparing the title and creating or updating the PR/MR with the verified documentation outcome (Steps 18-19), Read `sections/pr-body.md` relative to the installed `gstack-ship` SKILL.md directory and execute it
> in full. Do not work from memory — that section is the source of truth for this step.
> **STOP.** Before handing the branch to a coordinator instead of opening a PR (--no-pr or an automation lane; Step 18), Read `sections/handoff.md` relative to the installed `gstack-ship` SKILL.md directory and execute it
> in full. Do not work from memory — that section is the source of truth for this step.
## Step 20: Persist ship metrics
Log metrics for `/retro` through `gstack-review-log`; it handles project/branch paths,
JSON validation, storage and sync. It takes **no path argument**; do not build one.
JSON validation, storage and sync and takes **no path argument**.
```bash
[ -d "${GSTACK_ROOT:-/-}/bin" ]&&[ -d "$GSTACK_ROOT/lib" ]||{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/.factory/skills/gstack;[ -d "$_r/bin" ]||_r=~/.factory/skills/gstack;[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). Fix: ./setup --host factory from your gstack checkout; ./setup --status shows it.">&2;exit 1;};GSTACK_ROOT=$_r;}
@@ -1195,7 +1196,7 @@ Substitute from earlier steps:
- **VERIFY_RESULT**: "pass", "fail", or "skipped", set after Step 9 executes Step 8.1's verification list
- **VERSION**: `NEW_VERSION` from Step 12; `null` (unquoted) under NO_VERSION
The shell supplies the branch. Run this automatically, without confirmation.
The shell supplies the branch. Run it without confirmation.
---
@@ -1217,15 +1218,14 @@ if [ ! -f "$_NUDGE_MARKER" ] && [ "$_QT" = "false" ]; then
fi
```
The marker or enabled question_tuning suppresses it. To re-enable, remove
`$GSTACK_STATE_ROOT/.plan-tune-nudge-shown` before the next ship.
The marker or enabled question_tuning suppresses it; remove the marker to re-enable.
---
## Section self-check (before you finish)
List the applicable Section index entries and confirm each Read. If you worked from
memory, STOP, Read the section and redo that step. Use `gstack-version-bump`, never
memory, STOP, Read the section and redo that step. Use `gstack-version-bump`; never
hand-roll VERSION/package.json writes.
---
+7
View File
@@ -1,5 +1,12 @@
{
"newModules": [
{ "file": "lib/pricing.ts", "movedFrom": "test/helpers/pricing.ts (PRICING, estimateCostUsd; plan E1 moved the table under lib/ so bin/gstack-eval-plan can price arms)" },
{ "file": "lib/eval-plan.ts", "movedFrom": "(new: preregistration check, priced arms, $0 dry run, priced pilot ledger behind bin/gstack-eval-plan, plan E1)" },
{ "file": "lib/lane-ownership.ts", "movedFrom": "(new: lane ownership check behind bin/gstack-lane-check, plan E2)" },
{ "file": "lib/coordinator-contract.ts", "movedFrom": "(new: the inherited coordinator contract text, plan E2)" },
{ "file": "lib/owner-brief.ts", "movedFrom": "(new: the one-page owner brief and draft-direction close behind bin/gstack-owner-brief, plan E3)" },
{ "file": "lib/contributor-mode.ts", "movedFrom": "(new: maintainer-set detection behind bin/gstack-contributor-mode, plan E3)" },
{ "file": "lib/banned-terms.ts", "movedFrom": "(new: .gstack/banned-terms.json scan behind bin/gstack-banned-terms, plan E3)" },
{ "file": "scripts/lib/shard-engine.ts", "movedFrom": "scripts/test-strict-output.ts" },
{ "file": "autoplan/bin/phase-publication-hook.ts", "movedFrom": "(owner: the /autoplan publication guard; ENG-17 keeps it under the module limit)" },
{ "file": "autoplan/bin/guard-reasons.ts", "movedFrom": "(new: guard reason codes, denial text and anchors, ENG-17)" },
+11
View File
@@ -40,3 +40,14 @@ Later PRs of the wave append their rows; a row is never removed.
| p0-4-pipe-hidden-failure: a failing runner behind `\| tail` reported green; a zero-run lane reported green | C8 `lanes` (pipefail) | runner `node --test {files} 2>&1 \| tail -n 1` with a broken `lib/util.js`; runner that prints `# tests 0` | `test/pregate-selection.test.ts` "p0-4-pipe-hidden-failure" |
| p0-4-stale-pregate: a pre-gate record reused after the final stamp moved the tree | C8 publication | pregate.json recorded, then `README.md` edited | `test/pregate-invalidation.test.ts` "PREGATE_STALE for both" |
| p0-4-dropped-vendored-hunk: a `patchedDependencies` hunk silently dropped on regeneration | C8 `patches` (tier 2, deferred) | — | — (follow-up pre-gate PR) |
| p1-8-unpriced-arm: a paid eval ran arms whose price the table did not know, so the admission estimate was $0 | E1 `arms`, `dry-run` | `resolveArms` with a made-up model; `dryRun` with an unpriced arm | `test/pricing-eval-plan.test.ts` "EVAL_PRICE_MISSING", "price_table.missing" |
| p1-8-blank-prereg: bars and stop rules were written after the first priced call | E1 `prereg --check` | `preregTemplate()` with every section empty | `test/pricing-eval-plan.test.ts` "empty section fails the check" |
| p1-8-malformed-rows: rows without a prompt field reached the paid run; the retry prompt was never read | E1 `dry-run` | `rows.jsonl` with a non-JSON line and a row missing `prompt`; no retry prompt; a 100-token output budget | `test/pricing-eval-plan.test.ts` "flags malformed rows" |
| p1-8-pilot-overrun: a run kept reserving calls past its cap; unknown charges were counted as zero | E1 `pilot` | cap `$0.0001`; one attempt settled `unknown` | `test/pricing-eval-plan.test.ts` "EVAL_BUDGET_EXHAUSTED", "verdict unknown" |
| p2-9-overlapping-lanes: two lanes edited the same planned file and the second merge rewrote the first | E2 `gstack-lane-check` | fixture repo with `lane-a` (PR #7) and `lane-b` both pushed; planned `src/a.ts`, `docs/**` | `test/lane-coordination.test.ts` "LANE_CONFLICT" |
| p2-9-borrowed-evidence: a bundle produced on another machine was cited as a pass here | E2 `gstack-evidence verify` | bundle with no matching ledger record; `--rerun` on a moved tree | `test/lane-coordination.test.ts` "stays unverified", "moved tree refuses" |
| p2-9-contract-drift: lane prompts paraphrased the merge conditions and spend caps differently | E2 `{{COORDINATOR_CONTRACT}}` | resolver, `gstack-autoplan contract`, both reviewer prompts, spec's spawn pipe | `test/lane-coordination.test.ts` "same bytes" |
| p2-10-buried-gate: a 400-line final report hid the decisions and a critical finding | E3 `gstack-owner-brief render` | nine-item gate with one urgent finding | `test/owner-brief-contributor-banned.test.ts` "one page" |
| p2-10-draft-left-standing: `draft direction stands until the owner decides` survived the owner's answer | E3 `gstack-owner-brief close` | plan with three draft lines, one still pending | `test/owner-brief-contributor-banned.test.ts` "close rewrites" |
| p2-10-outside-pr-as-code: an outside PR was reviewed as code first and credited without carried-over evidence | E3 `gstack-contributor-mode` | explicit `maintainers`, `read` vs `write` permission, `gh` offline | `test/owner-brief-contributor-banned.test.ts` "contributor mode" |
| p2-10-banned-term: a renamed product's old name landed in README while the glossary was allowed | E3 `gstack-banned-terms` | diff touching README, `docs/migration/**`, the glossary and the term file itself | `test/owner-brief-contributor-banned.test.ts` "added lines are scanned" |
+10 -4
View File
@@ -13,9 +13,9 @@
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { execFile, spawnSync } from 'child_process';
import { promisify } from 'util';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'fs';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { dirname, join } from 'path';
import { createHash } from 'crypto';
import { runGeneration } from '../scripts/gen-skill-docs';
import { ALL_HOST_CONFIGS } from '../hosts/index';
@@ -45,9 +45,11 @@ function mustSh(cwd: string, cmd: string) {
/** A directory that looks like a gstack checkout (VERSION, setup, bin/gstack-config). */
function makeGstackLike(dir: string, git: boolean) {
mkdirSync(join(dir, 'bin'), { recursive: true });
mkdirSync(join(dir, 'lib'), { recursive: true });
writeFileSync(join(dir, 'VERSION'), '1.0.0.0\n');
writeFileSync(join(dir, 'setup'), '#!/bin/sh\necho SETUP_RAN "$@"\n', { mode: 0o755 });
writeFileSync(join(dir, 'bin', 'gstack-config'), '#!/bin/sh\necho false\n', { mode: 0o755 });
writeFileSync(join(dir, 'bin', 'gstack-autoplan'), '#!/bin/sh\necho "## Coordinator contract (fixture)"\n', { mode: 0o755 });
if (git) mustSh(dir, 'git init -q -b main && git add -A && git commit -q -m init');
}
@@ -90,7 +92,7 @@ async function snapshot(dir: string): Promise<string> {
return h.digest('hex');
}
interface HostRender { host: string; upgrade: string[]; spec: string[]; localDir: string }
interface HostRender { host: string; upgrade: string[]; spec: string[]; localDir: string; globalRoot: string }
const hostRenders: HostRender[] = [];
beforeAll(async () => {
@@ -109,7 +111,7 @@ beforeAll(async () => {
const spec = bashFences(readFileSync(specPath, 'utf8'));
const team = fence(upgrade, 'git rm -r --cached');
const localDir = team.match(/git rm -r --cached (\S+)\/ /)![1];
hostRenders.push({ host: config.name, upgrade, spec, localDir });
hostRenders.push({ host: config.name, upgrade, spec, localDir, globalRoot: config.globalRoot });
}
});
@@ -224,6 +226,10 @@ describe.skipIf(IS_WINDOWS)('C9: destructive upgrade/spec fences refuse bad path
const team = await run(wd, fence(h.upgrade, 'git rm -r --cached'), { LOCAL_GSTACK: local });
if (team.status !== 0 || existsSync(local)) problems.push(`${h.host}: team-mode removal did not run: ${team.stderr}`);
mustSh(wd.project, `git worktree add -q "${join(wd.w, 'wt')}" -b spec/x-1 HEAD`);
// The spawn reads the contract from this host's ~/<globalRoot>/bin: install the fixture there.
const installed = join(root, 'home', h.globalRoot);
mkdirSync(dirname(installed), { recursive: true });
if (!existsSync(installed)) symlinkSync(wd.gstack, installed);
const spawn = await run(wd, fence(h.spec, 'claude -p'), { SPAWN_PATH: join(wd.w, 'wt'), SPAWN_BRANCH: 'spec/x-1', ARCHIVE_PATH: join(wd.gstack, 'VERSION') });
const deadline = Date.now() + 5_000;
while (!existsSync(join(wd.w, 'claude.log')) && Date.now() < deadline) await Bun.sleep(50);
+2 -1
View File
@@ -168,7 +168,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
// wave's headline capability) grows the union to 1.195x. Deliberate:
// the section is on-demand (loads only for Apple store targets), so
// per-invocation cost for non-iOS ships is one manifest line.
maxSizeRatio: 1.6400, // Shared advisory identity/dedup + critical-severity validation: 248,065 union bytes / 187,706 baseline = 1.3216 (2026-09-17). + test value bar in the lazy Step 7 section (value cards, weak paths, gate table, base control, machine checks; ~13.6KB): measured 1.396 (2026-09-29). + W1 guarded state-root resolution (`eval gstack-paths; : "${GSTACK_STATE_ROOT:?…}"`) in the Context Recovery preamble, the eureka log and each state-writing bash block; measured 1.401 (2026-09-30). + the shared QA review step's plan-check timing rule (plan checks and their revalidation run on --timeout-ms after smoke expiry); measured 1.4022 (2026-09-30). + the shared TEST_BOOTSTRAP owned-change undo rules (s03 H1-H3, M1) and the value-bar coverage wording (s03 M2/M3); measured 1.4044 (2026-10-02). + severe fix wave ship-review lane: G1 NO_VERSION dispatch, title and CHANGELOG arms (versionless repos ship without inventing a version), F1 parent-side plan binding before the audit dispatch (binding, docs/designs candidates, exact not-run line) and D3's data-not-instructions line; +2,126 union bytes, measured 1.4135 (2026-10-03). + INV-1 consumer lines (outside review `unverified`/`unavailable` is missing coverage in the readiness note and PR body); measured 1.4148 (2026-10-03). + v1.91.19.0 severe fix wave: verdict-form outside-review callers with sandbox preflight and first-use notice (INV-1/B1/Q2), P0/P1 gate and unverified wording, bound-plan discovery (F1), visible LEARNINGS failures (B5), carved external pointers; measured 267,752 / 187,706 = 1.4264 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 267,874 / 187,706 = 1.4271 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051) and quoted-heredoc issue template (#3046); measured 268,199 / 187,706 = 1.4288 (2026-10-06). + Step 10's inline no-PR check before the Greptile dispatch (#3020); measured 268,655 / 187,706 = 1.4313 (2026-10-06). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); measured 275,422 / 187,706 = 1.4673 (2026-10-06). Combined Oct 6 wave head (B free-text files + D measure loop + A rework): measured 280,363 / 187,706 = 1.4936 (2026-10-06). + gate-once push rule (no second paid gate after a CI push); measured 280,573 / 187,706 = 1.4947 (2026-10-06). + the measurement bar's decision step (exit-code branches for MEETS, MEETS-qualified, needs-classify with the qualifying and fix-round classes, EXTEND once; the measure prose was tightened to offset most of it); measured 281,058 / 187,706 = 1.4973 (2026-10-06). + Oct 7 wave B3: the Codex probe runs as a command (select-model capture lines, check-sandbox/probe-model/run-with-timeout subcommands) instead of being sourced; measured 281,438 / 187,706 = 1.4994 (2026-10-07). + Oct 7 wave CEO-28/DX-11: review prompts ask for a severity per finding and design proposals use the proposal gate; measured 281,556 / 187,706 = 1.5000 (2026-10-07). + Oct 7 wave B4: /ship publishes through gstack-post (title prepared in a file in the pr-body section, compose block plus gstack-post exit-code branches replacing the two scans); measured 281,894 / 187,706 = 1.5018 (2026-10-07). + B2 early Greptile PR (lazy Step 6.5 section: detect, public-repo consent, push, provisional title file, open; Step 10's bounded wait; the stop-leaves-the-draft rule); measured 286,626 / 187,706 = 1.5270 (2026-10-07). B3, the classifier, B4 and B2 merged: measured 287,128 / 187,706 = 1.5297 (2026-10-07). + Oct 7 wave guard (CEO-20): conditional `run_in_background` request plus the background-run recovery at each foreground dispatch site; measured 281,370 / 187,706 = 1.4990 (2026-10-07). Merged with the guard area: measured 287,440 / 187,706 = 1.5313 (2026-10-07). + Oct 8 wave: the defensive-security framing and fixture summary mode reach the security specialist and the Red Team (stated once in the dispatch, referenced by the Red Team); measured 288,783 / 187,706 = 1.5385 (2026-10-08). + gate integrity (HONEST_WORK_GATE_INTEGRITY W3): the shared GATE_SCAN_BLOCK in the core checklist (2.5) and after Step 7's child, the four-option gate question and record fields in 9.4, tests_ran states in Step 5, Gate edits and per-lane counts in the PR body with draft-while-open; measured 300,143 / 187,706 = 1.5990 (2026-10-10). + Honest Work wave merged (W2/W3 gate integrity in review/ship, W4 kernel + DONE line in every tier-2+ skill, W5/W7 in spec/ship): re-measured on the integrated tree (2026-10-10).
maxSizeRatio: 1.6450, // Shared advisory identity/dedup + critical-severity validation: 248,065 union bytes / 187,706 baseline = 1.3216 (2026-09-17). + test value bar in the lazy Step 7 section (value cards, weak paths, gate table, base control, machine checks; ~13.6KB): measured 1.396 (2026-09-29). + W1 guarded state-root resolution (`eval gstack-paths; : "${GSTACK_STATE_ROOT:?…}"`) in the Context Recovery preamble, the eureka log and each state-writing bash block; measured 1.401 (2026-09-30). + the shared QA review step's plan-check timing rule (plan checks and their revalidation run on --timeout-ms after smoke expiry); measured 1.4022 (2026-09-30). + the shared TEST_BOOTSTRAP owned-change undo rules (s03 H1-H3, M1) and the value-bar coverage wording (s03 M2/M3); measured 1.4044 (2026-10-02). + severe fix wave ship-review lane: G1 NO_VERSION dispatch, title and CHANGELOG arms (versionless repos ship without inventing a version), F1 parent-side plan binding before the audit dispatch (binding, docs/designs candidates, exact not-run line) and D3's data-not-instructions line; +2,126 union bytes, measured 1.4135 (2026-10-03). + INV-1 consumer lines (outside review `unverified`/`unavailable` is missing coverage in the readiness note and PR body); measured 1.4148 (2026-10-03). + v1.91.19.0 severe fix wave: verdict-form outside-review callers with sandbox preflight and first-use notice (INV-1/B1/Q2), P0/P1 gate and unverified wording, bound-plan discovery (F1), visible LEARNINGS failures (B5), carved external pointers; measured 267,752 / 187,706 = 1.4264 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 267,874 / 187,706 = 1.4271 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051) and quoted-heredoc issue template (#3046); measured 268,199 / 187,706 = 1.4288 (2026-10-06). + Step 10's inline no-PR check before the Greptile dispatch (#3020); measured 268,655 / 187,706 = 1.4313 (2026-10-06). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); measured 275,422 / 187,706 = 1.4673 (2026-10-06). Combined Oct 6 wave head (B free-text files + D measure loop + A rework): measured 280,363 / 187,706 = 1.4936 (2026-10-06). + gate-once push rule (no second paid gate after a CI push); measured 280,573 / 187,706 = 1.4947 (2026-10-06). + the measurement bar's decision step (exit-code branches for MEETS, MEETS-qualified, needs-classify with the qualifying and fix-round classes, EXTEND once; the measure prose was tightened to offset most of it); measured 281,058 / 187,706 = 1.4973 (2026-10-06). + Oct 7 wave B3: the Codex probe runs as a command (select-model capture lines, check-sandbox/probe-model/run-with-timeout subcommands) instead of being sourced; measured 281,438 / 187,706 = 1.4994 (2026-10-07). + Oct 7 wave CEO-28/DX-11: review prompts ask for a severity per finding and design proposals use the proposal gate; measured 281,556 / 187,706 = 1.5000 (2026-10-07). + Oct 7 wave B4: /ship publishes through gstack-post (title prepared in a file in the pr-body section, compose block plus gstack-post exit-code branches replacing the two scans); measured 281,894 / 187,706 = 1.5018 (2026-10-07). + B2 early Greptile PR (lazy Step 6.5 section: detect, public-repo consent, push, provisional title file, open; Step 10's bounded wait; the stop-leaves-the-draft rule); measured 286,626 / 187,706 = 1.5270 (2026-10-07). B3, the classifier, B4 and B2 merged: measured 287,128 / 187,706 = 1.5297 (2026-10-07). + Oct 7 wave guard (CEO-20): conditional `run_in_background` request plus the background-run recovery at each foreground dispatch site; measured 281,370 / 187,706 = 1.4990 (2026-10-07). Merged with the guard area: measured 287,440 / 187,706 = 1.5313 (2026-10-07). + Oct 8 wave: the defensive-security framing and fixture summary mode reach the security specialist and the Red Team (stated once in the dispatch, referenced by the Red Team); measured 288,783 / 187,706 = 1.5385 (2026-10-08). + gate integrity (HONEST_WORK_GATE_INTEGRITY W3): the shared GATE_SCAN_BLOCK in the core checklist (2.5) and after Step 7's child, the four-option gate question and record fields in 9.4, tests_ran states in Step 5, Gate edits and per-lane counts in the PR body with draft-while-open; measured 300,143 / 187,706 = 1.5990 (2026-10-10). + Honest Work wave merged (W2/W3 gate integrity in review/ship, W4 kernel + DONE line in every tier-2+ skill, W5/W7 in spec/ship): re-measured on the integrated tree (2026-10-10). + Oct 10 multi-agent wave PR E2 (ship/sections/handoff.md: the no-PR automation handoff, with its Section-index row and STOP pointer in the skeleton), offset by tightening Steps 1–3, 12, 14–17 and 20–21 prose so the skeleton stays under 81,000 bytes (80,997); C-pregate landed 1,123 union bytes in tests.md in between; measured 308,619 / 187,706 = 1.6442 on the integra Line truncated
},
'plan-ceo-review': {
skill: 'plan-ceo-review',
@@ -606,6 +606,7 @@ do not launch the downstream skill or open a browser.`,
behavioral: 'prompt',
maxSkeletonBytes: 58_400, // + v2.0 {{ASIDE_RESEARCH}} (Aside first, WebSearch fallback); measured 57_356
minUnionBytes: 64_500, // measured union 67,430
maxSizeRatio: 1.07, // Oct 10 multi-agent wave PR E2: the lazy gate-and-file section renders {{COORDINATOR_CONTRACT}} (the block every spawned lane inherits word for word; `gstack-autoplan contract` prints the same bytes) before the --execute spawn pipe; measured 86,614 / 81,577 = 1.0617 after the contract became a fail-closed `CONTRACT=$(…)` capture (2026-10-10). The skeleton is unchanged (57,107).
mustContain: ['HARD GATE', 'dedupe', 'quality gate', 'acceptance criteria', 'archive'],
},
'setup-gbrain': {
+6
View File
@@ -52,6 +52,12 @@ export const IDENTIFIER_PLACEHOLDERS: Record<string, IdentifierGrammar> = {
'<new-version>': { grammar: /^[0-9]+(?:\.[0-9]+)*$/, what: 'NEW_VERSION from the version bump (dot-separated digits)' },
'<issue-number>': { grammar: /^[0-9]*$/, what: 'filed issue number, or empty when none was filed' },
'<check-number>': { grammar: NUM, what: 'canary check number' },
// /eval-plan (plan E1): the pilot's ledger inputs.
'<cap-usd>': { grammar: NUMBER, what: 'spend cap in USD' },
'<total-items>': { grammar: NUM, what: 'items in the full eval run' },
'<workers>': { grammar: NUM, what: 'concurrent eval workers' },
'<attempt-id>': { grammar: ID, what: 'PILOT_ATTEMPT id printed by pilot start' },
'<usd|unknown>': { grammar: /^(?:[0-9]+(?:\.[0-9]+)?|unknown)$/, what: 'actual cost in USD from the provider usage field, or unknown' },
'<run-id>': { grammar: NUM, what: 'GitHub Actions run id' },
'<PID>': { grammar: NUM, what: 'process id printed by an earlier block' },
'<retained-owner-token>': { grammar: ID, what: 'freeze owner token printed by acquire' },
+4 -64
View File
@@ -1,66 +1,6 @@
/**
* Per-model pricing tables.
*
* Prices are USD per million tokens as of `as_of`. Update quarterly.
* Standard text-token estimates, not billed totals: excludes cache writes,
* tool fees, and service-tier or long-context multipliers.
* Link to provider pricing pages:
* - Anthropic: https://www.anthropic.com/pricing#api
* - OpenAI: https://openai.com/api/pricing/
* - Google AI: https://ai.google.dev/pricing
*
* When a model isn't in the table, estimateCost returns 0 with a console warning.
* Prefer adding a new row to the table over guessing.
* Per-model pricing tables: re-exported from lib/pricing.ts (plan E1), which
* documents the billing categories the table excludes. Test and script
* importers keep this path; add rows in lib/pricing.ts.
*/
export interface ModelPricing {
input_per_mtok: number;
output_per_mtok: number;
cached_input_per_mtok?: number;
as_of: string; // YYYY-MM
}
export const PRICING: Record<string, ModelPricing> = {
// Claude (Anthropic)
// https://platform.claude.com/docs/en/models/fable-5-1/overview#pricing
'claude-fable-5-1': { input_per_mtok: 10.00, output_per_mtok: 50.00, cached_input_per_mtok: 0.25, as_of: '2026-09' },
'claude-opus-4-7': { input_per_mtok: 15.00, output_per_mtok: 75.00, as_of: '2026-04' },
'claude-sonnet-4-6': { input_per_mtok: 3.00, output_per_mtok: 15.00, as_of: '2026-04' },
'claude-haiku-4-5': { input_per_mtok: 1.00, output_per_mtok: 5.00, as_of: '2026-04' },
// OpenAI (GPT + o-series)
// https://developers.openai.com/api/docs/models/gpt-6-astra
'gpt-6-astra': { input_per_mtok: 10.00, output_per_mtok: 50.00, cached_input_per_mtok: 1.00, as_of: '2026-09' },
'gpt-5.4': { input_per_mtok: 2.50, output_per_mtok: 10.00, as_of: '2026-04' },
'gpt-5.4-mini': { input_per_mtok: 0.60, output_per_mtok: 2.40, as_of: '2026-04' },
'o3': { input_per_mtok: 15.00, output_per_mtok: 60.00, as_of: '2026-04' },
'o4-mini': { input_per_mtok: 1.10, output_per_mtok: 4.40, as_of: '2026-04' },
// Google
'gemini-2.5-pro': { input_per_mtok: 1.25, output_per_mtok: 5.00, as_of: '2026-04' },
'gemini-2.5-flash': { input_per_mtok: 0.30, output_per_mtok: 1.20, as_of: '2026-04' },
};
const WARNED = new Set<string>();
export function estimateCostUsd(
tokens: { input: number; output: number; cached?: number },
model: string | undefined
): number {
if (!model) return 0;
const row = PRICING[model];
if (!row) {
if (!WARNED.has(model)) {
WARNED.add(model);
console.error(`WARN: no pricing for model ${model}; returning 0. Add it to test/helpers/pricing.ts.`);
}
return 0;
}
// This helper expects uncached input and cache reads as disjoint counts.
// Preserve the historical 10% rate for rows without an explicit cache-read price.
const cachedRate = row.cached_input_per_mtok ?? row.input_per_mtok * 0.1;
const inputCost = tokens.input * row.input_per_mtok / 1_000_000;
const cachedCost = (tokens.cached ?? 0) * cachedRate / 1_000_000;
const outputCost = tokens.output * row.output_per_mtok / 1_000_000;
return +(inputCost + cachedCost + outputCost).toFixed(6);
}
export { EXCLUDED_BILLING_CATEGORIES, PRICING, estimateCostUsd, hasPricing, pricingRow, type ModelPricing } from '../../lib/pricing';
+147
View File
@@ -0,0 +1,147 @@
/**
* PR E2: lane ownership (`gstack-lane-check`), `gstack-evidence verify <bundle>`,
* and the coordinator contract that every lane prompt inherits byte for byte.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { COORDINATOR_CONTRACT, COORDINATOR_CONTRACT_HEADING } from '../lib/coordinator-contract';
import { readBundle, renderVerification, verifyBundle } from '../lib/evidence-bundle';
import { laneCheck, renderLaneCheck } from '../lib/lane-ownership';
import { RESOLVERS } from '../scripts/resolvers';
import { cleanup, makeFixtureRepo, recordAndBundle, runBin, type FixtureRepo } from './helpers/restamp-fixture';
const ROOT = path.resolve(import.meta.dir, '..');
const repos: FixtureRepo[] = [];
const fixture = () => { const r = makeFixtureRepo({ prefix: 'gstack-lane-' }); repos.push(r); return r; };
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
function commitOn(repo: FixtureRepo, branch: string, files: Record<string, string>): void {
repo.git('checkout', '-q', '-b', branch, 'main');
for (const [rel, body] of Object.entries(files)) { fs.mkdirSync(path.dirname(path.join(repo.work, rel)), { recursive: true }); fs.writeFileSync(path.join(repo.work, rel), body); }
repo.git('add', '-A');
repo.git('commit', '-q', '-m', `work on ${branch}`);
repo.git('push', '-q', 'origin', branch);
repo.git('checkout', '-q', 'main');
}
describe('lane ownership', () => {
test('flags planned files an in-flight branch or PR already touches, with the PR number and hunk count', () => {
const repo = fixture();
repo.git('push', '-q', 'origin', 'main');
commitOn(repo, 'lane-a', { 'src/a.ts': 'export const a = 1;\n', 'docs/notes.md': 'Previous release v1.2.3.0.\nleft-pad pinned at 1.2.3 (was 1.2.2)\nmore\n' });
commitOn(repo, 'lane-b', { 'src/b.ts': 'export const b = 2;\n' });
const check = laneCheck({
cwd: repo.work, planned: ['src/a.ts', 'docs/**', 'lib/untouched.ts'], branches: ['lane-b'], prs: [7], base: 'origin/main',
resolvePr: pr => (pr === 7 ? { branch: 'lane-a' } : { error: `no PR ${pr}` }),
});
expect(check.verdict).toBe('conflicts');
expect(check.intersections.map(i => [i.file, i.planned, i.lane.pr, i.hunks])).toEqual([
['docs/notes.md', 'docs/**', 7, 1],
['src/a.ts', 'src/a.ts', 7, 1],
]);
const text = renderLaneCheck(check);
expect(text).toContain('LANE_CONFLICT: src/a.ts planned=src/a.ts in-flight=PR #7 (lane-a) hunks=1');
expect(text).toContain('LANE: lane-b head=');
expect(text.trim().split('\n').pop()).toMatch(/^LANE_CHECK: conflicts planned=3 lanes=2 intersections=2 unresolved=0/);
});
test('a lane that is not fetched or a PR that cannot be resolved is unresolved, never silently clear', () => {
const repo = fixture();
repo.git('push', '-q', 'origin', 'main');
const check = laneCheck({ cwd: repo.work, planned: ['src/a.ts'], branches: ['never-fetched'], prs: [9], base: 'origin/main', resolvePr: () => ({ error: 'gh offline' }) });
expect(check.verdict).toBe('unresolved');
expect(check.unresolved.map(l => l.branch).sort()).toEqual(['#9', 'never-fetched']);
expect(renderLaneCheck(check)).toContain('LANE: PR #9 (#9) unresolved: gh offline');
const clear = laneCheck({ cwd: repo.work, planned: ['src/a.ts'], branches: [], base: 'origin/main' });
expect(clear.verdict).toBe('clear');
});
test('the bin prints the same lines and exits 1 on a conflict (LANE_CONFLICT) and 3 (refused) when a lane is unresolved', () => {
const repo = fixture();
repo.git('push', '-q', 'origin', 'main');
commitOn(repo, 'lane-a', { 'src/a.ts': 'export const a = 1;\n' });
const conflict = runBin(repo, 'gstack-lane-check', ['--planned', 'src/a.ts', '--branch', 'lane-a', '--base', 'origin/main']);
expect(conflict.out).toContain('LANE_CONFLICT: src/a.ts');
expect(conflict.status).toBe(1);
const unresolved = runBin(repo, 'gstack-lane-check', ['--planned', 'src/a.ts', '--branch', 'ghost', '--base', 'origin/main']);
expect(unresolved.out).toContain('LANE_CHECK: unresolved');
expect(unresolved.err).toContain('LANE_REF_UNAVAILABLE');
expect(unresolved.status).toBe(3);
const clear = runBin(repo, 'gstack-lane-check', ['--planned', 'lib/x.ts', '--branch', 'lane-a', '--base', 'origin/main', '--json']);
expect(clear.status).toBe(0);
expect(JSON.parse(clear.out).verdict).toBe('clear');
});
});
describe('gstack-evidence verify', () => {
test('ledger mode verifies a lane only from this machine\'s own record and log; a bundle from elsewhere stays unverified', () => {
const repo = fixture();
const bundleFile = path.join(repo.root, 'bundle.json');
recordAndBundle(repo, repo.work, [{ label: 'tests', command: 'true' }], bundleFile);
const read = readBundle(bundleFile);
expect(read.bundle).not.toBeNull();
const local = runBin(repo, 'gstack-evidence', ['verify', bundleFile]);
expect(local.status).toBe(0);
expect(local.out).toContain('VERIFY: tests verified');
expect(local.out).toMatch(/EVIDENCE_VERIFY: .* mode=ledger tree=same verified=1 unverified=0/);
const elsewhere = verifyBundle({ repoRoot: repo.work, bundle: read.bundle!, bundlePath: bundleFile, ledgerRecords: [] });
expect(elsewhere.claims[0]).toMatchObject({ label: 'tests', verdict: 'unverified', required: true });
expect(elsewhere.required_unverified).toEqual(['tests']);
expect(renderVerification(elsewhere)).toContain('required_unverified=tests');
});
test('--rerun replays each lane command on the bundle tree and marks a changed exit unverified; a moved tree refuses to rerun', () => {
const repo = fixture();
const bundleFile = path.join(repo.root, 'bundle.json');
recordAndBundle(repo, repo.work, [{ label: 'tests', command: 'true' }, { label: 'lint', command: 'echo lint-ok' }], bundleFile);
const bundle = readBundle(bundleFile).bundle!;
const ran: string[] = [];
const same = verifyBundle({ repoRoot: repo.work, bundle, bundlePath: bundleFile, rerun: true, runCommand: c => { ran.push(c); return c.includes('lint-ok') ? 0 : 1; } });
expect(ran).toHaveLength(2);
expect(same.mode).toBe('rerun');
expect(same.claims.map(c => c.verdict)).toEqual(['unverified', 'verified']);
expect(same.claims[0]!.reason).toContain('rerun exited 1, bundle recorded 0');
fs.writeFileSync(path.join(repo.work, 'moved.txt'), 'x\n');
repo.git('add', '-A'); repo.git('commit', '-q', '-m', 'move the tree');
const moved = verifyBundle({ repoRoot: repo.work, bundle, bundlePath: bundleFile, rerun: true, runCommand: () => { throw new Error('must not run'); } });
expect(moved.tree.same).toBe(false);
expect(moved.claims.every(c => c.verdict === 'unverified' && c.reason.includes('differs from bundle tree'))).toBe(true);
const bin = runBin(repo, 'gstack-evidence', ['verify', bundleFile, '--rerun']);
expect(bin.status).toBe(1);
expect(bin.err).toContain('EVIDENCE_UNVERIFIED');
});
});
describe('coordinator contract', () => {
test('the resolver, the prompt printer and `gstack-autoplan contract` emit the same bytes', () => {
expect(COORDINATOR_CONTRACT.startsWith(COORDINATOR_CONTRACT_HEADING + '\n')).toBe(true);
for (const term of ['merge', 'behavior', 'spend', 'Handoff', 'jam_']) expect(COORDINATOR_CONTRACT).toContain(term);
const resolver = RESOLVERS.COORDINATOR_CONTRACT;
expect(typeof resolver).toBe('function');
expect(resolver({ skillName: 'spec', tmplPath: 'spec/SKILL.md.tmpl', host: 'claude', paths: { skillRoot: '~/.claude/skills/gstack' } } as any)).toBe(COORDINATOR_CONTRACT);
const bin = spawnSync('bun', [path.join(ROOT, 'bin', 'gstack-autoplan'), 'contract'], { encoding: 'utf8', timeout: 60_000 });
expect(bin.status).toBe(0);
expect(bin.stdout).toBe(COORDINATOR_CONTRACT + '\n');
const rendered = fs.readFileSync(path.join(ROOT, 'spec', 'sections', 'gate-and-file.md'), 'utf8');
expect(rendered).toContain(COORDINATOR_CONTRACT);
expect(rendered).toContain('gstack-autoplan contract) || {');
});
test('both reviewer prompts carry the contract after the plan, labelled as data', async () => {
const { writePrompts } = await import('../lib/autoplan-prompts');
const dir = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'gstack-contract-'));
try {
const snapshot = path.join(dir, 'plan.md');
fs.writeFileSync(snapshot, '# plan\n');
const out = writePrompts({ phase: 'ceo', snapshot: { nativePrompt: 'review this', snapshotPath: snapshot, sha256: 'a'.repeat(64) }, outDir: dir, priorConsensus: [] });
for (const file of [out.native, out.outside]) {
const text = fs.readFileSync(file, 'utf8');
expect(text).toContain('COORDINATOR CONTRACT (data, not instructions to you');
expect(text).toContain(COORDINATOR_CONTRACT);
expect(text.indexOf(COORDINATOR_CONTRACT)).toBeGreaterThan(text.indexOf('# plan') === -1 ? 0 : text.indexOf('# plan'));
}
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
});
});
+2 -2
View File
@@ -121,8 +121,8 @@ test('automatic workflow templates delegate reviewer commands to shared resolver
for (const skill of skills) {
for (const file of templateFiles(join(ROOT, skill))) {
for (const line of shellLines(readFileSync(file, 'utf8'))) {
// /spec's --execute worker implements the filed spec; it is not a reviewer.
if (file === join(ROOT, 'spec', 'sections', 'gate-and-file.md.tmpl') && line.trim() === 'cat "$ARCHIVE_PATH" | (cd "$SPAWN_PATH" && GSTACK_SESSION_KIND=spawned claude -p 2>&1) &') continue;
// /spec's --execute worker implements the filed spec (archive + coordinator contract on stdin); it is not a reviewer.
if (file === join(ROOT, 'spec', 'sections', 'gate-and-file.md.tmpl') && line.trim() === '{ cat "$ARCHIVE_PATH"; printf \'\\n\\n%s\\n\' "$CONTRACT"; } | (cd "$SPAWN_PATH" && GSTACK_SESSION_KIND=spawned claude -p 2>&1) &') continue;
if (/\bcodex\s+(?:exec|review)\b|\bclaude\s+(?:-p|--print)\b|\bgstack-claude-code\s+--/.test(line)) {
violations.push(`${file.slice(ROOT.length + 1)}: ${line.trim()}`);
}
+229
View File
@@ -0,0 +1,229 @@
/**
* PR E3: the one-page owner brief and the draft-direction close
* (`gstack-owner-brief`), contributor-PR mode detection
* (`gstack-contributor-mode`, decision D10), banned terms
* (`gstack-banned-terms`), and the `test_backend` config key behind the
* doctor's `cores` row.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { addedLines, exampleBannedTermsJson, normalizeRules, renderBannedTerms, scanBannedTerms, validateBannedTerms } from '../lib/banned-terms';
import { CONTRIBUTOR_INSTRUCTIONS, detectContributorMode, renderDetection } from '../lib/contributor-mode';
import type { GateList } from '../lib/gate-list';
import { EXIT } from '../lib/headless-artifacts';
import { BRIEF_FILE, DRAFT_DIRECTION_PHRASE, closeDraftDirections, loadBriefInputs, renderBrief } from '../lib/owner-brief';
const ROOT = path.resolve(import.meta.dir, '..');
const dirs: string[] = [];
const tmp = (prefix: string) => { const d = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); dirs.push(d); return d; };
afterEach(() => { while (dirs.length) fs.rmSync(dirs.pop()!, { recursive: true, force: true }); });
function bin(name: string, args: string[], opts: { cwd?: string; env?: Record<string, string> } = {}) {
const r = spawnSync('bun', [path.join(ROOT, 'bin', name), ...args], { cwd: opts.cwd ?? ROOT, encoding: 'utf8', timeout: 60_000, env: { ...process.env, ...opts.env } });
return { status: r.status ?? -1, out: r.stdout ?? '', err: r.stderr ?? '' };
}
// ── owner brief ─────────────────────────────────────────────────────────────
function gateRun(dir: string, o: { items?: number; decided?: Record<string, string>; urgent?: boolean } = {}): GateList {
const n = o.items ?? 9;
const items = Array.from({ length: n }, (_, i) => ({
id: i === 0 ? 'p1' : `d${i}`, title: i === 0 ? 'Approve the plan' : `Taste decision ${i}`, kind: (i === 0 ? 'approval' : i % 3 === 0 ? 'approval' : 'auto') as 'auto' | 'approval',
recommended: 'a', options: [{ key: 'a', text: `option a for ${i}` }, { key: 'b', text: `option b for ${i}` }],
}));
const gate: GateList = { schema_version: 1, run: 'run-1', gate_rev: 2, items };
fs.writeFileSync(path.join(dir, 'gate.json'), JSON.stringify(gate));
const decisions = items.map(it => {
const chosen = o.decided?.[it.id];
return { schema_version: 1, run: 'run-1', id: `run-1-${it.id}`, label: it.id.toUpperCase(), title: it.title, options: ['a', 'b'], recommended: 'a', kind: it.kind, status: chosen ? 'answered' : 'pending', gate_rev: 2, chosen: chosen ?? null, answered_at: chosen ? '2026-10-10T12:00:00Z' : undefined };
});
fs.writeFileSync(path.join(dir, 'decisions.jsonl'), decisions.map(d => JSON.stringify(d)).join('\n') + '\n');
const findings = [{ id: 'F1', severity: 'High', title: 'a finding', resolution: 'accepted' }, ...(o.urgent ? [{ id: 'F2', severity: 'Critical', title: 'token leak outside this plan', urgent: true, suggested_owner: 'platform' }] : [])];
fs.writeFileSync(path.join(dir, 'findings.jsonl'), findings.map(f => JSON.stringify(f)).join('\n') + '\n');
fs.writeFileSync(path.join(dir, 'run.json'), JSON.stringify({ required_phases: ['ceo', 'eng'], phases: { ceo: { closed_at: 'x' }, eng: { closed_at: 'y' } } }));
return gate;
}
describe('owner brief', () => {
test('one page: URGENT block first, plain summary, seven numbered decisions with defaults or pending, and the review-record pointer', () => {
const dir = tmp('gstack-brief-');
gateRun(dir, { urgent: true, decided: { d1: 'b' } });
fs.writeFileSync(path.join(dir, 'review-record.md'), '## Review record\nlong\n');
const loaded = loadBriefInputs(dir);
if ('error' in loaded) throw new Error(loaded.error.message);
const brief = renderBrief(loaded.inputs, { outDir: dir });
const lines = brief.split('\n');
expect(lines[0]).toBe('# /autoplan owner brief — run run-1 (gate_rev 2, page 1/2)');
expect(brief.indexOf('## URGENT, outside this plan')).toBeLessThan(brief.indexOf('### Summary'));
expect(brief).toContain('token leak outside this plan');
expect(brief).toContain('2 review phases closed (ceo, eng); 2 findings (1 accepted into the plan).');
expect(brief).toContain('9 decisions below: 6 take their default if you do not answer; 3 need your answer (3 still pending).');
const numbered = lines.filter(l => /^\d+\. \[/.test(l));
expect(numbered).toHaveLength(7);
expect(numbered[0]).toContain('[p1]');
expect(numbered[0]).toContain('→ pending until you answer');
expect(numbered[1]).toContain('→ decided d1b on 2026-10-10');
expect(numbered[2]).toContain('→ default d2a taken if unanswered');
expect(brief).toContain('Full review: ' + path.join(dir, 'review-record.md'));
expect(brief).toContain('--gate-rev 2');
expect(renderBrief(loaded.inputs, { page: 2 }).split('\n').filter(l => /^\d+\. \[/.test(l))).toHaveLength(2);
expect(renderBrief(loaded.inputs, { summary: 'Owner-written summary.' })).toContain('Owner-written summary.');
});
test('the bin renders, defaults to <dir>/summary.md, writes brief.md with --write, and refuses when an input is missing', () => {
const dir = tmp('gstack-brief-bin-');
gateRun(dir);
fs.writeFileSync(path.join(dir, 'summary.md'), 'Three sentences the run wrote.\n');
const r = bin('gstack-owner-brief', ['render', '--out', dir, '--write']);
expect(r.status).toBe(EXIT.ok);
expect(r.out).toContain('Three sentences the run wrote.');
expect(fs.readFileSync(path.join(dir, BRIEF_FILE), 'utf8')).toBe(r.out);
const json = bin('gstack-owner-brief', ['render', '--out', dir, '--json']);
expect(JSON.parse(json.out)).toMatchObject({ run: 'run-1', gate_rev: 2, items: 9 });
fs.rmSync(path.join(dir, 'decisions.jsonl'));
const missing = bin('gstack-owner-brief', ['render', '--out', dir]);
expect(missing.status).toBe(EXIT.refused);
expect(missing.err).toContain('BRIEF_INPUT_MISSING');
});
test('close rewrites decided draft-direction lines to the option and date, leaves pending ones and reports them', () => {
const dir = tmp('gstack-brief-close-');
gateRun(dir, { decided: { d1: 'b', p1: 'a' } });
const loaded = loadBriefInputs(dir);
if ('error' in loaded) throw new Error(loaded.error.message);
const plan = [
'# Plan',
`- d1: cache layer — ${DRAFT_DIRECTION_PHRASE}.`,
`- d2: naming — ${DRAFT_DIRECTION_PHRASE}.`,
`- overall ${DRAFT_DIRECTION_PHRASE} (no id here, falls to the plan approval).`,
'- unrelated line',
].join('\n');
const r = closeDraftDirections(plan, loaded.inputs, '2026-10-10');
expect(r.rewrites.map(x => [x.line, x.id, x.status])).toEqual([[2, 'd1', 'rewritten'], [3, 'd2', 'pending'], [4, 'p1', 'rewritten']]);
expect(r.text.split('\n')[1]).toBe('- d1: cache layer — decided d1b (2026-10-10): option b for 1.');
expect(r.text.split('\n')[2]).toBe(`- d2: naming — ${DRAFT_DIRECTION_PHRASE}.`);
expect(r.text.split('\n')[3]).toContain('decided p1a (2026-10-10): option a for 0');
expect(r.unresolved.map(x => x.id)).toEqual(['d2']);
const file = path.join(dir, 'plan.md');
fs.writeFileSync(file, plan);
const closed = bin('gstack-owner-brief', ['close', '--out', dir, '--plan', file, '--date', '2026-10-10']);
expect(closed.status).toBe(EXIT.fail);
expect(closed.out).toContain('DRAFT_DIRECTIONS: rewritten=2 unresolved=1');
expect(closed.err).toContain('DRAFT_DIRECTION_UNRESOLVED');
expect(fs.readFileSync(file, 'utf8')).toContain('decided d1b (2026-10-10)');
});
});
// ── contributor mode ────────────────────────────────────────────────────────
describe('contributor mode', () => {
test('an explicit maintainers list decides without the network; an empty list falls to push permission; nothing available is off with the source recorded', () => {
const cwd = ROOT;
const policy = detectContributorMode({ cwd, author: 'outsider', maintainers: ['garrytan'], policySource: '.gstack/ship-policy.json@origin/main', resolvePermission: () => { throw new Error('must not be called'); } });
expect(policy).toMatchObject({ mode: 'on', source: 'policy', author: 'outsider' });
expect(detectContributorMode({ cwd, author: 'GarryTan', maintainers: ['garrytan'], policySource: 'x', resolvePermission: () => { throw new Error('no'); } }).mode).toBe('off');
const collaborators = detectContributorMode({ cwd, author: 'someone', maintainers: [], policySource: 'x', resolvePermission: () => ({ permission: 'read' }) });
expect(collaborators).toMatchObject({ mode: 'on', source: 'collaborators', permission: 'read' });
expect(detectContributorMode({ cwd, author: 'someone', maintainers: [], policySource: 'x', resolvePermission: () => ({ permission: 'write' }) }).mode).toBe('off');
const unavailable = detectContributorMode({ cwd, author: 'someone', maintainers: [], policySource: 'x', resolvePermission: () => ({ error: 'gh offline' }) });
expect(unavailable).toMatchObject({ mode: 'off', source: 'off', error: { code: 'CONTRIBUTOR_SOURCE_UNAVAILABLE' } });
const noAuthor = detectContributorMode({ cwd, maintainers: [], policySource: 'x', resolveAuthor: () => ({ error: 'no PR' }) });
expect(noAuthor.mode).toBe('off');
expect(noAuthor.error?.code).toBe('CONTRIBUTOR_SOURCE_UNAVAILABLE');
const forced = detectContributorMode({ cwd, maintainers: [], policySource: 'x', force: true, resolveAuthor: () => ({ error: 'no PR' }) });
expect(forced).toMatchObject({ mode: 'on', source: 'flag' });
expect(renderDetection(collaborators)).toMatch(/^CONTRIBUTOR_MODE: on author=someone source=collaborators permission=read — /);
});
test('the instructions are evidence-first: the table, the three decisions, and credit lines only when evidence carried over; CODEOWNERS is never consulted', () => {
for (const term of ['| Change | Evidence value | Risk surfaces | Decision |', 'accept', 'supersede', 'decline', 'Contributed by @<handle>', 'Co-Authored-By:', 'Supersedes #<n>', 'plain-language approval']) expect(CONTRIBUTOR_INSTRUCTIONS).toContain(term);
expect(CONTRIBUTOR_INSTRUCTIONS).toContain('never a higher one');
const src = fs.readFileSync(path.join(ROOT, 'lib', 'contributor-mode.ts'), 'utf8') + fs.readFileSync(path.join(ROOT, 'bin', 'gstack-contributor-mode'), 'utf8');
expect(src).not.toMatch(/CODEOWNERS['"`)]/);
expect(src).toContain('writeReceipt(');
const r = bin('gstack-contributor-mode', ['instructions']);
expect(r.status).toBe(EXIT.ok);
expect(r.out.trim()).toBe(CONTRIBUTOR_INSTRUCTIONS);
const forced = bin('gstack-contributor-mode', ['detect', '--contributor', '--author', 'x']);
expect(forced.out.split('\n')[0]).toMatch(/^CONTRIBUTOR_MODE: on author=x source=flag/);
expect(forced.out).toContain('CONTRIBUTOR-PR MODE');
});
});
// ── banned terms ────────────────────────────────────────────────────────────
describe('banned terms', () => {
test('the example validates; unknown keys, empty terms and escaping globs are BANNED_TERMS_INVALID', () => {
expect(validateBannedTerms(JSON.parse(exampleBannedTermsJson()))).toEqual([]);
expect(validateBannedTerms({ terms: ['x'], bogus: 1 }).map(e => e.path)).toEqual(['bogus']);
expect(validateBannedTerms({ terms: [] }).length).toBeGreaterThan(0);
expect(validateBannedTerms({ terms: ['x'], allowed_paths: ['../outside/**'] }).map(e => e.code)).toEqual(['BANNED_TERMS_INVALID']);
expect(validateBannedTerms({ terms: [{ term: 'legacy', allowed_paths: ['/abs/path'] }] }).map(e => e.code)).toEqual(['BANNED_TERMS_INVALID']);
expect(validateBannedTerms('nope')[0]!.message).toContain('JSON object');
});
test('added lines are scanned per term outside its allowed locations; findings name the term, file:line and the allowed paths', () => {
const config = { terms: ['blazing fast', { term: 'OldName', allowed_paths: ['CHANGELOG.md', 'docs/migration/**'], reason: 'renamed' }], allowed_paths: ['docs/glossary.md'] };
expect(normalizeRules(config).map(r => r.allowed_paths)).toEqual([['docs/glossary.md'], ['docs/glossary.md', 'CHANGELOG.md', 'docs/migration/**']]);
const diff = [
'diff --git a/README.md b/README.md', '--- a/README.md', '+++ b/README.md', '@@ -1,2 +1,4 @@', ' intro', '+It is blazing fast.', '+OldName lives on here.', '-gone', ' tail',
'diff --git a/docs/migration/v2.md b/docs/migration/v2.md', '--- /dev/null', '+++ b/docs/migration/v2.md', '@@ -0,0 +1 @@', '+OldName became NewName.',
'diff --git a/docs/glossary.md b/docs/glossary.md', '--- a/docs/glossary.md', '+++ b/docs/glossary.md', '@@ -1 +1 @@', '+blazing fast: a banned phrase',
'diff --git a/.gstack/banned-terms.json b/.gstack/banned-terms.json', '--- a/.gstack/banned-terms.json', '+++ b/.gstack/banned-terms.json', '@@ -1 +1 @@', '+"blazing fast"',
].join('\n');
const added = addedLines(diff);
expect([...added.keys()]).toEqual(['README.md', 'docs/migration/v2.md', 'docs/glossary.md', '.gstack/banned-terms.json']);
expect(added.get('README.md')!.map(l => l.line)).toEqual([2, 3]);
const r = scanBannedTerms(config, 'test', added);
expect(r.findings.map(f => [f.term, `${f.file}:${f.line}`])).toEqual([['blazing fast', 'README.md:2'], ['OldName', 'README.md:3']]);
expect(r.skipped).toEqual(['.gstack/banned-terms.json']);
const text = renderBannedTerms(r);
expect(text).toContain('BANNED_TERM: "OldName" README.md:3 allowed: docs/glossary.md, CHANGELOG.md, docs/migration/** (renamed)');
expect(text.trim().split('\n').pop()).toBe('BANNED_TERMS: found=2 terms=2 files=4 source=test');
expect(scanBannedTerms({ terms: ['Blazing'], allowed_paths: [] }, 'x', added).findings.map(f => f.file)).toEqual(['README.md', 'docs/glossary.md']);
});
test('the bin: `none` without a list (exit 0), findings on --files (exit 1), init and validate', () => {
const repo = tmp('gstack-banned-repo-');
const git = (...a: string[]) => { const r = spawnSync('git', a, { cwd: repo, encoding: 'utf8', timeout: 30_000, env: { ...process.env, GIT_CONFIG_GLOBAL: path.join(repo, '.gitconfig'), GIT_CONFIG_NOSYSTEM: '1' } }); if (r.status !== 0) throw new Error(r.stderr); return r.stdout.trim(); };
fs.writeFileSync(path.join(repo, '.gitconfig'), '[user]\n\tname = t\n\temail = t@test\n[init]\n\tdefaultBranch = main\n');
git('init', '-q');
fs.writeFileSync(path.join(repo, 'README.md'), 'hello\n');
git('add', '-A'); git('commit', '-q', '-m', 'base');
const none = bin('gstack-banned-terms', ['check', '--files', 'README.md'], { cwd: repo });
expect(none.status).toBe(EXIT.ok);
expect(none.out).toContain('BANNED_TERMS: none');
const init = bin('gstack-banned-terms', ['init'], { cwd: repo });
expect(init.status).toBe(EXIT.ok);
expect(bin('gstack-banned-terms', ['validate'], { cwd: repo }).status).toBe(EXIT.ok);
fs.writeFileSync(path.join(repo, 'README.md'), 'hello\nblazing fast\n');
const found = bin('gstack-banned-terms', ['check', '--files', 'README.md', '--policy-from', 'head'], { cwd: repo });
expect(found.status).toBe(EXIT.fail);
expect(found.out).toContain('BANNED_TERM: "blazing fast" README.md:2');
fs.writeFileSync(path.join(repo, '.gstack', 'banned-terms.json'), '{"terms": ["x"], "nope": true}\n');
const invalid = bin('gstack-banned-terms', ['validate'], { cwd: repo });
expect(invalid.status).toBe(EXIT.refused);
expect(invalid.err).toContain('BANNED_TERMS_INVALID');
});
});
// ── test_backend ────────────────────────────────────────────────────────────
describe('test_backend config', () => {
test('defaults to local, accepts only local|ubicloud, and the doctor cores row names the remote gate under 8 cores', () => {
const state = tmp('gstack-test-backend-');
const cfg = (...args: string[]) => { const r = spawnSync(path.join(ROOT, 'bin', 'gstack-config'), args, { encoding: 'utf8', timeout: 30_000, env: { ...process.env, GSTACK_STATE_ROOT: state, GSTACK_HOME: state } }); return { code: r.status ?? -1, out: (r.stdout ?? '').trim(), err: r.stderr ?? '' }; };
expect(cfg('get', 'test_backend')).toMatchObject({ code: 0, out: 'local' });
expect(cfg('set', 'test_backend', 'ubicloud').code).toBe(0);
expect(cfg('get', 'test_backend').out).toBe('ubicloud');
expect(cfg('set', 'test_backend', 'mars').code).not.toBe(0);
expect(cfg('get', 'test_backend').out).toBe('ubicloud');
expect(cfg('defaults').out).toContain('test_backend');
const doctor = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-doctor-check.sh'), 'utf8');
expect(doctor).toContain('gstack-config" get test_backend');
expect(doctor).toContain('set gstack-config set test_backend ubicloud, scripts/ubicloud/ubi-runner.sh');
});
});
+154
View File
@@ -0,0 +1,154 @@
/**
* PR E1: lib/pricing.ts (the table test/helpers/pricing.ts re-exports) and the
* /eval-plan machinery in lib/eval-plan.ts: preregistration check, priced arms,
* the $0 dry run, and the priced pilot ledger with its spend table.
*/
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 { EXCLUDED_BILLING_CATEGORIES, PRICING, estimateCostUsd, hasPricing, pricingRow } from '../lib/pricing';
import * as helperPricing from './helpers/pricing';
import {
PREREG_SECTIONS, checkPrereg, dryRun, pilotReport, preregTemplate, renderArms, renderDryRun, renderPilotReport, renderSpendTable,
resolveArms, settlePilot, startPilot,
} from '../lib/eval-plan';
const dirs: string[] = [];
const tmp = () => { const d = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-eval-plan-')); dirs.push(d); return d; };
afterEach(() => { while (dirs.length) fs.rmSync(dirs.pop()!, { recursive: true, force: true }); });
describe('lib/pricing', () => {
test('the helper re-exports the lib table unchanged and documents the excluded billing categories', () => {
expect(helperPricing.PRICING).toBe(PRICING);
expect(helperPricing.estimateCostUsd).toBe(estimateCostUsd);
expect(EXCLUDED_BILLING_CATEGORIES.length).toBeGreaterThan(0);
for (const row of Object.values(PRICING)) { expect(row.input_per_mtok).toBeGreaterThan(0); expect(row.output_per_mtok).toBeGreaterThan(0); expect(row.as_of).toMatch(/^\d{4}-\d{2}$/); }
});
test('provider-prefixed ids resolve to their row; unknown models price at 0 and report missing', () => {
expect(pricingRow('openai/gpt-6-astra')).toBe(PRICING['gpt-6-astra']);
expect(hasPricing('anthropic/claude-fable-5-1')).toBe(true);
expect(hasPricing('nobody/made-this-up')).toBe(false);
expect(estimateCostUsd({ input: 1_000_000, output: 1_000_000 }, 'gpt-6-astra')).toBe(60);
expect(estimateCostUsd({ input: 1_000_000, output: 0, cached: 1_000_000 }, 'claude-fable-5-1')).toBe(10.25);
expect(estimateCostUsd({ input: 10, output: 10 }, 'nobody/made-this-up')).toBe(0);
});
});
describe('preregistration', () => {
test('the template has every required section and an empty section fails the check', () => {
const t = preregTemplate('x');
const check = checkPrereg(t);
expect(check.missing).toEqual([]);
expect(check.empty.length).toBe(PREREG_SECTIONS.length);
expect(check.ok).toBe(false);
const filled = t.replace(/^(##\s+.+)$/gm, '$1\n- filled in');
expect(checkPrereg(filled)).toMatchObject({ ok: true, missing: [], empty: [] });
expect(checkPrereg('# nothing here\n').missing).toEqual([...PREREG_SECTIONS]);
});
});
describe('arms', () => {
test('explicit arms are priced from the table; an unpriced arm is EVAL_PRICE_MISSING', () => {
const d = tmp();
const priced = resolveArms({ cwd: d, explicit: ['gpt-6-astra', 'claude-fable-5-1'] });
expect(priced.errors).toEqual([]);
expect(priced.arms.map(a => a.priced)).toEqual([true, true]);
expect(renderArms(priced, { input: 1000, output: 1000 })).toContain('ARM: gpt-6-astra');
const unpriced = resolveArms({ cwd: d, explicit: ['gpt-6-astra', 'made-up-model'] });
expect(unpriced.errors.map(e => e.code)).toEqual(['EVAL_PRICE_MISSING']);
expect(renderArms(unpriced, { input: 1000, output: 1000 })).toContain('price=MISSING');
});
test('the eval-arms role is read through the models CLI seam, never the network', () => {
const d = tmp();
const calls: string[][] = [];
const selections = [
{ provider: 'openai', requestedModel: 'gpt-6-astra', status: 'selected', tier: 'implementation' },
{ provider: 'anthropic', requestedModel: 'claude-fable-5-1', status: 'selected', tier: 'implementation' },
{ provider: 'google', requestedModel: 'gemini-x', status: 'unavailable' },
];
const r = resolveArms({ cwd: d, modelsCli: args => { calls.push(args); return { code: 0, stdout: JSON.stringify({ selections }) }; } });
expect(calls).toEqual([['resolve', '--role', 'eval-arms', '--json']]);
expect(r.source).toBe('policy:eval-arms');
expect(r.arms.map(a => [a.model, a.provider, a.source])).toEqual([
['gpt-6-astra', 'openai', 'policy:eval-arms/implementation'],
['claude-fable-5-1', 'anthropic', 'policy:eval-arms/implementation'],
]);
const none = resolveArms({ cwd: d, modelsCli: () => ({ code: 1, stdout: '' }) });
expect(none.errors.map(e => e.code)).toEqual(['EVAL_ARMS_UNRESOLVED']);
});
});
describe('dry run', () => {
test('flags malformed rows and missing fields, requires the retry prompt and the output floor, and prices per item', () => {
const d = tmp();
const rows = path.join(d, 'rows.jsonl');
fs.writeFileSync(rows, [JSON.stringify({ id: 1, prompt: 'hello world' }), 'not json', JSON.stringify({ id: 3 }), ''].join('\n'));
const retry = path.join(d, 'retry.txt');
fs.writeFileSync(retry, 'Try again with the schema.\n');
const r = dryRun({ input: rows, arms: ['gpt-6-astra'], retryPrompt: retry });
expect(r.rows).toBe(3);
expect(r.bad_rows.map(b => b.line)).toEqual([2, 3]);
expect(r.bad_rows[0]!.missing).toEqual(['<not a JSON object>']);
expect(r.bad_rows[1]!.missing).toEqual(['prompt']);
expect(r.pass).toBe(false);
expect(r.per_item_usd['gpt-6-astra']).toBeGreaterThan(0);
expect(renderDryRun(r)).toContain('DRY_RUN:');
fs.writeFileSync(rows, JSON.stringify({ prompt: 'hello world' }) + '\n');
expect(dryRun({ input: rows, arms: ['gpt-6-astra'], retryPrompt: retry }).pass).toBe(true);
expect(dryRun({ input: rows, arms: ['gpt-6-astra'] }).checks.retry_prompt.ok).toBe(false);
expect(dryRun({ input: rows, arms: ['gpt-6-astra'], retryPrompt: retry, outputBudget: 100 }).checks.output_budget.ok).toBe(false);
expect(dryRun({ input: rows, arms: ['made-up'], retryPrompt: retry }).checks.price_table.missing).toEqual(['made-up']);
});
});
describe('priced pilot', () => {
function passingDryRun(d: string): string {
const rows = path.join(d, 'rows.jsonl');
fs.writeFileSync(rows, JSON.stringify({ prompt: 'x'.repeat(400) }) + '\n');
const retry = path.join(d, 'retry.txt'); fs.writeFileSync(retry, 'retry\n');
const file = path.join(d, 'dry-run.json');
fs.writeFileSync(file, JSON.stringify(dryRun({ input: rows, arms: ['gpt-6-astra'], retryPrompt: retry })));
return file;
}
test('reserves n attempts per arm under the cap, settles actual spend, and projects the full run with a band', () => {
const d = tmp();
const out = path.join(d, 'run');
const started = startPilot({ out, cap: 10, n: 3, arms: ['gpt-6-astra'], dryRun: passingDryRun(d), now: new Date('2026-10-10T00:00:00Z') });
expect(started.error).toBeUndefined();
expect(started.record.attempts).toHaveLength(3);
expect(started.record.status).toBe('reserved');
for (const [i, a] of started.record.attempts.entries()) settlePilot(out, a.attempt, 0.4 + i * 0.01);
const report = pilotReport(out, { total: 20, workers: 2 });
expect(report.settled_known).toBe(3);
expect(report.projection.per_item_usd).toBeCloseTo(0.41, 6);
expect(report.projection.projected_usd).toBeCloseTo(8.2, 6);
expect(report.projection.band_usd![0]).toBeLessThan(report.projection.projected_usd!);
expect(report.projection.verdict).toBe('within');
const text = renderPilotReport(report);
expect(text).toContain('PILOT:');
expect(renderSpendTable(report.spend, 10)).toContain('cap');
});
test('a reservation past the cap ends the pilot EVAL_BUDGET_EXHAUSTED; a failed dry run never starts it; unknown spend keeps the verdict unknown', () => {
const d = tmp();
const dry = passingDryRun(d);
const exhausted = startPilot({ out: path.join(d, 'tight'), cap: 0.0001, n: 3, arms: ['gpt-6-astra'], dryRun: dry });
expect(exhausted.error?.code).toBe('EVAL_BUDGET_EXHAUSTED');
expect(exhausted.record.status).toBe('budget_exhausted');
const failed = path.join(d, 'failed.json');
fs.writeFileSync(failed, JSON.stringify({ ...JSON.parse(fs.readFileSync(dry, 'utf8')), pass: false }));
expect(startPilot({ out: path.join(d, 'never'), cap: 10, n: 3, arms: ['gpt-6-astra'], dryRun: failed }).error?.code).toBe('EVAL_DRY_RUN_FAILED');
expect(fs.existsSync(path.join(d, 'never', 'pilot.json'))).toBe(false);
const out = path.join(d, 'unknown');
const s = startPilot({ out, cap: 10, n: 2, arms: ['gpt-6-astra'], dryRun: dry });
settlePilot(out, s.record.attempts[0]!.attempt, 0.3);
settlePilot(out, s.record.attempts[1]!.attempt, 'unknown');
const r = pilotReport(out, { total: 5 });
expect(r.settled_unknown).toBe(1);
expect(r.projection.verdict).toBe('unknown');
});
});
+56 -10
View File
@@ -53,7 +53,9 @@ describe('ubicloud free-suite runner', () => {
test('remote commands force umask 022 and teardown is trapped on exit', () => {
const runner = read('scripts/ubicloud/ubi-runner.sh');
expect(runner).toContain('"umask 022; $*"');
expect(runner).toMatch(/trap 'cmd_down "\$RUN_VM"[^']*' EXIT/);
// Armed before the create request and shielded from a second signal (record-before-create teardown, plan E2).
expect(runner).toMatch(/trap 'trap "" INT TERM HUP QUIT; cmd_down "\$RUN_VM"[^']*' EXIT/);
expect(runner.indexOf("trap 'trap \"\" INT TERM HUP QUIT; cmd_down \"$RUN_VM\"")).toBeLessThan(runner.indexOf('cmd_up "${up_args[@]}" >/dev/null'));
});
test('pull retrieves the retained free-test logs and skips a glob that matches nothing', () => {
@@ -93,24 +95,33 @@ describe('ubicloud free-suite runner', () => {
});
describe('ubi-runner teardown is confirmed and never sweeps other runners by default', () => {
function fakeApi(mode: { destroy: number; listed: string }) {
/**
* Fake Ubicloud API: `vm list` returns `listed`; `vm <loc>/<name> show` is 200
* while the name is listed and 404 after a successful destroy; `destroy`
* returns `destroy`. Every call body is appended to calls.log.
*/
function fakeApi(mode: { destroy: number; listed: string; owner?: string }) {
const root = mkdtempSync(join(tmpdir(), 'ubi-down-'));
const bin = join(root, 'bin'), state = join(root, 'state'), log = join(root, 'calls.log');
const bin = join(root, 'bin'), state = join(root, 'state'), log = join(root, 'calls.log'), record = join(root, 'vms.tsv');
mkdirSync(bin); mkdirSync(join(state, 'vm-a'), { recursive: true });
writeFileSync(join(state, 'vm-a/env'), 'NAME=vm-a\nLOCATION=eu-central-h1\nIP=192.0.2.1\n');
writeFileSync(join(state, 'vm-a/env'), 'NAME=vm-a\nLOCATION=eu-central-h1\nSIZE=standard-16\nIP=192.0.2.1\nCREATED=yes\nCREATE_AT=0\nCREATE_PID=1\n');
writeFileSync(join(root, 'listed'), mode.listed);
writeFileSync(join(bin, 'curl'), `#!/usr/bin/env bash
out=""; while [ $# -gt 0 ]; do [ "$1" = -o ] && out=$2; shift; done
body=$(cat); echo "$body" >> ${JSON.stringify(log)}
listed=${JSON.stringify(join(root, 'listed'))}
name=$(printf '%s' "$body" | sed -n 's/.*"vm", *"[^/"]*\\/\\([^"]*\\)".*/\\1/p')
case "$body" in
*destroy*) : > "$out"; printf '%s' ${mode.destroy} ;;
*list*) printf '%s' ${JSON.stringify(mode.listed)} > "$out"; printf 200 ;;
*destroy*) : > "$out"; if [ ${mode.destroy} = 200 ]; then grep -v " $name$" "$listed" > "$listed.tmp"; mv "$listed.tmp" "$listed"; fi; printf '%s' ${mode.destroy} ;;
*'"list"'*) cat "$listed" > "$out"; printf 200 ;;
*'"show"'*) if grep -q " $name$" "$listed"; then printf 'state: running\nsize: standard-16\nip4: 192.0.2.1\n' > "$out"; printf 200; else : > "$out"; printf 404; fi ;;
*) : > "$out"; printf 200 ;;
esac
`);
chmodSync(join(bin, 'curl'), 0o755);
const env = { ...process.env, PATH: `${bin}:${process.env.PATH}`, UBICLOUD_API_KEY: 'offline', UBI_RUNNER_STATE: state };
const env = { ...process.env, PATH: `${bin}:${process.env.PATH}`, UBICLOUD_API_KEY: 'offline', UBI_RUNNER_STATE: state, UBI_POLL_SECONDS: '1', UBI_VM_RECORD: record, UBI_OWNER: mode.owner ?? 'thrd1' };
const run = (...args: string[]) => Bun.spawnSync(['bash', join(DIR, 'ubi-runner.sh'), ...args], { env, timeout: 90_000 });
return { root, run, calls: () => (existsSync(log) ? readFileSync(log, 'utf8') : '') };
return { root, run, record, calls: () => (existsSync(log) ? readFileSync(log, 'utf8') : '') };
}
test('down fails when the destroy request fails', () => {
@@ -122,12 +133,14 @@ esac
} finally { rmSync(f.root, { recursive: true, force: true }); }
});
test('down succeeds only once the VM is no longer listed', () => {
const f = fakeApi({ destroy: 200, listed: 'eu-central-h1 other-vm\n' });
test('down succeeds only once the VM is no longer listed, and forgets its scratch record', () => {
const f = fakeApi({ destroy: 200, listed: 'eu-central-h1 vm-a\neu-central-h1 other-vm\n' });
try {
writeFileSync(f.record, 'vm-a\teu-central-h1\tthrd1\t2026-10-10T00:00:00Z\t1\nubirun-other-1000000000-aa\teu-central-h1\tother\t2026-10-10T00:00:00Z\t2\n');
const r = f.run('down', 'vm-a');
expect(r.exitCode).toBe(0);
expect(r.stderr.toString()).toContain('destroyed eu-central-h1/vm-a');
expect(readFileSync(f.record, 'utf8')).toBe('ubirun-other-1000000000-aa\teu-central-h1\tother\t2026-10-10T00:00:00Z\t2\n');
} finally { rmSync(f.root, { recursive: true, force: true }); }
});
@@ -138,4 +151,37 @@ esac
expect(f.calls()).toBe('');
} finally { rmSync(f.root, { recursive: true, force: true }); }
});
test('gc destroys only this owner’s stale VMs: another owner’s and untagged legacy names are never touched', () => {
const listed = 'eu-central-h1 ubirun-thrd1-1000000000-aaaa\neu-central-h1 ubirun-other-1000000000-bbbb\neu-central-h1 ubirun-1000000000-cccc\neu-central-h1 ubirun-thrd1-9999999999-dddd\n';
const f = fakeApi({ destroy: 200, listed });
try {
const r = f.run('gc', '1');
expect(r.exitCode).toBe(0);
const destroyed = f.calls().split('\n').filter(l => l.includes('destroy'));
expect(destroyed).toHaveLength(1);
expect(destroyed[0]).toContain('ubirun-thrd1-1000000000-aaaa');
expect(f.calls()).not.toMatch(/destroy.*ubirun-other/);
expect(f.calls()).not.toMatch(/destroy.*ubirun-1000000000-cccc/);
} finally { rmSync(f.root, { recursive: true, force: true }); }
});
test('list --mine shows this owner’s VMs only; owner tags are lowercase letters and digits, 12 max, starting with a letter', () => {
const f = fakeApi({ destroy: 200, listed: 'eu-central-h1 ubirun-thrd1-1000000000-aaaa\neu-north-h1 ubirun-other-1000000000-bbbb\neu-central-h1 ubirun-1000000000-cccc\n' });
try {
const mine = f.run('list', '--mine');
expect(mine.exitCode).toBe(0);
expect(mine.stdout.toString()).toBe('eu-central-h1 ubirun-thrd1-1000000000-aaaa\n');
const owner = Bun.spawnSync(['bash', join(DIR, 'ubi-runner.sh'), 'owner'], { env: { ...process.env, UBI_OWNER: '12-Capy_Thread-ABCDEFGH', UBI_RUNNER_STATE: f.root }, timeout: 10_000 });
expect(owner.stdout.toString().trim()).toBe('u12capythrea');
} finally { rmSync(f.root, { recursive: true, force: true }); }
});
test('up records the VM in the scratch file before the create request is sent', () => {
const runner = read('scripts/ubicloud/ubi-runner.sh');
const create = runner.indexOf('cli "${args[@]}" >/dev/null 2>"$dir/create.err"');
expect(create).toBeGreaterThan(0);
expect(runner.indexOf('record_vm "$name" "$location"')).toBeLessThan(create);
expect(runner).toContain('$HOME/.capy/work/ubi-runner/vms.tsv');
});
});