v1.91.252.0 feat(retro): factory scorecard (lead time, gate cost, red-train and flaky rates) and the recommended-harness adoption doc (#3471)

* feat(retro): factory scorecard bin (lead time, gate cost, red-train and flaky rates) and the recommended-harness adoption doc

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

* test(scorecard): register the recorded gh fixture with its free consumer

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

* test(scorecard): cover --gate-event any, an empty window, a failed PR read and a bad --now

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

* fix: pre-landing review fixes

Review pass 1 (core checklist, testing, maintainability, security, performance, simplification, state-machines, red team) over the whole diff:
- the flaky line and cost-per-merge depend on every input they read, so a failed free-run or PR read prints unavailable (...) on its half instead of 0
- a PR verdict-comment cost backs one gate run per PR; a READY comment after the merge is ignored
- one unreadable artifact leaves its run without cost or flake data instead of dropping the gate section
- --repo defaults to the origin owner/name and is required without one (no built-in repository)
- --max-artifact-runs, --pass-cap and --pass-marker are validated; --csv creates its directory
- one artifact listing per gate run; PR comments are paged; issues use the exact window instant
- every run appends its rows to trend.csv in --out-dir
- new row: review passes per PR (median, p90, PRs at the cap) from the ship receipt or a comment marker
- /retro: report format gains the Factory scorecard slot; the Step 7.5 fence quotes its placeholder
- tests for each; <window-days> joins the placeholder allowlist

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

* fix: review pass 2 (delta): usage text, pass-marker capture group, GitHub-host origin parsing, idempotent trend.csv

- usage() strips the header comment decoration again; --help text asserted
- passCount takes the first all-digit capture group; --pass-marker without a capture group exits 2
- --repo falls back to the origin remote only on github.com or GH_HOST; a local-path origin exits 2
- trend.csv keeps one row set per repo and window (a rerun replaces its rows) and always carries its header
- /retro Step 7.5 points at SCORECARD_TREND for the weeks; the doc cites the per-PR baseline CSV

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

* test(host-conformance): delete a named section in the C3 broken-link case

The case removed the first .md readdirSync returned under gstack-qa/sections.
qa-patterns.md and system-functional.md are referenced only in table form, which
setup --status does not count as section links, so on an ext4 directory order
that listed one of them first the deleted file broke nothing and --status exited
0: deterministic red on remote-gate VMs, green on Actions. Delete scope.md, which
every host's rendered SKILL.md reads.

Closes #3479

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

* v1.91.252.0 stamp

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-26 17:50:02 +00:00
1 parent fa3a2400b5
commit 8663c3d25f
18 files changed
+1696 -5

No files matched your search

+13
View File
@@ -1,5 +1,18 @@
# Changelog
## [1.91.252.0] - 2026-10-26
**`/retro` can now say whether the factory is getting better, and a new team has one page that takes it from zero to its first merged train on gstack + Capy.** `bin/gstack-factory-scorecard` reads one window (default 7 days) from GitHub through `gh` and prints a Markdown table plus a CSV: merged PRs (crew vs outside contributors), lead time (PR created → merged, and first READY comment → merged), gate runs green / red / cancelled with their known cost, cost per merge, the red-train rate, flaky events from the same two sources the daily flaky pass reads, issues opened vs closed, and releases cut. Each line carries its counting rule and what one of the best software factories would show on it; a line whose input cannot be read says `unavailable (<reason>)`, never 0. `docs/recommended-harness.md` is the adoption guide: install, the Captain and its Drive, the first train, the daily flaky pass, the weekly release, the scorecard with gstack's own numbers against the bar, what is Capy-specific, the options where two crews differ, and the known Capy limits as of 2026-10-26.
### Added
- **`bin/gstack-factory-scorecard`** (`lib/factory-scorecard.ts`): `--repo`, `--base`, `--since-days`, `--crew login,login` (else author association OWNER/MEMBER/COLLABORATOR), `--gate-workflow`, `--gate-event`, `--free-workflow`, `--train-prefix`, `--ready-marker`, `--pass-marker` and `--pass-cap` (review passes per PR: median, p90 and how many PRs sat at the cap, from the ship receipt's `"passes"` in the PR body or a `passes=N` / `REVIEW_PASSES: N` comment), `--max-artifact-runs`, `--out-dir`, `--csv`, `--json`, `--fixtures` (recorded gh responses, no network) and `--now`. `--repo` defaults to the origin remote's owner/name and is required without one; every run appends its rows to `trend.csv` in `--out-dir`. Gate cost comes from the run's `report-verdict-a*` artifact (`collector-outcomes.json` `.verdict.costUsd`), else the `**$N** rule/judge cost` or `cost=$N` line of the PR's verdict comment; flaky events from `flake-records-a*` (paid) and `flake-ledger-*` (free) artifacts. A READY comment posted after the merge is ignored, a PR's verdict-comment cost backs one gate run, and a run whose artifacts cannot be read is counted and named rather than scored 0. Read-only; exits 0 with the table even when an input is unavailable, 2 on bad usage.
- **`/retro` Step 7.5 (Factory scorecard, optional)**: the report format gains a **Factory scorecard** slot; the step runs the bin for the retro window when `gh` is authenticated and pastes the table, third column included, into the report.
- **`docs/recommended-harness.md`**, linked from the README docs table; `docs/standalone-binaries.md` lists the new CLI.
### Tests
- `test/factory-scorecard.test.ts`: the recorded window (`test/fixtures/factory-scorecard/recorded.json`) yields the expected merges, lead times, gate kinds and outcomes, artifact and comment cost sources, flake counts, free retries, issues and releases; a crew login list overrides the association rule; runs older than the window, non-dispatch events and draft releases are left out; the artifact cap leaves later runs without cost or flake data; a missing input marks only its lines unavailable; the markdown and CSV carry every row; gate classification, READY-marker matching, verdict-cost and flake-record parsing and nearest-rank percentiles; `--gate-event any` keeps non-dispatch runs; an empty window prints n/a instead of NaN; a failed merged-PR read leaves the gate rows standing; the bin end to end with `--fixtures`, `--crew`, `--out-dir`, `--csv`, `--ready-marker`, bad usage (including `--now`) and `--help`; the retro template pins the fence.
- `test/host-conformance.test.ts` C3 ("--status names a symlinked router and a broken section link per host") deletes a named section (`scope.md`) instead of the first `.md` that `readdirSync` returns: two gstack-qa sections are referenced only in table form, which `setup --status` does not count as links, so on an ext4 directory order that listed one of them first the case failed deterministically on remote-gate VMs while passing on Actions (#3479).
## [1.91.251.0] - 2026-10-26
**A free, deterministic design lint ships with gstack: `bin/gstack-design-lint` checks a page's HTML, CSS and components for the tells that make it read as machine-made, in under a second, and with `--render` measures what only a browser can.** Until now gstack's only mechanical design check was the opt-in impeccable engine most users never install, plus `gstack-design-craft`'s seventeen copy and ship-floor regexes; everything else in the Slop Gate was prose the model judged itself. The lint ports the roughly fifty-five machine-checkable gates of [Nutlope/hallmark](https://github.com/Nutlope/hallmark)'s `sloplint` (MIT; see NOTICE.md) into TypeScript under `lib/design-lint/`, rewritten in gstack's voice and mapped onto gstack's severity words. This release is the tool alone (Phase 1 of the hallmark absorption plan, approved by Garry on 2026-10-26); /design-html and /design-review start calling it in Phase 2, so no skill prose changes here.
+1
View File
@@ -234,6 +234,7 @@ On Windows without Developer Mode (MSYS2 / Git Bash), `setup` falls back to file
| Doc | What it covers |
|-----|---------------|
| [Skill Deep Dives](docs/skills.md) | Philosophy, examples, and workflow for every skill (includes Greptile integration) |
| [The recommended harness](docs/recommended-harness.md) | gstack + Capy from zero to the first merged train: install, the Captain and its Drive, trains, the daily flaky pass, the weekly release, the factory scorecard, what is Capy-specific, known Capy limits |
| [Why gstack](docs/why-gstack.md) | The story, the productivity numbers and their caveats, Karpathy's failure modes, parallel sprints |
| [Install details](docs/install.md) | Setup timing, requirements in detail, team mode, the hooks setup registers |
| [Other agents](docs/hosts.md) | OpenClaw and ClawHub, per-host notes, the Codex profile and model tiers, outside-review providers, the design binary's models |
+1 -1
View File
@@ -1 +1 @@
1.91.251.0
1.91.252.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.251.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.252.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows,
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* gstack-factory-scorecard — one window of a repository's shipping numbers,
* read from GitHub through gh: merged PRs (crew vs outside), lead time (created
* to merged, READY to merged), gate runs with known cost, red-train rate, flaky
* events, issues opened vs closed, releases cut. Markdown table on stdout plus
* a CSV; /retro Step 7.5 runs it; docs/recommended-harness.md explains each
* line and where gstack's own numbers stand. Read-only: no write, no paid work.
*
* Usage:
* gstack-factory-scorecard [--repo owner/name] [--base main] [--since-days 7]
* [--crew login,login] [--gate-workflow evals.yml] [--gate-event workflow_dispatch|any]
* [--free-workflow free-tests.yml] [--train-prefix train/] [--ready-marker <regex>]
* [--pass-marker <regex>] [--pass-cap 3] [--max-artifact-runs 60]
* [--out-dir <dir>] [--csv <file>] [--json] [--cache-dir <dir>]
* [--fixtures <recorded gh responses.json>] [--now <iso>]
*
* --repo defaults to the GitHub owner/name of the origin remote; without one,
* pass it. Every run writes this window's scorecard.{csv,md} and appends its
* rows to trend.csv in --out-dir, so the weeks line up.
* Exit 0 with the table even when an input is unavailable (the line says so);
* exit 2 on bad usage. Needs gh with read access to the repository unless
* --fixtures replays recorded responses.
*/
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 {
DEFAULTS, collectScorecard, fixtureSource, ghSource, renderCsv, renderMarkdown, scorecardRows, type FixtureFile, type ScorecardOptions,
} from '../lib/factory-scorecard';
function usage(code: number): never {
const lines = fs.readFileSync(new URL(import.meta.url), 'utf8').split('\n');
const text = lines.slice(2, lines.indexOf(' */')).map(l => l.replace(/^ ?\* ?/, '')).join('\n');
(code ? console.error : console.log)(text);
process.exit(code);
}
const args = process.argv.slice(2);
const flags = new Map<string, string>();
let json = false;
for (let i = 0; i < args.length; i++) {
const a = args[i]!;
if (a === '--help' || a === '-h') usage(0);
if (a === '--json') { json = true; continue; }
if (!a.startsWith('--') || i + 1 >= args.length) { console.error(`unexpected argument: ${a}`); usage(2); }
flags.set(a.slice(2), args[++i]!);
}
const sinceDays = Number(flags.get('since-days') ?? DEFAULTS.sinceDays);
if (!Number.isFinite(sinceDays) || sinceDays <= 0) { console.error('--since-days must be a positive number'); usage(2); }
const now = flags.has('now') ? Date.parse(flags.get('now')!) : Date.now();
if (!Number.isFinite(now)) { console.error('--now must be an ISO-8601 instant'); usage(2); }
let readyMarker: RegExp;
try { readyMarker = new RegExp(flags.get('ready-marker') ?? DEFAULTS.readyMarker, 'm'); } catch (e: any) { console.error(`--ready-marker: ${e.message}`); usage(2); }
let passMarker: RegExp;
try { passMarker = new RegExp(flags.get('pass-marker') ?? DEFAULTS.passMarker, 'm'); } catch (e: any) { console.error(`--pass-marker: ${e.message}`); usage(2); }
if (new RegExp(passMarker.source + '|').exec('')!.length < 2) { console.error('--pass-marker needs a capture group for the pass count'); usage(2); }
const passCap = Number(flags.get('pass-cap') ?? DEFAULTS.passCap);
if (!Number.isInteger(passCap) || passCap < 1) { console.error('--pass-cap must be a positive integer'); usage(2); }
const gateEvent = flags.get('gate-event') ?? DEFAULTS.gateEvent;
const maxArtifactRuns = Number(flags.get('max-artifact-runs') ?? DEFAULTS.maxArtifactRuns);
if (!Number.isInteger(maxArtifactRuns) || maxArtifactRuns < 0) { console.error('--max-artifact-runs must be a non-negative integer'); usage(2); }
function originRepo(): string {
const origin = spawnSync('git', ['remote', 'get-url', 'origin'], { encoding: 'utf8', timeout: 10_000 });
const host = (process.env.GH_HOST || 'github.com').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const slug = new RegExp(`(?:^|[@/])${host}[:/]([^/:]+\\/[^/]+?)(?:\\.git)?\\/?$`).exec((origin.stdout ?? '').trim())?.[1];
if (origin.status !== 0 || !slug) { console.error(`--repo owner/name is required: the origin remote is missing or not a ${process.env.GH_HOST || 'github.com'} owner/name`); usage(2); }
return slug;
}
const options: ScorecardOptions = {
repo: flags.get('repo') ?? originRepo(),
base: flags.get('base') ?? DEFAULTS.base,
now, sinceDays,
crew: flags.has('crew') ? flags.get('crew')!.split(',').map(s => s.trim()).filter(Boolean) : null,
gateWorkflow: flags.get('gate-workflow') ?? DEFAULTS.gateWorkflow,
gateEvent: gateEvent === 'any' ? null : gateEvent,
freeWorkflow: flags.get('free-workflow') ?? DEFAULTS.freeWorkflow,
trainPrefix: flags.get('train-prefix') ?? DEFAULTS.trainPrefix,
readyMarker, passMarker, passCap,
maxArtifactRuns,
};
const cacheDir = flags.get('cache-dir') ?? path.join(os.tmpdir(), 'gstack-factory-scorecard', options.repo.replace('/', '__'));
const source = flags.has('fixtures')
? fixtureSource(JSON.parse(fs.readFileSync(flags.get('fixtures')!, 'utf8')) as FixtureFile)
: ghSource(options.repo, cacheDir);
const card = collectScorecard(source, options);
const rows = scorecardRows(card);
const outDir = flags.get('out-dir') ?? path.join('.context', 'factory-scorecard');
fs.mkdirSync(outDir, { recursive: true });
const csvFile = flags.get('csv') ?? path.join(outDir, 'scorecard.csv');
fs.mkdirSync(path.dirname(csvFile), { recursive: true });
fs.writeFileSync(csvFile, renderCsv(card, rows));
fs.writeFileSync(path.join(outDir, 'scorecard.md'), renderMarkdown(card, rows));
if (json) fs.writeFileSync(path.join(outDir, 'scorecard.json'), JSON.stringify({ card, rows }, null, 2) + '\n');
const trendFile = path.join(outDir, 'trend.csv');
const [csvHeader, ...csvRows] = renderCsv(card, rows).split('\n').filter(Boolean);
const thisWindow = csvRows[0]!.split(',').slice(0, 3).join(',');
const kept = (fs.existsSync(trendFile) ? fs.readFileSync(trendFile, 'utf8').split('\n').filter(Boolean) : [])
.filter(line => line !== csvHeader && !line.startsWith(thisWindow + ','));
fs.writeFileSync(trendFile, [csvHeader, ...kept, ...csvRows].join('\n') + '\n');
process.stdout.write(renderMarkdown(card, rows));
process.stdout.write(`SCORECARD_CSV: ${csvFile}\n`);
process.stdout.write(`SCORECARD_MD: ${path.join(outDir, 'scorecard.md')}\n`);
process.stdout.write(`SCORECARD_TREND: ${trendFile}\n`);
+430
View File
@@ -0,0 +1,430 @@
# The recommended harness: gstack + Capy, from zero to the first merged train
This is the operating model a team adopts when it runs gstack under a Capy
Captain: one person, one Captain thread that directs a crew, crew threads that
each own a branch and stop at an unstamped READY, one controller that merges
through trains, a daily flaky pass, a weekly release, and a scorecard that says
whether the factory is getting better. It was written from two crews that ran
it for a week on their own repositories (about 100 merges a week each) and from
the recurring problems four Captains reported while doing so. Every path and
check name below is configurable; nothing here depends on gstack's own
repository.
Two layers, kept apart on purpose:
- **Harness-generic** (works in Claude Code, Codex and any host that reads the
SKILL.md files): `/ship`, `gstack-ship-queue`, `gstack-restamp`,
`gstack-pregate`, the ship receipt, the train protocol, the flaky-rerun
rule, `release-cut`, `gstack-factory-scorecard`, `/retro`.
- **Capy-specific**: the user-scope skill folder and its install check, the
Captain and its Drive, crew threads, automations, `tools.pr.create`, the
approvals relay. Each one is marked **Capy** below; the section at the end
lists what is only Capy.
```mermaid
flowchart LR
P[Person] -->|plain-language asks and one-word answers| C[Captain thread]
C -->|briefs, slot calls| L1[Crew thread: branch A]
C --> L2[Crew thread: branch B]
C --> L3[Crew thread: branch C]
L1 -->|/ship, READY block with SHIP_RECEIPT| C
L2 --> C
L3 --> C
C -->|train build: one gate for up to 5 PRs| G[(Gate workflow)]
G -->|GREEN or RED with flake records| C
C -->|slot: stamp, verify, merge, verify| M[(main)]
M -->|Monday| R[Weekly release: tag, notes, stable]
G -.->|flake-records artifacts| F[Daily flaky pass]
M -.->|free-suite retries| F
M -.->|/retro Step 7.5| S[Factory scorecard]
```
## Day 0: install
### 1. The user-scope gstack skill (Capy)
Copy `capy/skill/gstack/` from a gstack checkout into the person's user-scope
Drive as `skills/gstack/` (SKILL.md, `captain.md`, `scripts/install.sh`).
Add to the user-scope `AGENTS.md`:
> At the start of each session on a Capy cloud machine, load the user-scoped
> `gstack` skill and perform its installation check before starting work. If
> the check fails, follow the skill's session-start steps. Treat a session with
> no `gstack: ok` line from this machine as having no gstack: run no gstack
> workflow, claim none works, and report any blocker. Repeat this check on each
> separate task machine.
What that buys you: every thread runs `bash scripts/install.sh --check
[--for <skill>]` first and reports exactly one trailer, `gstack: ok <revision>
for=<skill>` or `gstack: fail <ids>`. The install is about 30 seconds cold
and prints the `GSTACK_LAUNCH:` assignments every later command needs
([docs/capy-install.md](capy-install.md)). A thread that runs `/ship` installs
with `--for ship`; a planning-only verdict (`SKIP pins (planning-only)`) does
not admit `/ship`, `/review` or `/qa`.
Two things the first week taught every crew:
- **Most threads never install.** The rule installs right before the first
workflow, so a thread that does not plan one never gets `/review` or
`/investigate` either. If most threads in a project run a workflow, bake
gstack into the project's Capy Setup snapshot instead
([capy-install.md#bake-into-a-capy-snapshot](capy-install.md#bake-into-a-capy-snapshot)):
each session then pays only the check.
- **One revision per thread.** Subagents on fresh machines install
separately and can land one patch apart. Put the minimum revision your crew
needs in the crew rules and have every first report quote the trailer, so a
lane that is behind says so.
### 2. The repository
The harness reads four files from the repository. A team can adopt them one at
a time; each is independent.
| File | Why | Without it |
|---|---|---|
| `.gstack/pregate.json` | Maps changed paths to test lanes, so `/ship` runs the tests that cover the diff and the remote gate selects correctly ([docs/pregate.md](pregate.md)) | `/ship` fails every touched file with `PREGATE_NO_LANE`. Write it once, from one owner, before the crew starts. |
| `.gstack/ship-policy.json` | `queue_mode`, the `train` block (gate and free workflow names, the full-gate check, the train size and fill wait), the stamp files ([docs/ship-policy.md](ship-policy.md)) | Per-PR queue; `train …` refuses with `QUEUE_TRAIN_OFF`. |
| `CHANGELOG.md` + `VERSION` | A stamp per merge and a weekly release with notes ([docs/releases.md](releases.md)) | Skip the version and changelog steps; the train still works on the tree receipt. |
| `AGENTS.md` validation discipline | Diagnose before fixing, focused checks during repair, one full suite at the end on a frozen tree | Threads retry blindly and report passes from older revisions. |
A `.github/workflows` gate that the train can dispatch is the one hard
requirement for trains: a workflow that accepts `workflow_dispatch` with the
train nonce and publishes a check (`full_gate_check`) and a record artifact on
the train head. gstack's own `evals.yml` is the reference; a repository with a
plain test workflow sets `gate_workflow` to it and gets a train without paid
cases.
### 3. The Captain and its Drive (Capy)
Create a Captain from the Capy app (New Captain) or with
`tools.captain.create`. Its Drive is mounted read-write on every crew thread;
this layout is what the crews converged on:
```
captain-<name>/
AGENTS.md the Captain's standing rules: mandate, severity bar, standing approvals, every decision quoted verbatim with its date
CREW.md the crew rules every thread reads at start (install check, validation discipline, READY shape, stamp-on-call, flaky rule, approvals)
DIGEST.md the state of the crew, "Needs you" first; rewritten, never appended
NOTES.md the Captain's running log
skills/ procedures the crew runs: merge-train/, captain-approvals/, and any repo-specific runbook
reports/<code>.md one per thread: Outcome, PR (with head SHAs), Needs, Cost, Lesson, Approvals
handoffs/ datasets and files the next thread needs (the flaky ledger lives here)
plans/ plans brought for approval
```
The phase-1 templates for these files (`AGENTS.md`, `CREW.md`, `DIGEST.md`, a
report, a READY block, an approvals table, a slot call) ship under
`captain/templates/` with the `/captain` skill, and `docs/captain.md`
explains how the Captain runs a day. Drop the templates in, replace the
placeholders (repository, crew login list, gate workflow name, minimum
revision, who may approve what), and the crew has its rules before its first
thread starts. The approvals shape itself (plan asks with before → after, why,
cause, big decisions and a one-word default; a `## Approvals` table in every
report and PR body) is the harness-generic part and is documented in
[capy/skill/gstack/captain.md](../capy/skill/gstack/captain.md).
## The first train
A lane (one crew thread, one branch) does this, in order. Every command is
harness-generic; only the thread and the PR tool are Capy.
1. **Branch from the default branch, build, test with focused checks.** Write
the CHANGELOG entry under `## [Unreleased]`; leave VERSION at main's value.
Never pre-pick a version.
2. **Push the final head.** Everything below binds to the pushed head; a later
push invalidates the receipt, the pregate record and the review binding.
3. **Open the PR** with the host's PR tool (Capy: `tools.pr.create`, never
`gh pr create`, so CI and review callbacks reach the thread), base the
default branch.
4. **Run `/ship` unattended** with the `GSTACK_LAUNCH` assignments
exported: it runs the pregate (on a machine under 8 cores the test tier
needs `gstack-pregate --stage tests --remote`, about $0.06 on a Ubicloud VM
or free locally for small selections), both reviews, the gate-integrity
list, the docs audit, and writes the ship receipt into the PR body
(`gstack-ship-receipt write --pr <n>`). With `queue_mode:
stamp-at-merge`, `/ship` does not bump the version. `/ship` never merges
and never merges main into the branch.
5. **READY.** `gstack-ship-queue ready --pr <n> --no-screen` (after running the
skill's own paid cases focused) or `--screen` (one paid screen on the merge
SHA). Send the printed block as is: `READY #<n> <head>
STAMP_VERSION=unstamped`, CI at that head, `PREGATE: pass`, the screen line
and `SHIP_RECEIPT: pr=<n> head=<head>`. A block with `SHIP_RECEIPT: none` is
not READY. From now on the head is frozen: a push drops the PR from any
train (`QUEUE_TRAIN_MOVED`) and the lane must READY again.
6. **Post the READY block as a PR comment too.** The Captain reads it in the
thread; the scorecard (below) measures READY → merge from the first PR
comment that carries it. A crew that only reports in-thread has no
READY-to-merge number.
The controller (the Captain, or a thread it designates) does this; the
Captain's `skills/merge-train/` runbook holds the crew's copy and
[ship-policy.md#run-a-merge-train](ship-policy.md#run-a-merge-train) is the
reference:
1. `gstack-ship-queue train build --prs 41@<sha>,42@<sha> --dry-run`, then
without `--dry-run`. Up to 5 READY pins, screened first; with fewer than 5
it waits until the oldest READY is `fill_hours` old, `--urgent` for one PR.
Refusals happen before any spend: a moved head, a missing receipt, a fork
head, a real conflict (the line names the PR and files; rebuild without it
and send it back to its lane to merge main).
2. `train verdict`: GREEN, possibly with `FLAKY N` (a case failed once and
passed its one in-run rerun; that is green, and the flake record goes to the
daily pass), RED (cases that failed twice: bisect on them, drop the attributed
PR, or rebuild once after an all-green bisect, never more), or INCOMPLETE
(one re-dispatch for an INFRA-only census).
3. **Per slot, in order:** call the lane ("#41 slot <version>, base <sha>,
order <…>"). The lane merges main if needed (CHANGELOG by section), runs
`gstack-restamp --next --base main --pr 41 --expect-base <sha> --expect-order
<…> --allow-repo-commands`, commits **only** the stamp files, pushes, waits
for required CI, and reports `READY #41 <stamped sha> STAMP_VERSION=<X>`.
The controller runs `gstack-ship-queue verify --pr 41 --train <nonce>`,
merges with the exact head (`gh pr merge 41 --squash --match-head-commit
<sha>`, or `--merge` where the repository keeps merge commits), and
verifies again. A stamp commit that holds anything beyond the stamp files
fails `verify --train`, freezes the queue and costs a backstop gate.
4. `train prune` when every slot has merged.
**Who merges.** One controller, never the lane. Both crews that ran this
learned it the same way: a PR merged off a green list state whose gate never
passed, and a stamp commit with one extra line that froze the queue.
## The daily flaky pass
The rule (set once by the owner and quoted in the crew rules): a case that
fails on its own verdict is rerun once at the same tree, inside the same run.
A pass is green with a flake record; a second failure is a real red; harness,
INFRA and INCOMPLETE reds are never flakes. Nothing is retried twice, and no
threshold, budget or trial count moves to manufacture a pass.
The pass reads two sources, the same two the scorecard counts:
- **Paid:** the gate workflow's `flake-records-a<attempt>` artifact
(`flake-records.jsonl`, one record per flaky case: case, kind, PR, head,
run, both attempts, symptom, rerun cost).
- **Free:** the free workflow's `flake-ledger-*` artifacts (one `flaky-pass`
line per file that failed and passed on the serial retry).
It appends each event to a ledger the crew can read (the crews keep it at
`handoffs/flaky-ledger.md` in the Captain's Drive: date, case, tier, PR and
head, both runs, a one-line symptom), ranks repeats first, and opens one fix
thread per day with the ledger's Open section as its brief. The fix thread
moves entries to Fixed (with the PR) or Won't fix (with the reason), never
deletes them. `bun run test:health` is the free weekly check on the free lane
(a file that flakes in more than 5% of main runs over at least 20 runs fails
it).
**As a Capy automation** (Capy): one automation, one schedule trigger a day in
the owner's timezone, prompt along the lines of "read the newest gate runs'
`flake-records-a*` artifacts and the free runs' `flake-ledger-*` artifacts
since the last pass, append new events to `handoffs/flaky-ledger.md`, rank
repeats first, start one fix thread with the Open section as its brief, and
report the count to the Captain". Pass `thread: "this"` to keep the history in
one place, and run it once by hand before enabling it.
## The weekly release
`.github/workflows/weekly-release.yml` runs `scripts/release-cut.ts` every
Monday morning: it takes the newest green commit on main's first-parent history
(its required check succeeded, the backstop check succeeded, every check run
completed and none failed, no active freeze), tags it `v<VERSION>`, publishes a
GitHub Release whose notes are one line per CHANGELOG version since the
previous release, and fast-forwards `stable`. Team-mode installs follow
`stable`; `gstack-config set update_channel main` follows every merge. A
hotfix is `--ref <sha on main>`. Details and the "nothing green in the window"
failure mode: [docs/releases.md](releases.md). A repository adopting this
copies the workflow, sets its required check and backstop check names
(`REQUIRED_CHECK`, `BACKSTOP_CHECK`), and keeps the CHANGELOG headline
convention ([docs/CHANGELOG_STYLE.md](CHANGELOG_STYLE.md)).
Watch for release starvation: when trains merge faster than the backstop
settles, most main commits are superseded before they are gated and the week
can end with no candidate. The fix is in tooling (accept a commit a green train
record covers), not in holding slots by hand.
## The factory scorecard
`bin/gstack-factory-scorecard` reads one window (default 7 days) from GitHub
through `gh` and prints a Markdown table plus a CSV; `/retro` runs it as
Step 7.5 when `gh` is authenticated. Read-only, no paid work; it downloads the
small verdict and flake artifacts, so a busy repository takes a few minutes
(`--max-artifact-runs` caps that). Every line prints its counting rule, and a
line whose input could not be read says `unavailable (<reason>)` rather than 0.
```bash
gstack-factory-scorecard --repo owner/name --since-days 7 \
[--crew login,login] [--gate-workflow evals.yml] [--free-workflow free-tests.yml] \
[--train-prefix train/] [--ready-marker 'READY #\d+|SHIP_RECEIPT:'] [--pass-marker '"passes": (\d+)|passes=(\d+)'] \
[--pass-cap 3] [--out-dir .context/factory-scorecard]
```
`--repo` defaults to the origin remote's owner/name on github.com (or
`GH_HOST`) and is required without one. Each run writes this window's `scorecard.md` and `scorecard.csv`
to `--out-dir` and appends the same rows to `trend.csv` there, so a weekly
retro accumulates its own trend without renaming files.
| Line | How it is counted | What one of the best software factories would show |
|---|---|---|
| Merged PRs | `merged_at` in the window, base = default branch; crew by `--crew` logins, else author association OWNER/MEMBER/COLLABORATOR | several merges a day, every day; outside contributions land in the same week |
| Lead time, created → merged | PR `created_at` to `merged_at`, median and p90 | median under 24h, p90 under 3 days (DORA "elite" change lead time is under a day) |
| Lead time, READY → merged | first PR comment matching `--ready-marker` at or before `merged_at`; PRs with no such comment are left out and counted | under 2h; every merged PR carries a READY comment |
| Review passes per PR | the largest count `--pass-marker` captures in the PR body (the ship receipt's `"passes": N` once /ship writes it) or a PR comment (`passes=N`, `REVIEW_PASSES: N`), median and p90, plus how many PRs sat at `--pass-cap` (3: pass 1 reviews the whole diff, later passes only the delta, a pass that finds nothing new is the exit) | median 2 passes; none at the cap |
| Gate runs green / red / cancelled | completed runs of the gate workflow (`workflow_dispatch` by default; `--gate-event any`); cost from the run's `report-verdict-a*` artifact (`collector-outcomes.json` `.verdict.costUsd`), else the `**$N** rule/judge cost` or `cost=$N` line of the PR's verdict comment | green on 95%+ of runs, cancelled near 0, cost known for every run |
| Known gate cost per merged PR | known cost / merged PRs; unknown-cost runs make it a floor | known, stable or falling week over week |
| Red-train rate | runs whose head branch starts with `--train-prefix`, cancelled excluded; red = conclusion other than success; red backstops and screens shown beside it | under 5% (each red train is a bisect plus a rebuild, about two gates) |
| Flaky events | `flake-records-a*` records of the newest gate runs (count, known rerun spend) and `flake-ledger-*` entries of free runs on the default branch; a run whose artifacts cannot be read is counted and named, never scored 0 | 0 repeat cases; under 1 flaky case per 10 gates; free retries under 1% of runs |
| Issues opened / closed | GitHub search, created and closed in the window | closed ≥ opened; nothing severe older than a week |
| Releases cut | releases published in the window, drafts excluded | at least one tagged release a week, on schedule |
The third column is a bar, not a boast: it is what DORA's elite band and the
best-run open source projects show, and it is in the table so a reader sees
the gap next to the number.
### Where gstack's own numbers stand
Measured on gstack's repository with this tool for 2026-10-19 to 2026-10-26
(the first full week of trains and `/ship`-before-READY; 103 merged PRs, 77
gate runs, artifacts read for the newest 60):
| Line | gstack, that week | Against the bar |
|---|---|---|
| Merged PRs | 103 (crew 100, outside 3) | volume is there; outside contributions are few and are ported by the crew rather than merged from forks |
| Lead time, created → merged | median 7.9h, p90 23.7h | inside the bar on both |
| Lead time, READY → merged | no number: 0 of 103 PRs had a READY comment | the crew reports READY in-thread; step 6 above (post it on the PR) is what makes this measurable |
| Review passes per PR | no number: 0 of 103 PRs carried a pass count | the receipt's `passes` field lands with /ship's convergence rule; until then crews record the count in the READY block, and the marker reads it from a PR comment |
| Gate runs | 73 green / 4 red / 0 cancelled; known cost $1,628 on 59 of 77 runs | green rate 95%; cost unknown on 18 runs (screens that planned no cases, and runs older than the artifact cap) |
| Known gate cost per merged PR | $15.81 (a floor) | about $24 per train of up to 5 PRs, plus screens; one gate per PR would have been about $33 each |
| Red-train rate | 4% (1 red of 28 trains; 0 red backstop, 3 red screens) | inside the bar; the three red screens were one judge reading a moved file |
| Flaky events | 21 paid cases passed on their one rerun (rerun spend $10.72); 17 free-suite retries in 17 of 103 runs | above the bar on both: 0.27 flaky cases per gate against 0.1, and 17% of free runs retried against 1%. One family (`ship-docsync-*`) repeated three times in two days |
| Issues opened / closed | 50 / 56 | inside the bar |
| Releases cut | 1 (the first weekly release) | on schedule, with the starvation caveat above |
Read the gap honestly: lead time and throughput are where they should be, the
gate is green and its cost is mostly known, and flakiness is the line that is
not. The daily pass exists for exactly that line; the scorecard's job is to
show next week whether it moved. The inventory lane's per-PR baseline CSV in
the Captain's Drive (142 merged PRs over the eleven days before this window
closed) holds the longer history behind these numbers; from here on
`trend.csv` in `--out-dir` carries the weeks.
## What is Capy-specific and what is not
| Concern | Harness-generic (any host) | Capy-specific |
|---|---|---|
| Install and health | `./setup`, `gstack-doctor`, `gstack-upgrade` | the user-scope skill folder, `scripts/install.sh --check` and its trailer, the project Setup snapshot |
| Running a workflow | the SKILL.md files, `GSTACK_SESSION_KIND=unattended`, `GSTACK_RESULT:` lines | the `GSTACK_LAUNCH` assignments, `GSTACK_CAPY=1` and the `capy-asks` block, no enforced hooks (safety skills are advisory) |
| Approvals | `gstack-approvals`, `gstack-owner-brief render --captain`, the `## Approvals` table | the Captain relay (`needs_you`), the private Drive mirror |
| Pull requests | `/ship`, the ship receipt, `gstack-pr-shots` | `tools.pr.create` (callbacks), `tools.pr.uploadAsset` (before/after images) |
| Merging | `gstack-ship-queue` (ready, train build, verdict, verify), `gstack-restamp`, `gh pr merge --match-head-commit` | the controller is a Captain thread; slot calls are thread messages |
| Flaky pass | the rerun rule, `flake-records-a*` and `flake-ledger-*` artifacts, `test:health` | the daily automation and the ledger in the Captain's Drive |
| Release | `release-cut`, `weekly-release.yml`, the `stable` channel | nothing |
| Scorecard | `gstack-factory-scorecard`, `/retro` Step 7.5 | nothing (the CSV can be kept in the Captain's Drive) |
| Memory | `reports/<code>.md`, `DIGEST.md`, the flaky ledger | the Drive scopes they live in |
## Options where crews differ
Two crews ran this a week on different repositories and kept different habits
on a few points. Ship the one that fits the repository; both are supported.
| Point | Option A | Option B |
|---|---|---|
| Merge method | squash with `--match-head-commit` (one commit per PR; CHANGELOG merges by section) | merge commit with `--match-head-commit` (keeps the lane's commits; `history: merge` in the policy) |
| Paid evidence per PR | one train gate covers up to 5 PRs; lanes choose `--screen` or focused own cases | a full gate per PR at handoff, plus "gate-ahead": PR N+1 gates on (its branch + N's head) so N's merge needs no second gate |
| Reusing a gate | tree rule: a head whose diff from the gated tree touches only stamp files reuses the gate (`gstack-tree-receipt`) | the same rule, checked by the controller with `git diff <gated> <head>` before every merge |
| Severe fixes racing for a slot | the next train, `--urgent` for one PR | call the slot for all; the first complete READY wins and the rest restamp to the next patch |
| Cheap CI on PR pushes | free tests on every push; paid work only in trains and screens | a cheap tier on pushes, a `full-ci` label that dispatches the full suites on the exact head; the cheap tier is never merge evidence |
| Infra flakes | never rerun a harness/INFRA red; one re-dispatch for an INFRA-only census | one named infra flake (for example a known runner wedge) may be rerun once and must be stated in the READY; a second wedge escalates |
| Small fixes | one PR each, batched by the train | batched into wave PRs, since serial slots cost about 30 minutes each |
| After the merge | the main backstop gates any push a train did not cover, and freezes the queue on red | the controller watches main's test, e2e and release runs until the release publishes; a red main freezes the queue until root-caused |
| Severe triage | the Captain watches issues and PRs and starts a thread per unowned severe item | a scheduled severe sweep (every 4 to 8 hours) with a fixed severity bar, dedupe against owned lanes, one hotfix thread per batch |
| Contributor PRs | ported onto a crew branch with the author's commits kept and `Co-authored-by`; the fork PR is closed with a thank-you that links the port | landed through a wave PR with "Contributed by @handle", `Co-Authored-By` and `Supersedes` lines |
| Privacy | the pregate's sealed-output and secret scans before every push | plus a per-user denylist grep (names, hostnames, identifying counts from private sources) before any public artifact |
## Known Capy limits
Collected from the issue files five Captains wrote on 2026-10-26 after a week
of running this harness on five repositories. Each is a limit of the platform or of the
harness on it as of that date, with the workaround the crews use.
**Install and versions**
- Every machine, subagents included, needs its own install and trailer; a
fresh machine spends 30 seconds to a few minutes on it, and crews with nine
shard subagents per run pay it nine times. Workaround: bake gstack into the
project Setup snapshot.
- Threads that plan no workflow never install, so `/review` and
`/investigate` go unused. Workaround: a crew rule that names the workflow
each thread will run, or the snapshot.
- gstack versions drift between machines in one thread (`behind=1` on one,
current on another); every minimum bump was a crew-wide broadcast (eight in
ten days for one crew). Workaround: the minimum revision in the crew rules,
quoted in every first report.
- A reviewer installed `--for review-voice` is refused by every workflow and
must reinstall `--for <skill>` when later asked to run one.
**Machines and long work**
- Machine sleep and restarts kill detached servers, local drivers and long
verify loops; `/tmp` does not survive a wake. Workaround: watched background
operations (they keep the machine awake), run directories under
`~/.capy/work`, long drivers on a remote VM, one worktree per counted run.
- Commits on a shared tree sweep up another subagent's uncommitted hunks.
Workaround: one committer per tree.
- Capy's git wrapper forces the linked identity, so tests that set
`GIT_AUTHOR_NAME` and assert on it fail on every Capy machine and pass in CI
(gstack's own `test/gstack-retro-metrics.test.ts` is one). Workaround: read
those failures as environment, and run the free suite on a remote VM for
acceptance.
- A broken shell line can print the environment, keys included, into a tool
log. Workaround: never run `env`, `printenv`, `set` or `export` bare;
check a secret as set or unset only (gstack's launch-env ratchet enforces
this in its own files).
**Pull requests and review**
- `tools.pr.create` without `index` or `head` publishes the primary
checkout's branch, not a worktree's, and targets the configured base, which
on a project that works off a non-default branch must be passed every time.
- Capy's per-push automatic review plus `/ship`'s two review passes double the
review time, and a review loop can run five or more cycles on a P0 as each
push surfaces one new finding. Workaround: run the review voices before the
first push; on a P0 fix only findings in the P0 path and defer the rest to a
follow-up.
- A clean review is not carried forward to a tree-identical merge head, which
can sit "running" for ten minutes or more.
- PR watches and CI callbacks fire only for repositories in a Capy project;
a thread waiting on a repository outside one sleeps until a timer wakes it.
- No per-subagent cost is visible, so `/ship` and plan reports cannot state
what the host subagents spent ("six host subagents, spend unknown").
- A merge rule keyed on "ship receipt on the head" has no machine-checkable
form on the platform side: the controller greps the PR body. `gstack-ship-queue
ready` and `train build` check it on the harness side.
**Approvals**
- An autoplan gate inside a crew thread costs a Captain round trip even when
the owner's standing answer is "take every recommendation"; record the
standing answer once with `gstack-approvals` and cite it. Approval rows
mirrored to the project-user Drive are not readable by other crew threads,
so the Captain carries the text into PR bodies.
**Harness gaps the same files name (fixed or owned in gstack, listed so the
reader knows the date):** the pregate failing every touched file in a
repository without `.gstack/pregate.json`; `/ship` printing no
`GSTACK_RESULT: skill=ship` line in unattended mode; the receipt going stale
on a late push (now refused by `ready`); the receipt-write step truncating a
PR body; `/ship`'s docs audit blocking on a risk the owner must accept by
hand; the pregate's secret scan refusing benign per-trial dummy tokens with no
allowlist; no cost ceiling surfaced at autoplan gates.
## Where things live
| What | Path |
|---|---|
| The user-scope skill and the Captain guide | `capy/skill/gstack/SKILL.md`, `capy/skill/gstack/captain.md`, `capy/skill/gstack/scripts/install.sh` |
| Captain templates and the Captain's day | `captain/templates/`, `docs/captain.md` (ship with the `/captain` skill) |
| Install on Capy, snapshots, the launch line | `docs/capy-install.md` |
| Ship policy, queue and trains | `docs/ship-policy.md`, `lib/ship-queue*.ts`, `bin/gstack-ship-queue`, `bin/gstack-restamp` |
| Pregate and lanes | `docs/pregate.md`, `bin/gstack-pregate` |
| Flaky reruns and records | `docs/TESTING_INTERNALS.md#flaky-reruns`, `scripts/lib/paid-rerun.ts`, `scripts/lib/ci-history.ts` |
| Weekly release | `docs/releases.md`, `scripts/release-cut.ts`, `.github/workflows/weekly-release.yml` |
| Scorecard | `bin/gstack-factory-scorecard`, `lib/factory-scorecard.ts`, `retro/SKILL.md.tmpl` Step 7.5, `test/factory-scorecard.test.ts` |
+1
View File
@@ -6,6 +6,7 @@ Beyond the slash-command skills, gstack ships standalone CLIs for workflows that
|---------|-------------|
| `gstack-model-benchmark` | **Cross-model benchmark** — run the same prompt through Claude, GPT (via Codex CLI), and Gemini; compare latency, tokens, cost, and (optionally) LLM-judge quality score. Auth detected per provider, unavailable providers skip cleanly. Output as table, JSON, or markdown. `--dry-run` validates flags + auth without spending API calls. |
| `gstack-taste-update` | **Design taste learning** — writes approvals and rejections from `/design-shotgun` into a persistent per-project taste profile. Decays 5%/week. Feeds back into future variant generation so the system learns what you actually pick. |
| `gstack-factory-scorecard` | **Factory scorecard** — one window (default 7 days) of a repository's shipping numbers, read from GitHub through `gh`: merged PRs (crew vs outside), lead time (created → merged, first READY comment → merged), gate runs with their known cost, red-train rate, flaky events (paid reruns and free-suite retries), issues opened vs closed, releases cut. Markdown table plus CSV, every line with its counting rule and what one of the best factories would show; `/retro` Step 7.5 runs it. Read-only, no paid work. [Guide](recommended-harness.md#the-factory-scorecard). |
| `gstack-egress` | **Egress receipt auditor** — every gstack-initiated off-machine send writes a tamper-evident, hash-chained receipt to `~/.gstack/security/egress.jsonl` before the send. `list` shows what gstack attempted to send and to which host, `grants` shows the standing consent settings plus the exact command that revokes each, `verify` recomputes the hash chain and exits 3 on tamper (catches edits, reordering, and mid-chain deletion; truncating or deleting the ledger itself is out of scope — it's a forensic log, not tamper-proof storage). |
| `gstack-context-bill` | **Token bill-of-materials** — read-only, offline audit of what an installed skills tree costs in tokens: always-on frontmatter every session pays vs per-invocation SKILL.md + forced references. `--diff` compares two trees, `--budget` enforces a ceiling, `--exact` opts into Anthropic `count_tokens` (sends the text of the billed `.md` files inside the tree, the linked gstack checkout or gstack's render folder off-machine, names and estimates anything else a symlink reaches, labels a part-estimated bill `mixed`; writes an egress receipt first, degrades to the offline estimate if the receipt can't be written). |
| `gstack-code-intelligence` | **Code-intelligence provider picker** — wraps GBrain, Sourcebot, and Graphify behind one interface: `options`/`status` to see what's available, `select` to pick one, `index`/`search` to use it, `suggest` to check whether the one-time indexing offer should fire here. The offer triggers on large repos (1,000+ tracked files; a decline is persisted). Non-local providers refuse to index *or search* until you record per-repo consent (`consent <repo> yes\|no` — the query text is repo-derived content), the per-repo trust policy's deny and read-only tiers veto write-class operations regardless of consent, and every off-machine send writes an egress receipt. Fully optional — with nothing selected, gstack falls back to grep. |
+420
View File
@@ -0,0 +1,420 @@
/**
* factory-scorecard — one window of a repository's shipping numbers, read from
* GitHub through `gh` (bin/gstack-factory-scorecard; /retro Step 7.5;
* docs/recommended-harness.md#the-factory-scorecard). Every line prints its
* counting rule next to the number, and a line whose input cannot be read says
* so ("unavailable: <reason>") instead of printing 0.
*
* Inputs, all REST reads (no write, no paid work):
* merged PRs pulls?state=closed&base=<base>, merged_at inside the window;
* crew = --crew logins, else author_association OWNER/MEMBER/COLLABORATOR
* lead time PR created_at -> merged_at (median, p90); ready -> merged when
* a PR comment matches --ready-marker (first such comment)
* gate runs <gate workflow> runs in the window (workflow_dispatch by default),
* kind by head branch (train/<nonce>) and run title (screen #, backstop);
* cost from the run's report-verdict artifact (collector-outcomes.json
* .verdict.costUsd), else the "**$N** rule/judge cost" / "cost=$N" line of
* the PR's verdict comment (used by one run per PR), else unknown
* flaky events flake-records-a<n> artifacts of gate runs (paid, one record per case
* that failed once and passed its one rerun) and flake-ledger-* artifacts
* of <free workflow> runs on <base> (free-suite serial retries); the same
* two sources the daily flaky pass reads
* issues search/issues created / closed inside the window
* releases releases published inside the window (drafts excluded)
*
* Reads are injectable (ScorecardSource) so the tests replay recorded gh
* responses from a fixture file and never touch the network.
*/
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import {
GH_CLIENT, downloadRunArtifacts, filesUnder, historyFetcher, listWorkflowRuns, readFlakeLedgerDir, unavailableReason,
type GhClient,
} from '../scripts/lib/ci-history';
export interface ScorecardSource {
getJson(apiPath: string): unknown;
/** Downloaded, unzipped artifact directories of one run whose names match, keyed by artifact name; one listing per call. */
artifactDirs(runId: number, match: (name: string) => boolean): Map<string, string>;
}
export interface ScorecardOptions {
repo: string;
base: string;
now: number;
sinceDays: number;
/** Crew logins; null means the author_association rule. */
crew: string[] | null;
gateWorkflow: string;
gateEvent: string | null;
freeWorkflow: string;
trainPrefix: string;
readyMarker: RegExp;
/** Matches a review pass count in the PR body (the ship receipt's "passes": N) or a comment; the first captured number is the count. */
passMarker: RegExp;
/** The review-pass cap (passes at or above it are counted as capped). */
passCap: number;
/** Newest runs whose artifacts are read; the rest count without cost or flake data. */
maxArtifactRuns: number;
}
export const DEFAULTS = {
base: 'main', sinceDays: 7, gateWorkflow: 'evals.yml', gateEvent: 'workflow_dispatch', freeWorkflow: 'free-tests.yml',
trainPrefix: 'train/', readyMarker: 'READY #\\d+|SHIP_RECEIPT:|gstack-ship-receipt',
passMarker: '"passes"\\s*:\\s*(\\d+)|\\bpasses=(\\d+)|\\bREVIEW_PASS(?:ES)?[:= ]\\s*(\\d+)', passCap: 3, maxArtifactRuns: 60,
} as const;
export const CREW_ASSOCIATIONS = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
const PAGE = 100;
const MAX_PAGES = 20;
const COST_LINE = /\*\*\$(\d+(?:\.\d+)?)\*\* rule\/judge cost|\bcost=\$(\d+(?:\.\d+)?)/g;
export interface MergedPr {
number: number; author: string; association: string; crew: boolean; title: string;
createdAt: string; mergedAt: string; readyAt: string | null; commentCostUsd: number | null; passes: number | null;
}
export type GateKind = 'train' | 'screen' | 'backstop' | 'other';
export type GateOutcome = 'green' | 'red' | 'cancelled';
export interface GateRun {
id: number; kind: GateKind; outcome: GateOutcome; createdAt: string; branch: string; title: string; pr: number | null;
costUsd: number | null; costSource: 'artifact' | 'comment' | null; flakes: number; flakeRerunUsd: number;
}
/** retries is null when the run's artifacts could not be read. */
export interface FreeRetryRun { id: number; conclusion: string | null; retries: number | null }
export interface Scorecard {
repo: string; base: string; windowStart: string; windowEnd: string;
prs: MergedPr[]; gates: GateRun[]; freeRuns: FreeRetryRun[]; artifactRunsRead: number; passCap: number;
/** Gate runs whose artifacts could not be read; they count without cost or flake data. */
artifactErrors: number;
issuesOpened: number | null; issuesClosed: number | null; releases: Array<{ tag: string; publishedAt: string }>;
unavailable: string[];
}
export interface ScorecardRow { id: string; metric: string; value: string; benchmark: string; rule: string }
// --- Sources ---
export function ghSource(repo: string, cacheDir: string, client: GhClient = GH_CLIENT): ScorecardSource {
const fetcher = historyFetcher(client);
return {
getJson: apiPath => client.getJson(apiPath),
artifactDirs: (runId, match) => new Map(downloadRunArtifacts({ repo, run: { id: runId }, match, cacheDir, fetcher }).map(dir => [path.basename(dir), dir])),
};
}
/** Recorded gh responses: { responses: { "<api path>": <json> }, artifacts: { "<run id>": { "<artifact name>": { "<file>": "<text>" } } } }. */
export interface FixtureFile { responses: Record<string, unknown>; artifacts?: Record<string, Record<string, Record<string, string>>> }
export function fixtureSource(fixture: FixtureFile, scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-scorecard-'))): ScorecardSource & { calls: string[] } {
const calls: string[] = [];
return {
calls,
getJson: apiPath => {
calls.push(apiPath);
if (!(apiPath in fixture.responses)) throw new Error(`no recorded response for ${apiPath}`);
return fixture.responses[apiPath];
},
artifactDirs: (runId, match) => {
const dirs = new Map<string, string>();
for (const [name, files] of Object.entries(fixture.artifacts?.[String(runId)] ?? {})) {
if (!match(name)) continue;
const dir = path.resolve(scratch, String(runId), name);
for (const [file, text] of Object.entries(files)) {
const target = path.resolve(dir, file);
if (!target.startsWith(dir + path.sep)) throw new Error(`fixture artifact file escapes its directory: ${file}`);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, text);
}
dirs.set(name, dir);
}
return dirs;
},
};
}
// --- Reads ---
function pullsPage(source: ScorecardSource, o: ScorecardOptions, page: number): any[] {
const body = source.getJson(`repos/${o.repo}/pulls?state=closed&base=${encodeURIComponent(o.base)}&sort=updated&direction=desc&per_page=${PAGE}&page=${page}`);
return Array.isArray(body) ? body : [];
}
/** Every PR merged into base inside the window; pages stop once a page's oldest update predates the window. */
export function readMergedPrs(source: ScorecardSource, o: ScorecardOptions, since: string): MergedPr[] {
const out: MergedPr[] = [];
const crewLogins = o.crew ? new Set(o.crew.map(l => l.toLowerCase())) : null;
for (let page = 1; page <= MAX_PAGES; page++) {
const pulls = pullsPage(source, o, page);
for (const pr of pulls) {
const mergedAt = typeof pr.merged_at === 'string' ? pr.merged_at : null;
if (!mergedAt || mergedAt < since) continue;
const author = String(pr.user?.login ?? '');
const association = String(pr.author_association ?? 'NONE');
const comments = readComments(source, o.repo, Number(pr.number), { readyMarker: o.readyMarker, passMarker: o.passMarker, mergedAt });
const bodyPasses = passCount(String(pr.body ?? ''), o.passMarker);
out.push({ number: Number(pr.number), author, association, title: String(pr.title ?? ''),
crew: crewLogins ? crewLogins.has(author.toLowerCase()) : CREW_ASSOCIATIONS.has(association),
createdAt: String(pr.created_at), mergedAt, readyAt: comments.readyAt, commentCostUsd: comments.costUsd,
passes: bodyPasses === null ? comments.passes : Math.max(bodyPasses, comments.passes ?? 0) });
}
const oldest = pulls[pulls.length - 1]?.updated_at;
if (pulls.length < PAGE || (typeof oldest === 'string' && oldest < since)) break;
}
return out.sort((a, b) => a.mergedAt.localeCompare(b.mergedAt));
}
/** The largest pass count the marker captures in a text, null when it never matches. */
export function passCount(text: string, marker: RegExp): number | null {
let max: number | null = null;
for (const m of text.matchAll(new RegExp(marker.source, marker.flags.includes('g') ? marker.flags : marker.flags + 'g'))) {
const digits = m.slice(1).find(g => g !== undefined && /^\d+$/.test(g));
if (digits === undefined) continue;
const n = Number(digits);
if (max === null || n > max) max = n;
}
return max;
}
/** The first READY-style comment's time (comments after the merge are ignored), the newest verdict comment's cost and the largest pass count, across every page of a PR's comments. */
export function readComments(source: ScorecardSource, repo: string, pr: number, m: { readyMarker: RegExp; passMarker: RegExp; mergedAt?: string }):
{ readyAt: string | null; costUsd: number | null; passes: number | null } {
let readyAt: string | null = null;
let costUsd: number | null = null;
let passes: number | null = null;
for (let page = 1; page <= MAX_PAGES; page++) {
const body = source.getJson(`repos/${repo}/issues/${pr}/comments?per_page=${PAGE}&page=${page}`);
const comments = Array.isArray(body) ? body : [];
for (const c of comments) {
const text = String(c.body ?? '');
const at = String(c.created_at ?? '');
if (m.readyMarker.test(text) && (!m.mergedAt || at <= m.mergedAt) && (!readyAt || at < readyAt)) readyAt = at;
for (const match of text.matchAll(COST_LINE)) costUsd = Number(match[1] ?? match[2]);
const n = passCount(text, m.passMarker);
if (n !== null && (passes === null || n > passes)) passes = n;
}
if (comments.length < PAGE) break;
}
return { readyAt, costUsd, passes };
}
export function classifyGate(run: { branch: string; title: string }, trainPrefix: string): GateKind {
if (run.branch.startsWith(trainPrefix)) return 'train';
if (/backstop/i.test(run.title)) return 'backstop';
if (/screen #\d+/i.test(run.title)) return 'screen';
return 'other';
}
export function gateOutcome(conclusion: string | null): GateOutcome {
if (conclusion === 'success') return 'green';
if (conclusion === 'cancelled' || conclusion === 'skipped') return 'cancelled';
return 'red';
}
/** Cost from the verdict artifact (collector-outcomes.json .verdict.costUsd), null when absent or unreadable. */
export function verdictCost(dirs: string[]): number | null {
for (const file of dirs.flatMap(dir => filesUnder(dir, base => base === 'collector-outcomes.json'))) {
try {
const cost = JSON.parse(fs.readFileSync(file, 'utf8'))?.verdict?.costUsd;
if (typeof cost === 'number' && Number.isFinite(cost)) return Math.round(cost * 100) / 100;
} catch { /* unreadable: no cost from this file */ }
}
return null;
}
/** gate-flake records (one per case that failed once and passed its rerun) and their known rerun spend. */
export function flakeRecords(dirs: string[]): { flakes: number; rerunUsd: number } {
let flakes = 0;
let rerunUsd = 0;
for (const file of dirs.flatMap(dir => filesUnder(dir, base => base.endsWith('.jsonl')))) {
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
if (!line.trim()) continue;
try {
const rec = JSON.parse(line);
if (rec?.kind !== 'gate-flake') continue;
flakes++;
if (typeof rec.rerun_cost_usd === 'number') rerunUsd += rec.rerun_cost_usd;
} catch { /* torn line */ }
}
}
return { flakes, rerunUsd: Math.round(rerunUsd * 100) / 100 };
}
/** Gate runs in the window with kind, outcome, cost and flake records; artifacts are read for the newest maxArtifactRuns only. */
export function readGateRuns(source: ScorecardSource, o: ScorecardOptions, since: string, prCost: Map<number, number>): { gates: GateRun[]; artifactRunsRead: number; artifactErrors: number } {
const query = [`status=completed`, `per_page=${PAGE}`, `created=${encodeURIComponent(`>=${since.slice(0, 10)}`)}`,
...(o.gateEvent ? [`event=${encodeURIComponent(o.gateEvent)}`] : [])].join('&');
const gates: GateRun[] = [];
for (let page = 1; page <= MAX_PAGES; page++) {
const body = source.getJson(`repos/${o.repo}/actions/workflows/${o.gateWorkflow}/runs?${query}&page=${page}`) as { workflow_runs?: any[] };
const runs = Array.isArray(body?.workflow_runs) ? body.workflow_runs : [];
for (const run of runs) {
if (String(run.created_at) < since || (o.gateEvent && run.event !== o.gateEvent)) continue;
const title = String(run.display_title ?? run.name ?? '');
const branch = String(run.head_branch ?? '');
const pr = Number(/#(\d+)/.exec(title)?.[1] ?? run.pull_requests?.[0]?.number ?? NaN);
gates.push({ id: Number(run.id), kind: classifyGate({ branch, title }, o.trainPrefix), outcome: gateOutcome(run.conclusion ?? null),
createdAt: String(run.created_at), branch, title, pr: Number.isFinite(pr) ? pr : null, costUsd: null, costSource: null, flakes: 0, flakeRerunUsd: 0 });
}
if (runs.length < PAGE) break;
}
gates.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
const artifactRunsRead = Math.min(gates.length, o.maxArtifactRuns);
const commentCostUsed = new Set<number>();
let artifactErrors = 0;
gates.forEach((gate, index) => {
if (index < artifactRunsRead) {
try {
const dirs = source.artifactDirs(gate.id, name => /^(report-verdict|flake-records)-a\d+$/.test(name));
const named = (prefix: string) => [...dirs].filter(([name]) => name.startsWith(prefix)).map(([, dir]) => dir);
gate.costUsd = verdictCost(named('report-verdict-'));
if (gate.costUsd !== null) gate.costSource = 'artifact';
const flakes = flakeRecords(named('flake-records-'));
gate.flakes = flakes.flakes;
gate.flakeRerunUsd = flakes.rerunUsd;
} catch { artifactErrors++; }
}
if (gate.costUsd === null && gate.pr !== null && prCost.has(gate.pr) && !commentCostUsed.has(gate.pr)) {
gate.costUsd = prCost.get(gate.pr)!; gate.costSource = 'comment'; commentCostUsed.add(gate.pr);
}
});
return { gates, artifactRunsRead, artifactErrors };
}
/** Free-suite runs on base in the window and the flaky-pass entries their flake-ledger-* artifacts hold. */
export function readFreeRetries(source: ScorecardSource, o: ScorecardOptions, since: string): FreeRetryRun[] {
const client: GhClient = { getJson: p => source.getJson(p), downloadZip: () => { throw new Error('not used'); } };
const runs = listWorkflowRuns(client, o.repo, o.freeWorkflow, { since, branch: o.base });
const retries = (runId: number): number | null => {
try { return [...source.artifactDirs(runId, name => name.startsWith('flake-ledger-')).values()].flatMap(readFlakeLedgerDir).length; } catch { return null; }
};
return runs.map((run, index) => ({ id: run.id, conclusion: run.conclusion, retries: index < o.maxArtifactRuns ? retries(run.id) : 0 }));
}
function searchCount(source: ScorecardSource, q: string): number | null {
const body = source.getJson(`search/issues?q=${encodeURIComponent(q)}&per_page=1`) as { total_count?: number };
return typeof body?.total_count === 'number' ? body.total_count : null;
}
export function readReleases(source: ScorecardSource, repo: string, since: string): Array<{ tag: string; publishedAt: string }> {
const body = source.getJson(`repos/${repo}/releases?per_page=${PAGE}&page=1`);
return (Array.isArray(body) ? body : []).filter(r => !r.draft && typeof r.published_at === 'string' && r.published_at >= since)
.map(r => ({ tag: String(r.tag_name ?? ''), publishedAt: String(r.published_at) }));
}
/** Every read, each guarded: one unreadable input marks its lines unavailable and the rest still print. */
export function collectScorecard(source: ScorecardSource, o: ScorecardOptions): Scorecard {
const since = new Date(o.now - o.sinceDays * 86_400_000).toISOString();
const card: Scorecard = { repo: o.repo, base: o.base, windowStart: since, windowEnd: new Date(o.now).toISOString(),
prs: [], gates: [], freeRuns: [], artifactRunsRead: 0, passCap: o.passCap, artifactErrors: 0, issuesOpened: null, issuesClosed: null, releases: [], unavailable: [] };
const guard = (what: string, fn: () => void) => { try { fn(); } catch (e) { card.unavailable.push(`${what}: ${unavailableReason(e)}`); } };
guard('merged PRs', () => { card.prs = readMergedPrs(source, o, since); });
guard('gate runs', () => {
const prCost = new Map(card.prs.filter(p => p.commentCostUsd !== null).map(p => [p.number, p.commentCostUsd!] as const));
Object.assign(card, readGateRuns(source, o, since, prCost));
});
guard('free retries', () => { card.freeRuns = readFreeRetries(source, o, since); });
const instant = since.replace(/\.\d{3}Z$/, 'Z');
guard('issues', () => {
card.issuesOpened = searchCount(source, `repo:${o.repo} is:issue created:>=${instant}`);
card.issuesClosed = searchCount(source, `repo:${o.repo} is:issue closed:>=${instant}`);
});
guard('releases', () => { card.releases = readReleases(source, o.repo, since); });
return card;
}
// --- Arithmetic and rendering ---
export function percentile(values: number[], p: number): number | null {
if (values.length === 0) return null;
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(p * sorted.length) - 1))]!;
}
export function hours(from: string, to: string): number { return (Date.parse(to) - Date.parse(from)) / 3_600_000; }
export function fmtHours(h: number | null): string {
if (h === null) return 'n/a';
if (h < 1) return `${Math.round(h * 60)}m`;
if (h < 48) return `${h.toFixed(1)}h`;
return `${(h / 24).toFixed(1)}d`;
}
const pct = (num: number, den: number) => (den === 0 ? 'n/a' : `${Math.round((num / den) * 100)}%`);
const usd = (n: number) => `$${n.toFixed(2)}`;
/** What one of the best software factories would show on each line; the honest comparison lives in docs/recommended-harness.md. */
export const BENCHMARKS: Record<string, string> = {
merged: 'several merges a day, every day; outside contributions land in the same week',
lead_time: 'median under 24h, p90 under 3 days (DORA elite: change lead time under a day)',
ready_to_merge: 'under 2h from READY to merge; every merged PR carries a READY comment',
review_passes: 'median 2 passes, none at the cap (a pass that finds nothing new is the exit; what is left at the cap is named in the PR body)',
gates: 'green on 95%+ of runs, cancelled near 0, cost known for every run',
cost_per_merge: 'known, stable or falling week over week',
red_train: 'under 5% of trains red (each red train costs a bisect and a rebuild)',
flaky: '0 repeat cases; under 1 flaky case per 10 gates; free retries under 1% of runs',
issues: 'closed >= opened over the window; nothing severe older than a week',
releases: 'at least one tagged release a week, on schedule',
};
export function scorecardRows(card: Scorecard): ScorecardRow[] {
const crewPrs = card.prs.filter(p => p.crew);
const lead = card.prs.map(p => hours(p.createdAt, p.mergedAt));
const ready = card.prs.filter(p => p.readyAt).map(p => hours(p.readyAt!, p.mergedAt));
const gates = card.gates;
const by = (outcome: GateOutcome, kind?: GateKind) => gates.filter(g => g.outcome === outcome && (!kind || g.kind === kind)).length;
const known = gates.filter(g => g.costUsd !== null);
const knownUsd = known.reduce((s, g) => s + g.costUsd!, 0);
const trains = gates.filter(g => g.kind === 'train' && g.outcome !== 'cancelled');
const flakes = gates.reduce((s, g) => s + g.flakes, 0);
const flakeUsd = gates.reduce((s, g) => s + g.flakeRerunUsd, 0);
const retries = card.freeRuns.reduce((s, r) => s + (r.retries ?? 0), 0);
const retryRuns = card.freeRuns.filter(r => (r.retries ?? 0) > 0).length;
const unreadableFree = card.freeRuns.filter(r => r.retries === null).length;
const passes = card.prs.filter(p => p.passes !== null).map(p => p.passes!);
const unavailable = (what: string) => card.unavailable.find(u => u.startsWith(what + ':'));
const unavailableText = (what: string) => `unavailable (${unavailable(what)!.slice(what.length + 2)})`;
const row = (id: string, metric: string, what: string | string[], value: () => string, rule: string): ScorecardRow => {
const failed = (Array.isArray(what) ? what : [what]).find(w => unavailable(w));
return { id, metric, value: failed ? unavailableText(failed) : value(), benchmark: BENCHMARKS[id]!, rule };
};
const paidFlakes = () => `${flakes} paid case(s) passed on their one rerun (rerun spend ${usd(flakeUsd)})${card.artifactErrors ? `; artifacts unreadable on ${card.artifactErrors} run(s)` : ''}`;
const freeRetries = () => `${retries} free-suite retr${retries === 1 ? 'y' : 'ies'} in ${retryRuns} of ${card.freeRuns.length} runs${unreadableFree ? ` (${unreadableFree} unreadable)` : ''}`;
return [
row('merged', 'Merged PRs', 'merged PRs', () => `${card.prs.length} (crew ${crewPrs.length}, outside ${card.prs.length - crewPrs.length})`,
`merged_at inside the window, base ${card.base}; crew by login list or author_association OWNER/MEMBER/COLLABORATOR`),
row('lead_time', 'Lead time, created to merged', 'merged PRs', () => `median ${fmtHours(percentile(lead, 0.5))}, p90 ${fmtHours(percentile(lead, 0.9))}`,
'PR created_at to merged_at over the merged PRs above'),
row('ready_to_merge', 'Lead time, READY to merged', 'merged PRs', () => `median ${fmtHours(percentile(ready, 0.5))}, p90 ${fmtHours(percentile(ready, 0.9))} (${ready.length} of ${card.prs.length} PRs with a READY comment)`,
'first PR comment matching the READY marker, at or before merged_at; PRs without one are left out'),
row('review_passes', 'Review passes per PR', 'merged PRs', () => `median ${percentile(passes, 0.5) ?? 'n/a'}, p90 ${percentile(passes, 0.9) ?? 'n/a'} (${passes.length} of ${card.prs.length} PRs report a pass count; ${passes.filter(n => n >= card.passCap).length} at the cap of ${card.passCap})`,
'largest count matching the pass marker in the PR body (the ship receipt\'s "passes") or a PR comment; PRs without one are left out'),
row('gates', 'Gate runs green / red / cancelled', 'gate runs', () => `${by('green')} / ${by('red')} / ${by('cancelled')}; known cost ${usd(knownUsd)} on ${known.length} of ${gates.length} runs${unavailable('merged PRs') ? ' (comment costs unavailable: merged PRs unreadable)' : ''}`,
'completed runs of the gate workflow in the window; cost from the verdict artifact, else the PR verdict comment (one run per PR)'),
row('cost_per_merge', 'Known gate cost per merged PR', ['gate runs', 'merged PRs'], () => (card.prs.length ? `${usd(knownUsd / card.prs.length)} (${gates.length - known.length} runs without a known cost)` : 'n/a (no merges)'),
'known gate cost divided by merged PRs; unknown-cost runs make this a floor'),
row('red_train', 'Red-train rate', 'gate runs', () => `${pct(trains.filter(t => t.outcome === 'red').length, trains.length)} (${trains.filter(t => t.outcome === 'red').length} red of ${trains.length} trains; ${by('red', 'backstop')} red backstop, ${by('red', 'screen')} red screens)`,
`runs whose head branch starts with the train prefix, cancelled runs excluded; red means the run's conclusion was not success`),
row('flaky', 'Flaky events', [], () => `${unavailable('gate runs') ? unavailableText('gate runs') : paidFlakes()}; ${unavailable('free retries') ? unavailableText('free retries') : freeRetries()}`,
`flake-records artifacts of the newest ${card.artifactRunsRead} gate runs and flake-ledger artifacts of free runs on the base branch`),
row('issues', 'Issues opened / closed', 'issues', () => `${card.issuesOpened ?? 'n/a'} / ${card.issuesClosed ?? 'n/a'}`, 'GitHub search: created, closed inside the window'),
row('releases', 'Releases cut', 'releases', () => `${card.releases.length}${card.releases.length ? ` (${card.releases.map(r => r.tag).join(', ')})` : ''}`, 'releases published inside the window, drafts excluded'),
];
}
export function renderMarkdown(card: Scorecard, rows: ScorecardRow[]): string {
const days = Math.round((Date.parse(card.windowEnd) - Date.parse(card.windowStart)) / 86_400_000);
const esc = (s: string) => s.replace(/\|/g, '\\|');
return [
`## Factory scorecard: ${card.repo} (${card.windowStart.slice(0, 10)} to ${card.windowEnd.slice(0, 10)}, ${days} days)`, '',
'| Metric | This window | One of the best factories would show | Counting rule |', '|---|---|---|---|',
...rows.map(r => `| ${r.metric} | ${esc(r.value)} | ${esc(r.benchmark)} | ${esc(r.rule)} |`), '',
...(card.unavailable.length ? ['Unavailable inputs: ' + card.unavailable.join('; '), ''] : []),
`Sources: ${card.prs.length} merged PRs, ${card.gates.length} gate runs (artifacts read for ${card.artifactRunsRead}), ${card.freeRuns.length} free runs on ${card.base}. Read-only; no paid work.`, '',
].join('\n');
}
export function renderCsv(card: Scorecard, rows: ScorecardRow[]): string {
const cell = (s: string) => `"${s.replace(/"/g, '""')}"`;
return ['repo,window_start,window_end,metric_id,metric,value,benchmark,rule',
...rows.map(r => [card.repo, card.windowStart, card.windowEnd, r.id, r.metric, r.value, r.benchmark, r.rule].map(cell).join(','))].join('\n') + '\n';
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.251",
"version": "1.91.252",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+12
View File
@@ -673,6 +673,18 @@ Report `COMMIT_SIZE_BUCKETS`:
- **Large** (500-1500 LOC)
- **XL** (1500+ LOC)
### Step 7.5: Factory scorecard (optional)
With `gh` authenticated, add what git cannot show: merged PRs (crew vs outside), lead time (PR open → merge, READY → merge), review passes per PR, gate runs and known cost, red-train rate, flaky events, issues opened vs closed, releases. Read-only, no spend; a busy week takes minutes.
```bash
_FS="$HOME/.claude/skills/gstack/bin/gstack-factory-scorecard"
gh auth status >/dev/null 2>&1 && "$_FS" --base "<default>" --since-days "<window-days>" \
|| echo "FACTORY_SCORECARD: unavailable"
```
Paste the table under **Factory scorecard**, third column included (what one of the best factories shows on that line). Keep any `unavailable (...)` wording; never write 0 for it. `SCORECARD_TREND:` accumulates the weeks. `docs/recommended-harness.md#the-factory-scorecard` explains each line and the `--crew`, `--gate-workflow`, `--free-workflow`, `--train-prefix` knobs.
### Step 8: Focus Score + Ship of the Week
**Focus score:** `FOCUS_SCORE` is the percentage of file changes touching the single most-changed top-level directory (e.g., `app/services/`). Higher score = deeper focused work. Lower score = scattered context-switching. Report as: "Focus score: 62% (app/services/)"
+12
View File
@@ -269,6 +269,18 @@ Report `COMMIT_SIZE_BUCKETS`:
- **Large** (500-1500 LOC)
- **XL** (1500+ LOC)
### Step 7.5: Factory scorecard (optional)
With `gh` authenticated, add what git cannot show: merged PRs (crew vs outside), lead time (PR open → merge, READY → merge), review passes per PR, gate runs and known cost, red-train rate, flaky events, issues opened vs closed, releases. Read-only, no spend; a busy week takes minutes.
```bash
_FS="{{RUNTIME_ROOT}}/bin/gstack-factory-scorecard"
gh auth status >/dev/null 2>&1 && "$_FS" --base "<default>" --since-days "<window-days>" \
|| echo "FACTORY_SCORECARD: unavailable"
```
Paste the table under **Factory scorecard**, third column included (what one of the best factories shows on that line). Keep any `unavailable (...)` wording; never write 0 for it. `SCORECARD_TREND:` accumulates the weeks. `docs/recommended-harness.md#the-factory-scorecard` explains each line and the `--crew`, `--gate-workflow`, `--free-workflow`, `--train-prefix` knobs.
### Step 8: Focus Score + Ship of the Week
**Focus score:** `FOCUS_SCORE` is the percentage of file changes touching the single most-changed top-level directory (e.g., `app/services/`). Higher score = deeper focused work. Lower score = scattered context-switching. Report as: "Focus score: 62% (app/services/)"
+3
View File
@@ -35,6 +35,9 @@ Narrative covering:
- Fix-chain detection (sequences of fix commits on the same subsystem)
- Version bump discipline
### Factory scorecard
(from Step 7.5 when it ran; the table as printed, `unavailable (...)` lines kept; omit the section when the step was skipped)
### Code Quality Signals
- Test LOC ratio trend
- Hotspot analysis (are the same files churning?)
+3
View File
@@ -33,6 +33,9 @@ Narrative covering:
- Fix-chain detection (sequences of fix commits on the same subsystem)
- Version bump discipline
### Factory scorecard
(from Step 7.5 when it ran; the table as printed, `unavailable (...)` lines kept; omit the section when the step was skipped)
### Code Quality Signals
- Test LOC ratio trend
- Hotspot analysis (are the same files churning?)
+364
View File
@@ -0,0 +1,364 @@
/**
* factory-scorecard (free): the window's merged PRs, lead times, gate runs with
* known cost, red-train rate, flaky events, issues and releases, computed from
* recorded gh responses (test/fixtures/factory-scorecard/recorded.json); an
* unreadable input marks its lines unavailable and the rest still print; the
* bin writes the table, the CSV and the JSON without touching the network.
*/
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 {
BENCHMARKS, DEFAULTS, classifyGate, collectScorecard, fixtureSource, flakeRecords, fmtHours, gateOutcome, passCount, percentile, readComments, renderCsv,
renderMarkdown, scorecardRows, verdictCost, type FixtureFile, type ScorecardOptions,
} from '../lib/factory-scorecard';
const ROOT = path.resolve(import.meta.dir, '..');
const FIXTURE = path.join(ROOT, 'test', 'fixtures', 'factory-scorecard', 'recorded.json');
const BIN = path.join(ROOT, 'bin', 'gstack-factory-scorecard');
const NOW = Date.parse('2026-10-26T12:00:00Z');
const tmp: string[] = [];
afterEach(() => { for (const dir of tmp.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); });
const scratch = () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-scorecard-test-')); tmp.push(dir); return dir; };
const fixture = (): FixtureFile => JSON.parse(fs.readFileSync(FIXTURE, 'utf8'));
const options = (over: Partial<ScorecardOptions> = {}): ScorecardOptions => ({
repo: 'acme/widgets', base: DEFAULTS.base, now: NOW, sinceDays: DEFAULTS.sinceDays, crew: null, gateWorkflow: DEFAULTS.gateWorkflow,
gateEvent: DEFAULTS.gateEvent, freeWorkflow: DEFAULTS.freeWorkflow, trainPrefix: DEFAULTS.trainPrefix,
readyMarker: new RegExp(DEFAULTS.readyMarker, 'm'), passMarker: new RegExp(DEFAULTS.passMarker, 'm'), passCap: DEFAULTS.passCap,
maxArtifactRuns: DEFAULTS.maxArtifactRuns, ...over,
});
const byId = (rows: ReturnType<typeof scorecardRows>) => Object.fromEntries(rows.map(r => [r.id, r.value]));
describe('factory-scorecard: recorded window', () => {
test('merged PRs, lead times, gates, cost, trains, flakes, issues and releases from the recorded responses', () => {
const source = fixtureSource(fixture(), scratch());
const card = collectScorecard(source, options());
expect(card.unavailable).toEqual([]);
expect(card.prs.map(p => p.number)).toEqual([101, 103, 102]);
expect(card.prs.map(p => p.crew)).toEqual([true, true, false]);
expect(card.prs.find(p => p.number === 101)!.readyAt).toBe('2026-10-24T16:00:00Z');
expect(card.prs.find(p => p.number === 101)!.commentCostUsd).toBe(12.5);
expect(card.prs.find(p => p.number === 102)!.readyAt).toBeNull();
expect(card.prs.map(p => p.passes)).toEqual([2, 3, null]);
expect(card.gates.map(g => [g.id, g.kind, g.outcome, g.costUsd, g.costSource, g.flakes])).toEqual([
[9004, 'backstop', 'cancelled', null, null, 0],
[9003, 'screen', 'green', 12.5, 'comment', 0],
[9002, 'train', 'red', null, null, 0],
[9001, 'train', 'green', 30.5, 'artifact', 2],
]);
expect(card.gates.find(g => g.id === 9001)!.flakeRerunUsd).toBe(1.1);
expect(card.freeRuns.map(r => [r.id, r.retries])).toEqual([[8003, 0], [8002, 0], [8001, 2]]);
expect([card.issuesOpened, card.issuesClosed]).toEqual([5, 7]);
expect(card.releases).toEqual([{ tag: 'v1.0.0', publishedAt: '2026-10-23T07:00:00Z' }]);
const values = byId(scorecardRows(card));
expect(values.merged).toBe('3 (crew 2, outside 1)');
expect(values.lead_time).toBe('median 8.0h, p90 5.0d');
expect(values.ready_to_merge).toBe('median 1.0h, p90 2.0h (2 of 3 PRs with a READY comment)');
expect(values.review_passes).toBe('median 2, p90 3 (2 of 3 PRs report a pass count; 1 at the cap of 3)');
expect(values.gates).toBe('2 / 1 / 1; known cost $43.00 on 2 of 4 runs');
expect(values.cost_per_merge).toBe('$14.33 (2 runs without a known cost)');
expect(values.red_train).toBe('50% (1 red of 2 trains; 0 red backstop, 0 red screens)');
expect(values.flaky).toBe('2 paid case(s) passed on their one rerun (rerun spend $1.10); 2 free-suite retries in 1 of 3 runs');
expect(values.issues).toBe('5 / 7');
expect(values.releases).toBe('1 (v1.0.0)');
});
test('a crew login list overrides the author_association rule', () => {
const card = collectScorecard(fixtureSource(fixture(), scratch()), options({ crew: ['Alice'] }));
expect(byId(scorecardRows(card)).merged).toBe('3 (crew 1, outside 2)');
});
test('a run older than the window, a non-dispatch event and a draft release are left out; the page stops once older than the window', () => {
const source = fixtureSource(fixture(), scratch());
const card = collectScorecard(source, options());
expect(card.gates.map(g => g.id)).not.toContain(9000);
expect(card.gates.map(g => g.id)).not.toContain(9005);
expect(card.releases.map(r => r.tag)).not.toContain('v1.1.0');
expect(source.calls.filter(c => c.includes('/pulls?'))).toHaveLength(1);
});
test('a full page inside the window reads the next page; a full page whose oldest update predates the window does not', () => {
// Value: protects=the PR paging loop continues past page 1 only while the page is full and still inside the window; fails_when=the break condition stops after one page or ignores the oldest update; why_new=the recorded fixture never fills a page, so the continue branch was untested; seam=none; no_claim=none beyond protects
const pullsKey = (page: number) => `repos/acme/widgets/pulls?state=closed&base=main&sort=updated&direction=desc&per_page=100&page=${page}`;
const page = (first: number, updatedAt: string) => Array.from({ length: 100 }, (_, i) => ({ number: first + i, title: `pr ${first + i}`, user: { login: 'alice' }, author_association: 'MEMBER',
created_at: '2026-10-24T00:00:00Z', merged_at: '2026-10-25T00:00:00Z', updated_at: updatedAt }));
const withPages = (oldest: string) => {
const fx = fixture();
fx.responses[pullsKey(1)] = page(1000, oldest);
fx.responses[pullsKey(2)] = page(2000, oldest).slice(0, 5);
for (let n = 1000; n < 1100; n++) fx.responses[`repos/acme/widgets/issues/${n}/comments?per_page=100&page=1`] = [];
for (let n = 2000; n < 2005; n++) fx.responses[`repos/acme/widgets/issues/${n}/comments?per_page=100&page=1`] = [];
return fx;
};
const inWindow = fixtureSource(withPages('2026-10-25T00:00:00Z'), scratch());
expect(collectScorecard(inWindow, options()).prs).toHaveLength(105);
expect(inWindow.calls.filter(c => c.includes('/pulls?'))).toHaveLength(2);
const stale = fixtureSource(withPages('2026-10-01T00:00:00Z'), scratch());
expect(collectScorecard(stale, options()).prs).toHaveLength(100);
expect(stale.calls.filter(c => c.includes('/pulls?'))).toHaveLength(1);
});
test('a PR comment cost backs one gate run per PR; the gate-run read failing marks only its lines unavailable', () => {
// Value: protects=two artifact-less gate runs on one PR do not both copy the PR's comment cost into known cost; fails_when=the once-per-PR guard is removed; why_new=review found the fallback keyed by PR number only; seam=none; no_claim=none beyond protects
const fx = fixture();
const runsKey = Object.keys(fx.responses).find(k => k.includes('/actions/workflows/evals.yml/runs?') && k.includes('event=workflow_dispatch'))!;
const body = fx.responses[runsKey] as { workflow_runs: any[] };
const screen = body.workflow_runs.find(r => r.id === 9003)!;
body.workflow_runs.push({ ...screen, id: 9006, created_at: '2026-10-25T13:00:00Z' });
const card = collectScorecard(fixtureSource(fx, scratch()), options());
expect(card.gates.filter(g => g.id === 9003 || g.id === 9006).map(g => [g.pr, g.costSource])).toEqual([[101, 'comment'], [101, null]]);
expect(byId(scorecardRows(card)).gates).toBe('3 / 1 / 1; known cost $43.00 on 2 of 5 runs');
const broken = fixture();
delete broken.responses[runsKey];
const noGates = collectScorecard(fixtureSource(broken, scratch()), options());
expect(noGates.unavailable).toHaveLength(1);
expect(noGates.unavailable[0]).toStartWith('gate runs: ');
const values = byId(scorecardRows(noGates));
expect(values.gates).toStartWith('unavailable (');
expect(values.red_train).toStartWith('unavailable (');
expect(values.merged).toBe('3 (crew 2, outside 1)');
});
test('--max-artifact-runs caps artifact reads: later runs count without cost or flake data', () => {
const card = collectScorecard(fixtureSource(fixture(), scratch()), options({ maxArtifactRuns: 2 }));
expect(card.artifactRunsRead).toBe(2);
expect(card.gates.find(g => g.id === 9001)!.costUsd).toBeNull();
expect(card.gates.find(g => g.id === 9001)!.flakes).toBe(0);
expect(byId(scorecardRows(card)).gates).toBe('2 / 1 / 1; known cost $12.50 on 1 of 4 runs');
});
test('an unreadable input marks only its lines unavailable, with the reason, and the rest still print', () => {
const fx = fixture();
for (const key of Object.keys(fx.responses)) if (key.startsWith('search/issues')) delete fx.responses[key];
const card = collectScorecard(fixtureSource(fx, scratch()), options());
expect(card.unavailable).toHaveLength(1);
expect(card.unavailable[0]).toStartWith('issues: ');
const values = byId(scorecardRows(card));
expect(values.issues).toStartWith('unavailable (');
expect(values.merged).toBe('3 (crew 2, outside 1)');
expect(renderMarkdown(card, scorecardRows(card))).toContain('Unavailable inputs: issues: ');
});
test('markdown and CSV carry every row with its benchmark and counting rule', () => {
const card = collectScorecard(fixtureSource(fixture(), scratch()), options());
const rows = scorecardRows(card);
expect(rows.map(r => r.id)).toEqual(Object.keys(BENCHMARKS));
const md = renderMarkdown(card, rows);
expect(md).toStartWith('## Factory scorecard: acme/widgets (2026-10-19 to 2026-10-26, 7 days)');
expect(md).toContain('| Metric | This window | One of the best factories would show | Counting rule |');
expect(md).toContain('| Red-train rate | 50% (1 red of 2 trains; 0 red backstop, 0 red screens) | under 5% of trains red');
const csv = renderCsv(card, rows).split('\n');
expect(csv[0]).toBe('repo,window_start,window_end,metric_id,metric,value,benchmark,rule');
expect(csv).toHaveLength(rows.length + 2);
expect(csv[1]).toStartWith('"acme/widgets","2026-10-19T12:00:00.000Z","2026-10-26T12:00:00.000Z","merged","Merged PRs","3 (crew 2, outside 1)"');
});
test('--gate-event any keeps non-dispatch runs and drops the event filter from the query', () => {
// Value: protects=gateEvent null counts every completed gate run and omits event= from the runs query; fails_when=the null branch filters or the query keeps event=; why_new=only the default dispatch path was covered; seam=none; no_claim=live GitHub query semantics
const fx = fixture();
const key = Object.keys(fx.responses).find(k => k.includes('/evals.yml/runs?'))!;
fx.responses[key.replace('&event=workflow_dispatch', '')] = fx.responses[key];
const source = fixtureSource(fx, scratch());
const card = collectScorecard(source, options({ gateEvent: null }));
expect(source.calls.find(c => c.includes('/evals.yml/runs?'))).not.toContain('event=');
expect(card.gates.map(g => g.id)).toEqual([9005, 9004, 9003, 9002, 9001]);
expect(card.gates.find(g => g.id === 9005)!.kind).toBe('other');
});
test('an empty window prints n/a on every ratio instead of NaN, and short durations read as minutes', () => {
// Value: protects=zero merges and zero trains render n/a and sub-hour lead times render as minutes; fails_when=a 0/0 guard is removed or fmtHours loses a branch; why_new=every other case has merges and hour-scale durations; seam=none; no_claim=none beyond protects
const fx = fixture();
for (const key of Object.keys(fx.responses)) {
if (key.includes('/pulls?') || key.includes('/releases?')) fx.responses[key] = [];
else if (key.includes('/runs?')) fx.responses[key] = { total_count: 0, workflow_runs: [] };
else if (key.startsWith('search/issues')) fx.responses[key] = { total_count: 0, items: [] };
}
const card = collectScorecard(fixtureSource(fx, scratch()), options());
const values = byId(scorecardRows(card));
expect(card.unavailable).toEqual([]);
expect(values.merged).toBe('0 (crew 0, outside 0)');
expect(values.lead_time).toBe('median n/a, p90 n/a');
expect(values.cost_per_merge).toBe('n/a (no merges)');
expect(values.red_train).toBe('n/a (0 red of 0 trains; 0 red backstop, 0 red screens)');
expect(values.releases).toBe('0');
expect(renderMarkdown(card, scorecardRows(card))).not.toContain('NaN');
expect(fmtHours(0.5)).toBe('30m');
expect(fmtHours(null)).toBe('n/a');
});
test('a failed merged-PR read leaves gate rows standing with comment costs unknown', () => {
// Value: protects=each input is guarded on its own so a PR-list failure still prints gates, free retries, issues and releases; fails_when=the guards are merged into one try or the gate read depends on the PR read succeeding; why_new=only the issues guard was exercised; seam=none; no_claim=none beyond protects
const fx = fixture();
for (const key of Object.keys(fx.responses)) if (key.includes('/pulls?')) delete fx.responses[key];
const card = collectScorecard(fixtureSource(fx, scratch()), options());
expect(card.unavailable).toHaveLength(1);
expect(card.unavailable[0]).toStartWith('merged PRs: ');
const values = byId(scorecardRows(card));
expect(values.merged).toStartWith('unavailable (');
expect(values.lead_time).toStartWith('unavailable (');
expect(values.gates).toBe('2 / 1 / 1; known cost $30.50 on 1 of 4 runs (comment costs unavailable: merged PRs unreadable)');
expect(values.cost_per_merge).toStartWith('unavailable (');
expect(values.review_passes).toStartWith('unavailable (');
expect(card.gates.find(g => g.id === 9003)!.costSource).toBeNull();
expect(values.issues).toBe('5 / 7');
});
test('a failed free-run read marks only the free half of the flaky line; an unreadable artifact leaves its run without cost or flake data', () => {
// Value: protects=the flaky line never prints a clean 0 for a read that failed, and one bad artifact does not drop the whole gate section; fails_when=the flaky row is keyed to one guard again or the per-run try/catch is removed; why_new=red-team review found both; seam=none; no_claim=none beyond protects
const fx = fixture();
for (const key of Object.keys(fx.responses)) if (key.includes('/actions/workflows/free-tests.yml/')) delete fx.responses[key];
const values = byId(scorecardRows(collectScorecard(fixtureSource(fx, scratch()), options())));
expect(values.flaky).toMatch(/^2 paid case\(s\) passed on their one rerun \(rerun spend \$1\.10\); unavailable \(/);
expect(values.gates).toBe('2 / 1 / 1; known cost $43.00 on 2 of 4 runs');
const inner = fixtureSource(fixture(), scratch());
const flaky = { getJson: (p: string) => inner.getJson(p), artifactDirs: (id: number, m: (n: string) => boolean) => { if (id === 9001 || id === 8001) throw new Error('unzip failed'); return inner.artifactDirs(id, m); } };
const card = collectScorecard(flaky, options());
expect(card.unavailable).toEqual([]);
expect(card.artifactErrors).toBe(1);
expect(card.gates.find(g => g.id === 9001)!.costUsd).toBeNull();
expect(card.freeRuns.find(r => r.id === 8001)!.retries).toBeNull();
const v = byId(scorecardRows(card));
expect(v.gates).toBe('2 / 1 / 1; known cost $12.50 on 1 of 4 runs');
expect(v.flaky).toBe('0 paid case(s) passed on their one rerun (rerun spend $0.00); artifacts unreadable on 1 run(s); 0 free-suite retries in 0 of 3 runs (1 unreadable)');
});
});
describe('factory-scorecard: pieces', () => {
test('gate kind by train prefix, backstop and screen titles; outcome by conclusion', () => {
expect(classifyGate({ branch: 'train/gate.train-x', title: 'E2E Evals [eval-bg gate.train-x]' }, 'train/')).toBe('train');
expect(classifyGate({ branch: 'main', title: 'E2E Evals [eval-bg gate.backstop-abc]' }, 'train/')).toBe('backstop');
expect(classifyGate({ branch: 'main', title: 'E2E Evals [eval-bg pr.screen.narrow-x] screen #12' }, 'train/')).toBe('screen');
expect(classifyGate({ branch: 'feature/x', title: 'E2E Evals [eval-bg pr.gate-x]' }, 'train/')).toBe('other');
expect(classifyGate({ branch: 'queue/gate-1', title: 'gate' }, 'queue/')).toBe('train');
expect(['success', 'failure', 'timed_out', 'cancelled', 'skipped', null].map(gateOutcome)).toEqual(['green', 'red', 'red', 'cancelled', 'cancelled', 'red']);
});
test('READY marker: the first matching comment wins and the newest cost line is kept', () => {
const source = fixtureSource(fixture(), scratch());
const markers = (readyMarker: RegExp, mergedAt?: string) => ({ readyMarker, passMarker: new RegExp(DEFAULTS.passMarker, 'm'), mergedAt });
expect(readComments(source, 'acme/widgets', 101, markers(/READY #\d+/m))).toEqual({ readyAt: '2026-10-24T16:00:00Z', costUsd: 12.5, passes: null });
expect(readComments(source, 'acme/widgets', 101, markers(/never matches/m))).toEqual({ readyAt: null, costUsd: 12.5, passes: null });
expect(readComments(source, 'acme/widgets', 103, markers(new RegExp(DEFAULTS.readyMarker, 'm')))).toEqual({ readyAt: '2026-10-25T03:00:00Z', costUsd: null, passes: 3 });
// Value: protects=a READY-style comment posted after the merge (a controller recap) does not become the READY time; fails_when=the mergedAt bound is dropped; why_new=review found negative READY-to-merge durations possible; seam=none; no_claim=none beyond protects
expect(readComments(source, 'acme/widgets', 102, markers(/READY #\d+/m, '2026-10-25T12:00:00Z')).readyAt).toBeNull();
expect(readComments(source, 'acme/widgets', 102, markers(/READY #\d+/m)).readyAt).toBe('2026-10-26T01:00:00Z');
expect(passCount('"passes": 4 then passes=1 then REVIEW_PASSES: 2', new RegExp(DEFAULTS.passMarker, 'm'))).toBe(4);
expect(passCount('reviews: 3 and passes=2', /(passes|reviews)[=: ]+(\d+)/m)).toBe(3);
expect(passCount('no count here', new RegExp(DEFAULTS.passMarker, 'm'))).toBeNull();
});
test('verdict cost reads collector-outcomes.json and ignores an unreadable one; flake records skip torn lines and unknown rerun cost', () => {
const dir = scratch();
fs.mkdirSync(path.join(dir, 'a', 'paid-report'), { recursive: true });
fs.writeFileSync(path.join(dir, 'a', 'paid-report', 'collector-outcomes.json'), '{not json');
expect(verdictCost([path.join(dir, 'a')])).toBeNull();
fs.writeFileSync(path.join(dir, 'a', 'paid-report', 'collector-outcomes.json'), JSON.stringify({ verdict: { costUsd: 23.814 } }));
expect(verdictCost([path.join(dir, 'a')])).toBe(23.81);
fs.writeFileSync(path.join(dir, 'a', 'flake-records.jsonl'), [
JSON.stringify({ kind: 'gate-flake', case: 'x', rerun_cost_usd: 0.5 }), 'torn {', JSON.stringify({ kind: 'gate-flake', case: 'y', rerun_cost_usd: null }),
JSON.stringify({ kind: 'other', case: 'z', rerun_cost_usd: 9 }), ''].join('\n'));
expect(flakeRecords([path.join(dir, 'a')])).toEqual({ flakes: 2, rerunUsd: 0.5 });
expect(flakeRecords([path.join(dir, 'missing')])).toEqual({ flakes: 0, rerunUsd: 0 });
});
test('percentile is nearest-rank: p50 and p90 of small samples, null when empty', () => {
expect(percentile([], 0.5)).toBeNull();
expect(percentile([4, 8, 120], 0.5)).toBe(8);
expect(percentile([4, 8, 120], 0.9)).toBe(120);
expect(percentile([2, 1], 0.5)).toBe(1);
});
});
describe('factory-scorecard: the bin', () => {
const run = (args: string[], cwd: string) => spawnSync(BIN, args, { cwd, encoding: 'utf8', timeout: 60_000, env: { ...process.env, HOME: cwd } });
test('--fixtures replays the recorded window and writes the table, the CSV and the JSON', () => {
const cwd = scratch();
const r = run(['--repo', 'acme/widgets', '--fixtures', FIXTURE, '--now', '2026-10-26T12:00:00Z', '--json'], cwd);
expect(r.status, r.stderr).toBe(0);
expect(r.stdout).toContain('| Merged PRs | 3 (crew 2, outside 1) |');
expect(r.stdout).toContain('| Known gate cost per merged PR | $14.33 (2 runs without a known cost) |');
expect(r.stdout).toContain(`SCORECARD_CSV: ${path.join('.context', 'factory-scorecard', 'scorecard.csv')}`);
const csv = fs.readFileSync(path.join(cwd, '.context', 'factory-scorecard', 'scorecard.csv'), 'utf8');
expect(csv.split('\n')[0]).toBe('repo,window_start,window_end,metric_id,metric,value,benchmark,rule');
expect(csv).toContain('"red_train","Red-train rate","50% (1 red of 2 trains; 0 red backstop, 0 red screens)"');
expect(fs.existsSync(path.join(cwd, '.context', 'factory-scorecard', 'scorecard.md'))).toBe(true);
const json = JSON.parse(fs.readFileSync(path.join(cwd, '.context', 'factory-scorecard', 'scorecard.json'), 'utf8'));
expect(json.card.prs).toHaveLength(3);
expect(json.rows).toHaveLength(Object.keys(BENCHMARKS).length);
});
test('--crew, --out-dir, --csv and a custom --ready-marker are honoured', () => {
const cwd = scratch();
const out = path.join(cwd, 'out');
const r = run(['--repo', 'acme/widgets', '--fixtures', FIXTURE, '--now', '2026-10-26T12:00:00Z', '--crew', 'alice,bo', '--out-dir', out,
'--csv', path.join(cwd, 'card.csv'), '--ready-marker', 'ship receipt follows'], cwd);
expect(r.status, r.stderr).toBe(0);
expect(r.stdout).toContain('| Merged PRs | 3 (crew 2, outside 1) |');
expect(r.stdout).toContain('(1 of 3 PRs with a READY comment)');
expect(fs.existsSync(path.join(cwd, 'card.csv'))).toBe(true);
expect(fs.existsSync(path.join(out, 'scorecard.md'))).toBe(true);
// Value: protects=--csv into a directory that does not exist yet is created instead of crashing after every read; fails_when=the mkdirSync before the CSV write is removed; why_new=review found only --out-dir was created; seam=none; no_claim=none beyond protects
const nested = run(['--repo', 'acme/widgets', '--fixtures', FIXTURE, '--now', '2026-10-26T12:00:00Z', '--csv', path.join(cwd, 'trends', 'w43', 'card.csv')], cwd);
expect(nested.status, nested.stderr).toBe(0);
expect(fs.existsSync(path.join(cwd, 'trends', 'w43', 'card.csv'))).toBe(true);
// Value: protects=every run appends its rows to trend.csv with one header, so weeks line up without renaming files; fails_when=the append is removed or the header repeats; why_new=review found scorecard.csv overwritten every run; seam=none; no_claim=none beyond protects
const trend = fs.readFileSync(path.join(cwd, '.context', 'factory-scorecard', 'trend.csv'), 'utf8').split('\n').filter(Boolean);
expect(trend.filter(l => l.startsWith('repo,')).length).toBe(1);
expect(trend.length).toBe(1 + Object.keys(BENCHMARKS).length);
const again = run(['--repo', 'acme/widgets', '--fixtures', FIXTURE, '--now', '2026-10-27T12:00:00Z'], cwd);
expect(again.status, again.stderr).toBe(0);
const trendLines = () => fs.readFileSync(path.join(cwd, '.context', 'factory-scorecard', 'trend.csv'), 'utf8').split('\n').filter(Boolean).length;
expect(trendLines()).toBe(1 + 2 * Object.keys(BENCHMARKS).length);
expect(run(['--repo', 'acme/widgets', '--fixtures', FIXTURE, '--now', '2026-10-27T12:00:00Z'], cwd).status).toBe(0);
expect(trendLines()).toBe(1 + 2 * Object.keys(BENCHMARKS).length);
expect(nested.stdout).toContain(`SCORECARD_TREND: ${path.join('.context', 'factory-scorecard', 'trend.csv')}`);
});
test('bad usage exits 2 with the usage text; --help exits 0', () => {
const cwd = scratch();
expect(run(['--since-days', 'soon'], cwd).status).toBe(2);
// Value: protects=an unparsable --now exits 2 before any read; fails_when=the --now validation branch is removed; why_new=the other flag validations were covered and this one was not; seam=none; no_claim=none beyond protects
expect(run(['--now', 'yesterday-ish', '--fixtures', FIXTURE], cwd).status).toBe(2);
expect(run(['--ready-marker', '(', '--fixtures', FIXTURE], cwd).status).toBe(2);
// Value: protects=a non-numeric or negative --max-artifact-runs exits 2 instead of silently reading no artifact; fails_when=the validation is removed and Number('lots') flows into Math.min; why_new=review found this flag unvalidated; seam=none; no_claim=none beyond protects
const lots = run(['--max-artifact-runs', 'lots', '--fixtures', FIXTURE], cwd);
expect(lots.status).toBe(2);
expect(lots.stderr).toContain('--max-artifact-runs must be a non-negative integer');
expect(run(['--max-artifact-runs', '-1', '--fixtures', FIXTURE], cwd).status).toBe(2);
expect(run(['--pass-cap', '0', '--fixtures', FIXTURE], cwd).status).toBe(2);
expect(run(['--pass-marker', '[', '--fixtures', FIXTURE], cwd).status).toBe(2);
// Value: protects=without --repo and without a GitHub origin the bin refuses instead of guessing a repository; fails_when=a hard-coded fallback repository returns; why_new=red-team review found the shared helper's fallback would score the wrong repository; seam=none; no_claim=none beyond protects
const noRepo = run(['--fixtures', FIXTURE], cwd);
expect(noRepo.status).toBe(2);
expect(noRepo.stderr).toContain('--repo owner/name is required');
const local = scratch();
spawnSync('git', ['init', '-q', local]);
spawnSync('git', ['-C', local, 'remote', 'add', 'origin', '/srv/git/repo.git']);
expect(run(['--fixtures', FIXTURE], local).stderr).toContain('--repo owner/name is required');
spawnSync('git', ['-C', local, 'remote', 'set-url', 'origin', 'git@ghe.example.test:acme/widgets.git']);
const ghe = spawnSync(BIN, ['--fixtures', FIXTURE, '--now', '2026-10-26T12:00:00Z'], { cwd: local, encoding: 'utf8', timeout: 60_000, env: { ...process.env, HOME: local, GH_HOST: 'ghe.example.test' } });
expect(ghe.status, ghe.stderr).toBe(0);
expect(ghe.stdout).toContain('| Merged PRs | 3 (crew 2, outside 1) |');
expect(run(['--pass-marker', 'passes', '--fixtures', FIXTURE], cwd).stderr).toContain('--pass-marker needs a capture group');
const stray = run(['stray'], cwd);
expect(stray.status).toBe(2);
expect(stray.stderr).toContain('unexpected argument: stray');
const help = run(['--help'], cwd);
expect(help.status).toBe(0);
expect(help.stdout).toContain('gstack-factory-scorecard [--repo owner/name]');
expect(help.stdout).not.toContain(' * ');
});
test('the retro template runs the bin from the runtime root and reads its table', () => {
const tmpl = fs.readFileSync(path.join(ROOT, 'retro', 'SKILL.md.tmpl'), 'utf8');
expect(tmpl).toContain('"{{RUNTIME_ROOT}}/bin/gstack-factory-scorecard"');
expect(tmpl).toContain('### Step 7.5: Factory scorecard');
expect(fs.readFileSync(path.join(ROOT, 'retro', 'SKILL.md'), 'utf8')).toContain('$HOME/.claude/skills/gstack/bin/gstack-factory-scorecard');
});
});
+320
View File
@@ -0,0 +1,320 @@
{
"_comment": "Recorded gh REST shapes (trimmed) for test/factory-scorecard.test.ts: pulls, issue comments, workflow runs, search, releases, plus the artifact files a run would download. Window: --now 2026-10-26T12:00:00Z, --since-days 7.",
"responses": {
"repos/acme/widgets/pulls?state=closed&base=main&sort=updated&direction=desc&per_page=100&page=1": [
{
"number": 105,
"title": "closed without merge",
"user": {
"login": "garry"
},
"author_association": "OWNER",
"created_at": "2026-10-25T20:00:00Z",
"merged_at": null,
"closed_at": "2026-10-25T00:00:00Z",
"updated_at": "2026-10-25T21:00:00Z",
"base": {
"ref": "main"
}
},
{
"number": 103,
"title": "fix(queue): count the latest run per check",
"user": {
"login": "bo"
},
"author_association": "MEMBER",
"created_at": "2026-10-25T00:00:00Z",
"merged_at": "2026-10-25T04:00:00Z",
"closed_at": "2026-10-25T04:00:00Z",
"updated_at": "2026-10-25T04:00:00Z",
"base": {
"ref": "main"
}
},
{
"number": 102,
"title": "docs: typo in install guide",
"user": {
"login": "alice"
},
"author_association": "NONE",
"created_at": "2026-10-20T12:00:00Z",
"merged_at": "2026-10-25T12:00:00Z",
"closed_at": "2026-10-25T12:00:00Z",
"updated_at": "2026-10-25T12:00:00Z",
"base": {
"ref": "main"
}
},
{
"number": 101,
"title": "feat(retro): factory scorecard",
"user": {
"login": "garry"
},
"author_association": "OWNER",
"created_at": "2026-10-24T10:00:00Z",
"merged_at": "2026-10-24T18:00:00Z",
"closed_at": "2026-10-24T18:00:00Z",
"updated_at": "2026-10-24T18:00:00Z",
"base": {
"ref": "main"
},
"body": "```gstack-ship-receipt\n{\n \"schema_version\": 1,\n \"pr\": 101,\n \"passes\": 2\n}\n```\n\n## What changed\n"
},
{
"number": 104,
"title": "old merge outside the window",
"user": {
"login": "garry"
},
"author_association": "OWNER",
"created_at": "2026-10-09T10:00:00Z",
"merged_at": "2026-10-10T18:00:00Z",
"closed_at": "2026-10-10T18:00:00Z",
"updated_at": "2026-10-10T18:00:00Z",
"base": {
"ref": "main"
}
}
],
"repos/acme/widgets/issues/101/comments?per_page=100&page=1": [
{
"id": 1,
"user": {
"login": "github-actions[bot]"
},
"created_at": "2026-10-24T15:00:00Z",
"body": "## E2E Evals: \u2705 PASS\n\n**24 executed, 0 reused** rule/judge records | **0 manual accepted** | **$12.50** rule/judge cost | reconcile exit: 0"
},
{
"id": 2,
"user": {
"login": "garry"
},
"created_at": "2026-10-24T16:00:00Z",
"body": "READY #101 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa STAMP_VERSION=unstamped\npr: https://github.com/acme/widgets/pull/101\nSHIP_RECEIPT: pr=101 head=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
},
{
"id": 3,
"user": {
"login": "garry"
},
"created_at": "2026-10-24T17:00:00Z",
"body": "READY #101 again after a push"
}
],
"repos/acme/widgets/issues/102/comments?per_page=100&page=1": [
{
"id": 4,
"user": {
"login": "alice"
},
"created_at": "2026-10-21T09:00:00Z",
"body": "Thanks for the review, fixed."
},
{
"id": 6,
"user": {
"login": "minder"
},
"created_at": "2026-10-26T01:00:00Z",
"body": "merged in train 7: READY #102 recap"
}
],
"repos/acme/widgets/issues/103/comments?per_page=100&page=1": [
{
"id": 5,
"user": {
"login": "bo"
},
"created_at": "2026-10-25T03:00:00Z",
"body": "ship receipt follows\nSHIP_RECEIPT: pr=103 head=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb passes=3"
}
],
"repos/acme/widgets/actions/workflows/evals.yml/runs?status=completed&per_page=100&created=%3E%3D2026-10-19&event=workflow_dispatch&page=1": {
"total_count": 6,
"workflow_runs": [
{
"id": 9005,
"name": "E2E Evals",
"display_title": "E2E Evals",
"head_branch": "feature/x",
"head_sha": "ffffffffffffffffffffffffffffffffffffffff",
"event": "pull_request",
"status": "completed",
"conclusion": "success",
"created_at": "2026-10-25T13:00:00Z",
"updated_at": "2026-10-25T13:00:00Z",
"run_attempt": 1,
"pull_requests": []
},
{
"id": 9004,
"name": "E2E Evals",
"display_title": "E2E Evals [eval-bg gate.backstop-0123456789ab-2026-10-25]",
"head_branch": "main",
"head_sha": "ffffffffffffffffffffffffffffffffffffffff",
"event": "workflow_dispatch",
"status": "completed",
"conclusion": "cancelled",
"created_at": "2026-10-25T12:00:00Z",
"updated_at": "2026-10-25T12:00:00Z",
"run_attempt": 1,
"pull_requests": []
},
{
"id": 9003,
"name": "E2E Evals",
"display_title": "E2E Evals [eval-bg pr.screen.narrow-0123456789ab-0123456789ab-abcdef] screen #101",
"head_branch": "main",
"head_sha": "ffffffffffffffffffffffffffffffffffffffff",
"event": "workflow_dispatch",
"status": "completed",
"conclusion": "success",
"created_at": "2026-10-24T14:00:00Z",
"updated_at": "2026-10-24T14:00:00Z",
"run_attempt": 1,
"pull_requests": []
},
{
"id": 9002,
"name": "E2E Evals",
"display_title": "E2E Evals [eval-bg gate.train-bbbbbbbbbbbb-cccccccccccc-bbbbbb]",
"head_branch": "train/gate.train-bbbbbbbbbbbb-cccccccccccc-bbbbbb",
"head_sha": "ffffffffffffffffffffffffffffffffffffffff",
"event": "workflow_dispatch",
"status": "completed",
"conclusion": "failure",
"created_at": "2026-10-23T10:00:00Z",
"updated_at": "2026-10-23T10:00:00Z",
"run_attempt": 1,
"pull_requests": []
},
{
"id": 9001,
"name": "E2E Evals",
"display_title": "E2E Evals [eval-bg gate.train-aaaaaaaaaaaa-cccccccccccc-aaaaaa]",
"head_branch": "train/gate.train-aaaaaaaaaaaa-cccccccccccc-aaaaaa",
"head_sha": "ffffffffffffffffffffffffffffffffffffffff",
"event": "workflow_dispatch",
"status": "completed",
"conclusion": "success",
"created_at": "2026-10-22T10:00:00Z",
"updated_at": "2026-10-22T10:00:00Z",
"run_attempt": 1,
"pull_requests": []
},
{
"id": 9000,
"name": "E2E Evals",
"display_title": "E2E Evals [eval-bg gate.train-000000000000-cccccccccccc-000000]",
"head_branch": "train/gate.train-000000000000-cccccccccccc-000000",
"head_sha": "ffffffffffffffffffffffffffffffffffffffff",
"event": "workflow_dispatch",
"status": "completed",
"conclusion": "failure",
"created_at": "2026-10-19T08:00:00Z",
"updated_at": "2026-10-19T08:00:00Z",
"run_attempt": 1,
"pull_requests": []
}
]
},
"repos/acme/widgets/actions/workflows/free-tests.yml/runs?status=completed&per_page=100&created=%3E%3D2026-10-19&branch=main&page=1": {
"total_count": 3,
"workflow_runs": [
{
"id": 8003,
"name": "E2E Evals",
"display_title": "Free Tests",
"head_branch": "main",
"head_sha": "ffffffffffffffffffffffffffffffffffffffff",
"event": "push",
"status": "completed",
"conclusion": "success",
"created_at": "2026-10-25T12:00:00Z",
"updated_at": "2026-10-25T12:00:00Z",
"run_attempt": 1,
"pull_requests": []
},
{
"id": 8002,
"name": "E2E Evals",
"display_title": "Free Tests",
"head_branch": "main",
"head_sha": "ffffffffffffffffffffffffffffffffffffffff",
"event": "push",
"status": "completed",
"conclusion": "failure",
"created_at": "2026-10-24T12:00:00Z",
"updated_at": "2026-10-24T12:00:00Z",
"run_attempt": 1,
"pull_requests": []
},
{
"id": 8001,
"name": "E2E Evals",
"display_title": "Free Tests",
"head_branch": "main",
"head_sha": "ffffffffffffffffffffffffffffffffffffffff",
"event": "push",
"status": "completed",
"conclusion": "success",
"created_at": "2026-10-23T12:00:00Z",
"updated_at": "2026-10-23T12:00:00Z",
"run_attempt": 1,
"pull_requests": []
}
]
},
"repos/acme/widgets/releases?per_page=100&page=1": [
{
"tag_name": "v1.1.0",
"draft": true,
"prerelease": false,
"published_at": "2026-10-25T07:00:00Z"
},
{
"tag_name": "v1.0.0",
"draft": false,
"prerelease": false,
"published_at": "2026-10-23T07:00:00Z"
},
{
"tag_name": "v0.9.0",
"draft": false,
"prerelease": false,
"published_at": "2026-10-01T07:00:00Z"
}
],
"search/issues?q=repo%3Aacme%2Fwidgets%20is%3Aissue%20created%3A%3E%3D2026-10-19T12%3A00%3A00Z&per_page=1": {
"total_count": 5,
"items": []
},
"search/issues?q=repo%3Aacme%2Fwidgets%20is%3Aissue%20closed%3A%3E%3D2026-10-19T12%3A00%3A00Z&per_page=1": {
"total_count": 7,
"items": []
}
},
"artifacts": {
"8001": {
"flake-ledger-shard-1": {
"flake-ledger.jsonl": "{\"kind\":\"flaky-pass\",\"file\":\"test/a.test.ts\",\"shard\":\"shard-1\",\"attempts\":2}\n{\"kind\":\"flaky-pass\",\"file\":\"test/b.test.ts\",\"shard\":\"shard-1\",\"attempts\":2}\n{\"kind\":\"other\",\"file\":\"test/c.test.ts\"}\n"
}
},
"9001": {
"report-verdict-a1": {
"paid-report/collector-outcomes.json": "{\n \"version\": 2,\n \"files\": [\n {\n \"file\": \"x\",\n \"cost\": 30.5\n }\n ],\n \"verdict\": {\n \"lane\": \"gate/full census\",\n \"verdict\": \"GREEN\",\n \"costUsd\": 30.5,\n \"counts\": {\n \"flaky\": 2\n }\n }\n}",
"report.txt": "GREEN"
},
"flake-records-a1": {
"flake-records.jsonl": "{\"schema\":1,\"kind\":\"gate-flake\",\"case\":\"ship-docsync-store\",\"case_kind\":\"rule\",\"repository\":\"acme/widgets\",\"pr\":null,\"head_sha\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"tested_sha\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"lane\":\"gate/full census\",\"run_id\":\"9001\",\"first\":{\"attempt\":1,\"phase\":\"first\",\"where\":\"attempt 1 (paid-slice-3-a1)\"},\"rerun\":{\"attempt\":1,\"phase\":\"rerun\",\"where\":\"attempt 1 rerun (slice 1 attempt 1)\"},\"symptom\":\"assertion: expect(received).toEqual(expected)\",\"rerun_cost_usd\":1.1,\"recorded_at\":\"2026-10-22T11:00:00Z\"}\n{\"schema\":1,\"kind\":\"gate-flake\",\"case\":\"qa-functional-webhook-fix\",\"case_kind\":\"rule\",\"repository\":\"acme/widgets\",\"pr\":null,\"head_sha\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"tested_sha\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"lane\":\"gate/full census\",\"run_id\":\"9001\",\"first\":{\"attempt\":1,\"phase\":\"first\",\"where\":\"attempt 1 (paid-slice-3-a1)\"},\"rerun\":{\"attempt\":1,\"phase\":\"rerun\",\"where\":\"attempt 1 rerun (slice 1 attempt 1)\"},\"symptom\":\"assertion: expect(received).toEqual(expected)\",\"rerun_cost_usd\":null,\"recorded_at\":\"2026-10-22T11:00:00Z\"}\nnot json\n"
},
"paid-slice-1-a1": {
"junit.xml": "<testsuite/>"
}
}
}
}
+1
View File
@@ -133,6 +133,7 @@ export const FREE_FIXTURES: Readonly<Record<string, readonly string[]>> = {
'test/fixtures/evidence/shard-runner-files-from.out': ['test/evidence.test.ts'],
'test/fixtures/exercised-map-replay.json': ['scripts/exercised-map.ts'],
'test/fixtures/exit-propagation/**': ['test/exit-propagation.test.ts'],
'test/fixtures/factory-scorecard/**': ['test/factory-scorecard.test.ts'],
'test/fixtures/free-shard-capture.ts': ['test/test-free-shards-capture.test.ts'],
'test/fixtures/golden/**': ['test/host-config.test.ts', 'test/ship-review-loop.test.ts'],
'test/fixtures/impeccable-antipatterns.json': ['test/design-catalog.test.ts', 'test/impeccable-fixtures.test.ts'],
+1
View File
@@ -90,6 +90,7 @@ export const IDENTIFIER_PLACEHOLDERS: Record<string, IdentifierGrammar> = {
'<start_date>': { grammar: DATE, what: 'YYYY-MM-DD' },
'<since>': { grammar: /^[0-9]+[dhwm]$|^[0-9]{4}-[0-9]{2}-[0-9]{2}$/, what: 'retro window (7d, 24h, 2w) or date' },
'<window>': { grammar: /^[0-9]+[dhwm]$/, what: 'retro window (7d, 24h, 2w)' },
'<window-days>': { grammar: NUM, what: 'retro window in whole days' },
// Git refs. Branch names from remote data travel as data (ENG-8), never through this list.
'<base>': { grammar: REF, what: 'base branch detected by the skill' },
+1 -2
View File
@@ -202,8 +202,7 @@ describe.skipIf(process.platform === 'win32')('host conformance kit', () => {
const router = join(f.home, '.codex/skills/gstack/SKILL.md');
rmSync(router);
symlinkSync(join(src, '.agents/skills/gstack/SKILL.md'), router);
const qa = join(src, '.agents/skills/gstack-qa/sections');
rmSync(join(qa, readdirSync(qa).find(n => n.endsWith('.md'))!));
rmSync(join(src, '.agents/skills/gstack-qa/sections/scope.md'));
const status = runSetup(f, join(src, 'setup'), ['--status']);
// A1: broken section links make --status exit 1 instead of always 0.
expect(status.status).toBe(1);