Files
legal-ai/scripts/sync_missing_agent_skills.py
Chaim cf5f6fe274 feat(paperclip): close 11 integration gaps (#16-#28)
Brings the legal-ai ↔ Paperclip integration in line with the official
Paperclip skill. Net effect: HEARTBEAT.md -47% (370→195 lines), all 14
agents on uniform runtime_config + budget + instructionsBundleMode, and
two cross-company helpers replacing manual SQL.

Highlights:
- HEARTBEAT.md refactor: project-specific only, delegates to the official
  paperclipai/paperclip skill (loaded per agent). Adds heartbeat-context
  fast-path (§1.7) and PAPERCLIP_WAKE_PAYLOAD_JSON shortcut (§1.5).
- Issue Thread Interactions API: legal-ceo.md now uses
  ask_user_questions / request_confirmation / suggest_tasks instead of
  free-text comments — gives chair structured UI with idempotency keys.
- pc.sh + paperclip_api.pc_request: every API call goes through helpers
  that inject Authorization + X-Paperclip-Run-Id (audit trail).
- sync_agents_across_companies.py: master(CMP)→mirror(CMPA) sync via
  Paperclip API, idempotent, with --verify and --apply modes.
- skills/new-company-setup: 11-step blueprint distilling all 11 gaps
  into a single onboarding runbook for the next company.
- .taskmaster: 12 tasks covering each gap (one already closed: #29).

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

192 lines
7.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""sync_missing_agent_skills.py — One-shot fix for Gap #28.
Adds the missing paperclipSkillSync to הגהת מסמכים and מנתח משפטי
in both companies (1xxx CMP, 8xxx CMPA). Idempotent: safe to re-run.
Design: "אל-כשל" — backup, dry-run mode, idempotent, clear errors.
Usage:
python sync_missing_agent_skills.py --dry-run # show plan only
python sync_missing_agent_skills.py --apply # actually do it
python sync_missing_agent_skills.py --verify # check current state
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import asyncpg
import httpx
PAPERCLIP_DB_URL = os.environ.get(
"PAPERCLIP_DB_URL", "postgresql://paperclip:paperclip@127.0.0.1:54329/paperclip"
)
PAPERCLIP_API_URL = os.environ.get("PAPERCLIP_API_URL", "http://localhost:3100")
PAPERCLIP_BOARD_API_KEY = os.environ.get("PAPERCLIP_BOARD_API_KEY", "")
BACKUP_DIR = Path("/home/chaim/.paperclip/instances/default/data/backups/manual")
PAPERCLIP_BASE_SKILLS = [
"paperclipai/paperclip/paperclip",
"paperclipai/paperclip/paperclip-create-agent",
"paperclipai/paperclip/paperclip-create-plugin",
"paperclipai/paperclip/para-memory-files",
]
CMP_COMPANY_ID = "42a7acd0-30c5-4cbd-ac97-7424f65df294" # 1xxx — רישוי ובניה
CMPA_COMPANY_ID = "8639e837-4c9d-47fa-a76b-95788d651896" # 8xxx — היטלי השבחה
# Per-agent + per-company desired skills
PLAN: dict[tuple[str, str], list[str]] = {
# (agent_name, company_id) -> desired skills
("מנתח משפטי", CMP_COMPANY_ID): PAPERCLIP_BASE_SKILLS + ["local/eba6210d5a/legal-decision"],
("מנתח משפטי", CMPA_COMPANY_ID): PAPERCLIP_BASE_SKILLS, # CMPA has no local skills
("הגהת מסמכים", CMP_COMPANY_ID): PAPERCLIP_BASE_SKILLS,
("הגהת מסמכים", CMPA_COMPANY_ID): PAPERCLIP_BASE_SKILLS,
}
def fail(msg: str) -> None:
print(f"{msg}", file=sys.stderr)
sys.exit(1)
async def fetch_targets() -> list[dict[str, Any]]:
"""Return rows for the agents we plan to update."""
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try:
rows = await conn.fetch(
"""
SELECT a.id, a.name, a.company_id::text as company_id,
COALESCE(
jsonb_array_length(a.adapter_config->'paperclipSkillSync'->'desiredSkills'),
0
) as current_skill_count
FROM agents a
WHERE a.name IN ('מנתח משפטי', 'הגהת מסמכים')
ORDER BY a.name, a.company_id
"""
)
finally:
await conn.close()
return [dict(r) for r in rows]
def backup_agents_table() -> Path:
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
out = BACKUP_DIR / f"agents-pre-skill-sync-{stamp}.sql"
env = {**os.environ, "PGPASSWORD": "paperclip"}
subprocess.run(
["pg_dump", "-h", "127.0.0.1", "-p", "54329", "-U", "paperclip",
"-d", "paperclip", "-t", "agents", "--data-only", "-f", str(out)],
check=True, env=env,
)
return out
async def call_skill_sync(agent_id: str, desired_skills: list[str]) -> tuple[int, dict[str, Any]]:
"""Call POST /api/agents/{id}/skills/sync with the desired skills list."""
if not PAPERCLIP_BOARD_API_KEY:
fail("PAPERCLIP_BOARD_API_KEY not set — needed for /api/agents/.../skills/sync")
url = f"{PAPERCLIP_API_URL}/api/agents/{agent_id}/skills/sync"
headers = {
"Authorization": f"Bearer {PAPERCLIP_BOARD_API_KEY}",
"X-Paperclip-Run-Id": "",
"Content-Type": "application/json",
}
body = {"desiredSkills": desired_skills}
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(url, headers=headers, json=body)
try:
data = resp.json()
except Exception:
data = {"raw": resp.text[:500]}
return resp.status_code, data
async def main() -> None:
p = argparse.ArgumentParser()
g = p.add_mutually_exclusive_group(required=True)
g.add_argument("--dry-run", action="store_true", help="Show plan, do not apply")
g.add_argument("--apply", action="store_true", help="Actually call the skill-sync API")
g.add_argument("--verify", action="store_true", help="Show current state only")
args = p.parse_args()
targets = await fetch_targets()
if len(targets) != 4:
fail(f"Expected 4 target rows (2 agents × 2 companies), got {len(targets)}")
# Build a map for plan
by_key = {(r["name"], r["company_id"]): r for r in targets}
print(f"\n=== Targets in DB ({len(targets)} rows) ===")
for r in targets:
company_label = "1xxx CMP" if r["company_id"] == CMP_COMPANY_ID else "8xxx CMPA"
print(f" {r['name']:14s} | {company_label} | id={r['id']} | currently {r['current_skill_count']} skills")
print(f"\n=== Plan ===")
for (agent_name, company_id), desired in PLAN.items():
company_label = "1xxx CMP" if company_id == CMP_COMPANY_ID else "8xxx CMPA"
target = by_key.get((agent_name, company_id))
if not target:
print(f"{agent_name} in {company_label}: NOT FOUND in DB")
continue
print(f" {agent_name:14s} | {company_label} | will set {len(desired)} skills:")
for s in desired:
print(f" - {s}")
if args.verify:
print("\n(verify mode — exiting without changes)")
return
if args.dry_run:
print("\n(dry-run mode — exiting without changes)\nRe-run with --apply to execute.")
return
# APPLY mode
print(f"\n=== Backup ===")
backup_path = backup_agents_table()
print(f" ✓ Backed up agents table → {backup_path}")
print(f"\n=== Applying skill-sync via API ===")
failures = []
for (agent_name, company_id), desired in PLAN.items():
target = by_key.get((agent_name, company_id))
if not target:
failures.append(f"{agent_name} in {company_id}: not found")
continue
status, data = await call_skill_sync(target["id"], desired)
if status >= 400:
failures.append(f"{agent_name} ({company_id[:8]}...): HTTP {status}{json.dumps(data)[:200]}")
print(f"{agent_name} ({target['id']}): HTTP {status}")
else:
new_count = len(data.get("desiredSkills") or data.get("skills") or [])
print(f"{agent_name} ({target['id']}): HTTP {status} (now {new_count or len(desired)} skills)")
if failures:
print(f"\n=== ⚠️ {len(failures)} failures ===")
for f in failures:
print(f" - {f}")
print(f"\nRollback: psql ... -f {backup_path}")
sys.exit(1)
# Verify
print(f"\n=== Post-apply verification ===")
final = await fetch_targets()
for r in final:
company_label = "1xxx CMP" if r["company_id"] == CMP_COMPANY_ID else "8xxx CMPA"
emoji = "" if r["current_skill_count"] >= 4 else ""
print(f" {emoji} {r['name']:14s} | {company_label} | now {r['current_skill_count']} skills")
if __name__ == "__main__":
asyncio.run(main())