DESIGN.md@crates/geas · git:20261031.76f9a81 · 2026-10-31 · sha256 b7b98f6dac61dc9b

DESIGN.md@crates/geas git:20261031.76f9a81B

Immutable. This exact content never changes and is served at /api/v1/blob/b7b98f6dac61dc9b.

# geas — design sheet (working name; naming open)

Status: spike, 2026-08-29. Everything in `README.md` marked as run output
was actually run on this machine (macOS/arm64, rustc 1.97.1, Python 3.14).
This file is the design; the README is the evidence.

2026-10-02: the design of v1 is added as §6–§16 — the diff half of the
gate, GUI targets, the matchers, parallel runs, quoting, pinning, the
diagnostics and the skill. §0–§5 stay as the spike wrote them; where v1
changes a v0 decision, the v1 section says so. `PLAN.md` is the order of
work. v1 builds with the stable toolchain (rustc 1.94.1, edition 2024)
and stays dependency-free. §16 lists what was measured to settle the
design, and how.

2026-10-03: stage B is built (the diagnostics, `geas map`, `geas
affected`, the four runtimes); §6, §7, §13 and §16 say what building it
decided.

2026-10-03: the first half of stage C is built: command strings (§11),
the matchers and the `header` subject (§9), pins (§12), and parallel runs
with process groups (§10, §10.1). Each section ends with what building it
decided; §7.2 says why the Node hook changed, and §16 what was measured.

2026-10-03: the second half of stage C is built: the screen and its checks
(§8.1, §8.6), the driver protocol (§8.5), pixie (§8.3) and Chrome (§8.4),
and E012, E013 and E034-E037 (§13). §6 and each section of §8 end with what
building them decided, and §16 says what was measured.

2026-10-03: stage D is built: the skill and `geas skill` (§14), two more
examples (`web-greeter`, a page in Chrome with every pin, and
`pixie-greeter`) and the greeter's claims named in Japanese, README.md
rewritten around v1, README.ja.md, and the tests that hold the pages to the
tool. §14 ends with what building it decided, §15 gathers what is still
out, and §16 says what was measured. v1 is now what §6–§14 describe.

2026-10-05: §17 is added: the scenarios of an OpenSpec spec held to the
claims of the same names (`geas scenarios`), and the claims drafted for
the scenarios no claim answers.

## 0. Thesis

Agent-written code has a verification gap: specs (Spec Kit, Kiro) drive
the *generation* half and are prose; tests written by the same agent that
wrote the code are circular. geas is a small language for the *acceptance*
half. A human audits a page of **claims** about observable behavior; agents
write the implementation in any language; a deterministic runner binds the
two and reports in both human and machine form.

The division of labor it sells: **you audit 50 lines of claims; the agent
writes 5,000 lines of code; the gate binds them.**

Design constraints that follow:

- The claim language must stay small enough to read aloud. Its unit is an
  observation, not a hook into implementation code (Gherkin died of step
  definitions; agents now do that binding work, so the language can stay
  black-box).
- The runner must be deterministic and leave evidence (a replayable
  journal), because its consumer is often another agent loop.
- Adoption must be additive: a claims file *beside* any codebase, in any
  language. No rewrite is ever requested.

## 1. Surface, v0

```
target calc {
  run "python3 calc.py"            # process adapter: base argv
}

target api {
  serve "python3 server.py 8123"   # service adapter: spawn + wait for port
  port 8123
}

claim "adds two integers" {
  when calc.run("2", "+", "3")
  then stdout is "5"
  and  exit is 0
}

claim "totals accumulate across requests" {
  when api.post("/reset")
  then status is 200
  when api.post("/add", body: "5")
  when api.post("/add", body: "7")
  when api.get("/total")
  then body is "12"
}
```

Grammar (v0):

```
file    := (target | claim)*
target  := "target" IDENT "{" prop* "}"
prop    := "run" STR | "serve" STR | "port" NUM
claim   := "claim" STR "{" step* "}"          ; first step must be `when`
step    := "when" IDENT "." method "(" args ")"
         | ("then" | "and") check
method  := "run" (STR,*)  | "get" (STR)  | "post" (STR ["," "body" ":" STR])
check   := subject matcher value
subject := "stdout" | "stderr" | "exit" | "status" | "body" | "body" "json" STR
matcher := "is" | "contains"
value   := STR | NUM
```

Comments `#` to end of line. A `then` binds to the most recent `when`'s
observation; `and` is the same word for the same observation.

(v1's surface is §6. Every v0 file stays valid, but for a command string
holding a quote or a backslash, §11.)

## 2. Semantics decided in v0

- **Claim isolation.** Each claim gets a fresh instance of every `serve`
  target it touches (spawned lazily, killed at claim end). Claims are
  therefore order-independent and individually replayable. Within one
  claim the instance persists, so stateful scenarios are expressible.
- **Observations.** A `run` call observes `{stdout, stderr, exit}`. A
  `get`/`post` observes `{status, body}`. `body json "<path>"` reads into
  a JSON body with `.field` / `[index]` steps.
- **Matching.** `is` on stdout/stderr/body ignores exactly one trailing
  newline; `is` on exit/status/json-numbers is numeric; `contains` is
  substring and only valid on string subjects.
- **Failure shapes.** A failed *check* marks the claim FAIL and evaluation
  continues (all divergences reported). A failed *when* (spawn error,
  timeout, port never opened) marks the claim ERROR and the rest of the
  claim is blocked.
- **Determinism scope (v0).** Fixed timeouts (5 s per step, 5 s
  ready-wait), one claim at a time, fresh service state per claim, target
  commands run with cwd = the spec file's directory. Wall-clock never
  enters a comparison.
- **Evidence.** Every interaction and verdict is appended to
  `.geas/journal.jsonl` next to the spec — the machine-readable trace an
  agent (or a human diffing two runs) consumes.
- **Exit codes.** 0 all claims hold · 1 some claim failed · 2 the spec
  itself is malformed. `--json` swaps the human report for a machine one.

## 3. Adapter contract

An adapter turns a `when` into an observation map. v0 ships two:

| adapter | spawn | when | observes |
|---|---|---|---|
| process (`run`) | per `when` | argv appended to base | stdout, stderr, exit |
| service (`serve` + `port`) | per claim | HTTP/1.1 over loopback, `Connection: close` | status, body |

The contract is deliberately dumb: no adapter exposes implementation
internals; everything is observed at a boundary a user could also observe.
Planned adapters keep that rule: GUI via the accessibility-tree dump
(donor: pixie's `PIXIE_SCRIPT`/a11y machinery), library via FFI harness.

(v1 builds the GUI adapters in §8: pixie, Chrome, and a driver protocol for
any other GUI. The FFI harness stays out, §15.)

## 3.5 Drift — the second layer (built later the same day)

`geas snap` records every interaction's **full** observation (status,
headers, body / stdout, stderr, exit) to `.geas/baseline.jsonl`;
`geas drift` replays the same interactions against the current
implementation and classifies each divergence:

- **claimed** — some check in that claim covers the diverged field
  (subject-level for status/exit/text, segment-wise path-level for
  JSON bodies), so `geas check` is the authority on it;
- **unclaimed** — observable behavior changed where no claim promises
  anything. This is the information a test runner structurally cannot
  produce, and the reason the journal records more than the checks
  assert.

Noise is handled by declaration, not heuristics: `mask header "date"` /
`mask body json ".request_id"` name volatility in the audited file and
apply at compare time (changing masks needs no re-snapshot);
`content-length` is auto-ignored as derived. JSON bodies diff
structurally, so a claim pinning `.message` does not silence a new
`.debug` sibling. Exit: 0 quiet · 1 drift · 2 baseline missing or a
replay error. Golden/snapshot testing fails toward noise (every change
is a failure); assertion testing fails toward silence (unasserted
change is invisible); the claims/journal split is the midpoint, priced
at one `mask` line per genuinely volatile field.

## 4. Non-goals in v0 (deliberate)

Static diff-to-claim binding ("this PR touches claims 3 and 7" read
from the code diff alone; the runtime half exists as `drift`). Matchers
beyond `is`/`contains`. Parallel claims. Quoting in command strings.
Env/clock/seed pinning knobs. Claim dependencies. Windows.

(v1 builds all of these but the last two: §7–§12. Why claim
dependencies and Windows stay out is in §15.)

## 5. Next, in order

1. **Static diff-to-claim binding** — complete the gate for agent PRs
   (drift covers the runtime half; this reads the diff).
2. **GUI adapter** over an accessibility dump, which makes desktop apps
   claimable with the same five subjects.
3. **Distribution through the agent channel**: ship the CLI with a skill
   file so an agent is fluent on first contact; the human only ever reads
   the claims. (The cute-skill workflow, generalized into a GTM.)
4. Naming, licensing, publication — owner calls.

v1 designs 1–3 (§7, §8, §14). Of 4, the owner chose the license on
2026-10-03: MIT OR Apache-2.0, as for rulec and dandori. Naming and
publication stay with the owner.

## 6. Surface, v1

Every v0 file stays valid, with one exception (§11: a quote or a
backslash in a command string now means something). The whole grammar
after this round:

```
file    := (target | claim | mask | pin)*
target  := "target" IDENT "{" tprop* "}"
tprop   := "run" STR | "serve" STR | "port" (NUM | "auto")
         | "pixie" STR | "driver" STR | "serial" | pin
pin     := "env" STR STR | "env" "clean" | "env" "pass" STR
         | "tz" STR | "locale" STR
         | "clock" STR ["env" STR] | "seed" NUM ["env" STR]
claim   := "claim" STR "{" step* "}"            ; first step must be `when`
step    := "when" IDENT "." call [wait] | ("then" | "and") check
wait    := "within" NUM ("second" | "seconds")  ; 1 to 60, on run, get, post (§18)
call    := "run" "(" [STR ("," STR)*] ")"
         | "get" "(" STR ")" | "post" "(" STR ["," "body" ":" STR] ")"
         | "open" "(" [STR] ")"
         | "click" "(" STR ["," "nth" ":" NUM] ")"
         | "input" "(" STR ["," place] ")"
         | "submit" "(" [place] ")"
         | "press" "(" STR ")" | "advance" "(" NUM ")"
place   := "field" ":" NUM | "into" ":" STR ["," "nth" ":" NUM]
check   := subject matcher
subject := "stdout" | "stderr" | "exit" | "status" | "header" STR
         | "body" | "body" "json" STR | "screen"
matcher := "is" ["not"] value
         | "is" ("above" | "below" | "at" "least" | "at" "most") NUM
         | "is" "between" NUM "and" NUM
         | "contains" thing | "does" "not" "contain" thing
         | "matches" STR | "does" "not" "match" STR
         | "exists" | "does" "not" "exist"
value   := STR | NUM | "true" | "false" | "null"
thing   := STR                                  ; on a text subject
         | node                                 ; on `screen`
node    := ["exactly" NUM] ROLE [label] ["with" "value" STR] [state]
           ["in" ROLE [label]]
label   := STR | "containing" STR | "matching" STR
state   := "disabled" | "enabled" | "checked" | "unchecked"
mask    := "mask" "header" STR | "mask" "body" "json" STR
         | "mask" "screen" node
```

ROLE is a role name of WAI-ARIA 1.2, or `text` (§8.1). A pin at the top
level applies to every target, and one inside a target overrides it there
(§12).

What each call observes, which decides the subjects its `then`s may name:

| call | on a target with | observes |
|---|---|---|
| `run` | `run` | stdout, stderr, exit |
| `get`, `post` | `serve` | status, header, body, body json |
| `open`, `click`, `input`, `submit`, `press`, `advance` | `pixie`, `driver`, or `serve` (in a browser) | screen |

Rules beyond the grammar, each new in v1:

- Target names and claim names are unique in a file (E003). v0 let a
  second target of the same name shadow the first, and drift keyed its
  baseline by claim name, so two claims of one name overwrote each
  other's records.
- A `then` naming a subject its `when` cannot observe is a spec error
  (E007). v0 found it at run time and reported a failed check.
- An HTTP answer sent with `Transfer-Encoding: chunked` is read as the body
  it carries. An HTTP/1.1 client has to read one, and Node's `http` sends one
  whenever a handler sets no `Content-Length`; the spike took the chunk sizes
  for part of the body.
- A spec's files under `.geas/` are named after it:
  `<stem>.journal.jsonl`, `<stem>.baseline.jsonl`, `<stem>.map.jsonl`.
  v0's `journal.jsonl` and `baseline.jsonl` collided when two specs
  shared a directory. A v0 `baseline.jsonl` is not read; when `drift`
  finds none under the new name, E050 points at the old one. `check`,
  `snap`, `drift` and `map` take several specs; they run one after
  another, and the exit status is the worst of theirs.

The language grows from five subjects and two matchers to eight subjects
and thirteen matcher forms, every one of which reads aloud as English;
what each is for is in §8–§12.

As built, for the GUI forms:

- `exactly` takes a whole number from 0; `nth:` and `field:` count from 1,
  and 0 is E013 once the target is known. A count or a place that is not a
  whole number is E001. `nth:` goes with `into:` only (E002 otherwise:
  without `into:`, `field: n` says the place), and `field:` with `into:` is
  E002. A mask's node takes no count (E001).
- A node pattern ends before the words that start what follows a check
  (`when`, `then`, `and`, `}`, and what the top level holds); a word there
  that is none of them is read as a state geas does not know (E002). After
  `contains`, a word that is not `true`, `false` or `null` starts a node, so
  `stdout contains text "5"` is E008 (a node on a text), not E001.
