Coding Agents (--format=agent)

PhpSpec speaks a machine-readable dialect designed for coding agents (Claude Code, Cursor, or any script that shells out and parses output). Instead of the ANSI-decorated prose the human formatters produce, --format=agent emits JSON Lines: one self-contained event per line, written as it happens, where the failure itself is the payload: everything an agent needs to decide its next move, without re-reading the project.

The same idea runs through the scaffolding commands: describe --format=agent and exemplify --format=agent return JSON receipts instead of prose, and --accept-offers applies PhpSpec's code-generation offers non-interactively. Together they let an agent drive the whole spec → red → green loop through the CLI, never guessing at output and never hand-editing what PhpSpec can generate.

Just want the CLAUDE.md snippet? Jump to Teaching an agent to use PhpSpec.

Running

$ vendor/bin/phpspec run --format=agent

Standard output carries one JSON object per line, with no ANSI and no prose. Decode a line, act on it, decode the next: on a long suite the first failure reaches you while the rest is still running.

It is --parallel-safe: a worker reports every result back over PhpSpec's own wire, so an entry from a parallel run carries the same detail as one from a single process, expectation, output, spec and rerun included. Entries arrive in completion order rather than file order.

The stream

A run of three examples (one passing, one failing, one erroring on a missing class) produces four lines:

{"v":2,"event":"run_started","suite":"default","seed":null,"php":"8.3.16","coverage":false,"guard":"off"}
{"v":2,"event":"example","id":"6fd046add251","example":"App\\Basket > totals the prices of its products","state":"failing","expectation":{"matcher":"toBe","expected":4000,"actual":3500,"negated":false},"message":"Expected 3500 to be 4000","spec":"spec/App/Basket.spec.php:6","rerun":"run spec/App/Basket.spec.php:6"}
{"v":2,"event":"example","id":"66b1647a77b6","example":"App\\Basket > applies a coupon","state":"error","message":"Class \"App\\Coupon\" not found","exception":{"class":"Error","message":"Class \"App\\Coupon\" not found","at":"spec/App/Basket.spec.php:8"},"spec":"spec/App/Basket.spec.php:8","rerun":"run spec/App/Basket.spec.php:8","offer":{"action":"create_class","target":"App\\Coupon"}}
{"v":2,"event":"summary","examples":3,"scenarios":0,"steps":0,"passing":1,"failing":1,"errors":1,"pending":0,"skipped":0,"risky":0,"warnings":0,"deprecations":0,"notices":0,"actionable":2,"duration_ms":4,"offers":[{"action":"create_class","target":"App\\Coupon"},{"action":"fake_method","target":"App\\Basket::total","value":"4000"}]}

Every line carries "v", the agent-protocol version (currently 2; version 1 was a single JSON object), and an "event" naming its kind:

eventWhenMeaning
run_startedalways, firstThe run began: what it targets and the seed it was shuffled with.
exampleper entryOne example or scenario that needs attention, the moment it is known.
fatalwhen the run diedWhat stopped it; the counts describe only what ran before it.
summaryalways, lastThe totals, and the one number to branch on.

The order is guaranteed: run_started first, summary last, whatever happened in between. A run that never started still emits both.

run_started: the header

suite is what the run targets, as the paths were given; seed is the random-order seed when one was used, else null. php is the version that ran, coverage says whether coverage is being collected, so a coverage verdict will follow, and guard is on or off; a guard that is on with no coverage driver to judge with stops the run instead, as a fatal with the remedy. The totals are not here: at this point nothing has run yet, and the summary carries them.

example: only what needs attention

Passing entries are omitted unless you ask for them. A green suite of thousands need not spend tokens on entries an agent will never act on; the summary still counts them. Each listed entry is one that failed, errored, or is pending/skipped. Under -v every passing example and scenario is reported too, as {id, example, state: "passing", spec, rerun}, so you can name the examples that cover a change and re-run one of them on its own. The summary is the same either way, and its rerun still names only what failed.

One entry is one thing to fix. A spec run reports examples; a Story BDD run reports scenarios, not steps, because a scenario is what fails, what re-runs, and what you act on. The steps that did not pass ride inside the entry, so one broken scenario is a single item naming the step that broke it, never a failure followed by a train of skipped siblings pointing at the same line. Each row of a Scenario Outline is its own entry, named by its values (Adding > Adding numbers (1, 2)).

