mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 22:48:57 +02:00
* feat(aside): browser-driver contract, cookbook, research and fallback resolvers
{{ASIDE_SETUP}} (readiness probe + ten rules for driving the user's real browser), {{ASIDE_COOKBOOK}} (script shapes verified live against Aside CLI 1.26: one flow per aside repl script, CDP console hook before navigation, evidence lines, session-directory artifact handoff, GSTACK_STEP_OK sentinel), {{ASIDE_RESEARCH}} (research through aside exec, WebSearch when Aside is absent, knowledge otherwise) and {{BROWSE_FALLBACK}} (the fifteen-row Aside-step to $B-command table plus the rules that differ, so every browsing skill keeps working on gstack's own headless browser). test/aside-driver.test.ts pins the sentences and asserts every browsing skill carries the Aside block followed by the fallback; test/helpers/aside-available.ts is the shared live-Aside probe.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* feat(render): Aside-first local-HTML renderer with the bundled browser as fallback
lib/aside-render.ts serves the HTML's directory on loopback (Aside refuses file:// URLs), opens it with waitUntil load, prints through CDP Page.printToPDF so tagged output, outlines, header/footer templates and page numbers survive, emulates device metrics for sized screenshots, and writes in-page evaluations to files; when Aside is absent it runs the same spec through the browse daemon (newtab, load, js, pdf, screenshot, closetab) and reports ENGINE=aside|browse. bin/gstack-render.ts is the CLI skill templates call. lib/claude-bin.ts and lib/error-handling.ts become the canonical copies (browse/src re-exports them).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* refactor(browse): /browse drives Aside first, with the $B reference behind the fallback
Contract, cookbook, mode choice (aside repl by default, aside exec for reading), report format, the fallback section, and the full command reference carved on demand.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* refactor(qa): /qa and /qa-only drive Aside, fall back to $B
QA_METHODOLOGY runs every phase as Aside scripts (orient, explore, document, re-test, mobile viewport via CDP emulation, links via HEAD fetch); the authenticate phase is 'you are already signed in'; a 13th rule requires consent before mutating actions on non-local targets; the fallback section translates each step onto $B. The qa E2E tests run on whichever engine is present.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* refactor(design): design-review, design-consultation, design-shotgun, plan-design-review, design-html drive Aside
Design-system extraction is one script printing FONTS/COLORS/HEADINGS/TOUCH_TARGETS/NAV; competitor research confirms the exact URLs before opening them in the real browser and runs on the bundled browser when Aside is absent; design-html's viewport screenshots, sketches and comparison boards render through gstack-render.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* refactor(deploy): benchmark, canary, land-and-deploy Step 7, devex-review drive Aside
One aside repl script per page prints NAV/PAINT/LCP/RESOURCES/SCRIPTS/CSS/SUMMARY (benchmark), CONSOLE_ERRORS/NAV/TEXT + screenshot (canary, re-run every 60s), and the post-deploy check reads responseStatus from the navigation entry; each carries the $B fallback.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* feat(third-party-actions): Aside is the recommended driver; gstack's visible browser stays the fallback
The readiness probe is lifted from {{ASIDE_SETUP}} at gen time (byte-identity pinned) and rule 3 points at browse/SKILL.md for how to drive; the consent question offers Aside first and gstack's own visible browser (handoff/resume for sign-in) as the fallback, as v1.72 framed it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* refactor(scrape): /scrape reads pages through Aside; the browser-skills runtime rides the fallback
Look-then-extract scripts build the JSON inside the page and print it between JSON_START/JSON_END; aside exec for fuzzy intents; on the $B fallback the browser-skills match/prototype flow and /skillify apply as before.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* refactor(make-pdf): print through Aside first, the bundled browser otherwise
asideClient.ts replaces the direct $B client with one render() call per PDF (the exact option mapping the browse pdf command had: paper, margins, header/footer/page numbers, tagged, outline, printBackground, preferCSSPageSize, Paged.js wait); the diagram pre-pass, oversized-image downscale and DOCX rasters each run as one render script with per-fence try/catch; exit 4 now means no browser is available and names both remedies; $P setup reports which engine it found. The e2e gates run on whichever engine is present, so the Linux lane exercises the fallback.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* refactor(diagram): the triplet is one gstack-render call
SVG, PNG and excalidraw from one invocation over the content-addressed bundle staged under /tmp/gstack-render; every diagram type gets an excalidraw export; gstack-render picks the engine and prints ENGINE=; the diagram E2E gates on either engine.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* feat(research): web research runs in Aside first, WebSearch second
The planning, review, design, security and investigate skills research through {{ASIDE_RESEARCH}}; WebSearch stays in allowed-tools as the fallback; testing.ts's bootstrap step follows; skeleton ceilings ratcheted for the research block.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* feat(setup,gen-skill-docs): prune renders of skills that no longer exist
setup gains _prune_stale_generated for every host tree and the doc generator removes gstack-* output dirs it did not write, so a skill removed from the source tree can never linger in an install.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test: registries, budgets and suite reconciled for Aside-first with the $B fallback
Touchfiles + E2E tiers gain the Aside keys, coverage matrix and eval baselines updated, size budget re-baselined to parity-baseline-v1.80.0.0.json (the contract plus fallback ride in every browsing skill), parity ceilings ratcheted with measured values, LLM-judge prompts and the E2E fixtures speak Aside-first, browse-fallback.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* docs: Aside first, gstack browser fallback
README, BROWSER.md, docs/, CONTRIBUTING, CLAUDE.md, ARCHITECTURE, AGENTS.md, TODOS and the root router describe the one product story: Aside is the browser gstack drives first; the bundled headless browser is the automatic fallback (Linux, Windows, app closed) where cookie import, GStack Browser, pair-agent and browser-skills still apply.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* chore: regenerate SKILL.md docs, llms.txt, agents digest, ship goldens, context-budget fixture
bun run gen:skill-docs over the templates; goldens re-rendered; context-budget ceilings recaptured.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* v1.80.0.0: Aside is the browser gstack drives first; the bundled browser is the fallback
MINOR: new capability across ten skills, the renderer and research; nothing removed. CHANGELOG release summary + itemized changes; VERSION 1.80.0.0; package.json 1.80.0.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* docs(todos): file non-Claude host ownership-gate and version-heading pin follow-ups
Two follow-ups from the /plan-ceo-review + /plan-eng-review pass on merging
PR #2804 with main's v1.80.0.0 ownership gate: bring the Codex/Factory/
OpenCode/Cursor/Kiro copy loops and the stale-render prune under the
.gstack-owned marker rule, and a free test pinning that the CHANGELOG top
heading equals VERSION (the collision that git cannot see).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: pre-landing review fixes for the Aside-first branch
Review army + adversarial passes (Claude and Codex) on the merged branch:
setup
- _prune_stale_generated scans the host dirs too (the generator already
removed the render before setup ran, so the host branch was dead), skips
symlinks in the render tree (rm -rf on a slash-terminated link empties its
target), removes a host symlink only when it resolves into gstack, cleans a
bannered real dir through _cleanup_weak_dir, recognizes frontmatter-renamed
skills, and logs through log. The always-run codex render passes every host
dir that may link to it.
- NEEDS_BUILD checks all three binaries (with $_EXE) and lib/ sources; the
browser hint and the bootstrap summary honor GSTACK_SKIP_ASIDE, treat a
requested skip as a request, and derive one skill list.
lib/aside-render.ts + bin/gstack-render.ts
- The loopback server carries a per-render secret path, checks containment on
the real path (symlink escapes are 403), and rejects malformed encoding.
- Inline eval results are one base64 line, so page text cannot forge
ASIDE_DIR= or the sentinel; the last ASIDE_DIR wins.
- runProc escalates SIGTERM to SIGKILL, bounds every wait, and clears every
timer (an uncleared one kept gstack-render alive after printing OK).
- renderTmpDir refuses a shared /tmp name owned by someone else; the work dir
and server are created inside try; goto's budget follows the render budget.
- probeAside classifies a present-but-failing CLI as ASIDE_NOT_RUNNING like
the skills' bash probe; render() retries on gstack's own browser when Aside
could not start or its private CDP bridge is gone (never on a page error
or a timeout of a running script); the CLI reports the engine that actually
rendered, exits 0 on --help, rejects non-numeric flags, documents
--wait-timeout, fences EVAL/PAGE_ERRORS as untrusted content, and names the
daemon's cookie-import JS lock remedy.
- The browse path passes --scale only when asked (a scale change rebuilds
the daemon context) and restores the viewport after a sized screenshot.
resolvers / templates
- The bash probe honors GSTACK_SKIP_ASIDE and has a perl deadline on stock
macOS; .local is no longer LOCAL (mDNS); same-origin filters compare parsed
origins; link status is HEAD-checked only on LOCAL targets; every
aside exec goes through the receipted _aside_exec prelude
({{ASIDE_EXEC_PRELUDE}}), including nine template blocks that called it
bare; the design sketch and diagram staging use private directories.
- The generator prunes only bannered renders and never a host whose
generation failed.
Docs, stale comments and dead code cleaned; goldens re-rendered; tests
updated and added for every behavior above.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test: coverage for the render CLI, setup rebuild check, make-pdf exit codes, and prose $B spans
New free tests from the ship coverage audit: test/gstack-render-cli.test.ts
(argv guards, --help, output contract with a fake daemon, failure and
serve-root paths, no-browser case, prompt exit), test/setup-needs-build.test.ts
(every binary and source set flips NEEDS_BUILD, Windows suffixes),
make-pdf/test/cli-exit-codes.test.ts and setup-smoke.test.ts (error to exit
code mapping, runSetup stages, renderPdf's engine), and prose-span cases for
extractBrowseCommands in test/skill-parser.test.ts.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* docs: CHANGELOG and TODOS cover the review fixes (v1.81.0.0)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* docs: sync project docs with the v1.81.0.0 review fixes
BROWSER.md, ARCHITECTURE.md, CONTRIBUTING.md, README.md, CLAUDE.md,
docs/TESTING_INTERNALS.md and docs/PROJECT_STRUCTURE.md now describe the
shipped renderer and setup: the loopback render server's per-render secret
path and real-path containment, ENGINE= naming the engine that actually
rendered (mid-run retry on gstack's own browser), EVAL/PAGE_ERRORS fenced as
untrusted content, --wait-timeout and the CLI's argv guards, the receipted
_aside_exec prelude ({{ASIDE_EXEC_PRELUDE}} in the placeholder table), the
LOCAL host rule without .local, LOCAL-only HEAD checks in the links script,
GSTACK_SKIP_ASIDE across probe/renderer/setup, the ownership-gated
retired-skill prune, the widened NEEDS_BUILD check, and the new free tests
(gstack-render-cli, setup-prune-stale-generated, setup-browser-hint,
setup-needs-build, make-pdf cli-exit-codes and setup-smoke).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* docs: CHANGELOG states the precise mid-run retry rule
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(test): skill-e2e-bws slices the $B setup block from the Browser fallback section
browse/SKILL.md no longer has '## SETUP' / '## Core QA Patterns' (Aside is the
primary driver; the $B block moved under 'Browser fallback'), so the gate test
sliced an empty block and handed the agent nothing to run. Anchor on
'### Find the `$B` binary' up to the next heading. 7/7 pass.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(test): gate POSIX-only fixtures off Windows
windows-free-tests: the gstack-render CLI tests drive a shebang fake browse
that CreateProcess cannot exec, and two NEEDS_BUILD cases assert an execute
bit and a bare-name miss that MSYS bash does not have (test -x ignores mode
bits and resolves design -> design.exe). Those describes and cases now
self-skip on win32; argument guards, --help, the no-browser case, and every
other rebuild-check case still run there.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(render): runProc waits for the exit code until the kill deadline; newtab retries once on a cold daemon
A process whose pipes have reached EOF is exiting, but runProc gave the exit
code only five seconds to arrive and then returned null, which run() reports
as a failed command. Under CI's six-shard load one such render failed with the
artifact already written. The SIGTERM/SIGKILL timers already bound the wait,
so the exit race now runs to the kill deadline.
The first CLI call auto-starts the browse daemon; on a cold start it can
answer 'Unable to connect' once while the server is still coming up. That
single case is retried after 1.5s; every other newtab failure is not.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test(aside-render): warm the daemon before live fallback cases; failures name the render error
- Live fallback cases run 'goto about:blank' up to twice before asserting and
skip (never fail) when the daemon cannot come up.
- expectOk() puts r.error and the browse transcript into the assertion so a
failed render is diagnosable from the CI log.
- The argv-contract cases dump the fake's log on a miss.
- File default timeout is 30s: the subject is the CLI contract, not latency.
- Two cases pin the cold-daemon newtab retry and that other errors are not
retried.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* docs: CHANGELOG notes the cold-start tolerance of the bundled-browser renderer
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Sina <sdroid674+github@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
918 lines
41 KiB
TypeScript
918 lines
41 KiB
TypeScript
/**
|
|
* LLM-as-a-Judge evals for generated SKILL.md quality.
|
|
*
|
|
* Uses the Anthropic API directly (not Agent SDK) to evaluate whether
|
|
* generated command docs are clear, complete, and actionable for an AI agent.
|
|
*
|
|
* Requires: ANTHROPIC_API_KEY env var (or EVALS=1 with key already set)
|
|
* Run: EVALS=1 bun run test:eval
|
|
*
|
|
* Cost: ~$0.05-0.15 per run (sonnet)
|
|
*/
|
|
|
|
import { afterAll, expect } from 'bun:test';
|
|
import { JUDGE_MS } from './helpers/eval-budgets';
|
|
import Anthropic from '@anthropic-ai/sdk';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { callJudge, judge } from './helpers/llm-judge';
|
|
import type { JudgeScore } from './helpers/llm-judge';
|
|
import { LLM_JUDGE_TOUCHFILES } from './helpers/touchfiles';
|
|
// Runs when EVALS=1 is set (requires ANTHROPIC_API_KEY in env) — the EVALS
|
|
// gate lives in the shared describeIfSelected. Selection machinery is shared
|
|
// with the E2E suite; only the touchfiles table (LLM_JUDGE_TOUCHFILES, passed
|
|
// explicitly below) differs. No EVALS_TIER filter applies here — LLM-judge
|
|
// tests have no E2E_TIERS entries and run in both tier lanes.
|
|
import {
|
|
ROOT,
|
|
computeDiffSelection,
|
|
createEvalCollector,
|
|
finalizeEvalCollector,
|
|
describeIfSelected as describeIfSelectedShared,
|
|
testConcurrentIfSelected,
|
|
} from './helpers/e2e-helpers';
|
|
|
|
// Eval result collector
|
|
const evalCollector = createEvalCollector('llm-judge');
|
|
|
|
/**
|
|
* Browse carve (token-reduction Phase 4): the '## Snapshot Flags' and
|
|
* '## Full Command List' reference blocks moved from browse/SKILL.md into the
|
|
* generated on-demand section browse/sections/command-list.md ('## Snapshot
|
|
* Flags' first, then '## Full Command List'). '## SETUP', '## Core QA
|
|
* Patterns', and '## CSS Inspector' stay in the skeleton. Non-empty guard:
|
|
* judging an empty slice would silently pass garbage to the judge.
|
|
*/
|
|
function readBrowseCommandSection(): string {
|
|
const p = path.join(ROOT, 'browse', 'sections', 'command-list.md');
|
|
const content = fs.readFileSync(p, 'utf-8');
|
|
if (!content.includes('## Snapshot Flags') || !content.includes('## Full Command List')) {
|
|
throw new Error(
|
|
`${p} is missing the expected headers — regenerate with: bun run gen:skill-docs`,
|
|
);
|
|
}
|
|
return content;
|
|
}
|
|
|
|
/** Slice a section out of the command-list section file, guarded non-empty. */
|
|
function sliceBrowseSection(startHeader: string, endHeader?: string): string {
|
|
const content = readBrowseCommandSection();
|
|
const start = content.indexOf(startHeader);
|
|
if (start < 0) throw new Error(`browse/sections/command-list.md: "${startHeader}" not found`);
|
|
const end = endHeader ? content.indexOf(endHeader) : -1;
|
|
const section = end > start ? content.slice(start, end) : content.slice(start);
|
|
if (section.trim().length < 200) {
|
|
throw new Error(`browse/sections/command-list.md slice at "${startHeader}" is empty/stub — regenerate with: bun run gen:skill-docs`);
|
|
}
|
|
return section;
|
|
}
|
|
|
|
// --- Diff-based test selection (LLM_JUDGE_TOUCHFILES, not the E2E table) ---
|
|
const selectedTests = computeDiffSelection(LLM_JUDGE_TOUCHFILES, 'LLM-judge');
|
|
|
|
/** Wrap a describe block to skip if none of THIS FILE's tests are selected. */
|
|
function describeIfSelected(name: string, testNames: string[], fn: () => void) {
|
|
describeIfSelectedShared(name, testNames, fn, selectedTests);
|
|
}
|
|
|
|
/** Per-test gate against this file's selection (concurrent, as before). */
|
|
function testIfSelected(testName: string, fn: () => Promise<void>, timeout: number) {
|
|
testConcurrentIfSelected(testName, fn, timeout, selectedTests);
|
|
}
|
|
|
|
describeIfSelected('LLM-as-judge quality evals', [
|
|
'command reference table', 'snapshot flags reference',
|
|
'browse/SKILL.md reference', 'setup block', 'regression vs baseline',
|
|
], () => {
|
|
testIfSelected('command reference table', async () => {
|
|
const t0 = Date.now();
|
|
// Browse carve: the command reference lives in the generated on-demand
|
|
// section browse/sections/command-list.md now (read via non-empty guard).
|
|
const section = sliceBrowseSection('## Full Command List');
|
|
|
|
const scores = await judge('command reference table', section);
|
|
console.log('Command reference scores:', JSON.stringify(scores, null, 2));
|
|
|
|
// Completeness threshold is 3 (not 4) — the command reference table is
|
|
// intentionally terse (quick-reference format). The judge consistently scores
|
|
// completeness=3 because detailed argument docs live in per-command sections.
|
|
evalCollector?.addTest({
|
|
name: 'command reference table',
|
|
suite: 'LLM-as-judge quality evals',
|
|
tier: 'llm-judge',
|
|
passed: scores.clarity >= 4 && scores.completeness >= 3 && scores.actionability >= 4,
|
|
duration_ms: Date.now() - t0,
|
|
cost_usd: 0.02,
|
|
judge_scores: { clarity: scores.clarity, completeness: scores.completeness, actionability: scores.actionability },
|
|
judge_reasoning: scores.reasoning,
|
|
});
|
|
|
|
expect(scores.clarity).toBeGreaterThanOrEqual(4);
|
|
expect(scores.completeness).toBeGreaterThanOrEqual(3);
|
|
expect(scores.actionability).toBeGreaterThanOrEqual(4);
|
|
}, 30_000);
|
|
|
|
testIfSelected('snapshot flags reference', async () => {
|
|
const t0 = Date.now();
|
|
// Browse carve: snapshot flags live in browse/sections/command-list.md now,
|
|
// ordered before '## Full Command List' (the '## CSS Inspector' end boundary
|
|
// stayed in the skeleton).
|
|
const section = sliceBrowseSection('## Snapshot Flags', '## Full Command List');
|
|
|
|
const scores = await judge('snapshot flags reference', section);
|
|
console.log('Snapshot flags scores:', JSON.stringify(scores, null, 2));
|
|
|
|
evalCollector?.addTest({
|
|
name: 'snapshot flags reference',
|
|
suite: 'LLM-as-judge quality evals',
|
|
tier: 'llm-judge',
|
|
passed: scores.clarity >= 4 && scores.completeness >= 4 && scores.actionability >= 4,
|
|
duration_ms: Date.now() - t0,
|
|
cost_usd: 0.02,
|
|
judge_scores: { clarity: scores.clarity, completeness: scores.completeness, actionability: scores.actionability },
|
|
judge_reasoning: scores.reasoning,
|
|
});
|
|
|
|
expect(scores.clarity).toBeGreaterThanOrEqual(4);
|
|
expect(scores.completeness).toBeGreaterThanOrEqual(4);
|
|
expect(scores.actionability).toBeGreaterThanOrEqual(4);
|
|
}, 30_000);
|
|
|
|
testIfSelected('browse/SKILL.md reference', async () => {
|
|
const t0 = Date.now();
|
|
// Browse carve: flags + commands are the whole generated section file.
|
|
const section = sliceBrowseSection('## Snapshot Flags');
|
|
|
|
const scores = await judge('browse skill reference (flags + commands)', section);
|
|
console.log('Browse SKILL.md scores:', JSON.stringify(scores, null, 2));
|
|
|
|
evalCollector?.addTest({
|
|
name: 'browse/SKILL.md reference',
|
|
suite: 'LLM-as-judge quality evals',
|
|
tier: 'llm-judge',
|
|
passed: scores.clarity >= 4 && scores.completeness >= 4 && scores.actionability >= 4,
|
|
duration_ms: Date.now() - t0,
|
|
cost_usd: 0.02,
|
|
judge_scores: { clarity: scores.clarity, completeness: scores.completeness, actionability: scores.actionability },
|
|
judge_reasoning: scores.reasoning,
|
|
});
|
|
|
|
expect(scores.clarity).toBeGreaterThanOrEqual(4);
|
|
expect(scores.completeness).toBeGreaterThanOrEqual(4);
|
|
expect(scores.actionability).toBeGreaterThanOrEqual(4);
|
|
}, 30_000);
|
|
|
|
testIfSelected('setup block', async () => {
|
|
const t0 = Date.now();
|
|
// P2 (v1.2.0): the browse setup block moved from the root router to browse/SKILL.md.
|
|
const content = fs.readFileSync(path.join(ROOT, 'browse', 'SKILL.md'), 'utf-8');
|
|
// The setup block is the Aside contract ('## BROWSER SETUP (Aside ...') with
|
|
// the browse binary as fallback; older renders headed it '## SETUP'. Slice
|
|
// from whichever heading is present to the next H2.
|
|
let setupStart = content.indexOf('## BROWSER SETUP');
|
|
if (setupStart < 0) setupStart = content.indexOf('## SETUP');
|
|
const setupEnd = content.indexOf('\n## ', setupStart + 3);
|
|
if (setupStart < 0 || setupEnd < 0) throw new Error('browse/SKILL.md: setup block not found — regenerate with: bun run gen:skill-docs');
|
|
const section = content.slice(setupStart, setupEnd);
|
|
|
|
const scores = await judge('setup/binary discovery instructions', section);
|
|
console.log('Setup block scores:', JSON.stringify(scores, null, 2));
|
|
|
|
evalCollector?.addTest({
|
|
name: 'setup block',
|
|
suite: 'LLM-as-judge quality evals',
|
|
tier: 'llm-judge',
|
|
passed: scores.actionability >= 3 && scores.clarity >= 3,
|
|
duration_ms: Date.now() - t0,
|
|
cost_usd: 0.02,
|
|
judge_scores: { clarity: scores.clarity, completeness: scores.completeness, actionability: scores.actionability },
|
|
judge_reasoning: scores.reasoning,
|
|
});
|
|
|
|
// Setup block is intentionally minimal (binary discovery only).
|
|
// SKILL_DIR is inferred from context, so judge sometimes scores 3.
|
|
expect(scores.actionability).toBeGreaterThanOrEqual(3);
|
|
expect(scores.clarity).toBeGreaterThanOrEqual(3);
|
|
}, 30_000);
|
|
|
|
testIfSelected('regression vs baseline', async () => {
|
|
const t0 = Date.now();
|
|
// Browse carve: the command reference lives in browse/sections/command-list.md.
|
|
const genSection = sliceBrowseSection('## Full Command List');
|
|
|
|
const baseline = `## Command Reference
|
|
|
|
### Navigation
|
|
| Command | Description |
|
|
|---------|-------------|
|
|
| \`goto <url>\` | Navigate to URL |
|
|
| \`back\` / \`forward\` | History navigation |
|
|
| \`reload\` | Reload page |
|
|
| \`url\` | Print current URL |
|
|
|
|
### Interaction
|
|
| Command | Description |
|
|
|---------|-------------|
|
|
| \`click <sel>\` | Click element |
|
|
| \`fill <sel> <val>\` | Fill input |
|
|
| \`select <sel> <val>\` | Select dropdown |
|
|
| \`hover <sel>\` | Hover element |
|
|
| \`type <text>\` | Type into focused element |
|
|
| \`press <key>\` | Press key (Enter, Tab, Escape) |
|
|
| \`scroll [sel]\` | Scroll element into view |
|
|
| \`wait <sel>\` | Wait for element (max 10s) |
|
|
| \`wait --networkidle\` | Wait for network to be idle |
|
|
| \`wait --load\` | Wait for page load event |
|
|
|
|
### Inspection
|
|
| Command | Description |
|
|
|---------|-------------|
|
|
| \`js <expr>\` | Run JavaScript |
|
|
| \`css <sel> <prop>\` | Computed CSS |
|
|
| \`attrs <sel>\` | Element attributes |
|
|
| \`is <prop> <sel>\` | State check (visible/hidden/enabled/disabled/checked/editable/focused) |
|
|
| \`console [--clear\\|--errors]\` | Console messages (--errors filters to error/warning) |`;
|
|
|
|
const client = new Anthropic();
|
|
const response = await client.messages.create({
|
|
model: 'claude-sonnet-4-6',
|
|
max_tokens: 1024,
|
|
messages: [{
|
|
role: 'user',
|
|
content: `You are comparing two versions of CLI documentation for an AI coding agent.
|
|
|
|
VERSION A (baseline — hand-maintained):
|
|
${baseline}
|
|
|
|
VERSION B (auto-generated from source):
|
|
${genSection}
|
|
|
|
Which version is better for an AI agent trying to use these commands? Consider:
|
|
- Completeness (more commands documented? all args shown?)
|
|
- Clarity (descriptions helpful?)
|
|
- Coverage (missing commands in either version?)
|
|
|
|
Respond with ONLY valid JSON:
|
|
{"winner": "A" or "B" or "tie", "reasoning": "brief explanation", "a_score": N, "b_score": N}
|
|
|
|
Scores are 1-5 overall quality.`,
|
|
}],
|
|
});
|
|
|
|
const text = response.content[0].type === 'text' ? response.content[0].text : '';
|
|
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
|
if (!jsonMatch) throw new Error(`Judge returned non-JSON: ${text.slice(0, 200)}`);
|
|
const result = JSON.parse(jsonMatch[0]);
|
|
console.log('Regression comparison:', JSON.stringify(result, null, 2));
|
|
|
|
evalCollector?.addTest({
|
|
name: 'regression vs baseline',
|
|
suite: 'LLM-as-judge quality evals',
|
|
tier: 'llm-judge',
|
|
passed: result.b_score >= result.a_score,
|
|
duration_ms: Date.now() - t0,
|
|
cost_usd: 0.02,
|
|
judge_scores: { a_score: result.a_score, b_score: result.b_score },
|
|
judge_reasoning: result.reasoning,
|
|
});
|
|
|
|
expect(result.b_score).toBeGreaterThanOrEqual(result.a_score);
|
|
}, 30_000);
|
|
});
|
|
|
|
// --- Part 7: QA skill quality evals (C6) ---
|
|
|
|
/**
|
|
* QA carve (token-reduction Phase 4): the '## Modes', '## Workflow',
|
|
* '## Health Score Rubric', '## Framework-Specific Guidance', and
|
|
* '## Important Rules' blocks moved from qa/SKILL.md into the generated
|
|
* on-demand section qa/sections/qa-patterns.md. Monolith-tolerant: falls back
|
|
* to the skeleton when the section file doesn't exist (pre-carve checkout).
|
|
*/
|
|
function readQaPatterns(): string {
|
|
const sectionPath = path.join(ROOT, 'qa', 'sections', 'qa-patterns.md');
|
|
return fs.existsSync(sectionPath)
|
|
? fs.readFileSync(sectionPath, 'utf-8')
|
|
: fs.readFileSync(path.join(ROOT, 'qa', 'SKILL.md'), 'utf-8');
|
|
}
|
|
|
|
/** Slice out of the qa-patterns section, guarded non-empty: judging an empty
|
|
* slice would silently pass garbage to the judge. */
|
|
function sliceQaPatterns(startHeader: string, endHeader?: string): string {
|
|
const content = readQaPatterns();
|
|
const start = content.indexOf(startHeader);
|
|
if (start < 0) throw new Error(`qa/sections/qa-patterns.md: "${startHeader}" not found — regenerate with: bun run gen:skill-docs`);
|
|
const end = endHeader ? content.indexOf(endHeader, start) : -1;
|
|
const section = end > start ? content.slice(start, end) : content.slice(start);
|
|
if (section.trim().length < 200) {
|
|
throw new Error(`qa/sections/qa-patterns.md slice at "${startHeader}" is empty/stub — regenerate with: bun run gen:skill-docs`);
|
|
}
|
|
return section;
|
|
}
|
|
|
|
describeIfSelected('QA skill quality evals', ['qa/SKILL.md workflow', 'qa/SKILL.md health rubric', 'qa/SKILL.md anti-refusal'], () => {
|
|
testIfSelected('qa/SKILL.md workflow', async () => {
|
|
const t0 = Date.now();
|
|
const section = sliceQaPatterns('## Workflow', '## Health Score Rubric');
|
|
|
|
const scores = await callJudge<JudgeScore>(`You are evaluating the quality of a QA testing workflow document for an AI coding agent.
|
|
|
|
The agent reads this document to learn how to systematically QA test a web application. The workflow references
|
|
a browser driver (Aside 'aside repl' scripts, with the headless browse CLI's $B commands as fallback) that is documented
|
|
separately in the skill's BROWSER SETUP section — do NOT penalize for missing driver definitions.
|
|
Instead, evaluate whether the workflow itself is clear, complete, and actionable.
|
|
|
|
Rate on three dimensions (1-5 scale):
|
|
- **clarity** (1-5): Can an agent follow the step-by-step phases without ambiguity?
|
|
- **completeness** (1-5): Are all phases, decision points, and outputs well-defined?
|
|
- **actionability** (1-5): Can an agent execute the workflow and produce the expected deliverables?
|
|
|
|
Respond with ONLY valid JSON:
|
|
{"clarity": N, "completeness": N, "actionability": N, "reasoning": "brief explanation"}
|
|
|
|
Here is the QA workflow to evaluate:
|
|
|
|
${section}`);
|
|
console.log('QA workflow scores:', JSON.stringify(scores, null, 2));
|
|
|
|
evalCollector?.addTest({
|
|
name: 'qa/SKILL.md workflow',
|
|
suite: 'QA skill quality evals',
|
|
tier: 'llm-judge',
|
|
passed: scores.clarity >= 4 && scores.completeness >= 3 && scores.actionability >= 4,
|
|
duration_ms: Date.now() - t0,
|
|
cost_usd: 0.02,
|
|
judge_scores: { clarity: scores.clarity, completeness: scores.completeness, actionability: scores.actionability },
|
|
judge_reasoning: scores.reasoning,
|
|
});
|
|
|
|
expect(scores.clarity).toBeGreaterThanOrEqual(4);
|
|
// Completeness scores 3 when judge notes the health rubric is in a separate
|
|
// section (the eval only passes the Workflow section, not the full document).
|
|
expect(scores.completeness).toBeGreaterThanOrEqual(3);
|
|
expect(scores.actionability).toBeGreaterThanOrEqual(4);
|
|
}, 30_000);
|
|
|
|
testIfSelected('qa/SKILL.md health rubric', async () => {
|
|
const t0 = Date.now();
|
|
const section = sliceQaPatterns('## Health Score Rubric');
|
|
|
|
const scores = await callJudge<JudgeScore>(`You are evaluating a health score rubric that an AI agent must follow to compute a numeric QA score.
|
|
|
|
The agent uses this rubric after QA testing a website. It needs to:
|
|
1. Understand each scoring category and what counts as a deduction
|
|
2. Apply the weights correctly to compute a final score out of 100
|
|
3. Produce a consistent, reproducible score
|
|
|
|
Rate on three dimensions (1-5 scale):
|
|
- **clarity** (1-5): Are the categories, deduction criteria, and weights unambiguous?
|
|
- **completeness** (1-5): Are all edge cases and scoring boundaries defined?
|
|
- **actionability** (1-5): Can an agent compute a correct score from this rubric alone?
|
|
|
|
Respond with ONLY valid JSON:
|
|
{"clarity": N, "completeness": N, "actionability": N, "reasoning": "brief explanation"}
|
|
|
|
Here is the rubric to evaluate:
|
|
|
|
${section}`);
|
|
console.log('QA health rubric scores:', JSON.stringify(scores, null, 2));
|
|
|
|
evalCollector?.addTest({
|
|
name: 'qa/SKILL.md health rubric',
|
|
suite: 'QA skill quality evals',
|
|
tier: 'llm-judge',
|
|
passed: scores.clarity >= 4 && scores.completeness >= 3 && scores.actionability >= 4,
|
|
duration_ms: Date.now() - t0,
|
|
cost_usd: 0.02,
|
|
judge_scores: { clarity: scores.clarity, completeness: scores.completeness, actionability: scores.actionability },
|
|
judge_reasoning: scores.reasoning,
|
|
});
|
|
|
|
expect(scores.clarity).toBeGreaterThanOrEqual(4);
|
|
expect(scores.completeness).toBeGreaterThanOrEqual(3);
|
|
expect(scores.actionability).toBeGreaterThanOrEqual(4);
|
|
}, 30_000);
|
|
|
|
testIfSelected('qa/SKILL.md anti-refusal', async () => {
|
|
const t0 = Date.now();
|
|
// Extract both the diff-aware mode section and Important Rules section.
|
|
// (Pre-carve this sliced '## Important Rules' → '## Framework-Specific',
|
|
// which was EMPTY — Framework-Specific precedes Important Rules — so the
|
|
// judge only ever saw excerpt 1. The section-file slice fixes that: rules
|
|
// run to the end of qa-patterns.md, so rule 12 "Never refuse to use the
|
|
// browser" now actually reaches the judge.)
|
|
const diffAwareSection = sliceQaPatterns('### Diff-aware', '### Full');
|
|
const rulesSection = sliceQaPatterns('## Important Rules');
|
|
|
|
const result = await callJudge<{ would_browse: boolean; fallback_behavior: string; confidence: number; reasoning: string }>(`You are evaluating whether a QA testing skill document would cause an AI agent to USE THE BROWSER or REFUSE to use the browser in a specific scenario.
|
|
|
|
SCENARIO:
|
|
A user runs /qa (a browser-based QA testing skill). The branch diff shows ONLY prompt template files and config file changes — no routes, views, controllers, components, or CSS were changed. The changes are "purely backend" with no obvious UI surface.
|
|
|
|
QUESTION: Based on the document excerpts below, would the agent open the browser and test the application, or would it decline/refuse to browse and suggest running evals or unit tests instead?
|
|
|
|
DOCUMENT EXCERPT 1 (Diff-aware mode instructions):
|
|
${diffAwareSection}
|
|
|
|
DOCUMENT EXCERPT 2 (Important Rules):
|
|
${rulesSection}
|
|
|
|
Respond with ONLY valid JSON:
|
|
{
|
|
"would_browse": true or false,
|
|
"fallback_behavior": "description of what the agent would do when no UI pages are identified from the diff",
|
|
"confidence": N (1-5, how confident you are in your answer),
|
|
"reasoning": "brief explanation"
|
|
}
|
|
|
|
Rules:
|
|
- would_browse should be true if the document instructs the agent to always use the browser regardless of diff content
|
|
- would_browse should be false if the document allows the agent to skip browser testing for non-UI changes
|
|
- confidence: 5 = document is unambiguous, 1 = document is unclear or contradictory`);
|
|
|
|
console.log('QA anti-refusal result:', JSON.stringify(result, null, 2));
|
|
|
|
evalCollector?.addTest({
|
|
name: 'qa/SKILL.md anti-refusal',
|
|
suite: 'QA skill quality evals',
|
|
tier: 'llm-judge',
|
|
passed: result.would_browse === true && result.confidence >= 4,
|
|
duration_ms: Date.now() - t0,
|
|
cost_usd: 0.02,
|
|
judge_scores: { would_browse: result.would_browse ? 1 : 0, confidence: result.confidence },
|
|
judge_reasoning: result.reasoning,
|
|
});
|
|
|
|
expect(result.would_browse).toBe(true);
|
|
expect(result.confidence).toBeGreaterThanOrEqual(4);
|
|
}, 30_000);
|
|
});
|
|
|
|
// --- Part 7: Cross-skill consistency judge (C7) ---
|
|
|
|
describeIfSelected('Cross-skill consistency evals', ['cross-skill greptile consistency'], () => {
|
|
testIfSelected('cross-skill greptile consistency', async () => {
|
|
const t0 = Date.now();
|
|
const reviewContent = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
|
|
const shipContent = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
|
|
const triageContent = fs.readFileSync(path.join(ROOT, 'review', 'greptile-triage.md'), 'utf-8');
|
|
const retroContent = fs.readFileSync(path.join(ROOT, 'retro', 'SKILL.md'), 'utf-8');
|
|
|
|
const extractGrepLines = (content: string, filename: string) => {
|
|
const lines = content.split('\n')
|
|
.filter(l => /greptile|history\.md|REMOTE_SLUG/i.test(l))
|
|
.map(l => l.trim());
|
|
return `--- ${filename} ---\n${lines.join('\n')}`;
|
|
};
|
|
|
|
const collected = [
|
|
extractGrepLines(reviewContent, 'review/SKILL.md'),
|
|
extractGrepLines(shipContent, 'ship/SKILL.md'),
|
|
extractGrepLines(triageContent, 'review/greptile-triage.md'),
|
|
extractGrepLines(retroContent, 'retro/SKILL.md'),
|
|
].join('\n\n');
|
|
|
|
const result = await callJudge<{ consistent: boolean; issues: string[]; score: number; reasoning: string }>(`You are evaluating whether multiple skill configuration files implement the same data architecture consistently.
|
|
|
|
INTENDED ARCHITECTURE:
|
|
- greptile-history has TWO paths: per-project (~/.gstack/projects/{slug}/greptile-history.md) and global (~/.gstack/greptile-history.md)
|
|
- /review and /ship WRITE to BOTH paths (per-project for suppressions, global for retro aggregation)
|
|
- /review and /ship delegate write mechanics to greptile-triage.md
|
|
- /retro READS from the GLOBAL path only (it aggregates across all projects)
|
|
- REMOTE_SLUG derivation should be consistent across files that use it
|
|
|
|
Below are greptile-related lines extracted from each skill file:
|
|
|
|
${collected}
|
|
|
|
Evaluate consistency. Respond with ONLY valid JSON:
|
|
{
|
|
"consistent": true/false,
|
|
"issues": ["issue 1", "issue 2"],
|
|
"score": N,
|
|
"reasoning": "brief explanation"
|
|
}
|
|
|
|
score (1-5): 5 = perfectly consistent, 1 = contradictory`);
|
|
|
|
console.log('Cross-skill consistency:', JSON.stringify(result, null, 2));
|
|
|
|
evalCollector?.addTest({
|
|
name: 'cross-skill greptile consistency',
|
|
suite: 'Cross-skill consistency evals',
|
|
tier: 'llm-judge',
|
|
passed: result.consistent && result.score >= 4,
|
|
duration_ms: Date.now() - t0,
|
|
cost_usd: 0.02,
|
|
judge_scores: { consistency_score: result.score },
|
|
judge_reasoning: result.reasoning,
|
|
});
|
|
|
|
expect(result.consistent).toBe(true);
|
|
expect(result.score).toBeGreaterThanOrEqual(4);
|
|
}, 30_000);
|
|
});
|
|
|
|
// --- Part 7: Baseline score pinning (C9) ---
|
|
|
|
describeIfSelected('Baseline score pinning', ['baseline score pinning'], () => {
|
|
const baselinesPath = path.join(ROOT, 'test', 'fixtures', 'eval-baselines.json');
|
|
|
|
testIfSelected('baseline score pinning', async () => {
|
|
const t0 = Date.now();
|
|
if (!fs.existsSync(baselinesPath)) {
|
|
console.log('No baseline file found — skipping pinning check');
|
|
return;
|
|
}
|
|
|
|
const baselines = JSON.parse(fs.readFileSync(baselinesPath, 'utf-8'));
|
|
const regressions: string[] = [];
|
|
|
|
// Browse carve: the command reference lives in browse/sections/command-list.md.
|
|
const cmdSection = sliceBrowseSection('## Full Command List');
|
|
const cmdScores = await judge('command reference table', cmdSection);
|
|
|
|
for (const dim of ['clarity', 'completeness', 'actionability'] as const) {
|
|
if (cmdScores[dim] < baselines.command_reference[dim]) {
|
|
regressions.push(`command_reference.${dim}: ${cmdScores[dim]} < baseline ${baselines.command_reference[dim]}`);
|
|
}
|
|
}
|
|
|
|
if (process.env.UPDATE_BASELINES) {
|
|
baselines.command_reference = {
|
|
clarity: cmdScores.clarity,
|
|
completeness: cmdScores.completeness,
|
|
actionability: cmdScores.actionability,
|
|
};
|
|
fs.writeFileSync(baselinesPath, JSON.stringify(baselines, null, 2) + '\n');
|
|
console.log('Updated eval baselines');
|
|
}
|
|
|
|
const passed = regressions.length === 0;
|
|
evalCollector?.addTest({
|
|
name: 'baseline score pinning',
|
|
suite: 'Baseline score pinning',
|
|
tier: 'llm-judge',
|
|
passed,
|
|
duration_ms: Date.now() - t0,
|
|
cost_usd: 0.02,
|
|
judge_scores: { clarity: cmdScores.clarity, completeness: cmdScores.completeness, actionability: cmdScores.actionability },
|
|
judge_reasoning: passed ? 'All scores at or above baseline' : regressions.join('; '),
|
|
});
|
|
|
|
if (!passed) {
|
|
throw new Error(`Score regressions detected:\n${regressions.join('\n')}`);
|
|
}
|
|
}, JUDGE_MS);
|
|
});
|
|
|
|
// --- Workflow SKILL.md quality evals (10 new tests for 100% coverage) ---
|
|
|
|
/**
|
|
* DRY helper for workflow SKILL.md judge tests.
|
|
* Extracts a section from a SKILL.md file and judges its quality as an agent workflow.
|
|
*/
|
|
async function runWorkflowJudge(opts: {
|
|
testName: string;
|
|
suite: string;
|
|
skillPath: string;
|
|
startMarker: string;
|
|
endMarker: string | null;
|
|
judgeContext: string;
|
|
judgeGoal: string;
|
|
thresholds?: { clarity: number; completeness: number; actionability: number };
|
|
}) {
|
|
const t0 = Date.now();
|
|
const defaults = { clarity: 4, completeness: 3, actionability: 4 };
|
|
const thresholds = { ...defaults, ...opts.thresholds };
|
|
|
|
// Read the skeleton + sections UNION so carved skills (v2 plan T9) still
|
|
// expose markers that moved into sections/*.md (e.g. plan-eng's "## Review
|
|
// Sections" + "## CRITICAL RULE", plan-design's 7 passes). Without this the
|
|
// slice markers vanish from the skeleton and the judge scores empty content.
|
|
let content = fs.readFileSync(path.join(ROOT, opts.skillPath), 'utf-8');
|
|
const secDir = path.join(ROOT, path.dirname(opts.skillPath), 'sections');
|
|
const sectionBodies: string[] = [];
|
|
if (fs.existsSync(secDir)) {
|
|
for (const f of fs.readdirSync(secDir).sort()) {
|
|
if (f.endsWith('.md') && !f.endsWith('.md.tmpl')) {
|
|
const body = fs.readFileSync(path.join(secDir, f), 'utf-8');
|
|
sectionBodies.push(body);
|
|
content += '\n' + body;
|
|
}
|
|
}
|
|
}
|
|
const startIdx = content.indexOf(opts.startMarker);
|
|
if (startIdx === -1) throw new Error(`Start marker not found in ${opts.skillPath}: "${opts.startMarker}"`);
|
|
|
|
let section: string;
|
|
if (opts.endMarker) {
|
|
const endIdx = content.indexOf(opts.endMarker, startIdx);
|
|
if (endIdx === -1) throw new Error(`End marker not found in ${opts.skillPath}: "${opts.endMarker}"`);
|
|
section = content.slice(startIdx, endIdx);
|
|
} else {
|
|
section = content.slice(startIdx);
|
|
}
|
|
|
|
// Two carve shapes exist. plan-eng/plan-design moved the MARKERS into the
|
|
// section files, so the slice above already reaches the carved content.
|
|
// document-release instead keeps its markers in the skeleton and carves the
|
|
// workflow BODY (Steps 2-9 → sections/release-body.md) AFTER the endMarker,
|
|
// so the marker slice drops it. Re-append any carved section the window
|
|
// excluded, so the judge always sees the full workflow the agent executes.
|
|
for (const body of sectionBodies) {
|
|
const head = body.trim().slice(0, 120);
|
|
if (head && !section.includes(head)) section += '\n' + body;
|
|
}
|
|
|
|
const scores = await callJudge<JudgeScore>(`You are evaluating the quality of ${opts.judgeContext} for an AI coding agent.
|
|
|
|
The agent reads this document to learn ${opts.judgeGoal}. It references external tools and files
|
|
that are documented separately — do NOT penalize for missing external definitions.
|
|
|
|
Rate on three dimensions (1-5 scale):
|
|
- **clarity** (1-5): Can an agent follow the instructions without ambiguity?
|
|
- **completeness** (1-5): Are all steps, decision points, and outputs well-defined?
|
|
- **actionability** (1-5): Can an agent execute this workflow and produce the expected deliverables?
|
|
|
|
Respond with ONLY valid JSON:
|
|
{"clarity": N, "completeness": N, "actionability": N, "reasoning": "brief explanation"}
|
|
|
|
Here is the document to evaluate:
|
|
|
|
${section}`);
|
|
|
|
console.log(`${opts.testName} scores:`, JSON.stringify(scores, null, 2));
|
|
|
|
evalCollector?.addTest({
|
|
name: opts.testName,
|
|
suite: opts.suite,
|
|
tier: 'llm-judge',
|
|
passed: scores.clarity >= thresholds.clarity && scores.completeness >= thresholds.completeness && scores.actionability >= thresholds.actionability,
|
|
duration_ms: Date.now() - t0,
|
|
cost_usd: 0.02,
|
|
judge_scores: { clarity: scores.clarity, completeness: scores.completeness, actionability: scores.actionability },
|
|
judge_reasoning: scores.reasoning,
|
|
});
|
|
|
|
expect(scores.clarity).toBeGreaterThanOrEqual(thresholds.clarity);
|
|
expect(scores.completeness).toBeGreaterThanOrEqual(thresholds.completeness);
|
|
expect(scores.actionability).toBeGreaterThanOrEqual(thresholds.actionability);
|
|
}
|
|
|
|
// Block 1: Ship & Release skills
|
|
describeIfSelected('Ship & Release skill evals', ['ship/SKILL.md workflow', 'document-release/SKILL.md workflow'], () => {
|
|
testIfSelected('ship/SKILL.md workflow', async () => {
|
|
await runWorkflowJudge({
|
|
testName: 'ship/SKILL.md workflow',
|
|
suite: 'Ship & Release skill evals',
|
|
skillPath: 'ship/SKILL.md',
|
|
startMarker: '# Ship:',
|
|
endMarker: '## Important Rules',
|
|
judgeContext: 'a ship/release workflow document',
|
|
judgeGoal: 'how to create a PR: merge base branch, run tests, review diff, bump version, update changelog, push, and open PR',
|
|
});
|
|
}, 30_000);
|
|
|
|
testIfSelected('document-release/SKILL.md workflow', async () => {
|
|
await runWorkflowJudge({
|
|
testName: 'document-release/SKILL.md workflow',
|
|
suite: 'Ship & Release skill evals',
|
|
skillPath: 'document-release/SKILL.md',
|
|
startMarker: '# Document Release:',
|
|
endMarker: '## Important Rules',
|
|
judgeContext: 'a post-ship documentation update workflow',
|
|
judgeGoal: 'how to audit and update project documentation after code ships: README, ARCHITECTURE, CONTRIBUTING, CLAUDE.md, CHANGELOG, TODOS',
|
|
});
|
|
}, 30_000);
|
|
});
|
|
|
|
// Block 2: Plan Review skills
|
|
describeIfSelected('Plan Review skill evals', [
|
|
'plan-ceo-review/SKILL.md modes', 'plan-eng-review/SKILL.md sections', 'plan-design-review/SKILL.md passes',
|
|
], () => {
|
|
testIfSelected('plan-ceo-review/SKILL.md modes', async () => {
|
|
await runWorkflowJudge({
|
|
testName: 'plan-ceo-review/SKILL.md modes',
|
|
suite: 'Plan Review skill evals',
|
|
skillPath: 'plan-ceo-review/SKILL.md',
|
|
startMarker: '## Step 0: Nuclear Scope Challenge',
|
|
endMarker: '## Review Sections',
|
|
judgeContext: 'a CEO/founder plan review framework with 4 scope modes',
|
|
judgeGoal: 'how to conduct a CEO-perspective plan review: challenge scope, select a mode (Expansion, Selective Expansion, Hold Scope, Reduction), then review sections interactively',
|
|
});
|
|
}, 30_000);
|
|
|
|
testIfSelected('plan-eng-review/SKILL.md sections', async () => {
|
|
await runWorkflowJudge({
|
|
testName: 'plan-eng-review/SKILL.md sections',
|
|
suite: 'Plan Review skill evals',
|
|
skillPath: 'plan-eng-review/SKILL.md',
|
|
startMarker: '## BEFORE YOU START:',
|
|
endMarker: '## CRITICAL RULE',
|
|
judgeContext: 'an engineering plan review framework with 4 review sections',
|
|
judgeGoal: 'how to review a plan for architecture quality, code quality, test coverage, and performance — walking through each section interactively with AskUserQuestion',
|
|
});
|
|
}, 30_000);
|
|
|
|
testIfSelected('plan-design-review/SKILL.md passes', async () => {
|
|
await runWorkflowJudge({
|
|
testName: 'plan-design-review/SKILL.md passes',
|
|
suite: 'Plan Review skill evals',
|
|
skillPath: 'plan-design-review/SKILL.md',
|
|
startMarker: '## Review Sections',
|
|
endMarker: '## CRITICAL RULE',
|
|
judgeContext: 'a design plan review framework with 7 review passes',
|
|
judgeGoal: 'how to review a plan for design quality using a 0-10 rating method: rate each dimension, explain what a 10 looks like, edit the plan to fix gaps, then re-rate',
|
|
});
|
|
}, 30_000);
|
|
});
|
|
|
|
// Block 3: Design skills
|
|
describeIfSelected('Design skill evals', ['design-review/SKILL.md fix loop', 'design-consultation/SKILL.md research'], () => {
|
|
testIfSelected('design-review/SKILL.md fix loop', async () => {
|
|
await runWorkflowJudge({
|
|
testName: 'design-review/SKILL.md fix loop',
|
|
suite: 'Design skill evals',
|
|
skillPath: 'design-review/SKILL.md',
|
|
startMarker: '## Phase 7:',
|
|
endMarker: '## Additional Rules',
|
|
judgeContext: 'a design audit triage and fix loop workflow',
|
|
judgeGoal: 'how to triage design issues by severity, fix them atomically in source code, commit each fix, and re-verify with before/after screenshots',
|
|
});
|
|
}, 30_000);
|
|
|
|
testIfSelected('design-consultation/SKILL.md research', async () => {
|
|
await runWorkflowJudge({
|
|
testName: 'design-consultation/SKILL.md research',
|
|
suite: 'Design skill evals',
|
|
skillPath: 'design-consultation/SKILL.md',
|
|
startMarker: '## Phase 1:',
|
|
endMarker: '## Phase 4:',
|
|
judgeContext: 'a design consultation research and proposal workflow',
|
|
judgeGoal: 'how to gather product context, research the competitive landscape, and produce a complete design system proposal with typography, color, spacing, and motion specifications',
|
|
});
|
|
}, 30_000);
|
|
});
|
|
|
|
// Block 4: Deploy skills
|
|
describeIfSelected('Deploy skill evals', [
|
|
'land-and-deploy/SKILL.md workflow', 'canary/SKILL.md monitoring loop',
|
|
'benchmark/SKILL.md perf collection', 'setup-deploy/SKILL.md platform setup',
|
|
], () => {
|
|
testIfSelected('land-and-deploy/SKILL.md workflow', async () => {
|
|
await runWorkflowJudge({
|
|
testName: 'land-and-deploy/SKILL.md workflow',
|
|
suite: 'Deploy skill evals',
|
|
skillPath: 'land-and-deploy/SKILL.md',
|
|
startMarker: '## Step 1: Pre-flight',
|
|
endMarker: '## Important Rules',
|
|
judgeContext: 'a merge-deploy-verify workflow for landing PRs to production',
|
|
judgeGoal: 'how to merge a PR via GitHub CLI, wait for CI and deploy workflows (with platform-specific strategies for Fly.io/Render/Vercel/Netlify), run canary health checks on production, and offer revert if something breaks — with timing data logged for retrospectives',
|
|
});
|
|
}, 30_000);
|
|
|
|
testIfSelected('canary/SKILL.md monitoring loop', async () => {
|
|
await runWorkflowJudge({
|
|
testName: 'canary/SKILL.md monitoring loop',
|
|
suite: 'Deploy skill evals',
|
|
skillPath: 'canary/SKILL.md',
|
|
startMarker: '### Phase 2: Baseline Capture',
|
|
endMarker: '## Important Rules',
|
|
judgeContext: 'a post-deploy canary monitoring workflow driving a real browser (Aside first, the gstack headless browser as fallback)',
|
|
judgeGoal: 'how to capture baseline screenshots and metrics before deploy, run a continuous monitoring loop checking each page every 60 seconds for console errors and performance regressions, fire alerts with evidence (screenshots), and produce a health report with per-page status and verdict',
|
|
});
|
|
}, 30_000);
|
|
|
|
testIfSelected('benchmark/SKILL.md perf collection', async () => {
|
|
await runWorkflowJudge({
|
|
testName: 'benchmark/SKILL.md perf collection',
|
|
suite: 'Deploy skill evals',
|
|
skillPath: 'benchmark/SKILL.md',
|
|
startMarker: '### Phase 3: Performance Data Collection',
|
|
endMarker: '## Important Rules',
|
|
judgeContext: 'a performance regression detection workflow using browser-based Web Vitals measurement (Aside first, the gstack headless browser as fallback)',
|
|
judgeGoal: 'how to collect real performance metrics (TTFB, FCP, LCP, bundle sizes, request counts) via performance.getEntries(), compare against baselines with regression thresholds, produce a performance report with delta analysis, and track trends over time',
|
|
});
|
|
}, 30_000);
|
|
|
|
testIfSelected('setup-deploy/SKILL.md platform setup', async () => {
|
|
await runWorkflowJudge({
|
|
testName: 'setup-deploy/SKILL.md platform setup',
|
|
suite: 'Deploy skill evals',
|
|
skillPath: 'setup-deploy/SKILL.md',
|
|
startMarker: '### Step 2: Detect platform',
|
|
endMarker: '## Important Rules',
|
|
judgeContext: 'a deployment configuration setup workflow that detects deploy platforms and writes config to CLAUDE.md',
|
|
judgeGoal: 'how to detect deploy platforms (Fly.io, Render, Vercel, Netlify, Heroku, GitHub Actions, custom), gather platform-specific configuration (URLs, status commands, health checks, custom hooks), and persist everything to CLAUDE.md for future automated use',
|
|
});
|
|
}, 30_000);
|
|
});
|
|
|
|
// Block 5: Other skills
|
|
describeIfSelected('Other skill evals', [
|
|
'retro/SKILL.md instructions', 'qa-only/SKILL.md workflow', 'gstack-upgrade/SKILL.md upgrade flow',
|
|
], () => {
|
|
testIfSelected('retro/SKILL.md instructions', async () => {
|
|
await runWorkflowJudge({
|
|
testName: 'retro/SKILL.md instructions',
|
|
suite: 'Other skill evals',
|
|
skillPath: 'retro/SKILL.md',
|
|
startMarker: '## Instructions',
|
|
endMarker: '## Compare Mode',
|
|
judgeContext: 'an engineering retrospective data gathering and analysis workflow',
|
|
judgeGoal: 'how to gather git metrics (commit history, test counts, work patterns), analyze them, produce a structured retro report with praise, growth areas, and trend tracking',
|
|
});
|
|
}, 30_000);
|
|
|
|
testIfSelected('qa-only/SKILL.md workflow', async () => {
|
|
await runWorkflowJudge({
|
|
testName: 'qa-only/SKILL.md workflow',
|
|
suite: 'Other skill evals',
|
|
skillPath: 'qa-only/SKILL.md',
|
|
startMarker: '## Workflow',
|
|
endMarker: '## Important Rules',
|
|
judgeContext: 'a report-only QA testing workflow',
|
|
judgeGoal: 'how to systematically QA test a web application and produce a structured report with health score, screenshots, and repro steps — without fixing anything',
|
|
});
|
|
}, 30_000);
|
|
|
|
testIfSelected('gstack-upgrade/SKILL.md upgrade flow', async () => {
|
|
await runWorkflowJudge({
|
|
testName: 'gstack-upgrade/SKILL.md upgrade flow',
|
|
suite: 'Other skill evals',
|
|
skillPath: 'gstack-upgrade/SKILL.md',
|
|
startMarker: '## Inline upgrade flow',
|
|
endMarker: '## Standalone usage',
|
|
judgeContext: 'a version upgrade detection and execution workflow',
|
|
judgeGoal: 'how to detect install type, compare versions, back up current install, upgrade via git or fresh clone, run setup, and show what changed',
|
|
});
|
|
}, 30_000);
|
|
});
|
|
|
|
// Voice directive eval — tests that the voice section produces the right tone
|
|
describeIfSelected('Voice directive eval', ['voice directive tone'], () => {
|
|
testIfSelected('voice directive tone', async () => {
|
|
const t0 = Date.now();
|
|
// Read a tier 2+ skill to get the full voice directive in context
|
|
const content = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
|
|
const voiceStart = content.indexOf('## Voice');
|
|
if (voiceStart === -1) {
|
|
throw new Error('Voice section not found in review/SKILL.md. Was preamble.ts regenerated?');
|
|
}
|
|
const voiceEnd = content.indexOf('\n## ', voiceStart + 1);
|
|
const voiceSection = content.slice(voiceStart, voiceEnd > 0 ? voiceEnd : voiceStart + 3000);
|
|
|
|
const result = await callJudge<{
|
|
directness: number;
|
|
concreteness: number;
|
|
avoids_corporate: number;
|
|
avoids_ai_vocabulary: number;
|
|
connects_user_outcomes: number;
|
|
reasoning: string;
|
|
}>(`You are evaluating a voice directive for an AI coding assistant framework called GStack.
|
|
Score each dimension 1-5 where 5 is excellent:
|
|
|
|
1. directness: Does it instruct the agent to be direct, lead with the point, take positions?
|
|
2. concreteness: Does it instruct the agent to name specific files, commands, line numbers, real numbers?
|
|
3. avoids_corporate: Does it explicitly ban corporate/formal/academic tone and provide alternatives?
|
|
4. avoids_ai_vocabulary: Does it ban AI-tell words and phrases with specific lists?
|
|
5. connects_user_outcomes: Does it instruct the agent to connect technical work to real user experience?
|
|
|
|
Return JSON only:
|
|
{"directness": N, "concreteness": N, "avoids_corporate": N, "avoids_ai_vocabulary": N, "connects_user_outcomes": N, "reasoning": "..."}
|
|
|
|
THE VOICE DIRECTIVE:
|
|
${voiceSection}`);
|
|
|
|
console.log('Voice directive scores:', JSON.stringify(result, null, 2));
|
|
|
|
evalCollector?.addTest({
|
|
name: 'voice directive tone',
|
|
suite: 'Voice directive eval',
|
|
tier: 'llm-judge',
|
|
passed: result.directness >= 4 && result.concreteness >= 4 && result.avoids_corporate >= 4
|
|
&& result.avoids_ai_vocabulary >= 4 && result.connects_user_outcomes >= 4,
|
|
duration_ms: Date.now() - t0,
|
|
cost_usd: 0.02,
|
|
judge_scores: {
|
|
directness: result.directness,
|
|
concreteness: result.concreteness,
|
|
avoids_corporate: result.avoids_corporate,
|
|
avoids_ai_vocabulary: result.avoids_ai_vocabulary,
|
|
connects_user_outcomes: result.connects_user_outcomes,
|
|
},
|
|
judge_reasoning: result.reasoning,
|
|
});
|
|
|
|
expect(result.directness).toBeGreaterThanOrEqual(4);
|
|
expect(result.concreteness).toBeGreaterThanOrEqual(4);
|
|
expect(result.avoids_corporate).toBeGreaterThanOrEqual(4);
|
|
expect(result.avoids_ai_vocabulary).toBeGreaterThanOrEqual(4);
|
|
expect(result.connects_user_outcomes).toBeGreaterThanOrEqual(4);
|
|
}, 30_000);
|
|
});
|
|
|
|
// Module-level afterAll — finalize eval collector after all tests complete
|
|
afterAll(() => finalizeEvalCollector(evalCollector));
|