Skip to content
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
f42b58c
docs: add a BetterMan verification skill and feature map
cursoragent Oct 12, 2026
57a6a90
fix: make the verification skill launch and drive a live app
cursoragent Oct 12, 2026
ea6d9ff
Merge branch 'main' into cursor/verify-betterman-skill-701a
amanthanvi Oct 15, 2026
4f90782
fix: harden the verification skill against false ready and leftover s…
cursoragent Oct 15, 2026
9ffef36
fix: forward Playwright filters from verify:drive
cursoragent Oct 15, 2026
2bfdba4
fix: keep verification seed on a local anonymous Convex deployment
cursoragent Oct 15, 2026
66077da
fix: keep Playwright specs off the ESM verify runtime
cursoragent Oct 15, 2026
d8091a8
fix: retry the distro select until the URL updates
cursoragent Oct 15, 2026
2477ebb
fix: require a positive info pageCount before verify is ready
cursoragent Oct 15, 2026
9a6d3f1
fix: match verify reuse to the recorded listener
cursoragent Oct 15, 2026
06b3dc4
fix: keep verify evidence, state, and seed on one checkout
cursoragent Oct 15, 2026
3bce7a4
fix: give the manual smoke workflow a valid verify id
cursoragent Oct 15, 2026
d8934dd
fix: refuse to seed a Next server this checkout does not own
cursoragent Oct 15, 2026
18221d0
fix: snapshot generated files only after verify owns Next
cursoragent Oct 15, 2026
0a25625
fix: keep verify state files private and type the path helper
cursoragent Oct 15, 2026
9b5776c
fix: match verify repo paths on a separator boundary
cursoragent Oct 15, 2026
1f6c0f9
fix: keep convex env set off the parent dataset stage
cursoragent Oct 15, 2026
b4f8cdc
fix: match verify listeners by repo path prefix
cursoragent Oct 15, 2026
176da33
fix: keep the skill typecheck project off Node's spec loader
cursoragent Oct 15, 2026
548e2ff
fix: treat ipv6 loopback as a verify listener target
cursoragent Oct 15, 2026
347bf14
fix: refuse an ipv6 loopback verify launch
cursoragent Oct 15, 2026
f72bdb8
fix: keep a live session snapshot on a refused launch
cursoragent Oct 15, 2026
fc022b0
fix: drop a stale verify snapshot when Next is not running
cursoragent Oct 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 209 additions & 0 deletions .agents/skills/verify-betterman/SKILL.md

Large diffs are not rendered by default.

33 changes: 33 additions & 0 deletions .agents/skills/verify-betterman/playwright.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { createRequire } from 'node:module'
import path from 'node:path'
import { fileURLToPath } from 'node:url'

import { evidenceDir } from './scripts/runtime.mjs'

const here = path.dirname(fileURLToPath(import.meta.url))
const require = createRequire(path.join(here, '../../../nextjs/package.json'))
const { defineConfig, devices } = require('@playwright/test')

