Files
legal-ai/scripts/fix_paperclipai_skills_drift.py
Chaim 1b14e04373
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 7s
chore(skills): remove paperclip-dev, scope converting-plans-to-tasks
paperclip-dev is for maintaining the Paperclip codebase itself — not
relevant to legal work. Removed from all 14 agents (was on CMPA mirror).

paperclip-converting-plans-to-tasks helps decompose a plan into assigned
issues. Useful for the planning-heavy agents (CEO, analyst). Now scoped
to those two — removed from the other 5 in CMPA where it had crept in.

Net effect: zero drift on paperclipai/* skills across all 7 master+mirror
pairs. Verified via the new Agents tab dashboard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:47:05 +00:00

135 lines
4.9 KiB
Python

#!/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())