# Verification

There are two test commands. Run the default suite from the package root:

```sh
npm test
```

It uses only Node's built-in test runner and needs no installed dependencies. The release checks also run Git and Bash from `PATH`. The Pi CLI must be on `PATH`, or named by `CLEAN_CRAFT_PI_BIN`. Without it the discovery, delivery, and `/craft-mode` tests fail with a `BLOCKED` message. A package-local `node_modules/.bin` on `PATH` is skipped, because `npm run` puts it first and the development SDK installs a bundled `pi` there that is not the compiled CLI under test. They are required checks and never pass by skipping. The harness lifecycle checks start short-lived Node processes and loopback servers and read Linux `/proc`. On another platform they also fail with `BLOCKED`. The default suite does not need Herdr, pi-herdr-agents, or pi-herdr-pstack, and it never starts them.

The host integration gate is a separate compatibility check. `npm test` and the release job do not run it, and its result does not veto a release, but it should be run before one and its result reported as measured:

```sh
CLEAN_CRAFT_HOST_ROOT=/absolute/path/to/pi-herdr-agents \
CLEAN_CRAFT_PSTACK_ROOT=/absolute/path/to/pi-herdr-pstack \
npm run test:integration
```

A `BLOCKED` result exits nonzero because a gate that could not run has not passed. Treating it as a skip would let a missing prerequisite look like a green release check.

## Mandatory checks

The release job runs the same mandatory checks as pi-herdr-pstack's, after `npm ci --ignore-scripts`, which installs the exact development tools from the lockfile:

- `npm run format:check`: Biome 2.5.11 formats `pi-extension`, `scripts`, and `test`, excluding the fixture data under `test/fixtures`, whose bytes the retry fixture pins.
- `npm run lint`: oxlint 1.80.0 with its correctness rules as errors, over the same files.
- `npm run typecheck`: TypeScript 5.9.3 checks the retry fixture's TypeScript against its own strict `tsconfig.json`, then the shipped `/craft-mode` extension against `pi-extension/tsconfig.json`, with strict, exact-optional, and unchecked-index options, the coding-agent SDK 1.1.0 declarations, and Node types. Pi loads the extension's TypeScript directly; nothing is compiled or bundled.
- `npm run check`: `typecheck`, `lint`, `format:check`, and `npm test`, stopping at the first failure.
- `npm pack --dry-run --ignore-scripts`.

`npm run format` rewrites formatting in place. The release job runs `npm test` with the official Pi 1.1.0 Linux x64 build, verified by archive and binary digest, through `CLEAN_CRAFT_PI_BIN`. It does not run the host integration gate, so a green release job is not host evidence.

## What each check proves

