diff --git a/CHANGELOG.md b/CHANGELOG.md index a65d6250e..c392fa7b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ()`, 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. diff --git a/README.md b/README.md index eea86d521..b5c6f7f18 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/VERSION b/VERSION index b57ae0ba4..08286adc3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.251.0 +1.91.252.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 8d783de42..78f3c3dbc 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -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, diff --git a/bin/gstack-factory-scorecard b/bin/gstack-factory-scorecard new file mode 100755 index 000000000..e488f7a37 --- /dev/null +++ b/bin/gstack-factory-scorecard @@ -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 ] + * [--pass-marker ] [--pass-cap 3] [--max-artifact-runs 60] + * [--out-dir ] [--csv ] [--json] [--cache-dir ] + * [--fixtures ] [--now ] + * + * --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(); +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`); diff --git a/docs/recommended-harness.md b/docs/recommended-harness.md new file mode 100644 index 000000000..a2fa33db6 --- /dev/null +++ b/docs/recommended-harness.md @@ -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 ]` first and reports exactly one trailer, `gstack: ok +for=` or `gstack: fail `. 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-/ + 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/.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 `). 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 --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 # + STAMP_VERSION=unstamped`, CI at that head, `PREGATE: pass`, the screen line + and `SHIP_RECEIPT: pr= 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@,42@ --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 , base , + order <…>"). The lane merges main if needed (CHANGELOG by section), runs + `gstack-restamp --next --base main --pr 41 --expect-base --expect-order + <…> --allow-repo-commands`, commits **only** the stamp files, pushes, waits + for required CI, and reports `READY #41 STAMP_VERSION=`. + The controller runs `gstack-ship-queue verify --pr 41 --train `, + merges with the exact head (`gh pr merge 41 --squash --match-head-commit + `, 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` 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`, 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 `. 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 ()` 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/.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 ` 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 ` 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` | diff --git a/docs/standalone-binaries.md b/docs/standalone-binaries.md index 42639db81..245488bdc 100644 --- a/docs/standalone-binaries.md +++ b/docs/standalone-binaries.md @@ -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 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. | diff --git a/lib/factory-scorecard.ts b/lib/factory-scorecard.ts new file mode 100644 index 000000000..afdc6504e --- /dev/null +++ b/lib/factory-scorecard.ts @@ -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: ") instead of printing 0. + * + * Inputs, all REST reads (no write, no paid work): + * merged PRs pulls?state=closed&base=, merged_at inside the window; + * crew = --crew logins, else author_association OWNER/MEMBER/COLLABORATOR + * lead time PR created_at -> merged_at (median, p90); ready -> merged when + * a PR comment matches --ready-marker (first such comment) + * gate runs runs in the window (workflow_dispatch by default), + * kind by head branch (train/) 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 artifacts of gate runs (paid, one record per case + * that failed once and passed its one rerun) and flake-ledger-* artifacts + * of runs on (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; +} + +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: { "": }, artifacts: { "": { "": { "": "" } } } }. */ +export interface FixtureFile { responses: Record; artifacts?: Record>> } + +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(); + 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): { 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(); + 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 = { + 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'; +} diff --git a/package.json b/package.json index c3f15ac7e..83f466535 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/retro/SKILL.md b/retro/SKILL.md index 8c3b8a467..459f3de3a 100644 --- a/retro/SKILL.md +++ b/retro/SKILL.md @@ -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 "" --since-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/)" diff --git a/retro/SKILL.md.tmpl b/retro/SKILL.md.tmpl index 156ecd7df..44ab0dd0f 100644 --- a/retro/SKILL.md.tmpl +++ b/retro/SKILL.md.tmpl @@ -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 "" --since-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/)" diff --git a/retro/sections/report-format.md b/retro/sections/report-format.md index d73e8bb87..04ad78cfd 100644 --- a/retro/sections/report-format.md +++ b/retro/sections/report-format.md @@ -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?) diff --git a/retro/sections/report-format.md.tmpl b/retro/sections/report-format.md.tmpl index a81979b9c..1eb5a7edb 100644 --- a/retro/sections/report-format.md.tmpl +++ b/retro/sections/report-format.md.tmpl @@ -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?) diff --git a/test/factory-scorecard.test.ts b/test/factory-scorecard.test.ts new file mode 100644 index 000000000..afd19cfdd --- /dev/null +++ b/test/factory-scorecard.test.ts @@ -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 => ({ + 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) => 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'); + }); +}); diff --git a/test/fixtures/factory-scorecard/recorded.json b/test/fixtures/factory-scorecard/recorded.json new file mode 100644 index 000000000..c0de2a0e7 --- /dev/null +++ b/test/fixtures/factory-scorecard/recorded.json @@ -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": "" + } + } + } +} diff --git a/test/helpers/free-fixtures-data.ts b/test/helpers/free-fixtures-data.ts index d2d799641..47434abff 100644 --- a/test/helpers/free-fixtures-data.ts +++ b/test/helpers/free-fixtures-data.ts @@ -133,6 +133,7 @@ export const FREE_FIXTURES: Readonly> = { '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'], diff --git a/test/helpers/placeholder-allowlist.ts b/test/helpers/placeholder-allowlist.ts index d6ceca179..9e80dde12 100644 --- a/test/helpers/placeholder-allowlist.ts +++ b/test/helpers/placeholder-allowlist.ts @@ -90,6 +90,7 @@ export const IDENTIFIER_PLACEHOLDERS: Record = { '': { grammar: DATE, what: 'YYYY-MM-DD' }, '': { grammar: /^[0-9]+[dhwm]$|^[0-9]{4}-[0-9]{2}-[0-9]{2}$/, what: 'retro window (7d, 24h, 2w) or date' }, '': { grammar: /^[0-9]+[dhwm]$/, what: 'retro window (7d, 24h, 2w)' }, + '': { grammar: NUM, what: 'retro window in whole days' }, // Git refs. Branch names from remote data travel as data (ENG-8), never through this list. '': { grammar: REF, what: 'base branch detected by the skill' }, diff --git a/test/host-conformance.test.ts b/test/host-conformance.test.ts index 6e7ea0f42..6b5b6569e 100644 --- a/test/host-conformance.test.ts +++ b/test/host-conformance.test.ts @@ -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);