export default defineConfig({
testDir: './scripts',
testMatch: '*.spec.ts',
timeout: 60_000,
expect: { timeout: 15_000 },
retries: 0,
fullyParallel: false,
workers: 1,
reporter: [['list'], ['html', { open: 'never', outputFolder: path.join(evidenceDir, 'playwright-report') }]],
outputDir: path.join(evidenceDir, 'test-results'),
use: {
baseURL: process.env.E2E_BASE_URL ?? 'http://127.0.0.1:3000',
trace: 'on',
screenshot: 'only-on-failure',
video: 'off',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
})
68 changes: 68 additions & 0 deletions .agents/skills/verify-betterman/references/features/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# BetterMan feature map

Behavior-level inventory of the BetterMan web app. Agents use it to decide what to drive. The order below is the regression sweep.

## Baseline preconditions

- Doctor reports `READY` on `http://127.0.0.1:3000` before the first drive, and again after a failed drive.
- Local dataset is the E2E seed unless doctor found an existing `tar(1)`. Seeded pages: `tar(1)`, `gzip(1)`, `gunzip(1)` alias, `printf(1)`, `printf(3)`, `openssl(1ssl)`, `bash(1)`. Distros with data: Debian (default), Ubuntu, Fedora.
- The seed's license package list is empty.
- `pnpm next:dev` does not register the service worker. Say `INCONCLUSIVE` for offline cache hits on that server.
- Fresh Playwright context. Bookmarks, theme, and recent history start empty in that context.
- Prefer roles, accessible names, and `data-bm-*` hooks. Wait until the heading, mark, URL, or attribute you care about is visible.
- Blur text fields and `<select>` before single-key shortcuts. Wait until the `Cycle theme` button is visible first.
- Desktop viewport unless the file says 390x844.

## Proof and skip reporting

Drive the user path listed in the feature file: the success path, and the empty, error, and hostile cases the change can affect. Record the action and the end state. When a path cannot be reached, name it and the prerequisite, and cover the closest path that still runs.

## Full sweep

Walk this list top to bottom, then `journeys.md`.

## Chrome

- [site-chrome](site-chrome.md): Header, footer, skip link, wordmark, offline banner.

## Find a page

- [home](home.md): Home search, browse sections, empty recent and bookmarks.
- [search](search.md): Search results, try links, typos, section filter, load more.
- [section-browse](section-browse.md): Section index, letter jump, in-section search, pagination.
- [command-palette](command-palette.md): Palette search, `>` actions, `#` headings, `@distro`.
- [keyboard-shortcuts](keyboard-shortcuts.md): `?` dialog and the global chords.

## Read a page

- [man-page](man-page.md): Page body, options table, see also, related, alias, ambiguous name, copy.
- [find-in-page](find-in-page.md): Find bar, match count, next and previous.
- [table-of-contents](table-of-contents.md): Desktop sidebar and mobile contents drawer.
- [reading-preferences](reading-preferences.md): Font, measure, line height, code theme, default distro.
- [distro-variants](distro-variants.md): Per-page distro select and `?distro=`.

## Local reader state

- [theme](theme.md): System, light, and dark cycle.
- [bookmarks](bookmarks.md): Bookmark toggle and the home list.
- [recent](recent.md): Recent pages and `/history`.

## Other pages

- [licenses](licenses.md): Dataset attribution and package filter.
- [not-found-and-errors](not-found-and-errors.md): Unknown routes, missing names, render error.
- [offline-and-install](offline-and-install.md): Offline banner, web manifest, service worker.
- [discovery-and-share](discovery-and-share.md): robots, sitemaps, JSON-LD, share metadata.

## Journeys

- [journeys](journeys.md): Search, open, bookmark, and come back.

## Entry contract

Every feature file uses the same four H2s:

1. `Sub-features`
2. `How to get to it (user POV)`
3. `Driving it with Playwright`
4. `Gotchas`
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Bookmarks

Local bookmarks. No account. Storage key `bm-bookmarks`.

## Sub-features

- toggle: on a man page, button `Bookmark name(section)` or `Remove bookmark for name(section)`, `aria-pressed`
- shortcut: `m` toggles the current page
- list: home region `Bookmarks`, and `/bookmarks` which redirects to `/#bookmarks`
- remove: button `Remove bookmark for name(section)` on the home row
- palette: bookmarked rows show a star, and an empty palette lists bookmarks

## How to get to it (user POV)

Open a page and click the star, or press `m`. Go home and look under BOOKMARKS. The empty copy is `No bookmarks yet` and mentions the `M` key.

## Driving it with Playwright

```ts
await page.goto('/man/tar/1')
await expect(page.getByRole('button', { name: 'Cycle theme' })).toBeVisible()
await page.getByRole('button', { name: 'Bookmark tar(1)' }).click()
await expect(page.getByRole('button', { name: 'Remove bookmark for tar(1)' })).toHaveAttribute('aria-pressed', 'true')
await page.goto('/bookmarks')
await expect(page).toHaveURL(/\/#bookmarks$/)
await page.getByRole('region', { name: 'Bookmarks' }).getByRole('button', { name: 'Remove bookmark for tar(1)' }).click()
await expect(page.getByText('No bookmarks yet')).toBeVisible()
```

`m` toggles the same button. Press it twice and expect the name to return to `Bookmark tar(1)`.

Side effect: `localStorage.getItem('bm-bookmarks')` is JSON `{ version: 1, items: [...] }` while a bookmark exists, and the items list is empty after remove.

## Gotchas

- The home remove button and the man-page star share the accessible name `Remove bookmark for tar(1)` when the bookmark is on. Scope the home one to the `Bookmarks` region.
- Empty list is the fresh context. Do not seed `localStorage` as the proof. Click the star, navigate home, see the row, then remove it.
- Error: a thrown toggle would leave `aria-pressed` unchanged. Read the attribute after the click.
- Hostile: set `bm-bookmarks` to `{not json` and reload `/`. The list renders empty instead of crashing. A non-object item inside a versioned store is dropped.
- Cap is 200 items. Adding past that drops the oldest. The sweep does not need 200 real clicks.
- `m` is active only on a man page, and only when focus is outside a text field.
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Command palette

Modal search and command box. Dialog label `Command palette`. Input role `combobox`, name `Command palette input`.

## Sub-features

- page-search: typing a name lists options `name(section)` and navigates on click or Enter
- actions: a leading `>` filters actions (`Toggle theme`, `Go home`, `Go to section N`, `Clear recent history`)
- headings: a leading `#` lists table-of-contents titles on a man page
- distro-prefix: `@ubuntu tar` searches Ubuntu inside the palette
- recents: an empty input lists recent pages, recent searches, and bookmarks
- empty: `No matches.` when the query returns nothing

## How to get to it (user POV)

Click header `Search`, or press Ctrl+K or Command+K. On a man page, `/` opens the palette because there is no page search box. `Escape` or the scrim closes it.

## Driving it with Playwright

Wait until `Cycle theme` is visible, then:

```ts
await page.getByRole('button', { name: 'Search' }).click()
const input = page.getByRole('combobox', { name: 'Command palette input' })
await input.fill('tar')
await page.getByRole('option', { name: /tar\(1\)/i }).click()
await expect(page.getByRole('heading', { name: /tar\(1\)/i })).toBeVisible()
```

Actions: fill `>section 3`, click option `/Go to section 3/`, expect `/section/3`.

Headings: on `/man/tar/1`, fill `#examples`, click `/EXAMPLES/`, expect `#examples`.

Hostile empty: fill `zzz-no-match`, expect `No matches.`, then `Escape` hides the combobox.

## Gotchas

- The mode chip `Actions` or `Headings` is visible text. Options hide the leading `>` and `#` from the accessible name (`aria-hidden`).
- Search is debounced about 120ms. Wait for the option, not a fixed sleep. While the request is in flight the list can be empty.
- Error: a failed `/api/v1/search` shows `Search failed.` inside the dialog. That is the palette error state. Doctor should be `READY` before treating it as a product bug.
- Empty input on a fresh context has zero rows. That is valid. Bookmark or open a page in the same context to see recents.
- `Clear recent history` does not close the dialog. Press `Escape` after it, then check the Recent region.
- `@macos` is not a distro id. The parser leaves the whole string as the query. Supported prefixes match `debian`, `ubuntu`, `fedora`, `arch`, `alpine`.
- Ctrl+K is handled even when a text field is focused. Other single-key shortcuts are not.
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Discovery and share

Crawler and share metadata. Readers do not navigate these as pages, but the HTML and the files are part of opening a man page from the outside.

## Sub-features

- robots: `/robots.txt` allows the site, disallows `/api/`, and points at a sitemap
- sitemaps: `/sitemap.xml` is a sitemap index. The Debian index links a urlset that contains `/man/tar/1` on the seed
- json-ld: a man page includes one `script[type="application/ld+json"]` with `@type` `TechArticle`
- share-image: the man route exposes an Open Graph image generated from the page title

## How to get to it (user POV)

A search engine fetches `robots.txt` and the sitemap. A chat app fetching a man URL reads the Open Graph tags and JSON-LD in the document.

## Driving it with Playwright

```ts
const robots = await page.goto('/robots.txt')
expect(robots?.status()).toBe(200)
await expect(page.locator('body')).toContainText('Sitemap:')
await expect(page.locator('body')).toContainText('Disallow: /api/')

await page.goto('/man/tar/1')
const jsonLd = page.locator('head script[type="application/ld+json"][nonce]')
await expect(jsonLd).toHaveCount(1)
expect((await jsonLd.textContent()) ?? '').toContain('"@type":"TechArticle"')
```

Sitemap spot check: `/sitemap.xml` contains `sitemapindex`. On the seed, `/sitemap-debian.xml` and `/sitemap-debian-1.xml` exist and the urlset mentions `/man/tar/1`.

## Gotchas

- Search results send `noindex`. A man page is indexable. Do not expect JSON-LD on `/search`.
- Empty dataset: sitemaps 404 or render empty when no release is active. Doctor `READY` is the precondition.
- Error: JSON-LD is omitted when the page is the not-found article. `/man/zzznnotapage` should not advertise a `TechArticle` for a missing name.
- Hostile: share image parameters that fail validation return 404 and do not render. The page HTML for a normal `/man/tar/1` is the user-visible proof. Skip image-binary diffs unless the change is the image route.
- Scope JSON-LD to `head script[type="application/ld+json"][nonce]`. Dev HTML repeats the same tag inside the flight payload, so an unscoped locator counts many scripts. Playwright `hasText` does not read `<script>` bodies. Use `textContent()` for `@type`.
- The e2e file `nextjs/e2e/seo.spec.ts` also requires a CSP nonce on the JSON-LD script. Dev and `next start` both go through middleware. If nonce is absent, report that separately from a missing `TechArticle`.
- `metadataBase` is `https://betterman.sh`. Local Open Graph URLs can still point at that host. Judge the path and the title, not the production origin, when the server is local.
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Distro variants

The same page name can differ by distribution. The seed publishes Debian, Ubuntu, and Fedora.

## Sub-features

- select: `Select distribution variant` on pages whose variants have different content
- query: `?distro=ubuntu` on man, search, section, and home URLs
- cookie: `bm-distro` and `localStorage['bm-distro']` follow the select
- single-variant: when every variant hashes the same, the select is replaced by a text label such as `Debian`
- palette: `@ubuntu tar` searches that distro

## How to get to it (user POV)

Open `tar(1)`. Under the title, the `distro` select lists Debian, Ubuntu, and Fedora. Choosing Ubuntu reloads the page with `?distro=ubuntu` and shows the Ubuntu sentence in the description area.

Reading preferences also has a default-distro radio group. That sets the distro used when the URL omits `distro`.

## Driving it with Playwright

```ts
await page.goto('/man/tar/1')
await expect(page.getByRole('button', { name: 'Cycle theme' })).toBeVisible()
const select = page.getByLabel('Select distribution variant')
await expect(select.locator('option')).toHaveText(['Debian', 'Ubuntu', 'Fedora'])
for (let attempt = 1; attempt <= 3; attempt += 1) {
await select.selectOption('ubuntu')
try {
await expect(page).toHaveURL(/distro=ubuntu/, { timeout: 1_500 })
break
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (!message.includes('toHaveURL') || attempt === 3) throw error
}
}
await expect(page).toHaveURL(/distro=ubuntu/)
await expect(page.getByText('Ubuntu variant: this page is intentionally different for E2E testing.')).toBeVisible()
```

Unknown query: `/search?q=tar&distro=not-a-distro` still shows `tar(1)` on the default distro. The page stays on the Search heading.

## Gotchas

- The select is a controlled React control. `selectOption` before the change listener is attached resets to Debian. Retry that URL miss at most three times. Any other error fails on the first attempt, and the Ubuntu sentence is not retried. After `selectOption`, letter shortcuts are swallowed until focus moves.
- Empty: Arch and Alpine are valid ids in the radio group and have no seeded release. Choosing them falls back to Debian when the release is missing. The URL may still say `arch` while the body is Debian. Read the footer dataset line or the page text before calling that a switch.
- Error: a distro the normalizer rejects is ignored. Do not expect a 400 page for `?distro=not-a-distro`.
- Hostile: `?distro=<script>` is ignored the same way. The search page must still render.
- Ubuntu is the only seeded page with an obvious body difference. Fedora `tar(1)` exists and does not contain the Ubuntu sentence.
- Ordering is Debian, Ubuntu, Fedora on the seed because those are the variants present, sorted by the distro order. Arch and Alpine appear only when their content hash differs.
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Find in page

Overlay find bar on a man page. Hook `[data-bm-findbar]`. Textbox name `Find in page`.

## Sub-features

- open-close: title-bar button `Find in page` opens it. `Escape` or `Close find` closes it and returns focus to the trigger
- matches: `mark[data-bm-find]` and a `1/N` counter
- navigation: `Next match`, `Previous match`, Enter, and Shift+Enter

## How to get to it (user POV)

On a man page, click the find icon in the title row. Type a word. The count shows the current match. Enter moves forward when the find field is focused.

## Driving it with Playwright

```ts
await page.goto('/man/tar/1')
await expect(page.getByRole('button', { name: 'Cycle theme' })).toBeVisible()
await page.getByRole('button', { name: 'Find in page' }).click()
const findbar = page.locator('[data-bm-findbar]')
await findbar.getByRole('textbox', { name: 'Find in page' }).fill('tar')
await expect(page.locator('mark[data-bm-find]').first()).toBeVisible()
await findbar.getByRole('button', { name: 'Next match' }).click()
```

The counter starts at `1/N` with N greater than 1 on `tar(1)`. Next moves to `2/N`.

Clear the query or type `zzzz-no-such-token`. Marks go to zero. Next and Previous stay disabled.

## Gotchas

- Two controls share the accessible name `Find in page`: the icon button and the textbox. Scope the textbox to `[data-bm-findbar]`.
- The find bar is absent until opened. A count of zero bars is the closed state.
- Empty query: the bar can be open with no marks and a disabled Next. That is not a failed search.
- Error: a console error during mark rendering is a product bug. The drive should fail if `pageerror` fires.
- Hostile: a regex metacharacter query such as `(` or `.*` is a literal search. The page should stay up, with marks only where that substring exists.
- Single-key shortcuts do not fire while the find textbox is focused. `Escape` closes the bar first. Then `b` or `?` work again.
- Focus is moved with `requestAnimationFrame`. If a click reports the textbox focused and the next key lands elsewhere, wait for the focused assertion before typing.
38 changes: 38 additions & 0 deletions .agents/skills/verify-betterman/references/features/home.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Home

The operate surface at `/`: synopsis search, empty or filled recent and bookmarks, and section browse.

## Sub-features

- search-form: searchbox `Search man pages` (`[data-bm-home-search]`) submits to `/search`
- recent: region `Recent`. Empty copy is `No recent pages`
- bookmarks: region `Bookmarks`. Empty copy is `No bookmarks yet`
- browse: heading `Browse`, links whose names start with the section number

## How to get to it (user POV)

Open `/`. Press `/` to focus the search box when focus is not already in a field. The wordmark and the `h` shortcut also land on `/` (`h` adds `#recent`).

`/history` redirects to `/#recent`. `/bookmarks` redirects to `/#bookmarks`.

## Driving it with Playwright

```ts
await page.goto('/')
await expect(page.getByRole('searchbox', { name: 'Search man pages' })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Browse' })).toBeVisible()
await expect(page.getByText('No recent pages')).toBeVisible()
await page.getByRole('link', { name: /^1\s/i }).click()
await expect(page).toHaveURL(/\/section\/1/)
```

Submit a query by filling the searchbox and pressing Enter. End state is `/search?q=...` and a result link.

## Gotchas

- The running head `BETTERMAN(1)` is `aria-hidden`. Assert the real searchbox and the `Browse` heading.
- Empty recent and bookmarks are the fresh-context state. Open a man page in the same context before expecting a recent row.
- Error: if Convex has no active release, `/` throws and the error boundary replaces this page. Doctor `empty-dataset` or `home-unhealthy` covers that. Do not treat the error heading as a successful home.
- Hostile: a query is sent as the `q` parameter. The box has no client-side length limit. The search page slices `q` to 120 characters.
- `/` with `?distro=ubuntu` requests Ubuntu and falls back to Debian when that release is missing (`withDistroFallback`).
- Section links are `1 User Commands` through `9`. Match `/^1\s/i` so the number and the label stay together.
Loading
Loading