mirror of
https://github.com/boshu2/agentops.git
synced 2026-09-14 15:08:13 +08:00
ff8dd0febf
**Replacement for #313** after Phases 0+1+2 squash-merged into main as #308. ## What Replaces hand-edited table sections of `docs/reference/agentops-skill-domain-map.md` with generated output from canonical yaml sources. Adds a golden-file CI gate that **forbids drift**. ## Why #308 (Phase 0+1+2) just landed: - Phase 0 — drift detector - Phase 1 — schema-gate frontmatter (hexagonal_role + practices required) - Phase 2 — BC1-BC5 in `bounded-contexts.yaml` Phase 3 (this PR) completes the epic: the Full Skill Map table becomes generated, not hand-edited. Drift is structurally impossible. ## Implementation ### New files - `docs/contracts/skill-dispositions.yaml` — 78 canonical entries (skill / domain / hexagonal_role / disposition / rationale) - `scripts/generate-skill-domain-map.sh` — generator + golden-file checker (`--check` mode) ### Modified - `docs/reference/agentops-skill-domain-map.md` — keeps hand prose; 3 table sections wrapped in `<!-- BEGIN:* -->` / `<!-- END:* -->` markers that the generator replaces - AGENTS.md CI table — new `validate-skill-domain-map-golden` row - documentation-index.md — `skill-dispositions.yaml` link - pre-push-gate.sh + validate.yml — wired into both ### Generator invariants - Every `skills/<name>/` must have a dispositions row - Every dispositions row's `hexagonal_role` must match SKILL.md frontmatter - Output deterministic (sorted alphabetically; no timestamps) ## Verification ```text $ bash scripts/generate-skill-domain-map.sh --check PASS — skill-domain-map.md matches generator output. $ bash scripts/check-registry-drift.sh PASS — no drift. $ bash scripts/check-bounded-contexts-drift.sh PASS — registry docs match yaml canonical. ``` All 3 drift gates green. ## Domain-tightening epic complete after this lands End-state: `registries-drift` lesson is **structurally encoded**. Graduates from `practice: unassigned` → `practice: accepted`. Closes: soc-zxia.3, soc-zxia Supersedes: #313 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com>
208 lines
6.6 KiB
Bash
Executable File
208 lines
6.6 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# scripts/generate-skill-domain-map.sh
|
|
#
|
|
# Generate the data sections of docs/reference/agentops-skill-domain-map.md
|
|
# from canonical sources:
|
|
# - docs/contracts/bounded-contexts.yaml (BC1-BC5 definitions)
|
|
# - docs/contracts/skill-dispositions.yaml (per-skill judgment data)
|
|
# - skills/<name>/SKILL.md (hexagonal_role cross-check)
|
|
#
|
|
# The script replaces content between these markers in the .md file:
|
|
# <!-- BEGIN:audit-summary --> ... <!-- END:audit-summary -->
|
|
# <!-- BEGIN:domain-taxonomy --> ... <!-- END:domain-taxonomy -->
|
|
# <!-- BEGIN:full-skill-map --> ... <!-- END:full-skill-map -->
|
|
#
|
|
# Hand-written prose outside these markers is preserved.
|
|
#
|
|
# Modes:
|
|
# (default) write the .md file in place
|
|
# --check generate to a temp file; diff against committed; exit 1 if different
|
|
# (this is the CI golden-file gate)
|
|
# --stdout write to stdout instead of the file
|
|
#
|
|
# Determinism: dispositions sorted alphabetically by skill slug. No timestamps.
|
|
#
|
|
# Phase 3 of soc-zxia (registries-drift remediation).
|
|
|
|
set -euo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
|
BC_YAML="${REPO_ROOT}/docs/contracts/bounded-contexts.yaml"
|
|
DISP_YAML="${REPO_ROOT}/docs/contracts/skill-dispositions.yaml"
|
|
SKILLS_DIR="${REPO_ROOT}/skills"
|
|
MAP_DOC="${REPO_ROOT}/docs/reference/agentops-skill-domain-map.md"
|
|
|
|
MODE="write"
|
|
for arg in "$@"; do
|
|
case "$arg" in
|
|
--check) MODE="check" ;;
|
|
--stdout) MODE="stdout" ;;
|
|
-h|--help)
|
|
sed -n '2,28p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
|
exit 0
|
|
;;
|
|
*)
|
|
echo "ERROR: unknown arg: $arg (try --help)" >&2
|
|
exit 2
|
|
;;
|
|
esac
|
|
done
|
|
|
|
for f in "${BC_YAML}" "${DISP_YAML}" "${MAP_DOC}"; do
|
|
if [[ ! -f "$f" ]]; then
|
|
echo "ERROR: required file missing: $f" >&2
|
|
exit 2
|
|
fi
|
|
done
|
|
|
|
export BC_YAML DISP_YAML SKILLS_DIR MAP_DOC MODE
|
|
|
|
python3 - <<'PY'
|
|
import os
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
try:
|
|
import yaml
|
|
except ImportError:
|
|
print("ERROR: PyYAML not installed; install with: pip install pyyaml", file=sys.stderr)
|
|
sys.exit(2)
|
|
|
|
BC_YAML = Path(os.environ["BC_YAML"])
|
|
DISP_YAML = Path(os.environ["DISP_YAML"])
|
|
SKILLS_DIR = Path(os.environ["SKILLS_DIR"])
|
|
MAP_DOC = Path(os.environ["MAP_DOC"])
|
|
MODE = os.environ.get("MODE", "write")
|
|
|
|
bcs = yaml.safe_load(BC_YAML.read_text()).get("bounded_contexts", [])
|
|
disps = yaml.safe_load(DISP_YAML.read_text()).get("dispositions", [])
|
|
disps = sorted(disps, key=lambda d: d["skill"])
|
|
|
|
# Cross-check: every disposition row's hexagonal_role must match the
|
|
# skill's frontmatter, every skill must have a disposition row.
|
|
fm_role = {}
|
|
for d in sorted(SKILLS_DIR.iterdir()):
|
|
if not d.is_dir():
|
|
continue
|
|
skill_md = d / "SKILL.md"
|
|
if not skill_md.is_file():
|
|
continue
|
|
text = skill_md.read_text()
|
|
m = re.match(r'---\n(.*?)\n---', text, re.DOTALL)
|
|
if not m:
|
|
continue
|
|
try:
|
|
fm = yaml.safe_load(m.group(1)) or {}
|
|
except yaml.YAMLError:
|
|
continue
|
|
fm_role[d.name] = fm.get("hexagonal_role")
|
|
|
|
actual_skills = set(fm_role.keys())
|
|
disp_skills = {d["skill"] for d in disps}
|
|
|
|
missing_disps = sorted(actual_skills - disp_skills)
|
|
extra_disps = sorted(disp_skills - actual_skills)
|
|
role_drifts = []
|
|
for d in disps:
|
|
sk = d["skill"]
|
|
expected = fm_role.get(sk)
|
|
if expected is not None and expected != d["hexagonal_role"]:
|
|
role_drifts.append((sk, d["hexagonal_role"], expected))
|
|
|
|
errs = []
|
|
if missing_disps:
|
|
errs.append(f"skill(s) exist with no disposition row: {missing_disps}")
|
|
if extra_disps:
|
|
errs.append(f"disposition row(s) reference nonexistent skill: {extra_disps}")
|
|
if role_drifts:
|
|
errs.append(f"hexagonal_role drift (yaml vs SKILL.md): {role_drifts}")
|
|
if errs:
|
|
for e in errs:
|
|
print("ERROR:", e, file=sys.stderr)
|
|
print("Fix docs/contracts/skill-dispositions.yaml before regenerating.", file=sys.stderr)
|
|
sys.exit(2)
|
|
|
|
|
|
# --- Render the three replaceable sections ---
|
|
|
|
audit_summary = f"""| Signal | Result |
|
|
|---|---:|
|
|
| Skills audited | {len(actual_skills)} |
|
|
| Domains classified | {len(set(d['domain'] for d in disps))} of 5 (BC1-BC5) |
|
|
| Dispositions assigned | {len(disp_skills)} / {len(actual_skills)} |"""
|
|
|
|
|
|
taxonomy_lines = ["| Domain | Product layer | Responsibility |", "|---|---|---|"]
|
|
for bc in bcs:
|
|
taxonomy_lines.append(
|
|
f"| {bc['id']} {bc['name']} | {bc['product_layer']} | {bc['responsibility']} |"
|
|
)
|
|
domain_taxonomy = "\n".join(taxonomy_lines)
|
|
|
|
|
|
full_map_lines = [
|
|
"| Skill | Domain | Hex role | First disposition | Rationale |",
|
|
"|---|---|---|---|---|",
|
|
]
|
|
for d in disps:
|
|
full_map_lines.append(
|
|
f"| `{d['skill']}` | {d['domain']} | {d['hexagonal_role']} | {d['disposition']} | {d['rationale']}. |"
|
|
)
|
|
full_skill_map = "\n".join(full_map_lines)
|
|
|
|
|
|
# --- Replace marker blocks in the .md ---
|
|
|
|
text = MAP_DOC.read_text()
|
|
|
|
def replace_block(name, content):
|
|
global text
|
|
pattern = re.compile(
|
|
rf'(<!-- BEGIN:{name} -->\n).*?(\n<!-- END:{name} -->)',
|
|
re.DOTALL,
|
|
)
|
|
if not pattern.search(text):
|
|
# Block markers do not exist yet — insert near a known section.
|
|
# For first-run bootstrap, we expect the doc to already contain
|
|
# markers (added in this PR). Hard-fail otherwise.
|
|
print(f"ERROR: marker block <!-- BEGIN:{name} --> not found in {MAP_DOC.name};"
|
|
" the doc must contain marker pairs for every generated section.",
|
|
file=sys.stderr)
|
|
sys.exit(2)
|
|
text = pattern.sub(rf'\1{content}\2', text)
|
|
|
|
replace_block("audit-summary", audit_summary)
|
|
replace_block("domain-taxonomy", domain_taxonomy)
|
|
replace_block("full-skill-map", full_skill_map)
|
|
|
|
|
|
# --- Output ---
|
|
|
|
if MODE == "stdout":
|
|
sys.stdout.write(text)
|
|
elif MODE == "check":
|
|
current = MAP_DOC.read_text()
|
|
if text == current:
|
|
print("PASS — skill-domain-map.md matches generator output.")
|
|
sys.exit(0)
|
|
print("FAIL — skill-domain-map.md DIVERGES from generator output.", file=sys.stderr)
|
|
print("Run: bash scripts/generate-skill-domain-map.sh", file=sys.stderr)
|
|
print(file=sys.stderr)
|
|
import difflib
|
|
diff = difflib.unified_diff(
|
|
current.splitlines(keepends=True),
|
|
text.splitlines(keepends=True),
|
|
fromfile="committed",
|
|
tofile="generated",
|
|
n=2,
|
|
)
|
|
sys.stderr.writelines(diff)
|
|
sys.exit(1)
|
|
else: # write
|
|
MAP_DOC.write_text(text)
|
|
print(f"Generated {MAP_DOC.relative_to(SCRIPT_DIR.parent.parent.parent.parent) if False else MAP_DOC.name}")
|
|
print(f" {len(actual_skills)} skills, {len(bcs)} BCs, {len(disps)} dispositions")
|
|
PY
|