From 15783611fddeb59cd19ce4b4b84346632f71d040 Mon Sep 17 00:00:00 2001 From: mforce Date: Wed, 16 Sep 2026 17:20:29 +0000 Subject: [PATCH 1/6] chore(verify): rebuild the verify skill around the sim harness, add a capture helper and a feature map The skill predated the Playwright suite in tools/simulation/ui that every revamp PR now verifies with, and it had no doctor step, no evidence standard, no cleanup discipline and no feature map. It now launches and doctors the cluckwork-sim stack, drives it through the harness's own fixtures and catalog labels, states the proof standards, ships capture.sh (four 1:1 frames of a route on the demo farm, throwaway spec removed after), and maps the five features the smoke suite already covers. The dev-server path stays as the debugging section. .gitignore's carve-out for .claude/skills/verify/ never applied: git cannot re-include a file under an excluded directory, so the parent rule becomes .claude/skills/* and the negation now bites. --- .claude/skills/verify/SKILL.md | 195 +++++++++++++----- .claude/skills/verify/capture.sh | 64 ++++++ .claude/skills/verify/features/README.md | 23 +++ .claude/skills/verify/features/daily-entry.md | 52 +++++ .claude/skills/verify/features/dashboard.md | 52 +++++ .claude/skills/verify/features/flocks.md | 45 ++++ .claude/skills/verify/features/sales.md | 49 +++++ .claude/skills/verify/features/stock.md | 42 ++++ .gitignore | 2 +- 9 files changed, 466 insertions(+), 58 deletions(-) create mode 100755 .claude/skills/verify/capture.sh create mode 100644 .claude/skills/verify/features/README.md create mode 100644 .claude/skills/verify/features/daily-entry.md create mode 100644 .claude/skills/verify/features/dashboard.md create mode 100644 .claude/skills/verify/features/flocks.md create mode 100644 .claude/skills/verify/features/sales.md create mode 100644 .claude/skills/verify/features/stock.md diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md index 2e908c054..74bb53572 100644 --- a/.claude/skills/verify/SKILL.md +++ b/.claude/skills/verify/SKILL.md @@ -1,87 +1,168 @@ --- name: verify -description: Build, launch, and drive the Cluckwork app (API + SPA) locally to verify a change end-to-end in a real browser. +description: Launch, doctor, drive and capture evidence from the real Cluckwork app (the .NET API serving the built React SPA) to verify a change end-to-end in a real browser. Reach for it when a PR changes what a user sees or does, before claiming a UI change works, and for the before/after screenshots AGENTS.md requires. --- -# Verifying Cluckwork changes at runtime +# Verifying Cluckwork at runtime -## Launch the stack +Cluckwork's user surface is the web SPA (`web/`) served by the API. The primary way to +verify it is the **simulation stack**: a Production-config app plus Postgres and Redis under +the `cluckwork-sim` compose project, seeded with a deterministic fixture and driven by the +Playwright suite in `tools/simulation/ui/`. Every revamp PR in September 2026 was verified +this way. The dev-server path (Compose Postgres, `dotnet run`, Vite) still exists for +debugging and is described at the end; it is not the evidence path. -**First: is an Aspire AppHost already running?** Ask the AppHost, not the container names: +Read `features/README.md` for the per-feature map before driving anything: a proof that +takes one convenient entry point is incomplete when the map lists others. + +## Launch + +Run these in the repo root or in a worktree of the branch under review (the stack is built from the +checkout you run this in, so a worktree at the PR head captures the PR head): ```sh -pgrep -af 'Cluckwork\.AppHost' # the AppHost process itself -ss -ltn | grep -E ':18888\b' # its pinned dashboard (#565) -aspire describe --apphost src/Cluckwork.AppHost/Cluckwork.AppHost.csproj \ - --format Json --non-interactive # the endpoints THIS run actually holds +bash tools/simulation/bootstrap.sh # once per checkout: writes .env.sim + .sim-cast.json (git-ignored) +sg docker -c 'bash tools/simulation/reset.sh' # wipe, rebuild the image, migrate, seed both farms, self-check (about 4-6 min) ``` -Do **not** detect it with `docker ps | grep -E 'postgres-|redis-'` — that matches -`deploy-redis-1` and `cluckwork-sim-redis-1` too, so Compose or the sim stack reads as Aspire. -And do not treat `5433`/`6380`/`8080`/`5173` as more than **committed defaults**: `LocalPorts:*` -can be overridden per machine or set empty for a random port, so a running AppHost can be sitting -on none of them. `aspire describe` is the authority for a given run; the dashboard port is pinned -and so is a reliable presence check. +`sg docker` is required on this machine: the shell's group list predates the docker group +(see the global CLAUDE.md note). Ready is the last three lines of `reset.sh`'s output: -If one is running it already owns the API and SPA ports, so the manual launch below collides with -it, and Aspire's Postgres is a **different database** with a generated credential (#565). Either -drive the running Aspire stack at its advertised web endpoint and skip steps 1-3 entirely, or stop -the AppHost first. Do not run both. +``` +Manifest: .../tools/simulation/out/manifest.json +Cast: .../tools/simulation/.sim-cast.json +Farms: default-farm (simulation fixture), readme-farm (demo, README capture) +``` +Run it in the background and poll for the `Farms:` line with an `until` loop in a +foreground call; never end a turn to wait on it. -```sh -# 1. Dev Postgres (loopback 5432, creds cluckwork/cluckwork/cluckwork) -docker compose -f deploy/docker-compose.dev.yml up -d --wait +**Two farms come up.** `default-farm` is the simulation fixture: about 100 catalog flocks +that never file, so the Dashboard's Today list is capped at twelve unrecorded rows and the +fortnight strip has no complete day. `readme-farm` ("Meadowlark") is the demo profile: two +houses, one seeded as today's draft, real orders. **Capture screenshots on `readme-farm`**; +drive write flows on `default-farm`, which the smoke specs already do. -# 2. API — MUST set ASPNETCORE_ENVIRONMENT=Development or user-secrets -# (connection string, Seed:*, JWT keys) don't load and startup crashes -# with "ConnectionString property has not been initialized". -ASPNETCORE_ENVIRONMENT=Development ASPNETCORE_URLS=http://127.0.0.1:8080 \ - dotnet run --project src/Cluckwork.Api --no-launch-profile - -# 3. SPA dev server (proxies /api -> 8080) -cd web && npm run dev # http://localhost:5173 -``` +**Isolation: there is exactly one stack.** Both farms live in one compose project on +`127.0.0.1:8081`, and `reset.sh` runs `down -v` on it. Never run `reset.sh` while another +agent is driving or capturing; check with the doctor first and coordinate through the +coordinator. Reading the running stack (a capture, a smoke run) is safe to share; a reset +is not. -A freshly reset database has no admin at all — provision one with -[first-admin provisioning](../../../docs/runbooks/first-admin-provisioning.md) (form 4 if the -database is Aspire's), then use that generated password rather than `Seed:*`. +## Doctor -Login credentials depend on which stack you are driving, and the two are not interchangeable: +Run this before driving whenever anything looks off. All four answers must be yes. -- **Compose dev database, seeded** — `dotnet user-secrets list --project src/Cluckwork.Api | grep '^Seed:'` - (email `admin@cluckwork.local`). These are the **Compose** credentials only; they do not exist in - Aspire's database. -- **Aspire's database, or any freshly reset one** — there is no `Seed:*` answer. The sign-in password - is the one `bootstrap-admin` printed **to stdout on the run that created the Owner** (form 4), and - it is not recoverable afterwards: it is stored hashed and never logged. If it was not captured, mint - a new one with `recover-admin` rather than hunting for it. The AppHost user-secret - `Parameters:postgres-password` is the **database** password — it is what form 4 passes in - `ConnectionStrings__Default`, and it is never a login credential. +```sh +sg docker -c 'docker ps --filter name=cluckwork-sim --format "{{.Names}} {{.Status}}"' # app, db, redis healthy +curl -fsS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8081/health/ready # 200 +ls tools/simulation/.sim-cast.json tools/simulation/.env.sim # both present in THIS checkout +git rev-parse --short HEAD # the commit the image was built from is this checkout's HEAD +``` -Don't print either password into reports. +The fourth line is the one people skip: a long-running stack serves the bytes it was built +from, not the branch you are looking at. If the stack predates your head, reset. -## Drive the browser +## Drive -Playwright MCP fails here (wants Google Chrome at /opt/google/chrome; machine has chromium). Instead script `playwright-core` against system chromium: +Use the harness, not a scratch `playwright-core` script: its fixtures sign in through the +real login form with credentials from `.sim-cast.json`, respect the farm clock, and run in +two projects (`chromium` at 1280x720, `chromium-phone` at 390x844, partitioned by the +`@phone` tag). ```sh -cd && npm i playwright-core +cd tools/simulation/ui +npm test # the whole smoke suite, about 30 s +npx playwright test specs/owner.spec.ts --project=chromium # one spec +npx playwright test specs/phone.spec.ts --project=chromium-phone # the phone project ``` -```js -import { chromium } from "playwright-core"; -const browser = await chromium.launch({ executablePath: "/usr/bin/chromium" }); +Inside a spec, the handles that matter: + +- `import { test, expect } from "../src/fixtures"` gives `page`, `signIn(member)`, `farm` + (timezone), `nav` (desktop shell), `phone` (tab bar and More sheet at 390). +- Personas from `../src/cast`: `owner()`, `castMember("Manager")`, + `unrestrictedWorker()`, `restrictedWorker()`, `readmeFarmOwner()` (the demo farm; its + `farmCode` is `readme-farm`). Never type a credential; never print one. +- Labels through `tEn(":")` from `../src/i18n`, never hardcoded English: + `tEn("dailyEntry:flockLabel")`, `tEn("sales:newOrder")`, `tEn("stock:lotsHeading")`. +- `commitNamedPicker(page, tEn("dailyEntry:flockLabel"), "Sim House A")` from `../src/dom` + drives the searchable picker; `selectOptionContaining(select, needle)` for plain selects. +- Anything about `inert` or the accessibility tree goes through CDP in `../src/ax.ts`; + Playwright's own APIs do not model `inert` (#501). +- Rows on the new Dashboard are `getByRole("group", { name: })`; the sales + list is `getByRole("list", { name: tEn("dashboard:salesPanelTitle") })`; footer actions + are buttons by their catalog label. + +A throwaway spec for a one-off proof goes under `specs-screenshots/` with a name ending in +`-screenshots.spec.ts` (that config's `testMatch`) and is deleted before you commit; the +`capture.sh` helper below writes and removes one for you. + +## Evidence + +Proof standards, in order of how often they are skipped: + +1. **Exercise the real user path.** Sign in through the form, navigate by the nav or the + URL, click the control the user clicks. No test-only endpoints, no internal setters. +2. **Capture the action and the resulting state**, not only the final screen: a Draft row + after saving, the stock lot after a write-off, the order after a payment. +3. **Verify side effects** alongside the pixels: the API's row through + `GET /api/v1/...` with the persona's token, or the next screen that reads it (History for + an adjusted entry, Stock for an allocated order). +4. **Screenshots are 1:1**: viewport only, device scale factor 1, no full-page smears of + sticky bars, 1280x800 and 390x844, light and dark, from a stack rebuilt at the head under + review. Before frames come from `origin/main` rebuilt the same way. +5. **Where it goes**: `/tmp//{before,after}--.png`. Attach to the PR with + `gh pr comment -R mforce/cluckwork --attach './after-1280-light.png#After, 1280 light'`; + never commit images to a branch (a branch is deletable and takes the evidence with it). + Also record console errors (`page.on("pageerror")`, CSP refusals) in the PR comment when + they appear; `csp-nonce.spec.ts` shows the assertion. + +`capture.sh` does 4 and writes the files for 5: + +```sh +.claude/skills/verify/capture.sh [route] [farm] [prefix] +# examples +.claude/skills/verify/capture.sh 883-after / readme # Dashboard, four frames +.claude/skills/verify/capture.sh 888-after /daily-entry readme +.claude/skills/verify/capture.sh 888 /daily-entry readme before # on a stack built from main ``` -Login flow: fill `getByLabel(/email/i)` + `getByLabel(/password/i)`, click `getByRole("button", { name: /sign in/i })`, then wait for the target heading. Collect `page.on("pageerror")` + console errors; screenshot for evidence. +It signs in as the named farm's owner, sets the theme through `data-theme`, captures the +four frames into `/tmp//`, prints any console errors it saw, and removes its +throwaway spec. It reads the running stack; it never resets it. One `401 (Unauthorized)` +resource error per run is the sign-in page's session probe on a fresh browser context, +observed on every proof run against a healthy stack; anything else in that line is a +finding to report. + +## Cleanup -## API-level seeding +- Remove any throwaway spec you wrote under `specs-screenshots/` and any scratch files in + the worktree (`git status --short` must be empty of your leftovers before the commit). +- Leave the stack up unless you started it for yourself and nobody else is driving it. To + take it down: `sg docker -c 'docker compose -p cluckwork-sim down -v'` from the checkout + that started it. Never kill by process name. +- Evidence survives cleanup: `/tmp//` stays, and the attached PR comment is the + durable copy. -Get a token once, then curl with `Authorization: Bearer` + `Idempotency-Key: $(uuidgen)` on every POST/PUT/DELETE (middleware rejects writes without it). Useful for setting up scenario data (flocks, entries, orders) faster than clicking. +## The dev-server path (debugging, not evidence) -## Gotchas +Useful when you need hot reload or a debugger, not for proofs. First check that no Aspire +AppHost owns the ports (`pgrep -af 'Cluckwork\.AppHost'`, `ss -ltn | grep ':18888\b'`, or +`aspire describe --apphost src/Cluckwork.AppHost/Cluckwork.AppHost.csproj --format Json --non-interactive`); +Aspire's Postgres is a different database with a generated credential (#565), so do not +run both. + +```sh +docker compose -f deploy/docker-compose.dev.yml up -d --wait # Postgres 5432 +ASPNETCORE_ENVIRONMENT=Development ASPNETCORE_URLS=http://127.0.0.1:8080 \ + dotnet run --project src/Cluckwork.Api --no-launch-profile # API; the env var is load-bearing +cd web && npm run dev # Vite on 5173, proxies /api +``` -- Dev DB persists between sessions — expect residual data; create what the scenario needs rather than assuming empty state. -- First cold page load may log one or two transient 404 console errors (vite dev asset probing); ignore unless reproducible on reload. -- Teardown: kill dotnet/vite background tasks, `docker compose -f deploy/docker-compose.dev.yml down`. +A fresh database has no Owner: provision one per +`docs/runbooks/first-admin-provisioning.md` and use that printed password. Seeded Compose +credentials: `dotnet user-secrets list --project src/Cluckwork.Api | grep '^Seed:'`. API +writes need `Authorization: Bearer` and an `Idempotency-Key: $(uuidgen)` header. +Teardown: stop the two processes you started, then +`docker compose -f deploy/docker-compose.dev.yml down`. diff --git a/.claude/skills/verify/capture.sh b/.claude/skills/verify/capture.sh new file mode 100755 index 000000000..0ce30a716 --- /dev/null +++ b/.claude/skills/verify/capture.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Capture four 1:1 frames of one route from the RUNNING simulation stack: +# 1280x800 and 390x844, light and dark, into /tmp//after--.png. +# Reads the stack; never resets it. Signs in as the farm's owner from .sim-cast.json. +# +# .claude/skills/verify/capture.sh [route=/] [farm=readme|default] [prefix=after] +set -euo pipefail + +slug="${1:?slug, e.g. 883-after}" +route="${2:-/}" +farm="${3:-readme}" +prefix="${4:-after}" + +root="$(git rev-parse --show-toplevel)" +ui="$root/tools/simulation/ui" +out="/tmp/$slug" +spec="$ui/specs-screenshots/verify-capture-$slug-screenshots.spec.ts" + +case "$farm" in + readme) persona="readmeFarmOwner()" ;; + default) persona="owner()" ;; + *) echo "farm must be readme or default" >&2; exit 2 ;; +esac + +[ -f "$root/tools/simulation/.sim-cast.json" ] || { echo "no .sim-cast.json in $root: run tools/simulation/bootstrap.sh" >&2; exit 1; } +curl -fsS -o /dev/null http://127.0.0.1:8081/health/ready || { echo "stack not ready on 127.0.0.1:8081" >&2; exit 1; } +[ -d "$ui/node_modules" ] || (cd "$ui" && npm ci --silent) + +mkdir -p "$out" +cleanup() { rm -f "$spec"; } +trap cleanup EXIT + +cat > "$spec" < = [ + [1280, 800, "light"], [1280, 800, "dark"], [390, 844, "light"], [390, 844, "dark"], +]; + +test.describe("verify capture $slug", () => { + for (const [w, h, theme] of SHOTS) { + test(\`verify-capture \${w} \${theme}\`, async ({ page, signIn }) => { + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(String(e))); + page.on("console", (m) => { if (m.type() === "error") errors.push(m.text()); }); + await page.setViewportSize({ width: w, height: h }); + await page.emulateMedia({ colorScheme: theme }); + await signIn($persona); + await page.goto(ROUTE); + await page.evaluate((t) => { document.documentElement.dataset.theme = t; }, theme); + await page.waitForTimeout(800); + await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()); + await page.screenshot({ path: \`\${OUT}$prefix-\${w}-\${theme}.png\`, animations: "disabled" }); + if (errors.length) console.log(\`[console errors at \${w} \${theme}] \` + errors.join(" | ")); + }); + } +}); +EOF + +(cd "$ui" && npx playwright test --config playwright.screenshots.config.ts --grep "verify-capture" --project=chromium 2>&1 | grep -E "passed|failed|console errors" || true) +ls -1 "$out" diff --git a/.claude/skills/verify/features/README.md b/.claude/skills/verify/features/README.md new file mode 100644 index 000000000..35a66828d --- /dev/null +++ b/.claude/skills/verify/features/README.md @@ -0,0 +1,23 @@ +# Feature map + +One file per user-facing feature. Each answers, from the user's side: what it is, how to +reach it, how to drive it with the harness in `tools/simulation/ui/`, and what end state +proves it works. A proof that drives one entry point is incomplete when the file lists +others. Keep this map honest with `/maintain-verification-skill` when the app changes. + +| Feature | Route | Personas | Existing specs | +| --- | --- | --- | --- | +| [Dashboard](dashboard.md) | `/` | every role | `owner.spec.ts`, `phone.spec.ts`, `readonly.spec.ts` | +| [Daily entry](daily-entry.md) | `/daily-entry` | Worker, Manager, Owner | `worker.spec.ts`, `manager.spec.ts`, `phone.spec.ts` | +| [Sales](sales.md) | `/sales`, `/customers` | Sales, Manager, Owner | `sales.spec.ts`, `worker-sale-allocation.spec.ts` | +| [Stock](stock.md) | `/stock`, `/inventory` | every role reads; Manager, Owner write | `manager.spec.ts`, `readonly.spec.ts`, `pagination.spec.ts` | +| [Flocks](flocks.md) | `/flocks` | Manager, Owner | `manager.spec.ts`, `named-entity-picker.spec.ts` | + +Not yet mapped (routes exist, add a file when you first verify one): `/feed`, `/water`, +`/history`, `/reports` (`reports-range.spec.ts`), `/expenses`, `/settings` (Owner only, +#729), `/grades`, `/products`, `/users`, `/audit`, `/export`, `/account`, `/help`. + +Cross-cutting guarantees with their own specs, not features: session refresh and races +(`session-*.spec.ts`), i18n across es and tl (`i18n.spec.ts`), the PWA shell offline +(`pwa.spec.ts`), the CSP nonce (`csp-nonce.spec.ts`), live regions under a modal +(`a11y-live-regions.spec.ts`). diff --git a/.claude/skills/verify/features/daily-entry.md b/.claude/skills/verify/features/daily-entry.md new file mode 100644 index 000000000..ceac15012 --- /dev/null +++ b/.claude/skills/verify/features/daily-entry.md @@ -0,0 +1,52 @@ +# Daily entry + +The screen a worker uses standing in the shed: pick the house and the date, count eggs +and losses, grade them, save a draft or submit the day. Submitting creates egg lots. +Field-first on the phone per #830. + +## Sub-features + +- Context: flock picker (searchable, `commitNamedPicker`), date, "+ new flock". +- Egg counts pane: total eggs, cracked, dirty, discarded, mortality, each a stepper row. +- Grading pane: one stepper row per sellable grade, "Counted N" in the head, the sum row + that says whether the day adds up. +- Footer: Save draft and Submit day side by side (the #740 exemption; both 48px). +- Draft state line under the title ("Draft, saved HH:MM" in the farm clock). +- Adjust and void after submit happen on History, not here (`manager.spec.ts`). + +## How to get to it (user POV) + +Nav: Production, Daily entry, or the Daily entry tab on the phone. Route `/daily-entry`. +A Worker sees only assigned flocks in the picker (#388). + +## Driving it with Playwright + +```ts +import { test, expect } from "../src/fixtures"; +import { unrestrictedWorker } from "../src/cast"; +import { commitNamedPicker } from "../src/dom"; +import { farmToday } from "../src/farm"; +import { tEn } from "../src/i18n"; + +await signIn(unrestrictedWorker()); +await page.goto("/daily-entry"); +await page.getByLabel(tEn("dailyEntry:dateLabel")).fill(farmToday(farm.timeZoneId)); +await commitNamedPicker(page, tEn("dailyEntry:flockLabel"), "Sim House A"); +await page.getByLabel(tEn("dailyEntry:totalEggsLabel"), { exact: true }).fill("40"); +await page.getByRole("button", { name: tEn("dailyEntry:saveDraftButton") }).click(); +await expect(page.getByText(tEn("dailyEntry:draftSavedMessage"))).toBeVisible(); +``` + +Proof: after Submit day, `/stock` shows new lots for that flock and date, and the +Dashboard's Today row for the house reads recorded with the time. `worker.spec.ts` records +by grade; `manager.spec.ts` submits then adjusts and voids on History. + +## Gotchas + +- Respect the farm clock: fill dates with `farmToday(farm.timeZoneId)`, never the + machine's date. +- A restricted Worker is refused on an unassigned flock (#388); use + `unrestrictedWorker()` for write proofs. +- The number field keeps its own stepper logic (#828 owns its conversion); the row grid is + the screen's. Hold-to-repeat is real, so do not hold a button while asserting. +- The phone footer must stay clear of the tab bar (`phone.spec.ts` pins the margin). diff --git a/.claude/skills/verify/features/dashboard.md b/.claude/skills/verify/features/dashboard.md new file mode 100644 index 000000000..1ba89563d --- /dev/null +++ b/.claude/skills/verify/features/dashboard.md @@ -0,0 +1,52 @@ +# Dashboard + +The morning screen: which houses have filed today, eggs on hand by grade, the last +fortnight's production, recent orders. Since #883 it is the ruled ledger from +`docs/designs/864-visual-language/` at 1280 and a ruled house list at 390. + +## Sub-features + +- Attention line under the title: missing houses first, folding into "+N more" (two items + at 1280, one at 390). +- Today: one ruled row per active house with entry state and time, a Record button (the + single filled button on the page) on an unrecorded house, Continue as ruled text on a + draft, a count on the right, "Today so far" with "Yesterday by close: N" under it. Capped + at twelve rows. +- Recent sales: customer with the order number under it, amount, status as dot plus word, + "Review to confirm" on a draft. No eggs-and-grade cell yet (#887). +- Stock: eggs available, the stacked grade bar, the grade table. +- Last 14 days: the day strip with the hen-day figure and delta. + +## How to get to it (user POV) + +Sign in; it is the landing route `/`. On the phone it lives under More in the tab bar +(Dashboard is not one of the four tabs). + +## Driving it with Playwright + +```ts +import { test, expect } from "../src/fixtures"; +import { owner, readmeFarmOwner } from "../src/cast"; +import { tEn } from "../src/i18n"; + +await signIn(readmeFarmOwner()); // demo farm: two houses, one draft, real orders +await page.goto("/"); +await expect(page.getByRole("heading", { name: tEn("dashboard:title") })).toBeVisible(); +const row = page.getByRole("group", { name: "House 2 layers" }); // one Today row +await expect(row.getByRole("link", { name: /Record/ })).toBeVisible(); // the filled action +const sales = page.getByRole("list", { name: tEn("dashboard:salesPanelTitle") }); +await expect(sales.getByRole("listitem").first()).toBeVisible(); +``` + +Proof: the row's count changes after a daily entry is submitted for that house (drive +`/daily-entry`, come back, read the number); a draft order shows "Review to confirm" and +the link lands on `/sales` filtered to that customer. + +## Gotchas + +- On `default-farm` every catalog flock is unrecorded, so the Today list is twelve amber + bands and a draft you create never reaches it; use `readme-farm` for anything that needs + a recorded or draft row. +- The fortnight strip hatches days where any active house did not file; on the demo farm + the first seven days are hatched by the seed (#886). +- Section headings that are links are underlined by design (#884). diff --git a/.claude/skills/verify/features/flocks.md b/.claude/skills/verify/features/flocks.md new file mode 100644 index 000000000..34e044dd4 --- /dev/null +++ b/.claude/skills/verify/features/flocks.md @@ -0,0 +1,45 @@ +# Flocks + +The houses: create a flock (name, breed, placed date, bird count), see its status and bird +movements, deplete it at end of lay. + +## Sub-features + +- Flock list with status and current bird count. +- New flock dialog (`tEn("flocks:newFlockButton")`, `newFlockDialogTitle`, `nameLabel`, + `breedLabel`, `placedLabel`, `birdsLabel`, `addFlockButton`). +- Bird movement ledger per flock, paged (`pagination.spec.ts`, "Sim House A's + bird-movement ledger reveals its page-two sentinel"). +- Flock scoping: a Worker's assignments decide which flocks appear in pickers (#388, + #613). + +## How to get to it (user POV) + +Nav: Production, Flocks. Route `/flocks`. Also reachable from Daily entry's "+ new flock". + +## Driving it with Playwright + +```ts +await signIn(castMember("Manager")); +await page.goto("/flocks"); +await page.getByRole("button", { name: tEn("flocks:newFlockButton") }).click(); +const dialog = page.getByRole("dialog", { name: tEn("flocks:newFlockDialogTitle") }); +await dialog.getByLabel(tEn("flocks:nameLabel")).fill(`E2E Flock ${Date.now()}`); +await dialog.getByLabel(tEn("flocks:breedLabel")).fill("E2E Leghorn"); +await dialog.getByLabel(tEn("flocks:placedLabel")).fill(farmToday(farm.timeZoneId)); +await dialog.getByLabel(tEn("flocks:birdsLabel"), { exact: true }).fill("50"); +const created = page.waitForResponse((r) => r.url().includes("/api/v1/flocks") && r.request().method() === "POST" && r.ok()); +await dialog.getByRole("button", { name: tEn("flocks:addFlockButton") }).click(); +const { id } = await (await created).json(); +``` + +Proof: the new flock appears in the list and in the Daily entry picker (`commitNamedPicker` +resolves the same id); `manager.spec.ts` continues into an entry, an adjustment and a +void. + +## Gotchas + +- Names starting with `E2E` sort ahead of the seeded `Sim` ones and persist until the next + reset; a capture taken after a smoke run photographs test data. +- The Dashboard caps Today at twelve rows, so a new flock on `default-farm` is not visible + there; prove creation on `/flocks` or in the picker. diff --git a/.claude/skills/verify/features/sales.md b/.claude/skills/verify/features/sales.md new file mode 100644 index 000000000..9d3aec071 --- /dev/null +++ b/.claude/skills/verify/features/sales.md @@ -0,0 +1,49 @@ +# Sales + +Customers and orders: take an order, confirm it (which allocates stock FIFO from egg lots), +record payment. The Dashboard's Recent sales list reads the same orders. + +## Sub-features + +- Customers list and "New customer" (`/customers`). +- New order: customer picker, date, lines (product, unit egg, quantity), "Add line". +- Draft order, then confirm: allocation decrements stock; an under-stocked confirm is + refused with the shortfall. +- Record payment: method, amount; "Unpaid only" filter. +- Status vocabulary: Draft, Confirmed, Allocated, Paid, as dot plus word. + +## How to get to it (user POV) + +Nav: Sales & stock, Sales; the Sales tab on the phone. Route `/sales`; customers at +`/customers`. The Dashboard's "Review to confirm" link lands here filtered by customer. + +## Driving it with Playwright + +`sales.spec.ts` drives the whole path "takes an order from new customer through to a +recorded payment". The handles: + +```ts +await page.goto("/customers"); +await page.getByRole("button", { name: tEn("customers:newCustomerButton") }).click(); +// fill the dialog, save +await page.goto("/sales"); +await page.getByRole("button", { name: tEn("sales:newOrder") }).click(); +await commitNamedPicker(page, tEn("sales:customer"), customerName); +await page.getByRole("button", { name: tEn("sales:addLine") }).click(); +await commitNamedPicker(page, tEn("sales:product"), "Large"); +// quantity, unit tEn("sales:unitEgg"), save the draft, confirm, then +await page.getByRole("button", { name: tEn("sales:recordPayment") }).click(); +``` + +Proof: the order's status walks Draft, Confirmed, Paid on the list; `/stock` shows the +allocated lots decremented; `worker-sale-allocation.spec.ts` proves a Worker cannot +allocate. + +## Gotchas + +- Confirming needs stock: on a fresh reset the fixture has lots; after many smoke runs the + deep grades may be short, and the refusal is a legitimate outcome to assert, not a flake. +- The searchable pickers are paged: `named-entity-picker.spec.ts` shows how a customer on + page two is reached through search. +- Money is displayed in the farm's currency and locale; assert on the catalog string, not + on a formatted number typed by hand. diff --git a/.claude/skills/verify/features/stock.md b/.claude/skills/verify/features/stock.md new file mode 100644 index 000000000..0ff51c6bb --- /dev/null +++ b/.claude/skills/verify/features/stock.md @@ -0,0 +1,42 @@ +# Stock + +Eggs on hand by grade, the lots behind each grade, and the movement ledger: allocations +to orders, write-offs, adjustments. + +## Sub-features + +- Stock by grade with the stacked bar (`StockBar`, also on the Dashboard). +- Lots per grade ("Lots" button, `tEn("stock:lotsHeading")`), paged with "Load more" + (#465). +- Write-off from a lot (`tEn("stock:writeOffButton")`, then the submit button). +- Movement ledger with a date range (`tEn("stock:fromLabel")`, `tEn("stock:toLabel")`). +- General inventory (feed and supplies) is a separate screen at `/inventory`. + +## How to get to it (user POV) + +Nav: Sales & stock, Stock; the Stock tab on the phone. Route `/stock`. + +## Driving it with Playwright + +```ts +await signIn(castMember("Manager")); +await page.goto("/stock"); +await page.getByRole("button", { name: tEn("stock:lotsButton") }).first().click(); +await expect(page.getByRole("heading", { name: tEn("stock:lotsHeading") })).toBeVisible(); +await page.getByRole("button", { name: tEn("stock:writeOffButton") }).first().click(); +// fill the quantity and reason, then +await page.getByRole("button", { name: tEn("stock:writeOffSubmitButton") }).click(); +``` + +Proof: the grade's available count drops by the written-off quantity, the ledger shows the +movement with the actor, and the daily entry that created the lot is untouched +(`manager.spec.ts` "writes off lost stock from its own lot without touching the entry"). +`readonly.spec.ts` proves a ReadOnly user pages lots with Load more and cannot write. + +## Gotchas + +- A deep grade has more lots than one page; assert after "Load more", not before. +- Write-offs need a lot with quantity; pick the first lot of the largest grade on a fresh + reset. +- The bar's grade hues are the only place grade colour appears (direction); do not assert + on colour elsewhere. diff --git a/.gitignore b/.gitignore index 62a38edaf..f2ac693fc 100644 --- a/.gitignore +++ b/.gitignore @@ -50,7 +50,7 @@ TestResults/ coverage-out/ .agents/skills -.claude/skills +.claude/skills/* # verify/ is Cluckwork's own skill, not an installed one: it knows this repo's # stacks and ports, it is tracked (since #43), and gitignore does not apply to # tracked files anyway - so the carve-out states the intent instead of leaving From 2e4f7d68b30448d640c886653297652cfef09a46 Mon Sep 17 00:00:00 2001 From: mforce Date: Wed, 16 Sep 2026 17:23:57 +0000 Subject: [PATCH 2/6] chore(verify): hold capture.sh's arguments to a literal alphabet The slug, prefix and route are interpolated into a generated Playwright spec and a /tmp path; a slug with quotes or braces became code and one with a separator escaped the directory. Each argument is now validated against a fixed alphabet before anything is written. --- .claude/skills/verify/capture.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.claude/skills/verify/capture.sh b/.claude/skills/verify/capture.sh index 0ce30a716..83312d38c 100755 --- a/.claude/skills/verify/capture.sh +++ b/.claude/skills/verify/capture.sh @@ -11,6 +11,13 @@ route="${2:-/}" farm="${3:-readme}" prefix="${4:-after}" +# The four arguments are interpolated into a generated spec and a /tmp path, so +# each is held to a literal alphabet: no quotes, no braces, no path separators +# in the names, and the route is an absolute app path with a query at most. +[[ "$slug" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "slug must match [A-Za-z0-9._-]+" >&2; exit 2; } +[[ "$prefix" =~ ^[A-Za-z0-9_-]+$ ]] || { echo "prefix must match [A-Za-z0-9_-]+" >&2; exit 2; } +[[ "$route" =~ ^/[A-Za-z0-9._/?=&-]*$ ]] || { echo "route must be an absolute app path like /daily-entry" >&2; exit 2; } + root="$(git rev-parse --show-toplevel)" ui="$root/tools/simulation/ui" out="/tmp/$slug" From d390f11aadd46c9e1a0f8435eb2d39b92a6b049f Mon Sep 17 00:00:00 2001 From: mforce Date: Wed, 16 Sep 2026 17:24:36 +0000 Subject: [PATCH 3/6] chore(verify): keep the route pattern out of the conditional so bash parses it The unquoted & in the =~ pattern was a syntax error at parse time, so every invocation exited 2, the previous commit included. The patterns live in variables now; rejections and a real capture re-proven. --- .claude/skills/verify/capture.sh | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.claude/skills/verify/capture.sh b/.claude/skills/verify/capture.sh index 83312d38c..e13ea8846 100755 --- a/.claude/skills/verify/capture.sh +++ b/.claude/skills/verify/capture.sh @@ -14,9 +14,11 @@ prefix="${4:-after}" # The four arguments are interpolated into a generated spec and a /tmp path, so # each is held to a literal alphabet: no quotes, no braces, no path separators # in the names, and the route is an absolute app path with a query at most. -[[ "$slug" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "slug must match [A-Za-z0-9._-]+" >&2; exit 2; } -[[ "$prefix" =~ ^[A-Za-z0-9_-]+$ ]] || { echo "prefix must match [A-Za-z0-9_-]+" >&2; exit 2; } -[[ "$route" =~ ^/[A-Za-z0-9._/?=&-]*$ ]] || { echo "route must be an absolute app path like /daily-entry" >&2; exit 2; } +name_re='^[A-Za-z0-9._-]+$' +route_re='^/[A-Za-z0-9._/?=&-]*$' +[[ "$slug" =~ $name_re ]] || { echo "slug must match $name_re" >&2; exit 2; } +[[ "$prefix" =~ $name_re ]] || { echo "prefix must match $name_re" >&2; exit 2; } +[[ "$route" =~ $route_re ]] || { echo "route must be an absolute app path like /daily-entry" >&2; exit 2; } root="$(git rev-parse --show-toplevel)" ui="$root/tools/simulation/ui" From ec4ac75819d6c9423eb88bd16d556fd367cb40c7 Mon Sep 17 00:00:00 2001 From: mforce Date: Wed, 16 Sep 2026 17:29:55 +0000 Subject: [PATCH 4/6] chore(verify): make the verify skill agent-agnostic at tools/verify, symlinked into each agent's skill directory, and name it in AGENTS.md The tree is repo knowledge (stacks, ports, fixtures, proof standards), not Claude knowledge, and Codex never looked under .claude. It now lives at tools/verify/; .claude/skills/verify, .codex/skills/verify and .agents/skills/verify are tracked symlinks to it, with the gitignore parent rules ending in /* so the carve-outs apply. AGENTS.md carries the one-paragraph rule pointing at it. --- .agents/skills/verify | 1 + .claude/skills/verify | 1 + .codex/skills/verify | 1 + .gitignore | 17 ++++++++++------- AGENTS.md | 1 + {.claude/skills => tools}/verify/SKILL.md | 8 ++++---- {.claude/skills => tools}/verify/capture.sh | 2 +- .../skills => tools}/verify/features/README.md | 0 .../verify/features/daily-entry.md | 0 .../verify/features/dashboard.md | 0 .../skills => tools}/verify/features/flocks.md | 0 .../skills => tools}/verify/features/sales.md | 0 .../skills => tools}/verify/features/stock.md | 0 13 files changed, 19 insertions(+), 12 deletions(-) create mode 120000 .agents/skills/verify create mode 120000 .claude/skills/verify create mode 120000 .codex/skills/verify rename {.claude/skills => tools}/verify/SKILL.md (96%) rename {.claude/skills => tools}/verify/capture.sh (97%) rename {.claude/skills => tools}/verify/features/README.md (100%) rename {.claude/skills => tools}/verify/features/daily-entry.md (100%) rename {.claude/skills => tools}/verify/features/dashboard.md (100%) rename {.claude/skills => tools}/verify/features/flocks.md (100%) rename {.claude/skills => tools}/verify/features/sales.md (100%) rename {.claude/skills => tools}/verify/features/stock.md (100%) diff --git a/.agents/skills/verify b/.agents/skills/verify new file mode 120000 index 000000000..1ea967e2d --- /dev/null +++ b/.agents/skills/verify @@ -0,0 +1 @@ +../../tools/verify \ No newline at end of file diff --git a/.claude/skills/verify b/.claude/skills/verify new file mode 120000 index 000000000..1ea967e2d --- /dev/null +++ b/.claude/skills/verify @@ -0,0 +1 @@ +../../tools/verify \ No newline at end of file diff --git a/.codex/skills/verify b/.codex/skills/verify new file mode 120000 index 000000000..1ea967e2d --- /dev/null +++ b/.codex/skills/verify @@ -0,0 +1 @@ +../../tools/verify \ No newline at end of file diff --git a/.gitignore b/.gitignore index f2ac693fc..1612232b3 100644 --- a/.gitignore +++ b/.gitignore @@ -49,14 +49,17 @@ TestResults/ # here; nothing in the repo consumes it and it is regenerated on every run. coverage-out/ -.agents/skills +.agents/skills/* .claude/skills/* -# verify/ is Cluckwork's own skill, not an installed one: it knows this repo's -# stacks and ports, it is tracked (since #43), and gitignore does not apply to -# tracked files anyway - so the carve-out states the intent instead of leaving -# the file looking like an accident in every diff. -!.claude/skills/verify/ -.codex/skills +.codex/skills/* +# verify is Cluckwork's own skill, not an installed one: it knows this repo's +# stacks and ports. Its tree lives at tools/verify/ (agent-agnostic) and each +# agent's skill directory carries a symlink to it, tracked here so every +# runtime discovers the same SKILL.md. The parent rules end in /* on purpose: +# git cannot re-include a file under an excluded directory. +!.agents/skills/verify +!.claude/skills/verify +!.codex/skills/verify skills-lock.json scratchpad/ diff --git a/AGENTS.md b/AGENTS.md index d13509f17..bc8890764 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -137,6 +137,7 @@ dotnet test Cluckwork.sln # 2295 tests as of 2026-09; integrati - **A write-contract change must update its non-CI callers (#394).** Coverage is not uniform, and it is not what it was when this rule was written. The seeders are covered only to the handler layer, so a **validator-only** tightening is invisible to every seeder test. `tools/simulation/k6/` is **genuinely uncovered** — `k6-baseline.yml` is `workflow_dispatch` only, and a tolerated-status list means a green run does not mean a landing write. The **Playwright specs are no longer uncovered**: since 2026-08-08 `e2e-smoke.yml` runs the quick suite on every `pull_request` touching `src/**`, `web/**`, `tools/simulation/**` or `deploy/**` — which is every write-contract change by definition — so CI does catch a broken spec there. A docs-only PR still skips it, and `slow`/`canary` stay dispatch-only. **Verify the request/status contract by reading the callers, not by running** — the reading is what still finds the k6 case and what tells you whether a caller's *intent* changed. → [`394-write-contract-callers.md`](docs/decisions/394-write-contract-callers.md) - **SPA E2E lives in `tools/simulation/ui/` (#277/#385).** Playwright drives the real built SPA over the same `seed --profile simulation` fixture k6 uses; `web/` stays Vitest. Three enforced rules, each of which has caught something: never hardcode a credential, never hardcode English, respect the farm clock. Anything reasoning about `inert` or the accessibility tree must go through CDP (`src/ax.ts`) — Playwright's own APIs do not model `inert` (#501). The suite runs in two projects partitioned by the `@phone` tag (#814) — `chromium` at 1280 and `chromium-phone` at 390, where the sidebar is `display: none`, so the `nav` fixture throws and a phone test uses `phone` instead. → [`277-spa-e2e.md`](docs/decisions/277-spa-e2e.md) - **Any PR that changes what a user sees attaches screenshots, captured from a stack rebuilt at the head under review (#662).** Not only a PR whose *purpose* is visual: a new dialog, a new field, a changed label or an altered state all qualify, because the trigger is what the reader can no longer check from the diff. **Before and after wherever a before exists**, the same viewport and the same scenario in both; after-only where the UI is net-new and there is nothing to compare against. It is the only check in this repo that reads the *rendered* result: on #661 it found the one product defect that four review seats, three CI Playwright runs and the driver's own verification all passed. Three mechanics matter — capture at 1:1, because downscaling destroys exactly the 1px hairlines and 4–5% alpha shadows a styling change is about; rebuild first, because a long-running sim stack serves the bytes it was built from, not the branch under review; and attach the images to the PR with `gh pr comment --attach` (or `gh pr create --attach`), **never** by committing them to a branch — a branch is deletable, so routine cleanup silently breaks the images on a merged PR while the prose around them still claims they exist. +- **Runtime verification goes through the repo's own `verify` skill, and it is agent-agnostic.** The skill tree lives at [`tools/verify/`](tools/verify/SKILL.md): how to launch and doctor the `cluckwork-sim` stack, how to drive it through the Playwright harness's fixtures and catalog labels, the proof standards, `capture.sh` for the four 1:1 frames the rule above asks for, and a per-feature map under `tools/verify/features/` that says what end state proves each screen works. `.claude/skills/verify`, `.codex/skills/verify` and `.agents/skills/verify` are symlinks to that tree, tracked on purpose, so Claude Code, Codex and any other runtime that reads the Agent Skills layout discover the same file; edit the tree, never a symlink target copy. Two facts the skill enforces that are easy to skip: there is exactly one sim stack (one compose project, `reset.sh` runs `down -v`), so never reset it while another agent is driving it; and a long-running stack serves the bytes it was built from, so the doctor's last check is that the image came from the checkout under review. Keep the feature map honest with `/maintain-verification-skill` after a screen slice lands. - **Production logs: compact JSON on stdout; base layer argument-free (#404).** The base `appsettings.json` Console entry carries `Name` only — a template left beside the formatter is **silently** bound instead of it. Trace fields differ by format (`@tr`/`@sp` in compact JSON, `{TraceId}`/`{SpanId}` in the Dev template), and compact JSON serializes **every** property, so anything pushed via `BeginScope`/`LogContext` reaches the collector. → [`404-production-logs.md`](docs/decisions/404-production-logs.md) ## Secrets — never commit diff --git a/.claude/skills/verify/SKILL.md b/tools/verify/SKILL.md similarity index 96% rename from .claude/skills/verify/SKILL.md rename to tools/verify/SKILL.md index 74bb53572..83dc9e693 100644 --- a/.claude/skills/verify/SKILL.md +++ b/tools/verify/SKILL.md @@ -121,11 +121,11 @@ Proof standards, in order of how often they are skipped: `capture.sh` does 4 and writes the files for 5: ```sh -.claude/skills/verify/capture.sh [route] [farm] [prefix] +tools/verify/capture.sh [route] [farm] [prefix] # examples -.claude/skills/verify/capture.sh 883-after / readme # Dashboard, four frames -.claude/skills/verify/capture.sh 888-after /daily-entry readme -.claude/skills/verify/capture.sh 888 /daily-entry readme before # on a stack built from main +tools/verify/capture.sh 883-after / readme # Dashboard, four frames +tools/verify/capture.sh 888-after /daily-entry readme +tools/verify/capture.sh 888 /daily-entry readme before # on a stack built from main ``` It signs in as the named farm's owner, sets the theme through `data-theme`, captures the diff --git a/.claude/skills/verify/capture.sh b/tools/verify/capture.sh similarity index 97% rename from .claude/skills/verify/capture.sh rename to tools/verify/capture.sh index e13ea8846..ce177835a 100755 --- a/.claude/skills/verify/capture.sh +++ b/tools/verify/capture.sh @@ -3,7 +3,7 @@ # 1280x800 and 390x844, light and dark, into /tmp//after--.png. # Reads the stack; never resets it. Signs in as the farm's owner from .sim-cast.json. # -# .claude/skills/verify/capture.sh [route=/] [farm=readme|default] [prefix=after] +# tools/verify/capture.sh [route=/] [farm=readme|default] [prefix=after] set -euo pipefail slug="${1:?slug, e.g. 883-after}" diff --git a/.claude/skills/verify/features/README.md b/tools/verify/features/README.md similarity index 100% rename from .claude/skills/verify/features/README.md rename to tools/verify/features/README.md diff --git a/.claude/skills/verify/features/daily-entry.md b/tools/verify/features/daily-entry.md similarity index 100% rename from .claude/skills/verify/features/daily-entry.md rename to tools/verify/features/daily-entry.md diff --git a/.claude/skills/verify/features/dashboard.md b/tools/verify/features/dashboard.md similarity index 100% rename from .claude/skills/verify/features/dashboard.md rename to tools/verify/features/dashboard.md diff --git a/.claude/skills/verify/features/flocks.md b/tools/verify/features/flocks.md similarity index 100% rename from .claude/skills/verify/features/flocks.md rename to tools/verify/features/flocks.md diff --git a/.claude/skills/verify/features/sales.md b/tools/verify/features/sales.md similarity index 100% rename from .claude/skills/verify/features/sales.md rename to tools/verify/features/sales.md diff --git a/.claude/skills/verify/features/stock.md b/tools/verify/features/stock.md similarity index 100% rename from .claude/skills/verify/features/stock.md rename to tools/verify/features/stock.md From 428eeca8881cf7adf6a113c7d9717ba0c442a47c Mon Sep 17 00:00:00 2001 From: mforce Date: Wed, 16 Sep 2026 17:36:56 +0000 Subject: [PATCH 5/6] chore(verify): tighten the AGENTS.md verify paragraph --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index bc8890764..ff7f482e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -137,7 +137,7 @@ dotnet test Cluckwork.sln # 2295 tests as of 2026-09; integrati - **A write-contract change must update its non-CI callers (#394).** Coverage is not uniform, and it is not what it was when this rule was written. The seeders are covered only to the handler layer, so a **validator-only** tightening is invisible to every seeder test. `tools/simulation/k6/` is **genuinely uncovered** — `k6-baseline.yml` is `workflow_dispatch` only, and a tolerated-status list means a green run does not mean a landing write. The **Playwright specs are no longer uncovered**: since 2026-08-08 `e2e-smoke.yml` runs the quick suite on every `pull_request` touching `src/**`, `web/**`, `tools/simulation/**` or `deploy/**` — which is every write-contract change by definition — so CI does catch a broken spec there. A docs-only PR still skips it, and `slow`/`canary` stay dispatch-only. **Verify the request/status contract by reading the callers, not by running** — the reading is what still finds the k6 case and what tells you whether a caller's *intent* changed. → [`394-write-contract-callers.md`](docs/decisions/394-write-contract-callers.md) - **SPA E2E lives in `tools/simulation/ui/` (#277/#385).** Playwright drives the real built SPA over the same `seed --profile simulation` fixture k6 uses; `web/` stays Vitest. Three enforced rules, each of which has caught something: never hardcode a credential, never hardcode English, respect the farm clock. Anything reasoning about `inert` or the accessibility tree must go through CDP (`src/ax.ts`) — Playwright's own APIs do not model `inert` (#501). The suite runs in two projects partitioned by the `@phone` tag (#814) — `chromium` at 1280 and `chromium-phone` at 390, where the sidebar is `display: none`, so the `nav` fixture throws and a phone test uses `phone` instead. → [`277-spa-e2e.md`](docs/decisions/277-spa-e2e.md) - **Any PR that changes what a user sees attaches screenshots, captured from a stack rebuilt at the head under review (#662).** Not only a PR whose *purpose* is visual: a new dialog, a new field, a changed label or an altered state all qualify, because the trigger is what the reader can no longer check from the diff. **Before and after wherever a before exists**, the same viewport and the same scenario in both; after-only where the UI is net-new and there is nothing to compare against. It is the only check in this repo that reads the *rendered* result: on #661 it found the one product defect that four review seats, three CI Playwright runs and the driver's own verification all passed. Three mechanics matter — capture at 1:1, because downscaling destroys exactly the 1px hairlines and 4–5% alpha shadows a styling change is about; rebuild first, because a long-running sim stack serves the bytes it was built from, not the branch under review; and attach the images to the PR with `gh pr comment --attach` (or `gh pr create --attach`), **never** by committing them to a branch — a branch is deletable, so routine cleanup silently breaks the images on a merged PR while the prose around them still claims they exist. -- **Runtime verification goes through the repo's own `verify` skill, and it is agent-agnostic.** The skill tree lives at [`tools/verify/`](tools/verify/SKILL.md): how to launch and doctor the `cluckwork-sim` stack, how to drive it through the Playwright harness's fixtures and catalog labels, the proof standards, `capture.sh` for the four 1:1 frames the rule above asks for, and a per-feature map under `tools/verify/features/` that says what end state proves each screen works. `.claude/skills/verify`, `.codex/skills/verify` and `.agents/skills/verify` are symlinks to that tree, tracked on purpose, so Claude Code, Codex and any other runtime that reads the Agent Skills layout discover the same file; edit the tree, never a symlink target copy. Two facts the skill enforces that are easy to skip: there is exactly one sim stack (one compose project, `reset.sh` runs `down -v`), so never reset it while another agent is driving it; and a long-running stack serves the bytes it was built from, so the doctor's last check is that the image came from the checkout under review. Keep the feature map honest with `/maintain-verification-skill` after a screen slice lands. +- **Runtime verification uses the repo's `verify` skill at [`tools/verify/`](tools/verify/SKILL.md), and the tree is agent-agnostic.** It covers launching and doctoring the `cluckwork-sim` stack, driving it through the Playwright harness's fixtures and catalog labels, the proof standards, `capture.sh` for the four 1:1 frames the rule above asks for, and a per-feature map under `tools/verify/features/`. `.claude/skills/verify`, `.codex/skills/verify` and `.agents/skills/verify` are tracked symlinks to it, so every runtime that reads the Agent Skills layout loads the same file; edit the tree. Two rules it enforces: there is one sim stack (`reset.sh` runs `down -v` on the one compose project), so never reset it while another agent is driving it; and a running stack serves the bytes it was built from, so the doctor checks that the image came from the checkout under review. Run `/maintain-verification-skill` after a screen slice lands. - **Production logs: compact JSON on stdout; base layer argument-free (#404).** The base `appsettings.json` Console entry carries `Name` only — a template left beside the formatter is **silently** bound instead of it. Trace fields differ by format (`@tr`/`@sp` in compact JSON, `{TraceId}`/`{SpanId}` in the Dev template), and compact JSON serializes **every** property, so anything pushed via `BeginScope`/`LogContext` reaches the collector. → [`404-production-logs.md`](docs/decisions/404-production-logs.md) ## Secrets — never commit From 77e054dd2d7c9c2bd509ffe88a0f14ae58dc5a27 Mon Sep 17 00:00:00 2001 From: mforce Date: Wed, 16 Sep 2026 17:49:16 +0000 Subject: [PATCH 6/6] chore(verify): capture.sh fails when Playwright fails or a frame is missing, and rejects . and .. slugs Codex CLI review of #889: the trailing || true swallowed every Playwright failure and the following ls returned 0, so the helper could report success with no evidence; and the slug alphabet admitted . and .., so /tmp/.. resolved to /. The exit status now propagates, all four frames must exist, and a slug must start alphanumeric. --- tools/verify/capture.sh | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tools/verify/capture.sh b/tools/verify/capture.sh index ce177835a..1185d80f7 100755 --- a/tools/verify/capture.sh +++ b/tools/verify/capture.sh @@ -14,7 +14,7 @@ prefix="${4:-after}" # The four arguments are interpolated into a generated spec and a /tmp path, so # each is held to a literal alphabet: no quotes, no braces, no path separators # in the names, and the route is an absolute app path with a query at most. -name_re='^[A-Za-z0-9._-]+$' +name_re='^[A-Za-z0-9][A-Za-z0-9._-]*$' # must start alphanumeric: rules out . and .. route_re='^/[A-Za-z0-9._/?=&-]*$' [[ "$slug" =~ $name_re ]] || { echo "slug must match $name_re" >&2; exit 2; } [[ "$prefix" =~ $name_re ]] || { echo "prefix must match $name_re" >&2; exit 2; } @@ -69,5 +69,12 @@ test.describe("verify capture $slug", () => { }); EOF -(cd "$ui" && npx playwright test --config playwright.screenshots.config.ts --grep "verify-capture" --project=chromium 2>&1 | grep -E "passed|failed|console errors" || true) +set +e +(cd "$ui" && npx playwright test --config playwright.screenshots.config.ts --grep "verify-capture" --project=chromium 2>&1 | grep -E "passed|failed|console errors|Error") +status=${PIPESTATUS[0]} +set -e +for f in "$out/$prefix-1280-light.png" "$out/$prefix-1280-dark.png" "$out/$prefix-390-light.png" "$out/$prefix-390-dark.png"; do + [ -s "$f" ] || { echo "missing evidence: $f" >&2; exit 1; } +done +[ "$status" -eq 0 ] || { echo "playwright exited $status" >&2; exit "$status"; } ls -1 "$out"