# agentnative
The agent-native CLI linter. Audits whether CLI tools follow 7 agent-readiness principles.
## Architecture
Two-layer audit system:
- **Behavioral audits**: run the compiled binary, language-agnostic (any CLI)
- **Source audits**: ast-grep pattern matching via bundled `ast-grep-core` crate (Rust, Python at launch)
- **Project audits**: file existence, manifest inspection
Design doc: `~/.gstack/projects/brettdavies-agentnative/brett-main-design-20260327-214808.md`
## Skill Routing
When the user's request matches an available skill, ALWAYS invoke it using the Skill tool as your FIRST action. Do NOT
answer directly, do NOT use other tools first.
**gstack skills (ideation, planning, shipping, ops):**
- Product ideas, "is this worth building", brainstorming → invoke office-hours
- Plan review, scope challenge, "think bigger" → invoke autoplan (or plan-ceo-review, plan-eng-review)
- Ship, deploy, push, create PR → invoke ship
- Bugs, errors, "why is this broken" → invoke investigate
- What did we learn, persist learnings → invoke learn
- Weekly retro → invoke retro
- Security audit → invoke cso
- Second opinion → invoke codex
**compound-engineering skills (code loop):**
- Implementation plan from repo code → invoke ce-plan
- Write code following a plan → invoke ce-work
- Code review before PR → invoke ce-review
- Document solution in docs/solutions/ → invoke ce-compound
For the full routing table, see `~/.claude/skills/docs/workflow-routing.md`.
## Documented Solutions
`docs/solutions/` (symlink to `~/dev/solutions-docs/`) is a searchable archive of past solutions and best practices,
organized by category with YAML frontmatter (`module`, `tags`, `problem_type`). Search with `qmd query "<topic>"
--collection solutions`. Relevant when implementing or debugging in documented areas.
## gstack Project History
This project was designed in the `brettdavies/agent-skills` repo, then moved here. gstack project data (design doc, eng
review, naming rationale, review history) has been copied to `~/.gstack/projects/brettdavies-agentnative/`.
Key decisions already made:
- Name: `agentnative` with `anc` alias (see naming rationale)
- Approach B: bundled ast-grep hybrid (behavioral + source audits)
- ast-grep-core v0.42.0 validated via spike (3 PoC audits, 18 tests pass)
- Eng review: CLEARED, 10 issues resolved, 1 critical gap addressed
- Codex review: 12 findings, 3 actioned
## Conventions
- `ast-grep-core` and `ast-grep-language` pinned to exact version (`=0.42.0`) because the API is pre-1.0
- `Position` uses `.line()` / `.column(&node)` methods, not tuple access
- Pre-build `Pattern` objects for `find_all()`; `&str` rebuilds on every node
- Feature flag is `tree-sitter-rust`, not `language-rust`
- Edition 2024, dual MIT/Apache-2.0 license
## Source Audit Convention
Most source audits follow this structure (a few legacy helpers in `output_module.rs` and `error_types.rs` use different
helper shapes but still satisfy the core contract that `run()` is the sole `AuditResult` constructor):
- **Struct** implements `Audit` trait with `id()`, `label()`, `group()`, `layer()`, `applicable()`, `run()`
- **`audit_x()` helper** takes `(source: &str)` (or `(source: &str, file: &str)` when evidence needs file location
context) and returns `AuditStatus` (not `AuditResult`). This is the unit-testable core.
- **No `Audit` impl constructs `AuditResult` outside its own `run()`.** `run()` is the sole place each audit assembles
its own result; never hardcode ID/group/layer/label string literals in `audit_x()` or anywhere outside `run()`. The
runtime layer (`main::run`'s error and `--audit-profile` suppression branches) legitimately constructs `AuditResult`
as a *second* site, since it's the runner, not an `Audit` impl, and it uses `audit.id()`, `audit.label()`,
`audit.group()`, `audit.layer()` from the trait (never string literals). Test doubles (`FakeAudit` in
`src/principles/matrix.rs` and `src/scorecard/mod.rs`) similarly sidestep the rule by design.
- **`label()` returns `&'static str`** and feeds the `label` field in `run()`'s `AuditResult`. Having the label on the
trait also means the suppression and error branches can show the human label instead of falling back to the opaque
`id`. See `src/audit.rs`.
- **Tests call `audit_x()`** and match on `AuditStatus` directly, not `result.status`
This prevents ID triplication (the same string literal in `id()`, `run()`, and `audit_x()`) and ensures the `Audit`
trait is the single source of truth for audit metadata.
For cross-language pattern helpers, use `source::has_pattern_in()` / `source::find_pattern_matches_in()` /
`source::has_string_literal_in()` with a `Language` parameter; do not write private per-language helpers in individual
audit files.
## Principle Registry
`src/principles/registry.rs` is the single source of truth linking spec requirements (MUSTs, SHOULDs, MAYs across P1–P7)
to the audits that verify them. IDs follow `p{N}-{level}-{key}` and are stable once published; scorecards and the
coverage matrix pin against them.
- Add requirements by appending to the `REQUIREMENTS` static slice, grouped by principle then level (MUST → SHOULD →
MAY).
- Bumping `registry_size_matches_spec` or `level_counts_match_spec` is a deliberate act; the tests exist to flag
unintentional growth. Update both counter tests plus the summary prose in `docs/coverage-matrix.md` when the registry
grows.
- `Applicability::Universal` means every CLI; `Applicability::Conditional(reason)` names the gate in prose so the matrix
and the site `/coverage` page can render it.
- `ExceptionCategory` drives `--audit-profile` suppression. The `SUPPRESSION_TABLE` maps each variant to the audit IDs
it suppresses; drift tests fail the build if a category has no entry or a listed audit ID isn't in the catalog. Adding
a fifth category requires a plan revision. The four v0.1.3 categories (`human-tui`, `file-traversal`, `posix-utility`,
`diagnostic-only`) are the committed surface.
## covers() Declaration
Each `Audit` declares which requirements it evidences via `fn covers(&self) -> &'static [&'static str]`. The default
returns `&[]`; audits opt in explicitly. Return a static slice; never allocate. For an audit that verifies multiple
requirements, list them all:
```rust
fn covers(&self) -> &'static [&'static str] {
&["p1-must-no-interactive", "p1-should-tty-detection"]
}
```
The drift detector (`dangling_cover_ids` in `src/principles/matrix.rs`) fails the build if any ID returned by `covers()`
is missing from the registry, so typos surface at test time, not at render time.
## Coverage Matrix Artifact Lifecycle
`anc emit coverage-matrix` emits two committed artifacts:
- `docs/coverage-matrix.md`: human-readable table, grouped by principle.
- `coverage/matrix.json`: machine-readable (`schema_version: "0.1"`, pre-release-shape per the project's `0.x`
convention; bumps additively until the first public release locks the shape), consumed by the `agentnative-site`
`/coverage` page.
Both files are tracked in git, not `.gitignore`d. `anc emit coverage-matrix --check` exits non-zero when the committed
artifacts disagree with the current registry + `covers()` declarations. The integration test
`test_generate_coverage_matrix_drift_check_passes_on_committed_artifacts` mirrors this behavior so CI catches drift from
either source.
Regenerate whenever you add a requirement, change an audit's `covers()`, or rename an audit ID. The regeneration is a
deliberate commit, not a build-time artifact; the matrix is citable from outside this repo.
## Scorecard JSON fields
`src/scorecard/mod.rs` emits `schema_version: "0.5"`. The schema evolves additively during the `0.x` pre-launch window;
consumers feature-detect each addition rather than pinning exact shape. Cumulative history:
- `0.2`: `coverage_summary` (three-way `{must, should, may} × {total, verified}` counts), `audience`, `audit_profile`.
- `0.3`: `spec_version` (vendored agentnative-spec version, sourced by `build.rs` from `src/principles/spec/VERSION`).
- `0.4`: four top-level objects making the scorecard self-describing: `tool`, `anc`, `run`, `target`.
- `0.5`: `badge` block surfacing agent-native badge eligibility, embed snippet, and badge/scorecard URLs derived from
the live run.
Existing field semantics:
- `coverage_summary`: populated every run. Audits suppressed by `--audit-profile` do not count toward `verified`.
- `audience`: `Option<String>`, derived by `src/scorecard/audience.rs::classify()` from the 4 signal behavioral audits.
Emits `"agent-optimized"`, `"mixed"`, `"human-primary"`, or `null` when any signal audit is missing (including
`--audit-profile` suppression). Read-only over results; never gates totals or exit codes. Per CEO review Finding #3,
label mismatches are fixed via registry, not classifier logic.
- `audit_profile`: `Option<String>`, echoes the applied `--audit-profile` flag value (`"human-tui"`, `"file-traversal"`,
`"posix-utility"`, `"diagnostic-only"`). `null` when no profile is set.
- `spec_version`: `&'static str`, the vendored spec version this `anc` build was compiled against.
`0.4` additions (defined as serde-derived sub-structs in `src/scorecard/mod.rs`):
- `tool`: `ToolInfo { name: String, binary: Option<String>, version: Option<String> }`. Built in `main.rs`'s
`build_tool_info`. Project mode prefers the manifest version (`Cargo.toml`/`pyproject.toml`); command/binary mode
probes `<bin> --version` then `-V` via a fresh `BinaryRunner` with a 2-second timeout. Self-spawn guard compares the
resolved binary path against `std::env::current_exe()`; recursion declined → `tool.version: null`.
- `anc`: `AncInfo { version: &'static str }`. `version` is a build-time constant emitted by `build.rs` into
`$OUT_DIR/build_info.rs` (re-exported from `src/build_info.rs`). The `commit` field shipped pre-`0.5` was dropped
before the v0.3.0 tag: the version pin is sufficient build identity for scorecard consumers and the
`cargo:rerun-if-changed` watches on `.git/` made cached-build SHAs fragile across local commits.
- `run`: `RunInfo { invocation, started_at, duration_ms, platform: { os, arch } }`. `invocation` is captured **before**
`inject_default_subcommand` rewrites argv (so `anc .` records as `"anc ."`, not `"anc audit ."`). `started_at` is RFC
3339 UTC via the `time` crate (pinned `=0.3.45`). `duration_ms` uses `Instant` for monotonic measurement.
`platform.{os,arch}` come from `std::env::consts`.
- `target`: `TargetInfo { kind: String, path: Option<String>, command: Option<String> }`. `kind` is one of `"project"`,
`"binary"`, `"command"`. `path` is the **basename** of the resolved target (directory name in project mode, file name
in binary mode), never the absolute path, which would leak operator PII (home-dir username, org/employer dir
structure) into committed scorecards, README badge URLs, and any agent-posted artifact. `null` for `command` mode.
Pathological paths where `Path::file_name()` returns `None` (e.g. `/`, `..`) fall back to `null`. The unused field is
always `null`, never missing. See `src/main.rs::build_target_info` for the leak-vector rationale; the regression guard
lives in `tests/scorecard_schema_v05.rs::schema_v05_target_path_carries_no_separators`.
`0.5` addition (`BadgeInfo` in `src/scorecard/mod.rs`):
- `badge`: `BadgeInfo { eligible, score_pct, embed_markdown, scorecard_url, badge_url, convention_url }`. Computed by
`compute_badge(results, tool_name)` via `score_pct`, the credit-weighted, behavioral-only leaderboard formula from
`agentnative-spec` `principles/scoring.md`: over the denominator set `D` (behavioral rows whose status is in `{pass,
warn, fail, opt_out}`), `score_pct = round(100 × Σ w(tier)·credit / Σ w(tier))` with `credit(pass)=1.0`,
`credit(warn)=0.5`, `credit(fail)=credit(opt_out)=0.0`, and tier weights flat (`W_MUST = W_SHOULD = W_MAY = 1.0`, a
tunable parameter). `n_a` / `skip` / `error` and every non-behavioral (source/project) row are excluded from `D`;
source-only runs therefore have an empty `D` and score `0`. `eligible` is true iff `score_pct >=
BADGE_ELIGIBILITY_FLOOR_PCT` (currently `70`) **and** a tool slug was derivable; `embed_markdown` is `Some` only when
`eligible` (the do-not-nag contract from the site's badge convention). `scorecard_url` / `badge_url` are populated
whenever a slug exists, even below the floor, because the site renders an SVG for every scored tool so a regression
below the floor shifts color rather than 404s. `convention_url` is the fixed `https://anc.dev/badge` pointer. URLs are
anchored at `BADGE_BASE_URL = "https://anc.dev"` so the URL pattern lives in one place. Authority for the formula,
floor, and cohort bands is `principles/scoring.md`; the site renders the band colors. Text mode (`--output text`)
appends a post-summary hint via `BadgeInfo::text_hint()` when `eligible`; the same `tool.name` is used for the slug so
the JSON `embed_markdown` and the printed hint can never disagree.
Always-present null contract: `tool.version`, `tool.binary`, `target.path`, `target.command` serialize as JSON `null`
when not applicable, never as missing keys. Consumers can access these paths unconditionally. The exception is
`audience_reason`, which uses `skip_serializing_if = "Option::is_none"`; its absence carries information (audience has a
label).
Consumers (notably the site's `/score/<tool>` page) must feature-detect the new fields, since pre-`0.4` scorecards lack
the four metadata blocks; pre-`0.5` scorecards lack `badge`. The site's `agentnative-site/registry.yaml` will eventually
drop its parallel `version` / `scored_at` fields once consumers read those facts from the scorecard's `tool.version` /
`run.started_at`. That follow-up lives in the `agentnative-site` repo, not here.
## Skill Install Verb
`anc skill install <host>` ships the `agentnative-skill` bundle into a host's canonical skills directory. The host map
is **build-time-generated** from `src/skill_install/skill.json` (a verbatim copy of
`agentnative-site/src/data/skill.json`) into `$OUT_DIR/generated_hosts.rs` by `build.rs::emit_skill_hosts`.
`src/skill_install.rs` `include!`s the generated file; there is no hand-maintained host enum or destination table. To
add or change a host, edit `skill.json` (or run `bash scripts/sync-skill-fixture.sh` to pull the upstream site contract)
and `cargo build` regenerates the Rust map. There is no `skill.json` parsing in production, no HTTPS fetch at runtime,
and no allowlist validator; the host map is compile-time data.
CI catches drift between the committed fixture and the upstream site contract:
- `scripts/sync-skill-fixture.sh --check` (CI workflow `skill-fixture-drift.yml`) clones the upstream site at
`SKILL_SITE_REF` (default `dev`, since the site uses a dev/main forever-branch flow) and `cmp`s the live blob against
`src/skill_install/skill.json`. Runs on every PR and on push to main/dev.
Drift between the fixture and the generated Rust map cannot occur within a single build: `cargo:rerun-if-changed` on the
JSON file forces regeneration whenever the fixture moves. The earlier `host_map_matches_site_skill_json` test (test 12)
was deleted as provably redundant after this refactor.
The `git clone` invocation runs with named-const hardening that defeats ambient git-config and env subversion. The full
surface lives in `src/skill_install.rs`:
- `GIT_HARDEN_FLAGS: &[&str]`: five `-c key=value` pairs (`credential.helper=`, `core.askPass=`, `protocol.allow=never`,
`protocol.https.allow=always`, `http.followRedirects=false`). Applied via `Command::args` *before* the `clone`
subcommand, which is git's required position for top-level `-c` options.
- `GIT_HARDEN_ENV_REMOVE: &[&str]`: five env vars stripped via `env_remove` (`GIT_SSH{,_COMMAND}`, `GIT_PROXY_COMMAND`,
`GIT_ASKPASS`, `GIT_EXEC_PATH`).
- `GIT_HARDEN_ENV_SET: &[(&str, &str)]`: three env vars **set** on the spawned process. The
`GIT_CONFIG_GLOBAL=/dev/null` and `GIT_CONFIG_SYSTEM=/dev/null` pair disables every layer of user-controlled git
config, the actual defense against `insteadOf` URL-rewriting attacks (an earlier draft tried `-c
url.<repo>.insteadOf=`, which does the *opposite* of blocking and doubles the clone URL). `GIT_TERMINAL_PROMPT=0`
blocks credential prompts; git's default-when-unset is to prompt, which is the wrong default for a non-interactive
subcommand.
**Rules for changes touching skill install:**
- NEVER call `Command::env_clear()`; it strips PATH and breaks git's helper resolution. Use `env_remove` per var.
- NEVER use `sh -c` or any shell-mediated invocation. Tokens go directly to `git` via `Command::args`.
- NEVER reintroduce `skill.json` parsing in production code. The fixture is a build-time codegen input, not a runtime
resource.
- NEVER hand-edit `SkillHost`, `KNOWN_HOSTS`, `resolve_host`, or `host_envelope_str` in `src/skill_install.rs`. Those
identifiers come from the generated `$OUT_DIR/generated_hosts.rs` and any apparent definition in source is the
`include!` macro. To add a host, edit `src/skill_install/skill.json` (or run the sync script) and rebuild.
- The codegen rejects malformed install commands at build time. Each `install.<host>` value MUST tokenize as exactly
`git clone --depth 1 <url> <dest>` (six whitespace-separated tokens, dest not ending in `.git`). The validation
mirrors `agentnative-site/src/build/skill.mjs` so the two binaries reject the same inputs.
## Dogfooding Safety
Behavioral audits spawn the target binary as a child process. When dogfooding (`anc audit .`), the target IS
agentnative. Three rules guard the probe:
1. **Bare invocation prints help** (`cli.rs`): `arg_required_else_help = true` means children spawned with no args get
instant help output instead of running `audit .`. This is also correct CLI behavior (P1 principle).
2. **Safe probing only** (`json_output.rs`): Subcommands are probed with `--help`/`--version` suffixes only, never bare.
Bare `subcmd --output json` is unsafe for any CLI with side-effecting subcommands.
3. **Binary discovery picks the newer of release/debug by mtime** (`src/project.rs::discover_rust_binaries`): when both
`target/release/<bin>` and `target/debug/<bin>` exist, the function returns the one with the more recent mtime.
Avoids the stale-release-binary trap in dev workflows where `cargo run`/`cargo test` only refresh debug. CI scenarios
where only one profile is built fall through cleanly to the existence check. Ties go to debug (cargo's dev-flow
default). Test coverage: `test_discover_picks_newer_artifact_by_mtime` + `test_discover_picks_release_when_newer`.
Backstory: `docs/solutions/test-failures/stale-release-binary-dogfood-fail-2026-05-07.md`.
**Rules for new behavioral audits:**
- NEVER probe subcommands without `--help`/`--version` suffixes
- NEVER remove `arg_required_else_help` from `Cli`; it prevents recursive self-invocation
- NEVER revert binary discovery to the always-prefer-release shape (rule 3); that pattern silently masked
`p2-must-schema-print` regressions during the v0.4.0 spec sync
## CI and Quality
**Toolchain pin:** `rust-toolchain.toml` pins the channel to a specific `X.Y.Z` version with a trailing comment naming
the rustc commit SHA. Rustup reads this file on every `cargo` invocation, so both local and CI snap to identical bits.
Rustup verifies component SHA256s from the distribution manifest, so the version pin is effectively a SHA pin (the
manifest is the toolchain's "lockfile"). Bumping the toolchain is a reviewed PR that updates `rust-toolchain.toml`; no
runtime `rustup update` anywhere. Policy: bump only after a new stable has aged ≥7 days (supply-chain quarantine).
**Pre-push hook:** `scripts/hooks/pre-push` mirrors CI exactly: fmt, clippy with `-Dwarnings`, test, cargo-deny, and a
Windows compatibility check. Tracked in git and activated via `core.hooksPath`. After cloning, run: `git config
core.hooksPath scripts/hooks`
**Windows compatibility:** Only `libc` belongs in `[target.'cfg(unix)'.dependencies]`. All SIGPIPE/signal code must be
inside `#[cfg(unix)]` blocks. The pre-push hook checks this statically.
**After pushing:** Check CI status in the background with `gh run watch --exit-status` (use `run_in_background: true` so
it doesn't block). Report failures when notified.