- A pin may sit in a `pixie` or a `driver` target as in any other. `clock`
  and `seed` without `env` are fine on a driver, which is handed every pin,
  and on a service some claim opens as a page, whose clock and random
  numbers geas keeps itself (§12); on a command, a pixie app, or a service
  no claim opens, they are E011 as before.

## 7. Diff to claims: `geas map` and `geas affected`

`drift` reads the run: what a person could observe changed where no
claim promises anything. The other half reads the diff: which claims this
change touches, and which changed code no claim runs. A diff names files
and lines and a claim names neither, so the two meet through a record of,
for each claim, the lines of the implementation its run executed.

```
geas map <spec.geas> [--root <dir>] [--out <file>]
geas affected <spec.geas> <diff | -> [--map <file>]... [--root <dir>]
```

`map` is `check` with each language runtime's coverage switched on: it
runs every claim, prints the same report, and writes the record to
`.geas/<stem>.map.jsonl`. `affected` reads a unified diff (`git diff`, or
`diff -u`) and the record, runs nothing, and says what the change
touches.

### 7.1 How this squares with black-box claims

Nothing in a claim changes. No claim names a file, a function or a line,
and the record is never written by hand.

- The record is measured by each language's own coverage machinery,
  switched on from outside: environment variables the runtime reads, and,
  for the compiled languages, the toolchain's coverage build. geas never
  edits, wraps, imports or rebuilds the implementation.
- The record never decides a verdict; `check` and `drift` do not read it.
  It decides where a reviewer looks: which claims to read again for this
  change, and which code to ask about. A wrong record misroutes a review;
  it cannot turn a claim green.
- The record is derived and disposable, like the journal. It may be
  cached or committed; nobody audits it.

### 7.2 Four languages

geas sets every switch below for every process it starts during `map`,
and each runtime picks up its own. A target does not say what it is
written in, and a Python service that runs a Go command is recorded in
both.

