Maintain Pulp's installed design-time agent capability manifest and public-surface ledger. Use when adding, removing, renaming, or materially changing public audio, MIDI, signal, timebase, or sequence APIs; registering a new algorithm for generators; changing capability support or deprecation state; or repairing agent-capabilities freshness, schema, fingerprint, tombstone, or installed-SDK tests.
Installs into .claude/skills of the current project.
Are you the author of Agent Capabilities?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/danielraffel-agent-capabilities)
---
name: agent-capabilities
description: Maintain Pulp's installed design-time agent capability manifest and public-surface ledger. Use when adding, removing, renaming, or materially changing public audio, MIDI, signal, timebase, or sequence APIs; registering a new algorithm for generators; changing capability support or deprecation state; or repairing agent-capabilities freshness, schema, fingerprint, tombstone, or installed-SDK tests.
---
# Agent Capabilities
Maintain three related artifacts:
- `agent-capabilities.json` is the installed consumer contract: curated keys,
versions, digests, status, evolution, typed C++ bindings, and partial-coverage
semantics.
- `agent-capability-surface.json` is the maintenance ledger: every public header
in the covered roots, its byte fingerprint, and its reviewed disposition.
- `tools/agent-capabilities/contract-history.json` is the repository-only,
append-only evolution history checked against the protected Git tip. Shallow
GitHub Actions checkouts fetch the immutable event base SHA when necessary.
The consumer manifest, its schema, and the handoff schema install into the SDK.
Official release packaging stamps `agent-capability-handoff.json` only after
installation; it binds the exact SDK source SHA and platform to the installed
importer's SHA-256 plus the installed manifest's exact content and byte hash.
The release archive verifier must require and revalidate that identity at the
configured capability-handoff floor. The surface ledger, surface schema, legacy
baseline, and contract history are maintenance artifacts and must not be
installed.
When adding an installed SDK library in `PulpInstallRules.cmake`, register its
archive stem in `release_product_matrix.json` and classify every newly covered
public header here in the same change. A successful CMake export alone does not
prove the release archive or installed agent-capability contract is complete.
Keep both separate from the unified runtime control platform. This contract may
describe what an SDK can design or generate; it must never contain runtime
operations, grants, policy, risk decisions, instances, activation, sessions,
revocation, or receipts.
`pulp authority list|query` is the routing-only navigator across this and the
other bounded machine authorities. For design-time capability questions it
must point to `agent-capabilities.json` and this manifest checker; it must not
copy capability rows into `authority-navigation.json`, treat an absent key as
unsupported, or infer any live state. In a source checkout it binds its answer
to the checkout registry. In a downstream project it resolves the exact SDK
selected by `pulp.toml` without materializing it; otherwise an SDK-installed
CLI may bind to its adjacent `share/pulp/authority-navigation.json`.
Source-only routes report unavailable, and a missing selected SDK never falls
back to another installed version.
The standalone CLI-only release archive has no SDK registry and must fail
clearly rather than search another checkout or SDK. A reported
`query_or_validator` is descriptive guidance only and is never executed by the
navigator.
The installed SDK also ships canonical runtime-control headers, CMake helpers,
and control-authoring examples. Their presence in the same install tree does
not make them agent-capability rows: keep runtime-control operations and policy
out of `agent-capabilities.json`, and keep the capability surface ledger focused
on the design-time public-header contract.
The A3 GPU startup-health surface is a concrete example. The installed
`ControlGpuHealthProvider`, `ControlGpuHealthViewAdapter`,
`ControlGpuHealthReadExecutor`, and `pulp.gpu-health-read-result.v1` types are
runtime control plumbing. Do not add `dev.pulp.gpu/health.read@1`, its grants,
instances, receipts, measurement campaigns, or B4 disposition to
`agent-capabilities.json`. A public-header ledger classification, where one is
required by the covered roots, records only that the header was reviewed; it
must not turn this runtime operation into a generator-facing design capability.
The checkout-only `gpu_first_visible_a3_campaign.py` runner is also runtime
acceptance tooling, not an installed SDK capability: do not add its adapter
request/receipt schemas, 10+10 lifecycle ledger, or source-binding receipt to
the design-time capability catalog.
The same classification holds when that surface grows a producer.
`FrameObservation::gpu_submission_observed` is now fed by two `WindowHost`
queries (`supports_gpu_submission_evidence()`,
`last_frame_gpu_submission_observed()`). Those are public installed-header
additions and get a public-header ledger classification like any other, but
they stay OUT of `agent-capabilities.json`: a host telling the inspector whether
its last frame reached an output is runtime control plumbing, not a
generator-facing design capability, and a plausible `WindowHost::` prefix is not
evidence otherwise.
A stopped-install runtime observer can change a reviewed header without adding
an advertised design capability. Refresh the affected fingerprints and inspect
the generated manifest diff: its legacy method projection can change even when
all curated capability contracts stay identical. Bind related pending exposure
evidence to the specific API or header fingerprint, not a literal global
`SURFACE_INVENTORY_VERSION`; an unrelated inventory update must not invalidate
that evidence. None of these source checks establishes installed execution.
## Classify the change
For a new public header or symbol:
1. Decide whether it is a generator-facing capability. Do not infer this from a
plausible class name.
2. If yes, add an explicit row to `EXPORTS` in the domain-appropriate
`tools/scripts/agent_capability_catalog_*.py` module, add typed bindings for
every advertised entrypoint/operation, and record the current header
fingerprint. Add a nonempty `_link_probe` that constructs or invokes the
real API rather than merely taking `sizeof`, and start a new key at contract
version `1.0`. `agent_capability_manifest.py` assembles those catalogs; do
not put capability rows back into that orchestrator.
3. If no, add a reviewed header classification to
`tools/scripts/agent_capability_registry.py` instead:
`capability_support`, `infrastructure`, or `unsupported_capability`. Give a
durable rationale. Never grow the frozen `legacy_unreviewed` baseline.
4. Increase `SURFACE_INVENTORY_VERSION` for any ledger change. Increase
`MANIFEST_REVISION` whenever the installed manifest changes.
For overloaded C++ free functions, keep the public `qualified_name` as the real
API name and give each overload a distinct binding role. Use private generator
metadata for an explicit `static_cast` address expression so the generated
compile fixture proves the intended signature without leaking fixture syntax
into the installed contract. Each overload still needs its own operational
probe with arguments that select and invoke that overload.
If generation reports that a new public header is unclassified or has no
covered public target owner, do not retry `--write` or add a blanket exception.
Add the curated capability or reviewed disposition first, register the exact
minimal owner in `REVIEWED_MINIMAL_TARGETS`, and then regenerate. A capability
binding alone cannot establish which installed CMake target owns its header.
For a non-static member-function binding, keep the public `qualified_name` as
the real class-qualified method, provide an exact pointer-to-member
`address_expression`, and use an explicit-object `member_function_call` probe.
The generator must retain address references with `auto volatile`; `auto *`
cannot represent a pointer-to-member. The installed-SDK suite runs the matching
operational probe independently for every binding, not only in the aggregate
capability consumer.
A new TSP algorithm is therefore detected automatically but not advertised by
guesswork: the new/changed public header fails the ledger gate until its owner
makes the explicit registration or non-capability classification.
For a fixed-capacity record algebra such as `music.pattern-development`, bind
the stable record, error, configuration, and result types as well as every
advertised free function. Each free function needs its own operational probe;
a type-only row or one aggregate probe cannot establish that installed
consumers can execute density, fill, set-algebra, ID, and morph operations.
Keep scheduling, clocks, note ownership, and publication outside this manifest.
For bounded sampled-target FIR design, register the public
`pulp/signal/fir_design.hpp` entry point as `signal.fir-design`, keep it
offline-only, and have the generated compile fixture invoke an empty-target
request in addition to taking the exact function pointer. This preserves the
contract's proof that the published binding is operational rather than merely
type-visible.
For a bounded event-domain MIDI player such as `midi.linear-step-player`
(`pulp/midi/step_player.hpp`), follow the `midi.arpeggiator` row shape: one
`cpp_type` binding on the template entrypoint with a `member_call` probe that
default-constructs it and invokes a const query, and add the new key to the
`capability_keys` list of each support header the kernel actually includes
(e.g. `utility_contract.hpp`), not to every MIDI support header.
For an existing capability change:
- Update the reviewed header fingerprint for every public-header byte change,
even when the consumer contract is unchanged. Increase the surface inventory
version. `--write` cannot do the first of those for you: it reports the
measured digest and exits nonzero, because a generator free to restamp a
fingerprint would silently launder every unreviewed header edit. Paste the
measured digest over the declared one in `agent_capability_registry.py`, bump
`SURFACE_INVENTORY_VERSION` in `agent_capability_manifest.py`, then `--write`.
- Before choosing the next `SURFACE_INVENTORY_VERSION`, check whether an open
branch already claimed it. Two branches that both bump 79 to 80 do not
conflict — the edits are identical, so git merges them silently and the
surface ends up recorded under a version that describes two different
inventories. Nothing downstream catches that, because each side passes
`--check` on its own.
```sh
for p in $(ghapp api "repos/Generous-Corp/pulp/pulls?state=open&per_page=100" --jq '.[].number'); do
ghapp api "repos/Generous-Corp/pulp/pulls/$p/files?per_page=100" \
--jq '.[] | select(.filename=="tools/scripts/agent_capability_manifest.py") | .patch' 2>/dev/null \
| grep -q "^+SURFACE_INVENTORY_VERSION" && echo "PR #$p claims a version"
done
```
Skip past any claimed integer rather than racing for it; the values only have
to be distinct and increasing, not contiguous. `ghapp pr list --state open
--json number,files` answers "does any open PR touch the manifest file" in
one call; filter it before fetching any patch.
- A pending sequencer-exposure row can pin the current inventory integer as an
evidence needle (`"SURFACE_INVENTORY_VERSION = 95"`). Bumping the version
stales that row and `sequencer_exposure_check.py` fails on it, not on your
change. Repoint the needle to the new integer — it is a pending row, so the
edit is allowed — and grep every pending row for the outgoing literal first.
- A summary-only edit to a capability row is not a contract change: raising
`contract_version` for it is refused ("contract_version changed without a
contract change"). Leave the version, bump `MANIFEST_REVISION`, and let
`--write` regenerate the installed manifest.
- A byte change to a header still classified `legacy_unreviewed` cannot be
repaired by restamping `tools/agent-capabilities/legacy-unreviewed-baseline.json`.
`FROZEN_LEGACY_COUNT` and `FROZEN_LEGACY_DIGEST` in
`agent_capability_surface.py` pin that file, so editing a fingerprint there
and moving the constants to match is exactly the laundering the frozen
baseline exists to prevent. Graduate the header instead: add a reviewed
disposition for it to `agent_capability_registry.py` with the measured
fingerprint and a durable rationale, and leave the baseline untouched. The
reviewed branch is consulted ahead of the baseline, so the stale baseline
entry is simply no longer read; dozens of headers already sit in both.
- `--write` also appends a full snapshot to
`tools/agent-capabilities/contract-history.json` — tens of thousands of lines
that dwarf the change that caused them. `--check` does not require it, so for
a byte-level fingerprint refresh, revert that file and keep the three-file
change; `--check` still reports `fresh`. Reverting it is not free of meaning,
so keep the snapshot when the change is a real contract movement whose history
someone will read back.
- Increase the capability minor version for compatible additive contract
changes.
- Increase the capability major version when a binding is removed, renamed, or
replaced, or when lifecycle, RT, state, seed, domain, units, latency, tail, or
scheduling semantics change incompatibly.
- Keep `seed_model` and `determinism` separate. When
`determinism-contract-v1` is required, every live row must declare
repeatability, block-partition behavior, platform scope, and whether transport
history is an input. Strengthening a determinism promise is additive;
weakening or removing one requires a major increase or a new successor key.
- Treat a minor-0 row with no `determinism` as unspecified. Consumers that
require determinism must reject it and must reject unknown required features.
- Leave the capability version unchanged for summary-only wording. The generated
digest excludes the summary but covers the material contract.
- Keep numeric parameter ranges/defaults/choices in `forge-catalog.json`; use a
`forge_descriptor` reference instead of copying them.
For realtime capability implementations, do not treat a short-range
`std::stable_sort` as allocation-free merely because the macOS/libc++ probe is
green. libstdc++ may allocate scratch space for every non-empty range while
libc++ keeps small trivially-copyable ranges in place. Prefer a bounded
in-place stable ordering algorithm when the capability already declares a
fixed maximum, and size the allocation negative control beyond libc++'s short
in-place threshold so either standard library can expose a regression.
For removal:
1. First publish the live capability as `status: deprecated` with a matching
deprecated evolution state and ordered lifecycle versions. A capability that
is active in the protected base may not be removed in the current change.
2. In a later published revision, add a `status: removed` capability tombstone
with its introduction/deprecation versions, last version, and digest before
deleting the row. Never reuse a tombstoned key.
3. Add a surface tombstone with the immediately prior reviewed header
fingerprint before deleting a
covered public header.
4. Increase the manifest and surface inventory revisions as applicable.
5. Preserve tombstones permanently; do not rewrite history. Replacement keys
must exist, may not reference themselves, and may not form cycles.
## Interpret support honestly
The published capability table now includes bounded, versioned registered clip
content and trusted note-renderer hooks. Keep that row aligned with the compile
contract: note output/reset state only, a 4096-note fragment cap, and explicit
refusal of trimmed nesting and nondefault-production wire serialization.
- A live capability row's `status` is the explicit support claim. Use only
`stable`, `usable`, `experimental`, `partial`, `unsupported`, or `deprecated`;
never publish planned work.
- A header classified `unsupported_capability` is an explicit negative claim.
- `legacy_unreviewed` means only that no machine-readable claim has been
reviewed yet.
- Coverage is currently `partial`, so an absent key means unknown, not
unsupported.
## Reuse the spectral-mask processor
`signal.spectral-mask-processor` is the shared streaming STFT/WOLA layer for
products that apply authored spectral gain tables. Installed-SDK consumers
include `<pulp/signal/spectral_mask_processor.hpp>` and link `Pulp::signal`.
Prepare it off the audio thread, publish layouts or compiled tables from a
control thread, and call `process()` or `process_frame()` on the audio thread.
The processor owns frame-boundary table adoption, gain interpolation,
overlap-add reconstruction, latency reporting, and latency-aligned dry/wet.
Sample-scheduled host automation is the bounded exception to control-thread
publication. A single audio owner may call `set_layout_rt()` with the latest
fixed-capacity layout while applying events at their block offsets. The
processor copies that layout into prepared storage and compiles/adopts it only
at the next spectral-frame boundary, without allocation, locks, or a
control-thread round trip. Do not call `set_layout_rt()` from multiple writers
or use it as a replacement for UI/state-restore `publish_layout()`; the two
paths deliberately keep separate writer contracts and converge only at the
audio owner's frame-boundary adoption point.
Use categorical mask entries for true mute: a muted bin is multiplied by exact
zero, not represented by a finite decibel floor. Reuse this processor for
zoomable filter banks, spectral gates, freezes, morphing, and related products
instead of rebuilding an application-local STFT lifecycle or publication
protocol. Analyzer snapshots and captured-frame storage are separate layers;
do not infer them from this capability or duplicate them inside the processor.
Before presenting every authored band as independently controllable, call
`analyze_spectral_band_resolution()` with the product's layout, sample rate,
and FFT size. Its fixed-capacity report counts directly owned viewport bins per
band and excludes exterior edge-band extension. `fully_represented() == false`
means the UI or profile selector must disclose the resolution limit, select a
higher supported geometry, or use a different filter architecture; zoom alone
cannot create additional FFT bins.
## Reuse the realtime visualization bridge
`pulp::view::VisualizationBridge` is the shared realtime-safe audio-to-UI tap
for spectrum, waveform, and meter consumers. Configure it while fully
quiescent, call `process()` from the audio callback, and give exactly one UI
thread ownership of `poll()` plus the snapshot reads. The callback path only
meters and copies into fixed SPSC storage; FFT and waveform assembly happen in
the bounded, non-realtime `poll()` call.
`read_spectrum()` and `read_waveform()` remain cheap snapshot reads for source
compatibility. They do not analyze newly captured audio. A consumer that needs
fresh data must schedule `poll()` first, then read or use the explicit
`peek_spectrum()` / `peek_waveform()` aliases. Treat capture overflow, rejected
channel topology, and positive-length missing-channel callbacks as continuity
breaks: the bridge advances its epoch and never joins audio across the gap.
Keep `configure()` and `reset()` quiescent; neither is concurrent with the
audio producer or UI consumer.
## Regenerate and validate
Do not use the bootstrap or unpublished-migration switches during normal work.
When the base branch has already advanced either counter, recompute the next
`MANIFEST_REVISION` and `SURFACE_INVENTORY_VERSION` from that exact base before
running `--write`; replaying stale projection counters can silently reuse an
already-published contract identity.
Use Python 3.10 or newer: transactional generation uses modern standard-library
APIs such as `zip(..., strict=True)`, so an older system `python3` can fail before
validating the contract. Run installed-SDK capability tests from a Release build.
A Debug/coverage build is an invalid positive control because the installed SDK
guard intentionally refuses unacknowledged Debug SDK consumers. Also keep the
build tree free of stale nested SDK install prefixes: archive-mutation checks
require one owning build-tree library per target, and an old consumer-smoke
`prefix/lib` can create a false duplicate-owner failure. Move such generated
fixtures aside and rerun the same test before changing capability code or
weakening the archive check.
Run:
```bash
python3 tools/scripts/agent_capability_manifest.py --write
python3 tools/scripts/agent_capability_manifest.py --check
python3 tools/scripts/test_agent_capability_manifest.py
cmake --build build --config Release --target pulp-test-agent-capability-compile
ctest --test-dir build -C Release -R '^agent-capability-' --output-on-failure
```
The installed-SDK test must install to an isolated prefix, verify maintenance
artifacts are absent, read the installed schema and manifest, and independently
compile/link/run every capability and every typed binding against only its
declared minimal target. The positive consumers may share one CMake configure
and bounded parallel build, but each proof must retain its own source, executable
target, declared-minimal-target link, CMake File API isolation inspection, and
process execution. Coverage validation must reject missing, duplicate, or wrong-
target proofs before configuration. The test must reject wrong-target
declarations and checkout-path leakage, and use configuration-aware build/install
and executable paths. Because that proof can take roughly 18 minutes when every
consumer is configured and built serially on an Apple runner, its CTest
registration carries `slow-affected;agent-capability-installed-sdk` (every
lane's `slow` exclusion matches it). Ordinary PR and merge-group corpora exclude
it, but `classify_changes.py` restores the exact
test on the parallel macOS and Linux matrix legs when the diff touches capability manifests,
schemas, history, registries/generators, vocabulary, install rules, or their
compile tests. All CMake target/export definitions are included because an
`INSTALL_INTERFACE`, exported dependency, or target-name change can break the
isolated consumer even outside `PulpInstallRules.cmake`. A selected documentation
surface also forces allocation of the containing native job; unknown or
unavailable diffs run it fail-closed. Keep that
affected-surface step (macOS is the required queue context; Linux preserves its
platform-specific export proof) and the unfiltered main/nightly proof when changing its
label or registration; `slow` alone is not authorization to stop enforcing it.
When checking CMake File API include paths, permit paths outside the install
prefix only when CMake marks them as system includes; transitive platform and
third-party headers may be legitimate, but non-system source/build leakage is
still a failure. When finding build-tree archives for mutation controls, exclude
the staged install prefix because retrying the test leaves installed archives
under the build directory.
The official-SDK handoff self-test separately covers exact identity plus wrong
source SHA, importer hash, capability hash, and schema-invalid documents.
`test/cmake/quality_tests.cmake` is also the registry for capability-manifest
and adjacent policy self-tests. When adding a new Python policy test there,
register the test explicitly in the same change; merely creating a
`tools/ci/test_*.py` file does not make CTest execute it.
That registry also runs the browser DPR adapter self-test. Keep its exact
Playwright-version, artifact-confinement, product-digest, typed-metric, timer
calibration, logical-input, and same-content fidelity negatives registered;
an executable measurement script without the CTest entry is not maintained
evidence tooling.
That registry also carries the trusted Vellum merge self-test. Keep its clean
base+head positive control and real content-conflict negative control together:
the required gate must prove it can construct the exact two-parent candidate,
and that the same constructor refuses a conflicted candidate before validation.
The surface fingerprint is intentionally conservative SHA-256 over full header
bytes. Do not weaken it with regex symbol extraction. A future pinned-Clang AST
inventory may reduce comment/private-detail churn only if its version and
toolchain are pinned and mutation tests retain add/remove/change detection.
Because the fingerprint covers full bytes, **editing only comments in a
capability header is a surface change** and `--write` will refuse it twice
before it succeeds. The declared fingerprint lives in the catalog source, not
just the generated JSON, so the order is: edit the header, then replace every
occurrence of the old digest in the owning `agent_capability_catalog_*.py`
(one per binding, so a single header can hold a dozen copies), then raise
`MANIFEST_REVISION` and `SURFACE_INVENTORY_VERSION`, then run `--write` once.
Derive both counters from the CURRENT protected base every time. A capability
transaction can land while yours waits in the merge queue, which takes the
numbers you reserved and leaves your branch conflicting on exactly those two
constant lines. Re-read them from main and regenerate rather than resolving
that conflict by hand.
**Do that with the tool, not by hand:**
```bash
python3 tools/scripts/agent_capability_rederive.py --print # read-only: what would change
python3 tools/scripts/agent_capability_rederive.py # rewrite both, then --write
```
It resolves the same protected tip `--check` uses, compares this tree's
generated material against the base's with the counter excluded, and moves each
counter only when its own material actually moved. That asymmetry is the part
worth not hand-rolling: **over-bumping is not the safe direction.** Advancing a
counter whose material is identical fails the opposite rule,
`... changed without a manifest change`, so "bump both to be safe" trades one
red gate for another.
**A taken counter does not always announce itself as a conflict.** Re-read both
counters after *every* merge of the protected base, not only after git reports
one. When two lanes reserve the same next integer, the two sides hold
character-identical constant lines, so the merge is clean and silent — and the
*increase* is what gets annihilated: the surface has changed (your fingerprint)
while the counter equals the base again. That surfaces much later as
`STALE: public surface changed without an inventory_version increase`, on every
platform at once, naming generated files the diff appears not to touch.
Recovery needs the surface document reset **first**. `--write` derives from the
on-disk artifact, which already carries your fingerprint at the taken counter,
so raising the counter alone fails the opposite rule instead:
```bash
git checkout origin/main -- docs/status/agent-capability-surface.json
python3 tools/scripts/agent_capability_rederive.py
```
Reset that one document and nothing else. The same digest also lives in
`REVIEWED_HEADERS` in `tools/scripts/agent_capability_registry.py`, and that copy
must keep the NEW value — resetting it too restores the stale digest and
reproduces the original failure. No integer is picked by hand: `rederive.py`
resolves the protected tip, so it lands on whatever is free.
It refuses rather than guesses when the surface has unresolved problems — a
changed header with a stale fingerprint has no stable material to derive from,
and its fingerprints must be refreshed first. Counters are decided LAST.
Re-running it is idempotent: the counter a tree currently holds is not evidence
of anything, so a tree already carrying a stale reservation is derived back
down to what its material justifies.
### A digest lives in a catalog module, not only in the registry
A reviewed public header's fingerprint can be declared in **either**
`agent_capability_registry.py` (as a `REVIEWED_HEADERS` entry) or a
`agent_capability_catalog_*.py` module (as a binding's `header_fingerprint`).
`--check` names the header and both digests but not the file, and grepping the
registry for a header that is declared in a catalog finds nothing — which reads
as "not tracked" rather than "declared elsewhere". Find the declaration by the
**expected digest**, which is unique:
```sh
grep -rn "<the expected sha256 from --check>" tools/
```
`tools/agent-capabilities/contract-history.json` will also match; it is the
appended snapshot, never the declaration. Measured on
`pulp/timebase/grid_projection.hpp`, whose digest lives in
`agent_capability_catalog_timing.py` as the `project_grid` binding.
### The push gate runs `--check` for you — but only against a resolved base
`gates.sh` and `.githooks/pre-push` both run `--check` before a push, whenever
the diff touches an installed public header or a capability registry/manifest
path. Both pass `PULP_AGENT_CAPABILITY_BASE_REF` set to a **resolved commit**,
never the base's name, because a bare run resolves the *merge base* while CI
forces the *base tip*, and those disagree: measured on one tree in one second,
80 against the merge base and 82 against the tip — and 80 was exactly the
number a branch had already carried into the merge queue. Reproduce a CI
verdict the same way rather than trusting a bare local run.
Two things the gate deliberately does not hide:
- A base that does not resolve prints `SKIPPED` and says a skip is not a pass.
Nothing green is implied by silence there.
- A branch touching none of those paths is skipped with its reason printed. It
cannot take a counter, so there is nothing to go stale.
This matters more than the other push gates because the failure is
**unrepairable after the fact**. The merge group is the only place that sees a
stale counter, and a pull request that has entered the merge queue refuses a
push with `GH006: … Branches that are queued for merging cannot be updated` —
so the fix is locked out by the same queue that is about to reject the branch.
Removing it from the queue is itself gated (`queue-removal-guard: refusing
unaudited merge-queue removal`), which leaves waiting for eviction as the only
unprivileged route. Fifteen seconds before the push replaces roughly an hour of
merge-group time plus a queue lockout.
**Commit the merge before you run it.** During an uncommitted merge the incoming
tip is not yet an ancestor of `HEAD`, so base resolution steps back to the *merge
base* — a commit that predates both sides — and the counter derived from it is
stale while looking entirely plausible. Observed live: it read base 28/45 while
`main` already held 30/47. The tool now refuses in that state rather than
answering the wrong question, so the sequence when resolving a conflict is:
resolve → commit the merge → refresh fingerprints → re-derive → `--write`.
**Take the base's side of a `contract-history.json` conflict.** Merging a base
that landed its own transaction conflicts here as a both-append: your side holds
the entry your `--write` appended, the base holds every entry up to its own tip,
and the two are not reconcilable line by line. `git checkout --theirs` is right,
and it does not discard your transaction — `updated_history_entries()` appends
the entry describing the **previous** manifest, so history never carries your
branch's own state to begin with. That state lives in
`docs/status/agent-capabilities.json`, which the re-derive regenerates
afterwards. Hand-merging the two runs instead produces a history whose tail is
not the protected base's entry, which is exactly what the append-only check
rejects.
Regenerate exactly once from final header bytes. Each `--write` appends a full
entry to `contract-history.json`, so editing the header again after a
successful `--write` leaves two entries for one logical change.
**`--write` is not idempotent, so never use it to *verify* a transaction.** A
second `--write` on an unchanged tree still appends a second history entry, and
it reports the same cheerful `wrote ...` line either way — so the check reads as
confirmation while it is the thing creating the defect. The reviewer then sees
`capability history is not append-only relative to the protected base` for a
transaction that was correct until it was checked. Verify with `--check`, which
writes nothing and answers the same question (`fresh; N keys and M public
headers checked`). If a verification `--write` already ran, do not try to prune
the entry by hand: reset the four generated artifacts to the protected base and
regenerate once.
Prove the append is single rather than assuming it. `git diff --numstat` showing
zero deletions is necessary but not sufficient — two appends are also
deletion-free. Compare entry counts:
```bash
python3 -c "
import json,subprocess
c=json.load(open('tools/agent-capabilities/contract-history.json'))['entries']
b=json.loads(subprocess.run(['git','show','origin/main:tools/agent-capabilities/contract-history.json'],
capture_output=True,text=True,check=True).stdout)['entries']
print('delta', len(c)-len(b), 'prefix-identical', c[:len(b)]==b)"
```
`--check`'s diagnostics go to **stderr**, not stdout. A script that scrapes the
expected/got fingerprint pairs from captured stdout alone silently sees nothing
and reports success.
### Publishing a capability on a NEW public header takes four coordinated edits
`--write` validates one precondition at a time and stops at the first failure,
so a new-header capability surfaces as four *unrelated-looking* errors in
sequence rather than one checklist. Make all four edits before running it:
1. **Catalog entry** — a `capability(...)` block in
`agent_capability_catalog_<domain>.py`.
2. **Registry header ownership** — `"pulp/<domain>/<new>.hpp": "Pulp::<domain>",`
in `agent_capability_registry.py`. Without it the error is
`bindings[N] include has no covered public target owner`, which names the
*binding* rather than the missing map entry — the message points away from
the fix.
3. **Umbrella fingerprint** — adding the include to `<domain>.hpp` changes that
umbrella's digest too. Update the declared value to the `got sha256:` the
error reports. The stale digest also appears in `contract-history.json`;
**do not edit those** — history is append-only.
4. **Both counters** — `MANIFEST_REVISION` and `SURFACE_INVENTORY_VERSION`,
reported as two separate errors.
### History stores prior snapshots; the current artifacts store the new contract
`agent_capability_rederive.py` first restores the generated manifest, surface and
history from the protected base, then calls `--write`. Its output can print the
write before the reset message because the subprocess output is flushed first;
that display order does not mean the reset happened last.
`updated_history_entries()` in `agent_capability_history.py` appends the
**previous** manifest/surface snapshot unless it is already the last entry. It
stores the current snapshot directly only for an empty/bootstrap history. The
new contract and header fingerprints belong in the current generated manifest
and surface; their absence from the last history entry is not a failed write.
After rederive, verify the protected history is an unchanged prefix, inspect
which prior snapshot was appended, and run `agent_capability_manifest.py --check`.
Do not blindly call `--write` again to put the current fingerprint in history:
that second call treats the just-generated artifacts as previous material and
adds an unnecessary current snapshot. A one-entry delta is expected when the
protected base's own snapshot was not already recorded, but check the material
rather than prescribing a count independently of the starting history.
`rederive.py` refuses while a merge is in progress when the incoming commit is
not the protected base. Commit the merge before deriving counters so the base
resolver cannot silently compare against an older merge base. The checker's
protected-base prefix and evolution rules remain authoritative; never repair
history by rewriting or deleting protected entries.
### A catalog-bound header must NOT also get a `REVIEWED_HEADERS` row
A header named by a `binding(...)` in a catalog is already a capability
entrypoint. Adding a `capability_support` row for it in `REVIEWED_HEADERS` fails
with `headers cannot be both capability entrypoints and separately reviewed`,
and that failure arrives from `rederive.py` as a *surface* problem, which reads
like a fingerprint issue rather than a duplicate-registration one.
For a new kernel header, `REVIEWED_MINIMAL_TARGETS` is the only registry edit it
needs. `REVIEWED_HEADERS` rows are for headers that no catalog binds: shared
vocabulary headers (whose `capability_keys` list names the kernels expressed
over them) and `infrastructure` headers such as a private `detail/` helper,
which bind no key of their own.
### `header_fingerprint` IS the SHA-256 of the header file's bytes
The surface document says so itself — `"fingerprint_algorithm":
"sha256-file-bytes"` — and every one of the 453 declared fingerprints agrees
with the file on disk:
```python
import json, hashlib, pathlib
d = json.load(open('docs/status/agent-capability-surface.json'))
differ = [r['source'] for r in d['headers']
if 'sha256:' + hashlib.sha256(pathlib.Path(r['source']).read_bytes()).hexdigest()
!= r['fingerprint']]
print(len(d['headers']), 'headers,', len(differ), 'differ') # 453 headers, 0 differ
```
The only real difference is the `sha256:` prefix the declared value carries and
bare `shasum` output does not — which is what makes the two look unequal at a
glance. Corrupt one declared value and the same loop reports it, so a clean run
is a measurement rather than a tautology.
An earlier revision of this section claimed the two "legitimately differ" and
told you not to reconcile them by hashing the file. That was wrong, and it is
the expensive kind of wrong: it reads as permission to paper over a genuine
mismatch, when a mismatch means the header moved and the surface did not.
### `--write` cannot fix a fingerprint — it is authored, not generated
The fingerprint is declared in two places that must be edited by hand and kept
equal: the `header_fingerprint=` literal in the capability's `EXPORTS` row in
`tools/scripts/agent_capability_catalog_performance.py`, and the `fingerprint`
field for that `source` in `docs/status/agent-capability-surface.json`.
Regeneration checks them; it does not author them. So after changing a public
capability header:
1. `shasum -a 256 <header>` and prefix the digest with `sha256:`,
2. write that value into both places above,
3. bump `SURFACE_INVENTORY_VERSION` in `tools/scripts/agent_capability_manifest.py`
(surface axis — see the next section: this costs no contract bump),
4. re-run `--check` and confirm it reports `fresh`.
### The fingerprint lives in THREE places, and `--write` adds a fourth file
The recipe above names two. A third holds the value the checker actually
compares against: the `fingerprint` field of the header's row in
`REVIEWED_HEADERS` in `tools/scripts/agent_capability_registry.py`. Updating
only the surface document leaves `--write` and `--check` both reporting the
same mismatch with the old digest, which reads as "regeneration is broken"
rather than "one more literal to edit". Grep the old digest across
`tools/` and `docs/` and replace every hit.
`--write` then also appends a full manifest snapshot to
`tools/agent-capabilities/contract-history.json` — tens of thousands of lines
recording the (`manifest_revision`, `inventory_version`) pair. That append is
not required for freshness: `--check` reports `fresh` on the three-file edit
alone, so discard the history hunk unless the change is one whose lineage the
history is meant to carry. Confirm with `--check` rather than assuming either
way.
### Adding a function to an existing capability header costs NO contract bump
A binding's identity is `(role, kind, include, qualified_name, target,
availability)`. **`header_fingerprint` is not a component**, and it does not
appear in the capability row at all — it lives only in the surface document,
versioned on its own `SURFACE_INVENTORY_VERSION` axis. So a pure header-bytes
change is a *surface-axis* event that is invisible to every capability contract
payload.
Two agents independently reasoned "fingerprint ∈ bindings ∈ contract_payload,
therefore this needs a version bump," each having verified `_binding_identity`
(which legitimately excludes the fingerprint) and let that stand in for
checking the snapshot's actual binding shape one level down. The generator
rejected it with `contract_version changed without a contract change`. If you
are adding a function to a header that already backs a capability, expect the
existing key to stay at its current version and the change to be absorbed by
the two counters.
## Two capabilities can pin the SAME header fingerprint
`--write` / `--check` verify each binding's `header_fingerprint` against the
file's real bytes, and several capabilities may bind into one header. Adding a
second entry point to an existing header therefore breaks the FIRST capability's
fingerprint too, and the failure names the header rather than the capability, so
it is easy to scope the fix too narrowly:
```
capability bindings disagree on fingerprint: pulp/signal/fir_design.hpp
public header fingerprint changed: pulp/signal/fir_design.hpp; expected <old>, got <new>
```
Refresh EVERY binding that pins that header, not only the one you added. Compute
the value from the post-edit bytes:
```sh
python3 -c "import hashlib;print('sha256:'+hashlib.sha256(open('<header>','rb').read()).hexdigest())"
```
Then run `tools/scripts/agent_capability_rederive.py`. It decides the counters
LAST and refuses to run while the surface has unresolved problems, saying so
explicitly. That ordering is deliberate: fix fingerprints first, derive counters
second. Never hand-edit `manifest_revision` / `inventory_version` in the
generated JSON; they are projected from source constants and are regenerated.
## Editing a frozen `legacy_unreviewed` header is a capability transaction
Any edit to a public header sitting in the frozen legacy bucket fails
`--write`/`--check` with `public header fingerprint changed`, and you cannot fix
it by updating the fingerprint in
`tools/agent-capabilities/legacy-unreviewed-baseline.json`: the next error is
`legacy baseline digest changed; the frozen legacy_unreviewed set may only shrink
through explicit reviewed classifications`. That is deliberate. The frozen set is
content-pinned so headers in it cannot be edited silently.
The sanctioned path is to classify the header OUT of the bucket. That takes two
edits, and **the baseline file is not one of them**:
1. add it to `REVIEWED_HEADERS` in `tools/scripts/agent_capability_registry.py`
with its NEW fingerprint, a disposition, and a rationale; then
2. run `python3 tools/scripts/agent_capability_rederive.py`, not a hand-edit, to
move the counters. Editing `manifest_revision` / `inventory_version` directly
in the generated JSON does nothing: they are projected from
`MANIFEST_REVISION` / `SURFACE_INVENTORY_VERSION` constants, so `--write`
regenerates them and still reports `changed without a revision increase`.
**Do NOT delete the entry from the baseline, decrement `frozen_count`, recompute
`entries_digest`, or touch `FROZEN_LEGACY_COUNT` / `FROZEN_LEGACY_DIGEST`.** The
declaration alone satisfies the fingerprint check; the surface document derives
each header's disposition from `REVIEWED_HEADERS` first, so a declared header
stops being counted as legacy without the snapshot changing at all. Editing those
pinned constants to make a PR pass removes the deliberate guard — see "A header
in the frozen legacy baseline does NOT require unfreezing anything" below, which
is the authoritative statement. Afterwards confirm the baseline file has no diff
and its entry count is unchanged; `--check` should report `fresh`, and the
surface's `legacy_unreviewed` count should have dropped by exactly the number of
headers you declared.
Only classify a header when the classification is already defensible from a
written decision, and only when the edit that tripped the gate is load-bearing
for the change (the triage in that later section). Inventing a classification to
unblock an incidental edit converts a safety gate into paperwork.
## A portability fix to a frozen header classifies as `infrastructure`, not a capability
The triage above asks whether your capability actually needs the header to
change. Sometimes the header change *is* the whole change: a public header can
be unbuildable on a platform and the fix has to land in it. `simd_buffer.hpp`
wrapped C11 `aligned_alloc`, which Bionic does not provide below Android API 26,
so every Android build of anything including it failed to compile.
That still trips the frozen-baseline gate, and it still resolves by declaring
the header in `REVIEWED_HEADERS` — but declare it honestly:
- `"disposition": "infrastructure"` with `"capability_keys": []`. The edit
changed no consumer contract, so there is no capability to claim. Inventing
one to look tidier would assert a binding nothing offers.
- Say in the rationale that the branch is a portability detail of the primitive
rather than a change to the surface it presents, so a later reader is not left
guessing whether the allocator's behavior moved.
- It is a byte-level fingerprint refresh, so revert the `contract-history.json`
snapshot per the guidance above and keep the three-file change.
Confirm the same post-conditions as any classification: the baseline file has no
diff, its entry count is unchanged, `--check` reports `fresh`, and the surface's
`legacy_unreviewed` count dropped by exactly one.
**Measure `--write`'s exit status unpiped.** On a frozen header it prints
`INVALID: public header fingerprint changed` and exits 1, exactly as documented
above — but `--write | tail` reports `tail`'s status instead, which reads as a
silent success and invites the wrong conclusion that the generator accepted the
edit.
## The rederive self-test dirties the checkout for its whole run
`test_agent_capability_rederive.py` and `test_agent_capability_manifest.py` both
rewrite tracked files in place — including `tools/scripts/agent_capability_manifest.py`
itself — and restore them in a `finally`. Between the write and the restore the
repository is dirty *by construction*, and under `-j` contention that window is
the test's entire runtime rather than the fraction of a second it looks like.
That is why these tests hold `RESOURCE_LOCK agent-capability-manifest-source`.
The lock is not about the two tests colliding with each other; it is about
excluding anything that **observes** repository-wide state, which today includes
a CLI contract asserting the source tree is pristine. When you add a test here
that writes a tracked file, put it in that lock group. When you add one elsewhere
that reads global tree state, put it there too.
Declare `PROCESSORS` on anything in this family. The manifest self-test already
does; a sibling that omits it lets ctest schedule eight more tests alongside it,
which is how a test that runs in under twenty seconds locally reaches the
inherited 120s timeout on a loaded runner. Declaring the real cost is scheduling
accuracy, not a loosened budget.
## A same-size constant edit can be masked by a stale `__pycache__`
`FROZEN_LEGACY_COUNT = 338` to `337`, and one 64-hex digest to another, both
leave the source file byte size UNCHANGED. CPython invalidates bytecode on
`(source mtime, source size)`, so a `.pyc` written moments earlier can survive an
edit that changed neither, and the interpreter keeps executing the OLD constant.
The symptom is a contradiction that looks impossible: `python3
tools/scripts/agent_capability_manifest.py --check` prints `fresh`, while the
identical `agent-capability-manifest-check` ctest fails against the pre-edit
value. The two ran DIFFERENT interpreters (`ctest` uses the CMake-resolved
`Python3_EXECUTABLE`, often a specific `python3.N`), each with its own
`cpython-3N.pyc`, and only one cache was stale.
Before believing either result, reproduce with the interpreter the test actually
uses:
```sh
PY=$(grep -m1 "Python3_EXECUTABLE:" build/CMakeCache.txt | cut -d= -f2)
"$PY" tools/scripts/agent_capability_manifest.py --check
```
and clear the caches when a constant edit did not change file size:
```sh
find tools -name __pycache__ -type d -exec rm -rf {} +
```
## A STALE verdict on a tree you did not touch is a base problem, not a you problem
If `--check` reports these on a clean checkout whose diff touches no capability
file, stop and look at the base before touching anything:
```
agent-capabilities: STALE: capability history is not append-only relative to the protected base
agent-capabilities: STALE: manifest changed without a manifest_revision increase
agent-capabilities: STALE: public surface changed without an inventory_version increase
```
The check is not wrong — it is answering correctly against the wrong reference.
The protected base **must be an ancestor of the commit under validation**, or
"append-only relative to the base" is ill-posed: measured against a tip that
carries commits your branch does not have, every correct branch looks
non-append-only.
Naming a moving ref makes that routine. The build hosts run ~134 worktrees off
one shared `.git`, so a `git fetch` in **any** sibling advances `origin/main`
for all of them — mid-validation included, with your session issuing no fetch.
`_resolve_local_base` therefore checks ancestry and steps back to the merge-base
when the ref has moved past you. The merge-base does not move when the tip
advances, which is what makes the verdict reproducible.
Two consequences worth knowing:
* **Re-running does not help.** The tip keeps moving, so the failure reproduces
and reads like a real defect. It is the failure mode most likely to send you
editing correct generated files.
* **"Don't fetch during a validation" cannot fix it** and is not the rule. The
ref was observed moving with the validating session issuing no fetch at all;
discipline is blind to peers sharing the `.git`.
To pin the base explicitly — for a bisect, or to reproduce a CI verdict exactly:
```sh
PULP_AGENT_CAPABILITY_BASE_REF=<sha> python3 tools/scripts/agent_capability_manifest.py --check
```
That path is deliberately literal: an explicit ref is used as given, without the
ancestry fallback.
## `gates.sh` runs one check the capability transaction never mentions: the exposure ledger
The inverse of the section below is also true and catches people going the other
way. While an `agent_capability_catalog_*.py` file is an *exclusively owned* path
in `docs/status/sequencer-exposure`, adding a `capability(...)` block to it makes
`gates.sh` fail with
transition: sequencer-owned changed path is not covered by an added or
materially changed pending row: tools/scripts/agent_capability_catalog_<domain>.py
Nothing in `agent_capability_manifest.py --check` predicts this — it reports
`fresh` while the push is still blocked — and the four-edit checklist below is
silent about it because the fifth edit lives in a different gate entirely. The
fix is a **new** `pending` row under `docs/status/sequencer-exposure/rows/`
owning that catalog file and its own row file; an existing row that already owns
the path does not satisfy the transition rule. A capability published on an
installed header fills `installed_sdk` and `design_time_agent_manifest` as
`exposed` and the three timeline surfaces as `not_applicable`; measure
`installed_sdk` against the per-subsystem `install(DIRECTORY …)` loop in
`tools/cmake/PulpInstallRules.cmake` rather than assuming it.
**Do not generalise that to "every catalog is watched" — most are not, and the
owner count is what decides.** `_exclusively_owned_paths` in
`tools/scripts/sequencer_exposure_check.py` watches a path only while *exactly
one* row or tombstone declares it, in **both** the base and the resulting ledger
state. Zero owners is not watched, and two or more are shared by construction and
are not watched either. So the recorded fix is self-limiting: the row added to
satisfy the gate is another owner, and once a catalog has two, the next
`capability(...)` block added to it passes this gate silently. Measured on this
tree, only `agent_capability_catalog_performance.py` is watched at all;
`agent_capability_catalog_timing.py` already carried two owners before this row
existed, and `foundations`/`signal` carry none.
Count the owners of the file you are about to touch, and **count them through the
checker's own loader**. A glob over `docs/status/sequencer-exposure/rows/*.json`
gives the wrong answer three ways: one file can carry several rows, released rows
do not all live there, and tombstones declare `owned_paths` too. `_declared_owners`
is the authority:
```sh
python3 - <<'EOF'
import pathlib, sys
sys.path.insert(0, "tools/scripts")
import sequencer_exposure_check as check
root = pathlib.Path(".").resolve()
ledger = check.load_ledger_from_worktree(root)
owners = check._declared_owners(ledger[0] if isinstance(ledger, tuple) else ledger)
for path in sorted(p for p in owners if "agent_capability_catalog_" in p):
print(len(owners[path]), path, sorted(owners[path]))
EOF
```
A catalog printing `1` is watched by this gate; `0`, or `2` and up, is not.
## `gates.sh` does NOT run the capability check — adding a public header passes pre-push and fails in CI
The pre-push gates cover skill-sync, version-bump, compat, deps and friends. They do **not**
run `agent_capability_manifest.py --check`. So a change that adds a header under a covered
root, or edits an existing one, sails through `gates.sh: all gates pass` and then fails CI on
`agent-capability-manifest-check` / `-selftest`.
Adding one new DSP header produces two failures, not one:
unclassified public header: pulp/signal/<new>.hpp
public header fingerprint changed: pulp/signal/signal.hpp <- the umbrella include
The umbrella one is the easiest to miss: adding `#include <pulp/signal/foo.hpp>` to
`signal.hpp` changes *that* header's bytes too.
**Run `python3 tools/scripts/agent_capability_manifest.py --check` yourself before pushing any
change under `core/*/include/`.** Gates passing is not evidence here.
Classify honestly: a reusable bounded surface that is not an advertised generator claim takes
`infrastructure` with empty `capability_keys` and a durable rationale. Do not manufacture a
capability row to clear the gate; a capability row is a consumer contract with typed bindings
and operational probes.
**A header in the frozen legacy baseline does NOT require unfreezing anything.**
`tools/agent-capabilities/legacy-unreviewed-baseline.json` snapshots the public headers that
predate classification, guarded by `FROZEN_LEGACY_DIGEST` and `FROZEN_LEGACY_COUNT` in
`tools/scripts/agent_capability_surface.py`. Changing one fails with `public header fingerprint
changed`, which reads like it demands editing those pinned constants — it does not, and editing
them to make one PR pass would be removing a deliberate guard. Declaring the header in
`agent_capability_registry.py`, as a capability or as an `infrastructure` disposition, satisfies
the fingerprint check on its own; the baseline file, its entry count, and its digest all stay
untouched. Confirm afterwards that the baseline entry count is unchanged and the manifest
self-tests still pass.
**Ask the prior question first: does your capability actually need that header to change?**
Both this section and the recorded precedents jump straight to *how* to classify a frozen
header, which quietly assumes the edit to it is load-bearing. Often it is not. A cell that adds
a new processor and, while it is in there, refactors three existing processors onto a shared
kernel will trip this gate on headers its capability never touches — and the whole gate
disappears if the refactor is dropped.
So triage the edit before classifying the header:
- **Incidental to the capability** (a dedup, a comment, a rename, an ordering tidy) — revert it
and shrink the cell. Nothing about the new capability depends on it, the frozen baseline stays
out of the change entirely, and the diff shrinks to what a reviewer can actually check.
- **Required by the capability** — declare the header. The recorded example is
`frequency_response.hpp` templated over `SampleType` so the `_64` variants could compute a
response without narrowing: reverting it would have shipped those variants with no response
inspection, so there was no smaller correct slice.
The asymmetry is what makes this worth doing in that order. Reverting an incidental edit costs
one `git checkout origin/main -- <header>`. Declaring a header is permanent: it converts an
unreviewed legacy header into a reviewed contract the repo then owns, decided as a side effect
of a cleanup rather than on its own merits. If the dedup is worth having, it is worth its own
change, where the classification is the subject of review instead of collateral.
When you do revert, prune whatever the reverted call sites were the only users of. A shared
helper introduced for three call sites that no longer exist is dead public surface, and it
enlarges the very fingerprint you are trying to keep small.
### Keep platform-only overloads out of the portable vocabulary cap
The compatibility vocabulary intentionally publishes a bounded method list per type. A
conditional platform implementation overload can therefore crowd out a portable API even
though the public header still contains both. When adding such an overload, exclude its
platform-only signature from the generator-facing compatibility projection and add a
regression that asserts both sides: the portable method remains advertised and the conditional
implementation signature is absent. Do not raise the global method cap to hide this local
classification error.
### `SURFACE_INVENTORY_VERSION` is a shared ledger too — pick it by survey, not by increment
Every branch that moves a public header has to raise it, so concurrent branches
contend for the same integer. The obvious move — read main's value and add one —
is wrong whenever anyone else is mid-flight, and it fails in the quietest
possible way: two branches that both write the same number produce *identical*
text, so git finds nothing to conflict on and both auto-merge clean. The
collision surfaces later, at the merge commit, as an inventory version that did
not actually increase over the branch that landed first.
Choose `max(all live branches) + 1`, not `main + 1`:
```sh
git for-each-ref --format='%(refname)' refs/remotes/origin \
| grep -v -- '--help\|/HEAD$' > /tmp/refs.txt
xargs -n 300 sh -c \
'git grep -h "^SURFACE_INVENTORY_VERSION" "$@" -- tools/scripts/agent_capability_manifest.py' _ \
< /tmp/refs.txt | grep -o '[0-9][0-9]*$' | sort -n | uniq -c | tail
```
Two details that are load-bearing, because getting either wrong returns an empty
result rather than an error — and an empty survey reads exactly like "nobody
holds a number", which is the answer that makes you collide:
- **The refs must land in revision position, before `--`.** Anything after `--`
is a pathspec, so `git grep PATTERN -- path ref1 ref2` searches no revisions
and matches nothing.
- **macOS `xargs` has no `-a`.** `xargs -a file …` aborts with `invalid option`;
redirect the file in with `<` instead. BSD and GNU differ here and the BSD
failure is easy to miss inside a pipeline.
So pair the survey with a control that must return non-zero — `git grep` the
same constant on `origin/main` alone, which is known to carry it. If the control
is silent the instrument is broken and the survey proved nothing. A gap in the
observed numbers is not an invitation to fill it: prefer one above the maximum,
since a gap usually means that branch already landed or was deleted.
### `test_signal_no_exceptions.cpp` is a shared ledger — three hazards, not two
Nearly every signal capability appends to it, and it assigns a **unique non-zero exit code per
checked capability** so each failure is identifiable from the status alone. Cells prepared in
parallel therefore collide in it constantly. Two hazards are well known; the third is not, and
it defeats the checks for the other two.
1. **Duplicate exit codes.** Two cells both append above the same remembered maximum and take
the same numbers. Compiles fine, both pass in isolation, and two distinct failures become
indistinguishable. Only a duplicate-code scan finds it.
2. **Duplicate capability blocks.** Rebuilding the file from main's canonical version and
appending your block is the right resolution, but a cell with more than one commit touching
the file re-adds what the resolution already appended. This one is usually loud
(`redefinition of ...`).
3. **Two statements sharing a line.** A resolution can leave a declaration on the same line as
the preceding `return`:
```cpp
if (!(formants.configure(recipe) == FormantConfigureStatus::configured))
return 29; pulp::signal::ParallelDynamicsMixer parallel_dynamics;
if (!parallel_dynamics.prepare(8u, 16u))
return 31;
```
This compiles, and it is *reachable* — the `if` body is only the return, so the declaration
still executes — so no test can see it and neither the duplicate-code nor the
duplicate-block check fires. Both of those are **line-oriented**, and two statements on one
line is precisely the shape that slips past them. Observed on a published PR in 2026-08.
It is not a correctness bug today, which is why it survives review; it is a latent one. The
same shape with a guarded early return (`if (cond) return N; Type x;` inside a block that
can be taken) silently skips the declaration and every check after it, and the capability's
proof quietly stops running while the binary still exits 0.
**So do not hand-resolve this file.** Rebuild it from `git show origin/main:` and re-append your
block programmatically, which removes all three by construction — that is how the line-sharing
defect above was removed, as a side effect of the correct procedure rather than by spotting it.
Then verify all three: no non-zero code repeats, each capability block appears exactly once, and
no line carries two statements.
Extract codes from every return form, including ternaries (`return c ? 0 : N;`) — a naive
`grep -oE "return [0-9]+;"` misses those and manufactures phantom collisions.
For A3 v2 terminal acceptance, never treat receipt fields as publication or trace proof. The verifier must derive protected `main`, the canonical receipt blob, required check identities/results, and artifact digests live, then replay the pinned analyzer over the exact trace bytes.
## Graduating a frozen legacy header: add to the registry, never restamp the baseline
Editing the bytes of a header carried in
`tools/agent-capabilities/legacy-unreviewed-baseline.json` fails the check with
`public header fingerprint changed`. The baseline is pinned twice over —
`FROZEN_LEGACY_COUNT` and `FROZEN_LEGACY_DIGEST` in
`agent_capability_surface.py` — so editing that file to match is the laundering
the pin exists to prevent, and it fails anyway.
The supported move is to **graduate** the header: add an entry to
`REVIEWED_HEADERS` in `agent_capability_registry.py` with the header's new
fingerprint, a `disposition` (`infrastructure` when it binds no capability of
its own), and a rationale. `build_surface_document` consults `reviewed` **before**
`baseline_entries`, so the baseline row is simply never reached — leave that file
byte-identical. The frozen count stays 337 and its digest stays valid.
### The version bump compares against the snapshot on disk, not against main
`SURFACE_INVENTORY_VERSION` must increase relative to
`docs/status/agent-capability-surface.json` **as it currently sits in the working
tree** — which your own previous `--write` already moved. So a second round of
source edits (a `format_changed.sh` reflow is enough, since it changes the
header's bytes and therefore its fingerprint) makes `--write` exit 1 with
`public surface changed without an inventory_version increase`, even though you
already bumped. Bumping again burns a second published identity for one change.
Reset the snapshot to the base and write once instead:
```sh
git checkout origin/main -- docs/status/agent-capability-surface.json
python3 tools/scripts/agent_capability_manifest.py --write
```
Corollary: run `format_changed.sh` **before** deriving the fingerprint, or
re-derive after it. A fingerprint pasted from a pre-format read is stale.
### Pick the version above every branch in flight, not above main
Identical bumps on two branches merge cleanly and silently reuse one published
identity, so incrementing main's value is not enough. Survey the remote branches
first — and note that in zsh a `"$ref:tools/..."` expansion applies the `:t`
history modifier and silently mangles the path, so the survey loop returns
nothing while looking like a clean negative. Drive it from Python, or verify the
loop against a ref you know carries the constant:
```sh
python3 - <<'EOF'
import subprocess, re
refs = subprocess.run(["git","for-each-ref","--sort=-committerdate",
"--format=%(refname)","refs/remotes/origin","--count=250"],
capture_output=True, text=True).stdout.split()
seen = {}
for ref in refs:
r = subprocess.run(["git","show",f"{ref}:tools/scripts/agent_capability_manifest.py"],
capture_output=True, text=True)
m = re.search(r"^SURFACE_INVENTORY_VERSION\s*=\s*(\d+)", r.stdout, re.M) if not r.returncode else None
if m: seen[ref] = int(m.group(1))
assert seen, "instrument dead - no branch yielded the constant"
print("branches read:", len(seen), "max:", max(seen.values()))
EOF
```
## `PulpInstallRules.cmake` fires this gate for reasons that have nothing to do with capabilities
The skill-path map ties this skill to `tools/cmake/PulpInstallRules.cmake`,
which is right — that file decides what reaches the SDK, and a new public
header arriving there is squarely this skill's business.
But the same file also carries the SDK's *non-header* payload: CMake modules,
plist templates, catalogs. Adding a file there because an app-bundling feature
needs to ship a template trips this gate with nothing to classify.
Both outcomes are legitimate; say which one you are in rather than reaching for
the bypass trailer by reflex:
- **New public headers under a covered root** — classify them, and re-read the
`PUBLIC_ROOTS` note above before trusting a green `--check`.
- **Non-header SDK payload only** — no capability surface changed, and the
honest record is a line in this skill saying so, not a `Skill-Update: skip`.
A note costs the same as the trailer and leaves the next person something to
read.
## A platform header installed for per-binary recompilation is not capability surface
`PulpInstallRules.cmake` installs more than public API. The macOS ObjC cluster
under `core/view/platform/mac` is shipped as *source*, because ObjC class names
are process-global and every consumer binary has to recompile those translation
units with its own class-name suffix. A `.h` arriving in that install block is
a private header a consumer compiles against, not a header anyone includes.
So adding one fires this gate and there is nothing to classify: the path is
outside all six `PUBLIC_ROOTS`, no manifest key gains or loses a digest, and
`agent_capability_manifest.py --check` is green before and after. Run it anyway
and say so, rather than assuming it from the path.
The thing that *does* need care in that block is unrelated to capabilities and
easy to miss, so it is written down here because this is the skill an editor of
`PulpInstallRules.cmake` is sent to. A macOS ObjC translation unit is named in
three hand-maintained lists: `core/view/CMakeLists.txt`, this file, and
`_pulp_view_objc_srcs` in `PulpUtils.cmake`. Omitting the install entry makes
the `EXISTS` probe in `_pulp_apply_view_mac_objc_suffix()` fail, and that
`return()`s, dropping the per-binary suffix for *every* macOS ObjC class rather
than the new one. The binary still builds and still runs; it collides only when
a second Pulp plug-in is loaded beside it, in somebody else's host. The
`mac-objc-source-list-guard` ctest and its selftest exist to catch that.
The private headers those `.mm` files quote-include are in the same install
list, and the guard checks them too: a consumer compiles the cluster as one
generated per-binary translation unit that includes each `.mm` by absolute
path, so a header missing from the install breaks the consumer's compile
rather than its class names. `plugin_view_host_mac_view.h` (the shared
PulpPluginView / PulpGpuPluginView interfaces) and `mac_text_input_ranges.h`
are the ones a reader is least likely to expect there.
## A green `--check` says nothing about a module outside `PUBLIC_ROOTS`
`PUBLIC_ROOTS` in `tools/scripts/agent_capability_surface.py` lists exactly
seven domains: `audio`, `midi`, `music`, `playback`, `sequence`, `signal`,
`timebase`. Headers anywhere else are not scanned, not classified, and not
fingerprinted.
That matters most at the moment it is least visible. Adding a new optional
module under `core/` and exporting it — appending the target to
`PULP_SDK_TARGETS` and the directory to `_pulp_sdk_header_subsystems` in
`tools/cmake/PulpInstallRules.cmake` — ships its public headers in the SDK. Then
`agent_capability_manifest.py --check` prints `fresh; N keys and M public
headers checked` and exits 0, which reads like the new headers were reviewed.
They were not looked at.
Control the reading before trusting it:
```sh
python3 -c "import re; t=open('tools/scripts/agent_capability_surface.py').read(); \
print(re.findall(r'\"source\": \"([^\"]+)\"', t))"
```
If the new module's include root is absent, the manifest has no opinion about
it, and the header count staying put is the expected result rather than
evidence of coverage.
The `PulpInstallRules.cmake` edit is what fires the skill-sync gate for this
skill, and that is the right moment to make the call deliberately: does the new
domain belong in `PUBLIC_ROOTS`? A DSP or generator-facing surface does. An
authoring, packaging, or container surface — where "capability" would mean a
consumer contract with typed bindings and operational probes that do not exist —
does not, and adding it would mean manufacturing rows to describe headers no
generator claims. Record which way you went; silence here looks identical to
having never asked.
## A domain absent from the manifest is invisible, not absent
An unpublished domain does not read as "unknown" to the agents that consume this
manifest. It reads as "Pulp does not have this", because a capability search only
sees keys that exist. `agent-capabilities.json` carried 106 keys across `signal`,
`midi`, `timebase`, `audio`, `music` and `sequence` and **zero `timeline.*` rows**,
so two independent passes searching for a groove projector both found
`timebase.groove-kernel` — the strict non-reordering realtime subset — and never
saw `timeline::GrooveTemplate`, the canonical authored model the kernel's own
header points back to. One of them concluded a planned slice was impossible.
The tell is that nothing failed. Coverage was already `partial` with
`absence_semantics: unknown`, every gate was green, and the manifest was
internally consistent the whole time. A missing domain has no negative control of
its own, so when weighing whether a surface deserves a row, ask what a consumer
would *search* for and what it would conclude from finding nothing — not whether
anything currently complains.
When two rows are near-neighbours that differ in a load-bearing way, say so in
`state_model`, which the digest covers, rather than only in `summary`, which it
does not. `timeline.groove-template` names `timebase.groove-kernel` and states
that the authored table half may reorder events while the kernel refuses to, so
whichever one a search reaches first leads to the other.
## Publishing a row for a domain outside `PUBLIC_ROOTS`
Widening `PUBLIC_ROOTS` admits a whole domain to the header ledger and obliges a
reviewed disposition for every header in it. Publishing a capability does **not**
require that, and conflating the two turns a five-row change into a thirty-header
classification pass. `build_surface()` resolves a binding include that
`discover_headers()` did not inventory through `core/*/include/<include>`,
verifies its declared fingerprint, and requires only a `REVIEWED_MINIMAL_TARGETS`
owner. `pulp/host/*` has published this way for a long time.
Take that path when the generator-facing part of a domain is a minority of its
headers — a document/authoring subsystem whose musical-context types a generator
reads, with editing, persistence, schema and interchange headers around them that
no generator consumes. The domain then reports `not_inventoried` with a nonzero
capability count, which is the honest reading: these specific rows are reviewed
and the rest is unknown. Widening the root instead would demand a claim about
every neighbouring header that nobody measured.
Two edits are easy to miss on this path because the first one masks the second:
1. **`DOMAINS`** in `agent_capability_manifest.py`. Until the domain is listed,
the row is rejected as an unknown domain.
2. **The `domain` enum in `docs/status/agent-capabilities.schema.json`.** This is
a *different file* from the surface schema the four-edit list above names, and
it fails only after `DOMAINS` already accepts the row —
`$.capabilities[N].domain: 'x' is not one of [...]`, reported by the schema
validator rather than by the registry, so it reads like a regenerated-artifact
problem rather than a missing enum member.
## `--check` says `fresh` while the history is missing your key
`agent_capability_rederive.py` resets the generated artifacts to the protected
base before regenerating. Immediately afterwards `contract-history.json` can hold
a snapshot that predates the new key while `agent_capability_manifest.py --check`
reports `fresh` and exits 0 — because `--check` does not require the history to
contain the current contract. Measured: after a rederive a grep for
`timeline.groove-template` returned **0** while the control `timebase.groove-kernel`
returned 62; a following `--write` moved them to **1** and 63, and `--check` said
`fresh` in both states.
So do not read `--check` as proof the history recorded anything. Grep the history
for your own key alongside a key you know is already there, and compare both
counts. Then check `entries`: running `--write` after a rederive that already
wrote appends a **second** snapshot (61 → 63, ~35k lines for a change that needs
~18k). Restore the file from the protected base and run `--write` exactly once so
the diff carries one snapshot.
## Widening `PUBLIC_ROOTS` is four more edits, and each hides the next
The four coordinated edits above cover a new header inside a domain that is
already scanned. Admitting a whole new *domain* is a second, disjoint set, and
the tooling reveals them strictly one at a time — satisfying one produces an
error that looks unrelated to the one before it:
1. **`PUBLIC_ROOTS`** in `agent_capability_surface.py`. Until the root is
declared, `discover_headers()` returns nothing for it, so the domain reads as
fully reviewed because it is entirely invisible.
2. **The schema enum** in `docs/status/agent-capability-surface.schema.json` —
in **three** separate places (the review-root `domain`, the inventory's
`propertyNames`, and the frozen-entry `domain`). Miss one and a row that the
surface script just legitimately produced is rejected as schema-invalid.
3. **`REVIEWED_MINIMAL_TARGETS`** in `agent_capability_registry.py`. The failure
is `include has no covered public target owner`, which names the binding, not
the missing map row. The value is the CMake **export** name
(`Pulp::playback`), which `PulpInstallRules.cmake` derives from the target
(`pulp-playback`) — not the target name itself.
4. **The compile probe's link list** in `test/cmake/quality_tests.cmake`. The
generated link probe for `pulp-test-agent-capability-compile` cannot resolve a
symbol from a subsystem the target does not link, so the probe fails at link
time with no mention of capabilities at all.
Two preconditions are worth measuring before starting, because assuming either
one wastes the whole pass. The domain's headers must already install — the check
is the per-subsystem `install(DIRECTORY …)` loop over
`_pulp_sdk_header_subsystems` in `PulpInstallRules.cmake`, not the presence of
an `install(TARGETS …)` line. And the target must already be in
`PULP_SDK_TARGETS`; if it is not, adding it is itself public surface (see below)
and belongs in its own slice.
Partial coverage is the expected end state, and it must be spelled. Classify the
headers that are not part of the advertised closure as `infrastructure` with
empty `capability_keys`, never as `unsupported_capability`: an absent key means
*unknown*, and `unsupported_capability` asserts a fact about the header that
nobody measured.
## Splitting an exported target means exporting BOTH halves
`tools/cmake/PulpInstallRules.cmake` holds `PULP_SDK_TARGETS`, the list
`cmake --install` exports. If you split a target that appears in that list into
an umbrella plus its halves, **every half must be added to the list too**, not
just kept behind the umbrella name.
The trap is that the umbrella still installs fine on its own. What breaks is
the consumer: the exported umbrella's `INTERFACE_LINK_LIBRARIES` names the
halves, so a downstream `find_package(Pulp)` resolves a target whose interface
references targets the export set never defined, and fails there rather than at
install time. The symptom appears in someone else's build, one step removed
from the change that caused it.
`pulp-format` is the worked example: it exports as `pulp-format`,
`pulp-format-core` and `pulp-format-view`. Note also that the umbrella must
stay a real STATIC library rather than INTERFACE, because the export set
expects an archive artifact.
## Adding a target to `PULP_SDK_TARGETS` is public surface
`PULP_SDK_TARGETS` in `tools/cmake/PulpInstallRules.cmake` is the export set, so
appending a target publishes a new `Pulp::<name>` that outside projects can name
in `target_link_libraries` and `find_package(Pulp COMPONENTS ...)`. It carries
the ordinary compatibility weight even when the target is INTERFACE-only and
ships no archive.
Anything already in an exported target's link interface MUST be in that set:
CMake refuses to export a target whose interface names one that is not. So an
INTERFACE target introduced to narrow another target's link line is not optional
to export, it is required by the export that motivated it.
A target defined under `core/<x>/` but not owning a `core/` directory of its own
draws a `module '<x>': CMake links pulp-<name> but modules.yaml doesn't list it`
warning from `tools/check-docs.sh`. That warning is correct and unfixable from
`modules.yaml`, whose entries are validated against `core/<name>/` existing;
adding a row would convert a warning into a hard failure. `pulp-tracing`,
`pulp-perfetto` and `pulp-cpp` sit in the same position.
## A heavy selftest in `quality_tests.cmake` must declare `PROCESSORS`
The capability tests (`agent-capability-manifest-check`,
`agent-capability-manifest-selftest`, `agent-capability-rederive-selftest`)
share `test/cmake/quality_tests.cmake` with much heavier GPU and role-producer
selftests. CI runs that suite with `ctest -j8 --timeout 120`, and ctest's
scheduler charges a test one slot unless `PROCESSORS` says otherwise.
A test that spawns a subprocess tree while declaring the default single slot is
therefore co-scheduled with seven other tests that may do the same. Each then
inflates the others, and the ones nearest their budget time out — on a loaded
host, tests that pass comfortably in isolation fail together in a cohort, on
unrelated PRs, in varying subsets. The failure looks like flakiness or like a
break on `main`; it is neither.
When you register a test here:
- Declare `PROCESSORS 8` if it forks, builds, or drives subprocesses.
`agent-capability-manifest-selftest` already does.
- Declare an explicit `TIMEOUT` when the work genuinely exceeds the suite
default. `build.yml` sets `--timeout 120` specifically so one hung entry
cannot burn the workflow timeout, and its comment directs long tests to set
their own. An explicit `TIMEOUT` relaxes no assertion — every negative
control still runs and still must refuse.
- Compare the measured baseline against 120 s, not against zero. On an
unloaded Linux runner `agent-capability-rederive-selftest` takes 76.65 s and
`gpu-first-visible-role-producers-selftest` 49.07 s. The first has roughly
1.6x headroom and the second 2.4x, and both have timed out in the same
cohort — so a slot declaration without a budget still leaves the tighter one
failing under load.
- Measure before assuming which part is slow. Profiling a test's phases has
repeatedly retired the obvious suspect: an analyzer's per-file `git show`
hashing that costs seconds over the real tree ran against a two-file
synthetic fixture and accounted for under 1% of runtime, while the true cost
was the count of sealed-build invocations.
`RESOURCE_LOCK agent-capability-manifest-source` serializes the three capability
tests against each other, but it constrains nothing against tests holding a
different lock — it is a correctness guard for the manifest rewrite, not a
concurrency budget.
**Measure on the slowest runner the gate can land on, not the one you have.**
The required `macos` gate places on either Mac Studio, and M5 runs the same
suite roughly 1.5x slower than M3. A selftest measured at ~62 s on an idle M3 —
`gpu-recipe-catalog-selftest`, which builds throwaway clones per equivalence
class — is already over the 120 s default once that factor and a loaded host are
applied, even though the local number looks like comfortable headroom. Scale the
local measurement before deciding a test needs no explicit `TIMEOUT`.
## An installed `inspect/` control header is not a design-time capability row
`tools/cmake/PulpInstallRules.cmake` installs the capability-control executor
headers (`control_state_write_executor.hpp`,
`control_timeline_document_session_executor.hpp`, and their siblings) into the
same SDK that carries the design-time agent-capability contracts. Sharing an
install list is not sharing a registry, and the resemblance is the trap: the
headers declare typed request/outcome structs and a resolver, which reads like a
binding, so the reflex is to add a manifest row "for consistency".
Do not. Runtime operation metadata — operations, grants, instances, receipts —
is broker authority and stays outside the design-time manifest by construction.
A control operation is declared once in `inspect/src/control_manifest.cpp` and
its capability once in `capability_definitions.inc`; the CLI and MCP surfaces
then project it from `control_operation_registry()`. Nothing in that path reads
the agent-capabilities manifest, so a row added there would advertise a contract
no consumer resolves and no gate re-derives.
The practical consequence is a disposition, not a code change: a sequencer
exposure row covering a live control operation records
`design_time_agent_manifest: not_applicable` with that boundary as its
rationale, and never `gap` — `gap` claims someone owes the row, and nobody does.
What the install entry *does* buy is honesty on a different surface. Adding the
header to the install list is exactly what lets the same exposure row claim
`installed_sdk: exposed`, because an embedding host then links the typed source
seam rather than re-declaring it. Omit the install entry and that claim is
false, while the manifest row would still have been wrong.
## Humaniser kernel versus placed-device controls
`midi.humanize` 1.1 advertises the compatible future-attack spec update method
and supports a nonnegative timing floor. Its operational binding constructs a
kernel and invokes the update with a bounded spec. This design-time kernel
registration does not advertise placed-device parameter operations or grants;
the event-humaniser exposure ledger keeps those product-control gaps explicit.
## Sample-region capability rows
A sample-region capability describes a declared graph/control contract. Region
proof and runtime admission are the authority; capability rows and inspector
examples are projections. Keep unsupported latency, state-size, history, and
format projections explicit until their contracts and independent proofs exist.
Do not use vestigial flags or create a second DSP registry.
## Graph diagnostics are runtime inspection
A graph-owned custom-node diagnostics query can change a catalog-bound
`signal_graph_runtime.hpp` fingerprint without changing sample-region authoring
semantics. Refresh every existing binding to that header, but do not advertise
instance counters, availability, generation handles or provider reports as new
design-time capabilities. The sibling diagnostics descriptor is a runtime
inspection contract. Its new header follows the host directory install/Doxygen
parity rules; do not expand the deliberately closed sample-region header list
merely to force runtime diagnostics into the design-time catalog.