FieldMeaning
idA stable identifier for this example: a hash of the file it lives in and its full name. It survives edits that move lines or change where a failure fires, so you can ask "is THIS exact failure still here?" across runs, and it tells apart examples that share every title in different files. Recomputable from the file in spec and example.
exampleThe full name, as a path through every describe and context to the example, which is what --filter selects by: App\Basket > when empty > totals the prices for a spec, Checkout > Paying for a basket for a scenario. A title a sibling before it already has is numbered, totals the prices #2, as every formatter names it.
statefailing, error, pending, skipped or risky (ran without making an expectation); passing when it raised a PHP warning, deprecation or notice, and otherwise only under -v.
messageWhat went wrong, whatever the state; for pending and skipped, the reason the example gave, when it gave one. An error entry keeps exception too, for the class and the site.
specThe line to act on, project-relative and always in the spec file: the failing expect(), or the line where an error surfaced in the spec. An error thrown inside the code under test, or an expectation asserted in a helper, is addressed by the it() line that reached it; exception.at keeps the throw site. For a passing, pending or skipped example it is the it() line; for a scenario, the line its Scenario: keyword sits on. Absent when the site is not known.
rerunThe exact arguments to re-run just this one example or scenario: prepend your PhpSpec binary. It targets the it() line that declares the example (the Scenario: line for a scenario), which PhpSpec resolves to that one and no other. An it() in a loop declares several examples on one line, so each of those is targeted by its file and id instead: run spec/App/Calc.spec.php@6fd046add251. No full-suite re-run needed to verify one fix. It carries the --config the run was named and the --bootstrap it was given, so it runs against the same project; a config PhpSpec found by itself is found again from the same directory. An argument a shell would split or expand is single-quoted. Absent with spec.
rerun_argvThe same re-run as an argument list, ["run", "spec/App/Basket.spec.php:6", "--format=agent"] followed by the same --config and --bootstrap, to hand your PhpSpec binary and a process API: no quoting, and the stream stays JSON Lines. Absent with rerun.
outputWhat the code printed while this entry ran, present only when it printed something. See Printed output.
attachmentsContext the spec or scenario handed over about itself, by name. See Handing over context.
stepsScenarios only: the steps that did not pass or that raised a PHP note, each { title, state, message?, expectation?, at?, warnings?, deprecations?, notices? }, in the order they were declared.

failing entries also carry expectation, both sides in one block:

