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.
Scanned 9/3/2026
Install to Claude Code
npx -y skills add danielraffel/pulp --skill agent-capabilities --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Agent Capabilities?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/danielraffel-agent-capabilities)More formats (shields.io, HTML) on the badges page.
---
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.
## 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.
- 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;agent-capability-installed-sdk`. 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.
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.
**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.
### `rederive.py` alone leaves `contract-history.json` at the protected base
`agent_capability_rederive.py` prints `wrote ... contract-history.json` and then
`reset N generated artifact(s) to the protected base`. The reset wins: the
history file ends up matching the base, carrying **none** of the new keys, while
the manifest and surface files do carry them.
Nothing catches this. `--check` reports `fresh`, `test_agent_capability_manifest.py`
passes all its checks, and `gates.sh` is green — the history is append-only
evidence, not a validated input, so no gate reads it. The transaction only looks
complete.
Run `agent_capability_manifest.py --write` **after** `rederive.py`, then prove
the history actually moved before believing the transaction is done:
```sh
git diff --stat HEAD -- tools/agent-capabilities/contract-history.json # must be non-empty
grep -c "<your.new-key>" tools/agent-capabilities/contract-history.json # must be >= 1
```
Use an already-published key as the control for that grep — a brand-new key from
the previous transaction returns 1, so a 0 on yours is a real absence rather
than a broken pattern.
Whether `rederive.py` appends depends on the state it starts from, so **measure
the append rather than following either rule blindly**. Starting from a tree
whose four generated artifacts already sit at the protected base — which is
where the reset-and-regenerate-once recovery leaves you — `rederive.py` alone
bumps `SURFACE_INVENTORY_VERSION`, appends exactly one entry, and `--check`
reports `fresh`; adding `--write` after it appends a *second* entry for one
logical change and the append-only check rejects it. Starting from a tree that
already carries a `--write` from earlier in the branch, the reset described
above wins and the trailing `--write` is what moves the history.
So the invariant to hold is the count, not the command sequence: after
regenerating, compare entry counts against the protected base and require a
delta of exactly 1, then stop. Run `--write` only if that delta is 0.
`rederive.py` also refuses outright while a merge is in progress when the
incoming commit is not the protected base, because the base resolver would step
back to the merge base and derive a stale counter. That is why the conflict
sequence commits the merge before re-deriving — a version bump staged into the
merge commit itself is rejected as `inventory_version changed without a surface
change`, since the surface document has not been regenerated yet.
### 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`.
### 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, which means all
of these in one change:
1. add it to `REVIEWED_HEADERS` in `tools/scripts/agent_capability_registry.py`
with its NEW fingerprint, a disposition, and a rationale;
2. delete its entry from the baseline, decrement `frozen_count`, and recompute
`entries_digest` with `agent_capability_surface.canonical_digest(entries)`;
3. update BOTH `FROZEN_LEGACY_COUNT` and `FROZEN_LEGACY_DIGEST` in
`tools/scripts/agent_capability_surface.py` to match; and
4. 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`.
Only classify a header when the classification is already defensible from a
written decision. Inventing one to unblock an edit converts a safety gate into
paperwork.
## 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` 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.
### `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.
## `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 green `--check` says nothing about a module outside `PUBLIC_ROOTS`
`PUBLIC_ROOTS` in `tools/scripts/agent_capability_surface.py` lists exactly six
domains: `audio`, `midi`, `music`, `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.
## 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.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!