# AGENTS.md - msp-skills Instructions for AI agents working in this repository. Read this before making changes; see [`CONTRIBUTING.md`](CONTRIBUTING.md) for the human-facing version. ## What this repo is A monorepo of MSP vendor connectors. Each `skills//cli` is a Go CLI + MCP server **generated by [cli-printing-press](https://github.com/mvanhorn/cli-printing-press)** and then carries connector-specific edits. The press is upstream and refreshes independently; we pull new binaries and **reprint** connectors over time. ## Before you reprint or re-onboard a connector (READ THIS) A reprint regenerates the `DO NOT EDIT` files under `skills//cli` and can **silently clobber** a hand-fix - the build and tests stay green while the live-API behavior the fix encoded is gone (this is how the axcient `id_` primary-key fallback got reverted; see [`docs/reprint-survival.md`](docs/reprint-survival.md)). **Whenever you regenerate, reprint, re-onboard (msp-skills-publish UPDATE), or bulk-overwrite a connector's `cli/` tree, you MUST:** 1. **Before** generating, read the connector's recorded hand-fixes so you know what to preserve: ```bash python3 tools/maintainer/check_handfixes.py --brief --slug ``` Preserve every entry it lists (3-way merge, don't blind-overwrite). For a targeted fix, prefer a surgical change over a full reprint. 2. **After** generating, retire or re-apply the fleet-wide hand-fixes (onboard.py does this for you), then prove nothing regressed: ```bash python3 tools/maintainer/replay_handfixes.py --slug python3 tools/maintainer/check_handfixes.py --slug ``` `tools/maintainer/fleet_handfixes.json` records, per fleet-wide id, whether the press now generates it (retired: its entry is not demanded from `retire_at` on, the press's own shape is) or the replay tool re-applies it. Reconcile by hand only what the replay names. If it fails, restore the missing hand-fix (the failure names the file + marker) before committing. CI runs this same gate per-skill and will block the merge otherwise. 3. **When you add a new hand-edit to a generated file**, record it in `skills//handfixes.json` in the same change, or it will be unprotected on the next reprint. Schema + rationale: [`docs/reprint-survival.md`](docs/reprint-survival.md). Prefer encoding the behavior in the spec (e.g. `x-resource-id`) when the press supports it - that survives by construction. Find connectors whose hand-fixes are not yet recorded: ```bash python3 tools/maintainer/check_handfixes.py --discover ``` ## When a connector flips to live-verified (READ THIS TOO) A `live-verified` badge means a real MSP confirmed the connector against a real tenant. That is the moment it becomes worth keeping current, because it is the first moment anyone is positioned to notice a regression. Two questions come due: ```bash python3 tools/maintainer/check_engine_freshness.py --slug ``` 1. **Is the vendored engine behind the fleet?** The bar is the newest `printing_press_version` anywhere in `skills/*/manifest.json`, so it rises on its own - there is no constant to maintain. Only a MINOR-version gap is reported; a patch behind is not worth a reprint. Behind the bar means a reprint is worth considering, subject to the hand-fix rules above. It is not an order: a reprint is expensive and can clobber hand-fixes, and some connectors cannot be reprinted at all (a templated `token_url` is refused by press >= 4.30). 2. **Is the shipped binary behind `main`?** This is the one that actually bites. A tree-only fix reaches nobody: on 2026-08-16, 41 of 61 connectors were shipping binaries built before the 4.24 engine upgrade that had been sitting in `main` since June. `main` being current is not evidence that any user is running current code. Neither is a tag, since immutable releases landed: a tag can sit in front of a stranded draft or a permanently sealed, incomplete release. The evidence is a PUBLISHED release carrying its complete asset set - see "What counts as released" below. This question is answered by `release_state.classify()`, which compares a freshly computed CLI hash against `cli_hash_at_release`. Do **not** answer it by comparing engine version stamps: a security fix like the GO-2026-6218 toolchain sweep changes every connector's binary without touching a single `printing_press_version`, so a stamp comparison reports the whole fleet as fine. That is a false-GREEN, and it is exactly the failure this check exists to catch. `.github/workflows/live-verified.yml` runs this automatically on every badge flip and posts the result as an issue comment. It is **advisory** - nothing gates on it. Whoever decides should record the decision in a reply on that issue so the next agent does not re-litigate it. A badge also records the code it verified: `verify_live.py` stores the release version and the cli hash of that tag, and the catalog says "verified on vX.Y.Z" once the shipped release carries different code. When a report names the version it ran, pass `--verified-version`; for an existing badge use `--bind`. Never copy `cli_hash_at_release` into a badge by hand: `check_registry_state.py` recomputes the hash from the tag and fails a binding that is not that code. **Once live-verified, always live-verified.** A badge comes off only when the owner asks, through `verify_live.py --revoke --slug --owner-override ""`, which leaves a dated `revoked` record. Never run it on your own judgment. `check_registry_state.py` fails CI on any change where a live-verified badge changes status or disappears without that record, including a registry entry rewritten without its `live_verified` key (how servosity lost its badge in #248). **Beta and live-verified are mutually exclusive.** A live-verified connector never says Beta. Flip and revoke only through `verify_live.py`, which moves the registry `status` and the README / pain-point Status paragraphs with the badge (the docs page follows from `render_docs_page.py`). `verify_live.py --check` runs in CI and fails on any disagreement; `--sync` repairs the registry and the Status paragraphs, and a stray "Beta" elsewhere in those two files is fixed by hand. ## What counts as released (READ THIS BEFORE YOU CUT OR TRUST A TAG) A tag is necessary and no longer sufficient. This repository has GitHub **immutable releases** enabled: a published release is sealed the instant it exists and can never take another asset (`gh release upload` answers `HTTP 422: Cannot upload assets to an immutable release`). So `release.yml` assembles the entire artifact set into a **DRAFT**, asserts it is complete, and publishes last. Three consequences an agent has to hold: 1. **A pushed tag STARTS a release; only the seal finishes one.** A failed build target leaves a stranded DRAFT - invisible to installers, invisible to `release_state.py`, and easily mistaken for either success or "no release happened". The completion criterion is: published (not draft), asset set complete. 2. **A tag push runs `release.yml` from the TAGGED commit.** Tagging a commit that predates the draft-then-seal ordering runs that commit's publish-first workflow, which seals an empty release and burns the version number permanently. "A SHA on current `main`" is NOT the precondition and never was - `main` carried the publish-first workflow for as long as it took this fix to land, and it is the SHA's own `release.yml` that decides. Ask the probe, which reads the workflow at that commit and names the requirement it fails: ```bash python3 tools/maintainer/check_release_pipeline.py --sha # exit 0 = safe to tag ``` Do not substitute `git show :.github/workflows/release.yml | grep -- '--draft'` for it. That grep is satisfied by any commit that merely mentions the flag, and it cannot see the other five requirements (one sealing job, every uploader inside its `needs:` closure, the completeness gate running before the seal, the `.mcpb` inside the sealing contract, and the Windows binaries run on a Windows runner before the seal). Then: one tag per push, and never `git push --tags` (it pushes stale local tags, and more than three tags in a single push fires no workflow run at all). Never delete a release and re-cut the same version - bump instead. 3. **Only a network check is a release receipt.** `check_pinned_artifacts.py --no-network` proves the TAG exists and nothing more, so it stays green over a stranded draft and over a published-but-incomplete release (it was green over `auvik-v0.1.1`, which carries 16 of its 25 assets). Use: ```bash # per tag - the two facts that matter, then the derived asset-set check gh release view --json isDraft,assets -q '"draft=\(.isDraft) assets=\(.assets|length)"' gh release view --json assets \ | python3 tools/maintainer/check_release_assets.py --tag --with-mcpb # fleet - every pinned download URL resolves to a published asset python3 tools/maintainer/check_pinned_artifacts.py --network # sweep for releases that never finished (expect no output) gh api repos/Servosity/msp-skills/releases --paginate -q '.[] | select(.draft) | .tag_name' ``` A tag listed by that last sweep is a release that never finished: re-run the Release workflow for it while its draft is still mutable. 4. **`docs/_data/pending.json` and the README `.mcpb` pins follow COMPLETE PUBLISHED releases, not tags.** `catalog.yml` regenerates them after the Release workflow SUCCEEDS (`workflow_run`), when a release is deleted or unpublished, and weekly, from the API's non-draft release list filtered by `check_release_assets.py --admit-published` (the same asset-set contract the seal uses, `.mcpb` included), and hands that list to `release_state.py --released-tags` and `build-catalog.py --released-tags`. A tag whose release is a stranded draft, or sealed with a build target missing, therefore reads as `version-pending` there, and the README keeps its last complete published link, until a complete release for that version exists. ## General - Sign commits (`git commit -s`); no em-dashes in committed files; run `bash tools/maintainer/verify_all.sh --changed` (or `--slug `) while iterating, and once on the final commit after the last rebase. Those runs use the scoped profile (decision D1): only the tool self-tests the change can reach, the per-connector form of fleet checks where nothing shared changed, and the connector's Linux build and tests on this machine's own architecture (linux/arm64 on Apple silicon). CI is the authority for the x86 (linux/amd64) build row and for every tool self-test: it runs both on every PR. `--full` (or no arguments, the whole fleet) runs the full profile, emulated amd64 and every self-test included. The verdict (`.verify/verdict.json`) names the head and base SHAs it checked and which profile ran (`profile_kind`, `profile`). See `CONTRIBUTING.md`. - CI builds only what a change-set touched, on PRs AND on pushes to `main` (`release_matrix.py --changed-only`; see `tools/maintainer/README.md`). A docs-only or catalog-bot commit builds nothing; a change to a matrix-row checker, `registry.py`, or a build-feeding workflow builds all 65; a weekly sweep builds everything regardless. `check_cli_claims.py` is a hard gate in the matrix (no `--warn`): a README that names a command or flag the built binary does not have fails that skill's row.