| Check | Proves | Does not prove |
| --- | --- | --- |
| Manifest | Name, the public stable version with no `private` flag and public publish access, MIT license, `pi-package` keyword, a `pi` key with the skills directory and exactly one extension, the Pi SDK peer at any version and the host peer, `auto-changelog`, the exact Biome, oxlint, and TypeScript versions, and the exact SDK 1.1.0 and Node type packages as the only development dependencies and no other dependencies, and no install or lifecycle scripts other than the `version` hook that regenerates and stages the changelog. | That the peer version works with this pack. |
| Skill contract | Each shipped skill has portable single-line frontmatter, a string name matching its directory, a unique name, a non-empty string description, explicit-only invocation set to the boolean `true`, local links that resolve to files inside its skill directory, and exactly its declared sibling skill links. The hub links every shipped stage, and no stage links back to the hub or to an unshipped skill. Small fixtures show the checks reject a missing reference, unclosed frontmatter, and a mismatched name. Generated cases show they reject descriptions YAML reads as null, booleans, or numbers, a numeric name, a blank description, and quoted invocation values, while accepting the same words quoted as strings. Padded and whitespace-only values are typed the same way after trimming. Generated link cases show sibling links are recorded apart from owned references, and that missing, internal, escaping, and symlinked sibling targets are rejected. | That the instructions are good, that a model follows a link, or that the checker accepts every form of YAML Pi accepts. |
| Package contents | `npm pack --dry-run --json --ignore-scripts` lists exactly the inventory, its references, the one extension file, and the packaged documents, 24 files in all, and nothing from plans, contributor files, tests, fixtures, local settings, evidence, the release workflow and its scripts, the lockfile, the formatter, linter, and extension TypeScript configuration, or the project `.npmrc`. A test-owned candidate shows that packing ignores a project `.npmrc` and keeps erroneously allowlisted plans, tests, and evidence visible to the inventory check. Every skill in the extracted tarball resolves the same owned references and sibling skills as in the source, and a candidate whose allowlist omits a linked sibling fails that check. | Registry behavior. No registry request is made. |
| Pi discovery | Pi 1.1.0 in RPC mode returns exactly the six `skill:craft-*` commands and the package's own `/craft-mode` extension command from `get_commands`, each with the expected path and package metadata, both from the checkout and from an extracted tarball. Pi's built-in extensions are switched off in settings rather than with `--no-extensions`, which would also drop the package's extension. | That a model receives or follows the instructions. |
| `/craft-mode` | Seventeen cases run the compiled Pi CLI in RPC mode with persistent test-owned sessions, the package from settings, a small test-only driver extension, and a scripted loopback endpoint. They count exact hub copies, carrier position, reminders and tool pairing in each request the endpoint receives, and check the complete request ledger after Pi and the endpoint have closed. They cover registration and every control form; ordinary prompts, tool continuations, and user-loaded copies; assistant and tool-result quotations; on, off and task refusal during a run; literal, swallowed, and refused tasks; a simulated completion wake; manual compaction that keeps or evicts the user's copy; automatic compaction between tool turns; an earlier handler removing the user's copy; a preparation failure; restart, reload, tree navigation, fork, clone, and a new session; a seeded delegated child with and without `PI_SUBAGENT_ID`; missing, filtered, shadowed, linked, non-explicit, unreadable, and linked-root hubs; future and malformed records; two loaded copies; foreign and competing-mode content; and the packed tarball. Summary requests are counted apart from task requests. | That a model follows the guidance, the real host's completion path, other Pi versions, or token, cache, or cost effects. The wake is the driver's `sendMessage`, not pi-herdr-agents. |
| Pi delivery | For each of the six skills, from the checkout and from an extracted tarball, a real Pi process sends `/skill:<name> <task>` to a test-owned loopback model endpoint. The request that endpoint actually receives carries, as its only new input, the skill block at the expected location and reference base, the complete instruction body, and the distinct task argument once. The body is the file without its frontmatter and with outer whitespace trimmed, which is the expansion measured for Pi 1.1.0. The endpoint answers with a fixed reply that echoes neither the task nor the body, and every request it receives must come from this run. | That a model follows the instructions, or the expansion format of other Pi versions. |
| Evidence boundaries | Pure negative controls send hand-built request bodies through the same request parser and judges as the live checks. Delivery is rejected for a body truncated by one line, the same skill name at another path, a mismatched reference base, instructions found only in an earlier turn or in assistant text, raw unexpanded slash text, a wrong task argument, and an echoing reply. Host judges reject a standalone task without its role, a forked task that carries its role, a child that inherited nothing, altered or leaked read results, a result sent by the controller or never delivered as a host message, a dependent launch that precedes or lacks the accepted value, and a startup-only completion. The isolation judge rejects unattributed requests, origins that carry credential-like variable names or lack a passing recorded check, a connection-based attribution without confirmed disappearance, any lost observation, evidence without a loss record, dialogs, and stderr output, and it reports an empty ledger as blocked. The teardown judge rejects survivors, uncertain identities, unresolved closure, failed steps, and escalation. Prerequisite checks block missing, relative, misidentified, wrong-version, and hollow inputs, and a platform without `/proc` attribution. | That the live host or Pi behaves as the fixtures assume. The integration gate measures that. |
| Harness lifecycle | Real child processes, loopback sockets, and Linux `/proc` exercise the integration harness's own rules. Denials, contradictions, and malformed socket tables are injected only at individual `/proc` reads, and teardown and delivery close failures only through injected server, process, and endpoint boundaries. A denied read is reported as unknown, never as a departed process. A socket table with a malformed or cut-off row is unknown too, even for a request on an already proven connection, while the kernel's shorter `TIME_WAIT` and unnamed rows and a socket path containing spaces still read normally. A sender that lost its owned ancestry is attributed again only when its start time, executable, subagent identity, Herdr session, socket, pane, agent directory, offline flag, and credential-free environment still match. A changed executable or environment, or a denied read, is refused. A sender whose main thread has exited is read through a thread that still runs. A request on an already proven connection is attributed after its sender has gone only when the sender is confirmed absent and its client socket is closed or held by no process, and another connection never inherits that proof. Teardown keeps the run directory when an identity cannot be observed or a direct process could not be identified, and it finishes every step when an earlier one throws. A command whose descendant keeps its output open is reported unresolved after a bounded wait, and an error event is not mistaken for closure. The shared helper that runs `npm pack`, `tar`, and Pi discovery applies the same bound. It reports the command's process ID and start time, and keeps the command's root, and any run directory that contains it, until a close is actually observed. A full request or RPC log, an oversized output line, an incomplete request body, and an RPC record cut off at the end of its output are recorded as lost observations that fail isolation. A complete final record written without a newline is kept, so an extension error in it still fails isolation. A failed run keeps its evidence without a requested destination. Archive failures keep the run directory, and an unreadable launch script or a failed delivery setup does not skip cleanup. When the delivery check's Pi or endpoint close throws, the other is still closed, the error is kept, and the run is reported as retained, not closed. | That the live host produces these conditions. The integration gate measures what it does. |
| Release workflow | The workflow's own scripts, run in test-owned Git repositories, decide, refuse, and publish as [Releasing](../RELEASING.md) describes. Its steps, walked in order for a version bump and an exact retry, stop at a failing install, format, lint, check, or package-preview step before any later step, publication, tag, or release, and stop at provisioning when the Pi download does not match its pinned digest. See below. | That the workflow runs on GitHub Actions, that the real Pi download or the hosted checks succeed, or anything about the real registry or GitHub. The metadata-only bootstrap's hosted OIDC and provenance evidence is in [Releasing](../RELEASING.md), not in these tests. |
| Retry fixture | On Node 26.8.2, a small TypeScript fixture shows a named assertion failing before a fix and passing after it, a load failure that is not counted as red, two structurally different implementations passing the same literal cases, a forbidden runtime import found by file and edge, and one seeded mutant that survives weak assertions but is killed by a named boundary assertion. The source is restored and verified after normal completion, a worker error, and a controlled worker interruption. | That any upstream analyzer or mutation tool works, that the skills are followed by a model, or behavior on other Node versions. |

