chore: align governance with shipped artifact types (#62)

Closes #61\n\nImplemented and independently reviewed with AI assistance from Jasper on behalf of Magnus Hedemark.
This commit is contained in:
Magnus Hedemark
2026-07-17 23:37:37 -04:00
committed by GitHub
parent ce0527e94c
commit 7243433565
12 changed files with 174 additions and 31 deletions
+2
View File
@@ -19,4 +19,6 @@
## Review notes
- [ ] Final-head review evidence is tied to commit SHA `<sha>`, and all actionable findings are resolved.
<!-- Call out compatibility concerns, intentional simplifications, or follow-up work. -->
+8
View File
@@ -20,3 +20,11 @@ jobs:
ruby-version: '3.3'
- name: Validate skill format and links
run: ruby scripts/validate-skills.rb
- name: Check tracked repository artifacts
run: python3 scripts/check-artifacts.py
- name: Build tracked Python packages
run: |
wheel_dir=$(mktemp -d)
while IFS= read -r pyproject; do
python3 -m pip wheel --no-deps "./$(dirname "$pyproject")" --wheel-dir "$wheel_dir"
done < <(git ls-files -- ':(glob)**/pyproject.toml')
+4
View File
@@ -32,6 +32,10 @@ ruby scripts/validate-skills.rb
The same validation runs in GitHub Actions for pushes and pull requests. If a skill includes executable scripts or a package, run its documented checks as well and include the commands and results in your pull request.
## Deprecating a skill
When replacing a skill, preserve its old directory as a routing stub. Prefix its description with `Deprecated: use <replacement>`, explain the migration in the stub, and remove it only after compatibility is no longer required.
## Pull requests
- Create a branch from `main`: `feat/short-description`, `fix/short-description`, or `docs/short-description`.
+1 -1
View File
@@ -2,7 +2,7 @@
A collection of AI agent skills — reusable workflows, protocols, and knowledge packs for agentic systems. Skills follow the [Agent Skills open format](https://agentskills.io), making them compatible with any agent framework that supports the standard.
Bundles organize related skills under a single umbrella with shared reference material and auto-loading by trigger context; they appear in the same alphabetical catalog as standalone skills.
Bundles are this repository's convention for organizing related skills under a single umbrella with shared reference material; they appear in the same alphabetical catalog as standalone skills. Compatible harnesses are guaranteed to see the umbrella skill. Nested subskill auto-loading depends on the harness or on the umbrella skill's instructions.
## Skills
+1 -1
View File
@@ -17,4 +17,4 @@ Do not include credentials, private URLs, or personal data in the report unless
You should receive an acknowledgement within seven days. We will investigate, keep the reporter informed when practical, and coordinate disclosure after a fix or mitigation is available.
Supported versions are the latest commit on `main` and the most recent tagged release. Older releases may receive security fixes when the impact warrants it.
Supported version: the current `main` branch only.
+2
View File
@@ -38,6 +38,8 @@ Or install from the skill directory:
pip install -e /path/to/agent-council/
```
Pip and wheel installs include generated and user-supplied personas, but not the `hermes-profiles` library. To use real bundled profiles, start from a recursive source checkout.
## Usage
```bash
+4 -4
View File
@@ -82,7 +82,7 @@ Options:
## Profile Selection
By default, the council auto-selects relevant profiles from a library of **39 real professional profiles** (shipped as a git submodule from the [hermes-profiles](https://github.com/magnus919/hermes-profiles) repository). Each profile has a SOUL.md — an identity document with real methodology, values, and operating principles — rather than fabricated personas.
In a recursive source checkout, the council auto-selects relevant real professional profiles from the included [hermes-profiles](https://github.com/magnus919/hermes-profiles) library. Each profile has a SOUL.md — an identity document with real methodology, values, and operating principles — rather than a fabricated persona. Pip and wheel installs do not bundle that library; use generated or user-supplied personas instead.
### Auto-selection
@@ -102,10 +102,10 @@ Three ways to populate the council, with different tradeoffs:
| Method | Best for | Diversity | Setup |
|--------|----------|-----------|-------|
| `--profiles` (auto-select) | Single-domain questions with clear keywords | High — profiles have real SOUL.md methodology | Zero — just ask the question |
| `--profiles name1,name2` | Targeted debates where you know the stakeholders | Highest — you pick specific methodological voices | Know the profile names |
| `--profiles` (auto-select) | Single-domain questions with clear keywords | High — profiles have real SOUL.md methodology | Recursive source checkout required |
| `--profiles name1,name2` | Targeted debates where you know the stakeholders | Highest — you pick specific methodological voices | Recursive source checkout and profile names |
| `--persona-file file.json` | Full control over agent identities, custom domains | Variable — depends on how you design them | Create a JSON file |
| Auto (no flag) | Default — uses profiles if available, falls back to generated | Good — auto-selects from 39 profiles | Zero |
| Auto (no flag) | Default — uses profiles if available, falls back to generated | Good — varies with available profiles | No setup for generated personas; recursive source checkout for real profiles |
For most cases, let it auto-select or use `--profiles` with 3-5 names. Only use `--persona-file` when you need specific invented expertise that doesn't map to any existing profile.
+2 -19
View File
@@ -2,7 +2,6 @@
import json
import os
import subprocess
import sys
from pathlib import Path
@@ -11,24 +10,10 @@ import yaml
from agent_council.state import ProfileInfo
# Path to the profiles submodule within the skill directory
# Path to the locally available profiles library within the skill directory.
PROFILES_DIR = Path(__file__).resolve().parent.parent.parent / "profiles" / "profiles"
def _update_submodule() -> None:
"""Pull the latest profiles from the hermes-profiles submodule."""
skill_root = PROFILES_DIR.parent # agent-council/profiles/
try:
subprocess.run(
["git", "submodule", "update", "--remote", "--init"],
cwd=skill_root.parent, # agent-council/
capture_output=True,
timeout=30,
)
except Exception:
pass # Non-fatal — use whatever version we have
def _list_available() -> list[str]:
"""List all available profile names."""
if not PROFILES_DIR.exists():
@@ -63,8 +48,7 @@ def _load_profile(name: str) -> ProfileInfo | None:
def load_all() -> list[ProfileInfo]:
"""Load all available profiles. Updates submodule first."""
_update_submodule()
"""Load all locally available profiles."""
profiles = []
for name in _list_available():
p = _load_profile(name)
@@ -75,7 +59,6 @@ def load_all() -> list[ProfileInfo]:
def select_by_names(names: list[str]) -> list[ProfileInfo]:
"""Load specific profiles by name."""
_update_submodule()
profiles = []
for name in names:
name = name.strip().lower()
+43
View File
@@ -0,0 +1,43 @@
import importlib.util
import sys
import tempfile
import types
import unittest
from dataclasses import dataclass
from pathlib import Path
from unittest.mock import patch
yaml = types.ModuleType("yaml")
yaml.safe_load = lambda _: {}
sys.modules["yaml"] = yaml
state = types.ModuleType("agent_council.state")
@dataclass
class ProfileInfo:
name: str
description: str
soul_content: str
state.ProfileInfo = ProfileInfo
sys.modules["agent_council.state"] = state
select_spec = importlib.util.spec_from_file_location(
"select", Path(__file__).parents[1] / "agent_council" / "phases" / "select.py"
)
select = importlib.util.module_from_spec(select_spec)
select_spec.loader.exec_module(select)
class ProfileSelectionTests(unittest.TestCase):
def test_missing_profiles_are_empty_without_running_git(self):
with tempfile.TemporaryDirectory() as directory:
with patch.object(select, "PROFILES_DIR", Path(directory) / "profiles"):
self.assertEqual(select.load_all(), [])
if __name__ == "__main__":
unittest.main()
-1
View File
@@ -66,7 +66,6 @@ The `SKILL.md` file must contain YAML frontmatter followed by Markdown body cont
| `compatibility` | No | Max 500 chars. Indicates environment requirements. |
| `metadata` | No | Arbitrary key-value mapping. |
| `allowed-tools` | No | Space-separated string of pre-approved tools. (Experimental) |
| `confirmation` | No | Boolean (default: false). Signals destructive operations requiring explicit user confirmation. |
#### `name` field rules
- 164 characters
+1 -5
View File
@@ -33,7 +33,7 @@ The `SKILL.md` file must contain YAML frontmatter followed by Markdown content.
| `compatibility` | No | Max 500 characters. Indicates environment requirements (intended product, system packages, network access, etc.). |
| `metadata` | No | Arbitrary key-value mapping for additional metadata. |
| `allowed-tools` | No | Space-separated string of pre-approved tools the skill may use. (Experimental) |
| `confirmation` | No | Boolean (default: false). Signals destructive or state-changing operations requiring explicit user confirmation. |
<Card>
**Minimal example:**
@@ -197,10 +197,6 @@ The optional `allowed-tools` field:
```
</Card>
#### `confirmation` field
The optional `confirmation` field must be a boolean. When `true`, the agent must obtain explicit user confirmation before carrying out destructive or state-changing operations directed by the skill. This is a safety signal, not a permissions system. Most skills should omit it.
### Body content
The Markdown body after the frontmatter contains the skill instructions. There are no format restrictions. Write whatever helps agents perform the task effectively.
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""Validate runnable repository artifacts selected from the Git index."""
import argparse
import json
import subprocess
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
def tracked_files() -> list[Path]:
result = subprocess.run(
["git", "ls-files", "-z"],
cwd=ROOT,
check=True,
capture_output=True,
)
return [ROOT / name for name in result.stdout.decode().split("\0") if name]
def is_shell_script(path: Path) -> bool:
if path.suffix in {".sh", ".bash"}:
return True
try:
first_line = path.open("rb").readline().decode("utf-8", "replace")
except OSError:
return False
return first_line.startswith("#!") and any(
shell in first_line for shell in ("/sh", "/bash", "env sh", "env bash")
)
def test_directories(files: list[Path]) -> list[Path]:
directories = set()
for path in files:
for parent in path.parents:
if parent.name == "tests":
directories.add(parent)
break
if parent == ROOT:
break
return sorted(directories)
def run_checks(files: list[Path]) -> list[str]:
errors = []
for path in files:
relative = path.relative_to(ROOT)
if path.suffix == ".py":
try:
compile(path.read_bytes(), str(relative), "exec")
except (OSError, SyntaxError) as error:
errors.append(f"python {relative}: {error}")
elif path.suffix == ".json":
try:
json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
errors.append(f"json {relative}: {error}")
if is_shell_script(path):
result = subprocess.run(
["bash", "-n", str(path)], cwd=ROOT, text=True, capture_output=True
)
if result.returncode:
errors.append(f"bash -n {relative}: {result.stderr.strip()}")
for directory in test_directories(files):
relative = directory.relative_to(ROOT)
result = unittest.TextTestRunner(verbosity=0).run(
unittest.defaultTestLoader.discover(
str(directory), top_level_dir=str(directory)
)
)
if not result.wasSuccessful():
errors.append(
f"unittest discover {relative}: "
f"{len(result.failures)} failures, {len(result.errors)} errors"
)
return errors
def self_check() -> None:
files = [ROOT / "example/tests/test_example.py", ROOT / "elsewhere/file.py"]
assert test_directories(files) == [ROOT / "example/tests"]
assert is_shell_script(ROOT / "scripts/check-artifacts.py") is False
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--self-check", action="store_true")
args = parser.parse_args()
if args.self_check:
self_check()
return 0
errors = run_checks(tracked_files())
for error in errors:
print(error, file=sys.stderr)
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(main())