feat(ops): מעבר-אדפטר בטוח לכל סוכן ← כל אדפטר (any→any, שתי החברות) — סוגר FU-8a
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
הקשר: החלפת 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 <noreply@anthropic.com>
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -18,4 +18,6 @@ kiryat-yearim/
|
|||||||
continuation-prompt.md
|
continuation-prompt.md
|
||||||
node_modules/
|
node_modules/
|
||||||
data/eval/eval-report-*
|
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/
|
.claude/worktrees/
|
||||||
|
|||||||
@@ -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_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). | ידני אחרי כל שינוי |
|
| `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 חוזר. | חד-פעמי (בוצע) |
|
| `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/<name>.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 <adapter> [--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 = עבר). | ידני אחרי שינוי שכבת חיפוש |
|
| `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 <csv>` מגבה ואז מעדכן רק שורות שאושרו ע"י היו"ר. scope: internal בלבד (external → #68). FK-safe. | חד-פעמי, **chair-gated** (apply רק אחרי אישור דפנה) |
|
| `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 <csv>` מגבה ואז מעדכן רק שורות שאושרו ע"י היו"ר. 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 <csv>` מגבה ואז מעדכן שורות לא-חוסמות (כולל ADVISORY/NO_CITATION). `--overrides <csv>` (id,proposed_canonical,reason) פותח שורות-חוסמות בהכרעת-יו"ר מפורשת (למשל פס"ד מאוחד — ראה `data/audit/fu2c-overrides.csv` לרשומת לויתן/קלמנוביץ). לוגיקת-החילוץ + פיצול flags אומתו offline על 24 רשומות. scope: external בלבד (internal = FU-2b). 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 <csv>` מגבה ואז מעדכן שורות לא-חוסמות (כולל ADVISORY/NO_CITATION). `--overrides <csv>` (id,proposed_canonical,reason) פותח שורות-חוסמות בהכרעת-יו"ר מפורשת (למשל פס"ד מאוחד — ראה `data/audit/fu2c-overrides.csv` לרשומת לויתן/קלמנוביץ). לוגיקת-החילוץ + פיצול flags אומתו offline על 24 רשומות. scope: external בלבד (internal = FU-2b). FK-safe. | חד-פעמי, **chair-gated** (apply רק אחרי אישור דפנה) |
|
||||||
|
|||||||
93
scripts/adapter_profiles.py
Normal file
93
scripts/adapter_profiles.py
Normal file
@@ -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 "<content>"`,
|
||||||
|
`hermes chat -q "<content>"`). 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)
|
||||||
544
scripts/migrate_agent_adapter.py
Normal file
544
scripts/migrate_agent_adapter.py
Normal file
@@ -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 <adapter> נדרש עבור --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())
|
||||||
Reference in New Issue
Block a user