## Release workflow

The release tests run the scripts under `scripts/release/` that the workflow calls, never a copy of their logic. Each Git fixture is a new repository with a test-owned identity, signing off, an empty hooks directory, no global or system Git configuration, an empty home, and a local bare repository as its only remote.

- **Detection** runs against real commits. It covers the actual public `0.2.0` manifest: a push from the published `0.1.0` predecessor is a candidate, a later push at the same version is skipped as unchanged, and a dispatch from `main` is a candidate. It also covers private and prerelease manifests, a first push, unreadable predecessors, unchanged versions, increases, a removed `private` flag at the same version, the core version after a prerelease, numeric parts beyond the exact range of a double, lower and malformed versions, another package name, a name that would inject a step output, and dispatches and pushes from other refs. A refusal writes no outputs.
- **The registry check** talks to a loopback HTTP server through the real `fetch`. Only npm's exact not-found body, with JSON whitespace allowed between tokens, counts as absent. A not-found body with a repeated key, an escaped or differently spaced value, or non-JSON whitespace is refused. Redirects, a timeout, a refused connection, authentication and server errors, an oversized document, ambiguous not-found bodies, malformed or mismatched metadata, and a version published from another commit or without one are all refused. Invalid inputs are refused before any request.
- **The guard** refuses another ref, event, commit, repository, server, manifest identity, private manifest or repository, missing public access, repository metadata that names another repository, and a runner that is not GitHub-hosted.
- **The mutation script** runs with stand-ins for `npm` and `gh` that record each call, the credentials it could see, and the remote tag at that moment, against a real local remote. Run from the command line, it carries out the full plan in the fixture. The tests show the order publish, tag, release; which credential each command sees; the bootstrap and OIDC rules; the minimum npm version; exact-retry reuse of the tag and release; refusal of a conflicting local or remote tag, an ambiguous release lookup, a draft, and a release without its tag before any publish; and a single attempt, with no retry or forced push, after a failed publish or a failed release creation. A tag moved to another commit during publish is refused before the release, whether the tag was absent or already on this commit, and whether the release would be created or reused. A tag that disappears during publish, or a remote that cannot be read again, is refused the same way, and the tag is not pushed again. A tag that stays on this commit, or appears on it during publish, is not pushed again and the release proceeds. These tests cannot place a change between the script's last tag read and the release command. That window is documented in [Releasing](../RELEASING.md), not tested.
- **The workflow layout** is read by a small reader for the restricted YAML layout the file keeps. It checks the triggers, permissions, concurrency, action pins, Node version, step order, absence of conditions on steps, and where values and secrets enter. It also checks that the only download is the pinned Pi archive, into the runner's temporary directory, with the archive digest checked before extraction and the binary digest checked before `CLEAN_CRAFT_PI_BIN` is exported. The walk test then runs the workflow's own `run` lines in order in a fixture, resolving only the expressions the workflow uses and carrying `GITHUB_ENV` between steps. Stand-ins for `npm`, `gh`, and `curl` record each call. The `npm` stand-in passes every mandatory check except the one a test makes fail, and refuses anything else, and the `curl` stand-in serves a small test-built archive. There are two substitutions: the registry URL points at a loopback server, and the pinned Pi digests become the test archive's. For a version bump and an exact retry, a failure at each check stops the walk there, with no later step, `npm publish`, `gh release create`, or tag. With every check passing, the checks run in order, `npm run check` and the package preview see the extracted binary, and the walk reaches the publication script, whose first query the stand-ins refuse. Without the digest substitution, the test archive fails the pinned check and the walk stops at provisioning with nothing extracted or exported.

