feat(kyberforge): restructure agent-audit for plugin-scope apm agents

Validates the new single-file .apm/agents/<name>.agent.md shape agent-author
now produces at plugin/APM scope: frontmatter allowlist (name/description/
model only, from a new apm-agent-allowlist entry in field-inventory.md),
no counterpart derivation, and Pair Consistency dropped from that scope's
report entirely (nothing to pair by design). Adds a plugin/APM-scope-only
SUGGESTION when an agent's description/body implies a tool restriction or
Claude-only behavior the vendor-neutral frontmatter can no longer express
(ADR-0016).

Scope detection in both validate.sh and validate-provenance.sh switches
from a flat plugin.json/.claude-plugin/plugin.json check to a walk-up for
the nearest ancestor apm.yml with a top-level type: field, skipping
type:-less marketplace-only manifests — full switch, no dual-mode fallback
to the old plugin.json signal. validate-provenance.sh's walk-up was fixed
to match validate.sh's (it still used the old plugin.json check, and its
counterpart-merge logic was rewritten to read a single file's source_keys
instead of merging a CC+Copilot pair, since plugin/APM scope has no
counterpart). Project/user scope validation is unchanged in both scripts.

Refs: #89
This commit is contained in:
2026-08-11 18:05:33 +00:00
parent 8cd5c79c0a
commit 675ba40238
6 changed files with 615 additions and 370 deletions

View File

@@ -5,8 +5,10 @@ usage() {
cat <<EOF
Usage: validate-provenance.sh <agent-file>
Validate that an agent pair's sources provenance chain is complete and internally consistent.
Operates at plugin scope only — exits 0 silently for project and user scope agents.
Validate that an agent's sources provenance chain is complete and internally consistent.
Operates at plugin/APM scope only (a single vendor-neutral .apm/agents/<name>.agent.md
inside a package with a type:-bearing apm.yml) — exits 0 silently for project and user
scope agents.
Arguments:
agent-file Path to either the Claude Code .md or Copilot .agent.md agent file.
@@ -47,23 +49,28 @@ agent_file = os.path.abspath(sys.argv[1])
fname = os.path.basename(agent_file)
agent_dir = os.path.dirname(agent_file)
# --- Detect provider ---
if fname.endswith('.agent.md'):
provider = 'copilot'
name_stem = fname[:-len('.agent.md')]
elif fname.endswith('.md'):
provider = 'claude-code'
name_stem = fname[:-len('.md')]
else:
# --- Sanity-check extension (single vendor-neutral .agent.md file at plugin/APM scope) ---
if not (fname.endswith('.agent.md') or fname.endswith('.md')):
print(f"Error: unrecognized extension '{fname}' — expected .md or .agent.md", file=sys.stderr)
sys.exit(2)
# --- Find plugin root ---
TYPE_RE = re.compile(r'^type:\s*(instructions|skill|hybrid|prompts)\b')
# --- Find package root: walk up for the nearest ancestor apm.yml that
# declares a top-level type: field. An apm.yml with no type: field is a
# marketplace-only manifest (see monorepo-and-repo-shapes.md) — skip it and
# keep walking. Stop at a .git boundary or the filesystem root: neither is
# plugin/APM scope, so this script has nothing to check there.
def find_plugin_root(start_dir):
current = os.path.abspath(start_dir)
while True:
if (os.path.isfile(os.path.join(current, 'plugin.json')) or os.path.isfile(os.path.join(current, '.claude-plugin', 'plugin.json'))):
return current
apm_yml = os.path.join(current, 'apm.yml')
if os.path.isfile(apm_yml):
with open(apm_yml) as f:
if any(TYPE_RE.match(line) for line in f):
return current
if os.path.isdir(os.path.join(current, '.git')):
return None
parent = os.path.dirname(current)
if parent == current:
return None
@@ -73,12 +80,6 @@ plugin_root = find_plugin_root(agent_dir)
if plugin_root is None:
sys.exit(0)
# --- Derive counterpart ---
if provider == 'copilot':
counterpart = os.path.join(agent_dir, name_stem + '.md')
else:
counterpart = os.path.join(agent_dir, name_stem + '.agent.md')
sources_md_path = os.path.join(plugin_root, 'sources.md')
# --- Helpers ---
@@ -166,15 +167,9 @@ def get_source_keys_from_file(fpath):
fm, _ = parse_frontmatter(content)
return parse_source_keys(fm)
# Plugin/APM scope is a single vendor-neutral file — no counterpart to merge.
given_keys = get_source_keys_from_file(agent_file)
counterpart_keys = get_source_keys_from_file(counterpart)
# Deduplicated union, preserving order
seen = set()
all_source_keys = []
for k in given_keys + counterpart_keys:
if k not in seen:
seen.add(k)
all_source_keys.append(k)
all_source_keys = given_keys
sources_md_exists = os.path.isfile(sources_md_path)
@@ -212,8 +207,8 @@ for line in sources_content.splitlines():
)
break
# --- Check 2: source_keys in agent files → slug exists in sources.md ---
for fpath, keys in [(agent_file, given_keys), (counterpart, counterpart_keys)]:
# --- Check 2: source_keys in the agent file → slug exists in sources.md ---
for fpath, keys in [(agent_file, given_keys)]:
if not keys:
continue
rel = os.path.relpath(fpath, plugin_root)