Platform
verify.yml Starter Kits
.planwright/verify.yml tells Planwright how to run the tests an objective needs and where the reports land. These kits are working files for the three most common stacks, each with a minimal example repo, the marker convention for its test runner, coverage, and a CI workflow.
Copy the kit for your stack and change the commands to fit your repo. Other languages use the same protocol: any runner that writes JUnit XML works.
How the protocol works
- When an agent claims an objective, each acceptance criterion gets a marker such as [AC:3f2a91c0:ac_3]: the objective's id prefix and the criterion's id. The claim response lists them.
- The agent writes checked-in tests whose names carry those markers. Tests that guard an invariant carry [INV:<key>].
- Planwright fills the markers a run needs into verify.yml's run command and hands the agent the exact commands.
- The agent runs them on a clean checkout of its commit and submits the raw reports. Planwright checks that every selected marker is there.
- Your CI runs the full suite and uploads the same reports. Planwright reads them from the workflow's artifacts and matches them to the agent's commit, as independent evidence.
Agents run only the tests their change needs. Whether and where the full suite runs is your choice, in your CI.
The keys
- version
- Optional. Always 1.
- setup
- Optional. Runs once before the tests, e.g. installing dependencies.
- run
- Required. Runs the selected tests. Must contain {grep}, a regular expression matching the markers, or {markers}, a space-separated list of quoted markers.
- files
- Optional. Runs whole test files, given as {files}: the test files the test-impact index selects because their coverage includes a changed file. Writes its own JUnit report.
- reports
- Required. Where the commands write their reports, each with a format: junit, playwright, lcov, cobertura or jacoco. At least one must be junit or playwright.
- coverage
- Optional. Measures one test file, given as {file}, and writes a coverage report listed under reports. For the test-impact index.
- coverage_noise
- Optional, default 0.5. Source files covered by more than this share of measured test files are left out of the index as noise.
- full
- Optional. Runs the whole suite and writes a report. Only used to generate the CI workflow; never handed to an agent.
Commands run in a shell from the repository root. A commit that changes the file is approved when it lands on your default branch; an agent's change is approved when you accept its objective.
Markers
A test proves a criterion when its name carries [AC:<objective prefix>:<criterion id>], and guards an invariant when it carries [INV:<key>]. Use the exact marker from the claim response: every objective has an ac_1, so the prefix is what ties a result to one objective. Where the marker goes depends on the runner, since some don't allow brackets in a test function's name; each kit below shows how. A test without a marker still runs with the full suite, but doesn't count as evidence.
Report formats
JUnit XML
Test results from any runner: vitest, pytest --junitxml, gotestsum, Maven Surefire, dotnet test, rspec. The testcase name is the test's title, so the marker must be in it.
Playwright JSON
Test results from Playwright's json reporter. The test title carries the marker.
lcov
Coverage: which source files a test file ran. The coverage command's report builds the test-impact index; the run's own lcov is recorded with its evidence.
Cobertura XML
Coverage from coverage.py (coverage xml), coverlet for .NET, and other tools. Read for the test-impact index like lcov.
JaCoCo XML
Coverage from JaCoCo on the JVM. Files are named by package path (com/acme/Foo.java) and matched to your repo's source paths by suffix.
What falls back to hand-written scopes. An invariant's scope, the files whose changes require its tests, is the set of path globs a person writes on the Verification page's Invariants tab. The coverage command feeds a test-impact index that will work out which tests a change can affect, tagged or not. That index isn't available yet, so today every scope is hand-written, and a run is checked against the invariants whose globs its diff touches.
JavaScript and TypeScript
Starter kitvitest for unit tests, Playwright for end-to-end tests, pnpm
Layout
.planwright/verify.yml e2e/notes.spec.ts src/notes.test.ts src/notes.ts package.json playwright.config.ts vitest.config.ts
.planwright/verify.yml
The test commands agents are handed. Commit it; it applies once it lands on your default branch.
version: 1
setup: pnpm install --frozen-lockfile && pnpm exec playwright install chromium
run: >-
status=0;
pnpm vitest run -t "{grep}" --passWithNoTests
--reporter=junit --outputFile=reports/junit.xml
--coverage --coverage.reporter=lcov --coverage.reportsDirectory=coverage
--coverage.reportOnFailure
|| status=1;
PLAYWRIGHT_JSON_OUTPUT_NAME=reports/playwright.json
pnpm playwright test --grep "{grep}" --pass-with-no-tests --reporter=json
|| status=1;
exit $status
files: pnpm vitest run {files} --passWithNoTests --reporter=junit --outputFile=reports/files.xml
reports:
- path: reports/junit.xml
format: junit
- path: reports/playwright.json
format: playwright
- path: reports/files.xml
format: junit
- path: coverage/lcov.info
format: lcov
coverage: pnpm vitest run {file} --coverage --coverage.reporter=lcov --coverage.reportsDirectory=coverage
full: >-
status=0;
pnpm vitest run --reporter=junit --outputFile=reports/junit.xml || status=1;
PLAYWRIGHT_JSON_OUTPUT_NAME=reports/playwright.json
pnpm playwright test --reporter=json || status=1;
exit $status
src/notes.test.ts
Unit tests. The marker ends the title; the last test has none, so it runs only with the full suite.
import { describe, expect, it } from "vitest";
import { addNote } from "./notes";
describe("addNote", () => {
it("refuses a blank note [AC:3f2a91c0:ac_3]", () => {
expect(() => addNote([], " ")).toThrow("blank");
});
it("never stores surrounding whitespace [INV:notes-trimmed]", () => {
expect(addNote([], " buy milk ")).toEqual(["buy milk"]);
});
it("keeps earlier notes", () => {
expect(addNote(["a"], "b")).toEqual(["a", "b"]);
});
});
e2e/notes.spec.ts
An end-to-end test with its own marker. A real one would open your app with page.goto.
import { expect, test } from "@playwright/test";
test("shows a saved note [AC:3f2a91c0:ac_1]", async ({ page }) => {
await page.setContent("<ul><li>buy milk</li></ul>");
await expect(page.getByRole("listitem")).toHaveText("buy milk");
});
The rest of the example repo (src/notes.ts, package.json, vitest.config.ts, playwright.config.ts)
src/notes.ts
export function addNote(notes: string[], text: string): string[] {
const note = text.trim();
if (!note) throw new Error("A note can't be blank.");
return [...notes, note];
}
package.json
{
"name": "notes",
"private": true,
"type": "module",
"packageManager": "pnpm@9.12.0",
"scripts": {
"test": "vitest run"
},
"devDependencies": {
"@playwright/test": "^1.48.0",
"@vitest/coverage-v8": "^2.1.0",
"vitest": "^2.1.0"
}
}
vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: { include: ["src/**/*.test.ts"], coverage: { include: ["src/**"] } },
});
playwright.config.ts
import { defineConfig } from "@playwright/test";
export default defineConfig({ testDir: "e2e" });
Markers
Put the marker at the end of the test title: it("refuses a blank note [AC:3f2a91c0:ac_3]"). vitest selects by title with -t and Playwright with --grep; both take the regular expression Planwright puts in {grep}.
Run one test
What an agent runs when its selection is one criterion, then one invariant. Planwright fills in the markers; you never type these yourself.
[AC:3f2a91c0:ac_3]
status=0; pnpm vitest run -t "\[AC:3f2a91c0:ac_3\]" --passWithNoTests --reporter=junit --outputFile=reports/junit.xml --coverage --coverage.reporter=lcov --coverage.reportsDirectory=coverage --coverage.reportOnFailure || status=1; PLAYWRIGHT_JSON_OUTPUT_NAME=reports/playwright.json pnpm playwright test --grep "\[AC:3f2a91c0:ac_3\]" --pass-with-no-tests --reporter=json || status=1; exit $status
[INV:notes-trimmed]
status=0; pnpm vitest run -t "\[INV:notes-trimmed\]" --passWithNoTests --reporter=junit --outputFile=reports/junit.xml --coverage --coverage.reporter=lcov --coverage.reportsDirectory=coverage --coverage.reportOnFailure || status=1; PLAYWRIGHT_JSON_OUTPUT_NAME=reports/playwright.json pnpm playwright test --grep "\[INV:notes-trimmed\]" --pass-with-no-tests --reporter=json || status=1; exit $status
Coverage
The run command adds --coverage --coverage.reporter=lcov, so each verification run writes coverage/lcov.info for the selected tests (needs @vitest/coverage-v8). The coverage command measures one test file at a time, for the test-impact index, and files runs the whole test files that index selects for a change.
CI workflow
Commit this as .github/workflows/planwright-tests.yml, with your default branch in place of main. It runs the full suite on every push and pull request and uploads the test reports as an artifact, even when tests fail. Planwright reads that artifact with the GitHub App's Actions: read permission and credits each result to the criterion or invariant its marker names. Skip it if your CI already runs the suite and uploads these reports. A project with no CI configuration is offered this same file on its Verification page.
# Generated by Planwright from .planwright/verify.yml. Review it, then commit it yourself;
# Planwright never commits to your repository. Planwright reads the uploaded test reports
# as independent evidence for the objectives whose commits this runs on.
name: Tests
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 # pnpm version from packageManager in package.json
- uses: actions/setup-node@v4
with:
node-version: lts/* # no .nvmrc, .node-version or engines.node found; pin your version
cache: pnpm
- name: Set up
run: |
pnpm install --frozen-lockfile && pnpm exec playwright install chromium
- name: Run the full test suite
run: |
status=0; pnpm vitest run --reporter=junit --outputFile=reports/junit.xml || status=1; PLAYWRIGHT_JSON_OUTPUT_NAME=reports/playwright.json pnpm playwright test --reporter=json || status=1; exit $status
- name: Upload test reports
if: always()
uses: actions/upload-artifact@v4
with:
name: test-reports
path: |
reports/junit.xml
reports/playwright.json
reports/files.xml
if-no-files-found: warn
Worth knowing
- Playwright doesn't write lcov, so coverage comes from vitest only.
- setup installs Chromium without --with-deps, which needs root: agents run it on their own machines. If CI reports missing system libraries for the browser, add --with-deps to that step in the workflow.
- The run command keeps going when the unit tests fail, so the Playwright report and lcov are still written (coverage.reportOnFailure), and exits non-zero if either failed.
Python
Starter kitpytest, with pytest-cov (coverage.py) for coverage
Layout
.planwright/verify.yml src/notes/__init__.py tests/test_notes.py .python-version conftest.py pyproject.toml requirements-dev.txt
.planwright/verify.yml
The test commands agents are handed. Commit it; it applies once it lands on your default branch.
version: 1
setup: python -m pip install -r requirements-dev.txt
run: >-
python -m pytest --planwright {markers}
--junitxml=reports/junit.xml
--cov --cov-report=lcov:coverage/lcov.info
files: python -m pytest {files} --junitxml=reports/files.xml
reports:
- path: reports/junit.xml
format: junit
- path: reports/files.xml
format: junit
- path: coverage/lcov.info
format: lcov
coverage: python -m pytest {file} --cov --cov-report=lcov:coverage/lcov.info
full: python -m pytest --junitxml=reports/junit.xml
tests/test_notes.py
The marker is a parametrize id, so it lands in the test's name. The last test has none, so it runs only with the full suite.
import pytest
from notes import add_note
@pytest.mark.parametrize("marker", ["AC:3f2a91c0:ac_3"])
def test_refuses_a_blank_note(marker):
with pytest.raises(ValueError, match="blank"):
add_note([], " ")
@pytest.mark.parametrize("marker", ["INV:notes-trimmed"])
def test_never_stores_surrounding_whitespace(marker):
assert add_note([], " buy milk ") == ["buy milk"]
def test_keeps_earlier_notes():
assert add_note(["a"], "b") == ["a", "b"]
conftest.py
Adds --planwright: run only the tests whose names carry one of the given markers. Public pytest hooks only.
def pytest_addoption(parser):
parser.addoption(
"--planwright",
nargs="*",
metavar="MARKER",
help="Run only tests whose names contain one of these [AC:...] / [INV:...] markers.",
)
def pytest_collection_modifyitems(config, items):
wanted = config.getoption("--planwright")
if wanted is None:
return
selected, deselected = [], []
for item in items:
(selected if any(m in item.name for m in wanted) else deselected).append(item)
if deselected:
config.hook.pytest_deselected(items=deselected)
items[:] = selected
The rest of the example repo (src/notes/__init__.py, pyproject.toml, requirements-dev.txt, .python-version)
src/notes/__init__.py
def add_note(notes: list[str], text: str) -> list[str]:
note = text.strip()
if not note:
raise ValueError("A note can't be blank.")
return [*notes, note]
pyproject.toml
[project] name = "notes" version = "0.1.0" requires-python = ">=3.10" [tool.pytest.ini_options] pythonpath = ["src"] testpaths = ["tests"] [tool.coverage.run] source = ["src"]
requirements-dev.txt
pytest>=8 pytest-cov>=5
.python-version
3.12
Markers
A pytest test's name can't contain brackets, but a parametrize id can: @pytest.mark.parametrize("marker", ["AC:3f2a91c0:ac_3"]) makes the name test_refuses_a_blank_note[AC:3f2a91c0:ac_3], and that name is what the JUnit report carries. The kit's conftest.py adds a --planwright option that keeps only tests whose names contain one of the markers Planwright puts in {markers}.
Run one test
What an agent runs when its selection is one criterion, then one invariant. Planwright fills in the markers; you never type these yourself.
[AC:3f2a91c0:ac_3]
python -m pytest --planwright '[AC:3f2a91c0:ac_3]' --junitxml=reports/junit.xml --cov --cov-report=lcov:coverage/lcov.info
[INV:notes-trimmed]
python -m pytest --planwright '[INV:notes-trimmed]' --junitxml=reports/junit.xml --cov --cov-report=lcov:coverage/lcov.info
Coverage
The run command adds --cov --cov-report=lcov:coverage/lcov.info, so each verification run writes lcov for the selected tests (needs pytest-cov 4 or later, on coverage.py 6.3 or later). The coverage command measures one test file at a time, for the test-impact index, and files runs the whole test files that index selects for a change.
CI workflow
Commit this as .github/workflows/planwright-tests.yml, with your default branch in place of main. It runs the full suite on every push and pull request and uploads the test reports as an artifact, even when tests fail. Planwright reads that artifact with the GitHub App's Actions: read permission and credits each result to the criterion or invariant its marker names. Skip it if your CI already runs the suite and uploads these reports. A project with no CI configuration is offered this same file on its Verification page.
# Generated by Planwright from .planwright/verify.yml. Review it, then commit it yourself;
# Planwright never commits to your repository. Planwright reads the uploaded test reports
# as independent evidence for the objectives whose commits this runs on.
name: Tests
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version-file: .python-version
- name: Set up
run: |
python -m pip install -r requirements-dev.txt
- name: Run the full test suite
run: |
python -m pytest --junitxml=reports/junit.xml
- name: Upload test reports
if: always()
uses: actions/upload-artifact@v4
with:
name: test-reports
path: |
reports/junit.xml
reports/files.xml
if-no-files-found: warn
Worth knowing
- pytest exits with code 5 when no test matches. That happens when a run's selection is empty, before the agent has written its tests.
- If you use uv, put uv run in front of each command and set setup to uv sync.
Go
Starter kitgo test through gotestsum for JUnit XML, -coverprofile converted to lcov with gcov2lcov
Layout
.planwright/verify.yml notes/notes_test.go notes/notes.go go.mod
.planwright/verify.yml
The test commands agents are handed. Commit it; it applies once it lands on your default branch.
version: 1
setup: go install gotest.tools/gotestsum@v1.12.0
run: >-
status=0;
gotestsum --junitfile reports/junit.xml --
-run '/({grep})' -coverprofile=coverage.out ./...
|| status=1;
mkdir -p coverage &&
go run github.com/jandelgado/gcov2lcov@v1.1.1 -infile=coverage.out -outfile=coverage/absolute.info &&
sed "s|^SF:$PWD/|SF:|" coverage/absolute.info > coverage/lcov.info;
exit $status
files: >-
gotestsum --junitfile reports/files.xml --
$(for f in {files}; do echo "./$(dirname "$f")"; done | sort -u)
reports:
- path: reports/junit.xml
format: junit
- path: reports/files.xml
format: junit
- path: coverage/lcov.info
format: lcov
coverage: >-
go test -coverprofile=coverage.out ./$(dirname {file}) &&
mkdir -p coverage &&
go run github.com/jandelgado/gcov2lcov@v1.1.1 -infile=coverage.out -outfile=coverage/absolute.info &&
sed "s|^SF:$PWD/|SF:|" coverage/absolute.info > coverage/lcov.info
full: gotestsum --junitfile reports/junit.xml -- ./...
notes/notes_test.go
Each marked check is a subtest whose name ends with the marker. The last has none, so it runs only with the full suite.
package notes
import (
"reflect"
"testing"
)
func TestAddNote(t *testing.T) {
t.Run("refuses a blank note [AC:3f2a91c0:ac_3]", func(t *testing.T) {
if _, err := AddNote(nil, " "); err == nil {
t.Fatal("expected an error for a blank note")
}
})
t.Run("never stores surrounding whitespace [INV:notes-trimmed]", func(t *testing.T) {
got, _ := AddNote(nil, " buy milk ")
if !reflect.DeepEqual(got, []string{"buy milk"}) {
t.Fatalf("got %q", got)
}
})
t.Run("keeps earlier notes", func(t *testing.T) {
got, _ := AddNote([]string{"a"}, "b")
if !reflect.DeepEqual(got, []string{"a", "b"}) {
t.Fatalf("got %q", got)
}
})
}
The rest of the example repo (notes/notes.go, go.mod)
notes/notes.go
package notes
import (
"errors"
"strings"
)
// AddNote returns notes with text appended, trimmed. A blank note is an error.
func AddNote(notes []string, text string) ([]string, error) {
note := strings.TrimSpace(text)
if note == "" {
return nil, errors.New("a note can't be blank")
}
return append(append([]string{}, notes...), note), nil
}
go.mod
module example.com/notes go 1.23
Markers
A Go test function's name can't contain brackets, so put the marker in a subtest name: t.Run("refuses a blank note [AC:3f2a91c0:ac_3]", …). Go reports it as TestAddNote/refuses_a_blank_note_[AC:3f2a91c0:ac_3], marker intact. -run '/({grep})' selects subtests by the regular expression Planwright puts in {grep}.
Run one test
What an agent runs when its selection is one criterion, then one invariant. Planwright fills in the markers; you never type these yourself.
[AC:3f2a91c0:ac_3]
status=0; gotestsum --junitfile reports/junit.xml -- -run '/(\[AC:3f2a91c0:ac_3\])' -coverprofile=coverage.out ./... || status=1; mkdir -p coverage && go run github.com/jandelgado/gcov2lcov@v1.1.1 -infile=coverage.out -outfile=coverage/absolute.info && sed "s|^SF:$PWD/|SF:|" coverage/absolute.info > coverage/lcov.info; exit $status
[INV:notes-trimmed]
status=0; gotestsum --junitfile reports/junit.xml -- -run '/(\[INV:notes-trimmed\])' -coverprofile=coverage.out ./... || status=1; mkdir -p coverage && go run github.com/jandelgado/gcov2lcov@v1.1.1 -infile=coverage.out -outfile=coverage/absolute.info && sed "s|^SF:$PWD/|SF:|" coverage/absolute.info > coverage/lcov.info; exit $status
Coverage
The run command writes a Go cover profile, converts it with gcov2lcov and makes its file paths relative to the repository root (gcov2lcov writes absolute ones), so each verification run writes coverage/lcov.info for the selected tests. The coverage command measures the test file's package, for the test-impact index, and files runs the packages of the test files that index selects for a change.
CI workflow
Commit this as .github/workflows/planwright-tests.yml, with your default branch in place of main. It runs the full suite on every push and pull request and uploads the test reports as an artifact, even when tests fail. Planwright reads that artifact with the GitHub App's Actions: read permission and credits each result to the criterion or invariant its marker names. Skip it if your CI already runs the suite and uploads these reports. A project with no CI configuration is offered this same file on its Verification page.
# Generated by Planwright from .planwright/verify.yml. Review it, then commit it yourself;
# Planwright never commits to your repository. Planwright reads the uploaded test reports
# as independent evidence for the objectives whose commits this runs on.
name: Tests
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Set up
run: |
go install gotest.tools/gotestsum@v1.12.0
- name: Run the full test suite
run: |
gotestsum --junitfile reports/junit.xml -- ./...
- name: Upload test reports
if: always()
uses: actions/upload-artifact@v4
with:
name: test-reports
path: |
reports/junit.xml
reports/files.xml
if-no-files-found: warn
Worth knowing
- Go matches each level of a test's name separately, so -run '/…' also runs every top-level test that has no subtests. Planwright only counts the marked ones; the others cost time. Keep marked checks in subtests, and in a large module narrow ./... to the packages you change.
- The run command keeps converting coverage when tests fail, and exits non-zero if they did.