These tests do not run GitHub Actions, the real registry, the real Pi download, OIDC, provenance, or a hosted runner. The local syntax check of the workflow file is recorded with the release setup evidence, not run by the suite.

## Sibling links

A skill may link another shipped skill only at `../<name>/SKILL.md`, relative to the skills directory that holds both. That records a sibling dependency, not a file the linking skill owns. The checker does not follow the sibling's own links, because the sibling is checked on its own. Owned references remain the only input to the package allowlist. The checker rejects a link to a missing sibling, to any other file or directory inside a sibling, to anything outside the skills directory, and to a sibling whose real path, after symlinks, lies outside it. The same check runs against the source tree and the extracted tarball.

## Retry fixture

`test/fixtures/retry-eligibility` is test-only and never packed. It holds one small retry-eligibility program: a domain function that decides retry or stop from a transient or permanent failure and the attempt count, an adapter that maps error codes and calls it, a constant-only infrastructure module, and literal behavior cases. The cases include NaN attempt counts and maximums, which the domain's `number` type admits, so a refactor must preserve the base comparison's handling of them. `base/` is the correct program. Each directory under `variants/` replaces whole source files with a complete alternative: a behavior defect, a renamed export that fails to load, a structurally different correct implementation, and a domain module that imports infrastructure. `mutants/boundary` replaces one file and differs from the base by exactly one line. The main behavior, refactor, dependency, and boundary-mutant states are checked-in whole-file snapshots. Negative controls derive malformed or throwing mutants in test-owned copies. The loader rejects any mutant that changes zero or several lines, changes the line count, replaces several files, or targets a file outside the base `src/` tree.

Each test writes its state into its own temporary directory. A worker process, started with the test-owned environment, preloads every source module under Node's synchronous module hooks. It then reports the hash of each loaded source file, the import edges it resolved, and any module that failed to evaluate, and waits. On receipt of that record, the parent tells it to run the fixture tests through `node:test` in the same process, tells it to fail, or kills it. The hash is taken from the bytes before evaluation, so a matching hash alone shows only that the mutated file was read. After worker closure, the test accepts the checkpoint as evidence that a state loaded only when the expected hash is present and no module failed to evaluate. A negative control interrupts a mutant whose first line throws and shows that the evidence gate refuses the checkpoint despite a matching hash. The worker never writes to the workspace, and the parent writes all files. Red and killed results require a named test that failed with a Node `AssertionError`. A load failure, a worker error, and a nonzero exit status are recorded as such and never count as red or killed.

The mutation trial checks for a green baseline and confirms the target still matches the base before writing the mutant. After the worker closes, the parent restores the file from the in-memory snapshot, compares the whole workspace with the snapshot byte for byte, and runs the unmutated tests again. A workspace that does not match is kept and its path reported instead of being deleted. Negative controls cover a red baseline, a drifted target, a stray file, and malformed mutants. Worker deadlines are explicit, but no test exercises a timeout, and nothing here recovers from the death of the test process itself.

