diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000..0396081 --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "skill-feedback-engine", + "interface": { + "displayName": "Openly Useful" + }, + "plugins": [ + { + "name": "skill-feedback-engine", + "source": { + "source": "local", + "path": "./" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..37d49a7 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-marketplace.json", + "name": "skill-feedback-engine", + "version": "0.1.0", + "description": "Openly Useful local-first, review-gated feedback for Agent Skills.", + "owner": { + "name": "Openly Useful", + "email": "hello@openlyuseful.org", + "url": "https://openlyuseful.org" + }, + "plugins": [ + { + "name": "skill-feedback-engine", + "source": "./", + "description": "Capture reusable corrections and prepare sanitized, review-gated Agent Skill proposals.", + "version": "0.1.0", + "author": { + "name": "Openly Useful", + "email": "hello@openlyuseful.org", + "url": "https://openlyuseful.org" + }, + "category": "productivity", + "strict": true + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..6ac73a5 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,14 @@ +{ + "name": "skill-feedback-engine", + "version": "0.1.0", + "description": "Local-first, review-gated feedback for improving Agent Skills without exporting raw evidence.", + "author": { + "name": "Openly Useful", + "email": "hello@openlyuseful.org", + "url": "https://openlyuseful.org" + }, + "homepage": "https://github.com/Openly-Useful/skill-feedback-engine", + "repository": "https://github.com/Openly-Useful/skill-feedback-engine", + "license": "MIT", + "skills": "./skills/" +} diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json new file mode 100644 index 0000000..bc1bc04 --- /dev/null +++ b/.codex-plugin/plugin.json @@ -0,0 +1,39 @@ +{ + "name": "skill-feedback-engine", + "version": "0.1.0", + "description": "Local-first, review-gated feedback for improving Agent Skills without exporting raw evidence.", + "author": { + "name": "Openly Useful", + "email": "hello@openlyuseful.org", + "url": "https://openlyuseful.org" + }, + "homepage": "https://github.com/Openly-Useful/skill-feedback-engine", + "repository": "https://github.com/Openly-Useful/skill-feedback-engine", + "license": "MIT", + "keywords": [ + "agent-skills", + "feedback", + "review", + "local-first" + ], + "skills": "./skills/", + "interface": { + "displayName": "Skill Feedback Engine", + "shortDescription": "Turn recurring corrections into reviewed skill proposals", + "longDescription": "A local-first feedback loop that captures reusable corrections, failures, patterns, and outcomes; keeps raw evidence private; and prepares sanitized, review-gated proposals for improving Agent Skills.", + "developerName": "Openly Useful", + "category": "Productivity", + "capabilities": [ + "Local feedback", + "Review-gated proposals" + ], + "websiteURL": "https://github.com/Openly-Useful/skill-feedback-engine", + "privacyPolicyURL": "https://openlyuseful.org/legal/privacy", + "termsOfServiceURL": "https://openlyuseful.org/legal/terms", + "defaultPrompt": [ + "Capture this correction as reusable skill feedback after the task.", + "Review recurring skill feedback and prepare draft proposals." + ], + "brandColor": "#247A4B" + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c98bebe..2c5fec5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,3 +18,4 @@ jobs: - run: python -m pip install -e . - run: python -m unittest discover -s tests -v - run: skill-feedback validate skills/skill-feedback-engine + - run: python scripts/validate_registration.py diff --git a/README.md b/README.md index 4f63175..8bf1a4b 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,14 @@ Then add `adapters/codex/AGENTS.snippet.md` to the applicable persistent instruc Provider adapters contain activation guidance only. They all use the same CLI, database, observation policy, and export format. +## Repository-local plugin registration + +The repository root is a skill-only plugin for OpenAI/Codex and Claude. `.codex-plugin/plugin.json` and `.claude-plugin/plugin.json` both expose the existing canonical `./skills/` directory, so provider registration does not create wrapper copies of `SKILL.md`. Repository-local catalogs are at `.agents/plugins/marketplace.json` and `.claude-plugin/marketplace.json`. + +Skill Feedback Engine has no MCP component, and none is declared by these manifests. The registration files prepare local discovery only; they do not install, authenticate, publish, or activate anything externally. + +`publisher/publisher.json` derives Openly Useful identity and public policy URLs from . Openly Useful LLC remains a planned, formation-pending publisher/operator/licensee, and external publication authorization remains withheld. + ## Daily review on macOS `adapters/codex/com.openlyuseful.skill-feedback-engine.plist` is a launchd template for 4:00 AM in the machine's local timezone, including daylight-saving changes. @@ -174,6 +182,7 @@ Replace `__SKILL_FEEDBACK_EXECUTABLE__` with the absolute path returned by `comm python3 -m unittest discover -s tests -v python3 -m skill_feedback_engine --help python3 -m skill_feedback_engine validate skills/skill-feedback-engine +python3 scripts/validate_registration.py ``` When running directly from a checkout without installing it, set `PYTHONPATH=src` before the `python3 -m skill_feedback_engine` commands. diff --git a/publisher/publisher.json b/publisher/publisher.json new file mode 100644 index 0000000..0237fd0 --- /dev/null +++ b/publisher/publisher.json @@ -0,0 +1,46 @@ +{ + "schemaVersion": 1, + "authorityManifest": "https://openlyuseful.org/publisher/manifest.json", + "publisher": { + "displayName": "Openly Useful", + "homepage": "https://openlyuseful.org", + "studio": "https://openlyuseful.com", + "publicContact": "hello@openlyuseful.org" + }, + "plannedLegalEntity": { + "name": "Openly Useful LLC", + "status": "formation-pending", + "roles": [ + "publisher", + "operator", + "licensee" + ] + }, + "policies": { + "privacy": "https://openlyuseful.org/legal/privacy", + "terms": "https://openlyuseful.org/legal/terms", + "security": "https://openlyuseful.org/security", + "support": "https://openlyuseful.org/support" + }, + "namespaces": { + "npm": "@openly-useful", + "openSourceMcp": "org.openlyuseful", + "reservedStudioMcp": "com.openlyuseful" + }, + "component": { + "name": "skill-feedback-engine", + "displayName": "Skill Feedback Engine", + "version": "0.1.0", + "repository": "https://github.com/Openly-Useful/skill-feedback-engine", + "license": "MIT", + "skillsPath": "./skills/", + "skillNames": [ + "skill-feedback-engine" + ], + "mcp": false + }, + "externalPublication": { + "allowed": false, + "authorization": "withheld" + } +} diff --git a/scripts/validate_registration.py b/scripts/validate_registration.py new file mode 100644 index 0000000..a566a81 --- /dev/null +++ b/scripts/validate_registration.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Validate skill-only Codex/Claude registration against publisher metadata.""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any, Dict, List + + +ROOT = Path(__file__).resolve().parents[1] +SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") + + +def read_json(path: Path, errors: List[str]) -> Dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + errors.append(f"{path.relative_to(ROOT)} is missing or invalid JSON") + return {} + if not isinstance(value, dict): + errors.append(f"{path.relative_to(ROOT)} must contain a JSON object") + return {} + return value + + +def validate_registration(root: Path = ROOT) -> List[str]: + errors: List[str] = [] + publisher = read_json(root / "publisher" / "publisher.json", errors) + if publisher.get("schemaVersion") != 1: + errors.append("publisher schemaVersion must be 1") + if publisher.get("authorityManifest") != "https://openlyuseful.org/publisher/manifest.json": + errors.append("publisher authority manifest is invalid") + identity = publisher.get("publisher") + expected_identity = { + "displayName": "Openly Useful", + "homepage": "https://openlyuseful.org", + "studio": "https://openlyuseful.com", + "publicContact": "hello@openlyuseful.org", + } + if identity != expected_identity: + errors.append("publisher identity does not match Openly Useful") + legal = publisher.get("plannedLegalEntity") + if not isinstance(legal, dict) or legal.get("name") != "Openly Useful LLC" or legal.get("status") != "formation-pending": + errors.append("planned entity must be Openly Useful LLC with formation-pending status") + elif sorted(legal.get("roles", [])) != ["licensee", "operator", "publisher"]: + errors.append("planned entity roles are invalid") + if publisher.get("externalPublication") != {"allowed": False, "authorization": "withheld"}: + errors.append("external publication must remain withheld") + + component = publisher.get("component") + if not isinstance(component, dict): + errors.append("publisher component metadata is required") + return errors + if component.get("name") != "skill-feedback-engine" or not SEMVER.fullmatch(str(component.get("version", ""))): + errors.append("component identity/version is invalid") + if component.get("skillNames") != ["skill-feedback-engine"]: + errors.append("component must expose exactly one canonical skill") + if component.get("mcp") is not False: + errors.append("Skill Feedback Engine must explicitly remain MCP-free") + + skill_artifacts = sorted(path.relative_to(root).as_posix() for path in (root / "skills").rglob("SKILL*.md")) + if skill_artifacts != ["skills/skill-feedback-engine/SKILL.md"]: + errors.append("duplicate or unexpected SKILL artifacts: " + ", ".join(skill_artifacts)) + + author = { + "name": expected_identity["displayName"], + "email": expected_identity["publicContact"], + "url": expected_identity["homepage"], + } + common = { + "name": component.get("name"), + "version": component.get("version"), + "author": author, + "homepage": component.get("repository"), + "repository": component.get("repository"), + "license": component.get("license"), + "skills": component.get("skillsPath"), + } + codex = read_json(root / ".codex-plugin" / "plugin.json", errors) + claude = read_json(root / ".claude-plugin" / "plugin.json", errors) + for label, manifest in (("Codex", codex), ("Claude", claude)): + for field, expected in common.items(): + if manifest.get(field) != expected: + errors.append(f"{label} plugin {field} does not derive from publisher metadata") + if "mcpServers" in manifest or (root / ".mcp.json").exists(): + errors.append(f"{label} skill-only registration cannot declare MCP") + interface = codex.get("interface") + if not isinstance(interface, dict): + errors.append("Codex interface metadata is required") + else: + policies = publisher.get("policies", {}) + if interface.get("displayName") != component.get("displayName"): + errors.append("Codex display name mismatch") + if interface.get("developerName") != expected_identity["displayName"]: + errors.append("Codex developer name mismatch") + if interface.get("privacyPolicyURL") != policies.get("privacy"): + errors.append("Codex privacy URL mismatch") + if interface.get("termsOfServiceURL") != policies.get("terms"): + errors.append("Codex terms URL mismatch") + + codex_marketplace = read_json(root / ".agents" / "plugins" / "marketplace.json", errors) + codex_entries = codex_marketplace.get("plugins") + if codex_marketplace.get("interface") != {"displayName": "Openly Useful"}: + errors.append("Codex marketplace publisher mismatch") + if not isinstance(codex_entries, list) or len(codex_entries) != 1: + errors.append("Codex marketplace must contain exactly one plugin") + else: + entry = codex_entries[0] + if entry.get("name") != component.get("name") or entry.get("source") != {"source": "local", "path": "./"}: + errors.append("Codex marketplace root source mismatch") + if entry.get("policy") != {"installation": "AVAILABLE", "authentication": "ON_INSTALL"}: + errors.append("Codex marketplace policy mismatch") + + claude_marketplace = read_json(root / ".claude-plugin" / "marketplace.json", errors) + claude_entries = claude_marketplace.get("plugins") + if claude_marketplace.get("owner") != author or claude_marketplace.get("version") != component.get("version"): + errors.append("Claude marketplace publisher/version mismatch") + if not isinstance(claude_entries, list) or len(claude_entries) != 1: + errors.append("Claude marketplace must contain exactly one plugin") + else: + entry = claude_entries[0] + if entry.get("name") != component.get("name") or entry.get("source") != "./" or entry.get("strict") is not True: + errors.append("Claude marketplace root source mismatch") + if entry.get("author") != author or entry.get("version") != component.get("version"): + errors.append("Claude marketplace component metadata mismatch") + return errors + + +def main() -> int: + errors = validate_registration() + if errors: + for error in errors: + print(f"ERROR: {error}") + return 1 + print("Validated canonical skill plus Codex, Claude, marketplace, and publisher registration") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/skill_feedback_engine/validation.py b/src/skill_feedback_engine/validation.py index ebb878d..2589609 100644 --- a/src/skill_feedback_engine/validation.py +++ b/src/skill_feedback_engine/validation.py @@ -21,6 +21,14 @@ def validate_skill(path: Path) -> Dict[str, Any]: "errors": ["SKILL.md not found"], "warnings": warnings, } + if path.is_dir(): + duplicate_artifacts = sorted( + candidate.name for candidate in path.glob("SKILL*.md") if candidate.name != "SKILL.md" + ) + if duplicate_artifacts: + errors.append( + "duplicate or unexpected SKILL artifacts: " + ", ".join(duplicate_artifacts) + ) content = skill_file.read_text(encoding="utf-8") if not content.startswith("---\n"): errors.append("SKILL.md must start with YAML frontmatter") diff --git a/tests/test_registration.py b/tests/test_registration.py new file mode 100644 index 0000000..4441ca7 --- /dev/null +++ b/tests/test_registration.py @@ -0,0 +1,72 @@ +import json +import tempfile +import unittest +from pathlib import Path + +from scripts.validate_registration import ROOT, validate_registration +from skill_feedback_engine.validation import validate_skill + + +class RegistrationTests(unittest.TestCase): + def test_repository_registration_is_current_and_mcp_free(self): + self.assertEqual(validate_registration(), []) + + def test_validator_rejects_duplicate_skill_artifacts(self): + with tempfile.TemporaryDirectory() as directory: + fixture = Path(directory) + for relative in [ + "publisher/publisher.json", + ".codex-plugin/plugin.json", + ".claude-plugin/plugin.json", + ".claude-plugin/marketplace.json", + ".agents/plugins/marketplace.json", + "skills/skill-feedback-engine/SKILL.md", + ]: + source = ROOT / relative + target = fixture / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(source.read_bytes()) + (fixture / "skills" / "skill-feedback-engine" / "SKILL 2.md").write_text( + "---\nname: skill-feedback-engine\ndescription: duplicate\n---\n", + encoding="utf-8", + ) + errors = validate_registration(fixture) + self.assertTrue(any("duplicate or unexpected SKILL artifacts" in error for error in errors)) + + def test_portable_skill_validator_rejects_duplicate_sibling(self): + with tempfile.TemporaryDirectory() as directory: + skill_root = Path(directory) + (skill_root / "SKILL.md").write_text( + "---\nname: test-skill\ndescription: Test duplicate detection.\n---\nInstructions.\n", + encoding="utf-8", + ) + (skill_root / "SKILL 2.md").write_text("unsafe duplicate\n", encoding="utf-8") + result = validate_skill(skill_root) + self.assertFalse(result["valid"]) + self.assertTrue(any("duplicate or unexpected SKILL artifacts" in error for error in result["errors"])) + + def test_validator_rejects_premature_llc_activation(self): + with tempfile.TemporaryDirectory() as directory: + fixture = Path(directory) + for relative in [ + "publisher/publisher.json", + ".codex-plugin/plugin.json", + ".claude-plugin/plugin.json", + ".claude-plugin/marketplace.json", + ".agents/plugins/marketplace.json", + "skills/skill-feedback-engine/SKILL.md", + ]: + source = ROOT / relative + target = fixture / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(source.read_bytes()) + publisher_path = fixture / "publisher" / "publisher.json" + publisher = json.loads(publisher_path.read_text(encoding="utf-8")) + publisher["plannedLegalEntity"]["status"] = "active" + publisher_path.write_text(json.dumps(publisher), encoding="utf-8") + errors = validate_registration(fixture) + self.assertTrue(any("formation-pending" in error for error in errors)) + + +if __name__ == "__main__": + unittest.main()