"expectation": {"matcher": "toBeTrue", "expected": true, "actual": false, "negated": false}
  • matcher: the matcher's name (toBe, toThrow, …), null for a custom or predicate matcher, which reaches PhpSpec anonymously.
  • expected is what the matcher wanted, actual what your code produced, in the universal sense of those words. (PhpSpec's internal naming is the reverse; the formatter un-inverts it for you.)
  • negated: true when the expectation used not().
  • diff: present when both sides are strings, or arrays of one shape, that differ: for strings { "offset", "expected", "actual" }, the byte where they part and a window of each from there; for arrays { "path", "expected", "actual" }, the key where they part (items[2].price). A trailing newline stops costing a re-read.

A matcher that names its target only in prose still states it as a value, so toBeTrue() reports expected: true rather than the null it has no argument for. Emptiness wants the empty form of what it was given: "" against a string, [] against an array, 0 against a number. A matcher with no target at all, such as a predicate, says "N/A" rather than reporting a null one.

toThrow compares what was asked for against what happened, not against the callable you handed it:

Expectationexpectedactual
toThrow() and it threw"N/A""Exception"
toThrow() and it did not"N/A""No exception"
toThrow(RuntimeException::class)"RuntimeException""RuntimeException"
toThrow(RuntimeException::class, 'boom')"RuntimeException(\"boom\")""RuntimeException(\"boom\")"
toThrow(new RuntimeException('boom'))"RuntimeException(\"boom\")""RuntimeException(\"boom\")"
toThrow(RuntimeException::class) and it did not"RuntimeException""No exception"

toThrow() with no argument means "throw something".

A mock expectation (toBeCalled, toHaveBeenCalled) compares the call the spec wanted with the calls the double received, arguments included:

"expectation": {"matcher": "toBeCalled", "expected": {"method": "App\\Ledger::record", "arguments": ["sale"], "times": "at least 1"}, "actual": {"method": "App\\Ledger::record", "calls": [{"arguments": ["refund"]}]}, "negated": false}

arguments are the ones the expectation was written with, [] for a call written with none, and an argument matcher reads as it was written: any(), cetera(), type(string), anInstanceOf(App\Task).

A story step that failed an expectation reports the same block, on the step inside steps (with at, the file:line of the expectation in your step file) and hoisted onto the entry next to message, so acting on the entry never means going a level deeper. A step whose code threw has a message and no expectation: there was none to report.

error entries carry exception (class, message, at) as well. When the error names something PhpSpec can generate (a missing class, interface, or method), the entry also carries a per-example offer (see below). A failing expectation carries no offer: the code exists and its behaviour is simply wrong, so state: failing already says everything.

Warnings, deprecations and notices. A PHP warning, deprecation or notice raised while an example or a step ran is listed under warnings, deprecations or notices: an array of { "message", "at" }, where at is the line that raised it, in the spec, the steps file or the code under test. An example carries its own on its entry; a scenario carries each on the step that raised it. An example or scenario that passed but raised one is reported all the same, state: passing with its spec and rerun, so a note never goes by unseen. The keys are present only when non-empty. Sometimes the deprecation is the actual clue behind a failure. A deprecation a library raised for another library's code calling it is left out, as it is on the console (see Warnings, deprecations and notices).

{"v":2,"event":"example","id":"4c2e34fc4bc4","example":"App\\Ledger > totals the entries","state":"passing","spec":"spec/App/Ledger.spec.php:3","rerun":"run spec/App/Ledger.spec.php:3","warnings":[{"message":"totals are rounded","at":"src/App/Ledger.php:9"}]}
{"v":2,"event":"example","id":"ffd3b79b2ddb","example":"Counting > Counting up","state":"passing","steps":[{"title":"Given I run the counter","state":"passing","deprecations":[{"message":"run() is deprecated, use count()","at":"features/steps/steps.php:4"}]}],"spec":"features/counting.feature:2","rerun":"run features/counting.feature:2"}

Large or object values in expectation are exported compactly (long strings and arrays are truncated with a { "truncated": true, "length": N } marker) so one entry can never flood a context window. Objects are named by what they are, not by which instance they were: an enum as App\Status::Active, anything that can describe itself as App\Money("12.00 GBP"), everything else as App\Basket. Two runs of the same failure therefore report the same value. Floats keep their fraction, so 90.0 is never reported as the 90 it was compared against. A float JSON has no word for is named instead: "INF", "-INF", "NAN", at any depth; a byte that is not UTF-8, in a value or in printed output, is replaced by U+FFFD. An event is never reduced to an empty object: should anything in it still defeat the encoder, the event keeps its identity and says what was dropped in encoding.

Printed output

Anything your code prints while an entry runs (an echo left in a spec, a var_dump, the output of a process a step shelled out to and echoed) is captured and reported as that entry's output, rather than landing in the middle of the stream and breaking it. What beforeEach and afterEach print belongs to the example they wrap and rides in the same output, before and after what the body printed. For a scenario it is everything its steps printed, in order, including the steps that passed: a scenario usually runs the process in one step and reads the result in another. Anything printed outside an example altogether, from a describe body or a beforeAll, goes to standard error, so standard output stays the document and nothing else.

output is a string, or { "truncated": true, "length": N, "value": "…" } when there was more than 4000 characters of it. The key is absent when nothing was printed.

Handing over context PhpSpec cannot see

Some of what explains a failure never passes through PhpSpec: a log file a process is writing, the output a step captured into a variable, the body of an HTTP response. Hand it over with attach(), and it is reported as the entry's attachments if the run needs attention:

use function PhpSpec\attach;

given('the watcher is running', function () {
    $this->log = $this->workspace . '/watch.log';
    $this->process = proc_open($cmd, [1 => ['file', $this->log, 'w']], $pipes);

    attach('watch log', fn() => file_get_contents($this->log));
});
"attachments": {
  "watch log": "── SYNTAX ERROR ── src/Todo.phunkie:1:9 ──\nSyntax error, unexpected ';'"
}

Four rules make that work, and each one exists because of a way it would otherwise quietly fail:

  • A closure is read at the end, not when you attach it. The log above is empty at proc_open time; what you get is everything the process wrote before the failure. Pass a string instead when the value is already final: attach('stdout', $process->getOutput()).
  • Read before your teardown runs. An afterScenario that terminates the process and deletes the workspace cannot empty the attachment first.
  • Nothing is read when the run is green. A closure over a huge log costs nothing on a passing suite, so attach freely.
  • The same name twice keeps the latest. A helper attaching on every poll leaves one attachment, not a pile.

Attach inside your helpers, not at every call site. A helper is usually the only thing that knows why it gave up, and returning a bool throws that away:

$eventually = function (callable $condition, float $seconds = 10.0) {
    // ... poll until $condition holds or the time runs out ...
    attach('waited', sprintf('%.1f seconds, and the condition never held.', $seconds));

    return false;
};

Values follow the same cap as output: a string, or a truncation marker past 4000 characters. A closure that throws, or that returns false (what file_get_contents answers when it cannot read), is reported as { "error": "…" } rather than as an empty attachment, so "PhpSpec could not look" never reads as "the subject said nothing".

Browser specs get this for free. Every request through the browser DSL attaches http.request (the method, URL and status) and http.response (the body), so an assertion that says expected 200, actual 500 is read next to what the server actually said.

summary

The counts (passing, failing, errors, pending, skipped, risky) are for the whole run, in the units the entries are reported in: one per example, one per scenario. steps is a size, not a verdict. warnings, deprecations and notices count every PHP note raised, by examples and steps alike, each one listed where it was raised. Like risky, they are never actionable: the run passed, and a deprecation raised in a vendor's code may be nothing the project can fix. The one number to branch on is actionable = failing + errors + pending, plus a coverage gate the run missed and anything that stopped it. Zero means there is nothing to do. It counts more than the exit code does: a pending example is work left to do, but, like PHPUnit's incomplete tests, it leaves the exit code at 0. duration_ms is the run's wall time.

FieldPresent whenMeaning
rerunan entry needing attention has a locationOne command that re-runs every failing example, and every one that raised a note, at once, so a fix is checked against all of what it was meant to fix. It carries the run's --config and --bootstrap as an entry's rerun does.
rerun_argvwith rerunThe same as an argument list, carrying --format=agent.
coveragea --coverage* option was given{ "percent", "required", "met" }. required is null without --coverage-min, and met is then always true. A missed gate adds 1 to actionable.
guardguard is on and either judged the change or could not{ "held": false, "judged": true, "violations": [{ "file", "lines", "member", "remedy" }] }. Each violation is new logic no example reaches, and adds 1 to actionable. When judged is false there are no violations and a reason says what stopped it.
offersthe run found code it can generateThe run-wide, de-duplicated list. Absent when there is nothing to take.
stopped_bya stop option cut the run shortThe option that did, --stop-on-warning for one: what came after it did not run, so the counts are not the suite's.
focusedthe spec focused with fit or fdescribeHow many examples were left out, pending. This run is not the suite; the exit code does not say so to an agent, this does.
applied--accept-offers was asked to write{ "offers": [{ "id", "action", "target", "file", "applied", "reason"? }], "files", "verified": false }: what was written after the run, under the ids the offers carried, and what could not be, with applied: false and the reason. files names only what changed. The counts describe the code before it, so run again to verify.

fatal: when the run could not finish

A run that never started (a missing bootstrap, an unknown format, a config that cannot be taken, an option run does not take or one missing its value), that was narrowed by --filter, --tags or paths to nothing at all, or that died partway (a parse error, a class that fails to compile) still answers. It emits a fatal line of { "message", "at", "remedy"? }, keeps whatever it managed to collect, and counts 1 in actionable. remedy is present when the way past is known: a run that wanted coverage and found no engine on gets the same command under the engine it has, php -d pcov.enabled=1 or XDEBUG_MODE=coverage, a missing bootstrap or path gets what to point where, a --filter that matches nothing is told it is not a pattern, and a file:LINE selector that reaches no example is told to point inside an it() or a Scenario:

{"v":2,"event":"run_started","suite":"default","seed":null,"php":"8.3.16","coverage":false,"guard":"off"}
{"v":2,"event":"fatal","message":"Class Mute contains 1 abstract method and must therefore be declared abstract or implement the remaining methods (Speaks::speak)","at":"spec/App/Broken.spec.php:8"}
{"v":2,"event":"summary","examples":0,"actionable":1,"…":"…"}

Standard output carries the stream, and nothing else

Under --format=agent those lines are the only thing written to standard output: the randomised-order seed, the --profile table, coverage lines, report notes, error text and anything your own code printed all go elsewhere, and PHP's own fatal reports are sent to the error stream. Parse stdout a line at a time; read stderr when you want the human text as well.

Offers: code PhpSpec can generate for you

An offer is a { action, target } pair (with an optional value). They are the same "shall I create this?" prompts the interactive runner shows a human, turned into flat data:

actiontargetMeaning
create_classApp\CouponA referenced class doesn't exist yet.
create_interfaceApp\RepositoryA mocked type doesn't exist yet.
create_methodApp\Basket::checkoutA called method doesn't exist on its class/interface.
create_stepsfeatures/steps/steps.phpThe run has undefined steps; accepting appends them all to that file.
fake_methodApp\Basket::totalAn existing empty method that a spec pins to a value; value is the return expression --fake would insert (e.g. "4000", "'hello'", "true").

Offers appear in two places: run-wide on the summary's offers, and per-example on the offer field of an error entry that maps to a concrete generation.

Acting on offers

PhpSpec never applies a change because nobody was there to say no. Every offer carries an id and waits under it, so you accept what you have actually read, in a second command:

$ vendor/bin/phpspec accept o_7f3a1c2d               # apply exactly that offer
$ vendor/bin/phpspec accept o_7f3a1c2d o_91b0e4aa    # several at once, all or nothing

An id is derived from what the offer would do, so the same offer keeps it between runs and a new one is visibly new. accept refuses an id it has never seen, and refuses an offer whose file has changed since it was made: at that point the decision was taken about something else. Nothing is applied unless every named offer can be.

With --format=agent, accept answers with one receipt:

{"v":2,"action":"accept","accepted":[{"id":"o_7f3a1c2d","action":"create_class","target":"App\\Coupon","files":["src/App/Coupon.php"],"applied":true}]}

target is what the offer named, a class, a method, a feature file, the path of a proposed write, or the class a plan refactors; files are the files it wrote, from the project root. An offer that could not be applied (a method written by hand meanwhile, a class file not where the layout says, a plan that stopped short) comes back with applied: false and a reason, and the command exits 1. Generated classes land where the project's layout says their namespace lives (see Code Generation).

For the common case of taking everything a run found, the bulk shortcut remains:

$ vendor/bin/phpspec run --accept-offers            # create missing classes/interfaces/methods/steps
$ vendor/bin/phpspec run --accept-offers --fake     # ...and fill empty methods with their spec'd return values

--accept-offers runs the suite, applies all of that run's offers (no prompts), and exits 0. Add --fake to also fill empty method bodies with the hardcoded returns their specs expect (the fake_method offers), a fast way to a first green before you replace the fakes with real logic.

The summary of such a run says what was written, in applied: each piece under the id its offer carried, the files it changed, and verified: false, because the counts on the same line describe the code before the writing. Run again to verify; the exit code of 0 says the offers were applied, not that the suite is green.

generate proposes rather than writes: its receipt carries an id per proposal with applied: false, and accept writes exactly the content that was reported.

Scaffolding with JSON receipts

describe and exemplify take --format=agent, which swaps their prose for a one-line JSON receipt, so an agent can scaffold without parsing English. No file content is returned; the agent reads the file itself.

$ vendor/bin/phpspec describe App/Basket --format=agent
{"v":2,"action":"describe","class":"App\\Basket","spec":"spec/App/Basket.spec.php","created":true}

A command line the command cannot read (an option it does not take, an argument too many, a command that does not exist) is refused the same way, as { "v", "action", "error" } with exit code 1, and nothing else on standard output. A name under none of the mapped namespaces, with no default_namespace to put it under, is refused with the remedy, exit code 1:

{"v":2,"action":"describe","error":"Acme\\Thing is under none of the mapped namespaces: Brew\\.","remedy":"Describe Brew\\Acme\\Thing, name a default_namespace in the phpspec config to put every such name under, or answer when asked which one."}

created is false when the spec already existed (both commands are idempotent). Combine describe --format=agent with -e to also add a method example:

$ vendor/bin/phpspec describe App/Basket --format=agent -e checkout
{"v":2,"action":"describe","class":"App\\Basket","spec":"spec/App/Basket.spec.php","created":false,"example":{"method":"checkout","added":true}}

exemplify --format=agent adds a single method example to a spec (creating the spec first if needed):

$ vendor/bin/phpspec exemplify App/Basket checkout --format=agent
{"v":2,"action":"exemplify","class":"App\\Basket","method":"checkout","spec":"spec/App/Basket.spec.php","added":true}

added is false when an example for that method is already present.

Refactoring

refactor --format=agent answers with the plan as one receipt, offered once and whole. Nothing is written until you accept it, which carries every step out as refactor --no-interaction does (see How it works).

$ vendor/bin/phpspec refactor Brew/DrinkPricer --format=agent
{"v":2,"action":"refactor","class":"Brew\\DrinkPricer","source":"src/Brew/DrinkPricer.php","spec":"spec/Brew/DrinkPricer.spec.php","technique":"Introduce Parameter Object","rationale":"priceOf takes size, milk and shots, which always travel together; a Drink holds them.","steps":["Describe Drink","Implement Drink","Add price method to DrinkPricer","Update DrinkPricer specs to use price","Remove priceOf method"],"offer":{"id":"o_4e6db6b4","action":"refactor","target":"Brew\\DrinkPricer"}}
$ vendor/bin/phpspec accept o_4e6db6b4 --format=agent
{"v":2,"action":"accept","accepted":[{"id":"o_4e6db6b4","action":"refactor","target":"Brew\\DrinkPricer","files":["spec/Brew/Drink.spec.php","src/Brew/Drink.php","src/Brew/DrinkPricer.php","spec/Brew/DrinkPricer.spec.php"],"applied":true}]}

class, source and spec say what was resolved; method appears when the target named one. When the model finds nothing worth changing, the receipt carries its reason as declined and no offer. Both exit 0.

files in the accept receipt are what the kept steps wrote. A plan that stopped short comes back applied: false with the reason, and accept exits 1. A plan made reading a file that has changed since is refused like any stale offer.

A refusal carries error, a remedy when there is one, and failures when the specs are not green; it exits 1:

{"v":2,"action":"refactor","error":"Specs must pass before refactoring.","remedy":"Fix what the run left first.","failures":["spec/App/Broken.spec.php:6  Broken > fails: Expected true to be false"]}

The loop

Putting it together, an agent drives the full cycle through the CLI:

  1. Scaffold: describe App/Basket --format=agent (and exemplify … --format=agent per method) to lay down specs.
  2. Read: run --format=agent; act on each example line as it arrives, and branch on the summary line's actionable.
  3. Generate: for create_* offers, either write the code or run run --accept-offers; for fake_method offers, run --accept-offers --fake to get a first green.
  4. Fix & verify: implement real behaviour for failing entries, then run <entry.rerun> to check just that one example. Track it by id across runs.
  5. Repeat until actionable is 0 (exit code 0).

Teaching an agent to use PhpSpec

Drop something like this into your CLAUDE.md (or .cursorrules, or any agent-instruction file) so the agent uses PhpSpec effectively:

## Running specs with PhpSpec

Always run PhpSpec with the machine-readable formatter and parse the JSON:

    vendor/bin/phpspec run --format=agent

It prints JSON Lines: one JSON object per line. Decode each line and branch on
its `event`:

- `summary` is the last line. Its `actionable` is the number to act on. **0 means
  there is nothing left, so stop.** It counts `failing + errors + pending`, plus
  a missed `--coverage-min` gate and anything that stopped the run.
- A `fatal` line means the run could not finish. Read it before anything else:
  the counts describe only what ran before it.
- `guard` on the summary means you wrote logic no example reaches. Each
  violation names the `member` and the `remedy`: write that example, do not
  weaken the code to get past it. If `judged` is `false`, guard reached no
  conclusion at all and `reason` says why: nothing was checked, so do not read
  the run as having been guarded.
- `example` lines are what needs attention (a passing example is reported only
  when it raised a PHP note, or under `-v`). Each has a `state`:
  - `failing`: the code ran but behaviour is wrong. Look at
    `expectation.expected` (what the spec wants), `expectation.actual` (what the
    code produced), and `message`.
    Fix the implementation; do not change the spec to match the code unless the
    spec is genuinely wrong.
  - `error`: an exception was thrown. `message` says what, `exception` adds the
    class and the site. If the entry has an `offer`, PhpSpec can generate the
    missing piece.
  - `pending`: an unimplemented example; implement it.
  - `risky`: an example that made no expectation, so it checked nothing;
    give it one. Not actionable: nothing is red.
- `warnings`, `deprecations` or `notices` on an entry, or on one of its
  `steps`, are what PHP raised while it ran, at `at`. Fix the ones raised in
  this project's code. They never count in `actionable`, so read the summary's
  `warnings`, `deprecations` and `notices` before you stop.
- `output` on an entry is what the code printed while it ran: read it, it is
  often the whole diagnosis for a scenario that drove a process of its own.
- `attachments` on an entry is context the spec handed over about itself, by
  name: a log, a captured stdout, an HTTP response body. Read it before you go
  looking at files yourself.
- To verify a single fix, re-run just that example: take its `rerun` value and
  prepend the binary, e.g. `vendor/bin/phpspec run spec/App/Basket.spec.php:6`. Don't
  re-run the whole suite to check one change. The `summary`'s `rerun` does the
  same for every failure at once.
- Track a specific failure across runs by its `id` (stable across edits that
  move lines). A failure is fixed when its `id` no longer appears. A failing
  Story BDD scenario has an `id` and a `rerun` of its own, just like an example.

## Learning the spec-writing API

Before writing specs, read the surface off the code once instead of guessing
or reflecting on the source:

    vendor/bin/phpspec api --format=agent

One JSON object: every DSL function and matcher with its signature and
summary, the mock and story functions, and the `timing` rules. Read `timing`
first: an expectation is judged on the line it is written, `toThrow()` runs its
callable there, and only `toBeCalled()` waits for the end of the example.

## Writing specs that explain themselves

When a spec or a step knows something PhpSpec cannot see, hand it over. Without
this, an assertion on a log file or a subprocess reports `Expected false to be
true` and nothing else, and you spend a cycle finding out why:

    use function PhpSpec\attach;

    attach('stdout', $process->getOutput());          // a value you already hold
    attach('watch log', fn() => file_get_contents($f)); // read at failure time

A closure is read at the end of the example or scenario and before any teardown,
so it captures what the process wrote *after* you attached it and survives an
`afterScenario` that deletes the workspace. Nothing is read when the run passes.
Attach inside a polling or process helper rather than at each call site: the
helper is the only thing that knows why it gave up.

## Generating code

Prefer letting PhpSpec generate boilerplate over writing it by hand:

- Scaffold a spec: `vendor/bin/phpspec describe App/Basket --format=agent`
- Add a method example: `vendor/bin/phpspec exemplify App/Basket checkout --format=agent`
- Apply all pending offers (missing classes/interfaces/methods/steps):
  `vendor/bin/phpspec run --accept-offers`
- Also fill empty methods with their spec'd return values (fast first green):
  `vendor/bin/phpspec run --accept-offers --fake`
- The summary of an `--accept-offers` run carries `applied`: what was written,
  by file. Its counts describe the run before the writing, so run again to
  verify; exit code 0 means applied, not green.

`--fake` produces hardcoded returns to reach green quickly; always replace them
with real logic before considering the work done. Offers are suggestions, not
commands: skip any that don't fit the design.

## Workflow

1. `describe`/`exemplify` to lay down specs (red).
2. `run --format=agent`; if `actionable > 0`, act on the entries.
3. Use `--accept-offers` for missing code; implement real behaviour for
   `failing` entries.
4. Re-run each fixed example via its `rerun`; repeat until `actionable` is 0.

Exit codes

Unchanged from every other formatter: 1 when an example or scenario fails or errors, when a coverage gate is missed, or when guard finds untested logic; 0 otherwise, pending examples included. --accept-offers exits 0 after applying offers.

See also