The import edges come from runtime resolution. Type-only imports, which Node erases, and imports on paths that never execute do not appear, so the fixture's boundary check does not measure them. The fixture TypeScript runs through Node's built-in type stripping, which does not type-check. Type checking during development used an editor language server, which no test runs.

## Frontmatter grammar

The skill checker reads a deliberately small subset of YAML: one `key: value` pair per line, with each value either plain or double-quoted without escapes. Spaces and tabs around a value are ignored, while spaces inside double quotes are kept. Plain values resolve the way Pi's YAML 1.2 core-schema parser reads them, so `null`, `~`, an empty or whitespace-only value, `true`, `False`, `123`, `0x1F`, and `.inf` are not strings. Names and descriptions must be strings, so quote any that would otherwise read as another type. `disable-model-invocation` must be an unquoted boolean. Pi treats only the boolean `true` as explicit-only, and a quoted `"true"` is a string.

## Isolation

Each Pi or npm process runs with a test-owned temporary home, agent directory, XDG directories, working directory, npm cache, and empty npm configuration. The environment is built from an allowlist that carries no credentials. Pi runs offline with telemetry and version checks disabled, no context files, prompt templates, or themes, and no project trust. Delivery runs with no extensions, tools, or session file. Discovery and the `/craft-mode` cases switch off Pi's built-in extensions in settings so the package's own extension loads; the `/craft-mode` cases also load the test driver, enable only the read tool, and keep sessions inside their temporary root. The only package sources in settings are the paths under test, plus generated copies some cases build. Every process has an explicit timeout, and the temporary directories are removed afterwards.

npm reads project configuration from the package root's `.npmrc`, and empty user and global configuration do not prevent that. Therefore npm never runs in the checkout. The tests pack a staged copy inside a test-owned directory. The copy is faithful except for three exclusions. It omits `.npmrc` and `.git` at any depth, because npm never packs either. It also omits the root `node_modules`, because npm packs it only for bundled dependencies, which the manifest check forbids. Plans, tests, fixtures, `.audit`, and nested `node_modules` stay in the copy, so an allowlist that wrongly includes them still fails the exact inventory check. If the temporary directory sits inside the checkout, staging skips it rather than copying it into itself. A regression packs a test-owned candidate whose `.npmrc` redirects npm's logs to a sibling directory. It checks that the redirect target is never created, that npm's log lands in the test-owned cache, and that the extracted tarball holds exactly the allowlisted files with unchanged contents.

This registers the package only in temporary settings. It does not install anything into your own Pi configuration.

The delivery checks add a loopback model endpoint on `127.0.0.1` with a dummy key in test-owned `models.json`. Each request is tied to the run by a random path segment in that endpoint's URL. Retries, compaction, cache warming, analytics, and install telemetry are off. Pi runs without tools, so the scripted reply cannot run anything. Once the endpoint starts, it and Pi are closed even if setup fails, and any observation the endpoint or the RPC log lost fails the check.

## Host integration gate

The gate tests the packed candidate, not the checkout. It packs this package with the same staging helper as the package checks, extracts the tarball, and refuses to continue unless the tarball holds exactly the inventory.

### Inputs

`CLEAN_CRAFT_HOST_ROOT` and `CLEAN_CRAFT_PSTACK_ROOT` must name existing local package directories by absolute path. The host must be `pi-herdr-agents` at exactly the version in this manifest's `peerDependencies`. pstack must be `pi-herdr-pstack`, and its version is recorded but not pinned. Both must declare extension entry points that exist. `CLEAN_CRAFT_PI_BIN` and `CLEAN_CRAFT_HERDR_BIN` override a `PATH` lookup for `pi` and `herdr` and must be absolute executable paths. The gate never searches your Pi configuration or guesses install locations. It records the versions it ran, plus a content digest of each package. `CLEAN_CRAFT_EVIDENCE_DIR`, when set, receives a new `run-<time>-<random>` directory for every run, and earlier runs are never overwritten. When it is unset, a run that fails, or whose teardown is not clean, writes its evidence to a new `clean-craft-evidence-<random>` directory in the system temporary directory, and the test prints that path. Only a passing run with a clean teardown and no requested destination keeps no evidence. Evidence holds the report, the request ledger, the RPC and Herdr logs, and the host's launch scripts. It never includes the run directory or upstream module caches. The gate needs Linux, because it identifies processes and sockets through `/proc`. Other platforms report `BLOCKED`.

