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>
This commit is contained in:
191
scripts/sync_missing_agent_skills.py
Normal file
191
scripts/sync_missing_agent_skills.py
Normal file
@@ -0,0 +1,191 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user