| language | geas sets | the target has to | read with |
|---|---|---|---|
| Python 3.12 and later | `PYTHONPATH` gains a directory under `.geas/` holding geas's `sitecustomize.py` | nothing | the JSON that file writes |
| Node: JavaScript, and TypeScript by Node's type stripping | `NODE_V8_COVERAGE`; `NODE_OPTIONS` gains `--require` of geas's hook | nothing | V8's coverage JSON |
| Go | `GOCOVERDIR` | be built with `go build -cover` (`-coverpkg=./...` to take in its own packages); as a service, return from `main` on SIGTERM | `go tool covdata textfmt` |
| Rust | `LLVM_PROFILE_FILE` | be built with `-C instrument-coverage` and be the program the command starts; as a service, exit normally on SIGTERM | `llvm-profdata merge`, `llvm-cov export -format=lcov` (rustup's `llvm-tools`) |

- **Python.** The `sitecustomize.py` geas writes uses only the standard
  library: `sys.monitoring` in its coverage slot, LINE and PY_START
  events, each callback returning DISABLE after the first hit, so a line
  costs one callback per process. The lines that are code come from
  `co_lines()` of every code object of every module the run loaded under
  the root (line 0, the frame's start, is dropped). It writes at exit and
  on SIGTERM, for which it installs a handler that writes and exits with
  143. The `sitecustomize` the Python would have run without geas's, the
  project's own or the installation's (Homebrew's Python has one, which
  adds its `site-packages`), is found on `sys.path` without geas's
  directory and run after the monitor starts. A Python older than 3.12 has
  no `sys.monitoring` and gives no record; `map` warns (W060).
- **Node.** V8 writes one JSON a process when the process exits normally,
  not when a signal kills it (measured: none after SIGTERM). The hook
  geas preloads listens for SIGTERM and exits with 143, which is a normal
  exit, when the program has no SIGTERM handler of its own; a program that
  has one shuts down by its own code and exits normally too (measured,
  both ways). The hook does not call `v8.takeCoverage()`: that resets the
  counts, and the exit then writes a file without them, named by the pid
  and the millisecond, so when the two writes fell in the same
  millisecond the counts were lost (33 runs of 60, measured in stage C;
  stage B's hook called it, and a record of `tally-node` came out without
  one claim's lines). A line counts as run when the innermost V8 range
  holding its first non-blank character has a count above zero; every
  non-blank line of a loaded file counts as code. Node blanks TypeScript's
  types instead of moving the code, so the offsets fall on the `.ts`
  file's own lines (measured). V8 names an ES module by its `file://` URL
  and a CommonJS script by its plain path (measured); both are read.
- **Go.** Counters are written when the program exits normally (`os.Exit`,
  or returning from `main`); a service killed by SIGTERM leaves only its
  metadata file (measured), so a Go service is recorded when it handles
  SIGTERM, as the usual `signal.NotifyContext` and `Server.Shutdown` do
  (measured). The text format names import paths
  (`example.com/calc/main.go`); geas maps them to files through the
  `go.mod` files under the root. `go tool covdata` is built into the Go
  build cache the first time it is used. Metadata without counters is a
  program that did not exit normally: E066. Built with `-cover`, a program
  prints `warning: GOCOVERDIR not set, no coverage data emitted` on stderr
  when `GOCOVERDIR` is not set (measured), and `check`, `snap` and `drift`
  do not set it, so a claim on such a command's stderr would see it there:
  the `-cover` build is for `map`.
- **Rust.** The profile is written by an exit hook: `std::process::exit`
  writes it, a signal does not, and Rust's standard library cannot catch
  SIGTERM, so a Rust service needs code or a crate of its own to stop on
  it; the example is a command-line program. `llvm-cov` needs the
  instrumented binary. A profile is named `rust-<pid>-<signature>.profraw`,
  and geas knows the pid of every process it starts and the program its
  first word starts (resolved as the OS would): a profile whose pid is a
  process geas started, running a program that carries LLVM's coverage
  mapping (`__llvm_covmap`), comes from that program, and so does one with
  the same signature (a process the program forked). Any other profile
  came from a program geas did not start itself, through a script or
  `cargo run`, and is left out with W061; the command has to start the
  binary itself. Handed a program without coverage mapping, `llvm-cov`
  refuses the whole export (measured), which is why the mapping is looked
  for. The LLVM tools are looked for once a profile is found: from
  `GEAS_LLVM_BIN` when it is set, and otherwise in the toolchain's sysroot
  (`rustc --print sysroot`, then `lib/rustlib/<host>/bin`), then on PATH;
  missing, they are E065. Built with `-C instrument-coverage`, a program
  writes `default_*.profraw` into its working directory when
  `LLVM_PROFILE_FILE` is not set (measured), as under `check`: the
  instrumented build is for `map`.

### 7.3 One claim, one slice

Every process geas starts for a claim writes into a directory of its own
under the claim's (`.geas/cover/<n>/<k>/` for the k-th, removed once the
claim has been read), so a claim's slice is the union of what its
processes ran and nothing else, and each target's part of it is known.
A `serve` target already gets a fresh instance per claim (§2), so a
service's record is per claim by construction. A process the target
starts in turn inherits the switches and lands in the same directory,
with one exception: a Rust program geas did not start itself, whose
binary `llvm-cov` would need (W061). A target whose processes reported no
line under the root, in any claim, is W060, unless W061, E065 or E066
already says why.

A service is stopped with SIGTERM, given 5 s to exit, then killed, its
process group with it, in `map` as in every command (§10.1); the runtimes
write on a normal exit. SIGTERM goes through the C library's `kill`,
declared in an `extern` block, so geas stays without dependencies. A
service that has to be killed after the 5 s, or a Go service that leaves
metadata without counters, may have stopped without writing its record,
and the claim's slice cannot be trusted: E066.

Startup code (imports, route tables, the `def` lines Python runs on
import) runs in every claim that starts the target. `affected` shows a
change there as touching "every claim that starts `api`" rather than as a
list.

### 7.4 The record

`.geas/<stem>.map.jsonl`, one JSON object a line, every path relative to
the root and written with `/`, no absolute path anywhere:

- a header: the format version, the spec's path, the root (relative to the
  spec's directory), and the claims in order with how each ended in the
  mapped run (ok, fail, error) and the targets each started;
- one line per source file under the root, loaded or not: its path, its
  git blob hash, its language by extension (`.py`; `.js .mjs .cjs .ts
  .mts .cts`; `.go`; `.rs`), and the lines its runtime calls code, or
  `null` where no runtime reported the file (Python and Node report only
  the files a run loaded, Go and Rust only code built with coverage);
- one line per claim, target and file the claim ran through that target:
  the lines, as ranges (`1-4,6,9-11`).

The lines are sorted (files by path; claims in spec order, then targets
in spec order, then paths), so one run on one tree writes the same bytes
(the tests run `map` twice and compare). The root is the nearest
directory above the spec that holds `.git` or `ritsu.mod` (ritsu's
DESIGN 17.6), found by looking, not by running git; else the spec's own
directory; `--root` overrides it. Paths
with a component `.geas`, `.git`, `node_modules`, `site-packages`,
`__pycache__` or `target` are left out: tool state, installed packages,
byte code, and Cargo's build directory, which holds generated `.rs` files
(and, for a Rust project's own tests, their scratch directories) that
would make every record stale. Symbolic links are not followed: git keeps
a link as the path it points to, not as that file's bytes.

### 7.5 How old a record is

A record is exactly the code it was measured on: every source file in it
carries its git blob hash (SHA-1 of `blob <size>\0<bytes>`, computed by
geas; git is not needed). `affected` holds the record to the diff file by
file, and refuses one that does not fit (E062, exit 2). It never answers
"no claim touched" from a record of other code.

- A source file the diff does not touch must still have the recorded hash
  on disk.
- A source file the diff touches must have, in the record, the hash of one
  side of the diff: the code before the change or after it. That side is
  the record's side, for that file.
- A file the diff adds is absent from a record of the code before, and
  present in a record of the code after; a file it deletes, the reverse.

The hashes of a diff's two sides come from its `index <before>..<after>`
lines when it has them (a `git diff`; an abbreviated hash matches by
prefix). A diff without them (`diff -u`) is held to the file on disk: if
the disk agrees with the diff's after side (every context and `+` line
where its hunk puts it), the disk is the after side, and the before side
is the disk with the hunks undone; the reverse when the disk agrees with
the before side; neither is E064. A plain diff has no renames: its two
names are one file, the `+++` one (`diff -u calc.py.orig calc.py`), and
`a/` and `b/` are dropped when both lines have them, as Mercurial writes
them. A hunk with fewer lines of context after its changes than before
them ran into the end of the file, as `patch` reads it, so a file that
goes on past such a hunk is not that side: without this, a diff that
removes a file's last line fits the file before the change as well as
after it.

Re-anchoring an old record onto moved lines was discarded. A heuristic
there would make the list of touched claims quietly wrong, which is the
one failure this command must not have; recording again is cheap, since
it is a run of the claims.

### 7.6 What a change touches

Each changed line is looked up on the side a record covers. A blank line
is never code in any of the four languages and counts for nothing.

- An added or rewritten line (`+`) needs a record of the code after the
  change (E063 otherwise: record the changed code). There it is run by
  some claims, which it touches; or it is code no claim runs, which is
  unclaimed code; or it is not code (blank, a comment, a line the runtime
  does not report), which counts for nothing.
- A removed line (`-`) is looked up in a record of the code before the
  change when one is given: `--map` can be given twice, one record per
  side, each matched to its side by its hashes. With only a record of the
  code after, a run of removed lines that no added line replaces touches
  the claims that run the nearest code above and below where it was,
  marked "near", and is unclaimed when none does; removed lines that added
  lines replace are looked up through those.
- A line that every claim starting a target runs through that target, when
  two claims or more start it, is shown once, as touching "every claim that
  starts `<target>`", instead of under each claim: imports and route
  tables, and the code every request passes.
- A deleted file is listed apart. With a record of the code before the
  change, its lines are looked up as any removed lines; without one, which
  claims ran it is not known, and that makes the exit status 1.
- A source file in a mapped language that no runtime reported (§7.4) is
  "run by no claim" as a whole, and every added line of it is unclaimed.
- Other files (documents, configuration, data) are listed as outside the
  record; they do not change the exit status.

Two files are reported apart from the code, because a change to them
needs a person whatever else the diff does: the spec (the claims
themselves changed; read them again) and its baseline (drift now compares
against something new, and re-snapping is the way a change drift would
have reported disappears). Either makes the exit status 1.

The report, for a person and as JSON (`--json`, Appendix A of PLAN.md):
the records and the side each is of; the touched claims in spec order,
each with the files and lines that touch it (numbers of the code after
the change, or "removed" and the numbers before it, "next to lines it
runs" where a removal was attributed by its neighbours), then each
target's startup group; unclaimed code, by file and lines ("no runtime
reported this file" where none did); deleted files; files outside the
record; changes to the spec and the baseline; and a last line of counts.
Exit 0 when no changed code is unclaimed, no deleted file's claims are
unknown, and neither the spec nor its baseline changed; 1 otherwise; 2
when a record is missing or stale, or the diff cannot be read.

`affected` never runs claims, and `check` never runs fewer because of it:
the record routes review and never narrows acceptance.

### 7.7 Limits

- Lines are the unit. A line holding two branches counts as run when
  either ran, and Node counts a comment inside a block that ran. Node
  decides a line by its first character, so `} else if (…) {` counts as
  run when the block its `}` closes ran, whatever the new condition does:
  in `tests/changes/tally-node`, the line that adds a `/health` route is
  shown as touching the two claims that add a number.
- Python and Node report only the files a run loaded; a file nothing
  loaded is known by its extension, not by its lines.
- A process whose runtime the switches do not reach (a Go binary built
  without `-cover`, Python before 3.12, a shell script) adds nothing;
  `map` warns for each target that gave no record at all (W060).
- Templates, SQL, configuration and generated files are outside the
  record. pixie apps and the scripts of a page in the browser are not
  recorded in v1 (§15).

Discarded:

- Reading the implementation (call graphs, imports) to guess which claims
  reach a line: a parser per language, unsound under dynamic dispatch, and
  geas would be reading the code its claims treat as a black box. What
  each claim ran is measured instead.
- Claims that say what code they cover: the hook into the implementation
  §0 rules out.
- coverage.py, c8 or nyc as dependencies: not installed everywhere. geas
  carries its own small Python collector and reads what Node and the Go
  and Rust toolchains already write.
- git as a dependency: the blob hash is computed, and `index` lines are
  read when a diff has them.

## 8. GUI targets: the screen

A GUI target turns each `when` into an action on an app, and observes the
screen once the app has settled: its accessibility tree, which is what a
screen reader is told. Claims say what the screen contains. They never
name a widget class, a CSS selector, an element id or a pixel, and a
control a claim cannot reach by its role and name is one a person using a
screen reader cannot reach either, so that failure says something true
about the app.

```
target app {
  pixie "build/greeter"
}

claim "greets the name typed in" {
  when app.input("Ada")
  when app.click("greet")
  then screen contains text "Hello, Ada!"
  and  screen contains textbox "type here" with value "Ada"
  and  screen does not contain button "retry"
}
```

§5 meant the GUI adapter to fit "the same five subjects". It adds one,
`screen`, instead: the five are text and numbers out of a process or an
HTTP exchange, and a screen is a tree whose claims are about nodes. Put
into `body contains`, a claim would depend on how the tree happens to be
printed.

### 8.1 The contract

Whatever drives the app, geas hands it actions and gets back, after each,
a tree of one shape: a node has a role, a name, a value, states and
children.

- The role is a role name of WAI-ARIA 1.2 (`button`, `textbox`, `heading`,
  `checkbox`, `dialog`, `listitem`, …), or `text` for static text, which
  ARIA has no role for. A driver maps its own words onto these, so one
  claim reads the same against a pixie app and a web page.
- The name is the accessible name; the value is what a screen reader
  reads after it (a field's contents, a slider's number); the states are
  those of `disabled`, `checked`, `unchecked` the driver knows.
- A container that only lays things out reports nothing and hands its
  children up (pixie's rule, and Chrome's `generic`); a node named by its
  own text drops the text children that repeat the name; empty text
  reports nothing.

The actions:

| action | what it does |
|---|---|
| `open()`, `open("/path")` | the app's first screen; in a browser, that path of the target |
| `click("<name>", nth: n)` | press the n-th (by default the first) button, link, checkbox, radio, switch, tab, menuitem or option of that name |
| `input("<text>", field: n)` | type into the n-th text field; `into: "<name>"` picks the field by its name |
| `submit(…)` | Enter in a field, picked the same way |
| `press("<key>")` | a key or chord: `enter`, `escape`, `tab`, `backspace`, `up`, `cmd-s`, `ctrl-a`, `shift-tab` |
| `advance(<ms>)` | move the app's clock forward |

Each claim gets a fresh app, as it gets a fresh service (§2).

Discarded: comparing screenshots (fonts and antialiasing make noise, and a
picture cannot say that a button is disabled); selectors and ids in claims
(a renamed class breaks the claim while nothing a person sees changed);
pixie's element tree (`Column[Text(…)]`) as the observation (it is the
app's layout, not what the app tells a person).

As built:

- The screen is a node without a role whose children are what the app
  shows at its top level. A node without a role reports nothing and hands its
  children up, wherever it is, so a driver's root may come with or without
  one: without, it is the screen; with one (`window "Greeter"`), it is the
  screen's one node. Each driver maps its own root to the screen: pixie's
  window `group`, Chrome's `RootWebArea`.
- The children a node drops are text leaves whose name is the node's name
  or its value, so a button keeps no `text "Add"` under it and a field no
  `text "milk"`.
- States are written `disabled`, `checked`, `unchecked`, then any other a
  driver reports, in that order.
- `input` makes the text what the field holds, as pixie's `input:` does: a
  browser selects what is there before it types. `click` reaches the eight
  roles above; `input` and `submit` the text fields, `textbox`,
  `searchbox` and `spinbutton`, counted together. A `combobox` is not one:
  Chrome reports a `<select>` as one, and typing into it does nothing.

### 8.2 Live drivers, and a replayed one

A live driver keeps the app running for the whole claim and acts as each
`when` comes, so another target's `when` can come between two actions. A
replayed driver runs a whole script in one process: geas collects the
claim's actions on that target, runs them as one script with a tree read
after each, and hands each `when` its tree. So within one claim, no other
target's `when` may come between two actions on a replayed target (E012):
the app is not running between them. Before the first and after the last
is fine.

As built, E012 is found before anything runs, at the `when` that comes
between, with the lines of the actions before and after it. A pixie app
starts once in a claim, so `open()` on it after another action on it is
E013: the first screen comes before the others.

### 8.3 pixie, the first driver (replayed)

A pixie app reads `PIXIE_SCRIPT`, a comma-separated list of steps, runs it
headless, and prints its element tree at the start and at the end; a step
`a11y` prints the accessibility tree at that point (pixie's README,
`script.rs`, `a11y.rs`). geas:

- always sets `PIXIE_SCRIPT`, since an app without it opens a window, and
  sets `PIXIE_DUMP` to a file under `.geas/`: newer pixie kernels write
  the transcript there, apart from what the app itself prints, and older
  ones ignore it and print to stdout, which geas reads instead;
- translates the actions: `click("x", nth: n)` to `click:x`
  (`click@<n-1>:x` for n > 1), `input("t", field: n)` to `input:t`
  (`input@<n-1>:t`), `submit(field: n)` to `submit` (`submit@<n-1>`),
  `press("k")` to `key:k`, `advance(ms)` to `advance:<ms>`, each followed
  by `a11y`; `open()` is the starting screen, an `a11y` alone. Commas and
  backslashes in text are written `\,` and `\\`;
- reads the transcript: the lines that start with `group` are the
  accessibility trees, one per action, in order; any other count is E037;
- maps pixie's roles onto §8.1's: `label` to `text`, `textInput` to
  `textbox`, `image` to `img`, `listItem` to `listitem`, `progress` to
  `progressbar`, `comboBox` to `combobox`, `radioGroup` to `radiogroup`,
  `tabList` to `tablist`, the rest as they are; a checkbox's or a
  switch's value `true` or `false` becomes the state `checked` or
  `unchecked`;
- refuses `into:` on a pixie target (E013): pixie reaches text fields by
  position only.

When a step names something the app does not have, pixie exits with 101
and prints nothing about it, and newer kernels print a script's trees only
at its end, so the trees before the refused step are lost as well
(measured). geas then runs the script's prefixes until the first that
fails, which finds the refused action and gives the screen just before
it, so the message can say which buttons and links that screen had. Two
steps of the greeter took 32 ms in all, and this is done only on a
failure.

pixie prints names and values without escaping them, so a name holding
`", ` or `]` can be misread (measured: a value "a, b" prints as `=a, b`
followed by `, ` and the next node). geas reads with pixie's role names
as anchors and checks that what it read prints back to the same line,
which catches most such cases but not all; an escaped dump in pixie would
close it (§15).

As built:

- A name or a value may hold a line break, and pixie prints it as it is
  (measured: a field given `a` and `b` on two lines printed its tree on two
  lines), so the trees are read out of the transcript as a whole, not line
  by line: a tree starts a line with `group` and runs to the `]` that
  closes it. Each name and value is read shortest first, going back when the
  rest of the tree does not read, at most 16 readings a place; a tree no
  reading fits is E037. A reading prints back to the text by construction,
  so what is left unknown is which of two readings a tree meant, and the
  shortest is taken.
- The transcript goes to `.geas/<stem>.pixie-<n>.txt` (`n` the claim's
  number, so claims side by side do not meet), named absolutely since the
  app runs in the spec's directory, and the file is removed once read.
- An app has 5 s for each action of its script. One that does not finish is
  stopped (E037), and so is one that ends with an exit code other than 0 and
  101. A script refused once that runs to its end when it is run again, as
  the last prefix, is E037 too: the app does not do the same on every run.
- The command's first word is the app: a file of that name in the spec's
  directory (`pixie "greeter"` runs `./greeter`), and otherwise a program
  on PATH, since an app is something built rather than installed.
- A refused action's message gives the screen just before it, and what on
  that screen the action could reach: the buttons and links for a click,
  the text fields for `input` and `submit`. `tests/pixie/greeter.geas`
  (four claims) ran in 0.03 s, and so did `tests/pixie/fails.geas`, a
  refused click and the prefixes it took among its runs.
- pixie's greeter has nothing bound to Enter, so `press("enter")` is
  refused there (measured), and `submit()` is how a claim sends Enter to a
  field.

### 8.4 Chrome, the second driver (live)

A `serve` target can be driven in headless Chrome: `open("/path")` loads
`http://127.0.0.1:<port>/path`, and the other actions act on that page.
`get` and `post` still reach the same service, in the same claim.

- Chrome is found through `GEAS_CHROME`, then the macOS application, then
  `google-chrome`, `chromium` or `chromium-browser` on PATH. Not found,
  the claims that need it end in E034, and the tests print SKIP.
- One Chrome per run (one per worker under `--jobs`), headless, with
  `--remote-debugging-port=0` and a profile directory under `.geas/`; the
  port is read from `DevToolsActivePort` in that directory. geas stops it
  and removes the profile when it exits or panics.
- geas speaks the DevTools Protocol over one WebSocket on 127.0.0.1, with
  a session per page (`Target.attachToTarget` with `flatten`). The client
  is standard library only: the RFC 6455 handshake (SHA-1 and base64,
  written in geas; the SHA-1 is §7.5's), masked frames, 16- and 64-bit
  lengths, continuation frames, pings answered. HTTP on the debugging port
  is read by Content-Length, with a timeout: Chrome keeps the connection
  open after it answers, and reading to the end hung (measured).
- Each claim gets a browser context of its own
  (`Target.createBrowserContext`), so cookies and storage do not pass
  from one claim to the next (measured: a second context read `null`
  where the first had stored a value).
- Settling is by virtual time, always. Before a page loads, geas pauses
  the page's clock (`Emulation.setVirtualTimePolicy`, starting at the
  pinned clock, or else now); after each action it grants one second of
  the page's own time (policy `pauseIfNetworkFetchesPending`) and waits
  for `Emulation.virtualTimeBudgetExpired`. Timers due within that second
  fire, a fetch holds the clock until it returns, and after k actions the
  page's clock reads its start plus k seconds (measured: started at
  2026-08-29T00:00:00Z, the page read 00:00:02.000Z after two). A page
  whose budget does not run out within 10 s of real time is E036.
  `advance(ms)` grants that much more. Settling by polling until the tree
  stopped changing was discarded: a timer longer than the polling window
  slips through it.
- `click` finds the node by role and name, scrolls it into view, and
  presses the mouse at the centre of its box, so whatever a person's
  click would land on is what gets it, including an overlay. `input`
  focuses the field and inserts the text (`Input.insertText`); `submit`
  and `press` send key events (Enter submitted a form, measured).
- The tree is `Accessibility.getFullAXTree`, normalized: ignored nodes,
  and nodes without a name whose role is `generic`, `none`,
  `presentation`, `paragraph`, `LabelText`, `ListMarker`, `InlineTextBox`
  or `LineBreak`, hand their children up; `StaticText` becomes `text`;
  the root (`RootWebArea`) is the screen; a field's text child repeating
  its value is dropped.

As built:

- `GEAS_CHROME`, when it is set, is the only Chrome looked at: one that is
  not a file is E034, not a reason to look elsewhere.
- Chrome names its `InlineTextBox`, `ListMarker` and `LineBreak` nodes
  (the text again, `• `, a line break; measured), so those are dropped,
  with what is under them, named or not. Unnamed `MenuListPopup` (a closed
  `<select>`'s list), `sectionheader` and `sectionfooter` (a header and a
  footer inside `<main>`) hand their children up with the layout roles
  above. `image` becomes `img`. The states are read from the properties
  `disabled` and `checked` (`true`, `false`). Chrome's own roles that are not
  ARIA's (`DescriptionList`, `DisclosureTriangle`, `Iframe`) stay as they
  are. Text keeps Chrome's runs: a sentence with a bold word or a link in it
  is several `text` nodes, which `containing` reaches into.
- `Page.navigate` is answered once the service begins to answer the page
  (measured): a path the service never answers is E036 when the navigation
  is not answered within 10 s, and one Chrome refuses (its `errorText`) is
  E036 at once.
- `advance(ms)` grants exactly that much of the page's time, as pixie's
  `advance:` moves its clock; the one second follows the other actions.
- `input` selects what the field holds (`select()`, or a range over the
  node) and inserts the text; an empty text is a Backspace. `press` sends a
  letter, a digit, `enter`, `escape`, `tab`, `backspace`, `delete`,
  `space`, the arrows, `home`, `end`, `pageup`, `pagedown`, `f1` to `f12`,
  and chords of them with `cmd`, `ctrl`, `alt` and `shift`; any other key is
  E013 before anything runs.
- On a `port auto` service, the page's names and values have the service's
  own address written `{port}`, as its HTTP answers do (§10).
- A JavaScript dialog would stop the page; geas accepts it.
- Chrome's profile is `.geas/chrome-<worker>/`. When a signal ends geas, the
  handler kills the groups geas started, Chrome's among them, and returns:
  a handler may not walk a directory. The run, its programs gone, winds
  down, starting nothing new, prints nothing more, removes the profile on
  its way out, and exits with 128 and the signal's number. A thread
  watching for the signal removes the profile and ends geas itself should
  the run not have ended 2 s after it.
- A page that pins no `locale`, `tz` or `clock` sees the machine's
  (measured: on a machine set to Japanese, a date printed as
  `2026年10月3日 3:14`), so a spec whose screens should read the same on
  every machine pins them.
- A spec of one claim that opens a page ran in 0.42 to 0.45 s, Chrome's
  start and stop and the service's included; the nine claims of
  `tests/browser/page.geas` ran in 1.8 to 2.0 s.

### 8.5 Any other GUI: a driver of your own (live)

```
target app {
  driver "python3 my_driver.py"
}
```

§8.1 as a protocol. geas starts the command once per claim (in the spec's
directory, its environment pinned as §12 says) and speaks JSON lines on
its stdin and stdout:

- first `{"geas":1,"pins":{…}}` with the pins of §12 that are set; the
  driver answers `{"ok":true}`, or `{"error":"…"}` for a pin it cannot
  keep (E011);
- then one action a line, `{"do":"open","path":"/"}`,
  `{"do":"click","name":"greet","nth":1}`,
  `{"do":"input","text":"Ada","field":1}` (or `"into"`), `{"do":"submit",…}`,
  `{"do":"press","key":"enter"}`, `{"do":"advance","ms":500}`, each
  answered by one line, `{"screen":<node>}`, or
  `{"error":"…","screen":<node>}` when the app refused the action (E035).
  A node is `{"role":…,"name":…,"value":…,"states":[…],"children":[…]}`
  without the members that are empty;
- last `{"do":"close"}`, and the driver exits. An answer that does not
  come within 5 s, or is not of that shape, is E037.

This is how a GUI that geas does not drive itself is reached (an
accessibility driver for macOS, one for Android) without geas growing.
The tests use a fake driver of a few dozen lines of Python, so the screen
checks, the errors and the interleaving are held to the contract on any
machine with Python.

As built:

- The pins line carries the pins as the journal's `env` event writes them
  (`{}` when there are none), and the driver gets the same environment a
  process of the target would (`env`, `tz`, `locale`).
- `click` always sends `nth` (1 when the claim does not say), `input` and
  `submit` send `"field":1` when the claim names no field, and `into` comes
  with `nth`.
- An answer with `error` is E035, and the screen it carries is in the
  message's notes. A driver that exits or closes its stdout before
  answering is E037, with the last lines of its stderr.
- At the claim's end the driver gets `{"do":"close"}` and 5 s to exit; what
  is left of its process group is then killed.

macOS's accessibility API is not built in. A process may read another
app's tree only after a person grants it Accessibility in System Settings;
a test run cannot, and nothing in this session could verify it. A driver
can be written against this protocol and verified where the permission
can be given.

### 8.6 Screen checks

`screen contains <node>` holds when some node of the screen matches;
`does not contain`, when none does; `contains exactly n <node>`, when n
do. A node pattern matches a node by role; then by its label, the whole
name, or a part (`containing "…"`), or a pattern (`matching "…"`, §9);
then by `with value "…"`, by a state, and, with `in <role> ["<name>"]`,
by an ancestor. `mask screen <node>` takes the matching nodes and what is
under them out of drift.

The screen goes into the journal and the baseline as JSON, and into
messages as text, one node a line, two spaces a level: `button "greet"`,
`textbox "type here" value "Ada"`, `button "retry" disabled`. `drift`
compares two screens as trees: the children of a node are aligned by role
and name (a longest common subsequence), and a node that appears or
disappears, or whose value or state changed, is a change, claimed when a
check of that `when` has a pattern matching the node, before or after.

As built:

- A failed `screen` check says how many nodes matched (`got no such node`,
  `got 2 such nodes`) and shows the screen under the check, cut to 40 nodes
  with the matching part first; a screen that two failed checks of one
  `when` share is shown once (`the screen: as above`). In `--json`, a
  failed `screen` check carries the screen it saw as `screen`.
- Every node that appears or disappears is a change of its own, so a dialog
  that appears with two buttons is three changes, each claimed or not by
  itself: claiming the dialog claims nothing about a button no check names.
  A change is placed by its parent (`screen in dialog "Confirm"`), and what
  went comes before what came.
- A node a `mask screen` pattern matches is written as its role alone in the
  journal and the baseline (`{"role":"text","masked":true}`). In drift, a
  node masked on either side, or matched by a mask now, is not compared, so
  adding a mask needs no new snap.

## 9. Matchers and subjects

| matcher | applies to |
|---|---|
| `is`, `is not` | every subject but `screen`; `true`, `false` and `null` are values now, for JSON |
| `is above`, `is below`, `is at least`, `is at most`, `is between a and b` (both ends in) | exit, status, a JSON number, a text that is a number once trimmed |
| `contains`, `does not contain` | a text (a part of it), the screen (a node, §8.6) |
| `matches`, `does not match` | a text, a JSON string: the whole value against a pattern |
| `exists`, `does not exist` | `body json "…"`, `header "…"` |

- `is` and `matches` compare a text after dropping one trailing newline,
  as v0's `is` did; `contains` looks in the whole text, as v0's did.
- A number matcher reads a text as a number when, trimmed, it is written as
  one: a sign, digits, a fraction and an exponent, each but the digits
  optional. `inf` and `NaN` are not numbers here, though Rust would parse
  them.
- `header "<name>"` is a subject now, the name compared without regard to
  case. v0 recorded headers for drift, but no check could name one, so
  every header change was unclaimed by construction. Several headers of
  one name read as one, their values joined by `, ` as RFC 9110 joins
  them.
- A value that is not there fails every matcher but `does not exist`: a
  header not sent, a path that leads nowhere. A body that is not JSON
  fails every `body json` check, `does not exist` included: a check is
  about a value, and a broken answer is not one. A failed check shows why:
  `<no header "etag">`, `"many" (not a number)`, `[1] (not a string)`.
- Patterns: literals, `.`, classes (`[a-z]`, `[^0-9]`), `\d \w \s` and
  their negations, groups, `|`, `* + ?`, `{n}`, `{n,}`, `{n,m}`; no
  backreferences, no lookaround. A pattern matches the whole value, by a
  Thompson-style simulation whose time is linear in the text. A pattern
  that does not parse is E009, before anything runs. Globs were discarded
  (they cannot say "digits"), and so were backtracking engines (some
  patterns take exponential time).
- As built: `.` is any character, a line break included, since the whole
  value is matched and a value may hold several lines; `\d` is `0-9`,
  `\w` a letter, a digit or `_` (letters of any script), `\s` white
  space; `\n`, `\t` and `\r` are what they are in a string, and a
  backslash before a character that is not a letter or a digit keeps it.
  `^` and `$` are E009, not anchors: the whole value is always matched.
  A count is at most 1000, and a pattern whose counts spelled out make a
  program of more than 100,000 steps is E009 too. `(a|aa)*b` against
  30,000 `a`s takes milliseconds (a unit test).
- In a claims file, a backslash in a pattern is written as it is:
  `matches "\d+"`. Any other string still refuses a backslash before a
  character other than `n`, `t`, `"` and `\` (E001, as in B), and says
  that a pattern would take it. Writing every pattern's backslash twice,
  `"\\d+"`, was discarded: patterns are where backslashes are dense, and
  the person who reads the claims reads them too.
- A JSON path is checked before anything runs, after `body json` and
  after `mask body json`: one that does not read is E009, pointing at
  the character where reading stops. B found it at run time, as a failed
  check.
- A matcher that does not fit its subject or its value is E008, before
  anything runs, as v0 already did for `contains` on a number: a text
  matcher (`contains`, `matches`, `exists`) on a number; `exists` on a
  subject that is always there; a number compared with a string,
  `true`, `false` or `null`, and a text with `true`, `false` or `null`;
  a number matcher given anything but a number, or `is between` with its
  larger end first; `contains` given anything but a string; `matches`
  given anything but a pattern in quotes. One E008 a check, the first
  that applies.

Discarded: JSONPath or jq filters (a second language inside the claims;
the path stays `.a[0].b`); arithmetic between observations (claims stay
observations).

## 10. Running claims side by side

`--jobs N` (`-j N`, or `GEAS_JOBS`) runs up to N claims at once. The
default stays 1: v0 promised one claim at a time (§2), and a spec whose
targets share something geas cannot see, a file or a database, would turn
flaky without a word.

- The report, the journal and the baseline come out in claim order,
  whatever finishes first, so a run with `-j4` prints and writes, byte for
  byte, what `-j1` does.
- Where no thread can be started (ritsu built for WASI, its npm package;
  ritsu's DESIGN 8.8), the claims run one after another on the one thread,
  whatever `--jobs` says, and each says that it cannot start its program.
- Ports are what geas can see. `port auto` gives each instance a free port
  on 127.0.0.1: geas binds port 0, reads the number, closes it, and never
  hands one number to two live instances, its own or another geas's on the
  machine. `{port}` in the target's command and in its `env` values is
  replaced by it. Claims that touch a fixed `port 8123` never run at the
  same time (a lock per fixed port); other claims run beside them.
- `serial` on a target runs the claims that touch it one at a time, for
  state geas cannot see.
- On a `port auto` target, `127.0.0.1:<port>`, `localhost:<port>` and
  `[::1]:<port>` in what it answers are written `{port}` before checks,
  the journal and drift see them: the port belongs to the environment,
  not to the program's behavior, and a redirect would otherwise drift on
  every run. A claim writes `{port}` where the port appears.
- Each worker has its own Chrome (§8.4).

Discarded: parallel by default (above); isolating claims in containers or
namespaces (per platform, and the implementation would run somewhere
other than where the claims say).

As built:

- A free worker takes the first claim, in spec order, that need not wait,
  and holds its fixed ports and `serial` targets for the claim's whole run.
  The report, the journal, the baseline, the record and `map`'s warnings
  are put together in claim order afterwards, so `-j4` gives the bytes
  `-j1` gives on every example (`tests/languages.rs` holds `map`, `check`
  and `snap` to it, `tests/parallel.rs` the greeter, `check --json` and
  `drift` included). In `map`, each worker reads its claim's coverage as
  soon as the claim ends; the LLVM tools are looked for once, by whichever
  claim needs them first.
- `-j4` is taken as `-j 4`, as make and cargo take it. `--jobs 0` is E080;
  a `GEAS_JOBS` that is not a whole number from 1 counts as unset, as a
  `GEAS_LANG` geas does not know does.
- A port the system handed out may be taken by another program between
  geas closing it and the service binding it; the service then fails to
  start, E032. A message names an auto port as "the port geas gave it",
  and gives the command with `{port}` in it, since the number changes
  from run to run and the output must not.
- geas could also keep such a port open itself. A program being started
  holds a copy of every descriptor geas has open until it execs; when
  another worker had the socket `port auto` reads its number from open at
  that moment, the copy kept the port listening after geas closed it,
  with nothing to accept on it. The claim given the port found its
  service ready on a connection to the copy, and its request was cut off
  when the copy went: E033, "closed the connection without answering",
  with nothing on the service's stderr. It showed on GitHub's runner, on
  `snap -j4` of tally-go. On Linux it came back with tally-node in 4 runs
  of 14, the listening socket on the port held by no process when the
  claim connected. Picking a port and starting a program now take one
  lock (`proc::starting`), so no program starts while that socket is
  open, and the same runs came back clean 10 times of 10. A start takes
  a moment, and claims still run side by side.
- Two geas side by side, as the tests run them, kept their numbers apart
  only each within itself. The system offered a number one had just
  closed to the other, both services were given it, and the second failed
  to bind it while its geas found the port open (on the first one's
  service) and ran its claims there. On GitHub's runner, a first `geas
  check` of the greeter in Japanese answered 1 because the English one
  beside it had changed its own page (2026-10-18), and a `snap` before a
  `drift` met E033 (2026-10-14). A number is now claimed against every
  geas of the user as well: a lock (`flock`) on a file named for it in
  `/tmp/geas-ports-<uid>`, held until the instance stops. The file goes
  with the claim, and the lock goes with the process, however it ends.
  The directory is not under TMPDIR, which one geas may be given and
  another not; where it cannot be made, or is not the user's alone, geas
  keeps its own instances apart as before. `proc.rs` holds a second
  process to it.
- geas cannot tell from a connection whether the listener is the service
  it started. Asking whether the service is still running once the port
  answers comes too early: the service that will fail to bind has
  usually not tried yet when the other's answers.
- `port auto` on a target whose command and `env` values never say
  `{port}` is E004: the service would have no way to learn its port.
- A masked value, of a header or of a JSON path in a body that is JSON, is
  written `"<masked>"` in the journal and the baseline: a value the spec
  declares to be noise would otherwise make two runs, or `-j1` and `-j4`,
  differ where nothing did. Checks see the value itself. Adding a mask
  still needs no new snap; removing one does, since the baseline holds
  `<masked>` where the value was.

### 10.1 Process groups

Every program geas starts runs in a process group of its own, and is
stopped as a group, and geas waits until no process of the group is left.
A service is sent SIGTERM, given 5 s, then killed, in every command: it
cleans up as it does whenever it is stopped (drops the database it made
for one run, removes its socket file), and in `map` it writes what it
ran. A `run` command that overruns its 5 s is killed at once (E031), and
what a finished one leaves running in its group is killed at once too,
but in `map`, where it is sent SIGTERM first. So a server behind a wrapper
(`sh -c 'python3 server.py {port}; true'`, `npm start`, `uv run`) is
stopped with it and gets the SIGTERM. Before, only the wrapper got the
signal: the server went on running after geas, holding its port, and gave
no record. A `run` command that leaves a program running in the
background has it stopped when the command ends, as soon as the command
ends rather than when the 5 s run out, since the program held the
command's output open. A program that leaves its group (a daemon calling
`setsid`) is out of reach, as before.

A terminal's Ctrl-C reaches only geas's own group then, so geas catches
SIGINT, SIGTERM and SIGHUP, kills the groups it started, and exits with
128 and the signal's number. The handler reads only atomics and calls
`kill` and `_exit`, declared in the `extern` block beside `kill`, so geas
stays without dependencies. `tests/parallel.rs` interrupts a claim and
finds neither the shell, nor the server behind it, nor a `sleep` alive.

Discarded: leaving a wrapper's children to the wrapper, which most shells
do not signal; walking the process table to find a program's children,
which differs from system to system and races with programs that start
more; and killing a service at once outside `map`, as `check`, `snap`
and `drift` did until 2026-10-28 since they need nothing written. That
saves the 5 s only for a service that ignores SIGTERM, and leaves behind,
claim after claim, what a service removes as it stops: a service that
makes a database for each run and drops it on the way out left one per
claim. `tests/parallel.rs` has `check` stop a service that removes, on
SIGTERM, the file it made as it started.

## 11. Command strings

`run` and `serve` strings are split into words by geas, never by a shell.
Blanks (white space, what v0 split on) separate words; `'…'` keeps
everything in it as it is; `"…"` keeps blanks and reads `\"` and `\\`;
outside quotes, a backslash keeps the next character as it is. Nothing
else is special: no variables, globs, `~`, pipes or redirections.
`{port}` is replaced after splitting. In a `.geas` string a double quote
is written `\"`, so single quotes are the easy form:
`run "python3 'my calc.py'"`. A shell, when one is wanted, is named:
`run "sh -c 'cd tools && ./gen'"`. An open quote or a trailing backslash
is E010, pointing at the character inside the string where the problem
opens, and so is a command with no word at all, found as the spec is
read (v0 found an empty command when a claim ran it).

This is the one change to v0's meaning: v0 split on blanks only and passed
quotes and backslashes on as characters. Commands without them split as
before.

Discarded: running commands through `sh -c`. Expansion and quoting would
then depend on the machine's shell, and a claims file would mean
different things on different machines.

## 12. Pinning what a target sees

A pin fixes part of what a target sees. At the top level it applies to
every target; inside a target it overrides the top level there.

| pin | a process (`run`, `serve`), a pixie app | a page in Chrome | a driver |
|---|---|---|---|
| `env "NAME" "value"` | sets NAME | (the service's process gets it) | sets NAME |
| `env clean` | the environment is `PATH` and what the spec sets or passes | (the service's process) | the same |
| `env pass "NAME"` | under `env clean`, NAME as geas has it | (the service's process) | the same |
| `tz "Asia/Tokyo"` | `TZ` | `Emulation.setTimezoneOverride` | in the pins line |
| `locale "ja-JP"` | `LANG` and `LC_ALL` as `ja_JP.UTF-8` | `Emulation.setLocaleOverride` | in the pins line |
| `clock "2026-08-29T09:00:00+09:00"` | E011, unless `env "NAME"` follows | where virtual time starts (§8.4) | in the pins line |
| `seed 7` | E011, unless `env "NAME"` follows | `Math.random` replaced, before any of the page's scripts, by a generator seeded with 7 | in the pins line |

`env clean` keeps `PATH` so the program can be found; anything else, HOME
included, is passed by name. With `env "NAME"` after it, `clock` and
`seed` set NAME to the time (as written) or the number, for a program that
reads them there.

geas cannot set the clock or the random numbers of a process from
outside: no switch is read by every runtime. Faking them inside the
process was discarded, whether by libfaketime through
`DYLD_INSERT_LIBRARIES` or `LD_PRELOAD` (per platform, and macOS refuses
it for its own binaries) or by patching `time` and `random` through the
coverage hook: either is the reach into the implementation §0 rules out.
A program whose output depends on the time or on chance takes them through
its boundary, as an environment variable the spec names, or its claims do
not depend on them and drift masks them. A page is different: geas runs
the page's world, so it can keep the page's clock and `Math.random`
(measured: the same seed gave the same first number in two browser
contexts, and `de-DE` printed 1234.5 as `1.234,5`).

Python's hash randomization changes the order of sets and of some
dictionaries' keys in output. The skill tells agents to pin
`env "PYTHONHASHSEED" "0"` for Python targets; geas does not set it on its
own, because a pin the spec does not show is one its reader cannot audit.

The journal records each target's pins at the start of each claim, and
`drift` notes when they differ from the baseline's.

As built:

- A pin inside a target replaces the same pin outside it (`tz` by `tz`,
  `env "A"` by `env "A"`); `env clean` and `env pass` add up. One place
  pinning one thing twice is E003, and so are two pins that set one
  variable of a target (`tz` and `env "TZ"`, wherever each is written):
  which one wins would be written nowhere a reader looks.
- The `env "NAME"` after `clock` or `seed` is read on the same line only,
  so an `env` pin on the next line is never taken for it.
- A pin outside any target is checked on every target and said once,
  naming the targets it fails on: `clock` without `env` is one E011 for
  `calc` and `report`. `{port}` in an `env` value on a target without a
  port is E010, as in a command.
- E011 also covers a value geas cannot read: a `clock` that is not RFC
  3339 (`T` and `Z` may be lower case, as RFC 3339 allows; an offset is
  `+09:00`), a `locale` not written `ll-RR` (`ja_JP.UTF-8` is told to
  write `ja-JP`), an empty `tz`, a `seed` that is not a whole number
  from 0, a variable's name that is empty or holds `=`.
- In `map`, the coverage switches are added on top of what the pins
  give: `PYTHONPATH` gains geas's hook in front of what the program would
  have seen, after `env clean` as well, and `NODE_OPTIONS` the hook after
  it, so a pinned target is recorded like any other (`tests/pins.rs`).
- The pins as the journal's `env` event and the baseline's first line
  write them: the members that are set, in a fixed order (Appendix A of
  PLAN.md). A baseline without that line, written before pins, is read
  as one whose targets had none.
- A service some claim opens as a page (any action on it, in any claim)
  takes `clock` and `seed` without `env`: they keep the page's clock and
  `Math.random`, and its process gets nothing for them. With `env "NAME"`,
  the process gets NAME as well. A driver takes them without `env` too, in
  its pins line, and says with `{"error":"…"}` when it cannot keep one,
  which is E011 when the claim runs; so is a `tz` or a `locale` Chrome
  refuses.
- A pixie app is a process for its pins: `env`, `tz` and `locale` reach it,
  and `clock` and `seed` need `env`.

## 13. Diagnostics, in two languages

Every problem geas reports about a spec, a run, a baseline or a record has
a code, a place, and the input or the run that leads to it, in the shape
dandori and rulec use. The first line is
`error[<code>]: <file>:<line>:<col>: <message>`; the line of the spec
follows; then notes, each `= …`; then the run that gets there, which for a
claim is its `when` lines up to the problem, each with what it observed,
cut to one line.

- A failed check is a verdict, not a diagnostic: `not ok <n> - <name>` as
  in v0, then the check, what it expected and what came back, and the run
  that gets there.
- `--lang ja`, or `GEAS_LANG=ja`, or `RITSU_LANG=ja` (the variable every
  language of ritsu reads, after `GEAS_LANG`), prints the prose in Japanese;
  English is the default. `--lang` takes `en` or `ja` and refuses anything
  else (E080); a variable with another value is passed over, since a variable
  left in an environment should not stop every command (`ja_JP` reads as
  Japanese, `fr` as no choice at all). `ok` and `not ok` stay as they are (they are TAP), and so
  do JSON keys. geas has two readers: an agent, which reads English and
  JSON, and the person who audits the claims, whose claims may well be
  named in Japanese. rulec and dandori make the same split.
- `--json` gives the diagnostics as data, with the same keys as dandori's:
  `code`, `severity`, `line`, `col`, `message`, `notes`, `path`, and one
  more, `file`: a diagnostic can point into a file other than the spec (a
  line of the baseline for E051), and without it that line would be read as
  the spec's.
- `geas explain <code>` prints when a code appears, what usually fixes it,
  and a spec that produces it; `geas explain --all` prints every one, and
  the skill carries that output. With `--json` each repro comes as files
  and arguments a program can run, and the tests run every one of them.

| code | what it reports |
|---|---|
| E001 | a character, a string or a token the grammar does not allow there |
| E002 | an unknown name: a target, a call, a subject, a matcher, a role, a state, a pin |
| E003 | two targets, or two claims, with one name; one thing pinned twice |
| E004 | a target whose lines do not fit together: no `run`, `serve`, `pixie` or `driver`, or two of them; `serve` without `port`; `port` without `serve`; `port auto` with no `{port}` in the command or an `env` value |
| E005 | a claim that cannot run: no steps, or a `then` before any `when` |
| E006 | a call its target does not take |
| E007 | a check of something its `when` does not observe |
| E008 | a matcher that does not fit its subject or its value |
| E009 | a pattern, or a JSON path, that does not parse |
| E010 | a command that cannot be split into words, or has none; `{port}` on a target without a port |
| E011 | a pin geas cannot keep for this target, or a time, a locale, a seed or a variable's name it cannot read; a pin a driver or Chrome refuses when the claim runs |
| E012 | another target's `when` between two actions on a replayed (pixie) target |
| E013 | an argument an action cannot take there: `into:` on pixie, `nth: 0`, `field: 0`, `advance(0)`, a path on pixie or `open()` after another action on it, a path without `/` in a browser, a browser action before `open`, a key geas does not send to a page |
| E014 | a wait geas cannot keep: `within` after an action on a screen, below 1 second or above 60, or on a `when` with no check under it (§18) |
| E030 | a program that could not be started |
| E031 | a `run` that did not finish within 5 s |
| E032 | a service that exited before opening its port, or did not open it within 5 s |
| E033 | an HTTP exchange that failed: refused, cut off, malformed, or no answer within 5 s |
| E034 | Chrome not found, not started, or not answering on its port |
| E035 | an action the app refused: no control of that name, pixie's exit 101, a driver's error |
| E036 | a page that did not load, or did not settle within 10 s |
| E037 | output of a GUI driver geas cannot read: pixie's trees, a protocol line, no answer within 5 s |
| E050 | no baseline: run `geas snap` first |
| E051 | a baseline that cannot be read |
| E060 | no record: run `geas map` first |
| E061 | a record that cannot be read, of another format version, or of another spec |
| E062 | a stale record: a file's hash on disk or in the diff is not the recorded one |
| E063 | added lines with only a record of the code before the change |
| E064 | a diff that cannot be read, or that fits neither side of the file on disk |
| E065 | a coverage converter missing or failing (`go tool covdata`, `llvm-profdata`, `llvm-cov`), or a coverage file that does not read |
| E066 | a service that stopped without writing its record: killed after 5 s of SIGTERM, or a Go program's metadata without counters |
| E080 | arguments the command does not take |
| E081 | a file that cannot be read or written |
| W060 | a target that gave no record at all in `map`, unless W061, E065 or E066 says why |
| W061 | a Rust profile from a program geas did not start itself |
| W901 | a key written in the spec, in a string or a comment alike (`check`) |

**W901 (2026-10-06).** A value in the shape of a key (an AWS access key ID, a
key or token of GitHub, Slack, Stripe, OpenAI, Anthropic or Google, a Slack
incoming webhook URL, a PEM private key) written in the spec, in a string or
a comment alike. `geas check` looks for them in the whole text of the spec
before it runs the claims, with ritsu-base's `secrets`, the rules every
language of ritsu looks with (ritsu's DESIGN 16.3); the claims run as they
always do, and a spec that does not parse is looked at too. The diagnostic
gives the kind of key, its prefix and its length, never the key, and quotes
no line, since the line holds the key. A value for tests is not reported when
a comment on the same line says `ritsu: test secret`. `--json` always has
`diagnostics` beside the claims, `[]` when the spec has no key, so that the
shape a reader reads does not change with the spec (the JSON of `map` carries
its warnings the same way). `snap`, `drift` and `map` do not look; they read
the same spec `check` does. The 9xx numbers mean the same check in every
language of ritsu (ritsu's DESIGN 16.2). Whatever else geas prints of the
spec or of the program's output — the line a diagnostic quotes, a message or
a note (the command of a target), a failed check and what it got, the run
that gets there, in the text and in the JSON — has every key in it masked to
its prefix and `…` (ritsu-base's `secrets::mask`). The journal and the
baseline under `.geas/` keep what the run did as it was, the arguments of
every call and what the program answered: a key a spec writes into a call,
or one a program prints, stays there in full.

### 13.1 What ritsu's base layer took over

geas now lives in ritsu, one workspace for seven small languages, and the
code it shared with the others is ritsu-base's and ritsu-testkit's (ritsu's
PLAN, C.6): the two languages of the prose (`Text`, `tr!`, `Lang`), the part
of a diagnostic every language has (code, severity, place, message, notes,
the text and the JSON), and finding the root (the nearest directory with a
`.git`). The sentences were turned from `t(en, ja)` into
`tr!("日本語", "English")` mechanically, and not one of them changed: every
golden file, in both languages, is what it was. What stays geas's is its
part of a diagnostic (the run that gets there, and the excerpt of the line
with its control characters made visible and cut to 120 characters), the
command line and its help (a hand-written page and a table of its own, as
rulec's), the explanations of the codes (each repro a command, files, an
exit code, the programs it needs), SHA-1 for git's blob ids, which files a
record covers, and the JSON reader.

The JSON reader stays because of what it reads: the output of the program
under test, whose numbers geas compares as floats and which may write them
with an exponent (`2e3`), and whose values geas writes back into baselines
and drift reports in its own form. ritsu-base's reader is rulec's, written
for JSON the suite's own tools print: it refuses an exponent and a key given
twice, and keeps integers exact. The tests read what geas prints with
ritsu-base's reader.

What a person or a program sees changed in four places:

- A diagnostic with no line, such as E081 for a file that is not there,
  now has `"line": null` and `"col": null` in its JSON, where it had `0`:
  a line number is not the way to say there is none, and every language of
  ritsu says it the same way. The golden file
  `tests/golden/en/errors/E081-missing.json` was written again. The report
  of `geas check --json`, which is geas's own format, still writes `0`.
- A diagnostic's JSON is written by ritsu-base, as serde_json writes JSON:
  a backspace or a form feed in a message is `\b` or `\f`, where geas
  wrote `\u0008` and `\u000c`. Both read as the same string.
- `RITSU_LANG` is read after `GEAS_LANG`, and a value such as `ja_JP` reads
  as Japanese.
- The tests' SKIP lines are ritsu-testkit's, `SKIP: geas: <reason>` on
  standard output, and their scratch directories are under the system's
  temporary directory, not under `target/`.

In the first part of ritsu's stage D, geas became a library and a command:
`src/lib.rs` holds every module, and `src/main.rs` is the command line over
it. A program that holds geas as a library reads a spec through ritsu's
ports (`src/ports.rs`; ritsu's DESIGN 3.2): the claims, each with the line
it starts on and its steps as written; the record `geas map` keeps, read
where `geas map` writes it when no `--out` says otherwise
(`.geas/<stem>.map.jsonl` beside the spec); and the claims as items, each
defined by its block (the `claim` line and its steps, each line without the
spaces around it, the blank lines and the comments left out). Nothing is
run. The command line, its exit codes, its output and its JSON did not
change.

In the last part of ritsu's stage D, the port of claims also answers what a
diff comes to for a spec's claims (`affected`): yuen follows the claims it
touches to the requirements that name them (yuen's DESIGN 8). The answer is
the one `geas affected` prints, from the same function (`answer_for`):
which side of the change each record is of, the claims the change touches
and the lines, the changed lines no claim runs, and the files deleted,
outside the source and of the spec. Reading a unified diff moved into
ritsu-base (`ritsu_base::udiff`), since yuen reads diffs too; what a side's
git blob is stays here (`src/diff.rs`). The command line, its exit codes,
its output and its JSON did not change.

In the first part of ritsu's stage E, the command moved from `src/main.rs`
into the library, as `geas::cli::run`: the binary of this crate calls it,
and so does `ritsu geas` (ritsu's DESIGN 8.2). For `ritsu check`,
`geas::cli::checked` runs `geas check` on a project's specs and hands back
what the command prints, a piece at a time (ritsu's DESIGN 8.3): a line for
each claim, from the same function (`report::claim`, which `report::claims`
now calls a claim at a time), the diagnostic of a claim that could not run
or of a spec that does not parse, each with its JSON as `--json` writes a
diagnostic, and the summary line. The claims run as `geas check` runs them,
and the journal is written beside the spec. The command line, its exit
codes, its output and its JSON did not change.

## 14. Distribution through the agent channel

The skill is the other half of §0's division of labor: the person reads
only the claims, and the agent arrives already knowing the CLI.

- `skills/geas/` is an Agent Skill (agentskills.io): `SKILL.md`, written
  by hand, and reference pages for the language, the commands, the codes
  (the output of `geas explain --all`), GUI targets, the record and
  `affected`, pins and parallel runs. The README links into these pages
  instead of repeating them, so each exists once.
- The binary carries the same files (`include_str!`). `geas skill` prints
  `SKILL.md`; `geas skill --install <dir>` writes the folder as
  `<dir>/geas/`, for a project's `.claude/skills` or a user's own skills
  directory; the first lines of `geas --help` say so, so an agent that has
  only the binary finds them. This is cute's `install-skills`, kept to the
  one open folder format. Writing into other tools' configuration (a
  Cursor rules file, a global `AGENTS.md`) was discarded: it can overwrite
  what a person wrote there.
- What the skill teaches is the loop and its rules. The agent writes the
  implementation, never the claims: a failing claim is fixed in the code
  unless the person says the claim is wrong. A claim the agent thinks is
  missing (code `affected` calls unclaimed, a change `drift` calls
  unclaimed) is proposed to the person as text, to be read and accepted,
  not written in. A `mask` line and a re-snap change what the claims
  promise or what drift compares against, so they are asked for too. The
  skill also says how to run each command and read each answer, and what
  each language needs for `map`.
- `tests/skill.rs` holds the skill to the tool: the copy in the binary is
  the folder; every command and flag it names is in `geas --help`; every
  code it names exists; every `geas` block in it parses; the frontmatter
  has `name` and `description`; no link leaves the folder.

The frontmatter's `license` field is the repository's, MIT OR Apache-2.0
(§5, item 4).

As built:

- The folder has seven pages: `SKILL.md` (when it applies, who writes
  what, the loop, the language on one page, reading the answers, from a
  diagnostic to a fix, what `map` needs from each language, GUI targets,
  pins and `--jobs`, what to ask the person), `language.md`,
  `commands.md`, `codes.md`, `gui.md`, `map.md` and `examples.md`. Pins
  and parallel runs have no page of their own: they are in `language.md`,
  `commands.md` and the SKILL's §8, where an agent looks for them.
  `skills/README.md` says how to install the folder, and how to point an
  agent that does not read Agent Skills at `geas skill` from its own
  instructions file.
- Every page but `codes.md` is written by hand. `skills/sync.sh` writes
  `codes.md` from `geas explain --all`; the geas it runs is `$GEAS`, else
  `target/debug/geas`, else the one on PATH.
- `geas skill --install <dir>` makes the directories it needs and prints
  `wrote the geas skill to <dir>/geas (7 files)`. A `<dir>/geas` that is
  already there is E081 unless `--force` is given; with it, the skill's
  files are written again and any other file in the folder is left as it
  is, since geas does not remove what it did not write. A file given to
  `skill`, and `--force` without `--install`, are E080. `--help` names
  `geas skill` on its second line.
- The frontmatter has `name`, `description` and `compatibility` (the
  binary, and what each optional feature needs).
- What §14 gave `tests/skill.rs` is split in two. `tests/skill.rs`:
  `geas skill` prints `SKILL.md`; what `--install` writes is the folder,
  file for file and byte for byte; the refusal and `--force`; `codes.md`
  is what `sync.sh` writes; no link leaves the folder; the frontmatter;
  and the driver `gui.md` gives runs, with the claims it gives for it.
  `tests/docs.rs`, over README.md, README.ja.md, `skills/README.md`, the
  skill's pages and the examples' READMEs: every block fenced `geas`
  parses, one that starts as an example's claims file is that file as it
  is, and one with `…` lines is an excerpt of a claims file in the
  repository; every console block that shows output, and every text
  block that starts as geas's output does, is lines of a golden file,
  `$ ` lines included, with `…` standing for lines left out; every code
  named is in the table, and the count of codes the READMEs give is the
  table's; every geas command and option named is in `geas --help`; every
  relative link leads to a file. Whether a block parses is asked of
  `geas drift --json`, which reads the spec and, with no baseline, stops
  at E050 before running anything.
- `tests/readme.rs` runs the stories the READMEs tell, as they tell them,
  and keeps each transcript as a golden under `tests/golden/{en,ja}/readme/`:
  the `$ ` lines a person would type, what each command printed, and the
  exit status as `$ echo $?` where the page shows it. The greeter's story
  runs in a git repository of its own, so `git diff | geas affected …`
  runs as it would in a project; a step the test takes itself (writing an
  agent's change into a file) has no `$ ` line, and the page says it in
  words.
- English counts in reports are singular for one (`1 claim`,
  `1 changed line`), where every count was plural before; the summary of
  `affected` is on the README.
- `examples/web-greeter` pins `locale`, `tz`, `clock` and `seed`, since its
  page writes the date, greets by the hour and draws a fortune with
  `Math.random`; with them, its claims name the exact date and greeting,
  and drift on the unchanged page is quiet. Its change in
  `tests/changes/web-greeter/` turns the Greet button into a `<div>` with a
  click handler, which the screen shows as `text "Greet"` and the claims
  refuse. `examples/pixie-greeter` holds no app: the greeter is a 57 MB
  binary, so its README says how pixie builds it, and `.gitignore` keeps
  `examples/pixie-greeter/greeter` out. `examples/greeter/greeter.ja.geas`
  is the greeter's claims named in Japanese, which README.ja.md runs with
  `--lang ja`.

## 15. Still out, and why

- **Windows.** The process and service adapters rest on Unix behavior:
  SIGTERM to ask a service to stop, and so to write its record (§7.3).
  Windows has no SIGTERM, and stopping a console program gracefully goes
  through console events. Nothing here can run Windows to check it, and an
  adapter that has not been run is not offered.
- **Claim dependencies.** Each claim builds its own state with its own
  `when`s and can be run and replayed alone (§2). A claim that relied on
  another's run would make one verdict depend on another, would break the
  isolation `--jobs` (§10) and the per-claim record (§7.3) rest on, and
  would hide from the reader the setup that makes the claim true.
- **macOS accessibility.** The permission (§8.5).
- **Recording pixie apps and page scripts.** A pixie app is Rust that pixie
  generated, and its lines are not the `.pix` source anyone wrote. A
  page's scripts could be recorded through the DevTools Protocol in the
  same V8 shape as Node's, but the URLs a page loads do not name files
  under the root; joining them needs a declared mapping, or source maps
  for bundled code. Both wait for that mapping to be designed.
- **Choosing an option** (`select`). pixie's choosers and a browser's
  native `<select>` popup act differently enough that one action for both
  needs a design of its own; until then a select is reached with `press`.
- **A library through an FFI harness** (§3). A library is claimed through a
  small command or service around it, which the agent writes.
- **Requests to pixie** that would make its driver exact: an escaped
  accessibility dump (or a JSON one), and the refusal's message on stderr
  when an app runs a script by itself. Until then, the test of E037 on a
  pixie app is the reader's unit tests: no app prints a tree geas cannot
  read.
- **A program that leaves its process group** (a daemon calling `setsid`)
  is out of reach of the group geas stops (§10.1). Following it would mean
  walking the process table, which §10.1 discarded.
- **A Chrome that dies in the middle of a run** is not started again: the
  later claims of that worker end in E034, which says so. Whether a new
  one should take over, and what that would say about the claims the old
  one ran, is not settled.
- **What is inside an `iframe`.** Chrome reports the frame as one node,
  and geas does not read the tree of the document in it.
- **A literal `{port}`.** On a `port auto` target, the port in what a
  service answers is written `{port}` (§10), so a service that prints
  `{port}` itself cannot be told from one that prints its address, and a
  `content-length` no longer counts a body the port was replaced in. There
  is no way yet to write either.
- **A `map` stopped by a signal** leaves `.geas/hook/` and `.geas/cover/`;
  the next `map` removes them.
- **A file the change adds, with only a record of the code after it.**
  `git diff` leaves out files git does not track, so `affected` does not
  see them; `git add -N` puts them in the diff, and the skill says so. A
  record of the code before notices such a file (E062).
- **Naming and publication** stay with the owner (§5, item 4). The
  license is chosen: MIT OR Apache-2.0, in `Cargo.toml`, LICENSE-MIT and
  LICENSE-APACHE, the READMEs and the skill's frontmatter.

## 16. Measured to settle the design (2026-10-02, macOS arm64)

Each was run once, small, in a scratch directory outside the repository;
none of it is geas code.

- The spike, built with rustc 1.94.1, ran both examples as the README
  shows, and drift on the unchanged greeter was quiet (exit 0).
- Python 3.14.6: a `sitecustomize.py` on `PYTHONPATH` recorded the lines
  `calc.py` ran (`1 / 0` ran the division branch and not the others), and
  the lines a service ran before SIGTERM; the service exited with 143 and
  the record was written.
- Node v23.11.0: a `.ts` command run with `NODE_V8_COVERAGE` gave ranges
  that fall on the `.ts` file's lines; a service killed by SIGTERM wrote
  nothing; with the SIGTERM hook preloaded through `NODE_OPTIONS`, a
  service with no handler of its own and one that closes gracefully both
  wrote theirs.
- Go 1.25.5: `go build -cover -trimpath`, `GOCOVERDIR`. A command wrote
  its counters, and `go tool covdata textfmt` gave its blocks, in import
  paths; a service killed by SIGTERM left only `covmeta`; one that stops
  through `signal.NotifyContext` and `Shutdown` wrote both. The probes
  grew the Go build cache by about 0.2 GB.
- Rust 1.94.1: `rustc -C instrument-coverage` and `LLVM_PROFILE_FILE`. A
  command ending in `std::process::exit(1)` wrote its profile, and
  `llvm-profdata` and `llvm-cov export -format=lcov` from rustup's
  llvm-tools turned it into lines.
- SHA-1 written in standard-library Rust gave
  `ce013625030ba8dba906f756967f9e9ca394464a` for `blob 6\0hello\n`, which
  is what `shasum` gives for the same bytes.
- Chrome 154.0.8037.93, headless, driven by a standard-library Rust client
  (WebSocket, SHA-1, base64, and geas's JSON module): it started Chrome,
  connected, loaded a page, typed into a field, clicked a button found by
  its accessible name, and read the tree before and after, in 372 to
  543 ms all told, Chrome being up in 161 to 234 ms. Over one socket with
  a session per page: a browser context per claim kept storage apart; a
  seeded `Math.random` gave the same number in two contexts; virtual time
  started at 2026-08-29T00:00:00Z read 00:00:01.000Z after one budget;
  `de-DE` printed 1234.5 as `1.234,5`; Enter submitted a form; the launch
  and two claims took 653 ms.
- pixie: a greeter built with a newer pixie kernel than the one in pixie's
  own tree, run with `PIXIE_SCRIPT="input:Ada,click:greet"`, printed its
  starting and final trees in 32 ms; `a11y` printed the accessibility
  tree; a step naming a missing button exited with 101, printed nothing
  on stderr, and lost the trees of the `a11y` steps before it;
  `PIXIE_DUMP` moved the transcript into a file; `\,` carried a comma into
  a field, and the tree printed it without escaping.

Measured while building stage B (2026-10-03, the same machine):

- Homebrew's Python 3.14.6 has a `sitecustomize.py` of its own, which adds
  Homebrew's `site-packages`; geas's hook runs it after starting the
  monitor.
- Node v23.11.0 named a CommonJS script in its coverage by its plain path
  and an ES module by a `file://` URL. Its `http` module answered with
  `Transfer-Encoding: chunked` when the handler set no `Content-Length`.
- Go 1.25.5: a binary built with `-cover` and run without `GOCOVERDIR`
  printed `warning: GOCOVERDIR not set, no coverage data emitted`. With a
  fresh build cache, building the tally service took 2.9 s and the first
  `go tool covdata` 1.8 s, and the cache grew to 157 MB; the tests keep it
  in their scratch directory. A service left to Go's default handling of
  SIGTERM wrote `covmeta` and no counters.
- Rust 1.94.1: an instrumented binary run without `LLVM_PROFILE_FILE`
  wrote `default_<signature>_0_<pid>.profraw` into its working directory.
  `llvm-cov export` given a program without coverage data refused the whole
  export (`no coverage data found`), and given macOS's `/bin/sh`, a
  universal binary, asked for `-arch`.

Measured while building the first half of stage C (2026-10-03, the same
machine):

- Node v23.11.0 names a coverage file `coverage-<pid>-<ms>-0.json`, the
  last number staying 0. `v8.takeCoverage()` followed at once by
  `process.exit()` wrote two files 1 ms apart, the second without the
  script's counts; in 33 runs of 60 they fell in one millisecond and the
  second replaced the first. `process.exit(143)` alone, from a SIGTERM
  handler, kept the counts in 30 runs of 30, and so did a program shutting
  down through a handler of its own.
- `(a|aa)*b` against 30,000 `a`s finishes within the unit test's bound of
  one second, in the debug build; a backtracking engine takes time
  exponential in the length on it.
- The suite, with every tool present: 181 tests (94 unit tests, 87 that
  run the binary), no SKIP, 50 s.

Measured while building the second half of stage C (2026-10-03, the same
machine):

- Chrome 154.0.8037.93 gave names to `InlineTextBox` (the text again),
  `ListMarker` (`• `, `1. `) and `LineBreak` (a line break), and reported a
  closed `<select>`'s list as `MenuListPopup`, a header and a footer inside
  `<main>` as `sectionheader` and `sectionfooter`, an `<img>` as `image`,
  `checked` as a property `true` or `false`, and `disabled` as one `true`.
  A typed field held the text twice, as its value and as a `StaticText`
  under a `generic`.
- `Page.navigate` to a path the service never answered was not answered
  either, until the 10 s ran out. To a service that closed every connection
  at once, Chrome answered with an `errorText` that changed from run to run
  (`net::ERR_SOCKET_NOT_CONNECTED`, `net::ERR_EMPTY_RESPONSE`); an answer
  with two `Content-Length` headers gave
  `net::ERR_RESPONSE_HEADERS_MULTIPLE_CONTENT_LENGTH` in each of four runs,
  so the tests and E036's repro use that.
- Chrome started eleven processes (helpers, renderers), all in its process
  group; killing the group left no process naming the profile.
- An unpinned page on this machine, whose language is Japanese, printed a
  date as `2026年10月3日 3:14`; with `locale "de-DE"`, `tz "Asia/Tokyo"` and
  `clock "2026-08-29T00:00:00Z"` it printed `29. August 2026 um 09:00` and
  `1.234,5`, and with `seed 7`, `Math.random()` was `0.000441` (to six
  places) in every claim.
- pixie's greeter, given a field's text with a line break in it, printed
  its accessibility tree across two lines; it refused `key:enter` (exit
  101), having nothing bound to it, and took `submit`.
- The suite, with every tool present and `GEAS_PIXIE_GREETER` naming
  pixie's greeter: 215 tests (117 unit tests, 98 that run the binary), no
  SKIP, 72 s, of which `tests/browser.rs` took 14 s (10 s of them a page
  that never loads). Without the greeter, the two tests of `tests/pixie.rs`
  print SKIP and nothing else does.

Measured while building stage D (2026-10-03, the same machine):

- The suite, with every tool present and `GEAS_PIXIE_GREETER` naming
  pixie's greeter: 236 tests (119 unit tests, 117 that run the binary), no
  SKIP, 79 s; `tests/readme.rs`, which records the READMEs' transcripts,
  took 6 s of it. Without the greeter, four lines say SKIP: the two tests
  of `tests/pixie.rs` and the two pixie stories of `tests/readme.rs`. The
  release binary is 2,327,616 bytes, and a release build from nothing took
  2.7 s.
- `examples/web-greeter`'s six claims ran in 1.8 s, Chrome's start and stop
  included, and drift on the unchanged page was quiet across runs with
  every pin; `examples/pixie-greeter`'s four ran in 0.04 s.
- A `.gitignore` of `**/.geas/*` and `!**/.geas/*.baseline.jsonl` left git
  seeing the baseline and nothing else of `.geas/`. The pull-request recipe
  of `skills/geas/map.md`, run in a scratch repository with a branch
  holding the greeter's change (a record of the base made with `--out`,
  then one of the branch), named the claims and lines of the report
  `tests/languages.rs` keeps for two records.
- Twelve deliberate breakages of the pages each failed the test that
  should catch them (PLAN.md, D6).

## 17. OpenSpec's scenarios: `geas scenarios`

Added 2026-10-05. OpenSpec (Fission-AI's `@fission-ai/openspec`; this
section read the documents and the source of 1.14.0, published
2026-09-30, and ran it here) keeps a project's behavior as specs in
Markdown: `openspec/specs/<capability>/spec.md` holds requirements
(`### Requirement: <name>`, a sentence with SHALL or MUST), and each
requirement holds scenarios (`#### Scenario: <name>`, a list of GIVEN,
WHEN and THEN). A change is proposed as a folder under
`openspec/changes/<id>/`, with its deltas to the specs (`## ADDED`,
`## MODIFIED`, `## REMOVED`, `## RENAMED Requirements`), and
`openspec archive` applies them.

A scenario is an acceptance case in prose: a person and an agent agree on
it before the code is written. It is the generation half of §0, and a
claim is the acceptance half: the same case, run against the program.
What OpenSpec does not say is whether each scenario is run by anything.
Its `/opsx:verify` has an agent read the code against the specs, which
need not give the same answer twice.

**Decided**: `geas scenarios <spec.geas>... --openspec <path>...` reads
the scenarios of the specs given and the claims of the claims files
given, and says, for each scenario, which claims answer it, and which
scenarios no claim answers. A claim answers a scenario when its name is
the scenario's name. `--draft` writes, for the scenarios no claim
answers, a claim to fill in. geas runs nothing for it.

### 17.1 A claim answers a scenario by its name

The claim's name and the scenario's name are compared as written, case
and spaces included: `claim "rejects an empty name"` answers
`#### Scenario: rejects an empty name`. The scenario's name is
OpenSpec's own: the header's text without a closing run of `#` and
without a leading `Scenario:`, trimmed (OpenSpec's
`scenarioNameFromHeaderText`).

Why the name:

- The claims file stays as it is. A claim already reads aloud as the
  case it holds ("rejects an empty name"), and OpenSpec's guide asks for
  scenario titles of the same kind ("Name the case in the title.
  'Scenario: Rejects an expired token'"). Nothing new is learned, and the
  language does not grow a reference into another tool's tree.
- Nothing goes quiet. A scenario renamed in the spec, or a claim renamed
  in the claims file, shows as a scenario no claim answers and a claim no
  scenario names, and the exit status is 1 until they meet again. A name
  that differs only in case or in its spaces is pointed out as the near
  one (the fold OpenSpec uses for its own near misses).
- One claim may answer scenarios of two requirements that share a name,
  and the report shows it under each; several claims files may answer one
  spec.

Discarded:

- A line in a claim naming its scenario (`covers "openspec/specs/…" "…"`).
  It ties the claims file to a path in another tool's tree, says again
  what the name already says, and adds a form to a language kept small
  enough to read aloud (§0).
- Steps guessed from the scenario's prose (`WHEN a client asks for
  /greet?name=Alice` → `when api.get("/greet?name=Alice")`). What a
  sentence means is for the person who reads it; a guessed step that
  looks right and runs the wrong thing is worse than none, and §0 keeps
  the agent from writing the claims.
- Running OpenSpec's CLI (`openspec show --json`). It would need Node and
  OpenSpec wherever geas runs. The format is fixed and its reader is
  published; ritsu's base layer reads it as OpenSpec does
  (`ritsu_base::openspec`, which yuen also reads), and its tests hold it
  to what OpenSpec 1.14.0's own reader makes of the same files.

### 17.2 What it reads

- A spec (`openspec/specs/<capability>/spec.md`): every scenario of every
  requirement.
- A delta spec (`openspec/changes/<id>/specs/<capability>/spec.md`): the
  scenarios of its ADDED and MODIFIED requirements, the ones the change
  asks for. A REMOVED or RENAMED entry has no scenarios to run.
- A directory: every `spec.md` under it, in path order, except under a
  directory named `archive` (OpenSpec's archived changes). So
  `--openspec openspec/specs` is the whole behavior, and
  `--openspec openspec/changes/trim-names` is one change.

A scenario is a `#### ` header of a requirement with something under it
(OpenSpec drops one with an empty body, and so does geas), and a line in
a fenced code block is never a header. A file that reads as neither a
spec nor a delta spec (no `## Requirements` and no delta section, two
requirements of one name, or not UTF-8) is E090; one that cannot be read
is E081.

### 17.3 What it says

The scenarios come in the order of the files, the requirements and the
scenarios, each with the claims that answer it (their numbers in their
files, and where they are), or `no claim`, with a near name when there
is one. The claims no scenario names follow, as information; the last
line counts. The exit status is 0 when every scenario has a claim, 1
when some scenario has none, and 2 when a file or an argument is wrong.
`--json` gives the same as one object.

The claims no scenario names never change the exit status: a claims file
may hold claims for what no spec says (the greeter's `/health` before
anyone wrote a scenario for it), and when only a change is read, the
claims of the rest of the behavior are not its business.

### 17.4 `--draft`

For each scenario no claim answers, `--draft` prints a claim to fill in:
comments that quote the spec, the requirement and the scenario's lines,
then `claim "<the scenario's name>" { … }` whose body is only comments.
geas refuses such a claim (E005: the claim has no steps, and a claim
starts with a `when`), so a draft pasted and left as it is fails `geas
check` instead of passing.
A person writes the steps, or reads the ones an agent proposes; the
draft only saves copying the names and keeps the scenario beside the
claim.

### 17.5 Scope, and what is still out

In: the command, its text and JSON, `--draft`, E090, the skill's pages
(SKILL.md and commands.md), the tests (the greeter's spec in OpenSpec,
in English and in Japanese, under `examples/greeter/openspec/` and
`examples/greeter/ja/openspec/`, with a change that asks for two
scenarios no claim answers; a claims file with a near name), README.md
and README.ja.md, whose story `tests/readme.rs` runs.

Out:

- Spec Kit's acceptance scenarios and Kiro's EARS criteria. Their forms
  are looser (numbered sentences in a user story); when a form is fixed
  and published, it can be read the same way.
- Holding the claim's steps to the scenario's sentences. That is what a
  person reads (§0).
- Following OpenSpec's stores (other repositories a project's
  `config.yaml` references). Only the paths given are read.

### 17.6 As built (2026-10-05)

Read on 2026-10-05: OpenSpec 1.14.0 from npm
(<https://registry.npmjs.org/@fission-ai/openspec>, `dist-tags.latest`, published 2026-09-30),
installed with `npm install --prefix` in a scratch directory and run; its source at the tag
`v1.14.0` of <https://github.com/Fission-AI/OpenSpec> (`docs/concepts.md`, `docs/cli.md`,
`docs/writing-specs.md`, `docs/agent-contract.md`, `src/core/parsers/`).

The reader is ritsu-base's `openspec` module, which yuen reads too; its
tests hold it to `expected.json`, what OpenSpec 1.14.0's own readers
(`extractRequirementsSection`, `MarkdownParser`, `parseDeltaSpec`, run
with node over the npm package) make of the same files: fenced headers,
closing `#` runs, CR LF and a byte order mark, a level-4 header without
`Scenario:`, a scenario with no body, `*` and `+` bullets in RENAMED and
REMOVED, a `FROM:` with no `TO:`. The greeter's spec and its change pass
OpenSpec's `openspec validate --all`; the Japanese spec needs `SHALL` or
`MUST` in its sentences, as OpenSpec's validator asks for those words.

The story the READMEs tell, as `tests/readme.rs` records it:

```
$ geas scenarios examples/greeter/greeter.geas --openspec examples/greeter/openspec/specs
examples/greeter/openspec/specs/greeting/spec.md
  Greeting by name
    greets by name: claim 1 of examples/greeter/greeter.geas (line 14)
    rejects an empty name: claim 2 of examples/greeter/greeter.geas (line 20)
  Running total
    totals accumulate across requests: claim 3 of examples/greeter/greeter.geas (line 25)
  Unknown paths
    unknown paths are 404: claim 4 of examples/greeter/greeter.geas (line 34)
claims no scenario names: none
4 scenarios · 4 with a claim · 0 with none
$ geas scenarios examples/greeter/greeter.geas --openspec examples/greeter/openspec/changes/trim-names
examples/greeter/openspec/changes/trim-names/specs/greeting/spec.md (a change's delta spec)
  MODIFIED Greeting by name
    greets by name: claim 1 of examples/greeter/greeter.geas (line 14)
    rejects an empty name: claim 2 of examples/greeter/greeter.geas (line 20)
    rejects a name of spaces: no claim
  ADDED Health check
    answers the health check: no claim
claims no scenario names:
  "totals accumulate across requests": claim 3 of examples/greeter/greeter.geas (line 25)
  "unknown paths are 404": claim 4 of examples/greeter/greeter.geas (line 34)
4 scenarios · 2 with a claim · 2 with none
$ echo $?
1
$ geas scenarios examples/greeter/greeter.geas --openspec examples/greeter/openspec/changes/trim-names --draft
# examples/greeter/openspec/changes/trim-names/specs/greeting/spec.md
# Requirement: Greeting by name
#   Scenario: rejects a name of spaces
#   - **WHEN** a client asks for `/greet?name=%20%20`
#   - **THEN** the status is 400
claim "rejects a name of spaces" {
  # when <target>.<call>(…)
  # then <subject> <matcher>
}

# examples/greeter/openspec/changes/trim-names/specs/greeting/spec.md
# Requirement: Health check
#   Scenario: answers the health check
#   - **WHEN** a client asks for `/health`
#   - **THEN** the status is 200
claim "answers the health check" {
  # when <target>.<call>(…)
  # then <subject> <matcher>
}
```

A claim pasted from the draft as it is gives E005 (the claim has no
steps, and a claim starts with a `when`), and `geas check` exits 2
(`tests/scenarios.rs`).

## 18. Waiting for an answer: `within`

Added 2026-10-28. A service that hands its work to a queue or a workflow
answers before the work is done: a `post` is accepted with 202, and what it
started finishes later, in another process. A claim about the result has
to read it once it is there, and geas had no way to say so: a `when` made
its call once, and its checks read that answer. The way left was a switch
in the service that holds its answer until the work settles
(`?wait=10`): a parameter that exists only to be checked, and a service
that decides for the claim what "settled" means.

**Decided**: `within <n> seconds` after a `when`'s call. geas makes the
call again, 100 ms after each answer, until every check under that `when`
holds on one answer, for up to `n` seconds from the first call.

```geas
claim "a job finishes in the background" {
  when api.post("/jobs", body: "12")
  then status is 202
  when api.get("/jobs/1") within 3 seconds
  then body json ".state" is "done"
  and  body json ".result" is 144
}
```

### 18.1 What a wait does

- `n` is a whole number from 1 to 60, and `second` and `seconds` are both
  read, whatever the number. A minute is the most one step waits: ten times
  the 5 s geas gives a call, enough for a workflow that takes a few seconds
  to start on a slow CI machine, and a broken claim still stops within a
  minute. The interval, 100 ms between an answer and the next call, is not
  written: it changes the load and the delay, not what a claim means, and an
  interval or a longer limit can be added later without changing a claim.
- The checks a wait holds each answer to are the `then` under its `when`
  and the `and`s after it, up to the next `when` or the end of the claim:
  the group drift counts as claimed. On the first answer all of them hold
  on, the step ends with that answer: the checks read it, the journal and
  the baseline write it, and the claim goes on. When the time runs out,
  the step's answer is the last one: the checks read it, those that do not
  hold fail the claim, and the claim goes on, as after any failed check
  (§2). Two checks that never hold on one answer wait out the time.
- The clock starts right before the first call: the start of a service the
  wait is the first to use (up to 5 s for its port) is not counted. No call
  starts after the time has passed; the last one starts by then at the
  latest, and each call keeps its own limit (E031, E033), so a step takes
  at most `n` + 5 seconds.
- On a `run` target, each call starts the command again, with the same
  pins. On a `serve` target, each call goes to the claim's instance, which
  is not started again. A `clock` pin hands every call the same time, so
  a wait is for work that goes on elsewhere (a job in the background, a
  workflow, another process), never for the pinned clock to move.
- A call that fails (E030 to E033) ends the claim at once, as in any
  `when`, with a note saying which call of the wait it was and how long
  after the first. An error is not an answer: calling again would hide for
  up to a minute that the service fell over, and leave open which of its
  errors to report.
- A signal ends a wait as it ends any run (§10.1): geas kills the
  programs it started and exits with 128 and the signal's number. When it
  has Chrome's profiles to remove first, and so goes on for a moment, the
  wait makes no call after the signal.
- `get`, `post` and `run` can wait. `post` can because an RPC-style API,
  such as Connect, calls its reads with POST too; `run`, because a command
  may be how a state is read. A call is made again as it is written, so
  whatever a `post` or a `run` does is done again on every call; the
  language guide and the skill say so, and a claim waits on a read where
  there is one.
- A screen action cannot wait (E014). A screen settles after each action
  (Chrome in virtual time, §8.4; pixie by its replay, §8.3), `advance(ms)`
  gives it more time, and a `click` made again would be a second click.

### 18.2 What it writes

- `check`: a claim whose waits held prints as before, with nothing of how
  many calls they took. A wait whose time ran out prints a line above the
  checks it failed, with how many calls geas made and how long they took,
  and the run that gets there writes the `when` with its `within`:

  ```text
  not ok 2 - a job finishes in the background
      examples/jobs/jobs.geas:20: within 3 seconds — no answer held every check under it (30 calls in 3.0 s); the checks below read the last one
          20 |   when api.get("/jobs/1") within 3 seconds
      examples/jobs/jobs.geas:21: body json ".state" is "done" — got "finished"
          21 |   then body json ".state" is "done"
  ```

- The journal's `when` event gets `"within":n`, and, only when the time
  ran out, `"tries"` and `"ms"`. An entry of `--json`'s `run` is the same,
  and has them too when a call failed while waiting, a step the journal
  writes as an `error` event, as for any `when`. The journal is made to be the same bytes over two runs of the same
  behavior, and with `-j1` and `-j4` (§10, PLAN appendix A; a masked value
  is written `<masked>` for the same reason). How many calls a wait made
  and how long they took changes from run to run, so writing them when the
  wait held would break that for every spec that waits. A wait that ran
  out is a failure, and its calls and its time are the evidence of it.
  Same behavior, same bytes, weighs more than a count in every record.
- `snap` writes the step's answer. The baseline's `call` does not hold the
  wait, so adding or removing `within` does not ask for a new snap by
  itself.
- `drift` waits as `check` does and compares the step's answer; when a
  wait's time ran out, a note says so, and that the answer compared is the
  last one.
- `map` waits as `check` does. Each process of a `run` that waits writes
  its coverage into a directory of its own (§7.3), and the lines any of
  them ran are the claim's.
- `--jobs`: a claim that waits keeps its worker, its fixed port and its
  `serial` target while it waits; other claims run beside it.

### 18.3 What it refuses

| Written | Code |
|---|---|
| `within` after an action on a screen | E014 |
| `within 0 seconds`, `within -5 seconds`, `within 90 seconds` | E014 |
| `within` on a `when` with no check under it: nothing to wait for | E014 |
| `within 1.5 seconds`; `within seconds`; `within 10` with no unit | E001 |
| `within 1 minute`, and any unit but seconds | E001, with a note: seconds, up to 60 |
| `within` after a check | E001, or E002 after a node (a state geas does not know), with a note on where `within` goes |
| `then eventually …` | E002 (a subject geas does not know), with a note pointing at `within` |

`eventually` is not given E014: the unknown name is what is wrong, and the
note leads to the form. A wait with no time written has no form at all.

### 18.4 Forms passed over

1. `then eventually <check> within 10 seconds`, on one check. What waits
   is the step, all its checks on one answer; with `and`s after it, which
   of them `eventually` covers does not read, and the call made again hides
   inside a check.
2. A block, `within 10 seconds { when … then … }`. It brings nesting into
   a flat grammar, and with two `when`s inside it would make a series of
   calls again, a `post` among them.
3. A default wait on a target (`target api { wait 10 seconds }`), an
   option (`--wait 10`), or waiting on every step. A claim would no longer
   say which of its steps is asynchronous, every failing check would take
   the time, and with an option a claim would mean something else run
   another way.
4. A wait with no time, or no limit.
5. An interval to write (`every 500 ms`), or one that grows. Not needed
   yet; either can be added without changing a claim.
6. Minutes and milliseconds. The limit is a minute, and a wait does not
   need finer steps than a second.
7. Calling again after an error (18.1).
8. Every call of a wait in the journal: their number changes from run to
   run, and the journal would no longer be the same bytes.
9. A wait on a screen action (18.1).
10. Leaving it to the service's own `?wait`: the trigger itself.

### 18.5 Checked, and not fixed

`tests/wait.rs` runs waits against `tests/impl/later.py`, a service whose
answers change after a number of calls or a while, and `tests/impl/tick.py`,
a command that prints `done` from its third run: a wait holds on the third
answer, and on the fourth when two checks have to hold on one; a job posted
before is waited for; the journal and the baseline keep the answer the wait
held on, with no count; a wait that runs out fails on the last answer and
the claim goes on; an error on the third call ends the claim at once; drift
on the same service is quiet, and says so when the time runs out; `-j1`
and `-j4` write the same bytes; SIGINT ends a minute's wait at once, and no
service is left. `tests/specs/` holds a spec for every refusal of 18.3;
`examples/jobs` is the story of the READMEs, with an agent's change under
`tests/changes/jobs/`. The calls a wait made and the time they took are
held in the goldens as `<n>` and `<t>` (`steady` in `tests/common`), and
`tests/docs.rs` compares the pages through the same function, so a page
shows a run as it printed.

Not fixed:

- `map`'s record of a claim that waits can differ from run to run: how
  many calls it took decides which lines ran (a branch for "still
  running", say). A line ran in one run and not in another is either the
  claim's or unclaimed; neither hides a change from `affected`.
- When the checks hold on the first answer, the claim does not wait, and
  it may end, and its service be stopped, while work it started is still
  running. A service holding its answer until the work settled never let
  that happen; a claim that has to see the work finish says so with a
  check.
- A screen that needs more than its settling and `advance(ms)` has no
  wait.
- geas's calls have no request headers yet; one added would be made again
  with the call.

## 19. The object of a spec that `ritsu check` hands on (2026-10-31)

Each item of `files` in `ritsu check --format json` has `check`: the object
the file's own language writes of it as JSON (ritsu's DESIGN 8.3). For a
spec, `geas::cli::checked` hands on, beside the lines it gave before, the
object `geas check --json` prints of the spec (`ritsu_ports::Checked::json`),
from the same functions: `report::claims_json_with` for a spec that runs,
with the keys the spec holds (W901) as its `diagnostics`, as the command
writes them; and `report::failure_json` for a spec that does not parse
(with those keys and the parse's diagnostics) and for a file that cannot be
read (with its E081), as the command prints it before it exits 2. ritsu
takes out `file` and `diagnostics` and keeps the rest:
`{"geas": 1, "ok": …, "claims": […]}`, or `{"geas": 1, "ok": false}`. The
claims still run once, and the command line, its exit codes, its output and
its JSON did not change.

Checked: `each_files_check_in_the_json_is_what_its_language_writes` in
ritsu's `tests/check.rs` gives the copy of `examples/calc` to `ritsu check`
(its claims run Python), and holds the `check` of `calc.geas` to what
`ritsu geas check calc.geas --json` prints, less those two keys.