### Isolation and lifetime

Each run creates a fresh short directory under `/tmp`, whatever `TMPDIR` says. The directory holds:

- home and XDG directories;
- a Pi agent directory with test-owned `settings.json`, `models.json`, and task-model configuration;
- a session directory and a working directory;
- a `bin` directory of symlinks to the selected `pi`, `herdr`, and `node`.

One environment is built from an allowlist, with no proxy or credential variables, and Pi's offline, version-check, and telemetry controls set. Pi's built-in extensions, retries, compaction, cache warming, and analytics are off.

The gate starts its own named Herdr server with that environment. Before creating any pane, it proves that the server it spawned, identified by process ID and start time, holds the listening socket that Herdr reports, and that every reported socket path lies inside the run directory. It then creates one anchor workspace and copies the `HERDR_*` values the anchor shell actually received. It checks that an implicit `herdr` call carrying those values resolves to the anchor, which is how the host's own Herdr calls reach the owned server. A single headless parent Pi then runs in RPC mode with that identity and with host, pstack, and the candidate active. Every Herdr command the gate issues names the owned session. A mutating command that times out marks the instance tainted, and it is never retried.

The loopback endpoint attributes each request while its connection is open. It finds the client socket in `/proc/net/tcp` and requires exactly one holder among the processes the run started and their descendants, including thread children. Every `/proc` read is classed as present, absent, or unknown. Absent means the process or file is gone. Unknown means the read was denied, malformed, or contradictory. A socket table counts as read only when every row carries the fields the gate uses and the table ends on a complete row. Unknown never counts as absent, and it refuses the request. The holder must run the selected Pi binary, with the same process ID and start time before and after its descriptors and environment are read. A host child must also descend from the owned server and carry the host's subagent identity, the owned Herdr session and socket, a pane other than the anchor, the owned agent directory, the offline flag, and no credential or proxy variable names. When a process's main thread has exited but other threads still run, it is read through a running thread. A recorded child that has lost its owned ancestry is attributed again only when every one of those values still matches its first proof. A request on a server connection that an earlier request proved is attributed to that earlier sender only when the sender is confirmed gone and its client socket is closed or held by no process. Each attribution records the checks it passed and its basis, and the result lists every request attributed without owned ancestry. Anything else is refused and fails the gate. Model labels and task markers are never used to identify a sender.

The scripted model has one response function for the parent and one per child scenario. Each function reads only the request it is answering. The parent never sees child requests or results except through the host's messages in its own context. A child's result is a nonce created only when that child's request arrives. Every wait is bounded and event-driven, and the gate never polls status or session files. A process counts as closed only when Node reports that it exited and that all of its output streams closed. An error event is not closure. On a deadline the gate signals through the process's own handle, never by a bare process ID. If closure still does not arrive within a further bounded wait, for example because a descendant keeps the output open, the process is reported unresolved, not closed. Packing and extracting the candidate follow the same rule. A packing command that stays unresolved is reported with its identity, and the run directory that holds it is kept until its close is observed.

The shared command helper bounds its post-signal wait to 1000 ms and captures at most 256 KiB per output stream. Its unresolved-command registry lives only in the test process. If that process exits first, the root stays on disk without a recovery service. Command closure proves direct-child and output-stream closure, not the exit of arbitrary descendants that detach from those streams. The helper neither identifies nor signals a descendant holding the output.

Teardown runs in an outer `finally` that also covers endpoint setup, the scenarios, and collection of the host's launch scripts. It attempts every step even when an earlier one fails, and keeps each failure:

1. Record the owned process tree.
2. Close each Pi process, escalating to signals only on deadline and recording any escalation.
3. Record the tree again, then stop the owned Herdr session once.
4. Close the endpoint.
5. Observe every recorded process identity as present, absent, or unknown, and look for owned sockets.

Evidence is written after teardown. The run directory is removed only when every recorded identity is confirmed absent, nothing is uncertain or unresolved, every step succeeded, and the evidence was written. Otherwise it is kept, and the test reports its path, every live identity, and every uncertain one.

### What each distinction proves

