From f7a8ad48acbae1be52106a24706d0b2b09af928c Mon Sep 17 00:00:00 2001 From: Chaim Date: Sat, 13 Jun 2026 11:28:16 +0000 Subject: [PATCH] =?UTF-8?q?feat(ops):=20=D7=9E=D7=A2=D7=91=D7=A8-=D7=90?= =?UTF-8?q?=D7=93=D7=A4=D7=98=D7=A8=20=D7=91=D7=98=D7=95=D7=97=20=D7=9C?= =?UTF-8?q?=D7=9B=D7=9C=20=D7=A1=D7=95=D7=9B=D7=9F=20=E2=86=90=20=D7=9B?= =?UTF-8?q?=D7=9C=20=D7=90=D7=93=D7=A4=D7=98=D7=A8=20(any=E2=86=92any,=20?= =?UTF-8?q?=D7=A9=D7=AA=D7=99=20=D7=94=D7=97=D7=91=D7=A8=D7=95=D7=AA)=20?= =?UTF-8?q?=E2=80=94=20=D7=A1=D7=95=D7=92=D7=A8=20FU-8a?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit הקשר: החלפת adapter_type ב-dropdown הגולמי של Paperclip מקריסה את הסוכן מיד — content_arg adapters (gemini_local/deepseek_local) שוברים את ה-frontmatter המוביל `---` (yargs/arg-parser קורא אותו כדגל → "Not enough arguments following: prompt"), המודל חייב להתאים ל-provider, ו-excludeTools של gemini גלובלי לכל הסוכנים. בנוסף — שינוי חברה-אחת מפר את INV-MC1 (סנכרון מדלג על adapter mismatch → drift שקט). הכלי מיישב את 3 צירי-הכשל data-driven, מעביר בשתי החברות יחד, הפיך מדויק, ומסרב מעבר לא-בטוח ב-preflight (סוגר את פער FU-8a "אין שער-ולידציה למעבר-אדפטר"). - scripts/adapter_profiles.py — רישום-פרופילי-אדפטר (מקור-אמת יחיד; אדפטר חדש = רשומה אחת) - scripts/migrate_agent_adapter.py — --check/--apply/--revert/--verify; frontmatter→.nofm.md נגזר, sidecar לשחזור, --relax-tools ל-excludeTools, PATCH דרך /api/agents/{id} (לא DB) - .gitignore + scripts/SCRIPTS.md נבדק: --verify (9 סוכנים תקינים), --check (CEO→gemini מציג nofm+model+tool-conflict; provider-guard תופס מודל לא-תואם, exit≠0), ובדיקות-לוגיקה טהורות (strip/canonical/recompute). Invariants: מקיים G2 (רישום+קובץ-קנוני יחיד, גרסת-gemini נגזרת), G12 (PATCH-API דרך המעטפת, לא DB), INV-MC1 (שתי חברות יחד). סוגר FU-8a. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 2 + scripts/SCRIPTS.md | 2 + scripts/adapter_profiles.py | 93 ++++++ scripts/migrate_agent_adapter.py | 544 +++++++++++++++++++++++++++++++ 4 files changed, 641 insertions(+) create mode 100644 scripts/adapter_profiles.py create mode 100644 scripts/migrate_agent_adapter.py diff --git a/.gitignore b/.gitignore index c1d0aae..84a79f7 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,6 @@ kiryat-yearim/ continuation-prompt.md node_modules/ data/eval/eval-report-* +data/adapter-migration-state.json # revert snapshot for migrate_agent_adapter.py (runtime state) +.claude/agents/.generated/ # frontmatter-stripped instruction copies for content_arg adapters (generated) .claude/worktrees/ diff --git a/scripts/SCRIPTS.md b/scripts/SCRIPTS.md index 180e7b1..cec299a 100644 --- a/scripts/SCRIPTS.md +++ b/scripts/SCRIPTS.md @@ -15,6 +15,8 @@ | `sync_missing_agent_skills.py` | python | סקריפט "אל-כשל" להוספת `paperclipSkillSync` ל-`הגהת מסמכים` ו-`מנתח משפטי` שפיספסו את ה-sync ההיסטורי (Gap #28). תומך `--verify`/`--dry-run`/`--apply`. גיבוי אוטומטי ל-`agents-pre-skill-sync-*.sql`. דורש `PAPERCLIP_BOARD_API_KEY` (Infisical /paperclip ב-nautilus env). idempotent. | חד-פעמי (בוצע 2026-05-04). שמור לרפרנס | | `sync_agents_across_companies.py` | python | **סנכרון סוכנים מ-CMP (1xxx, master) ל-CMPA (8xxx, mirror)** — Gap #25. משווה adapter_config (model/timeout/instructions/skills/etc), runtime_config (heartbeat), ושדות top-level (budget/metadata/icon/title/role). מסנן אוטומטית local skills שלא קיימים ב-mirror. לוגיקת subset (mirror יכול להחזיק יותר skills כי ה-API מוסיף required runtime skills). תומך `--verify`/`--dry-run`/`--apply [--only NAME]`. גיבוי אוטומטי. דורש `PAPERCLIP_BOARD_API_KEY`. **להריץ אחרי כל שינוי הגדרות ב-CMP.** **⚠ אם `adapter_type` שונה בין CMP ל-CMPA — `--apply` מדלג על הסוכן; `--verify` מדווח אותו רם כ-DRIFT.** בעת מעבר adapter (למשל ל-`deepseek_local`) חובה לעדכן ידנית בשתי החברות. **`--verify` יוצא exit≠0 על כל drift** (needs-sync / adapter-mismatch / missing-in-mirror) — שמיש כ-gate ל-cron/CI (GAP-21/FU-8a). | ידני אחרי כל שינוי | | `fix_paperclipai_skills_drift.py` | python | סקריפט חד-פעמי (בוצע 2026-05-04) שניקה drift על `paperclipai/*` skills בין CMP ל-CMPA. הסיר `paperclip-dev` מכל 14 הסוכנים, ודאג ש-`paperclip-converting-plans-to-tasks` קיים רק על CEO ו-analyst. תומך `--apply` (ברירת מחדל: dry-run). דורש `PAPERCLIP_BOARD_API_KEY`. נשמר לרפרנס למקרה שhdrift חוזר. | חד-פעמי (בוצע) | +| `adapter_profiles.py` | python (module) | **רישום-פרופילי-אדפטר** — מקור-אמת יחיד ל-3 צירי-הכשל של מעבר-אדפטר: provider/default_model, instructions_mode (`file_path` בטוח-frontmatter מול `content_arg` ששובר `---`), ו-tool_config (`gemini_global` excludeTools / `frontmatter` / `hermes`). מיובא ע"י `migrate_agent_adapter.py`. הוספת אדפטר עתידי = רשומה אחת. לא מורץ ישירות. | תשתית | +| `migrate_agent_adapter.py` | python | **מעבר-אדפטר בטוח לכל סוכן ← כל אדפטר, בשתי החברות יחד (INV-MC1)**. מיישב model↔provider, גורס frontmatter לעותק `.generated/.nofm.md` ל-content_arg adapters (אחרת קריסת `gemini --prompt`/`hermes -q` על `---`), ומשחרר excludeTools גלובלי של gemini (`--relax-tools`). `--check` (preflight בלבד, exit≠0 על שגיאה — שער FU-8a) / `--apply` / `--revert` (שחזור מדויק מ-sidecar `data/adapter-migration-state.json`) / `--verify` (מסמן מצב לא-תואם/א-סימטרי, exit≠0). `--agent "<שם>"\|all --to [--model X] [--relax-tools]`. PATCH דרך `/api/agents/{id}` (לא DB). דורש `PAPERCLIP_BOARD_API_KEY`. הרץ עם `mcp-server/.venv/bin/python`. **fallback-חירום כשנגמרים טוקני-Claude; החזר ל-claude_local כשחוזרים.** | ידני לפי צורך | | `test_retrieval_by_name.py` | python | בדיקת אחזור-לפי-שם (#52/RC-A) — מאמת ש`search_precedent_library`/`search_internal_decisions` מדרגים את ההחלטה עצמה (אגסי) מעל מי שמצטט אותה, + רגרסיות לשאילתות מהותיות. הרצה: `DOTENV_PATH=/home/chaim/.env DATA_DIR=.../data mcp-server/.venv/bin/python scripts/test_retrieval_by_name.py` (exit 0 = עבר). | ידני אחרי שינוי שכבת חיפוש | | `fu2b_reconcile_internal_case_numbers.py` | python | **FU-2b (GAP-07/08) — תיאום `case_number` של `internal_committee`** מציטוט-מלא למספר-בסיס קנוני (X1: trim·prefix-strip·`/`→`-`, חודש נשמר). דטרמיניסטי (token יחיד; 0/>1 → flag). `--dry-run` (ברירת-מחדל) מפיק טבלת-תיאום ל-`data/audit/fu2b-reconciliation-*.{csv,md}` עם flags (DUP_CHECK / PROC_MISMATCH / MISMATCH). `--apply --approved ` מגבה ואז מעדכן רק שורות שאושרו ע"י היו"ר. scope: internal בלבד (external → #68). FK-safe. | חד-פעמי, **chair-gated** (apply רק אחרי אישור דפנה) | | `fu2c_reconcile_external_case_numbers.py` | python | **FU-2c (GAP-08, #68) — תיאום `case_number` של פסיקה חיצונית** (`source_kind <> internal_committee`) מציטוט-מלא לצורה קנונית **מציין-הליך + docket** (החלטת-יו"ר 2026-05-31, Option A: `/` נשמר, *לא* `-`; תואם db.py:369 ו-INV-ID2). דטרמיניסטי (designator+docket; 0/>1 docket → flag). `--dry-run` (ברירת-מחדל) מפיק `data/audit/fu2c-reconciliation-*.{csv,md}` עם flags (MISMATCH / NO_CITATION / CIT_NO_DOCKET / DESIG_MISMATCH / DUP_CHECK). `--apply --approved ` מגבה ואז מעדכן שורות לא-חוסמות (כולל ADVISORY/NO_CITATION). `--overrides ` (id,proposed_canonical,reason) פותח שורות-חוסמות בהכרעת-יו"ר מפורשת (למשל פס"ד מאוחד — ראה `data/audit/fu2c-overrides.csv` לרשומת לויתן/קלמנוביץ). לוגיקת-החילוץ + פיצול flags אומתו offline על 24 רשומות. scope: external בלבד (internal = FU-2b). FK-safe. | חד-פעמי, **chair-gated** (apply רק אחרי אישור דפנה) | diff --git a/scripts/adapter_profiles.py b/scripts/adapter_profiles.py new file mode 100644 index 0000000..a61f0c5 --- /dev/null +++ b/scripts/adapter_profiles.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""adapter_profiles.py — single source of truth for Paperclip adapter capabilities. + +Why this exists: switching a Paperclip agent's adapter is NOT a free dropdown +flip. Each adapter family differs on three axes that, if not reconciled, crash +the agent immediately or degrade it silently: + + 1. model id ↔ provider — each adapter expects a model from its own provider + (claude-* / gemini-* / deepseek-*). Wrong/foreign id fails or silently + falls back to the adapter default. + 2. instructions transport — + • file_path adapters pass the instructions file as a *path* + (`claude --append-system-prompt-file`); the CLI parses it, + so a leading `---` YAML frontmatter is fine. + • content_arg adapters read the file and splice its *content* into the + CLI argument (`gemini --prompt ""`, + `hermes chat -q ""`). yargs/arg-parsers read a + value starting with `--` as a flag → the run dies within + ~2s with "Not enough arguments following: prompt". These + adapters therefore require a frontmatter-FREE instructions + file (must not start with `--`). + 3. tool availability — claude uses the frontmatter `tools:`/`--allowedTools`; + gemini reads a GLOBAL `~/.gemini/settings.json` `excludeTools` list (one + list for ALL gemini agents); deepseek uses `~/.hermes`. + +Keeping these facts in one declarative registry makes "migrate any agent to any +adapter" data-driven: adding a future adapter (codex_local, grok_local, ...) is +one new entry here. Consumed by scripts/migrate_agent_adapter.py. + +Verified 2026-06-13 against the installed adapter packages +(`@paperclipai/adapter-{claude,gemini}-local`, in-repo +`adapters/deepseek-paperclip-adapter`) and the live Paperclip agents table. +""" +from __future__ import annotations + + +# tool_config values: +# "frontmatter" — tools come from the agent .md frontmatter / --allowedTools +# "gemini_global" — tools gated by the global ~/.gemini/settings.json excludeTools +# "hermes" — tools configured under ~/.hermes (deepseek runtime) +ADAPTER_PROFILES: dict[str, dict] = { + "claude_local": { + "provider": "claude", + "default_model": "claude-opus-4-8", + "instructions_mode": "file_path", + "frontmatter_safe": True, + "tool_config": "frontmatter", + }, + "gemini_local": { + "provider": "gemini", + "default_model": "gemini-3.1-pro-preview", + "instructions_mode": "content_arg", + "frontmatter_safe": False, + "tool_config": "gemini_global", + }, + "deepseek_local": { + "provider": "deepseek", + "default_model": "deepseek-v4-pro", + "instructions_mode": "content_arg", + "frontmatter_safe": False, + "tool_config": "hermes", + }, +} + + +class UnknownAdapter(ValueError): + """Raised when an adapter type has no registered profile.""" + + +def get_profile(adapter_type: str) -> dict: + """Return the capability profile for an adapter type, or raise UnknownAdapter. + + Fail loud: a typo'd or unregistered adapter must not silently pass through + migration (that is exactly how the original crash happened).""" + profile = ADAPTER_PROFILES.get(adapter_type) + if profile is None: + known = ", ".join(sorted(ADAPTER_PROFILES)) + raise UnknownAdapter( + f"אדפטר לא מוכר: {adapter_type!r}. רשומים: {known}. " + f"הוסף פרופיל ל-adapter_profiles.py לפני מעבר אליו." + ) + return profile + + +def model_matches_provider(model: str, adapter_type: str) -> bool: + """True iff `model` belongs to the adapter's provider family. + + Empty model is treated as matching — the adapter falls back to its own + default model, which is by definition in-family.""" + if not model: + return True + provider = get_profile(adapter_type)["provider"] + return model.startswith(provider) diff --git a/scripts/migrate_agent_adapter.py b/scripts/migrate_agent_adapter.py new file mode 100644 index 0000000..0ac8868 --- /dev/null +++ b/scripts/migrate_agent_adapter.py @@ -0,0 +1,544 @@ +#!/usr/bin/env python3 +"""migrate_agent_adapter.py — safely migrate ANY Paperclip agent to ANY adapter. + +Why: flipping an agent's adapter in the raw Paperclip dropdown crashes it. The +three failure axes (model↔provider, instructions transport, tool gating) are +described in adapter_profiles.py. On top of those, INV-MC1 requires the two +companies (CMP 1xxx / CMPA 8xxx) to stay symmetric — a manual flip touches one +and the cross-company sync then *skips* the agent, leaving silent drift. + +This tool reconciles all three axes, applies to BOTH company records together, +snapshots the prior state for an exact revert, and refuses unsafe migrations +in preflight (closing the FU-8a "no adapter-switch validation gate" gap). + +It is an operations tool in the declared SHELL layer (like +sync_agents_across_companies.py) — it talks Paperclip via the documented +PATCH /api/agents/{id} path, never direct DB writes (G12). + +Runtime artifacts (frontmatter-stripped instruction copies, the revert +snapshot) are written to the MAIN working tree absolute paths, because that is +where Paperclip actually launches the agents (shared cwd /home/chaim/legal-ai). + +Usage: + # preflight only — reports what would change + warnings, no mutation + python migrate_agent_adapter.py --check --agent "עוזר משפטי" --to gemini_local + + # migrate one agent (both companies) — degraded-fallback warning printed + python migrate_agent_adapter.py --apply --agent "עוזר משפטי" --to gemini_local [--model X] [--relax-tools] + + # migrate the whole team + python migrate_agent_adapter.py --apply --agent all --to gemini_local --relax-tools + + # undo: restore each migrated agent to its pre-migration state + python migrate_agent_adapter.py --revert --agent "עוזר משפטי" # or --agent all + + # health gate: flag any agent in an incompatible / asymmetric state + python migrate_agent_adapter.py --verify + +Requires: PAPERCLIP_BOARD_API_KEY (Infisical: /paperclip @ nautilus). +Run with the interpreter that has asyncpg+httpx: + /home/chaim/legal-ai/mcp-server/.venv/bin/python +""" +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import re +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import asyncpg + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from adapter_profiles import ( # noqa: E402 + ADAPTER_PROFILES, + UnknownAdapter, + get_profile, + model_matches_provider, +) +from sync_agents_across_companies import ( # noqa: E402 (reuse SHELL helpers) + CMP_COMPANY_ID, + CMPA_COMPANY_ID, + PAPERCLIP_DB_URL, + call_patch, + fetch_agents, +) + +# Absolute MAIN-tree paths — agents run there, not in any worktree. +AGENTS_DIR = Path("/home/chaim/legal-ai/.claude/agents") +GENERATED_DIR = AGENTS_DIR / ".generated" +SIDECAR = Path("/home/chaim/legal-ai/data/adapter-migration-state.json") +GEMINI_SETTINGS = Path.home() / ".gemini" / "settings.json" +NOFM_SUFFIX = ".nofm.md" + +COMPANY_IDS = [CMP_COMPANY_ID, CMPA_COMPANY_ID] +COMPANY_LABELS = { + CMP_COMPANY_ID: "CMP (רישוי ובניה)", + CMPA_COMPANY_ID: "CMPA (היטלי השבחה)", +} + + +def fail(msg: str) -> None: + print(f"❌ {msg}", file=sys.stderr) + sys.exit(1) + + +# ─────────────────────────── sidecar (revert state) ─────────────────────────── + +def load_sidecar() -> dict: + if SIDECAR.exists(): + try: + return json.loads(SIDECAR.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as e: + fail(f"sidecar פגום ({SIDECAR}): {e}") + return {"agents": {}, "gemini_excludetools_baseline": None} + + +def save_sidecar(data: dict) -> None: + SIDECAR.parent.mkdir(parents=True, exist_ok=True) + SIDECAR.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + + +# ─────────────────────────── instructions handling ─────────────────────────── + +def canonical_instructions_path(current_path: str) -> str: + """Resolve the frontmatter-bearing canonical .md, even if currently pointed + at a generated .nofm.md copy.""" + if not current_path: + return "" + p = Path(current_path) + if p.parent == GENERATED_DIR and p.name.endswith(NOFM_SUFFIX): + base = p.name[: -len(NOFM_SUFFIX)] + return str(AGENTS_DIR / f"{base}.md") + return current_path + + +def strip_frontmatter(text: str) -> str: + """Remove a leading YAML `---...---` block so the content never starts with + `--` (which content_arg adapters parse as a CLI flag).""" + m = re.match(r"^---\n.*?\n---\n", text, re.DOTALL) + if m: + text = text[m.end():] + text = text.lstrip("\n") + if not text or text.lstrip().startswith("--"): + text = "# הוראות סוכן\n\n" + text + return text + + +def render_nofm_text(canonical_path: str) -> str: + return strip_frontmatter(Path(canonical_path).read_text(encoding="utf-8")) + + +def write_nofm(canonical_path: str) -> str: + """Generate the frontmatter-free copy and return its absolute path.""" + GENERATED_DIR.mkdir(parents=True, exist_ok=True) + base = Path(canonical_path).stem + out = GENERATED_DIR / f"{base}{NOFM_SUFFIX}" + out.write_text(render_nofm_text(canonical_path), encoding="utf-8") + return str(out) + + +def frontmatter_tools(canonical_path: str) -> list[str]: + """Parse the `tools:` list from an agent .md frontmatter (bare tool names, + `mcp__legal-ai__` prefix stripped).""" + try: + lines = Path(canonical_path).read_text(encoding="utf-8").splitlines() + except OSError: + return [] + tools: list[str] = [] + in_tools = False + for line in lines: + if not in_tools: + if re.match(r"^tools:\s*$", line): + in_tools = True + continue + m = re.match(r"^\s+-\s+(.+?)\s*$", line) + if m: + tools.append(m.group(1).strip()) + elif line.strip() and not line.startswith(" "): + break # left the tools: block + return [t.replace("mcp__legal-ai__", "") for t in tools] + + +# ─────────────────────────── gemini excludeTools ─────────────────────────── + +def read_gemini_excludetools() -> list[str]: + if not GEMINI_SETTINGS.exists(): + return [] + try: + cfg = json.loads(GEMINI_SETTINGS.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return [] + return list((((cfg.get("mcpServers") or {}).get("legal-ai") or {}).get("excludeTools")) or []) + + +def write_gemini_excludetools(tools: list[str]) -> None: + cfg = json.loads(GEMINI_SETTINGS.read_text(encoding="utf-8")) + cfg.setdefault("mcpServers", {}).setdefault("legal-ai", {})["excludeTools"] = sorted(set(tools)) + GEMINI_SETTINGS.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8") + + +def recompute_gemini_excludetools(sidecar: dict) -> list[str] | None: + """excludeTools = baseline minus the union of tools relaxed for agents still + targeted at gemini_local. Returns the list to write, or None if no baseline + was ever captured (nothing to recompute).""" + baseline = sidecar.get("gemini_excludetools_baseline") + if baseline is None: + return None + relaxed: set[str] = set() + for entry in sidecar["agents"].values(): + if entry.get("current_target") == "gemini_local": + relaxed.update(entry.get("relaxed_tools") or []) + return [t for t in baseline if t not in relaxed] + + +# ─────────────────────────── agent loading ─────────────────────────── + +async def load_pairs() -> dict[str, dict[str, dict]]: + """Return {agent_name: {company_id: agent_row}}.""" + conn = await asyncpg.connect(PAPERCLIP_DB_URL) + try: + rows = [] + for cid in COMPANY_IDS: + for a in await fetch_agents(conn, cid): + a["_company_id"] = cid + rows.append(a) + finally: + await conn.close() + pairs: dict[str, dict[str, dict]] = {} + for a in rows: + pairs.setdefault(a["name"], {})[a["_company_id"]] = a + return pairs + + +def adapter_of(agent: dict) -> str: + return agent.get("adapter_type") or "" + + +def model_of(agent: dict) -> str: + ac = agent.get("adapter_config") or {} + return ac.get("model") or "" + + +def instr_of(agent: dict) -> str: + ac = agent.get("adapter_config") or {} + return ac.get("instructionsFilePath") or "" + + +# ─────────────────────────── planning / preflight ─────────────────────────── + +def plan_migration(name: str, records: dict[str, dict], target: str, + model_override: str | None) -> dict: + """Compute the safe migration for one agent (both companies). Returns a dict + with target_model, target_instr, nofm_needed, tool_conflicts, errors[], + warnings[].""" + errors: list[str] = [] + warnings: list[str] = [] + + try: + profile = get_profile(target) + except UnknownAdapter as e: + return {"errors": [str(e)], "warnings": []} + + # INV-MC1: both company records must exist to flip symmetrically. + missing = [COMPANY_LABELS[c] for c in COMPANY_IDS if c not in records] + if missing: + errors.append(f"חסרה רשומת-סוכן בחברות: {', '.join(missing)} — אי-אפשר להפוך סימטרית.") + return {"errors": errors, "warnings": warnings} + + target_model = (model_override or profile["default_model"] or "").strip() + if not model_matches_provider(target_model, target): + errors.append( + f"מודל {target_model!r} אינו תואם ל-provider {profile['provider']!r} של {target}. " + f"השתמש ב---model או הסר אותו לקבלת ברירת-המחדל ({profile['default_model']})." + ) + + # instructions transport + canonical = canonical_instructions_path(instr_of(records[CMP_COMPANY_ID])) + if not canonical or not os.path.exists(canonical): + errors.append(f"קובץ-הוראות קנוני חסר: {canonical or '(לא מוגדר)'}") + nofm_needed = not profile["frontmatter_safe"] + target_instr = canonical + elif profile["frontmatter_safe"]: + nofm_needed = False + target_instr = canonical + else: + nofm_needed = True + target_instr = str(GENERATED_DIR / f"{Path(canonical).stem}{NOFM_SUFFIX}") + # validate the (in-memory) stripped content really is flag-safe + stripped = render_nofm_text(canonical) + if stripped.lstrip().startswith("--"): + errors.append("גריסת-ה-frontmatter עדיין פותחת ב-'--' — לא בטוח ל-content_arg adapter.") + + # tool gating (gemini global excludeTools) + tool_conflicts: list[str] = [] + if profile["tool_config"] == "gemini_global" and canonical and os.path.exists(canonical): + required = set(frontmatter_tools(canonical)) + excluded = set(read_gemini_excludetools()) + tool_conflicts = sorted(required & excluded) + if tool_conflicts: + warnings.append( + "כלי-כתיבה שייחסמו תחת gemini (excludeTools גלובלי): " + f"{', '.join(tool_conflicts)} — הוסף --relax-tools כדי לשחרר אותם." + ) + + return { + "target_model": target_model, + "target_instr": target_instr, + "nofm_needed": nofm_needed, + "canonical": canonical, + "tool_conflicts": tool_conflicts, + "errors": errors, + "warnings": warnings, + } + + +def print_plan(name: str, records: dict, target: str, plan: dict) -> None: + cur = adapter_of(records[CMP_COMPANY_ID]) + print(f"\n • {name} — {cur or '(none)'} → {target}") + if plan.get("errors"): + for e in plan["errors"]: + print(f" ❌ {e}") + return + print(f" model: {model_of(records[CMP_COMPANY_ID]) or '(default)'} → {plan['target_model']}") + print(f" instructions: {plan['target_instr']}" + f"{' (frontmatter-stripped copy)' if plan['nofm_needed'] else ''}") + for w in plan["warnings"]: + print(f" ⚠ {w}") + + +# ─────────────────────────── apply / revert ─────────────────────────── + +async def apply_one(name: str, records: dict, target: str, plan: dict, + sidecar: dict, relax_tools: bool) -> list[str]: + errors: list[str] = [] + + # 1. snapshot ORIGINAL state once (first time we ever touch this agent), + # so --revert always returns to the true pre-migration config. + agents_state = sidecar["agents"] + if name not in agents_state: + agents_state[name] = { + "original": { + cid: { + "adapter_type": adapter_of(records[cid]), + "model": model_of(records[cid]), + "instructionsFilePath": instr_of(records[cid]), + } + for cid in COMPANY_IDS + }, + "relaxed_tools": [], + } + agents_state[name]["current_target"] = target + + # 2. generate the frontmatter-free instructions copy if needed + if plan["nofm_needed"]: + write_nofm(plan["canonical"]) + + # 3. PATCH both company records (adapterType + adapterConfig merge) + for cid in COMPANY_IDS: + body = { + "adapterType": target, + "adapterConfig": { + "model": plan["target_model"], + "instructionsFilePath": plan["target_instr"], + }, + } + status, data = await call_patch(records[cid]["id"], body) + if status >= 400: + errors.append(f"{COMPANY_LABELS[cid]}: PATCH HTTP {status}: {json.dumps(data)[:200]}") + else: + print(f" ✓ {COMPANY_LABELS[cid]} → {target} / {plan['target_model']}") + + # 4. relax global gemini excludeTools if requested + if relax_tools and plan.get("tool_conflicts"): + if sidecar.get("gemini_excludetools_baseline") is None: + sidecar["gemini_excludetools_baseline"] = read_gemini_excludetools() + agents_state[name]["relaxed_tools"] = plan["tool_conflicts"] + new_excl = recompute_gemini_excludetools(sidecar) + if new_excl is not None: + write_gemini_excludetools(new_excl) + print(f" ✓ excludeTools שוחרר: {', '.join(plan['tool_conflicts'])} (גלובלי לכל gemini)") + + return errors + + +async def revert_one(name: str, records: dict, sidecar: dict) -> list[str]: + errors: list[str] = [] + entry = sidecar["agents"].get(name) + if not entry: + print(f" ⏭ {name} — אין snapshot לשחזור (לא הועבר דרך הכלי).") + return errors + + for cid in COMPANY_IDS: + if cid not in records: + errors.append(f"{name}: חסרה רשומה ב-{COMPANY_LABELS[cid]} — דלג.") + continue + orig = entry["original"][cid] + body = { + "adapterType": orig["adapter_type"], + "adapterConfig": { + "model": orig["model"], + "instructionsFilePath": orig["instructionsFilePath"], + }, + } + status, data = await call_patch(records[cid]["id"], body) + if status >= 400: + errors.append(f"{COMPANY_LABELS[cid]}: PATCH HTTP {status}: {json.dumps(data)[:200]}") + else: + print(f" ✓ {COMPANY_LABELS[cid]} ← {orig['adapter_type']} / {orig['model'] or '(default)'}") + + if not errors: + sidecar["agents"].pop(name, None) + # recompute global excludeTools now that this agent no longer needs relaxing + new_excl = recompute_gemini_excludetools(sidecar) + if new_excl is not None: + write_gemini_excludetools(new_excl) + return errors + + +# ─────────────────────────── verify ─────────────────────────── + +def verify_pairs(pairs: dict[str, dict]) -> int: + """Flag any agent whose live state is incompatible or asymmetric. Returns + the number of flagged agents (0 = healthy).""" + flagged = 0 + print("\n=== verify — תאימות מצב חי ===") + for name in sorted(pairs): + records = pairs[name] + problems: list[str] = [] + + # INV-MC1 — same adapter_type in both companies + adapters = {adapter_of(records[c]) for c in records} + if len(records) == 2 and len(adapters) > 1: + problems.append(f"adapter_type א-סימטרי בין החברות: {sorted(adapters)}") + + for cid, agent in records.items(): + at = adapter_of(agent) + if at not in ADAPTER_PROFILES: + problems.append(f"{COMPANY_LABELS.get(cid, cid)}: adapter לא מוכר {at!r}") + continue + profile = ADAPTER_PROFILES[at] + if not model_matches_provider(model_of(agent), at): + problems.append( + f"{COMPANY_LABELS.get(cid, cid)}: מודל {model_of(agent)!r} לא תואם ל-{at}") + if not profile["frontmatter_safe"]: + instr = instr_of(agent) + if instr and os.path.exists(instr): + head = Path(instr).read_text(encoding="utf-8", errors="ignore").lstrip() + if head.startswith("--"): + problems.append( + f"{COMPANY_LABELS.get(cid, cid)}: קובץ-הוראות פותח ב-'--' → יקרוס תחת {at}") + elif instr: + problems.append(f"{COMPANY_LABELS.get(cid, cid)}: קובץ-הוראות חסר: {instr}") + + if problems: + flagged += 1 + print(f" ❌ {name}") + for p in problems: + print(f" {p}") + else: + print(f" ✓ {name} — {sorted(adapters)[0] if adapters else '—'}") + + print(f"\nSummary: {flagged} סוכנים בעייתיים → {'DRIFT' if flagged else 'תקין'}") + return flagged + + +# ─────────────────────────── main ─────────────────────────── + +async def main() -> None: + p = argparse.ArgumentParser(description="Migrate any Paperclip agent to any adapter (both companies).") + g = p.add_mutually_exclusive_group(required=True) + g.add_argument("--check", action="store_true", help="Preflight only — no mutation") + g.add_argument("--apply", action="store_true", help="Perform the migration") + g.add_argument("--revert", action="store_true", help="Restore pre-migration state") + g.add_argument("--verify", action="store_true", help="Flag incompatible/asymmetric agents") + p.add_argument("--agent", help="Agent name (e.g. 'עוזר משפטי') or 'all'") + p.add_argument("--to", dest="target", help="Target adapter (for --check/--apply)") + p.add_argument("--model", help="Override target model (else adapter default)") + p.add_argument("--relax-tools", action="store_true", + help="Remove conflicting write tools from the global gemini excludeTools") + args = p.parse_args() + + pairs = await load_pairs() + + if args.verify: + sys.exit(1 if verify_pairs(pairs) else 0) + + if not args.agent: + fail("--agent נדרש (שם-סוכן או 'all').") + if args.agent == "all": + names = sorted(pairs) + elif args.agent in pairs: + names = [args.agent] + else: + fail(f"סוכן לא נמצא: {args.agent!r}. קיימים: {', '.join(sorted(pairs))}") + + # ── revert ── + if args.revert: + sidecar = load_sidecar() + all_errors: list[str] = [] + print(f"=== revert ({len(names)} סוכנים) ===") + for name in names: + print(f"\n ↩ {name}") + errs = await revert_one(name, pairs[name], sidecar) + all_errors += [f"{name}: {e}" for e in errs] + save_sidecar(sidecar) + if all_errors: + print(f"\n⚠️ {len(all_errors)} שגיאות:") + for e in all_errors: + print(f" {e}") + sys.exit(1) + print("\n✓ revert הושלם.") + return + + # ── check / apply ── + if not args.target: + fail("--to נדרש עבור --check/--apply.") + if args.target not in ADAPTER_PROFILES: + fail(f"אדפטר לא מוכר: {args.target}. רשומים: {', '.join(sorted(ADAPTER_PROFILES))}") + + plans = {n: plan_migration(n, pairs[n], args.target, args.model) for n in names} + print(f"=== {'check' if args.check else 'apply'} → {args.target} ({len(names)} סוכנים) ===") + for n in names: + print_plan(n, pairs[n], args.target, plans[n]) + + hard_errors = {n: pl["errors"] for n, pl in plans.items() if pl.get("errors")} + if hard_errors: + print(f"\n❌ {len(hard_errors)} סוכנים עם שגיאות-preflight — לא בטוח להעביר.") + if args.check: + sys.exit(1) + fail("תקן את השגיאות לפני --apply.") + + if args.check: + print("\n✓ preflight עבר. הרץ עם --apply לביצוע.") + return + + # degraded-fallback warning + profile = ADAPTER_PROFILES[args.target] + if profile["provider"] != "claude": + print(f"\n⚠️ מעבר ל-{args.target} הוא fallback-חירום (degraded), לא שקול-איכות ל-Claude.") + print(" החזר ל-claude_local (--revert) כשטוקני-Claude חוזרים.") + + sidecar = load_sidecar() + all_errors: list[str] = [] + print(f"\n=== applying ===") + for n in names: + print(f"\n → {n}") + errs = await apply_one(n, pairs[n], args.target, plans[n], sidecar, args.relax_tools) + all_errors += [f"{n}: {e}" for e in errs] + save_sidecar(sidecar) + + if all_errors: + print(f"\n⚠️ {len(all_errors)} שגיאות:") + for e in all_errors: + print(f" {e}") + sys.exit(1) + print(f"\n✓ הועברו {len(names)} סוכנים ל-{args.target}. הרץ --verify לאישור, ו-sync_agents_across_companies.py --verify לוודא אפס drift.") + + +if __name__ == "__main__": + asyncio.run(main()) -- 2.49.1