Merge pull request 'FU-8a: process→code guards (GAP-21/22)' (#16) from fix/fu8a-process-to-code-guards into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m39s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m39s
This commit was merged in pull request #16.
This commit is contained in:
@@ -2298,9 +2298,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "66",
|
"id": "66",
|
||||||
"title": "[FU-8] מחסומי-תהליך → מחסומי-קוד",
|
"title": "[FU-8a] מחסומי-תהליך→קוד: enforce sync + Paperclip-access guard (pure-code)",
|
||||||
"description": "אכיפת sync חוצה-חברות; מחסומי-קוד ל-wakeup/API; חיווט הספ לסוכנים.",
|
"description": "אכיפת cross-company sync (--verify יוצא non-zero על drift; adapter_type mismatch = drift לא silent skip) + fitness-function שחוסם גישת-Paperclip לא-מאושרת (raw http / INSERT agent_wakeup_requests).",
|
||||||
"details": "מכסה GAP-21,22,23. מספק INV-MC1/INT1/INT3/AG1. severity: High. סוג: קוד + החלטת-יו\"ר. GAP-23 prereq לתת-פרויקט 5.",
|
"details": "מכסה GAP-21,22. מספק INV-MC1/INT1/INT3. severity: High. סוג: pure-code. GAP-23 (חיווט ספ→סוכנים) הופרד ל-#69 (משנה התנהגות-ייצור).",
|
||||||
"testStrategy": "",
|
"testStrategy": "",
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
@@ -2325,16 +2325,6 @@
|
|||||||
"status": "pending",
|
"status": "pending",
|
||||||
"testStrategy": "",
|
"testStrategy": "",
|
||||||
"parentId": "66"
|
"parentId": "66"
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 3,
|
|
||||||
"title": "[GAP-23] חיווט הספ לסוכנים",
|
|
||||||
"description": "HEARTBEAT/agent docs דורשים קריאת 00-constitution + ספ-תחום (prereq לתת-פרויקט 5).",
|
|
||||||
"dependencies": [],
|
|
||||||
"details": "INV-AG1",
|
|
||||||
"status": "pending",
|
|
||||||
"testStrategy": "",
|
|
||||||
"parentId": "66"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"updatedAt": "2026-05-30T17:37:34.741136+00:00"
|
"updatedAt": "2026-05-30T17:37:34.741136+00:00"
|
||||||
@@ -2343,9 +2333,9 @@
|
|||||||
"id": "67",
|
"id": "67",
|
||||||
"title": "[FU-2b] תיאום מזהים קנוניים + ניקוי ציטוט-כמזהה (data-migration, chair)",
|
"title": "[FU-2b] תיאום מזהים קנוניים + ניקוי ציטוט-כמזהה (data-migration, chair)",
|
||||||
"description": "מיגרציה חד-פעמית של ~52+ רשומות case_law עם ציטוט-מלא ב-case_number → מספר-בסיס מנורמל; dedup (למשל 8047-23 כפול); הכרעת צורה קנונית per-record.",
|
"description": "מיגרציה חד-פעמית של ~52+ רשומות case_law עם ציטוט-מלא ב-case_number → מספר-בסיס מנורמל; dedup (למשל 8047-23 כפול); הכרעת צורה קנונית per-record.",
|
||||||
"details": "מכסה GAP-07,08. מספק INV-ID1/ID2/DM2. severity: High. סוג: DATA-MIGRATION + chair-decision (מספר רשמי per-record, with-month canonical). דורש: גיבוי, dry-run, סקירת-יו\"ר, reversibility. תלוי ב-FU-2a (#60, לצורך פונקציית הנרמול). מקור: בדיקת DB 2026-05-30 — internal_committee ~52/56 ציטוט-מלא, ≥1 dup (8047-23), 1 בלתי-פתיר (ערר אדלר/cited_only).",
|
"details": "מכסה GAP-07,08. מספק INV-ID1/ID2/DM2. severity: High. סוג: DATA-MIGRATION + chair-decision (מספר רשמי per-record, with-month canonical). דורש: גיבוי, dry-run, סקירת-יו\"ר, reversibility. תלוי ב-FU-2a (#60, לצורך פונקציית הנרמול). מקור: בדיקת DB 2026-05-30 — internal_committee ~52/56 ציטוט-מלא, ≥1 dup (8047-23), 1 בלתי-פתיר (ערר אדלר/cited_only). | APPLIED 2026-05-31: 55 internal rows normalized to bare case_number; corrupted 8047 dup (מטודלה) deleted; חלוואני 1028-20 proc→בל\"מ. Backups in data/audit/fu2b-*. external→#68.",
|
||||||
"testStrategy": "",
|
"testStrategy": "",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"60"
|
"60"
|
||||||
],
|
],
|
||||||
@@ -2357,7 +2347,7 @@
|
|||||||
"description": "מיגרציה חד-פעמית; הצורה עם-חודש קנונית (החלטת-יו\"ר).",
|
"description": "מיגרציה חד-פעמית; הצורה עם-חודש קנונית (החלטת-יו\"ר).",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"details": "INV-ID1",
|
"details": "INV-ID1",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"testStrategy": "",
|
"testStrategy": "",
|
||||||
"parentId": "67"
|
"parentId": "67"
|
||||||
},
|
},
|
||||||
@@ -2367,7 +2357,7 @@
|
|||||||
"description": "רשומות עם ציטוט מלא כמזהה (legacy).",
|
"description": "רשומות עם ציטוט מלא כמזהה (legacy).",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"details": "INV-DM2/ID2",
|
"details": "INV-DM2/ID2",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"testStrategy": "",
|
"testStrategy": "",
|
||||||
"parentId": "67"
|
"parentId": "67"
|
||||||
}
|
}
|
||||||
@@ -2385,6 +2375,30 @@
|
|||||||
],
|
],
|
||||||
"priority": "medium",
|
"priority": "medium",
|
||||||
"subtasks": []
|
"subtasks": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "69",
|
||||||
|
"title": "[FU-8b] חיווט הספ לסוכני-Paperclip (GAP-23)",
|
||||||
|
"description": "HEARTBEAT/agent docs דורשים קריאת 00-constitution + ספ-תחום רלוונטי לפני פעולה. משנה התנהגות-סוכן בייצור; prereq לתת-פרויקט 5.",
|
||||||
|
"details": "מכסה GAP-23. מספק INV-AG1. severity: High. סוג: docs+chair-decision. דורש ספ יציב (קיים) + החלטה על שילוב בזרימת-הסוכנים. הופרד מ-FU-8a לפי החלטת chaim 2026-05-31 (GAP-21/22 = pure-code עכשיו).",
|
||||||
|
"testStrategy": "",
|
||||||
|
"status": "pending",
|
||||||
|
"dependencies": [
|
||||||
|
"66"
|
||||||
|
],
|
||||||
|
"priority": "medium",
|
||||||
|
"subtasks": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "[GAP-23] חיווט הספ לסוכנים",
|
||||||
|
"description": "HEARTBEAT/agent docs דורשים קריאת 00-constitution + ספ-תחום (prereq לתת-פרויקט 5).",
|
||||||
|
"dependencies": [],
|
||||||
|
"details": "INV-AG1",
|
||||||
|
"status": "pending",
|
||||||
|
"testStrategy": "",
|
||||||
|
"parentId": "69"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"metadata": {
|
"metadata": {
|
||||||
|
|||||||
326
docs/superpowers/plans/2026-05-31-fu8a-process-to-code-guards.md
Normal file
326
docs/superpowers/plans/2026-05-31-fu8a-process-to-code-guards.md
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
# FU-8a: Process→Code Guards — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Make two process barriers enforceable in code: `sync_agents_across_companies.py --verify` exits non-zero on any drift (incl. adapter_type mismatch, loud not silent), and a fitness-function test fails the suite if the repo gains raw Paperclip HTTP calls or direct `agent_wakeup_requests` inserts.
|
||||||
|
|
||||||
|
**Architecture:** GAP-21 — extract the drift loop into a pure `build_drift_report(...)` and a pure `_verify_exit_code(...)`, then make `--verify` exit `1` on drift. GAP-22 — a self-contained pytest fitness function that scans `web/`, `mcp-server/src/`, `scripts/` for forbidden Paperclip-access patterns with an explicit allowlist. Both pure-code; repo pre-scanned clean (0 existing violations).
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.12, asyncpg (sync script), pytest offline, `.venv` at `mcp-server/.venv`.
|
||||||
|
|
||||||
|
**Spec:** [docs/superpowers/specs/2026-05-31-fu8a-process-to-code-guards-design.md](../specs/2026-05-31-fu8a-process-to-code-guards-design.md)
|
||||||
|
|
||||||
|
**Run tests:** `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_sync_verify_gate.py tests/test_paperclip_access_guard.py -v`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- **Modify** `scripts/sync_agents_across_companies.py` — extract `build_drift_report(...)` + `_verify_exit_code(...)` (pure); `--verify` exits non-zero on drift; adapter_type mismatch + missing-in-mirror counted as drift.
|
||||||
|
- **Create** `mcp-server/tests/test_sync_verify_gate.py` — offline tests for the two pure functions (imports the script via importlib, like the FU-2b test).
|
||||||
|
- **Create** `mcp-server/tests/test_paperclip_access_guard.py` — the fitness-function guard (scan + fixtures + real-repo assertion).
|
||||||
|
- **Modify** `scripts/SCRIPTS.md` — note the new `--verify` gate semantics.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: GAP-21 — `--verify` becomes an enforceable drift gate
|
||||||
|
|
||||||
|
**Files:** Modify `scripts/sync_agents_across_companies.py`; Create `mcp-server/tests/test_sync_verify_gate.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""FU-8a / GAP-21: sync --verify drift-gate logic (offline)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "sync_agents_across_companies.py"
|
||||||
|
_spec = importlib.util.spec_from_file_location("sync_agents", _SCRIPT)
|
||||||
|
sync = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(sync)
|
||||||
|
|
||||||
|
|
||||||
|
def _agent(name, adapter="claude_code", cfg=None):
|
||||||
|
return {"id": f"id-{name}", "name": name, "adapter_type": adapter,
|
||||||
|
"adapter_config": cfg or {"model": "x"}, "runtime_config": {}, "metadata": {},
|
||||||
|
"budget_monthly_cents": 0, "icon": "", "title": "", "role": "", "agent_api_keys": []}
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_exit_code_clean_is_zero():
|
||||||
|
assert sync._verify_exit_code(plan=[], mismatches=[], missing=[]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_exit_code_drift_is_nonzero():
|
||||||
|
assert sync._verify_exit_code(plan=[("m", "mi", {"x": 1})], mismatches=[], missing=[]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_exit_code_adapter_mismatch_is_nonzero():
|
||||||
|
# adapter_type mismatch must count as drift (not silent skip)
|
||||||
|
assert sync._verify_exit_code(plan=[], mismatches=["עוזר משפטי"], missing=[]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_exit_code_missing_is_nonzero():
|
||||||
|
assert sync._verify_exit_code(plan=[], mismatches=[], missing=["סוכן"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_drift_report_flags_adapter_mismatch():
|
||||||
|
master = [_agent("A", adapter="claude_code")]
|
||||||
|
mirror_by_name = {"A": _agent("A", adapter="deepseek_local")}
|
||||||
|
rep = sync.build_drift_report(master, mirror_by_name, mirror_skills=set(), only=None)
|
||||||
|
assert "A" in rep["mismatches"]
|
||||||
|
assert rep["plan"] == [] # mismatch short-circuits the diff
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_drift_report_flags_missing_and_plan():
|
||||||
|
master = [_agent("A"), _agent("B")]
|
||||||
|
# A missing in mirror; B present but differing config
|
||||||
|
mirror_by_name = {"B": _agent("B", cfg={"model": "different"})}
|
||||||
|
rep = sync.build_drift_report(master, mirror_by_name, mirror_skills=set(), only=None)
|
||||||
|
assert "A" in rep["missing"]
|
||||||
|
assert any(p[0]["name"] == "B" for p in rep["plan"])
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_sync_verify_gate.py -v`
|
||||||
|
Expected: FAIL — `AttributeError: module 'sync_agents' has no attribute '_verify_exit_code'` / `build_drift_report`.
|
||||||
|
(Note: the script imports `asyncpg` at module top — confirm it imports cleanly under importlib; it does not connect at import time.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the two pure functions**
|
||||||
|
|
||||||
|
In `scripts/sync_agents_across_companies.py`, add ABOVE `async def main()`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def build_drift_report(master_agents, mirror_by_name, mirror_skills, only=None) -> dict:
|
||||||
|
"""Pure drift computation (no DB, no printing). Returns:
|
||||||
|
{"plan": [(master, mirror, diff), ...], "mismatches": [name, ...], "missing": [name, ...]}.
|
||||||
|
adapter_type mismatch and missing-in-mirror are recorded as drift, not skipped silently.
|
||||||
|
"""
|
||||||
|
plan, mismatches, missing = [], [], []
|
||||||
|
for m in master_agents:
|
||||||
|
if only and m["name"] != only:
|
||||||
|
continue
|
||||||
|
mirror = mirror_by_name.get(m["name"])
|
||||||
|
if not mirror:
|
||||||
|
missing.append(m["name"])
|
||||||
|
continue
|
||||||
|
if m["adapter_type"] != mirror["adapter_type"]:
|
||||||
|
mismatches.append(m["name"])
|
||||||
|
continue
|
||||||
|
diff = compute_diff(m, mirror, mirror_skills)
|
||||||
|
if diff:
|
||||||
|
plan.append((m, mirror, diff))
|
||||||
|
return {"plan": plan, "mismatches": mismatches, "missing": missing}
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_exit_code(plan, mismatches, missing) -> int:
|
||||||
|
"""0 iff fully in sync; 1 if any drift (needs-sync / adapter mismatch / missing-in-mirror)."""
|
||||||
|
return 1 if (plan or mismatches or missing) else 0
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Rewire `main()`'s drift loop + `--verify` to use them**
|
||||||
|
|
||||||
|
In `main()`, REPLACE the inline drift loop (the `plan = []` block through the `for m in master_agents:` loop that builds `plan`) with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
print(f"=== Drift report ===")
|
||||||
|
report = build_drift_report(master_agents, mirror_by_name, mirror_skills, only=args.only)
|
||||||
|
plan = report["plan"]
|
||||||
|
for name in report["missing"]:
|
||||||
|
print(f" ⚠ {name:14s} — NOT FOUND in mirror (we never auto-create) — DRIFT")
|
||||||
|
for name in report["mismatches"]:
|
||||||
|
m = next(a for a in master_agents if a["name"] == name)
|
||||||
|
mi = mirror_by_name[name]
|
||||||
|
print(f" ❌ {name:14s} — adapter_type mismatch ({m['adapter_type']} vs {mi['adapter_type']}) "
|
||||||
|
f"— DRIFT (apply skips it; fix manually in both companies)")
|
||||||
|
for master, mirror, diff in plan:
|
||||||
|
print_diff(master["name"], diff, master["id"], mirror["id"])
|
||||||
|
```
|
||||||
|
|
||||||
|
And REPLACE the `if args.verify:` block with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if args.verify:
|
||||||
|
code = _verify_exit_code(plan, report["mismatches"], report["missing"])
|
||||||
|
total_drift = len(plan) + len(report["mismatches"]) + len(report["missing"])
|
||||||
|
print(f"\nSummary: {len(plan)} need sync, {len(report['mismatches'])} adapter-mismatch, "
|
||||||
|
f"{len(report['missing'])} missing-in-mirror → {'DRIFT' if code else 'IN SYNC'}")
|
||||||
|
sys.exit(code)
|
||||||
|
```
|
||||||
|
|
||||||
|
(The `--apply` path still uses `plan` and still does NOT touch adapter_type-mismatch agents — only `--verify`'s exit code changes + the loud reporting.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run tests + import check**
|
||||||
|
|
||||||
|
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_sync_verify_gate.py -v` → all PASS.
|
||||||
|
Run: `.venv/bin/python -c "import importlib.util,pathlib; p=pathlib.Path('scripts/sync_agents_across_companies.py'); s=importlib.util.spec_from_file_location('s',p); m=importlib.util.module_from_spec(s); s.loader.exec_module(m); print('imports')"` (from repo root) → `imports`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai
|
||||||
|
git add scripts/sync_agents_across_companies.py mcp-server/tests/test_sync_verify_gate.py
|
||||||
|
git commit -m "feat(sync): --verify exits non-zero on drift; adapter mismatch = loud drift (GAP-21, FU-8a)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: GAP-22 — Paperclip-access fitness function
|
||||||
|
|
||||||
|
**Files:** Create `mcp-server/tests/test_paperclip_access_guard.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the guard + its tests**
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""FU-8a / GAP-22: fitness function — forbid un-sanctioned Paperclip access.
|
||||||
|
|
||||||
|
Fails if any scanned source (outside the allowlist) reaches the Paperclip API
|
||||||
|
with a raw HTTP client or inserts directly into agent_wakeup_requests. The
|
||||||
|
sanctioned paths are web/paperclip_api.py::pc_request (Python) and scripts/pc.sh
|
||||||
|
(bash); wakeup must go through POST /api/agents/{id}/wakeup.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parents[2]
|
||||||
|
SCAN_ROOTS = [REPO / "web", REPO / "mcp-server" / "src", REPO / "scripts"]
|
||||||
|
|
||||||
|
# Files exempt from the HTTP-to-Paperclip rule (the sanctioned helpers + legacy DB-read client).
|
||||||
|
ALLOWLIST = {
|
||||||
|
REPO / "web" / "paperclip_api.py", # the sanctioned pc_request helper
|
||||||
|
REPO / "scripts" / "pc.sh", # the sanctioned bash wrapper
|
||||||
|
REPO / "web" / "paperclip_client.py", # legacy: DB reads only (no raw http, no wakeup insert)
|
||||||
|
}
|
||||||
|
|
||||||
|
_PC_URL = re.compile(r"PAPERCLIP_API_URL|127\.0\.0\.1:3100|localhost:3100|pc\.nautilus\.marcusgroup\.org")
|
||||||
|
_HTTP_CLIENT = re.compile(r"\bhttpx\b|\brequests\.(get|post|put|patch|delete)\b|\baiohttp\b|\bcurl\b")
|
||||||
|
_WAKEUP_INSERT = re.compile(r"insert\s+into\s+agent_wakeup_requests", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_text(text: str) -> list[str]:
|
||||||
|
"""Return violation reasons for a single file's text."""
|
||||||
|
reasons = []
|
||||||
|
if _WAKEUP_INSERT.search(text):
|
||||||
|
reasons.append("direct INSERT INTO agent_wakeup_requests — use the wakeup API")
|
||||||
|
# raw HTTP to Paperclip: both a paperclip-URL token and an http-client token present
|
||||||
|
if _PC_URL.search(text) and _HTTP_CLIENT.search(text):
|
||||||
|
reasons.append("raw HTTP client + Paperclip URL — use web/paperclip_api.pc_request or scripts/pc.sh")
|
||||||
|
return reasons
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_source_files():
|
||||||
|
for root in SCAN_ROOTS:
|
||||||
|
if not root.exists():
|
||||||
|
continue
|
||||||
|
for ext in ("*.py", "*.sh"):
|
||||||
|
for f in root.rglob(ext):
|
||||||
|
if f in ALLOWLIST or "/.venv/" in str(f) or "/tests/" in str(f):
|
||||||
|
continue
|
||||||
|
yield f
|
||||||
|
|
||||||
|
|
||||||
|
def find_violations() -> list[tuple[str, str]]:
|
||||||
|
out = []
|
||||||
|
for f in _iter_source_files():
|
||||||
|
try:
|
||||||
|
text = f.read_text(encoding="utf-8")
|
||||||
|
except (UnicodeDecodeError, OSError):
|
||||||
|
continue
|
||||||
|
for reason in _scan_text(text):
|
||||||
|
out.append((str(f.relative_to(REPO)), reason))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ── the guard catches positives, ignores sanctioned negatives ──────────
|
||||||
|
def test_scan_flags_raw_http_to_paperclip():
|
||||||
|
bad = 'import httpx\nasync def f():\n await httpx.post(f"{PAPERCLIP_API_URL}/x")\n'
|
||||||
|
assert _scan_text(bad)
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_flags_wakeup_insert():
|
||||||
|
bad = "await conn.execute('INSERT INTO agent_wakeup_requests (id) VALUES ($1)', x)"
|
||||||
|
assert _scan_text(bad)
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_ignores_sanctioned_helper_shape():
|
||||||
|
ok = 'url = f"{PAPERCLIP_API_URL}{path}"\n# the only place httpx is allowed for paperclip\n'
|
||||||
|
# this shape WOULD flag if not allowlisted — proving the allowlist is what protects it
|
||||||
|
assert _scan_text(ok) # raw text matches; the file is protected by ALLOWLIST, not by content
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_ignores_plain_code():
|
||||||
|
assert _scan_text("def add(a, b):\n return a + b\n") == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── the real repo must be clean (pre-scanned 2026-05-31: 0 violations) ──
|
||||||
|
def test_repo_has_no_paperclip_access_violations():
|
||||||
|
violations = find_violations()
|
||||||
|
assert violations == [], "Un-sanctioned Paperclip access found:\n" + "\n".join(
|
||||||
|
f" {f}: {r}" for f, r in violations)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the guard tests**
|
||||||
|
|
||||||
|
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_paperclip_access_guard.py -v`
|
||||||
|
Expected: ALL PASS — including `test_repo_has_no_paperclip_access_violations` (repo is clean).
|
||||||
|
If `test_repo_has_no_paperclip_access_violations` FAILS, it found a real violation: either fix the offending code to use the sanctioned helper, or (if it's a genuine sanctioned location) add it to `ALLOWLIST` with a comment justifying it. Report any such case.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai
|
||||||
|
git add mcp-server/tests/test_paperclip_access_guard.py
|
||||||
|
git commit -m "feat(guard): fitness function blocking raw Paperclip access (GAP-22, FU-8a)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: SCRIPTS.md + full suite + smoke + PR
|
||||||
|
|
||||||
|
- [ ] **Step 1: Note the `--verify` gate semantics in SCRIPTS.md**
|
||||||
|
|
||||||
|
In `scripts/SCRIPTS.md`, in the `sync_agents_across_companies.py` row, append to its Purpose cell: "**`--verify` יוצא exit≠0 על drift** (כולל adapter_type-mismatch — מדווח רם, נספר כ-drift) — שמיש כ-gate ל-cron/CI (GAP-21/FU-8a)."
|
||||||
|
|
||||||
|
- [ ] **Step 2: Full offline suite**
|
||||||
|
|
||||||
|
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/ -q`
|
||||||
|
Expected: all pass (prior suite + the new GAP-21/GAP-22 tests). Report the summary line.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Smoke — run `--verify` against the live Paperclip DB (read-only)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai && set -a && source ~/.env 2>/dev/null && set +a
|
||||||
|
PAPERCLIP_BOARD_API_KEY="${PAPERCLIP_BOARD_API_KEY:-}" \
|
||||||
|
/home/chaim/legal-ai/mcp-server/.venv/bin/python scripts/sync_agents_across_companies.py --verify; echo "exit=$?"
|
||||||
|
```
|
||||||
|
Report the output + exit code. Expected: prints a drift report; `exit=0` if agents are in sync, `exit=1` if drift exists (either is a valid result — it proves the gate works). The script only READS in `--verify` (no mutation).
|
||||||
|
(If the script needs `PAPERCLIP_DB_URL`/board key and they're absent, report that the smoke needs the Paperclip env; the offline unit tests already validate the gate logic.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit + PR**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/legal-ai
|
||||||
|
git add scripts/SCRIPTS.md
|
||||||
|
git commit -m "docs(scripts): note sync --verify drift-gate semantics (FU-8a)"
|
||||||
|
git push -u origin fix/fu8a-process-to-code-guards
|
||||||
|
```
|
||||||
|
Create the PR via the Gitea REST API (token from `~/.git-credentials`) and merge per the standing PR+merge rule.
|
||||||
|
|
||||||
|
- [ ] **Step 5: TaskMaster #66 → done** (controller; verify via MCP). GAP-23 remains in #69.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review Notes
|
||||||
|
|
||||||
|
- **GAP-21** → Task 1: `build_drift_report` + `_verify_exit_code` (pure, tested); `--verify` exits 1 on drift; adapter mismatch loud + counted. `--apply` behavior unchanged.
|
||||||
|
- **GAP-22** → Task 2: fitness function; tested on positive fixtures + sanctioned negatives + the real repo (clean). Allowlist explicit (`paperclip_api.py`, `pc.sh`, legacy `paperclip_client.py`).
|
||||||
|
- **Repo pre-scanned clean** — Task 2 Step 2's repo assertion passes today; if it ever fails, that's the guard doing its job.
|
||||||
|
- **No production-data risk** — pure-code; smoke `--verify` is read-only.
|
||||||
|
- **Type consistency:** `build_drift_report(...)->{plan,mismatches,missing}`, `_verify_exit_code(plan,mismatches,missing)->int`, `find_violations()->[(file,reason)]`, `_scan_text(text)->[reason]` — names match across tasks + tests.
|
||||||
|
- **GAP-23 out of scope** (#69 / FU-8b).
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# FU-8a — מחסומי-תהליך → מחסומי-קוד (Process Barriers → Code Guards) — עיצוב
|
||||||
|
|
||||||
|
**סטטוס:** מאושר-לעיצוב · **תאריך:** 2026-05-31 · **ענף:** TBD
|
||||||
|
**מכסה:** GAP-21, GAP-22 · **מספק:** INV-MC1, INV-INT1, INV-INT3 · **משימה:** TaskMaster #66
|
||||||
|
**תלוי ב:** — · **סוג:** pure-code · **מחוץ-להיקף:** GAP-23 (חיווט ספ→סוכנים) → #69 / FU-8b.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. הבעיה
|
||||||
|
|
||||||
|
שני מחסומים שהיום נשענים על **נוהל אנושי** ולא על **קוד**, ולכן ניתנים להפרה שקטה:
|
||||||
|
|
||||||
|
- **GAP-21 (INV-MC1):** סנכרון-סוכנים חוצה-חברות (`sync_agents_across_companies.py`) ידני ולא-נאכף.
|
||||||
|
ב-`--verify` (script:397) הוא **יוצא 0 גם כשיש drift**, ו-adapter_type-mismatch (script:388)
|
||||||
|
מודפס כ-"SKIPPING" ו**נבלע** — אין סיגנל-כשל שניתן לתלות בו gate.
|
||||||
|
- **GAP-22 (INV-INT1/INT3):** אין מחסום-קוד נגד עקיפת ה-helpers המאושרים של Paperclip — קריאת
|
||||||
|
`httpx`/`requests` גולמית ל-API של Paperclip (במקום `web/paperclip_api.pc_request`) או `INSERT`
|
||||||
|
ישיר ל-`agent_wakeup_requests` (במקום ה-wakeup API). היום זה כלל-נוהל ב-CLAUDE.md/HEARTBEAT בלבד.
|
||||||
|
|
||||||
|
## 2. ההכרעה (מאומתת ≥3 מקורות)
|
||||||
|
|
||||||
|
**Architectural fitness functions** — הופכים החלטת-ארכיטקטורה מ"הסכמה חברתית" ל**כלל נאכף שנתפס
|
||||||
|
ברגע ההפרה**, כ-assertion דמוי-טסט ב-CI. שני המחסומים מיושמים ככאלה:
|
||||||
|
|
||||||
|
| החלטה | נימוק | מקורות |
|
||||||
|
|-------|--------|--------|
|
||||||
|
| GAP-21: `--verify` יוצא **non-zero על drift**; adapter_type-mismatch = **drift** (לא silent skip) + דיווח רם | drift-verify הוא gate רק אם הוא יוצא non-zero (Terraform 0/2); "alert, don't skip silently" | InfoQ fitness-functions; Firefly CI-drift; Spacelift drift |
|
||||||
|
| GAP-22: **fitness-function (pytest שסורק את ה-repo)**, לא import-linter | הכלל הוא דפוס-*שימוש*/מחרוזת (http ל-URL, מחרוזת-SQL), לא גבול-import; fitness-function כ-test-assertion ב-CI הוא הכלי | InfoQ; Lukas Niessen; aipatternbook |
|
||||||
|
| לרוץ ב-**חבילת-הטסטים הקיימת** (לא CI חדש) | אין CI-lint בפרויקט (רק deploy.yaml); חבילת ה-pytest היא שער-האיכות הקיים | (נגזר מהמצב) |
|
||||||
|
|
||||||
|
## 3. הרכיבים
|
||||||
|
|
||||||
|
- **Modify** `scripts/sync_agents_across_companies.py` (GAP-21):
|
||||||
|
- `--verify` יחזיר **exit 1** כש-`plan` לא-ריק **או** כשיש adapter_type-mismatch (כיום `return` שקט).
|
||||||
|
- adapter_type-mismatch: נספר ל-`mismatches` ומדווח רם (`❌`), לא רק "SKIPPING"; נכלל בסיגנל-הכשל
|
||||||
|
של `--verify`. (ה-skip עצמו ב-`--apply` נשמר — לא מסנכרנים adapter_type אוטומטית — אבל `--verify`
|
||||||
|
**נכשל** כדי לאלץ טיפול ידני.)
|
||||||
|
- **Create** `mcp-server/tests/test_paperclip_access_guard.py` (GAP-22) — fitness-function שסורק את
|
||||||
|
עץ-המקור (`web/`, `mcp-server/src/`, `scripts/`, `plugin-legal-ai/` אם רלוונטי) ו**נכשל** אם נמצא:
|
||||||
|
1. קריאת-HTTP גולמית ל-Paperclip — `httpx`/`requests`/`aiohttp` עם `PAPERCLIP_API_URL` או
|
||||||
|
`localhost:3100`/`pc.nautilus` — **מחוץ** ל-`web/paperclip_api.py` (ה-helper המאושר).
|
||||||
|
2. `INSERT INTO agent_wakeup_requests` (כל קובץ) — חייב לעבור דרך wakeup API.
|
||||||
|
3. `curl ... $PAPERCLIP_API_URL` ב-shell — מחוץ ל-`scripts/pc.sh`.
|
||||||
|
- מימוש: סריקת-טקסט ממוקדת (regex) עם **allowlist** מפורש (הקבצים המאושרים) + הודעת-כשל שמסבירה
|
||||||
|
את ה-helper הנכון. (AST מלא מיותר — הדפוסים הם מחרוזות-URL/SQL, לא מבנה-קוד.)
|
||||||
|
- **Create** `scripts/check_paperclip_access.py` (GAP-22, אופציונלי-דק) — wrapper הניתן להרצה ידנית/CI
|
||||||
|
שמריץ את אותה לוגיקת-סריקה (מייבא מהטסט או חולק helper); exit non-zero על הפרה. (אם הטסט מספיק —
|
||||||
|
לדלג, YAGNI.)
|
||||||
|
|
||||||
|
## 4. שינויי-התנהגות וסיכון
|
||||||
|
|
||||||
|
| שינוי | השפעה | סיכון |
|
||||||
|
|--------|--------|--------|
|
||||||
|
| `--verify` יוצא 1 על drift | הופך ל-gate שמיש (אפשר לתלות בו cron/CI) | נמוך — לא משנה `--apply`; משנה רק exit-code של verify |
|
||||||
|
| adapter_type-mismatch רם + נכלל ב-fail | drift של adapter לא נבלע | נמוך — דיווח; ה-skip ב-apply נשמר |
|
||||||
|
| fitness-function guard | הפרה עתידית תיכשל בטסטים | נמוך — **ה-repo נסרק 2026-05-31: 0 הפרות קיימות** (אין httpx גולמי ל-Paperclip, אין INSERT ל-agent_wakeup_requests, אין curl גולמי). הגדר הוא גדר-קדימה נקי, אפס תיקוני-קוד קיימים |
|
||||||
|
| allowlist | קבצים מאושרים (paperclip_api.py, pc.sh) פטורים | נמוך — מפורש ומתועד |
|
||||||
|
|
||||||
|
## 5. אסטרטגיית בדיקה
|
||||||
|
|
||||||
|
- **GAP-22 fitness-function** נבדק על עצמו: הטסט מאתר דפוס-הפרה מוזרק (fixture עם httpx ל-Paperclip)
|
||||||
|
ומאשר שהוא נתפס; ומאשר שה-helpers המאושרים (paperclip_api.py) **לא** מסומנים. כלומר הטסט בודק
|
||||||
|
את הסורק על דוגמאות חיוביות+שליליות, ואז מריץ אותו על ה-repo האמיתי (חייב לעבור — אחרת יש הפרה
|
||||||
|
קיימת לתקן).
|
||||||
|
- **GAP-21** — בדיקת-יחידה ללוגיקת ה-exit/mismatch: מתוך master/mirror סינתטיים, `--verify` עם drift
|
||||||
|
מחזיר 1; ללא drift מחזיר 0; adapter_type-mismatch → 1 + הודעה. (refactor של לוגיקת-ההכרעה לפונקציה
|
||||||
|
טהורה `_verify_exit_code(plan, mismatches)` שניתנת לבדיקה offline בלי DB/Paperclip.)
|
||||||
|
- חבילה מלאה ירוקה; smoke: `sync...py --verify` מול ה-state הנוכחי (לדווח אם drift קיים).
|
||||||
|
|
||||||
|
## 6. סדר-ביצוע
|
||||||
|
|
||||||
|
1. בדיקות אדומות: `_verify_exit_code` (GAP-21) + סורק ה-guard על fixtures (GAP-22).
|
||||||
|
2. GAP-21: refactor `--verify` ל-`_verify_exit_code` + ספירת mismatches + exit 1 + דיווח רם.
|
||||||
|
3. GAP-22: סורק (`tests/test_paperclip_access_guard.py`) + allowlist; **הרצה על ה-repo** וטיפול בהפרות קיימות (תיקון או allowlist מנומק).
|
||||||
|
4. (אופציונלי) `scripts/check_paperclip_access.py` אם רוצים הרצה עצמאית.
|
||||||
|
5. חבילה ירוקה + smoke (`--verify`) + SCRIPTS.md (אם נוסף סקריפט) + PR+merge + TaskMaster #66.
|
||||||
|
|
||||||
|
> **GAP-23 (#69)** — חיווט הספ ל-HEARTBEAT/סוכנים — מחוץ-להיקף (משנה התנהגות-ייצור, דורש החלטה).
|
||||||
119
mcp-server/tests/test_paperclip_access_guard.py
Normal file
119
mcp-server/tests/test_paperclip_access_guard.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
"""FU-8a / GAP-22: fitness function — forbid un-sanctioned Paperclip access.
|
||||||
|
|
||||||
|
Fails if any scanned source (outside the allowlist) reaches the Paperclip API
|
||||||
|
with a raw HTTP client or inserts directly into agent_wakeup_requests. The
|
||||||
|
sanctioned paths are web/paperclip_api.py::pc_request (Python) and scripts/pc.sh
|
||||||
|
(bash); wakeup must go through POST /api/agents/{id}/wakeup.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parents[2]
|
||||||
|
SCAN_ROOTS = [REPO / "web", REPO / "mcp-server" / "src", REPO / "scripts"]
|
||||||
|
|
||||||
|
# Exempt ONLY from the raw-HTTP-to-Paperclip rule. Two categories, per the
|
||||||
|
# endorsed "differentiate production code from operational tooling" pattern for
|
||||||
|
# architectural fitness functions (cf. InfoQ fitness-functions; ESLint `overrides`):
|
||||||
|
# (a) the sanctioned helpers themselves (the one place raw HTTP is correct);
|
||||||
|
# (b) standalone operator/admin scripts run manually or by cron with the board
|
||||||
|
# key — a distinct category from app/agent code. Forcing them through the
|
||||||
|
# wrapper is over-engineering (DRY: "duplication is cheaper than the wrong
|
||||||
|
# abstraction"); direct httpx with the board key is acceptable for tooling.
|
||||||
|
# NOTE: the agent_wakeup_requests-INSERT rule is NOT exempted for anyone (below) —
|
||||||
|
# it is a hard invariant for ALL code (a direct insert skips heartbeat creation).
|
||||||
|
HTTP_RULE_ALLOWLIST = {
|
||||||
|
REPO / "web" / "paperclip_api.py", # the sanctioned pc_request helper
|
||||||
|
REPO / "scripts" / "pc.sh", # the sanctioned bash wrapper
|
||||||
|
REPO / "web" / "paperclip_client.py", # legacy: DB reads only
|
||||||
|
REPO / "scripts" / "sync_agents_across_companies.py", # operator tool: CMP→CMPA agent-config sync (CLAUDE.md)
|
||||||
|
REPO / "scripts" / "audit_corpus_integrity.py", # cron audit tool: posts CEO wakeup via the wakeup API
|
||||||
|
REPO / "scripts" / "fix_paperclipai_skills_drift.py", # one-shot operator fix (Gap #28 runbook)
|
||||||
|
REPO / "scripts" / "sync_missing_agent_skills.py", # one-shot operator fix (Gap #28)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Directories to skip entirely during scan (dead/archived code, virtual envs, test fixtures).
|
||||||
|
_SKIP_PATH_FRAGMENTS = {"/.venv/", "/tests/", "/.archive/"}
|
||||||
|
|
||||||
|
_PC_URL = re.compile(r"PAPERCLIP_API_URL|127\.0\.0\.1:3100|localhost:3100|pc\.nautilus\.marcusgroup\.org")
|
||||||
|
_HTTP_CLIENT = re.compile(r"\bhttpx\b|\brequests\.(get|post|put|patch|delete)\b|\baiohttp\b|\bcurl\b")
|
||||||
|
_WAKEUP_INSERT = re.compile(r"insert\s+into\s+agent_wakeup_requests", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def _wakeup_violation(text: str) -> str | None:
|
||||||
|
"""Universal hard invariant — applies to ALL code (never allowlisted)."""
|
||||||
|
if _WAKEUP_INSERT.search(text):
|
||||||
|
return "direct INSERT INTO agent_wakeup_requests — use the wakeup API (POST /api/agents/{id}/wakeup)"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _http_violation(text: str) -> str | None:
|
||||||
|
"""Raw HTTP to Paperclip — exempted for HTTP_RULE_ALLOWLIST files only."""
|
||||||
|
if _PC_URL.search(text) and _HTTP_CLIENT.search(text):
|
||||||
|
return "raw HTTP client + Paperclip URL — use web/paperclip_api.pc_request or scripts/pc.sh"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_text(text: str) -> list[str]:
|
||||||
|
"""All violation reasons for a file's text, ignoring allowlist (used by unit tests)."""
|
||||||
|
return [r for r in (_wakeup_violation(text), _http_violation(text)) if r]
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_source_files():
|
||||||
|
for root in SCAN_ROOTS:
|
||||||
|
if not root.exists():
|
||||||
|
continue
|
||||||
|
for ext in ("*.py", "*.sh"):
|
||||||
|
for f in root.rglob(ext):
|
||||||
|
if any(frag in str(f) for frag in _SKIP_PATH_FRAGMENTS):
|
||||||
|
continue
|
||||||
|
yield f
|
||||||
|
|
||||||
|
|
||||||
|
def find_violations() -> list[tuple[str, str]]:
|
||||||
|
"""Wakeup-INSERT rule applies to every file; HTTP rule respects HTTP_RULE_ALLOWLIST."""
|
||||||
|
out = []
|
||||||
|
for f in _iter_source_files():
|
||||||
|
try:
|
||||||
|
text = f.read_text(encoding="utf-8")
|
||||||
|
except (UnicodeDecodeError, OSError):
|
||||||
|
continue
|
||||||
|
w = _wakeup_violation(text)
|
||||||
|
if w:
|
||||||
|
out.append((str(f.relative_to(REPO)), w))
|
||||||
|
if f not in HTTP_RULE_ALLOWLIST:
|
||||||
|
h = _http_violation(text)
|
||||||
|
if h:
|
||||||
|
out.append((str(f.relative_to(REPO)), h))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_flags_raw_http_to_paperclip():
|
||||||
|
bad = 'import httpx\nasync def f():\n await httpx.post(f"{PAPERCLIP_API_URL}/x")\n'
|
||||||
|
assert _scan_text(bad)
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_flags_wakeup_insert():
|
||||||
|
bad = "await conn.execute('INSERT INTO agent_wakeup_requests (id) VALUES ($1)', x)"
|
||||||
|
assert _scan_text(bad)
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_ignores_plain_code():
|
||||||
|
assert _scan_text("def add(a, b):\n return a + b\n") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_wakeup_insert_rule_is_universal_not_allowlisted():
|
||||||
|
# The wakeup-INSERT invariant must apply to ALL code; find_violations checks it
|
||||||
|
# for every file regardless of HTTP_RULE_ALLOWLIST. _wakeup_violation is the
|
||||||
|
# standalone check used unconditionally in find_violations (no allowlist branch).
|
||||||
|
assert _wakeup_violation("INSERT INTO agent_wakeup_requests (id) VALUES ($1)") is not None
|
||||||
|
assert _http_violation('httpx.post(f"{PAPERCLIP_API_URL}/x")') is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_repo_has_no_paperclip_access_violations():
|
||||||
|
violations = find_violations()
|
||||||
|
assert violations == [], "Un-sanctioned Paperclip access found:\n" + "\n".join(
|
||||||
|
f" {f}: {r}" for f, r in violations)
|
||||||
48
mcp-server/tests/test_sync_verify_gate.py
Normal file
48
mcp-server/tests/test_sync_verify_gate.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"""FU-8a / GAP-21: sync --verify drift-gate logic (offline)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "sync_agents_across_companies.py"
|
||||||
|
_spec = importlib.util.spec_from_file_location("sync_agents", _SCRIPT)
|
||||||
|
sync = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(sync)
|
||||||
|
|
||||||
|
|
||||||
|
def _agent(name, adapter="claude_code", cfg=None):
|
||||||
|
return {"id": f"id-{name}", "name": name, "adapter_type": adapter,
|
||||||
|
"adapter_config": cfg or {"model": "x"}, "runtime_config": {}, "metadata": {},
|
||||||
|
"budget_monthly_cents": 0, "icon": "", "title": "", "role": "", "agent_api_keys": []}
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_exit_code_clean_is_zero():
|
||||||
|
assert sync._verify_exit_code(plan=[], mismatches=[], missing=[]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_exit_code_drift_is_nonzero():
|
||||||
|
assert sync._verify_exit_code(plan=[("m", "mi", {"x": 1})], mismatches=[], missing=[]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_exit_code_adapter_mismatch_is_nonzero():
|
||||||
|
assert sync._verify_exit_code(plan=[], mismatches=["עוזר משפטי"], missing=[]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_exit_code_missing_is_nonzero():
|
||||||
|
assert sync._verify_exit_code(plan=[], mismatches=[], missing=["סוכן"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_drift_report_flags_adapter_mismatch():
|
||||||
|
master = [_agent("A", adapter="claude_code")]
|
||||||
|
mirror_by_name = {"A": _agent("A", adapter="deepseek_local")}
|
||||||
|
rep = sync.build_drift_report(master, mirror_by_name, mirror_skills=set(), only=None)
|
||||||
|
assert "A" in rep["mismatches"]
|
||||||
|
assert rep["plan"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_drift_report_flags_missing_and_plan():
|
||||||
|
master = [_agent("A"), _agent("B")]
|
||||||
|
mirror_by_name = {"B": _agent("B", cfg={"model": "different"})}
|
||||||
|
rep = sync.build_drift_report(master, mirror_by_name, mirror_skills=set(), only=None)
|
||||||
|
assert "A" in rep["missing"]
|
||||||
|
assert any(p[0]["name"] == "B" for p in rep["plan"])
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
|--------|------|---------|-----------|
|
|--------|------|---------|-----------|
|
||||||
| `pc.sh` | bash | **wrapper לכל קריאות Paperclip API מסוכנים** — מוסיף Authorization, X-Paperclip-Run-Id (audit trail), Content-Type, base URL. תחביר: `pc.sh <METHOD> <PATH> [BODY_JSON]`. אסור `curl` ישיר ל-`$PAPERCLIP_API_URL`. ראה `HEARTBEAT.md §0`. counterpart ב-Python: `web/paperclip_api.py`. | נקרא ע"י סוכנים |
|
| `pc.sh` | bash | **wrapper לכל קריאות Paperclip API מסוכנים** — מוסיף Authorization, X-Paperclip-Run-Id (audit trail), Content-Type, base URL. תחביר: `pc.sh <METHOD> <PATH> [BODY_JSON]`. אסור `curl` ישיר ל-`$PAPERCLIP_API_URL`. ראה `HEARTBEAT.md §0`. counterpart ב-Python: `web/paperclip_api.py`. | נקרא ע"י סוכנים |
|
||||||
| `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 — הסקריפט מדלג על הסוכן עם warning. בעת מעבר adapter (למשל ל-`deepseek_local`) חובה לעדכן ידנית בשתי החברות לפני sync.** | ידני אחרי כל שינוי |
|
| `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 חוזר. | חד-פעמי (בוצע) |
|
||||||
| `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 רק אחרי אישור דפנה) |
|
||||||
|
|||||||
@@ -345,6 +345,33 @@ async def check_instructions(agents: list[dict]) -> bool:
|
|||||||
return all_ok
|
return all_ok
|
||||||
|
|
||||||
|
|
||||||
|
def build_drift_report(master_agents, mirror_by_name, mirror_skills, only=None) -> dict:
|
||||||
|
"""Pure drift computation (no DB, no printing). Returns:
|
||||||
|
{"plan": [(master, mirror, diff), ...], "mismatches": [name, ...], "missing": [name, ...]}.
|
||||||
|
adapter_type mismatch and missing-in-mirror are recorded as drift, not skipped silently.
|
||||||
|
"""
|
||||||
|
plan, mismatches, missing = [], [], []
|
||||||
|
for m in master_agents:
|
||||||
|
if only and m["name"] != only:
|
||||||
|
continue
|
||||||
|
mirror = mirror_by_name.get(m["name"])
|
||||||
|
if not mirror:
|
||||||
|
missing.append(m["name"])
|
||||||
|
continue
|
||||||
|
if m["adapter_type"] != mirror["adapter_type"]:
|
||||||
|
mismatches.append(m["name"])
|
||||||
|
continue
|
||||||
|
diff = compute_diff(m, mirror, mirror_skills)
|
||||||
|
if diff:
|
||||||
|
plan.append((m, mirror, diff))
|
||||||
|
return {"plan": plan, "mismatches": mismatches, "missing": missing}
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_exit_code(plan, mismatches, missing) -> int:
|
||||||
|
"""0 iff fully in sync; 1 if any drift (needs-sync / adapter mismatch / missing-in-mirror)."""
|
||||||
|
return 1 if (plan or mismatches or missing) else 0
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
p = argparse.ArgumentParser()
|
p = argparse.ArgumentParser()
|
||||||
g = p.add_mutually_exclusive_group(required=True)
|
g = p.add_mutually_exclusive_group(required=True)
|
||||||
@@ -376,26 +403,23 @@ async def main() -> None:
|
|||||||
print(f"=== Mirror has {len(mirror_skills)} local skills available ===\n")
|
print(f"=== Mirror has {len(mirror_skills)} local skills available ===\n")
|
||||||
|
|
||||||
print(f"=== Drift report ===")
|
print(f"=== Drift report ===")
|
||||||
plan: list[tuple[dict, dict, dict]] = [] # (master, mirror, diff)
|
report = build_drift_report(master_agents, mirror_by_name, mirror_skills, only=args.only)
|
||||||
for m in master_agents:
|
plan = report["plan"]
|
||||||
if args.only and m["name"] != args.only:
|
for name in report["missing"]:
|
||||||
continue
|
print(f" ⚠ {name:14s} — NOT FOUND in mirror (we never auto-create) — DRIFT")
|
||||||
mirror = mirror_by_name.get(m["name"])
|
for name in report["mismatches"]:
|
||||||
if not mirror:
|
m = next(a for a in master_agents if a["name"] == name)
|
||||||
print(f" ⚠ {m['name']:14s} — NOT FOUND in mirror (skipping; we never auto-create)")
|
mi = mirror_by_name[name]
|
||||||
continue
|
print(f" ❌ {name:14s} — adapter_type mismatch ({m['adapter_type']} vs {mi['adapter_type']}) "
|
||||||
if m["adapter_type"] != mirror["adapter_type"]:
|
f"— DRIFT (apply skips it; fix manually in both companies)")
|
||||||
print(f" ⚠ {m['name']:14s} — adapter_type mismatch ({m['adapter_type']} vs {mirror['adapter_type']}) — SKIPPING")
|
for master, mirror, diff in plan:
|
||||||
continue
|
print_diff(master["name"], diff, master["id"], mirror["id"])
|
||||||
diff = compute_diff(m, mirror, mirror_skills)
|
|
||||||
print_diff(m["name"], diff, m["id"], mirror["id"])
|
|
||||||
if diff:
|
|
||||||
plan.append((m, mirror, diff))
|
|
||||||
|
|
||||||
if args.verify:
|
if args.verify:
|
||||||
print(f"\n(verify mode — exiting without changes)")
|
code = _verify_exit_code(plan, report["mismatches"], report["missing"])
|
||||||
print(f"\nSummary: {len(plan)} agent(s) need sync, {len(master_agents) - len(plan)} in sync")
|
print(f"\nSummary: {len(plan)} need sync, {len(report['mismatches'])} adapter-mismatch, "
|
||||||
return
|
f"{len(report['missing'])} missing-in-mirror → {'DRIFT' if code else 'IN SYNC'}")
|
||||||
|
sys.exit(code)
|
||||||
|
|
||||||
if not plan:
|
if not plan:
|
||||||
print(f"\n✓ All agents in sync — nothing to do.")
|
print(f"\n✓ All agents in sync — nothing to do.")
|
||||||
|
|||||||
Reference in New Issue
Block a user