| Distinction | Proves | Does not prove |
| --- | --- | --- |
| discovery | With all three packages active in one parent, the six `craft-*` skills and the `/craft-mode` command come only from the candidate, which registers no other non-skill command. Every host and pstack skill, derived from their own manifests, keeps its owner. `subagent` belongs to the host, `/poteto-mode` and `/setup-pstack` belong to pstack, and no command name repeats. | Which commands a child session lists. Children are not queried over RPC. |
| standalone-role | A bare `systemPrompt` launched through the host's real `subagent` tool with `fork: false` reaches the child's first provider request inside the host's task artifact, with the complete role text before the complete task text. | That a model honors the role. |
| fork-control | With `fork: true`, the child inherits the parent conversation, and the turns after the inherited prefix are exactly the task without the supplied role. The inherited history keeps earlier launch arguments that contain role text, so a search of the whole request could not separate the cases. | Other inherited shapes. The measured inheritance ends before the launching prompt. |
| fresh-resources | A fresh host child issues real `read` calls for the candidate's craft-mode skill, its delegation reference, and its craft-tdd sibling. Its next provider request carries each file's exact contents, and none of that text reached it before the reads. | That skill expansion works inside a child (see skills-argument), or that a model would choose to read the files. |
| completion | Each child result was minted only on that child's request. It reaches the parent as a real host `subagent_result` and then in a later parent provider request, and the controller never sent it. | Cancellation, stalls, or delivery of failed results. |
| handoff | Child A's value reaches the parent only through the host. The parent's own next response accepts it and launches child B through the host with it, and B's first request carries it. | Parallel or worktree-based handoff. |
| skills-argument | Requires the host's `skills: "craft-mode"` launch to keep the canonical instructions in the child's actual task request and to complete with a result derived from that task. Startup turns get a fixed acknowledgement, and nothing keeps the child alive artificially. | Fork-mode skill loading, which is not exercised. The order of a later child request relative to the host's completion, because ledger and RPC indices are separate streams. |
| resource-controls | A package filter removes `craft-tdd` from registration, and its slash command reaches the model as raw text, yet the file stays readable through the read tool. An agent-directory skill and a decoy package of the same name win registration and are rejected by location. Unknown slash text reaches the model unchanged. A host whose extensions are disabled declares no `subagent` tool and is reported blocked. | That filters restrict file access, or the order of other shadow placements. |
| pstack-report | `/setup-pstack report` is handled as a command without a model turn or provider request, reads the owned task-model configuration, and leaves its bytes unchanged. | pstack's change or init flows. |
| pstack-mode | `/poteto-mode on`, `status`, and `off` are handled as commands with their documented transitions. While on, a `/skill:craft-tdd` prompt carries pstack's mode section, the canonical poteto-mode hub, and the complete craft-tdd instructions. Each wake request that carries a subagent result also carries the mode reminder. After off, no guidance is added. | That a model avoids starting a competing coordinator. |
| writer-refusal | A well-formed `subagents_write_task_models` call outside the setup flow is refused by pstack's writer gate. No dialog opens, and the owned configuration bytes are unchanged. | The approved setup write path. |
| isolation | Every provider request is attributed to an owned process by its recorded checks, not by its kind alone, and carries only the test key. Host children carry the owned Herdr session. No observation was lost to a full log, an oversized line, an output record cut off at the end of a stream, an incomplete or unparsable request, or an undelivered reply. No dialog, extension error, protocol fault, or diagnostic output occurs. A run that received no request is blocked. | The absence of other network traffic, or an OS sandbox. |
| teardown | Every recorded process identity is confirmed absent rather than unreadable, nothing is uncertain or unresolved, no owned socket remains, each direct process closed with exit 0 without escalation, every teardown step succeeded, and the endpoint closed. | Recovery after the test process itself dies. |

The gate passes only when every distinction passes and the run directory was removed after a clean close. One failing distinction fails the gate while the others still report their own results. Measured outcomes are in [compatibility](compatibility.md).

## Later gates

The default suite lists these as todo items:

- Host orchestration, fresh-child resources, and pstack coexistence. These are measured only by the separate integration gate above.
- Live-model adherence to the methodology. This is not measured and must not be read as passing.

The integration gate does not test cancellation, worktrees, parallel writers, or persistent children. Those remain owned by the host and unmeasured here.
