#!/usr/bin/env python3 """Fix paperclipai/* skill drift across CMP+CMPA agents. Goal: zero drift on paperclipai/* skills between master(CMP) and mirror(CMPA). Rules: * Remove ``paperclipai/paperclip/paperclip-dev`` from all 14 agents (not relevant for legal work — it's for maintaining Paperclip itself). * Ensure ``paperclipai/paperclip/paperclip-converting-plans-to-tasks`` exists on CEO + analyst agents in both companies (planning skill). * Remove ``paperclipai/paperclip/paperclip-converting-plans-to-tasks`` from any other agent in either company that currently has it. Local/* and company/* skills are not touched — they're scoped to a company by design and drift is expected. Usage:: PAPERCLIP_BOARD_API_KEY=pbk_... python scripts/fix_paperclipai_skills_drift.py # dry-run PAPERCLIP_BOARD_API_KEY=pbk_... python scripts/fix_paperclipai_skills_drift.py --apply # commit """ from __future__ import annotations import argparse import asyncio import os import sys import httpx PAPERCLIP_API_URL = os.environ.get("PAPERCLIP_API_URL", "http://localhost:3100") PAPERCLIP_BOARD_API_KEY = os.environ.get("PAPERCLIP_BOARD_API_KEY") COMPANIES = { "licensing": ("CMP ", "42a7acd0-30c5-4cbd-ac97-7424f65df294"), "betterment": ("CMPA", "8639e837-4c9d-47fa-a76b-95788d651896"), } DEV_SKILL = "paperclipai/paperclip/paperclip-dev" CONVERTING_SKILL = "paperclipai/paperclip/paperclip-converting-plans-to-tasks" # Hebrew names of the agents that should retain converting-plans-to-tasks. CONVERTING_TARGETS = {"עוזר משפטי", "מנתח משפטי"} def headers() -> dict[str, str]: if not PAPERCLIP_BOARD_API_KEY: sys.exit("PAPERCLIP_BOARD_API_KEY not set — fetch from Infisical first.") return { "Authorization": f"Bearer {PAPERCLIP_BOARD_API_KEY}", "Content-Type": "application/json", } async def fetch_company_agents(client: httpx.AsyncClient, company_id: str) -> list[dict]: r = await client.get(f"{PAPERCLIP_API_URL}/api/companies/{company_id}/agents", headers=headers()) r.raise_for_status() return r.json() def compute_changes(agent: dict) -> tuple[bool, list[str], list[str]]: skill_sync = (agent.get("adapterConfig") or {}).get("paperclipSkillSync") or {} old = list(skill_sync.get("desiredSkills") or []) new = [s for s in old if s != DEV_SKILL] if agent["name"] in CONVERTING_TARGETS: if CONVERTING_SKILL not in new: new.append(CONVERTING_SKILL) else: new = [s for s in new if s != CONVERTING_SKILL] return (sorted(old) != sorted(new), old, new) async def patch_agent( client: httpx.AsyncClient, agent_id: str, current_skill_sync: dict, new_skills: list[str] ) -> None: body = { "adapterConfig": { "paperclipSkillSync": {**current_skill_sync, "desiredSkills": new_skills}, } } r = await client.patch( f"{PAPERCLIP_API_URL}/api/agents/{agent_id}", headers=headers(), json=body, timeout=15 ) r.raise_for_status() async def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--apply", action="store_true", help="commit changes (default: dry-run)") args = parser.parse_args() mode = "APPLY" if args.apply else "DRY-RUN" print(f"=== {mode}: fixing paperclipai/* skill drift ===\n") async with httpx.AsyncClient(timeout=15) as client: all_agents: list[dict] = [] for label, (_, cid) in COMPANIES.items(): agents = await fetch_company_agents(client, cid) for a in agents: a["_company_label"] = COMPANIES[label][0] all_agents.extend(agents) changes_planned = 0 for a in sorted(all_agents, key=lambda x: (x["_company_label"], x["name"])): changed, old, new = compute_changes(a) label = a["_company_label"] if not changed: print(f" {label} {a['name']:20} no change") continue changes_planned += 1 removed = sorted(set(old) - set(new)) added = sorted(set(new) - set(old)) print(f" {label} {a['name']:20} -{len(removed)} +{len(added)}") for s in removed: print(f" - {s}") for s in added: print(f" + {s}") if args.apply: skill_sync = (a.get("adapterConfig") or {}).get("paperclipSkillSync") or {} try: await patch_agent(client, a["id"], skill_sync, new) print(" ✓ patched") except httpx.HTTPStatusError as e: print(f" ✗ failed: {e.response.status_code} {e.response.text[:200]}") raise print(f"\n{mode}: {changes_planned} agents would change") if not args.apply and changes_planned > 0: print("Run with --apply to commit.") if __name__ == "__main__": asyncio.run(main())