Compare commits

..

36 Commits

Author SHA1 Message Date
180f70bcc0 docs(workflow): ניהול המשימות עובר מ-TaskMaster ל-Gitea Issues
All checks were successful
INV-AG3 Agent Tool Grants / agent-tool-grants (pull_request) Successful in 4s
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
מקור-האמת לעבודה הוא מעכשיו
https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues —
לא TaskMaster, לא CLAUDE.md, לא זיכרון. המוסכמות נלקחו מ-infra/project-template
כך ש-legal-ai מתיישר עם שאר פרויקטי המשרד.

מה בוצע
- 26 המשימות הפתוחות הועברו ל-issues #433–#458, כל אחת עם type/priority/
  area/status וגוף במבנה What & Why → הקשר → Acceptance Criteria.
- 30 תוויות נוצרו בריפו: type/priority/status/size זהות לתבנית; שכבת area
  הותאמה לתחומי legal-ai (agents/retrieval/extraction/writing/mcp/...).
- CLAUDE.md: הסעיף "ניהול משימות" נכתב מחדש — Session-Start Protocol,
  Issue-First, מערכת התיוג, ומלכודת המספור.
- operations-runbook.md: פירוט TaskMaster הוחלף בעבודה מול Gitea Issues API.
- docs/taskmaster-to-gitea-migration.md — טבלת מיפוי מלאה + מה נותר ידני.

מלכודת מספור שחשוב לדעת: ב-Gitea issues ו-PRs חולקים מרחב-מספרים, ולכן
ה-issues מתחילים ב-#433. **כל `(#N)` בהודעת-commit מלפני היום מתייחס למזהה
TaskMaster, לא ל-issue.** הטבלה במסמך המיגרציה היא הגשר.

.taskmaster/tasks/tasks.json נשאר לא-מתויק (כרגיל) ומוקפא מקומית
לקריאה-בלבד — 166 שהושלמו + 4 שבוטלו, לחפירה היסטורית בלבד.

לא הועבר אוטומטית: size לא הוקצה לאף issue, ו-21 מתוך 26 עדיין ללא
Acceptance Criteria (לא היה להן testStrategy) — מסומן במפורש בגוף כל issue.
2026-08-05 11:05:06 +00:00
1544669c7a Merge pull request 'fix(extraction): מספר-תיק שהומצא נכתב לשדה-הזהות — עיגון בטקסט לפני כתיבה (#232 מלכודת 3)' (#432) from worktree-docket-grounding into main
All checks were successful
INV-AG3 Agent Tool Grants / agent-tool-grants (push) Successful in 5s
Build & Deploy / build-and-deploy (push) Successful in 1m35s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 11s
2026-08-05 10:46:36 +00:00
63387c0d7d fix(extraction): מספר-תיק שהומצא נכתב לשדה-הזהות — עיגון בטקסט לפני כתיבה (#232 מלכודת 3)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 5s
INV-AG3 Agent Tool Grants / agent-tool-grants (pull_request) Successful in 5s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
ערר (מרכז) 1094-09-19 (פדילה) נשמר כ-`1094-09-14` בעוד כל חמשת מסמכי
התיק גורסים ...-19. `_is_clean_docket` בדק **צורה בלבד**, ושתי הצורות
תקינות — כך שספרה שגויה אחת עברה את השער וניתקה את השורה מכל הפניה
לתיק האמיתי. `case_number` הוא שדה-זהות; זה לא שדה-תצוגה שאפשר לתקן
בקריאה.

שורש נוסף שהתגלה תוך כדי: ב-1094-09-19 המספר **אינו מופיע ב-full_text
כלל** (הטקסט מתחיל ב"בפני:"), כלומר המודל הפיק ספרות שאין להן עיגון
במקור — בדיוק מה ש-INV-AH בא למנוע.

מה שונה
- `_docket_grounded()` — כל קבוצת-ספרות של ה-docket חייבת להופיע, בסדר,
  בטקסט ההחלטה או בערך שהיא מחליפה. סובלני למפריד (`-` מול `/`), לרווחים
  סביבו, ולתווי-כיווניות (RLM/LRM) שנדחסים בתוך המספר ב-PDF עברי.
  המקורות לעולם אינם פלט-המודל עצמו — אחרת הבדיקה מעגלית.
- סירוב לכתוב מלווה `logger.warning` מפורש (§6) במקום להסתמך על הצורה.
- הפרומפט תוקן: הדוגמאות היו דו-חלקיות בלבד ('1062/24'), מה שהטה נגד
  docket תלת-חלקי של ועדות ערר. נוספו דוגמאות תלת-חלקיות והוראה מפורשת
  לא להשלים/לנחש/לתקן ספרה, ולהחזיר ריק כשהמספר אינו בטקסט.

אימות מול הקורפוס החי (386 שורות): מתוך 50 השורות שהנרמול חל עליהן
בפועל — **0 נחסמות**. הגארד חוסם רק ספרות שאין להן עיגון באף מקור.

invariants: INV-AH (quote-or-retract על שדה-זהות) · G1 (נרמול במקור) ·
§6 (סירוב מדווח, לא נבלע)

טסטים: 7 חדשים (tests/test_docket_grounding.py), הראשון שבהם משחזר בדיוק
את הרגרסיה — שתי הצורות עוברות את בדיקת-הצורה, רק הנכונה מעוגנת.
521 עוברים.
2026-08-05 10:45:22 +00:00
70c28c87ef Merge pull request 'fix(retrieval): 104 החלטות ועדות-ערר היו בלתי-נראות בחיפוש — סינון ולא דירוג (#232)' (#431) from worktree-232-internal-search into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m37s
G12 Leak-Guard / leak-guard (push) Successful in 8s
Lint — undefined names / undefined-names (push) Successful in 13s
INV-AG3 Agent Tool Grants / agent-tool-grants (push) Successful in 5s
2026-08-05 10:33:22 +00:00
2f50a8cc79 fix(retrieval): 104 החלטות ועדות-ערר היו בלתי-נראות בחיפוש — סינון ולא דירוג (#232)
All checks were successful
Lint — undefined names / undefined-names (pull_request) Successful in 11s
INV-AG3 Agent Tool Grants / agent-tool-grants (pull_request) Successful in 40s
G12 Leak-Guard / leak-guard (pull_request) Successful in 5s
`search_precedent_library` — הכלי שסוכני הכתיבה קוראים לו בפועל — לא חשף
`source_kind` כלל, וכל שכבה מתחתיו נפלה לברירת-מחדל `external_upload`.
התוצאה: `WHERE cl.source_kind = 'external_upload'` חתך 104 החלטות ועדות-ערר
(29% מהקורפוס) עוד לפני הדירוג. ערר (חיפה) 83/16 — האסמכתה הישירה ביותר
בקורפוס לטענת-סף ס-2 ב-1069-04-26 — לא הוחזרה גם בשאילתה כמעט-מילולית.

ההיפותזה שנרשמה ב-#232 (הטיית verified/cite_count מדירה החלטות חדשות)
**נבדקה ונפסלה**: כיסוי ה-verified כמעט זהה בשני הקורפוסים (55% מול 49%),
ותקרת-ההטיה 0.22 לא הסבירה פער מול התאמה כמעט-מילולית. הסיבה הייתה סינום
קשיח, לא דירוג.

מה שונה
- `_source_kind_clause()` — הגדרה אחת לאוצר-המילים של הסלקטור (G2), במקום
  שכפולו בכל אתר חיפוש/רשימה. `""`/`"all"` = כל הקורפוס; סלקטור לא-מוכר
  מרים ValueError במקום להגיע ל-SQL (הערך מוזרק ב-f-string, אז ה-whitelist
  הוא גם מה ששומר על זה בטוח).
- ברירת-המחדל בכל שרשרת-החיפוש והרשימה: `""` = הקורפוס כולו.
- `search_precedent_library` + `precedent_library_list` חושפים `source_kind`
  לסינון מפורש; `/api/precedent-library/search` מקבל אותו גם הוא כדי ש-UI
  ו-MCP לא יתפצלו.
- נרמול מפריד במספר-תיק: `83/16` ו-`83-16` מחזירים את אותה שורה.

אימות מול ה-DB החי — 4/4 החלטות ועדה חוזרות במקום **1** בשאילתה בלשון
הלכה מאושרת (83-16, 1029-18, 1085-23, 1094-09-19 פדילה). 83/16 עלתה
ל-0.711 מול 0.663 של 3213/97 שחסמה אותה קודם. רשימת-הקורפוס: 259 → 386.

invariants: G1 (נרמול במקור, לא תיקון-תסמין בקריאה) · G2 (הגדרה אחת
לסלקטור; UI ו-MCP על אותו מסלול) · §6 (סלקטור שגוי מתפוצץ, לא נבלע)

טסטים: 5 חדשים (tests/test_source_kind_selector.py), אחד מהם נועל את
ברירות-המחדל של 8 נקודות-הכניסה — זה בדיוק הבאג. 514 עוברים.
2026-08-05 10:32:27 +00:00
079a489f0e Merge pull request 'fix(aggregator): צד שלם נמחק בשקט — chunking לפי גודל + כשל שמדווח (#233)' (#430) from worktree-aggregator-chunking into main
All checks were successful
INV-AG3 Agent Tool Grants / agent-tool-grants (push) Successful in 5s
Build & Deploy / build-and-deploy (push) Successful in 1m37s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 12s
2026-08-05 10:08:15 +00:00
720057bc72 fix(aggregator): צד שלם נמחק בשקט — chunking לפי גודל + כשל שמדווח (#233)
All checks were successful
INV-AG3 Agent Tool Grants / agent-tool-grants (pull_request) Successful in 5s
G12 Leak-Guard / leak-guard (pull_request) Successful in 6s
Lint — undefined names / undefined-names (pull_request) Successful in 12s
בתיק 1069-04-26 נשלחו 310 טענות עוררים בקריאת-Claude אחת. הקריאה החזירה
לא-JSON, הקוד רשם warning והחזיר [], והפעולה דיווחה status=completed עם
אפס טיעונים לצד המרכזי בערר. 495 propositions_processed — כלומר "עיבדתי
הכל".

שתי תקלות מובחנות, שתיהן מתוקנות:

1. **הקריאה גדולה מדי.** הפיצול הפר-כתב-טענות שנוסף למשיבים (#224) הסתיר
   את זה במקרה — הוא שמר על קריאות קטנות — אבל עוררים וּועדה מדברים בקול
   אחד ואינם מפוצלים לעולם, כך שערר גדול יוצא בקריאה אחת ענקית. ההערה
   בקוד כבר תיעדה בדיוק את הכשל הזה אצל המשיבים; התיקון פשוט לא הוחל על
   העוררים. עכשיו כל צד מעל MAX_PROPS_PER_CALL=80 נצבר בכמה קריאות
   ומשורשר.

   הפיצול הוא trade-off ולא רווח חינם: כל chunk מקובץ בבידוד, ולכן צד
   שמתפצל עלול לקבל יותר טיעונים (וחופפים במקצת) מאשר במעבר יחיד. לאבד
   ליטיגנט שלם גרוע יותר, והחלופה — פרומפט קטן יותר לכל פרופוזיציה —
   הייתה מנוונת כל תיק כדי לתקן את הגדולים. הסדר נשמר בחיתוך, כי טענות
   מגיעות ממוינות לפי claim_index ושכנות שייכות בד"כ לאותו ראש-טיעון.

2. **הכשל נבלע.** החזרת [] על תשובה לא-רשימה אינה ניתנת להבחנה מ"לצד
   הזה אין טיעונים". עכשיו נזרקת AggregationFailed, הקורא רושם אותה
   ב-errors, והסטטוס יורד ל-completed_with_errors (כלל-הנדסה §6).
   ההודעה נוקבת בשם הצד ובמספר הפרופוזיציות, אחרת מפעיל שרואה
   completed_with_errors לא יודע איזה ליטיגנט נעלם.

מבחני רגרסיה: chunking לא מאבד ולא מסדר-מחדש (310→4 קריאות, רצף נשמר);
תשובה לא-רשימה זורקת ומזכירה את שם הצד. אם המבחן השני יחזור אי-פעם
לטעון == [] — באג הבליעה הוחזר.

invariants: כלל-הנדסה §6 — אין בליעה שקטה. G1 — תיקון במקור (גודל הקריאה)
ולא בקריאה. G2/G12 — לא נגועים; שני השערים ירוקים.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 09:58:48 +00:00
d8bb2c7c0c Merge pull request 'feat(guard): כלל-קשירה ל-INV-AG3 — השער דיווח תקין על allow-list שאיש אינו אוכף' (#429) from worktree-binding-check into main
All checks were successful
INV-AG3 Agent Tool Grants / agent-tool-grants (push) Successful in 6s
Build & Deploy / build-and-deploy (push) Successful in 11s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 13s
2026-08-05 08:56:58 +00:00
5f5b13c6a4 feat(guard): כלל-קשירה ל-INV-AG3 — השער דיווח "תקין" על allow-list שאיש אינו אוכף
All checks were successful
Lint — undefined names / undefined-names (pull_request) Successful in 12s
INV-AG3 Agent Tool Grants / agent-tool-grants (pull_request) Successful in 5s
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
כללים 1–4 משווים קבצים לקבצים, וזה לא אומר דבר על **אכיפה**. רשימת
tools: הופכת ל-allow-list רק כשה-runtime בוחר את הסוכן (--agent <name>);
בלעדיו אותו קובץ בדיוק נמסר כ---append-system-prompt-file — פרוזה שהמודל
רשאי לקיים או להתעלם ממנה — וכל 108 הכלים נשארים נגישים.

התגלה 2026-08-05: סוכן אחד נושא 41 הענקות **בלי** --agent. זו הרשימה
הגדולה במערכת, והשער דיווח עליה OK — כי הוא מעולם לא הסתכל על הקשירה.
שער שמדווח "תקין" על רשימה בלתי-נאכפת גרוע מהיעדר שער: הוא מייצר
ביטחון-שווא.

כלל 5 (host-only, --check-bindings): מוודא ש-extraArgs מכיל
["--agent", <name>] התואם ל-instructionsEntryFile. הקשירה יושבת ב-DB של
הפלטפורמה, שה-CI לא רואה, ולכן הבדיקה היא opt-in ומריצה psql בתת-תהליך
במקום להוסיף תלות-דרייבר שהייתה שוברת את תכונת ה-stdlib-בלבד שמסלול
ה-CI נשען עליה. DB בלתי-נגיש מחזיר [] — "לא נבדק", לא "אין הפרות".

ובמסלול ה-CI, השער אומר עכשיו במפורש מה **לא** נבדק, במקום להדפיס OK
חשוף שמזמין את הקורא להסיק שהרשימות נאכפות.

מדווח רק על סוכנים שבאמת מצהירים הענקות — סוכן בלי tools: אינו ממצא.

אומת: מסלול-CI ירוק; --check-bindings תופס את legal-ceo עם הנימוק
"extraArgs is empty" ומציע את שתי החלופות (להוסיף --agent, או למחוק
tools: ולתעד כבלתי-מוגבל) — במפורש לא שתיהן, כי זו הפרת G2.

invariants: INV-AG3 — מרחיב מקובץ לזמן-ריצה. G2 — מקיים; אין מפת-הרשאות
שנייה, רק אימות שהמפה הקיימת נאכפת.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 08:53:53 +00:00
b394177207 Merge pull request 'feat(ops): שירות pm2 legal-mcp-http (#231.3)' (#428) from worktree-mcp-http-service into main
All checks were successful
INV-AG3 Agent Tool Grants / agent-tool-grants (push) Successful in 5s
Build & Deploy / build-and-deploy (push) Successful in 9s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 11s
2026-08-05 08:27:07 +00:00
3f48dc0e11 feat(ops): שירות pm2 legal-mcp-http — שרת ה-MCP חשוף ב-HTTP מאחורי שער Bearer (#231.3)
All checks were successful
Lint — undefined names / undefined-names (pull_request) Successful in 11s
INV-AG3 Agent Tool Grants / agent-tool-grants (pull_request) Successful in 4s
G12 Leak-Guard / leak-guard (pull_request) Successful in 5s
סוכנים המונעים דרך Agent Client Protocol מקבלים את שרתי-ה-MCP שלהם מהלקוח
בפתיחת הסשן, והערוץ הזה נושא שרתי HTTP בלבד. ל-stdio אין מסלול לשם, ומכאן
שהסוכנים נותרו בלי 108 הכלים. זהו הקצה ה-HTTP שאפשר להפנות אותם אליו.

**אינו מחליף את stdio.** כל סשן אינטראקטיבי ממשיך להגיע לאותו שרת דרך
הרשומה legal-ai ב-~/.claude.json. אותו קוד, אותו מרשם-כלים, שתי תחבורות
(G2) — דלת שנייה, לא שרת שני.

אבטחה — שתי הגנות, שתיהן נדרשות:
1. bind ל-127.0.0.1. צר יותר מ-10.0.1.1 של legal-chat-service בכוונה: שום
   קונטיינר לא צריך לקרוא ל-MCP.
2. Bearer מ-MCP_HTTP_SHARED_SECRET. השרת מסרב לעלות בלי טוקן
   (services/http_auth.py), ולכן תקלת-הגדרה לא יכולה לייצר בשקט מאזין
   לא-מאומת — pm2 יציג crash במקום להגיש תעבורה פתוחה.

הסוד: מקור-אמת ב-Infisical (All Infrastructure / main / /apps/legal-ai,
תג credentials). ~/.legal-mcp-http.env הוא עותק-ריצה chmod 600, אותו סידור
כמו legal-chat-service.

max_restarts נמוך במתכוון: טוקן חסר מפיל את התהליך מיד, ועדיף ש-pm2 יפסיק
לנסות ויותיר רשומת errored גלויה מאשר ילולאה על טעות-הגדרה.

אומת על השירות החי:
  POST בלי Authorization → 401
  POST עם טוקן שגוי      → 401
  POST עם הטוקן הנכון    → 200
  אחרי pm2 restart       → 200
  pm2 save               → ✓

invariants: G2 — מקיים (תחבורה שנייה לאותו שרת, לא מימוש מקביל).
G12 — לא נגוע. INV-AG3 — לא נגוע.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 08:19:45 +00:00
c218864f36 Merge pull request 'docs: כתובת שרת-הסודות ומיקומי הסודות — התיעוד הצביע למופע שפורק' (#427) from worktree-secrets-docs into main
All checks were successful
INV-AG3 Agent Tool Grants / agent-tool-grants (push) Successful in 4s
Build & Deploy / build-and-deploy (push) Successful in 1m38s
G12 Leak-Guard / leak-guard (push) Successful in 6s
Lint — undefined names / undefined-names (push) Successful in 15s
2026-08-05 08:16:59 +00:00
c22935c008 Merge pull request 'feat(mcp): שער Bearer לתחבורת ה-HTTP (#231.2)' (#426) from worktree-mcp-http-auth into main
Some checks failed
INV-AG3 Agent Tool Grants / agent-tool-grants (push) Has been cancelled
Build & Deploy / build-and-deploy (push) Has been cancelled
Lint — undefined names / undefined-names (push) Has been cancelled
G12 Leak-Guard / leak-guard (push) Has been cancelled
2026-08-05 08:16:58 +00:00
58a2f33187 docs: כתובת שרת-הסודות ומיקומי הסודות — התיעוד הצביע למופע שפורק
All checks were successful
INV-AG3 Agent Tool Grants / agent-tool-grants (pull_request) Successful in 5s
G12 Leak-Guard / leak-guard (pull_request) Successful in 5s
Lint — undefined names / undefined-names (pull_request) Successful in 12s
secret.dev.marcus-law.co.il מת: רשומת ה-*.dev נמחקה מ-NPM עם פירוק
192.168.10.206 ב-2026-08-02. גם הזהות הישנה מחזירה 401.

השרת החי: secret.marcus-law.co.il, פרויקט All Infrastructure
(2c462576-b125-4279-b0ec-7220dbf51ccf), env main. אין פרויקט legal-ai
נפרד — סודות האפליקציות הם תיקיות: /apps/legal-ai, /apps/paperclip,
/apps/paperclip-hermes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 08:15:33 +00:00
363485d025 refactor(mcp): MCP_HTTP_TOKEN → MCP_HTTP_SHARED_SECRET — זהה לשם ב-Infisical
All checks were successful
Lint — undefined names / undefined-names (pull_request) Successful in 11s
INV-AG3 Agent Tool Grants / agent-tool-grants (pull_request) Successful in 5s
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
הסוד נוצר ב-All Infrastructure / env main / /apps/legal-ai בשם
MCP_HTTP_SHARED_SECRET, מתויג credentials, לפי הדפוס של שני טוקני-הגשר
שכבר שם: COURT_FETCH_SHARED_SECRET ו-LEGAL_CHAT_SHARED_SECRET.

שם זהה בקוד וב-Infisical = אין מיפוי שצריך לזכור.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 08:15:32 +00:00
eebb193fd3 feat(mcp): שער Bearer לתחבורת ה-HTTP — מסרב לעלות בלי טוקן (#231.2)
All checks were successful
Lint — undefined names / undefined-names (pull_request) Successful in 11s
INV-AG3 Agent Tool Grants / agent-tool-grants (pull_request) Successful in 4s
G12 Leak-Guard / leak-guard (pull_request) Successful in 5s
ב-stdio ההגנה היא הצינור עצמו: רק תהליך שכבר רץ כמשתמש הזה יכול לדבר עם
השרת. streamable-http מבטל את התכונה הזו לחלוטין — כל מי שמגיע ל-socket
יכול לקרוא לכל אחד מ-108 הכלים, ובמרשם יש case_delete,
precedent_library_delete, document_upload וכל כלי כתיבת-הבלוקים. מאזין
לא-מאומת הוא, הלכה למעשה, endpoint למחיקת תיקים.

מימוש דרך ה-TokenVerifier של ה-SDK ו-BearerAuthBackend שלו — לא middleware
משלנו. מסלול-אימות אחד, של המסגרת (G2).

ההחלטות שקובעות את בטיחות הפיצ'ר:
- **מסרב לעלות בלי טוקן.** MissingTokenError קטלנית. החלופה המפתה — לעלות
  ולרשום warning — מייצרת מאזין שנראה בריא ועונה על כל קריאה הרסנית.
  סירוב-אתחול הוא הכשל הבטוח (§6).
- הבנייה בזמן-import ולא בתוך main(): FastMCP מקבל token_verifier ו-auth
  כארגומנטי-בנאי, ולכן טוקן חסר חייב להיכשל *לפני* שה-listener קיים.
- **stdio לא נוגע.** דרישת טוקן שם הייתה שוברת כל סשן אינטראקטיבי בלי שום
  רווח אבטחתי — הגבול שם הוא הצינור.
- השוואה בזמן-קבוע (hmac.compare_digest); == נאיבי מדליף את הטוקן בייט-בייט
  לתוקף שמודד זמנים.
- verify_token מחזיר None ולא זורק — זה אות ה"דחייה" של הפרוטוקול ומניב 401
  נקי במקום 500 שנקרא ככשל-שרת.
- סף אורך 32 תווים; טוקן קצר נדחה באתחול ולא מתגלה מ-access log.
- הטוקן נקרא מ-env (שיאוכלס מ-Infisical), לא מוטמע, ולא נרשם ללוג.

אומת בהרצה חיה:
  HTTP בלי טוקן → סירוב לעלות, exit 1
  stdio בלי טוקן → עולה כרגיל, auth כבוי
  POST בלי Authorization        → 401
  POST עם טוקן שגוי             → 401
  POST עם הטוקן הנכון           → 200
  claude דרך HTTP+Bearer        → 108 כלים, mcp__legal-ai__case_get
  מופעי הטוקן בלוג              → 0

invariants: G2 — מקיים (מסלול-אימות יחיד, של ה-SDK). G12 — מקיים; המודול
נקי מסמלי-פלטפורמה, leak_guard ירוק. INV-AG3 — לא נגוע, השער ירוק.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 07:45:38 +00:00
6aac4a73f6 Merge pull request 'feat(mcp): תחבורת streamable-http לצד stdio (#231.1)' (#425) from worktree-mcp-http-transport into main
All checks were successful
INV-AG3 Agent Tool Grants / agent-tool-grants (push) Successful in 4s
Build & Deploy / build-and-deploy (push) Successful in 3m14s
G12 Leak-Guard / leak-guard (push) Successful in 6s
Lint — undefined names / undefined-names (push) Successful in 13s
2026-08-05 07:42:22 +00:00
81ea43a70a feat(mcp): תחבורת streamable-http לצד stdio — פותח את הדרך להזרקת השרת לסשני ACP (#231.1)
All checks were successful
INV-AG3 Agent Tool Grants / agent-tool-grants (pull_request) Successful in 42s
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
סוכני הפלטפורמה מקבלים את שרתי ה-MCP שלהם מהלקוח בפתיחת הסשן, והערוץ
הזה מקבל שרתי HTTP בלבד: כל שרת מוזרק כ-
{type:"http", name, url, headers:[Bearer]}. לשרת stdio אין מסלול לשם
בכלל — ומכאן שכל 108 הכלים נעדרו מהסוכנים מאז שמנוע-ההרצה שלהם השתנה.

שרת אחד, שתי תחבורות — לא מימוש שני (G2). מרשם-הכלים, השירותים ובריכת
ה-DB משותפים מילולית; רק פרוטוקול-החוט משתנה, כך שכלי לא יכול להתקיים
בתחבורה אחת ולא באחרת.

- MCP_TRANSPORT בוחר תחבורה, ברירת-מחדל stdio. ערך לא-חוקי נכשל ברעש
  (SystemExit) ולא נופל בשקט חזרה ל-stdio — אחרת ה-listener נעדר בעוד
  שהכול "נראה" תקין (כלל-הנדסה §6).
- stdio נשאר ברירת-המחדל: כל סשן אינטראקטיבי מגיע דרכו מ-~/.claude.json.

מלכודת שהתגלתה בהרצה: אי-אפשר להסתמך על FASTMCP_HOST/FASTMCP_PORT.
ל-FastMCP.__init__ יש ברירות-מחדל מפורשות (host="127.0.0.1", port=8000)
שמועברות ל-Settings(**settings), ובפידנטיק ארגומנטים מפורשים גוברים על
env — כך ש-FASTMCP_PORT מתעלמים ממנו והשרת נקשר ל-8000 בכל מקרה. על
המכונה הזו 8000 תפוס, כך שזה נכשל ברעש במקרה ולא בזכות תכנון. לכן
MCP_HTTP_HOST/MCP_HTTP_PORT נקראים אצלנו ומועברים לבנאי.

ברירת-מחדל loopback (127.0.0.1:8790): התחבורה עדיין ללא אימות, ובמרשם
יש כלים הרסניים (case_delete, precedent_library_delete). שער ה-Bearer
הוא #231.2 והוא חייב לנחות לפני חשיפה מחוץ למארח.

אומת end-to-end: claude התחבר ל-http://127.0.0.1:8791/mcp וקיבל 108
כלים בשם mcp__legal-ai__case_get — זהה בדיוק ל-stdio. כלומר 66 ההענקות,
הוראות-הסוכנים, web/paperclip_client.py ושער INV-AG3 ממשיכים לעבוד.

invariants: G2 — מקיים (שרת אחד, שתי תחבורות, מרשם משותף). G12 — מקיים;
חיווט צד-הפלטפורמה נשאר מאחורי web/agent_platform_port.py, והמודול הזה
נותר נקי מסמלי-פלטפורמה (leak_guard ירוק). INV-AG3 — לא נגוע, השער ירוק.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 07:40:05 +00:00
b8f6226fb1 Merge pull request 'fix(agents): INV-AG3 — כלי שמורים לסוכן להריץ חייב להיות מוענק לו, ושער-CI שיאכוף' (#424) from worktree-agent-tool-grants into main
All checks were successful
INV-AG3 Agent Tool Grants / agent-tool-grants (push) Successful in 4s
Build & Deploy / build-and-deploy (push) Successful in 10s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 11s
2026-08-04 09:09:03 +00:00
20a1da0a0e fix(agents): INV-AG3 — כלי שמורים לסוכן להריץ חייב להיות מוענק לו, ושער-CI שיאכוף
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
INV-AG3 Agent Tool Grants / agent-tool-grants (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 12s
ה-frontmatter `tools:` של סוכן claude_local הוא allow-list סגורה: כלי הרשום
בשרת-ה-MCP אך חסר ממנה אינו ניתן לקריאה, גם כשהשרת מחובר לחלוטין.

`analyze_protocol` נרשם בשרת ב-24e3e2f (2026-06-30) בקומיט שנגע ב-9 קבצים,
אף אחד מהם ב-.claude/agents/. אחר כך #226 הוסיף את
wake_analyst_for_protocol_analysis, שכותב לתוך ה-issue "הרץ
mcp__legal-ai__analyze_protocol(...)" — כלומר המערכת הורתה למנתח להריץ כלי
שמעולם לא הוענק לו. ב-CMP-229 (2026-08-04) המנתח דיווח נכונה ש"הכלים קיימים
בשרת אך אינם נחשפים לסשן", ועקף דרך psql ידני וסקריפט מקומי: ההרצה הראשונה
שיגרה אותו לרקע וסיימה את התור, קבוצת-התהליכים נהרגה, והניתוח אבד. רק ההרצה
השנייה (recovery) הצליחה.

INV-AG3 כיסה את זה בספ מ-2026-06-06, אבל האכיפה נדחתה ("אכיפה אוטומטית
עתידית — תת-פרויקט 5"), ולכן הדריפט חי חמישה שבועות.

הענקות שנוספו (כל אחת עם ההוראה המתאימה — לא הענקה עודפת):
- legal-analyst: analyze_protocol + get_protocol_analysis (+ סעיף "משימה
  על-פי-דרישה: ניתוח פרוטוקול-הדיון" — אף סוכן לא ידע שהיכולת קיימת),
  get_legal_arguments ו-get_appraiser_facts (קריאה-בחזרה אחרי כתיבה)
- legal-ceo: get_appraiser_facts (אימות שהחילוץ נחת)
- legal-qa: precedent_library_list — הוראותיו כבר אמרו "הרץ" אותו. אותו באג
  בדיוק, שהתגלה אגב הסריקה

שער-CI חדש `scripts/agent_tool_grants_guard.py` בדפוס leak_guard.py של G12,
ארבעה כללים קשיחים: (1) כל mcp__legal-ai__X ב-web/ מוענק לסוכן כלשהו · (2) כל
mcp__legal-ai__X בגוף קובץ-סוכן מוענק באותו קובץ · (3) אין הענקה לכלי לא-רשום
· (4) שם-כלי בגרשיים ללא תחילית — מוענק, או מסווג ב-CONTRASTIVE_OK עם נימוק
(9 סווגו: אזכור ניגודי, מטלת-סוכן-אחר, שם-עמודה מתנגש), עם בדיקת-התיישנות.
מוחרגים קבצים שאינם סוכני-claude_local (hermes-curator,
legal-analyst-gemini-critique — בלי frontmatter בכוונה; HEARTBEAT).

השער אומת שלילית: לפני התיקון החזיר בדיוק 3 הפרות (analyze_protocol,
precedent_library_list, get_legal_arguments) ואפס רעש; אחריו OK.

invariants: INV-AG3 (docs/spec/X4-agents.md §2א) — מקיים; האכיפה עברה מידנית
ל-CI. G2 — סוכן שאינו יכול לקרוא לכלי בונה מסלול מקביל (SQL ישיר/סקריפט),
וזה מה שנחסם כאן. G12 — leak-guard רץ נקי (רגרסיה).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 09:05:35 +00:00
90de9831c3 Merge pull request 'fix(ui): טענות-הסף והסוגיות בטור אחד — פתיחת כרטיס הקפיצה גם את הטור השני' (#423) from worktree-positions-single-column into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m42s
Lint — undefined names / undefined-names (push) Successful in 14s
G12 Leak-Guard / leak-guard (push) Successful in 8s
2026-08-04 08:20:29 +00:00
f91cb2a660 fix(ui): טענות-הסף והסוגיות בטור אחד — פתיחת כרטיס הקפיצה גם את הטור השני
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 49s
Lint — undefined names / undefined-names (pull_request) Successful in 12s
כרטיסי טענות-הסף והסוגיות ב"עמדות וטענות" ישבו ב-CSS Grid דו-טורי
(`lg:grid-cols-2`). ב-Grid כל שורה חולקת גובה, ולכן פתיחת כרטיס בטור
אחד הגדילה את גובה השורה ודחפה מטה את כל מה שמתחת — בשני הטורים.

מעבר לטור אחד (`space-y-3`) מקומם את הקפיצה למה שמתחת לכרטיס שנפתח
בלבד, ונותן לתוכן הנפתח (שדות + עורך עמדת-היו״ר + פסיקה תומכת) רוחב
מלא במקום חצי — נוח בהרבה לעריכה בעברית.

זוג הכרטיסים התחתון (פסיקה מצורפת + ייצוא/עדכון הניתוח) נשאר דו-טורי;
הוא אינו נפתח ואינו סובל מהבעיה.

invariants: INV-IA1 (מקור-אמת יחיד) נשמר — שינוי פריסה בלבד, ללא נגיעה
בנתונים, ב-hooks או ב-API. G2/G10/G12 — לא רלוונטיים.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 08:19:51 +00:00
bf9559c61a Merge pull request 'fix(research): עמדת ועדת הערר בטענות-סף נשמרה אך לא נקראה בחזרה' (#422) from worktree-chair-position-bullet-parse into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m55s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 13s
2026-08-02 14:05:37 +00:00
fbdbc64366 fix(research): עמדת ועדת הערר בטענות-סף נשמרה אך לא נקראה בחזרה
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 37s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
הקורא והכותב של analysis-and-research.md לא הסכימו על מהו שדה:
FIELD_LABEL_RE דרש `**כותרת:**` בתחילת שורה, בעוד הכותב התאים את
התווית בכל מקום בשורה. האנליסט כותב טענות-סף כרשימה
(`- **עמדת ועדת הערר:**`) ואת הסוגיות בתחילת שורה — ולכן שמירה
בטענת-סף הצליחה (200 + "✓ נשמר"), אבל הקורא לא ראה את השדה כלל
והעמדה נעלמה ברענון. גם יתר שדות הטענה (טענה/תשובה/שאלה משפטית)
היו בלתי-נראים באותן טענות.

- הקורא מקבל תווית עם סמן-רשימה אופציונלי, כמו הכותב.
- הכותב עובר להשתמש באותה הגדרת-גבולות של הקורא (_chair_field_span)
  במקום regex משלו — `[^*]*?` הישן גם קטע עמדה שהכילה `**הדגשה**`.
  התווית והסמן נשמרים כפי שהם, וכך גם `---` הסוגר.
- תווית מעוטרת (`עמדת ועדת הערר (הכוונת יו"ר 24.6)`) מזוהה בהתאמת-רישא;
  קודם הכותב הוסיף בלוק כפול במקום לעדכן.
- שדה כפול באותו H3 (`### סוגיות 4–6`): הקורא לקח את האחרון והכותב את
  הראשון. שניהם לוקחים עכשיו את הראשון.
- read-after-write: שמירה שהפרסר לא קורא בחזרה מדווחת כשגיאה במקום
  "נשמר" ירוק, וה-UI שומר בקאש את מה שהשרת קרא — לא את מה ששלח.
- תבנית האנליסט (§5) קיבלה שלד מפורש לטענות-סף, זהה לזה של הסוגיות,
  כדי שקבצים חדשים לא ייווצרו במבנה החורג (נרמול-במקור, G1).

הרצת round-trip על כל הקורפוס: 24 מתוך 141 תת-סעיפים נכשלו לפני
התיקון (1017, 1019, 1027, 1033, 1043, 1069, 8124) — 0 אחריו. העמדות
שכבר נשמרו בקבצים הקיימים חוזרות להיקרא בלי מיגרציה.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 14:04:24 +00:00
08419e4434 Merge pull request 'fix(writer): כלל-הסגנון בסוף הפרומפט — חותך אנטי-דפוסים ב-72–93%' (#421) from worktree-anti-pattern-directive-position into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 2m34s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 12s
2026-07-28 11:53:57 +00:00
2ebaa82f85 Merge remote-tracking branch 'origin/main' into worktree-anti-pattern-directive-position
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
2026-07-28 11:53:23 +00:00
79d9ea55d8 Merge pull request 'feat(eval): ממד model×prompt ל-harness הכיול (#208) — A/B מודל-ייצור מול הסופיים' (#420) from worktree-opus5-model-calibration into main
Some checks failed
Build & Deploy / build-and-deploy (push) Has been cancelled
G12 Leak-Guard / leak-guard (push) Has been cancelled
Lint — undefined names / undefined-names (push) Has been cancelled
2026-07-28 11:52:57 +00:00
86e66cc5bd feat(eval): פילוח אנטי-דפוסים per-ריצה — "איזה כלל הופר", לא רק כמה
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
`block_distance_to_final` החזיר `anti_pattern_total` בלבד. ריצת-כיול שמדווחת
"anti=4" אינה יכולה לומר מה לתקן — באבחון ה-A/B של 2026-07-28 נאלצנו להסיק
את הדפוס האשם מהקשר במקום למדוד אותו.

- `anti_by_pattern` (שם-דפוס → מספר-פגיעות) נוסף לתא-המדידה, מ-
  `count_anti_patterns` הקיים — אין ספירה מקבילה.
- `_mean_by_pattern` ממצע על **כל** הריצות: דפוס שלא נורה בריצה נספר כ-0
  ולא מושמט, אחרת הממוצע היה מוטה כלפי מעלה.
- הדוח מקבל טבלת "פילוח אנטי-דפוסים (איזה כלל הופר)" per block×effort×model.

invariants: INV-G8 (eval-harness) · G2 (מרונדר מ-count_anti_patterns/
ANTI_PATTERNS הקנוניים — מקור אחד).

אימות: self-test ALL PASS · 470 passed · בדיקת-שפיות ישירה —
טקסט עם 1 כותרת + 2 תבליטים + 1 פיצול-מיני מפולח נכון ל-
{markdown_headers:1, bullet_lists:2, inline_numbered_fragments:1}.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 11:17:35 +00:00
42ea1a7c58 fix(writer): כלל-הסגנון בסוף הפרומפט — הוא היה שם, במקום שבו הוא לא תופס
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 35s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
הרשימה הקנונית של lessons.ANTI_PATTERNS כבר הוזרקה לכותב, אבל בתו ~46,781
מתוך 46,950 של style_context — שהוא עצמו מקטע אחד מתוך ~12 בפרומפט. הטיוטות
המשיכו לפלוט בדיוק את מה שהיא אוסרת.

A/B מדוד מול הסופיים החתומים (9 תיקים, 60 ייצורים, 2026-07-28) הראה שאותו
כלל, בסוף הפרומפט, חותך anti_pattern_total ב-72–93%:

  block-vav   opus-4-8 1.75→0.12 · opus-5 2.25→0.62
  block-zayin opus-4-8 4.57→0.43 · opus-5 4.43→0.43

וב-distance: −12%/−29% (4-8), −9%/−27% (5). זה שיפור גדול פי-3 מכל הבדל
שנמדד בין המודלים עצמם.

- `lessons.anti_pattern_directive()` — רינדור שני של אותה רשימה קנונית
  (מקור אחד, שתי תצוגות — לא שני כללים).
- מתווסף **אחרון** בשני מסלולי-הכתיבה: `write_block` (בתהליך) ו-
  `get_block_context` (סוכן legal-writer). אילו הוחל רק באחד, שני הכותבים
  היו נפרדים בסגנון (G2).
- **תיקון בליעה-שקטה (§6):** הרשימה הקנונית רונדרה בתוך לולאת-ה-overrides,
  כך שכשל-DB בקטגוריה מוקדמת (golden_ratios) הפיל את הלולאה והשמיט את
  אינווריאנטי-הסגנון כליל — עם אזהרה גנרית בלבד. עכשיו היא מרונדרת ללא
  תנאי, לפני כל קריאת-DB; הערות-היו"ר מתווספות מעליה.

invariants: G11 (תוכן משפטי — סגנון דפנה) · G2 (מקור-אמת יחיד לכלל, ושני
מסלולי-הכתיבה מיושרים) · §6 (אין בליעה שקטה).

בדיקות: 473 passed (3 חדשות — רינדור מלא, שני המסלולים, שרידות לכשל-DB).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 11:14:47 +00:00
10e05700cc feat(eval): --instructions ל-harness — A/B של וריאנט-פרומפט
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 34s
Lint — undefined names / undefined-names (pull_request) Successful in 12s
מוחל על כל המודלים בריצה (אחרת השוואת-מודלים הופכת בשקט להשוואת-פרומפטים),
ונרשם ב-grid_summary + בכותרת הדוח כדי שריצת-וריאנט לא תושווה בטעות לבסיס.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 07:47:05 +00:00
11acdac337 feat(eval): ממד-מודל ל-harness הכיול (#208) — A/B של מודל-ייצור מול הסופיים
`calibrate_effort.py` כייל `effort` בלבד, על מודל נעוץ (GENERATION_MODEL).
כדי להשוות מודל-ייצור (opus-4-8 מול opus-5) מול הסופיים החתומים של דפנה
נדרש ממד שני — ללא מסלול-מדידה מקביל.

- `block_writer.write_block(model_override=…)` — אותו חוזה כמו
  `effort_override` הקיים (נוצר בדיוק ל-#208). מקבל את המזהה הבסיסי בלבד;
  אסקלציית ההקשר-1M (#216) מוחלת מעליו, כך ש-override לא מאבד בשקט את
  חלון ה-1M.
- `--models` ל-harness (ריק = המודל הנעוץ ⇒ ריצת ברירת-המחדל זהה לקודם).
- הדוח מקבל טבלת השוואת-מודלים (block × effort × model) ומסמן  לפי
  אותו דירוג style-clean (#213): anti_total → ratioΔ → distance.
- כל תא מתעד את `model_used` שה-CLI דיווח בפועל; אי-התאמה מסומנת כאזהרת
  fallback-שקט במקום להיזקף בטעות למודל המבוקש.

invariants: INV-G8 (eval-harness — מדידה, לא הרגשה) · G2 (אין מסלול מקביל:
משתמש ב-style_distance/learning_loop הקיימים ובשדה result הקיים
`model_used`) · §6 (אין בליעה שקטה — כשל-תא מדווח ומדולג).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 06:39:33 +00:00
4eb3312e9b Merge pull request 'refactor(legal-ceo): גיזום מסמך-ההכוונה לכותב — שיפוט במקום טופס' (#419) from ceo-prompt-trim into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 10s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 11s
2026-07-25 21:29:53 +00:00
574998021e refactor(legal-ceo): גיזום מסמך-ההכוונה לכותב — שיפוט במקום טופס
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 35s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
מיישם את ממצא ה-A/B (fable-5/opus-4-8 על תיק 1043-02-26): פרומפט
מגוזם-ומוכוון-שיפוט מפיק היסק משפטי חד יותר מתבנית נוקשה. משכתב את
תבנית מסמך-ההכוונה של ה-CEO לכותב בלבד — משמר את החוזה המלא (5 הרכיבים,
chair_directions בתגית, אילוצי-הסגנון) ומזריק את רמזי-השיפוט שהוכחו:
אדנים עצמאיים, מוקשי-עקביות-פנימית, מענה לצד המפסיד, הובלה בסוגיה
המכריעה. שאר מכונת-התזמור התפעולית (שערים, סטטוסים, API, MCP-race)
לא נגעה. net -27/+20.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 21:28:16 +00:00
cc4d757fce Merge pull request 'refactor(agents): גיזום כפילויות + rubric-קבלה משותף (context-engineering לדור-5)' (#418) from worktree-agent-prompts-trim into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 11s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-07-25 17:33:52 +00:00
253f8ff97b refactor(agents): גיזום כפילויות + rubric-קבלה משותף (context-engineering לדור-5)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 12s
מיושר עם "context engineering for Claude 5 models" — הסוכנים רצים על opus-4-8/sonnet-5.

גיזום (רק כפילות חד-משמעית; טבלאות-סגנון/שער-אמינות/HEARTBEAT לא נגעו):
- 6 סוכנים: קריסת שכפולי HEARTBEAT §4ב/§4ג (כל סוכן הפנה כמקור-יחיד ואז שכפל).
- exporter: ציטוט "בג\"ץ 1234/21" מומצא בדוגמת-JSON → placeholder (סתירה לשער-האמינות).
- writer: רשימת-קריאה לבלוק-י כפולה מילה-במילה → הפניה לעותק הראשון.

מיני-פרויקט rubric (גניבת מודל Outcome מ-Managed Agents, במסמך משותף = בלי כפילות):
- docs/qa-acceptance-rubric.md — הגדרת "מוכן" אחת (חוסמים/אזהרות + severity).
- writer מכוון אליו מראש; qa מפנה אליו כמקור-האכיפה של 7 הבדיקות.

הערה: תבנית-הלוג של analyst היא ההגדרה הקנונית (לא כפילות) — נשמרה במכוון.
2026-07-25 17:33:32 +00:00
044aecb034 Merge pull request 'refactor(agents): מבנה-פרומפט — verdict מובנה ל-QA + תגית chair_directions' (#417) from worktree-agent-prompts-xml-structure into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 9s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 12s
2026-07-25 16:49:48 +00:00
38 changed files with 1960 additions and 137 deletions

View File

@@ -15,8 +15,12 @@ tools:
- mcp__legal-ai__document_get_text
- mcp__legal-ai__extract_claims
- mcp__legal-ai__extract_appraiser_facts
- mcp__legal-ai__get_appraiser_facts
- mcp__legal-ai__get_claims
- mcp__legal-ai__aggregate_claims_to_arguments
- mcp__legal-ai__get_legal_arguments
- mcp__legal-ai__analyze_protocol
- mcp__legal-ai__get_protocol_analysis
- mcp__legal-ai__search_case_documents
- mcp__legal-ai__search_decisions
- mcp__legal-ai__search_precedent_library
@@ -123,9 +127,9 @@ tools:
4. חלץ טענות/תשובות/תגובות (`extract_claims` עם doc_type ו-party_hint מתאימים)
- **מסמך גדול (>15,000 תווים):** מאז phase 1 של מערכת הניתוח, ה-chunking הסמנטי + מקבילות + retry מטופל אוטומטית. גם מסמך של 100K+ תווים ירוץ עד הסוף. אם בכל זאת נכשל — דווח ב-issue.
- **טיפול בכשל:** אם `extract_claims` החזיר `partial=true` או 0 טענות ממסמך לא ריק — נסה שוב פעם אחת. אם עדיין נכשל — סטטוס issue = `blocked`, פרסם comment עם הפירוט.
5. **חלץ עובדות שמאי** — לכל מסמך `doc_type='appraisal'` בתיק, הרץ `extract_appraiser_facts(case_number)` (פעם אחת לתיק, מטפל בכל השומות). **חובה בכל ערר השבחה (8xxx) ופיצויים (9xxx) — בלי זה ה-writer לא יוכל לכתוב את בלוק ז עם מספרים מדויקים.**
5. **חלץ עובדות שמאי** — לכל מסמך `doc_type='appraisal'` בתיק, הרץ `extract_appraiser_facts(case_number)` (פעם אחת לתיק, מטפל בכל השומות). **חובה בכל ערר השבחה (8xxx) ופיצויים (9xxx) — בלי זה ה-writer לא יוכל לכתוב את בלוק ז עם מספרים מדויקים.** מיד אחריו הרץ `mcp__legal-ai__get_appraiser_facts(case_number)` כדי **לקרוא בחזרה** את מה שנשמר ולוודא שהחילוץ אכן נחת — אל תדווח על חילוץ שלא אימתת בקריאה.
6. וודא שכל פריט מסווג ל-claim_type הנכון
7. **קבץ טענות לטיעונים משפטיים** — לאחר שכל הטענות חולצו וסוּוגו, הרץ `aggregate_claims_to_arguments(case_number)` שמקבץ את הפרופוזיציות הגולמיות לטיעונים משפטיים מובחנים (~6-12 לכל צד). זהו קלט מובנה לבלוק ז (טענות הצדדים) ולבלוק י (דיון) — הכותב נשען עליו. אם 0 טענות חולצו — דלג. הפלט עובר שער-אישור (ראה `get_legal_arguments`).
7. **קבץ טענות לטיעונים משפטיים** — לאחר שכל הטענות חולצו וסוּוגו, הרץ `aggregate_claims_to_arguments(case_number)` שמקבץ את הפרופוזיציות הגולמיות לטיעונים משפטיים מובחנים (~6-12 לכל צד). זהו קלט מובנה לבלוק ז (טענות הצדדים) ולבלוק י (דיון) — הכותב נשען עליו. אם 0 טענות חולצו — דלג. הפלט עובר שער-אישור — קרא אותו בחזרה עם `mcp__legal-ai__get_legal_arguments(case_number)` ואמת שמספר הטיעונים לכל צד סביר לפני שאתה ממשיך.
### שלב 2: ניתוח מעמיק
הצג במבנה הבא:
@@ -143,6 +147,8 @@ tools:
**טענות סף** (אם קיימות):
חוסר סמכות, שיהוי, התיישנות, אי-מיצוי הליכים, חוסר יריבות, מעשה בית דין — הצג כל אחת עם עמדת שני הצדדים. לכל טענת סף הוסף **עמדת ועדת הערר** (שדה ריק ליו"ר). אם אין — כתוב: "לא זוהו טענות סף."
**מבנה השדות — זהה לזה של הסוגיות:** כל טענת סף היא H3 משלה, והשדות בתוכה נכתבים בתחילת שורה (`**טענה (claim):**`) — **לא** כפריטי רשימה (`- **טענה (claim):**`) ולא בכותרת מעוטרת. השדה `**עמדת ועדת הערר:**` נכתב בשורה נפרדת והערך מתחתיו. זהו השדה שדפנה עורכת ישירות מה-UI — סטייה מהפורמט מסתירה אותו ואת יתר השדות של אותה טענה מהמסך.
**תקן ביקורת**: ציין את תקן הביקורת של הוועדה בתיק זה — "הוועדה מפעילה שיקול דעת תכנוני עצמאי" (ברישוי) או "הוועדה בוחנת את תקינות השומה המכרעת" (בהיטל השבחה) או תקן אחר לפי סוג ההליך.
**מפת דרכים**: לאחר זיהוי טענות הסף ולפני הדיון בסוגיות — כתוב פסקת מפה: "X שאלות עומדות להכרעה: (1)...; (2)...; (3)..." — כדי שהקורא ידע מראש מה לצפות.
@@ -268,6 +274,29 @@ search_precedent_library(
**מינימום:** מספר queries ב-Q1+Q2+Q3 לקורפוס הסמכותי = מספר טענות סף + מספר סוגיות מרכזיות. אם זיהית 5 סוגיות + 2 טענות סף → לפחות 7 queries.
## משימה על-פי-דרישה: ניתוח פרוטוקול-הדיון
**זו אינה חלק מהזרימה הרגילה** — היא מגיעה כ-issue ייעודי כשחיים לוחץ "נתח את פרוטוקול הדיון" בפאנל **"מה קרה בדיון"** (טאב טיעונים-ועמדות). ה-issue יאמר במפורש להריץ `analyze_protocol`.
```
mcp__legal-ai__analyze_protocol(case_number="<מספר-התיק>")
mcp__legal-ai__analyze_protocol(case_number="<מספר-התיק>", document_id="<uuid>") # כשיש כמה פרוטוקולים
```
הכלי משווה את **פרוטוקול דיון ועדת-הערר** מול **הטיעונים המאוגדים**, ומסווג כל שינוי ל-`strengthened` / `newly_raised` / `dropped`, עם `evidence_quote` מהפרוטוקול לכל שורה (INV-AH — אין שורה בלי ציטוט).
**שני תנאים מוקדמים — בדוק אותם לפני שאתה מריץ:**
1. **פרוטוקול של ועדת-הערר.** הכלי בוחר אוטומטית פרוטוקול שה-`protocol_scope` שלו אינו `lower`; פרוטוקול של הוועדה **המקומית** מזין רקע (בלוק ו) בלבד ולא מוביל את ההשוואה. אם כל הפרוטוקולים בתיק הם `lower` — אין דיון-ערר להשוות אליו.
2. **טיעונים מאוגדים.** הרץ `get_legal_arguments(case_number)`. אם ריק — הרץ קודם `aggregate_claims_to_arguments`.
אם תנאי חסר — **אל תעקוף**: כתוב comment בעברית שמפרט מה חסר, וסגור `blocked`.
`re-run` מחליף את הניתוח הקודם (idempotent). לקריאה בלבד, בלי ניתוח מחדש: `mcp__legal-ai__get_protocol_analysis(case_number, change_type="")`.
**דווח ב-comment בעברית**: כמה טענות התחזקו, כמה נטענו לראשונה, כמה ירדו — ומה החידוד המרכזי שעלה בדיון.
> ⚠️ **הרץ את הכלי בקדמה — לעולם לא ברקע.** שיגור ל-background וסיום התור מסיים את ה-run, וקבוצת-התהליכים נהרגת יחד איתו: הניתוח נקטע ולא נשמר דבר (נצפה ב-CMP-229, 2026-08-04). הכלי לוקח כמה דקות; זה תקין. חכה לו.
## שלב 6: בדיקת שלמות — לפני שמסיימים!
**לפני סיום, בצע את הבדיקות הבאות. אם בדיקה נכשלת — אל תסיים כ-"done".**
@@ -323,7 +352,7 @@ FROM documents d WHERE d.case_id = '{case_id}' AND d.doc_type IN ('appeal', 'res
### העֵר את העוזר המשפטי (CEO) — חובה!
wakeup ל-CEO עם `payload.issueId=$PAPERCLIP_TASK_ID` ו-`reason="מנתח משפטי סיים $PAPERCLIP_TASK_ID בסטטוס done/blocked"` — הפרוטוקול המלא (CEO לפי חברה, אזהרות) במקור היחיד [HEARTBEAT.md](HEARTBEAT.md) §4ג. **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
wakeup ל-CEO עם `payload.issueId=$PAPERCLIP_TASK_ID` ו-`reason="מנתח משפטי סיים $PAPERCLIP_TASK_ID בסטטוס done/blocked"` — הפרוטוקול המלא (CEO לפי חברה, אזהרות) במקור היחיד [HEARTBEAT.md](HEARTBEAT.md) §4ג.
**⚠️ `$PAPERCLIP_TASK_ID` — זה UUID, לא CMP-XX.** מוגדר אוטומטית ע"י Paperclip; ב-double-quotes bash מרחיב לערך האמיתי. שגיאת `invalid input syntax for type uuid` = שלחת CMP-XX במקום UUID.
## מבנה הפלט המלא — analysis-and-research.md
@@ -347,7 +376,18 @@ wakeup ל-CEO עם `payload.issueId=$PAPERCLIP_TASK_ID` ו-`reason="מנתח מ
1. ...
## 5. טענות סף
[אם קיימות — כולל שאלות משפטיות + עמדת ועדת הערר לכל טענה]
[אם אין — "לא זוהו טענות סף." אם יש — טענה אחת לכל H3, במבנה הזה:]
### ס-1 — [כותרת הטענה] ([מי מעלה])
**טענה (claim):** ...
**תשובה (response):** ...
**שאלה משפטית:** ...
**עמדת ועדת הערר:**
[ימולא ע"י יו"ר הוועדה]
### ס-2 — ...
**תקן ביקורת:** [שיקול דעת עצמאי / בחינת תקינות השומה / אחר]

View File

@@ -41,6 +41,7 @@ tools:
- mcp__legal-ai__halacha_corroboration
- mcp__legal-ai__corroboration_rebuild
- mcp__legal-ai__extract_appraiser_facts
- mcp__legal-ai__get_appraiser_facts
- mcp__legal-ai__extract_plans
- mcp__legal-ai__plan_get
- mcp__legal-ai__plan_search
@@ -704,6 +705,8 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
```
⚠️ אם מחזיר `status="sides_missing"` → דווח לחיים שאין תיוג `appraiser_side` במסמכי השומה (`document_update` עם `appraiser_side` בערכים `committee`/`appellant`/`deciding`). עצור עד שיתוקן.
אחרי החילוץ — הרץ `mcp__legal-ai__get_appraiser_facts(case_number="...")` כדי **לקרוא בחזרה** ולוודא שהעובדות אכן נשמרו. חילוץ שדיווח הצלחה אך לא נקרא בחזרה אינו ראיה שהנתונים שם.
אם הטבלה כבר מלאה — `write_interim_draft` ידלג על ההרצה אוטומטית, אז גם בלי הצעד הזה זה יעבוד.
3. **כתיבת 5 הבלוקים:**
@@ -820,45 +823,38 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
---
**תבנית issue לכותב ההחלטה — חובה בכל issue שמוקצה לכותב:**
**מסמך-ההכוונה לכותב — הפק את התדריך שהיית רוצה לקבל, לא טופס למילוי:**
כל issue לכותב חייב לכלול את **כל** הסעיפים הבאים. אסור לשלוח issue עם משפט כמו "הועבר לכתיבה" — זה חסר תועלת. הכותב צריך הכל מוכן מראש.
כשאתה מעביר תיק לכותב אתה מבצע את **פעולת-ההיסק המרכזית שלך**: להמיר את ניתוח-המנתח + הכרעות-היו"ר למסמך שמאפשר לכותב לנסח החלטה חדה בסגנון דפנה **בלי לחזור אליך**. אל תמלא טופס — הפעל שיפוט משפטי. תדריך טוב:
- **מוביל בהכרעה ובסוגיה המכריעה** — קבע איזו סוגיה נושאת את התוצאה ומה מייתר את מה, והצב אותה ראשונה.
- **בונה כל סוגיה כסילוגיזם** (כלל → עובדות → מסקנה) עם התקדים והמסמך הספציפיים.
- **מזהה אדנים עצמאיים** — אם יותר מנימוק אחד מספיק לבדו לתוצאה, אמור זאת מפורשות, כך שנפילת אדן בערעור לא תפיל את ההחלטה.
- **בודק עקביות פנימית** — אם שתי הכרעות עלולות להיראות סותרות (למשל דחיית טענה פרשנית אחת וקבלת אחרת), סמן את המתח והסבר את האבחנה לפני שעורך-דין יטען לו.
- **עונה לנקודה החזקה של הצד המפסיד** — לא מתעלם ממנה.
- **משקלל את הכרעות-היו"ר** ומעביר אותן מילולית.
**מה התדריך חייב להכיל** (החוזה מול הכותב — אל תשמיט אף רכיב; אל תשלח issue עם "הועבר לכתיבה"):
```markdown
## הנחיות כתיבה — ערר {case_number}
### 1. תוצאה ומצב
- **תוצאה:** {דחייה / קבלה חלקית / קבלה מלאה}
- **טיוטה קיימת:** {כן/לא}. אם כן: נתיב מלא לקובץ + הנחיה "קרא את הטיוטה, השתמש בה כבסיס, אל תכתוב מאפס"
- **הוראות עריכה מתוך הטיוטה:** {רשימה מדויקת של מה חיים ביקש לשנות — פסקאות, תוכן, placeholders}
- **תוצאה:** {דחייה / קבלה חלקית / קבלה מלאה} — עם נימוק קצר ומהי הראיה הניצחת.
- **טיוטה קיימת:** {כן/לא}. אם כן: נתיב מלא + "קרא, השתמש כבסיס, אל תכתוב מאפס".
- **הוראות עריכה מהטיוטה:** {מה חיים ביקש לשנות — פסקאות, תוכן, placeholders}.
### 2. סדר סוגיות + מבנה סילוגיסטי
לכל סוגיה שצריך לכתוב/לערוך — מבנה סילוגיסטי מלא:
**סוגיה N: {כותרת}**
- סוג ניתוח: {כלל ברור / איזון אינטרסים / מידתיות / שיקול דעת}
- כלל (הנחה עליונה): {הוראת תכנית / סעיף חוק / הלכה — ציטוט מדויק}
- עובדות (הנחה תחתונה): {העובדות הספציפיות שצריך להחיל — הפנייה למסמך מקור ספציפי}
- מסקנה: {מה נובע מהחלת הכלל על העובדות}
- תקדימים: {שם פסק דין + מה הוא קובע + למה רלוונטי}
- מסמכי מקור: {שמות קבצים ספציפיים ב-data/cases/{case_number}/documents/originals/}
### 2. סוגיות — סדר סילוגיסטי, המכריעה מובילה
לכל סוגיה: סוג-ניתוח (כלל ברור / איזון / מידתיות / שיקול-דעת) · כלל (ציטוט מדויק של הוראת-תכנית/חוק/הלכה) · עובדות (בהפניה למסמך-מקור ספציפי) · מסקנה · תקדימים (שם + מה קובע + רלוונטיות) · מסמכי-מקור (ב-data/cases/{case_number}/documents/originals/). סמן אדנים עצמאיים, מוקשי-עקביות ומענה לצד המפסיד היכן שהם קיימים.
### 3. טיפול בטענות
| # | טענה | טיפול | סוגיה |
|---|------|-------|-------|
| 1 | {טענה} | דיון מלא / קיבוץ / דילוג | {באיזו סוגיה} |
...
טבלה: # | טענה | טיפול (דיון מלא / קיבוץ / דילוג) | סוגיה.
### 4. chair directions
- העתק מלא של עמדות הוועדה מ-analysis-and-research.md (או הפנייה: "קרא get_chair_directions").
- **עטוף את ההעתק המילולי בתגית `<chair_directions>…</chair_directions>`** — כך הכותב מבחין בין הוראות-היו"ר המילוליות לבין הערות ה-CEO, ואינו דורס אותן. בתוך התגית: טקסט מילולי בלבד, בלי פרפרזה.
### 4. הנחיות-היו"ר
העתק מילולי של עמדות-הוועדה מ-analysis-and-research.md (או "קרא get_chair_directions"), **עטוף ב-`<chair_directions>…</chair_directions>`** — טקסט מילולי בלבד בלי פרפרזה, כדי שהכותב לא ידרוס אותן.
### 5. הנחיות סגנון
- ניטרליות: בלוק ו = עובדות בלבד, בלי ציטוטים מצדדים
- ללא כפילות: בלוק י מפנה לבלוקים קודמים
- טענות מקוריות: בלוק ז = כתבי טענות מקוריים
- אורך מינימלי לדיון: 1,500 מילים לבלוק י
- פסיקה: חובה לצטט לפחות 3 תקדימים בדיון
ניטרליות (בלוק ו = עובדות בלבד, בלי ציטוטי-צדדים) · ללא כפילות (בלוק י מפנה לקודמים) · טענות מקוריות (בלוק ז) · דיון ≥ 1,500 מילים · ≥ 3 תקדימים בדיון.
```
---

View File

@@ -96,7 +96,7 @@ tools:
"id": "r1",
"type": "insert_after", // או insert_before, replace, delete
"anchor_bookmark": "block-yod", // מ-list_bookmarks
"content": "וכך נפסק בעניין פלוני. בבג\"ץ 1234/21 קבע השופט...",
"content": "<תוכן ההוספה — טקסט עברי נקי, בלי ציטוט מומצא>",
"style": "body", // או heading, quote
"reason": "הוספת פסק הלכה שחסר לפי בקשת יו\"ר"
}
@@ -128,7 +128,7 @@ tools:
בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO לפי חברה).** בקצרה: PATCH סטטוס `done` (הצלחה) או `blocked` (כשל/פלט-חסר), ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="מייצא טיוטה סיים [issue-id] בסטטוס [done/blocked]"`. **אסור** `done` עם פלט חסר; **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO).** בקצרה: PATCH סטטוס `done`/`blocked`, ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="מייצא טיוטה סיים [issue-id] בסטטוס [done/blocked]"`.
## כללים קריטיים

View File

@@ -96,5 +96,4 @@ tools:
בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO לפי חברה).** בקצרה: PATCH סטטוס `done` (הצלחה) או `blocked` (כשל / markers `[?]` רבים), ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="מגיה סיים [issue-id] בסטטוס [done/blocked]"`. **אסור** `done` עם פלט חסר; **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO).** בקצרה: PATCH סטטוס `done`/`blocked` (`blocked` אם markers `[?]` רבים), ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="מגיה סיים [issue-id] בסטטוס [done/blocked]"`.

View File

@@ -17,6 +17,7 @@ tools:
- mcp__legal-ai__search_precedent_library
- mcp__legal-ai__search_internal_decisions
- mcp__legal-ai__precedent_library_get
- mcp__legal-ai__precedent_library_list
- mcp__legal-ai__precedent_list
- mcp__legal-ai__halacha_review
---
@@ -56,6 +57,8 @@ tools:
## 7 בדיקות
> **סף-הקבלה המשותף — `docs/qa-acceptance-rubric.md`:** 7 הבדיקות למטה הן האכיפה המפורטת שלו (מקור-אמת אחד; הכותב מכוון לאותו rubric). בדיווח — מפה כל `fail` לחוסם/אזהרה שם.
### 1. שלמות מבנית (structural_integrity)
- כל בלוקי חובה קיימים (ה עד יא)
- מספור רציף ללא קפיצות
@@ -252,4 +255,4 @@ new → processing → documents_ready → analyst_verified → research_complet
בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO לפי חברה).** בקצרה: PATCH סטטוס `done` (הצלחה) או `blocked` (כשל/פלט-חסר), ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="בודק איכות סיים [issue-id] בסטטוס [done/blocked]"`. **אסור** `done` עם פלט חסר; **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO).** בקצרה: PATCH סטטוס `done`/`blocked`, ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="בודק איכות סיים [issue-id] בסטטוס [done/blocked]"`.

View File

@@ -410,7 +410,7 @@ python3 /home/chaim/legal-ai/scripts/notify.py \
בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO לפי חברה).** בקצרה: PATCH סטטוס `done` (הצלחה) או `blocked` (כשל/פלט-חסר), ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="חוקר תקדימים סיים [issue-id] בסטטוס [done/blocked]"`. **אסור** `done` עם פלט חסר; **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO).** בקצרה: PATCH סטטוס `done`/`blocked`, ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="חוקר תקדימים סיים [issue-id] בסטטוס [done/blocked]"`.
## כללים
- **דיוק** — ציין מספרי סעיפים, תאריכים, שמות שופטים

View File

@@ -53,6 +53,8 @@ tools:
## לפני שאתה מתחיל — קרא!
> **המטרה שלך — `docs/qa-acceptance-rubric.md`:** זהו סף-הקבלה שה-QA יאכוף. כתוב כדי לעבור אותו מהפעם הראשונה, ולפני מסירה ל-CEO עבור על החוסמים ואשר שכל אחד מתקיים.
### חובה לפני כל כתיבה — נקודת ההתחלה:
0. **עץ ההחלטה: `docs/daphna-decision-tree.md`****כלי הפעולה היומיומי**. מאחד את כל המסמכים לתהליך אנליטי קצר: מהי הראיה הניצחת? איזה ארכיטקטורה? איזה מוד פתיחה? איזה אורך? **תמיד להתחיל כאן** — המסמך מצביע איזה מסמך אחר לקרוא לפי השאלה.
@@ -220,18 +222,13 @@ case_update(case_number, status="drafted")
בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO לפי חברה).** בקצרה: PATCH סטטוס `done` (הצלחה) או `blocked` (כשל/פלט-חסר), ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="כותב החלטה סיים [issue-id] בסטטוס [done/blocked]"`. **אסור** `done` עם פלט חסר; **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO).** בקצרה: PATCH סטטוס `done`/`blocked`, ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="כותב החלטה סיים [issue-id] בסטטוס [done/blocked]"`.
**אם לא תעדכן סטטוס ל-drafted — בודק האיכות לא יוכל לרוץ!**
## בלוק י — דיון (הבלוק החשוב ביותר)
**קריאת חובה לפני כתיבה (5 מסמכים)**:
1. `docs/daphna-voice-fingerprint.md` — קבועים, פעלי "אנחנו", אנטי-דפוסים
2. `docs/daphna-precedent-network.md` — לכל סוגיה משפטית, איזה תקדם
3. `docs/daphna-architecture-by-outcome.md` — מבנה לפי תוצאה + עץ החלטה
4. `docs/daphna-acceptance-architecture.md` — **חובה אם תוצאה צפויה: קבלה (לא חלקית).** חמש תבניות מובחנות
5. `docs/voice-1130-25.md` — דוגמה עמוקה
> קרא קודם את **"חובה לפני בלוק י (חמישיית הקול)"** שלמעלה — חמשת מסמכי-הקול.
**עץ החלטה לבחירת ארכיטקטורה**:
1. מה התוצאה?

View File

@@ -0,0 +1,25 @@
name: INV-AG3 Agent Tool Grants
# Hard gate for INV-AG3 (docs/spec/X4-agents.md §2א): a subagent's `tools:`
# frontmatter is a CLOSED allow-list, so any MCP tool an agent is TOLD to run —
# by the backend delegation in web/, or by its own instructions — must appear
# there. Built after analyze_protocol shipped without a grant (2026-06-30) and
# the analyst was handed an issue instructing it to run a tool it could not
# call (CMP-229, 2026-08-04). Pure-stdlib check (no venv) — fast, runs on every
# PR and on push to main.
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
agent-tool-grants:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: INV-AG3 — agent tool-grant guard
run: python3 scripts/agent_tool_grants_guard.py

View File

@@ -1,6 +1,6 @@
# עוזר משפטי — Legal Decision Assistant
> **אינדקס דק.** הכללים הקריטיים נמצאים כאן; העומק התפעולי (Deploy, Paperclip-ops, adapters, מבנה-תיקיות, Chair-Feedback, TaskMaster מלא) הוצא ל-[`docs/operations-runbook.md`](docs/operations-runbook.md) כדי לרזות את ההקשר הנטען בכל סשן.
> **אינדקס דק.** הכללים הקריטיים נמצאים כאן; העומק התפעולי (Deploy, Paperclip-ops, adapters, מבנה-תיקיות, Chair-Feedback, עבודה מול Gitea Issues) הוצא ל-[`docs/operations-runbook.md`](docs/operations-runbook.md) כדי לרזות את ההקשר הנטען בכל סשן.
## רקע הפרויקט
@@ -64,7 +64,8 @@
| [`.claude/agents/HEARTBEAT.md`](.claude/agents/HEARTBEAT.md) | checklist הפעלת סוכן — routing, company filtering, quirks, wakeup עם UUID נכון | **לפני כל עבודה על סוכנים** |
| [`skills/dafna-decision-template/SKILL.md`](skills/dafna-decision-template/SKILL.md) | export DOCX לפי styles של תבנית Word של דפנה — line classification, dash policy, placeholder handling | לפני export DOCX |
| [`docs/corpus-graph.md`](docs/corpus-graph.md) | **מפת הקורפוס** (`/graph`) — גרף ציטוטים אינטראקטיבי נייטיב; 6 שכבות (פסיקה/נושא/תחום/הלכות/חוסרי‑מחקר/יומונים), אנליטיקה (PageRank/אשכולות), endpoints, ואיך מוסיפים שכבה | לפני עבודה על דף `/graph` או `web/graph_api.py` |
| [`docs/operations-runbook.md`](docs/operations-runbook.md) | **עומק תפעולי** — Deploy (Coolify/pm2), Paperclip-ops מלא (wakeup, sync, webhook, scheduled jobs, adapters), מבנה-תיקיות, Chair-Feedback, TaskMaster | לפני עבודה על Deploy / אינטגרציית-Paperclip / adapters |
| [`docs/taskmaster-to-gitea-migration.md`](docs/taskmaster-to-gitea-migration.md) | **מיגרציית ניהול-המשימות** — TaskMaster→Gitea Issues (2026-08-05), טבלת מיפוי מלאה, ומלכודת המספור בהפניות `(#N)` ישנות | כשנתקלים בהפניה `(#N)` בקומיט ישן, או כשמחפשים משימה שהייתה ב-TaskMaster |
| [`docs/operations-runbook.md`](docs/operations-runbook.md) | **עומק תפעולי** — Deploy (Coolify/pm2), Paperclip-ops מלא (wakeup, sync, webhook, scheduled jobs, adapters), מבנה-תיקיות, Chair-Feedback, Gitea Issues API | לפני עבודה על Deploy / אינטגרציית-Paperclip / adapters |
---
@@ -114,7 +115,7 @@ cd ~/legal-ai && claude --worktree <slug> # או, בתוך סשן: "עבוד
6. **אל תיגע** בשינויים לא-מתויקים שאינם שלך בעץ הראשי — הם של סשן אחר. אם העץ הראשי על ענף זר — אל תתייק עליו.
> **בידוד-DB:** ה-worktree מבודד-קבצים בלבד — לא בידוד-repo ולא בידוד-DB. **אל תריץ migrations מ-2 worktrees במקביל** על Postgres המשותף (`localhost:5433`) — סכמה שאף סשן לא מצפה לה ([Run agents in parallel](https://code.claude.com/docs/en/agents)).
> **סוכני Paperclip — אינם מבודדים (אומת 2026-06-06):** 14 מתוך 16 הסוכנים רצים על אדפטר `claude_local` הרשמי, שמריץ `claude -p` ב-`adapter_config.cwd=/home/chaim/legal-ai` **המשותף** — אין לו אופציית `worktreeMode`/`-w`. כלומר **כל סוכני Paperclip חולקים את עץ-העבודה הראשי**. הסיכון ממותן ע"י כלל הסשנים נתמך-הסביבה למעלה + תזמור סדרתי ע"י ה-CEO — **לא** ע"י בידוד-worktree per-agent. ניתוח מלא: TaskMaster `legal-ai` #104 (נסגר cancelled — "לתעד, לא לבדד").
> **סוכני Paperclip — אינם מבודדים (אומת 2026-06-06):** 14 מתוך 16 הסוכנים רצים על אדפטר `claude_local` הרשמי, שמריץ `claude -p` ב-`adapter_config.cwd=/home/chaim/legal-ai` **המשותף** — אין לו אופציית `worktreeMode`/`-w`. כלומר **כל סוכני Paperclip חולקים את עץ-העבודה הראשי**. הסיכון ממותן ע"י כלל הסשנים נתמך-הסביבה למעלה + תזמור סדרתי ע"י ה-CEO — **לא** ע"י בידוד-worktree per-agent. ניתוח מלא: ארכיון TaskMaster `legal-ai` #104 (נסגר cancelled — "לתעד, לא לבדד").
---
@@ -144,9 +145,36 @@ cd ~/legal-ai && claude --worktree <slug> # או, בתוך סשן: "עבוד
## כלל: עדכון `scripts/SCRIPTS.md`
בכל פעם שנוצר, נמחק, או משתנה סקריפט בתיקיית `scripts/`**חובה לעדכן את `scripts/SCRIPTS.md`** (תפקיד, סטטוס, החלפה).
## ניהול משימות — TaskMaster AI
**תמיד** TaskMaster (לא TASKS.md ידני). קובץ קנוני: `~/legal-ai/.taskmaster/tasks/tasks.json` (tags: `master`, `legal-ai`). פקודות: `get_tasks`, `next_task`, `add_task`, `update_task`, `expand_task`.
> **⚠️ מלכוד cwd ב-CLI:** `--tag` בוחר קבוצה *בתוך* הקובץ — לא לאיזה קובץ לכתוב (ה-CLI מאתר לפי cwd). תמיד `cd ~/legal-ai` לפני כל פקודה משנה, ואז אמת ב-MCP `get_tasks`. כשלא בטוחים — לערוך את הקובץ ישירות. פירוט: [`docs/operations-runbook.md`](docs/operations-runbook.md).
## ניהול משימות — Gitea Issues ⚠️
> **מקור-האמת לעבודה הוא [Gitea Issues](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues) — לא TaskMaster, לא הקובץ הזה, לא זיכרון.**
> **TaskMaster הוצא משימוש ב-2026-08-05.** 26 המשימות הפתוחות הועברו ל-issues #433#458 (מיפוי מלא: [`docs/taskmaster-to-gitea-migration.md`](docs/taskmaster-to-gitea-migration.md)). `.taskmaster/tasks/tasks.json` נשמר לקריאה-בלבד כארכיון היסטורי — **אין לכתוב אליו ואין ליצור בו משימות חדשות.**
המוסכמות מגיעות מ-[`infra/project-template`](https://gitea.marcus-law.co.il/infra/project-template) — אותה מערכת תיוג ואותו workflow כמו שאר פרויקטי המשרד.
### Session-Start Protocol
כשנפתח סשן עם "איפה אנחנו עומדים / מה נשאר / בוא נמשיך" — **אל תשחזר מצב מזיכרון.** קרא את ה-issues החיים קודם:
1. `GET /api/v1/repos/ezer-mishpati/legal-ai/issues?state=open` (טוקן מ-`~/.git-credentials` או Infisical `/apps/legal-ai` `GITEA_TOKEN`).
2. הבא בתור = ה-issue בעל העדיפות הגבוהה ביותר (`p0-blocker``p1-high``p2-normal``p3-low`; בתוך עדיפות — `type:bug` קודם).
3. **דלג** על `status:in-progress` (מישהו עובד), `status:in-review` (יש PR), `status:needs-decision` (ממתין להכרעת חיים/דפנה), `status:blocked`, `status:deferred`.
4. רק **אחרי** קריאת ה-issues — הצע את הצעד הבא.
### Issue-First
- **שום קוד בלי issue.** כל תקלה/צורך/משימה → issue קודם.
- כל issue חייב: **What & Why** + **Acceptance Criteria** + הקשר. issue ללא AC אינו `status:ready` ואינו נלקח.
- **Branch naming:** `<type>/<issue>-<slug>` (`feat`/`fix`/`chore`/`refactor`/`docs`/`test`) — יצירת branch = בלעדיות על ה-issue.
- ה-PR מפנה ל-issue (`Closes #N`) ומצהיר invariants (ראה [`.gitea/PULL_REQUEST_TEMPLATE.md`](.gitea/PULL_REQUEST_TEMPLATE.md)).
### מערכת התיוג
| שכבה | ערכים |
|------|-------|
| `type:` | `bug` · `feature` · `chore` · `spike` |
| `priority:` | `p0-blocker` · `p1-high` · `p2-normal` · `p3-low` |
| `status:` | `ready` · `in-progress` · `in-review` · `needs-decision` · `blocked` · `deferred` · `good-first-issue` |
| `size:` | `xs` (<חצי יום) · `s` (~יום) · `m` (2-3 ימים) · `l` (שבוע+ — לשקול פיצול) |
| `area:` | `agents` · `retrieval` · `extraction` · `writing` · `backend` · `frontend` · `mcp` · `infra` · `docs` · `ci` · `security` |
> ⚠️ **הפניות `(#N)` בהודעות-commit מלפני 2026-08-05 מתייחסות למזהי TaskMaster, לא למספרי issue.** מספרי ה-issue של legal-ai מתחילים ב-#433; מתחת לזה המספרים שייכים ל-PRs ולשני issues ישנים.
---

View File

@@ -17,7 +17,7 @@
| ezer-mishpati-web | ממשק העלאת מסמכים (Docker/Coolify) | `legal-ai.nautilus.marcusgroup.org` |
| Paperclip | סוכן AI — מריץ Claude Code agents (pm2, מקומי) | `localhost:3100` |
| legal-chat-service | גשר claude CLI לטאב הצ'אט ב-/training (pm2, loopback) | `127.0.0.1:8770` |
| Infisical | ניהול סודות | `secret.dev.marcus-law.co.il` |
| Infisical | ניהול סודות — פרויקט **All Infrastructure** (`2c462576-b125-4279-b0ec-7220dbf51ccf`), env `main`. סודות המערכת מפוזרים על שלוש תיקיות-אחיות: `/apps/legal-ai` · `/apps/paperclip` · `/apps/paperclip-hermes` | `secret.marcus-law.co.il` |
---
@@ -195,9 +195,33 @@ CEO שמתעורר מ-`weekly-feedback-job` כותב לקובץ בלבד — **
---
## ניהול משימות — TaskMaster AI (פירוט)
## ניהול משימות — Gitea Issues (פירוט)
- קובץ המשימות הקנוני: `~/legal-ai/.taskmaster/tasks/tasks.json` (יחסי ל-project root, **לא** `~/.taskmaster/tasks/tasks.json`). מכיל את כל ה-tags של legal-ai (`master`, `legal-ai`).
- פקודות עיקריות: `get_tasks`, `next_task`, `add_task`, `update_task`, `expand_task`
- לפני התחלת עבודה → `next_task`; אחרי סיום → `update_task` עם status=done; משימה מורכבת → `expand_task`
- **⚠️ מלכוד cwd ב-CLI:** הדגל `--tag` בוחר קבוצה לוגית *בתוך* הקובץ — הוא **לא** בוחר לאיזה `tasks.json` לכתוב. ה-CLI מאתר את הקובץ לפי ה-cwd. תמיד `cd ~/legal-ai` לפני `task-master add-task` או כל פקודה משנה, ואז אמת ב-MCP `get_tasks`. כשלא בטוחים — לערוך את `~/legal-ai/.taskmaster/tasks/tasks.json` ישירות.
> **TaskMaster AI הוצא משימוש ב-2026-08-05.** מקור-האמת לעבודה הוא
> [Gitea Issues](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues).
> ה-workflow, מערכת התיוג ו-Session-Start Protocol מתועדים ב-[`CLAUDE.md`](../CLAUDE.md);
> טבלת המיפוי TaskMaster→issue ב-[`taskmaster-to-gitea-migration.md`](taskmaster-to-gitea-migration.md).
### עבודה מול ה-API
הטוקן: `~/.git-credentials` (שורת `gitea.nautilus`) או Infisical `/apps/legal-ai` `GITEA_TOKEN`.
```bash
API=https://gitea.nautilus.marcusgroup.org/api/v1/repos/ezer-mishpati/legal-ai
T=$(grep -m1 'gitea.nautilus' ~/.git-credentials | sed 's|.*://[^:]*:\([^@]*\)@.*|\1|')
# מה הבא בתור (דילוג על in-progress/in-review/blocked/deferred/needs-decision)
curl -s -H "Authorization: token $T" "$API/issues?state=open&labels=priority:p1-high"
# issue חדש
curl -s -X POST -H "Authorization: token $T" -H "Content-Type: application/json" \
"$API/issues" -d '{"title":"...","body":"## What & Why\n...\n## Acceptance Criteria\n...","labels":[<ids>]}'
# תוויות (id ← שם)
curl -s -H "Authorization: token $T" "$API/labels?limit=100"
```
### ארכיון TaskMaster
`~/legal-ai/.taskmaster/tasks/tasks.json` **מוקפא לקריאה-בלבד** — 166 משימות שהושלמו,
4 שבוטלו, ו-26 הפתוחות שהועברו. שימושי לחפירה היסטורית ("למה עשינו X"), אבל
**אין לכתוב אליו**. אם צריך הקשר ממשימה ישנה — לחפש בקובץ הזה, ולפתוח issue חדש
אם נדרשת עבודה.

View File

@@ -0,0 +1,57 @@
# Rubric קבלה של החלטה — מקור-אמת אחד
מסמך זה הוא **הגדרת ה"מוכן"** של טיוטת החלטה: סף-הקבלה שהכותב **מכוון אליו מראש**
וה-QA **אוכף** כשער. שני הסוכנים מפנים לכאן — אין לשכפל את הקריטריונים בקבצי-הסוכן.
**זהו סף-הקבלה, לא הוראת-הכתיבה.** ה"איך" המלא חי במסמכי-הקול המופנים בכל שורה
(`daphna-voice-fingerprint.md`, `daphna-block-zayin-claims.md`,
`daphna-architecture-by-outcome.md`, `daphna-acceptance-architecture.md`,
`decision-methodology.md`, `anti-hallucination-gate.md`). כאן — רק המבחן.
> **לכותב:** לפני מסירה ל-CEO, עבור על החוסמים למטה ואשר שכל אחד מתקיים. טיוטה
> שלא עוברת חוסם אחד — תוחזר ב-`needs_revision`. כתוב כדי לעבור מהפעם הראשונה.
>
> **ל-QA:** אלה הקריטריונים ש-7 הבדיקות שלך אוכפות. `fail` על חוסם = `qa_review`;
> `fail` על אזהרה = דיווח לחיים, לא חוסם.
---
## חוסמי ייצוא (critical — כל אחד מכשיל את הטיוטה)
1. **שלמות מבנית** — בלוקים ה–יא קיימים; מספור רציף ללא קפיצות/כפילות; "להלן" בשימוש ראשון.
2. **רקע ניטרלי (בלוק ו)** — עובדות בלבד; **אפס** ציטוטים מצדדים; **אפס** מילות-שיפוט
("חריג", "בעייתי", "מגוחך", "פגום", "שערורייתי").
3. **כיסוי טענות** — כל טענה מהותית מבלוק ז קיבלה מענה בבלוק י (ישיר / קיבוץ / ציון שנבחנה).
טענות `[skip]` לא נספרות; `[bundle]` נבדקות כקבוצה. טענה מהותית לא-מסומנת שלא נענתה = כשל.
4. **מתודולוגיה** — לכל סוגיה מבנה סילוגיסטי (כלל+עובדות+מסקנה); ממצא עובדתי מופרד ממסקנה
משפטית; טענת הצד המפסיד הוצגה בחוזקתה (Steel-Man); איזון מפורש כשנדרש; בלי "נוסחאות ריקות";
ציטוטים בסנדוויץ' (הקדמה→ציטוט→ניתוח).
5. **קול דפנה**
- בלוק ז: כותרת **"תמצית טענות הצדדים"**; כותרת-משנה לכל צד; בלי רשימה ממוספרת בפסקה;
בלי מילות-הערכה; בלי גילוי-מסקנה; קול פעיל ("העורר טוען").
- בלוק י: כותרת **"דיון והכרעה"**; קול "אנחנו" תפקידי ("מצאנו", לא "הוועדה מוצאת");
דפוס **"אכן... אולם"** לטענות שנדחות; בלי מספור-פסקאות סדרתי; ציטוטי פסיקה במלואם (415 שורות);
"למעלה מן הצורך" לטיעונים מרכזיים; בלי תוצאת הכל-או-לא-כלום כשיש טענות מהותיות משני הצדדים.
- 8xxx עם הכרעה שמאית → ציטוט **בר"מ 3644/13** קיים.
6. **עיגון-מקור (INV-AH)** — כל אזכור פסיקה/חוק/הלכה/מספר מעוגן-מקור עם ציטוט; ציטוט חיצוני
תואם להלכה מאושרת בקורפוס (`search_precedent_library`). אזכור לא-מעוגן = הסר או הורד ל-`[ספקולציה]`.
7. **צירוף פסיקה ל-DB** — כל ציטוט בבלוק י (חיצוני/internal) רשום ב-`case_precedents` דרך
`precedent_attach`. ציטוט שקיים רק בטקסט ולא ב-DB = חוסם (ה-exporter ו-Hermes קוראים מה-DB).
8. **log שאילתות-קורפוס**`analysis-and-research.md` §7א ו-`precedent-research.md` §ז קיימים,
עם מינימום queries לקורפוס הסמכותי (`analyst ≥ טענות-סף + סוגיות`; `researcher ≥ סוגיות`)
ותיעוד negative-evidence (גם 0-results).
## אזהרות (warning — מדווח לחיים, לא חוסם)
- **משקלות בטווח:** ו 1540% · ז 2040% · י 3250% · יא 29%.
- **בלי כפילות:** בלוק י מפנה לבלוקים קודמים ("כאמור"), לא חוזר על עובדות/טענות.
- **מראה-מקום מלא בציטוטים:** מספר-תיק מלא + ערכאה + תאריך/`פורסם בנבו` (+ `page_reference` לציטוט ארוך).
## אם תוצאה = קבלה (מ-`daphna-acceptance-architecture.md`)
- התבנית הנבחרת (A/B/C/D/E) מתאימה לסיבת-הקבלה, ופורמט-הסיום תואם לתבנית
(A: "מתבטלת" + הודאת-צד-נגדי; B: "תיקבע לדיון"; C: "בכפוף לתיקונים" + הכרה בוועדה;
D: "דרישת התשלום בטלה"; E: "השומה תושב לתיקון").
---
> **מקור-אמת:** חומרת-הבדיקות המחייבת חיה ב-`.claude/agents/legal-qa.md` ("7 בדיקות" + טבלת-חומרה)
> וב-`docs/spec/05-qa-review.md`. Rubric זה הוא התמצית המשותפת — כשמשתנה שער QA, עדכן שם ואת התמצית כאן.

View File

@@ -146,9 +146,29 @@ another company`, [X2 §2](X2-multi-company.md)).
**כלל:** ה-frontmatter `tools:` של כל סוכן מעניק **בדיוק** את הכלים שהוראותיו דורשות — כל כלי שההוראות
מצריכות מוענק, וכלי שמוענק-ולא-בשימוש נבחן. מופע של [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)
(שערים מוגדרים) ו-[G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים); מקביל ל-[X9 INV-TOOL6](X9-mcp-tool-contract.md).
**מקור-סמכות:** frontmatter `tools:` מול ה-instructions בקבצי-[.claude/agents/](../../.claude/agents/). (פרויקטלי-תפעולי.)
**אכיפה:** בדיקת-עקביות tools↔instructions (FU-13 ✅ 2026-06-06). אכיפה אוטומטית עתידית — בתת-פרויקט 5 (spec-guardian).
**הפרה ידועה:** — (טופל ב-FU-13: legal-analyst קיבל `aggregate_claims_to_arguments`; researcher כבר היה תקין; `extract_references`/`extract_internal_citations` הם מטלת-researcher, לא analyst — ראה §2א).
> **ה-frontmatter הוא allow-list סגורה.** כלי הרשום בשרת-ה-MCP אך חסר מהרשימה **אינו ניתן לקריאה**
> ע"י הסוכן — גם כשהשרת מחובר לחלוטין. הסוכן חווה זאת כ"הכלים לא נחשפים לסשן", ובלי הבנת המנגנון
> הוא נוטה **לעקוף** (SQL ישיר, סקריפט מקומי) במקום לדווח — וזה מסלול מקביל, כלומר הפרת G2.
> **"מורים להריץ" כולל את ה-backend:** טקסט של issue שנוצר ב-`web/` ומכיל `mcp__legal-ai__X` הוא
> הוראה לכל דבר, ולכן מחייב הענקה.
**מקור-סמכות:** frontmatter `tools:` מול ה-instructions בקבצי-[.claude/agents/](../../.claude/agents/)
**ומול הוראות-ה-backend** ב-`web/`. (פרויקטלי-תפעולי.)
**אכיפה:****אוטומטית מ-2026-08-04** — [`scripts/agent_tool_grants_guard.py`](../../scripts/agent_tool_grants_guard.py),
שער-CI קשיח ([`.gitea/workflows/agent-tool-grants.yaml`](../../.gitea/workflows/agent-tool-grants.yaml)),
בדפוס [`leak_guard.py`](../../scripts/leak_guard.py) של G12. ארבעה כללים: (1) כל `mcp__legal-ai__X`
ב-`web/` מוענק לסוכן כלשהו · (2) כל `mcp__legal-ai__X` בגוף קובץ-סוכן מוענק **באותו** קובץ ·
(3) אין הענקה לכלי שאינו רשום בשרת · (4) שם-כלי בגרשיים-הפוכים ללא תחילית — מוענק, או מסווג
מפורשות ב-`CONTRASTIVE_OK` (אזכור ניגודי / מטלת-סוכן-אחר / שם-עמודה מתנגש). לא-סוכנים ולכן
מוחרגים: `hermes-curator.md` ו-`legal-analyst-gemini-critique.md` (בלי frontmatter בכוונה —
האדפטר שולח פרומפט גולמי) ו-`HEARTBEAT.md` (checklist משותף).
**הפרה ידועה:** — (היסטוריה: FU-13 ✅ 2026-06-06 — `aggregate_claims_to_arguments` ל-analyst;
`extract_references`/`extract_internal_citations` הם מטלת-researcher, ראה §2א. **הישנות 2026-08-04**
— האכיפה הידנית לא החזיקה: `analyze_protocol` (24e3e2f, 2026-06-30) נרשם בשרת בלי הענקה, ו-#226
הוסיף delegation שמורה למנתח להריץ אותו → CMP-229 נשרף בשתי הרצות ועקף ל-SQL ידני. נסגר יחד עם
`get_protocol_analysis`/`get_legal_arguments`/`get_appraiser_facts` ו-`precedent_library_list` ל-QA,
והאכיפה הועברה ל-CI כדי שלא תישען שוב על משמעת ידנית.)
### INV-AG4: שער שטן-מליץ — red-team לידים לא-סמכותיים תחת אישור-יו
**כלל:** אחרי שלב-הניתוח (`analysis-and-research.md` תקין) וב**לפני** הפעלת הכותב, ה-CEO מפעיל

View File

@@ -0,0 +1,61 @@
# מיגרציה: TaskMaster → Gitea Issues
**תאריך: 2026-08-05.** ניהול המשימות של legal-ai עבר מ-TaskMaster AI ל-[Gitea Issues](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues), לפי המוסכמות של [`infra/project-template`](https://gitea.marcus-law.co.il/infra/project-template).
## מה השתנה
- **מקור-האמת לעבודה = Gitea Issues.** לא TaskMaster, לא `CLAUDE.md`, לא זיכרון.
- **`.taskmaster/tasks/tasks.json` הוקפא** — נשמר לקריאה-בלבד כארכיון היסטורי (166 משימות שהושלמו + 4 שבוטלו). אין לכתוב אליו ואין ליצור בו משימות חדשות.
- **26 המשימות הפתוחות** הועברו ל-issues **#433#458**, כל אחת עם `type` / `priority` / `area` / `status`, וגוף במבנה *What & Why → הקשר → Acceptance Criteria*.
- **30 תוויות** נוצרו בריפו: 5 שכבות (`type`/`priority`/`status`/`size`/`area`). שכבות `type`/`priority`/`status`/`size` זהות לתבנית; שכבת `area` הותאמה לתחומי legal-ai.
## ⚠️ מלכודת מספור
ב-Gitea, issues ו-PRs חולקים מרחב-מספרים אחד. מספרי ה-issue של המיגרציה מתחילים ב-**#433** כי #1#432 כבר תפוסים (2 issues ישנים + כל ה-PRs).
**לכן: כל הפניה `(#N)` בהודעת-commit מלפני 2026-08-05 מתייחסת למזהה TaskMaster — לא למספר issue.** למשל `fix(retrieval): ... (#232)` מפנה ל-TaskMaster #232, בעוד #232 ב-Gitea הוא PR ישן. הטבלה למטה היא הגשר.
## טבלת המיפוי
| TaskMaster | Gitea | תוויות | כותרת |
|---|---|---|---|
| #14 | [#433](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/433) | type:chore · priority:p3-low · status:deferred | Upgrade: speed up halacha+metadata extraction |
| #116 | [#434](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/434) | type:chore · priority:p3-low · status:deferred | P2 (אופציונלי) — resume תוך-פאנל: פירוק הפאנל ל-map per-item |
| #121 | [#435](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/435) | type:spike · priority:p1-high · status:needs-decision | מיפוי כל שערי-האישור-האנושי (חיים/דפנה) והכרעה פר-מקום: להחליף ב"קונצנזוס תלת-מודלי ללא-אדם"? |
| #124 | [#436](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/436) | type:feature · priority:p3-low · status:ready | קטלוג עלון חודשי "עו"ד על נדל"ן" — פיצול ל-N רשומות digest (design-open) |
| #125 | [#437](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/437) | type:chore · priority:p1-high · status:in-progress | אופרציונליזציה של לולאת רכישת-הסגנון — סגירת ממצאי אודיט #122 |
| #126 | [#438](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/438) | type:chore · priority:p2-normal · status:in-progress | ניקוי persona "Hermes" → Curator + כיבוי self-learning אינרטי בפרופילי ה-curator |
| #128 | [#439](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/439) | type:chore · priority:p2-normal · status:ready | X14 finalize: ניקוי-דיסק אחרי soak + replication off-site מתוזמן (SNSD) |
| #129 | [#440](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/440) | type:chore · priority:p2-normal · status:ready | INV-STG1 Phase 2: read-wire ה-pipeline ל-ensure_local → כתיבת-בלוב ל-S3 בלבד (ללא דיסק) |
| #147 | [#441](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/441) | type:bug · priority:p3-low | חילוץ-הלכות: אטומיות-מלאה ל-force re-extract (extract-then-swap / staging_run_id) |
| #150 | [#442](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/442) | type:feature · priority:p2-normal | משיכת פרטי-תב"ע אוטומטית למרשם-התכניות (V38) + עיון בתכניות-מאושרות |
| #154 | [#443](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/443) | type:feature · priority:p1-high | X11 Phase 2 — חיווט סמכות-תקדים מודעת-טיפול לסוכני-המחקר |
| #155 | [#444](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/444) | type:bug · priority:p1-high | באג עמידות: ריצת-heartbeat כפולה על אותו issue דורסת טיוטת-קובץ תוך כדי כתיבה |
| #163 | [#445](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/445) | type:bug · priority:p2-normal | חקירת אנומליית chunking — פסיקות עליון ארוכות מקבלות chunk יחיד → 0 הלכות |
| #209 | [#446](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/446) | type:bug · priority:p3-low | Plugin status-sync מכוון ל-issue-ילד ומחזיר done→in_progress |
| #217 | [#447](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/447) | type:chore · priority:p2-normal | בלוק-י: צמצום-הקשר ממוקד-רלוונטיות (איכות + מניעת-דחיסה) |
| #218 | [#448](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/448) | type:feature · priority:p1-high · status:in-progress | Escalation primitive מדורג-חומרה בשער-הפלטפורמה (השראת Gastown gt escalate) |
| #219 | [#449](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/449) | type:feature · priority:p1-high · status:in-progress | טלמטריה מובנית (OpenTelemetry) על שכבת-הסוכנים (השראת Gastown OTel) |
| #220 | [#450](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/450) | type:feature · priority:p2-normal · status:in-progress | Seance — תשאול סשן-קודם לצמצום re-work של blind-heartbeats (השראת Gastown gt seance) |
| #221 | [#451](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/451) | type:feature · priority:p2-normal · status:deferred | Molecules — פייפליין 12-הבלוקים כתבנית הצהרתית עם checkpoint-recovery (השראת Gastown formulas) |
| #222 | [#452](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/452) | type:feature · priority:p2-normal · status:in-progress | טקסונומיית-בריאות מפורשת לסוכן ב-dashboard (Zombie/Stalled/Working/Idle — השראת Gastown gt feed --problems) |
| #225 | [#453](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/453) | type:spike · priority:p2-normal · status:needs-decision | בית-אב לדוקטרינה-משפטית של דפנה (chair legal-doctrine home) — תכנון בלבד |
| #228 | [#454](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/454) | type:bug · priority:p1-high | תשובת-interaction/הערה על issue בבעלות-אדם לא מעירה את ה-CEO — לנתב דרך פרימיטיב CEO-child (#227) |
| #229 | [#455](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/455) | type:bug · priority:p1-high | block_writer שומר טקסט-שיחה של המודל לתוך תוכן הבלוק — נדרש שער-ניקוי בכתיבה |
| #230 | [#456](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/456) | type:bug · priority:p2-normal | extractor._text_quality_ok לא מזהה סדר-קריאה RTL משובש ב-PDF דיגיטלי |
| #231 | [#457](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/457) | type:bug · priority:p1-high · status:ready | סוכני Paperclip מנותקים משרת ה-MCP legal-ai — ACP הפך לברירת-מחדל ב-claude_local |
| #233 | [#458](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/458) | type:bug · priority:p1-high · status:ready | צבירת-הטיעונים מוחקת צד שלם בשקט — 310 טענות עוררים → 0 טיעונים, status ok |
## משימות שנסגרו בזמן המיגרציה (לא הועברו)
| TaskMaster | למה נסגרה |
|---|---|
| #223 | PR #393 מוזג — בחירת פרוטוקול לפי `protocol_scope`. אומת ב-1069-04-26. |
| #224 | PRs #392 + #403 מוזגו — `claims.party_name` במקור + פיצול משיבים. אומת ב-1069-04-26. |
| #232 | PR #431 מוזג — 104 החלטות ועדות-ערר היו מסוננות מהחיפוש (סינון בברירת-מחדל). מלכודת 3 נסגרה ב-PR #432. |
## מה עדיין דורש עבודה ידנית
- **`size:` לא הוקצה** לאף issue — TaskMaster לא החזיק הערכות-גודל, ולא רציתי להמציא אותן. לפי כלל התבנית, issue ללא AC+size אינו `status:ready`.
- **21 מתוך 26 issues ללא Acceptance Criteria** — הן הועברו מ-TaskMaster בלי `testStrategy`, וגוף ה-issue מסמן זאת במפורש. לפני לקיחת issue כזה — להגדיר AC, להקצות `size`, ולסמן `status:ready`.
- 5 שכן קיבלו `status:ready` (היה להן `testStrategy`): #436, #439, #440, #457, #458.

View File

@@ -6,6 +6,7 @@ Run with: python -m legal_mcp.server
from __future__ import annotations
import logging
import os
import sys
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
@@ -41,11 +42,56 @@ async def lifespan(server: FastMCP) -> AsyncIterator[None]:
logger.info("Ezer Mishpati MCP server stopped")
# HTTP listener address, used only when MCP_TRANSPORT selects an HTTP transport.
#
# These MUST be passed to the constructor rather than left to FastMCP's own
# FASTMCP_HOST / FASTMCP_PORT environment settings: FastMCP.__init__ declares
# `host: str = "127.0.0.1"` and `port: int = 8000` as explicit keyword defaults
# and forwards them into Settings(**settings). In pydantic-settings, explicit
# init kwargs outrank environment variables — so FASTMCP_PORT is silently
# ignored and the server binds 8000 regardless (verified 2026-08-05; on this
# host 8000 is already taken, so it failed loudly by luck rather than design).
#
# Default to loopback. The bearer gate below is the real protection; the narrow
# bind is defence in depth, not a substitute for it.
MCP_HTTP_HOST = os.environ.get("MCP_HTTP_HOST", "127.0.0.1")
MCP_HTTP_PORT = int(os.environ.get("MCP_HTTP_PORT", "8790"))
# Bearer gate — wired only when an HTTP transport is actually selected (#231.2).
#
# stdio must never require a token: the pipe is the boundary there, and every
# interactive session reaches us that way. Demanding a token on stdio would
# break all of them for no security gain.
#
# Building the verifier at import time (rather than inside main()) is deliberate:
# FastMCP takes `token_verifier` and `auth` as constructor arguments, so a
# missing token has to fail here — before the listener exists — not after it is
# already accepting connections.
_http_transport = os.environ.get("MCP_TRANSPORT", "stdio").strip() in ("sse", "streamable-http")
_auth_kwargs: dict = {}
if _http_transport:
from mcp.server.auth.settings import AuthSettings
from legal_mcp.services.http_auth import StaticTokenVerifier, load_token
_base_url = f"http://{MCP_HTTP_HOST}:{MCP_HTTP_PORT}"
_auth_kwargs = {
"token_verifier": StaticTokenVerifier(load_token()),
# AuthSettings is what switches on the SDK's BearerAuthBackend. We are a
# resource server with a pre-shared token, not an OAuth client, so both
# URLs simply point at ourselves — they exist to satisfy the protected-
# resource metadata contract, and nothing issues tokens from them.
"auth": AuthSettings(issuer_url=_base_url, resource_server_url=_base_url),
}
# Create MCP server
mcp = FastMCP(
"Ezer Mishpati - עוזר משפטי",
instructions="מערכת AI לסיוע בניסוח החלטות משפטיות בסגנון דפנה תמיר",
lifespan=lifespan,
host=MCP_HTTP_HOST,
port=MCP_HTTP_PORT,
**_auth_kwargs,
)
# ── Import and register tools ───────────────────────────────────────
@@ -1260,7 +1306,44 @@ async def corroboration_rebuild(case_law_id: str = "") -> dict:
def main():
mcp.run(transport="stdio")
"""Run the server on the transport named by ``MCP_TRANSPORT`` (default stdio).
ONE server, two transports — deliberately not a second implementation (G2).
The tool registry, services and DB pool above are shared verbatim; only the
wire protocol differs, so a tool can never exist on one transport and not
the other.
- ``stdio`` (default) — the historical path. Every interactive Claude Code
session reaches us this way via the ``legal-ai`` entry in ``~/.claude.json``.
Changing this default would break them all, so it stays the default.
- ``streamable-http`` — required by the agent-platform port. Agents driven
over the Agent Client Protocol receive their MCP servers from the *client*
at session start, and that channel accepts HTTP servers only: each is
injected as ``{type:"http", name, url, headers:[Bearer]}``. A stdio server
has no path into such a session at all, which is why platform-driven agents
lost all 108 tools when their execution engine changed — TaskMaster #231.
Platform-side wiring lives behind the port (``web/agent_platform_port.py``),
not here; this module only has to be reachable over HTTP.
Host/port come from FastMCP's own ``FASTMCP_HOST`` / ``FASTMCP_PORT`` settings.
Bind to loopback only: this transport carries no authentication yet, and the
registry includes destructive tools (``case_delete``, ``precedent_library_delete``).
The Bearer gate is #231.2 and MUST land before this is reachable off-host.
"""
transport = os.environ.get("MCP_TRANSPORT", "stdio").strip() or "stdio"
valid = ("stdio", "sse", "streamable-http")
if transport not in valid:
# Fail loudly — a typo must not silently fall back to stdio and leave
# the HTTP listener absent while everything "looks" fine (§6).
raise SystemExit(
f"MCP_TRANSPORT={transport!r} is not one of {valid}",
)
if transport != "stdio":
logger.info(
"Serving MCP over %s on %s:%s",
transport, mcp.settings.host, mcp.settings.port,
)
mcp.run(transport=transport)
if __name__ == "__main__":

View File

@@ -147,6 +147,38 @@ def _normalize_argument(raw: dict, fallback_topic: str = "") -> dict | None:
}
class AggregationFailed(RuntimeError):
"""One side's aggregation call failed. Never swallowed — see #233."""
#: Largest number of propositions sent to Claude in a single aggregation call.
#:
#: Above roughly this size the model stops returning a JSON array and the whole
#: side is lost. The per-brief split added for the respondent side (#224) hid
#: this by accident — it kept those calls small — but appellant and committee
#: speak with a single voice and are never split by brief, so a big appeal goes
#: out in one oversized call. 1069-04-26 sent 310 propositions and got nothing
#: back (#233).
#:
#: Chunking is a real trade-off, not a free win: each chunk is grouped in
#: isolation, so a side split across chunks can end up with more (and slightly
#: overlapping) arguments than a single pass would have produced. Losing an
#: entire litigant is worse, and the alternative — a smaller prompt per
#: proposition — would degrade every case to fix the large ones.
MAX_PROPS_PER_CALL = 80
def _chunk(propositions: list[dict], size: int) -> list[list[dict]]:
"""Split propositions into calls of at most ``size``, keeping claim order.
Order matters: claims arrive sorted by ``claim_index``, so neighbouring
propositions usually belong to the same head of argument. Slicing in order
keeps related material together instead of scattering one argument across
chunks.
"""
return [propositions[i:i + size] for i in range(0, len(propositions), size)]
async def _aggregate_party(
party: str, propositions: list[dict], party_name: str = "",
) -> list[dict]:
@@ -155,9 +187,27 @@ async def _aggregate_party(
``party_name`` names the specific pleading when this is a split side
(respondent / permit_applicant brief), so the prompt scopes to that
litigant's position only (#224).
Sides larger than ``MAX_PROPS_PER_CALL`` are aggregated in several calls and
concatenated; a failure in any chunk raises rather than returning a partial
side quietly.
"""
if not propositions:
return []
if len(propositions) > MAX_PROPS_PER_CALL:
chunks = _chunk(propositions, MAX_PROPS_PER_CALL)
logger.info(
"argument_aggregator: party '%s'%s has %d propositions — "
"aggregating in %d calls of up to %d",
party, f" ({party_name})" if party_name else "",
len(propositions), len(chunks), MAX_PROPS_PER_CALL,
)
out: list[dict] = []
for chunk in chunks:
out.extend(await _aggregate_party(party, chunk, party_name=party_name))
return out
prompt = _build_prompt(party, propositions, party_name=party_name)
try:
@@ -171,11 +221,18 @@ async def _aggregate_party(
) from e
if not isinstance(raw_result, list):
logger.warning(
"argument_aggregator: Claude returned non-list (%s) for party '%s'",
type(raw_result).__name__, party,
# NOT a silent []: returning empty here is indistinguishable from "this
# side genuinely has no arguments", and the caller would then report
# status=completed while a whole litigant vanished. That is exactly what
# happened to the appellant side of 1069-04-26 — 310 claims in, 0
# arguments out, "completed" (#233). Raise so the caller records it in
# ``errors`` and the status degrades to completed_with_errors (§6).
raise AggregationFailed(
f"Claude returned {type(raw_result).__name__}, not a list, for party "
f"'{party}'{f' ({party_name})' if party_name else ''} with "
f"{len(propositions)} propositions — the side would otherwise be "
f"dropped without trace",
)
return []
out: list[dict] = []
for entry in raw_result:
@@ -276,6 +333,12 @@ async def aggregate_claims_to_arguments(
group_key = f"{party}·{party_name}" if party_name else party
try:
arguments = await _aggregate_party(party, props, party_name=party_name)
except AggregationFailed as e:
# A side that failed is NOT a side with no arguments. Record it so
# the status degrades and the caller can see which litigant is
# missing (#233).
errors.append(f"{group_key}: {e}")
continue
except RuntimeError as e:
# Most likely cause: Claude CLI not installed (running from
# the container). Don't crash — record the gap and continue.

View File

@@ -23,8 +23,10 @@ from pathlib import Path
from legal_mcp import config
from legal_mcp.services import db, embeddings, claude_session, audit, storage
from legal_mcp.services.lessons import (
ANTI_PATTERNS as _ANTI_PATTERNS,
OUTCOME_LABELS_HE,
PRACTICE_AREA_OVERRIDES,
anti_pattern_directive,
canonical_outcome,
get_content_checklist,
get_methodology_summary,
@@ -369,6 +371,7 @@ async def write_block(
block_id: str,
instructions: str = "",
effort_override: str | None = None,
model_override: str | None = None,
) -> dict:
"""כתיבת בלוק יחיד בהחלטה.
@@ -381,6 +384,12 @@ async def write_block(
THIS call only — used by the #208 model/effort calibration harness
to A/B efforts without mutating the pinned defaults. Production
callers leave it None and get the deterministic per-block effort.
model_override: optional per-call generation model id (e.g.
"claude-opus-5"). Same contract as effort_override — the #208
harness A/Bs MODELS without mutating the pinned GENERATION_MODEL.
Pass the BASE id only: the 1M-context escalation (#216) is applied
on top automatically for large prompts, so an override never
silently loses the 1M window. Production callers leave it None.
Returns:
dict עם content, word_count, block_id, generation_type
@@ -468,6 +477,12 @@ async def write_block(
if instructions:
prompt += f"\n\n## הנחיות נוספות:\n{instructions}"
# LAST in the prompt, deliberately (see lessons.anti_pattern_directive): the
# same canonical rule already appears inside style_context, but ~47K chars
# deep, where it measurably fails to bind. Restating it here is the only
# change the A/B isolated as effective — so nothing may be appended after it.
prompt += "\n\n" + anti_pattern_directive()
# Block י requires approved direction
if block_id == "block-yod":
dir_doc = (decision or {}).get("direction_doc") or {}
@@ -478,7 +493,12 @@ async def write_block(
# escalate to the 1M-context build (`[1m]`) instead of failing the block —
# block-yod legitimately carries the whole case as source-context. The 400K
# ceiling was an artifact of the old 200K-only build, NOT a model limit.
gen_model = GENERATION_MODEL_1M if len(prompt) > _CTX_1M_THRESHOLD_CHARS else GENERATION_MODEL
# model_override (#208 harness) swaps the BASE id only — the 1M decision below
# still applies, so an A/B'd model keeps the same context-window behaviour as
# the pinned default instead of silently falling back to the 200K build.
_base_model = model_override or GENERATION_MODEL
_model_1m = GENERATION_MODEL_1M if _base_model == GENERATION_MODEL else f"{_base_model}[1m]"
gen_model = _model_1m if len(prompt) > _CTX_1M_THRESHOLD_CHARS else _base_model
# Final guard: even the 1M build is finite (~2M Hebrew chars of input). Cap at
# 1.5M chars (~750K tokens) to leave room for output + a safety margin under 1M.
@@ -1107,6 +1127,16 @@ async def _build_style_context(practice_area: str = "") -> str:
# ── למידה מצטברת (T15) — עריכות היו"ר ב-/methodology + לקחי /training ──
# גובר על ברירות-המחדל לעיל. כך כל מה שלמדנו עד היום מגיע לכותב.
learned: list[str] = []
# The canonical anti-patterns are rendered UNCONDITIONALLY, before any DB
# call. They used to be produced inside the overrides loop below — so a
# failure on an EARLIER category (e.g. golden_ratios) aborted the loop and
# dropped the style invariants from the prompt silently, with only a generic
# "overrides not loaded" warning to show for it (§6). A chair-override
# outage must not be able to un-teach Dafna's structural style.
learned.append("\n**אנטי-דפוסים (להימנע) — כתוב נרטיב משפטי רציף; הימנע מ:**")
for ap in _ANTI_PATTERNS:
learned.append(f"- {ap['note']}")
try:
for cat, label in (
("golden_ratios", "יחסי-זהב (אחוזי-סעיפים)"),
@@ -1125,10 +1155,8 @@ async def _build_style_context(practice_area: str = "") -> str:
# corrects them, and drafts keep emitting them (the gap that left
# 8137 with 28 hits). Chair additions layer on top; they never
# remove the canonical ones.
from legal_mcp.services.lessons import ANTI_PATTERNS as _ANTI
learned.append(f"\n**{label} — כתוב נרטיב משפטי רציף; הימנע מ:**")
for ap in _ANTI:
learned.append(f"- {ap['note']}")
# The canonical list is already rendered above, outside this try —
# here we only layer the chair's ADDITIONS on top of it.
for k, v in (ov or {}).items():
learned.append(f"- (יו\"ר) {k}: {json.dumps(v, ensure_ascii=False)}")
continue
@@ -1264,6 +1292,12 @@ async def get_block_context(case_id: UUID, block_id: str, instructions: str = ""
if instructions:
formatted_prompt += f"\n\n## הנחיות נוספות:\n{instructions}"
# Same closing directive, same position, same canonical source as write_block.
# This is the EXTERNAL-writer path (legal-writer agent) — if the rule were
# applied only in write_block, agent-written blocks would keep emitting the
# anti-patterns and the two writers would drift apart (G2).
formatted_prompt += "\n\n" + anti_pattern_directive()
# Block י requires approved direction
if block_id == "block-yod":
dir_doc = (decision or {}).get("direction_doc") or {}

View File

@@ -5324,21 +5324,19 @@ async def list_external_case_law(
search: str = "",
limit: int = 100,
offset: int = 0,
source_kind: str = "external_upload",
source_kind: str = "",
) -> list[dict]:
"""List chair-uploaded precedents, with simple filters.
source_kind="" (default) = the whole corpus — court rulings *and*
appeals-committee decisions. The old ``external_upload`` default hid
every committee decision from a plain listing (#232).
source_kind="all_committees" expands to: source_kind='internal_committee'
OR (source_kind='external_upload' AND source_type='appeals_committee').
"""
pool = await get_pool()
if source_kind == "all_committees":
conditions = [
"(source_kind = 'internal_committee' OR "
"(source_kind = 'external_upload' AND source_type = 'appeals_committee'))"
]
else:
conditions = [f"source_kind = '{source_kind}'"]
sk_clause = _source_kind_clause(source_kind)
conditions = [sk_clause] if sk_clause else []
params: list = []
idx = 1
if practice_area:
@@ -5358,13 +5356,19 @@ async def list_external_case_law(
params.append(source_type)
idx += 1
if search:
# Case-number separator normalisation (#232, trap 2): committee numbers
# are stored hyphenated ("83-16") while people write them with a slash
# ("83/16"). Fold both sides to '/' so either form finds the row —
# matching at the point of comparison rather than asking every caller
# to guess the stored form.
conditions.append(
f"(case_number ILIKE ${idx} OR case_name ILIKE ${idx} "
f"OR summary ILIKE ${idx} OR headnote ILIKE ${idx})"
f"OR summary ILIKE ${idx} OR headnote ILIKE ${idx} "
f"OR replace(case_number, '-', '/') ILIKE replace(${idx}, '-', '/'))"
)
params.append(f"%{search}%")
idx += 1
where_sql = " AND ".join(conditions)
where_sql = " AND ".join(conditions) if conditions else "TRUE"
params.extend([limit, offset])
sql = f"""
SELECT id, case_number, case_name, court, date, practice_area,
@@ -7775,6 +7779,45 @@ async def list_corroboration_for_halacha(halacha_id: UUID) -> list[dict]:
]
#: Accepted ``source_kind`` selectors. ``""``/``"all"`` mean *no filter* —
#: the whole corpus, court rulings and appeals-committee decisions alike.
_SOURCE_KIND_SELECTORS = frozenset(
{"", "all", "all_committees", "external_upload", "internal_committee", "cited_only"}
)
def _source_kind_clause(source_kind: str, column_prefix: str = "") -> str:
"""Return the SQL predicate for a ``source_kind`` selector, or "" for none.
Single definition for every caller (G2) — the selector vocabulary was
previously restated at each search/list site, which is how the
``external_upload`` default silently hid 104 appeals-committee
decisions from ``search_precedent_library`` (#232).
``column_prefix`` is the table alias plus dot (e.g. ``"cl."``) or "" when
the query selects from ``case_law`` directly.
Raises ValueError on an unknown selector rather than interpolating it —
the value reaches SQL by f-string, so the whitelist is also what keeps
that safe.
"""
sk = (source_kind or "").strip()
if sk not in _SOURCE_KIND_SELECTORS:
raise ValueError(
f"source_kind לא מוכר: {source_kind!r}. "
f"ערכים חוקיים: {sorted(_SOURCE_KIND_SELECTORS - {''})} או '' (הכל)"
)
if sk in ("", "all"):
return ""
p = column_prefix
if sk == "all_committees":
return (
f"({p}source_kind = 'internal_committee' OR "
f"({p}source_kind = 'external_upload' AND {p}source_type = 'appeals_committee'))"
)
return f"{p}source_kind = '{sk}'"
async def search_precedent_library_semantic(
query_embedding: list[float],
practice_area: str = "",
@@ -7785,14 +7828,15 @@ async def search_precedent_library_semantic(
subject_tag: str = "",
limit: int = 10,
include_halachot: bool = True,
source_kind: str = "external_upload",
source_kind: str = "",
district: str = "",
chair_name: str = "",
) -> list[dict]:
"""Semantic search over precedents filtered by source_kind.
"""Semantic search over precedents, optionally filtered by source_kind.
source_kind='external_upload' → court rulings (default)
source_kind='internal_committee' → appeals-committee decisions
source_kind='' → the whole corpus (default, #232)
source_kind='external_upload' → court rulings only
source_kind='internal_committee' → appeals-committee decisions only
Returns merged halachot + chunks. Halachot are pre-distilled rules, so
they get a small score boost. Only ``approved`` / ``published`` halachot
@@ -7800,12 +7844,15 @@ async def search_precedent_library_semantic(
of halacha review status.
"""
pool = await get_pool()
sk_clause = _source_kind_clause(source_kind, "cl.")
halacha_filters = [
"h.review_status <> 'rejected'", # #153: include background; rank verified higher
f"cl.source_kind = '{source_kind}'",
"cl.searchable = true",
]
chunk_filters = [f"cl.source_kind = '{source_kind}'", "cl.searchable = true"]
chunk_filters = ["cl.searchable = true"]
if sk_clause:
halacha_filters.append(sk_clause)
chunk_filters.append(sk_clause)
h_params: list = [query_embedding, limit]
c_params: list = [query_embedding, limit]
h_idx = 3
@@ -8017,7 +8064,7 @@ async def search_precedent_library_lexical(
appeal_subtype: str = "",
is_binding: bool | None = None,
subject_tag: str = "",
source_kind: str = "external_upload",
source_kind: str = "",
district: str = "",
chair_name: str = "",
limit: int = 30,
@@ -8043,12 +8090,15 @@ async def search_precedent_library_lexical(
return []
pool = await get_pool()
sk_clause = _source_kind_clause(source_kind, "cl.")
halacha_filters = [
"h.review_status <> 'rejected'", # #153: include background; rank verified higher
f"cl.source_kind = '{source_kind}'",
"cl.searchable = true",
]
chunk_filters = [f"cl.source_kind = '{source_kind}'", "cl.searchable = true"]
chunk_filters = ["cl.searchable = true"]
if sk_clause:
halacha_filters.append(sk_clause)
chunk_filters.append(sk_clause)
# $1 = query, $2 = limit. Filters append starting at $3.
h_params: list = [query, limit]
c_params: list = [query, limit]

View File

@@ -0,0 +1,106 @@
"""Bearer-token gate for the HTTP transport of the MCP server (#231.2).
Why this exists
---------------
Over ``stdio`` the protection is the pipe itself: only a process that already
runs as this user can speak to the server. ``streamable-http`` removes that
property entirely — anything that can reach the socket can call any of the 108
registered tools, and the registry includes ``case_delete``,
``precedent_library_delete``, ``document_upload`` and every block-writing tool.
An unauthenticated listener is therefore a delete-any-case endpoint.
The agent platform already speaks this dialect: it injects each runtime MCP
server as ``{type:"http", name, url, headers:[{name:"Authorization",
value:"Bearer <token>"}]}``. So a static bearer token is exactly the shape the
caller will present — no negotiation, no OAuth dance.
Design notes
------------
- We implement the SDK's own ``TokenVerifier`` protocol and let
``BearerAuthBackend`` do the enforcement, rather than adding bespoke
middleware. One auth path, the framework's (G2).
- The token is read from the environment, which the service unit populates from
Infisical. It is never defaulted, never logged, and never embedded here.
- Comparison is constant-time: a naive ``==`` leaks the token byte-by-byte to a
caller who can time responses.
- ``verify_token`` returns ``None`` (not an exception) on mismatch — that is the
protocol's "reject" signal and yields a clean 401 instead of a 500 that would
read as a server fault.
"""
from __future__ import annotations
import hmac
import logging
import os
from mcp.server.auth.provider import AccessToken, TokenVerifier
logger = logging.getLogger("legal_mcp.http_auth")
#: Environment variable carrying the shared bearer token.
#:
#: Name matches the Infisical key exactly — All Infrastructure / env `main` /
#: `/apps/legal-ai` / ``MCP_HTTP_SHARED_SECRET``, tagged ``credentials``. Keeping
#: the two identical means nobody has to hold a mapping in their head, and it
#: follows the two bridge tokens already in that folder
#: (``COURT_FETCH_SHARED_SECRET``, ``LEGAL_CHAT_SHARED_SECRET``).
TOKEN_ENV = "MCP_HTTP_SHARED_SECRET"
#: Minimum acceptable token length. Short tokens are brute-forceable; refusing
#: them at startup is cheaper than discovering it from an access log.
MIN_TOKEN_LEN = 32
#: Reported as the authenticated principal. Single shared token today, so this
#: is a constant rather than a real identity — kept explicit so that audit rows
#: never imply per-agent attribution we cannot actually make.
CLIENT_ID = "mcp-http-shared"
class MissingTokenError(RuntimeError):
"""Raised when the HTTP transport is requested without a usable token.
Deliberately fatal. The tempting alternative — start anyway and log a
warning — produces a listener that looks healthy and answers every
destructive tool call. Refusing to boot is the safe failure (§6: never
swallow, never degrade silently).
"""
def load_token() -> str:
"""Return the configured bearer token, or raise if it is unusable."""
token = (os.environ.get(TOKEN_ENV) or "").strip()
if not token:
raise MissingTokenError(
f"{TOKEN_ENV} is not set. The HTTP transport exposes destructive "
f"tools and will not start without a bearer token. Set it from "
f"Infisical, or use MCP_TRANSPORT=stdio.",
)
if len(token) < MIN_TOKEN_LEN:
raise MissingTokenError(
f"{TOKEN_ENV} is shorter than {MIN_TOKEN_LEN} characters — refusing "
f"to start. Generate a long random token.",
)
return token
class StaticTokenVerifier(TokenVerifier):
"""Verifies the single shared bearer token presented by the platform.
Not an identity system: it answers "may this caller in at all", not "who is
it". Per-agent attribution would need per-agent tokens, which is a later
step once profiles exist (#231.4).
"""
def __init__(self, expected: str) -> None:
self._expected = expected
async def verify_token(self, token: str) -> AccessToken | None:
# compare_digest over bytes; it tolerates unequal lengths without
# short-circuiting, which is the whole point.
if not hmac.compare_digest(token.encode("utf-8"),
self._expected.encode("utf-8")):
# No token material in the log line — only the fact of a rejection.
logger.warning("Rejected MCP HTTP request: bearer token mismatch")
return None
return AccessToken(token=token, client_id=CLIENT_ID, scopes=[])

View File

@@ -98,15 +98,16 @@ async def search_precedent_library_hybrid(
is_binding: bool | None = None,
subject_tag: str = "",
include_halachot: bool = True,
source_kind: str = "external_upload",
source_kind: str = "",
district: str = "",
chair_name: str = "",
max_per_case_law: int = 2,
) -> list[dict]:
"""Hybrid wrapper for precedent-library search.
source_kind='external_upload' → court rulings (default)
source_kind='internal_committee' → appeals-committee decisions
source_kind='' → the whole corpus (default, #232)
source_kind='external_upload' → court rulings only
source_kind='internal_committee' → appeals-committee decisions only
max_per_case_law: MMR-style diversity cap — at most N hits per
case_law_id in the final ranked list (default 2). Prevents a
single precedent from monopolizing the result list when many of

View File

@@ -59,6 +59,28 @@ ANTI_PATTERNS: list[dict] = [
"note": "רשימות תבליטים באנליזה — דפנה כותבת נרטיב רציף"},
]
def anti_pattern_directive() -> str:
"""The closing style directive, rendered from ANTI_PATTERNS (the same list
style_distance scores against — one source, two renderings, not two rules).
WHY THIS EXISTS SEPARATELY FROM the style-context rendering: the rule was
already reaching the writer, buried ~47K chars deep inside style_context,
and drafts kept emitting the very patterns it forbids. A measured A/B over
the signed finals (9 cases, 60 generations, 2026-07-28) showed that the SAME
rule restated at the END of the assembled prompt cuts anti-pattern hits by
7293% on both blocks and both models:
block-vav opus-4-8 1.75 → 0.12 | opus-5 2.25 → 0.62
block-zayin opus-4-8 4.57 → 0.43 | opus-5 4.43 → 0.43
So this is a POSITION fix, not a new instruction. Keep it last in the prompt.
"""
lines = ["## כלל-סגנון מחייב (גובר על כל דוגמה בהקשר שלמעלה)",
"כתוב נרטיב משפטי רציף בלבד — פסקאות שלמות. אסור:"]
lines += [f"- {ap['note']}" for ap in ANTI_PATTERNS]
return "\n".join(lines)
# ── Paragraph length guidance (word counts) ────────────────────────
PARAGRAPH_LENGTHS = {

View File

@@ -477,7 +477,7 @@ async def list_precedents(
precedent_level: str = "",
source_type: str = "",
search: str = "",
source_kind: str = "external_upload",
source_kind: str = "",
limit: int = 100,
offset: int = 0,
) -> list[dict]:
@@ -503,12 +503,18 @@ async def search_library(
subject_tag: str = "",
limit: int = 10,
include_halachot: bool = True,
source_kind: str = "",
) -> list[dict]:
"""Semantic search merging halachot (rule-level) and chunks (passage-level).
Only ``approved`` / ``published`` halachot are returned, per chair-review
policy. Chunks are returned regardless of halacha review status.
``source_kind=""`` (default) covers the whole corpus — court rulings and
appeals-committee decisions together. It used to be hard-wired to
``external_upload`` here, which made 104 committee decisions
unreachable through this entry point (#232).
When ``VOYAGE_RERANK_ENABLED`` is set, results are passed through
voyage rerank-2 (cross-encoder). The +0.05 halacha boost from
``search_precedent_library_semantic`` is preserved before rerank
@@ -529,4 +535,5 @@ async def search_library(
is_binding=is_binding,
subject_tag=subject_tag,
include_halachot=include_halachot,
source_kind=source_kind,
)

View File

@@ -22,6 +22,7 @@ from __future__ import annotations
import logging
import re
import unicodedata
from datetime import date as date_type
from uuid import UUID
@@ -72,7 +73,7 @@ METADATA_EXTRACTION_PROMPT = """אתה מסייע משפטי בכיר. קרא א
"source_type": "אחד מ-2: 'court_ruling' (פסק דין של בית משפט — עליון/מנהלי) / 'appeals_committee' (החלטה של ועדת ערר). אם לא ברור — מחרוזת ריקה.",
"proceeding_type": "אחד מ-2 (רק להחלטות ועדת ערר): 'ערר' (הליך ערר עיקרי על החלטת ועדה מקומית) / 'בל\\\"מ' (בקשה להארכת מועד להגשת ערר). זהה דרך כותרת המסמך: 'ערר (ועדות ערר ...) NNNN/YY''ערר'; 'בל\\\"מ NNNN/YY' או נושא 'בקשה להארכת מועד להגשת ערר''בל\\\"מ'. בפסיקת בית משפט (לא ועדת ערר) — מחרוזת ריקה.",
"court": "שם הערכאה כפי שהוא מופיע בכותרת (למשל 'בית המשפט העליון', 'בית המשפט המחוזי בירושלים בשבתו כבית משפט לעניינים מנהליים', 'ועדת הערר לתכנון ובניה פיצויים והיטלי השבחה — מחוז ירושלים'). מחרוזת ריקה אם לא ניתן לזהות.",
"case_number_clean": "מספר הערר/תיק כפי שמופיע בכותרת — רק הספרות והאלכסון, למשל '1062/24' או '8031/21'. ללא המילה 'ערר', ללא שם הצדדים, ללא סוגריים. אם יש כמה עררים מאוחדים — הרשום הראשון. מחרוזת ריקה אם לא ניתן לזהות.",
"case_number_clean": "מספר הערר/תיק **בדיוק כפי שמופיע בכותרת** — רק ספרות ומפרידים. שתי צורות קיימות ושתיהן חוקיות: דו-חלקית ('1062/24', '8031/21') ותלת-חלקית של ועדות ערר ('1094-09-19', '85074-09-24' — סידורי-חודש-שנה). **העתק את הספרות מהמסמך; אל תשלים, אל תנחש ואל תתקן ספרה.** ללא המילה 'ערר', ללא שם הצדדים, ללא סוגריים. אם יש כמה עררים מאוחדים — הרשום הראשון. **אם המספר אינו מופיע בטקסט — מחרוזת ריקה** (הקוד דוחה ממילא מספר שאינו מעוגן בטקסט).",
"chair_name": "שם יו\\\"ר ההרכב של **ההחלטה הזו** — רלוונטי **רק להחלטות ועדת ערר**, לא לפסקי בית משפט. כמעט תמיד מופיע — בשני מקומות: (א) בכותרת/רובריקה בראש המסמך, ליד 'בפני:' / 'בהרכב:' / רשימת חברי הוועדה; (ב) בבלוק-החתימה בסוף ההחלטה, אחרי 'ההחלטה ניתנה' — שם מופיעים זה-לצד-זה מזכיר/ת הוועדה והיו\\\"ר (למשל בשתי עמודות: בצד אחד 'פלוני, עו\\\"ד / מזכיר ועדת הערר' ובצד השני 'אלמוני, עו\\\"ד / יו\\\"ר ועדת הערר'). **קח את השם שמעליו/לצדו כתוב 'יו\\\"ר' — לא את המזכיר/ה.** השאר שם פרטי+משפחה בלבד, בלי תוארים ('עו\\\"ד', 'אדריכל', 'עו\\\"ד דפנה תמיר''דפנה תמיר'). **אזהרה קריטית:** אל תיקח שם יו\\\"ר של פסק/החלטה אחרים ש**מצוטטים** בגוף ההחלטה (למשל 'כפי שנקבע ברשותה של יו\\\"ר פלונית בערר אחר...') — אלה תקדימים מצוטטים, לא היו\\\"ר של ההחלטה הנוכחית. אם זה פסק דין של בית משפט — מחרוזת ריקה.",
"district": "מחוז ועדת הערר — רלוונטי **רק להחלטות ועדת ערר**. ערכים מותרים: 'ירושלים', 'תל אביב', 'מרכז', 'חיפה', 'צפון', 'דרום', 'ארצית'. זהה מהכותרת ('ועדת הערר לתכנון ובניה — מחוז ירושלים''ירושלים'; 'ועדות ערר - תכנון ובנייה תל אביב-יפו''תל אביב'). אם זה פסק דין של בית משפט — מחרוזת ריקה.",
"parties": "שמות הצדדים בשורה אחת בצורה 'עורר נ\\' משיב' — בדיוק כפי שמופיעים בכותרת/רובריקה. בלי הדגשה, בלי מספר-תיק, בלי תוארים מיותרים. למשל 'ישיבת חברת אהבת שלום נ\\' תאיה' או 'ראם חיים נ\\' הוועדה המקומית לתכנון ובניה ירושלים'. אם הצדדים אינם מופיעים בטקסט (למשל החלטה שמתחילה בגוף בלי רובריקה) — מחרוזת ריקה. **אל תמציא שמות.**",
@@ -242,6 +243,37 @@ def _is_clean_docket(s: str) -> bool:
return bool(_DOCKET_RE.fullmatch((s or "").strip()))
def _strip_invisibles(s: str) -> str:
"""Drop Unicode format chars (category Cf) — RLM/LRM/ZWJ and friends.
Hebrew legal PDFs carry bidi marks *inside* docket numbers, so a plain
substring test against the raw text misses a docket that is plainly there.
"""
return "".join(ch for ch in (s or "") if unicodedata.category(ch) != "Cf")
def _docket_grounded(docket: str, *sources: str) -> bool:
"""True when every digit group of ``docket`` appears, in order, in a source.
INV-AH (quote-or-retract) applied to the identity field. ``_is_clean_docket``
only checks the *shape*, so a model that misreads one digit produces a
perfectly well-formed but wrong docket — which is exactly how ערר 1094-09-19
(פדילה) was stored as ``1094-09-14`` while all five case documents said
...-19 (#232, trap 3). Shape validation cannot catch that; grounding can.
Tolerant of the separator (``-`` vs ``/``), of whitespace around it, and of
bidi marks, so a real docket in the source still matches. Sources are the
document text and the value being replaced — never the LLM's own output,
which would make the check circular.
"""
parts = re.split(r"[-/]", (docket or "").strip())
if not parts or not all(p.isdigit() for p in parts):
return False
pattern = r"\s*[-/]\s*".join(re.escape(p) for p in parts)
haystack = _strip_invisibles("\n".join(s or "" for s in sources))
return re.search(pattern, haystack) is not None
def _source_type_for_level(level: str) -> str:
"""Derive source_type from precedent_level — the library section is driven by
source_type, so the two MUST agree (an LLM slip pairing
@@ -400,6 +432,23 @@ async def apply_to_record(
"already owned by another non-internal row (likely duplicate)",
cur_cn, cn_clean,
)
elif not _docket_grounded(
cn_clean,
record.get("full_text") or "",
cur_cn,
record.get("citation_formatted") or "",
):
# The docket is well-formed but appears nowhere in the decision text
# or in the value it would replace — i.e. the model produced digits
# it cannot point at. case_number is the identity field; a wrong one
# silently detaches the row from every reference to the real case.
# Refuse the write and say so (§6) rather than trust the shape.
logger.warning(
"metadata_extractor: case_number normalization %r%r REFUSED — the "
"docket does not appear in the decision text or in the current "
"value (ungrounded extraction, INV-AH). Keeping %r.",
cur_cn, cn_clean, cur_cn,
)
else:
fields_to_update["case_number"] = cn_clean

View File

@@ -43,8 +43,18 @@ SUBSECTION_RE = re.compile(r"^###\s+(.+?)$", re.MULTILINE)
# Matches "**LABEL:**" field markers — handles both inline and block variants:
# "**עמדת המבקשת:** Some text on same line"
# "**שאלות משפטיות:**\n1. First question"
# and both the bare and the list-item form, because the analyst agent writes
# threshold claims as a bullet list ("- **עמדת ועדת הערר:**") while it writes
# issues bare. Group 1 is the list marker (or None), group 2 is the label.
# The label itself must not contain ** or newlines.
FIELD_LABEL_RE = re.compile(r"^\*\*([^\n*]+?):\*\*[ \t]*", re.MULTILINE)
FIELD_LABEL_RE = re.compile(
r"^([ \t]*(?:[-*+]|\d+[.)])[ \t]+)?\*\*([^\n*]+?):\*\*[ \t]*",
re.MULTILINE,
)
# Terminators that end a field's content even without a following field label:
# a heading, or a horizontal rule closing the subsection.
FIELD_TERMINATOR_RE = re.compile(r"^(?:#{2,}[ \t]|[ \t]*---[ \t]*$)", re.MULTILINE)
# Matches the case number in the H1
CASE_NUMBER_RE = re.compile(r"#\s*ניתוח.*?ערר\s+([\d/\-]+)", re.MULTILINE)
@@ -53,6 +63,17 @@ CASE_NUMBER_RE = re.compile(r"#\s*ניתוח.*?ערר\s+([\d/\-]+)", re.MULTILIN
DATE_RE = re.compile(r"^תאריך:\s*(.+?)\s*$", re.MULTILINE)
def _is_chair_label(label: str) -> bool:
"""Is this field label the chair-position field?
Matches on prefix, not equality, because the analyst sometimes decorates the
label with a parenthetical — "עמדת ועדת הערר (הכוונת יו"ר 24.6)". Requiring
equality made the reader treat those as ordinary fields and the writer append
a second, duplicate block instead of updating the existing one.
"""
return label.strip().startswith(CHAIR_POSITION_LABEL)
def _is_placeholder(text: str) -> bool:
"""Check if a field value is one of the placeholder strings (empty)."""
stripped = text.strip()
@@ -135,7 +156,7 @@ def _extract_fields(text: str) -> list[dict]:
fields = []
for i, m in enumerate(matches):
label = m.group(1).strip()
label = m.group(2).strip()
content_start = m.end()
content_end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
content = text[content_start:content_end].strip()
@@ -164,11 +185,17 @@ def _build_subsection_dict(
parts = title.split(": ", 1)
display_title = parts[1] if len(parts) > 1 else title
# Only the *first* chair-position field is the editable one — the same one
# update_chair_position writes to. A subsection that carries more than one
# (e.g. an H3 covering "סוגיות 46") keeps the extras as ordinary fields
# rather than silently reading back a different field than the one saved.
chair_position = ""
chair_seen = False
regular_fields = []
for f in fields:
if f["label"] == CHAIR_POSITION_LABEL:
if not chair_seen and _is_chair_label(f["label"]):
chair_position = _normalize_chair_position(f["content"])
chair_seen = True
else:
regular_fields.append(f)
@@ -311,6 +338,46 @@ def _find_subsection_by_id(
return None
def _split_trailing_rule(body: str) -> tuple[str, str]:
"""Split a subsection body into (content, trailing "---" separator).
Returns ("<body>", "") when the subsection has no closing rule.
"""
m = re.search(r"\n[ \t]*---[ \t]*\s*\Z", body)
if not m:
return body, ""
return body[: m.start()], body[m.start() :]
def _chair_field_span(body: str) -> tuple[int, int] | None:
"""Locate the chair-position field's *content* range in a subsection body.
Returns (content_start, content_end) — the slice the chair's text occupies,
excluding the "**LABEL:**" marker itself so the marker line (and any list
bullet in front of it) survives an update untouched. Returns None when the
subsection has no chair-position field yet.
Field boundaries come from FIELD_LABEL_RE — the same definition the reader
uses — so what update writes is exactly what parse reads back (G2: one
definition of a field, not two that can drift).
"""
matches = list(FIELD_LABEL_RE.finditer(body))
for i, m in enumerate(matches):
if not _is_chair_label(m.group(2)):
continue
content_start = m.end()
if i + 1 < len(matches):
content_end = matches[i + 1].start()
else:
content_end = len(body)
# A heading or closing rule ends the field even without a next label.
term = FIELD_TERMINATOR_RE.search(body, content_start, content_end)
if term:
content_end = term.start()
return content_start, content_end
return None
def update_chair_position(
file_path: Path, section_id: str, new_text: str
) -> dict[str, Any]:
@@ -330,40 +397,62 @@ def update_chair_position(
_abs_start, _abs_end, subsection_body = found
# Find the "**עמדת ועדת הערר:**" label within this subsection
label_pattern = re.compile(
r"(\*\*" + re.escape(CHAIR_POSITION_LABEL) + r":\*\*)\s*\n?([^*]*?)(?=\n\*\*|\n##|\n---|\Z)",
re.DOTALL,
)
m = label_pattern.search(subsection_body)
if not m:
# Label not present — append it at the end of the subsection
# (just before the trailing --- if any)
new_block = f"\n\n**{CHAIR_POSITION_LABEL}:**\n{new_text.strip()}\n"
new_subsection = subsection_body.rstrip() + new_block
new_content = content[:_abs_start] + new_subsection + content[_abs_end:]
span = _chair_field_span(subsection_body)
body_text = new_text.strip() or CHAIR_POSITION_PLACEHOLDERS[0]
if span is None:
# Label not present — append it at the end of the subsection,
# before a trailing horizontal rule if there is one.
head, tail = _split_trailing_rule(subsection_body)
new_block = f"\n\n**{CHAIR_POSITION_LABEL}:**\n{body_text}\n"
new_subsection = head.rstrip() + new_block + tail
else:
# Replace the existing content of the chair_position field
replacement = f"{m.group(1)}\n{new_text.strip() if new_text.strip() else CHAIR_POSITION_PLACEHOLDERS[0]}\n"
# Replace the existing content of the chair_position field, keeping the
# label line exactly as written (including any list marker) so the file
# structure the analyst produced is preserved.
content_start, content_end = span
new_subsection = (
subsection_body[: m.start()] + replacement + subsection_body[m.end():]
subsection_body[:content_start].rstrip("\r\n \t")
+ f"\n{body_text}\n"
+ subsection_body[content_end:]
)
new_content = content[:_abs_start] + new_subsection + content[_abs_end:]
new_content = content[:_abs_start] + new_subsection + content[_abs_end:]
# Atomic write
tmp_path = file_path.with_suffix(file_path.suffix + ".tmp")
tmp_path.write_text(new_content, encoding="utf-8") # noqa: STG1 — atomic .tmp; in-place edit, S3 re-sync in Phase-2 read-wiring
os.replace(tmp_path, file_path)
preview = new_text.strip()[:120]
# Read-after-write: a position the parser cannot read back is not saved,
# however cleanly the write itself succeeded. Reporting success here is what
# let a whole class of format drift hide behind a green "נשמר" in the UI.
stored = _stored_chair_position(file_path, section_id)
expected = _normalize_chair_position(new_text)
if stored != expected:
raise RuntimeError(
f"העמדה נכתבה ל-{file_path.name} אך לא נקראה בחזרה עבור {section_id} "
f"— ככל הנראה מבנה השדה בקובץ חורג מהתבנית הצפויה"
)
return {
"saved": True,
"section_id": section_id,
"preview": preview,
"position": stored,
"preview": stored[:120],
"timestamp": datetime.now(IL_TZ).isoformat(),
}
def _stored_chair_position(file_path: Path, section_id: str) -> str:
"""Re-parse the file and return the chair position now stored for a section."""
parsed = parse(file_path)
for item in parsed.get("threshold_claims", []) + parsed.get("issues", []):
if item["id"] == section_id:
return item.get("chair_position", "") or ""
return ""
# ── Chair directions extraction (for downstream agents) ─────────

View File

@@ -176,7 +176,13 @@ def block_distance_to_final(
outcome = canonical_outcome(outcome)
diff = compute_diff_stats(regenerated_text or "", final_section_text or "")
change_percent = diff["change_percent"]
anti_total = count_anti_patterns(regenerated_text or "")["total"]
anti = count_anti_patterns(regenerated_text or "")
anti_total = anti["total"]
# Per-pattern breakdown, not just the total: a calibration run that only
# reports "anti=4" cannot tell you WHICH rule was broken, so it cannot say
# what to fix. (Diagnosing the 2026-07-28 model A/B needed exactly this and
# had to fall back on inference.)
anti_by_pattern = {name: h["count"] for name, h in anti["by_pattern"].items()}
section = _BLOCK_TO_SECTION.get(block_id)
regen_words = len((regenerated_text or "").split())
@@ -205,6 +211,7 @@ def block_distance_to_final(
"final_words": final_words,
"change_percent": change_percent,
"anti_pattern_total": anti_total,
"anti_by_pattern": anti_by_pattern,
"golden_ratio_deviation_pp": ratio_dev,
"distance": distance,
}

View File

@@ -96,10 +96,13 @@ async def precedent_library_list(
precedent_level: str = "",
source_type: str = "",
search: str = "",
source_kind: str = "external_upload",
source_kind: str = "",
limit: int = 100,
) -> str:
"""רשימה של פסיקה בקורפוס הסמכותי, עם פילטרים."""
"""רשימה של פסיקה בקורפוס הסמכותי, עם פילטרים.
source_kind ריק (ברירת מחדל) = כל הקורפוס, כולל החלטות ועדות ערר.
"""
rows = await precedent_library.list_precedents(
practice_area=practice_area,
court=court,
@@ -266,8 +269,9 @@ async def search_precedent_library(
subject_tag: str = "",
limit: int = 10,
include_halachot: bool = True,
source_kind: str = "",
) -> str:
"""חיפוש סמנטי בקורפוס הפסיקה הסמכותית.
"""חיפוש סמנטי בקורפוס הפסיקה הסמכותית — פסקי דין **והחלטות ועדות ערר**.
מחזיר תוצאות מעורבות: הלכות (rule-level, מאושרות בלבד) + קטעי טקסט
(passage-level). הלכות מקבלות boost קל בדירוג כי הן מזוקקות מראש.
@@ -282,6 +286,9 @@ async def search_precedent_library(
subject_tag: סינון לפי תגית נושא (לדוגמה "מועד_קביעת_שומה").
limit: מספר תוצאות מקסימלי.
include_halachot: האם לכלול הלכות (ברירת מחדל: כן).
source_kind: ריק (ברירת מחדל) = כל הקורפוס — פסקי דין והחלטות ועדות
ערר יחד. "external_upload" = פסקי בתי משפט בלבד;
"internal_committee" = החלטות ועדות ערר בלבד.
Returns: רשימה מדורגת. כל פריט הוא {"type": "halacha"|"passage", "score", ...}.
"""
@@ -299,6 +306,7 @@ async def search_precedent_library(
subject_tag=subject_tag,
limit=limit,
include_halachot=include_halachot,
source_kind=source_kind,
)
# X11 Phase 2 (#154): attach the incoming-citation authority breakdown so the
# research agent can WEIGH and ARGUE authority ("הלכה שאומצה ב-N החלטות ועדת-ערר")

View File

@@ -0,0 +1,60 @@
"""The style invariants must actually REACH the writer.
Both tests here cover defects found by the 2026-07-28 model×prompt A/B over the
signed finals: the canonical anti-patterns were present in the prompt but buried
~47K chars into style_context (where they measurably failed to bind), and they
were rendered inside a try/except that an unrelated DB failure could abort.
"""
import pytest
from legal_mcp.services import block_writer
from legal_mcp.services.lessons import ANTI_PATTERNS, anti_pattern_directive
def test_directive_renders_every_canonical_anti_pattern():
"""One source, two renderings — the directive may not drift from the list
style_distance scores against."""
text = anti_pattern_directive()
for ap in ANTI_PATTERNS:
assert ap["note"] in text, f"missing anti-pattern in directive: {ap['name']}"
def test_both_writer_paths_append_the_directive_last():
"""write_block (in-process) and get_block_context (legal-writer agent) must
both close with the directive — otherwise the two writers drift (G2)."""
import inspect
src = inspect.getsource(block_writer)
for fn in ("async def write_block(", "async def get_block_context("):
start = src.index(fn)
# bound the search to this function: up to the next top-level def
rest = src[start + len(fn):]
nxt = rest.find("\nasync def ")
body = rest[: nxt if nxt != -1 else len(rest)]
assert "anti_pattern_directive()" in body, f"{fn} does not append the style directive"
@pytest.mark.asyncio
async def test_style_context_keeps_anti_patterns_when_overrides_fail(monkeypatch):
"""A chair-override outage must not silently un-teach the structural style.
Regression: the canonical list used to be emitted inside the overrides loop,
so a throw on an EARLIER category (golden_ratios) dropped it entirely.
"""
async def _boom(*a, **k):
raise RuntimeError("methodology table unavailable")
async def _empty(*a, **k):
return []
# Every DB accessor this function touches is stubbed — the test must not open
# a real connection (a live pool here leaks across the shared event loop and
# breaks unrelated tests later in the run).
monkeypatch.setattr(block_writer.db, "get_style_patterns", _empty)
monkeypatch.setattr(block_writer.db, "get_methodology_overrides", _boom)
monkeypatch.setattr(block_writer.db, "get_recent_decision_lessons", _empty)
ctx = await block_writer._build_style_context("היטל השבחה")
assert "נרטיב משפטי רציף" in ctx
for ap in ANTI_PATTERNS:
assert ap["note"] in ctx, f"anti-pattern dropped on override failure: {ap['name']}"

View File

@@ -0,0 +1,54 @@
"""Regression tests for argument aggregation (#233).
Both tests cover the same 2026-08-05 incident from different angles: the
appellant side of 1069-04-26 sent 310 propositions in one Claude call, the call
came back as something other than a JSON array, and the code logged a warning
and returned ``[]``. The caller could not tell that apart from "this side has no
arguments", so ``aggregate_claims_to_arguments`` reported ``completed`` with the
central litigant of the appeal missing entirely.
"""
from __future__ import annotations
import pytest
from legal_mcp.services.argument_aggregator import (
MAX_PROPS_PER_CALL,
AggregationFailed,
_aggregate_party,
_chunk,
)
def test_chunk_preserves_every_proposition_and_their_order():
"""Chunking must not drop or reorder — losing claims here is invisible."""
props = [{"i": i} for i in range(310)]
chunks = _chunk(props, MAX_PROPS_PER_CALL)
assert sum(len(c) for c in chunks) == 310, "propositions were lost"
assert [p for c in chunks for p in c] == props, "order changed"
assert all(len(c) <= MAX_PROPS_PER_CALL for c in chunks)
@pytest.mark.asyncio
async def test_non_list_reply_raises_instead_of_dropping_the_side(monkeypatch):
"""A malformed reply must surface, never look like an empty side.
This is the exact 1069-04-26 failure. If this test ever goes back to
asserting ``== []``, the silent-drop bug has been reintroduced.
"""
async def _query_json(prompt, tools=""): # noqa: ARG001
return {"error": "not a list"}
monkeypatch.setattr(
"legal_mcp.services.argument_aggregator.claude_session.query_json",
_query_json,
)
with pytest.raises(AggregationFailed) as excinfo:
await _aggregate_party("appellant", [{"id": "x", "claim_text": "t"}])
# The message has to name the side, or an operator reading
# completed_with_errors cannot tell which litigant went missing.
assert "appellant" in str(excinfo.value)

View File

@@ -0,0 +1,65 @@
"""#232 trap 3 — a well-formed docket is not necessarily the right docket.
ערר (מרכז) 1094-09-19 (פדילה) was stored as ``1094-09-14``: shape-valid, so
``_is_clean_docket`` waved it through, but wrong — and case_number is the
identity field, so the row detached from every reference to the real case.
Grounding the digits in the source text is what shape validation cannot do.
"""
from legal_mcp.services.precedent_metadata_extractor import (
_docket_grounded,
_is_clean_docket,
_strip_invisibles,
)
HEADER = "ערר (ועדות ערר - תכנון ובנייה מרכז) 1094-09-19 פדילה אברהים נ' הוועדה המקומית"
def test_the_regression_shape_valid_but_wrong_digit():
"""Both pass the shape check; only the real one is grounded."""
assert _is_clean_docket("1094-09-14")
assert _is_clean_docket("1094-09-19")
assert _docket_grounded("1094-09-19", HEADER)
assert not _docket_grounded("1094-09-14", HEADER)
def test_separator_and_spacing_are_tolerated():
"""A real docket must still match when the source writes it differently."""
assert _docket_grounded("1094-09-19", "בערר 1094/09/19 נקבע")
assert _docket_grounded("4768/22", "עת\"מ 4768-22 פלוני")
assert _docket_grounded("1132-09-24", "תיק 1132 - 09 - 24")
def test_bidi_marks_inside_the_number_do_not_defeat_grounding():
"""Hebrew legal PDFs embed RLM/LRM between digits and separators."""
noisy = "ערר (‏מרכז‏) 1094-09-19 פדילה"
assert _strip_invisibles(noisy).count("") == 0
assert _docket_grounded("1094-09-19", noisy)
def test_grounding_accepts_the_value_being_replaced():
"""Normalising an uploader's citation string into a clean docket is the
whole point of the rewrite — the digits come from there, not the text."""
citation = "ערר (ועדות ערר - תכנון ובנייה מרכז) 1094-09-19 פדילה נ' טירה (נבו 4.12.2019)"
assert _docket_grounded("1094-09-19", "", citation)
assert not _docket_grounded("1094-09-14", "", citation)
def test_two_and_three_part_dockets_both_ground():
assert _docket_grounded("8031/21", "בהיטל השבחה 8031/21 נדון")
assert _docket_grounded("85074-09-24", "בל\"מ 85074-09-24")
def test_non_numeric_or_empty_never_grounds():
assert not _docket_grounded("", HEADER)
assert not _docket_grounded("ערר 1094", HEADER)
assert not _docket_grounded("abc-de", HEADER)
def test_absent_from_every_source_is_refused():
"""The פדילה failure mode: text has no docket at all, so anything the
model offers is ungrounded and must not reach the identity field."""
body = "בפני: יו\"ר הוועדה: רונית אלפר, עו\"ד\nהעוררים: 1. פדילה אברהים"
assert not _docket_grounded("1094-09-14", body)
assert not _docket_grounded("1094-09-19", body)

View File

@@ -0,0 +1,237 @@
"""Chair-position round-trip in analysis-and-research.md.
Regression cover for the reader/writer asymmetry that made "עמדת ועדת הערר"
appear to save on threshold claims and then vanish on refresh: the writer
matched the label anywhere on a line, the reader only at the start of one, and
the analyst agent writes threshold claims as a bullet list
("- **עמדת ועדת הערר:**") while it writes issues flush-left.
The invariant these tests pin down: whatever update_chair_position writes,
parse() must read back — for every label form that appears in the corpus.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from legal_mcp.services import research_md
BULLETED = """# ניתוח משפטי — ערר 1069-04-26
תאריך: 2026-08-02
## 5. טענות סף
### ס-1 — אי-מילוי תנאי ההפקדה
- **טענה (claim):** התכנית אושרה מבלי שמולאו תנאי ההפקדה.
- **שאלה משפטית:** האם הסטת התנאים פוגמת בחוקיות?
- **עמדת ועדת הערר:** [ימולא ע"י יו"ר הוועדה]
### ס-2 — זכות עמידה
- **טענה (claim):** לעוררים אין אינטרס מוגן.
- **עמדת ועדת הערר:** [ימולא ע"י יו"ר הוועדה]
## 6. סוגיות להכרעה
### סוגיה 1: סמכות לפי 62א(א)(11)
**ניתוח:**
- הכלל החל: ...
**עמדת ועדת הערר:** [ימולא ע"י יו"ר הוועדה]
---
"""
@pytest.fixture()
def analysis_file(tmp_path: Path) -> Path:
path = tmp_path / "analysis-and-research.md"
path.write_text(BULLETED, encoding="utf-8")
return path
def _positions(path: Path) -> dict[str, str]:
parsed = research_md.parse(path)
items = parsed["threshold_claims"] + parsed["issues"]
return {item["id"]: item["chair_position"] for item in items}
# ── the reported bug ────────────────────────────────────────────
def test_bulleted_threshold_position_survives_a_reload(analysis_file: Path) -> None:
"""The bug as chaim hit it: saved on a threshold claim, gone on refresh."""
research_md.update_chair_position(analysis_file, "threshold_1", "עמדתנו היא שהטענה נדחית.")
assert _positions(analysis_file)["threshold_1"] == "עמדתנו היא שהטענה נדחית."
def test_flush_left_issue_position_still_round_trips(analysis_file: Path) -> None:
"""The surface that already worked must keep working."""
research_md.update_chair_position(analysis_file, "issue_1", "יש לקבל את הערר בנקודה זו.")
assert _positions(analysis_file)["issue_1"] == "יש לקבל את הערר בנקודה זו."
def test_bulleted_threshold_fields_are_parsed_at_all(analysis_file: Path) -> None:
"""Bulleted labels were invisible to the reader — the whole claim looked empty."""
threshold = research_md.parse(analysis_file)["threshold_claims"]
labels = [f["label"] for f in threshold[0]["fields"]]
assert "טענה (claim)" in labels
assert "שאלה משפטית" in labels
# the chair field is surfaced separately, never as a regular field
assert not any(research_md._is_chair_label(label) for label in labels)
# ── writer/reader agreement on content boundaries ───────────────
def test_position_containing_markdown_is_not_truncated(analysis_file: Path) -> None:
"""The old writer stopped the field at the first '*' and swallowed the rest."""
text = 'העמדה כוללת **הדגשה** וגם קו --- באמצע\nושורה שנייה.'
research_md.update_chair_position(analysis_file, "issue_1", text)
assert _positions(analysis_file)["issue_1"] == text
def test_update_does_not_touch_sibling_subsections(analysis_file: Path) -> None:
research_md.update_chair_position(analysis_file, "threshold_1", "ראשונה")
research_md.update_chair_position(analysis_file, "threshold_2", "שנייה")
positions = _positions(analysis_file)
assert positions["threshold_1"] == "ראשונה"
assert positions["threshold_2"] == "שנייה"
assert positions["issue_1"] == ""
def test_closing_rule_and_bullet_marker_are_preserved(analysis_file: Path) -> None:
"""An update must not restructure the file the analyst produced."""
research_md.update_chair_position(analysis_file, "threshold_1", "עמדה")
research_md.update_chair_position(analysis_file, "issue_1", "עמדה")
content = analysis_file.read_text(encoding="utf-8")
assert "- **עמדת ועדת הערר:**" in content
assert content.rstrip().endswith("---")
def test_clearing_a_position_restores_the_placeholder(analysis_file: Path) -> None:
research_md.update_chair_position(analysis_file, "threshold_1", "עמדה")
research_md.update_chair_position(analysis_file, "threshold_1", "")
assert _positions(analysis_file)["threshold_1"] == ""
assert research_md.CHAIR_POSITION_PLACEHOLDERS[0] in analysis_file.read_text(
encoding="utf-8"
)
def test_repeated_saves_do_not_accumulate_blocks(analysis_file: Path) -> None:
for text in ("ראשון", "שני", "שלישי"):
research_md.update_chair_position(analysis_file, "threshold_1", text)
content = analysis_file.read_text(encoding="utf-8")
assert content.count(f"**{research_md.CHAIR_POSITION_LABEL}:**") == 3
assert _positions(analysis_file)["threshold_1"] == "שלישי"
# ── decorated and duplicated labels ─────────────────────────────
def test_decorated_label_is_updated_not_duplicated(tmp_path: Path) -> None:
"""'עמדת ועדת הערר (הכוונת יו"ר 24.6)' appears in the corpus (8125-09-24)."""
path = tmp_path / "a.md"
path.write_text(
"## 6. סוגיות להכרעה\n\n"
"### סוגיה 1: כותרת\n\n"
'**עמדת ועדת הערר (הכוונת יו"ר 24.6):** [ימולא ע"י יו"ר הוועדה]\n',
encoding="utf-8",
)
research_md.update_chair_position(path, "issue_1", "עמדה מעודכנת")
content = path.read_text(encoding="utf-8")
assert content.count(research_md.CHAIR_POSITION_LABEL) == 1
assert _positions(path)["issue_1"] == "עמדה מעודכנת"
def test_duplicate_labels_read_back_the_one_that_was_written(tmp_path: Path) -> None:
"""An H3 covering several issues (1033-02-25) must not read a sibling's text."""
path = tmp_path / "a.md"
path.write_text(
"## 6. סוגיות להכרעה\n\n"
"### סוגיות 46: מקובצות\n\n"
"**עמדת ועדת הערר:** ראשונה\n\n"
"**עמדת ועדת הערר:** אחרונה\n",
encoding="utf-8",
)
research_md.update_chair_position(path, "issue_1", "העמדה הנכונה")
assert _positions(path)["issue_1"] == "העמדה הנכונה"
# ── read-after-write guard ──────────────────────────────────────
def test_update_returns_the_persisted_value(analysis_file: Path) -> None:
result = research_md.update_chair_position(analysis_file, "threshold_1", " עמדה ")
assert result["saved"] is True
assert result["position"] == "עמדה"
def test_a_write_the_parser_cannot_read_back_is_reported_as_failure(
analysis_file: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Regression guard: the bug hid behind a success response for months."""
monkeypatch.setattr(
research_md, "_stored_chair_position", lambda *_args: "משהו אחר"
)
with pytest.raises(RuntimeError, match="לא נקראה בחזרה"):
research_md.update_chair_position(analysis_file, "threshold_1", "עמדה")
# ── downstream consumers see the recovered positions ────────────
def test_chair_directions_count_bulleted_positions(analysis_file: Path) -> None:
"""extract_chair_directions feeds legal-writer; it under-reported before."""
research_md.update_chair_position(analysis_file, "threshold_1", "עמדה")
directions = research_md.extract_chair_directions(analysis_file)
assert directions["filled_count"] == 1
assert directions["status"] == "partial"
filled = [t for t in directions["threshold_claims"] if t["direction"]]
assert [t["id"] for t in filled] == ["threshold_1"]
# ── the real corpus ─────────────────────────────────────────────
CORPUS = sorted(
Path("/home/chaim/legal-ai/data/cases").glob(
"*/documents/research/analysis-and-research.md"
)
)
@pytest.mark.skipif(not CORPUS, reason="case corpus not present on this host")
@pytest.mark.parametrize("source", CORPUS, ids=lambda p: p.parts[-4])
def test_every_corpus_subsection_round_trips(source: Path, tmp_path: Path) -> None:
sentinel = "עמדת-בדיקה — **הדגשה** ו---קו\nושורה שנייה."
before = _positions(source)
for section_id in before:
working = tmp_path / f"{section_id}.md"
working.write_text(source.read_text(encoding="utf-8"), encoding="utf-8")
research_md.update_chair_position(working, section_id, sentinel)
after = _positions(working)
assert after[section_id] == sentinel, f"{source.parts[-4]} {section_id}"
untouched = {k: v for k, v in after.items() if k != section_id}
assert untouched == {k: v for k, v in before.items() if k != section_id}

View File

@@ -0,0 +1,67 @@
"""#232 — the source_kind selector must not silently hide a corpus.
`search_precedent_library` defaulted to source_kind='external_upload' all the
way down the stack, so 104 appeals-committee decisions (29% of the corpus)
were unreachable through the entry point the writing agents actually call.
These tests pin the selector semantics; the end-to-end retrieval check lives
in scripts/test_retrieval_by_name.py (needs a live DB).
"""
import pytest
from legal_mcp.services.db import _source_kind_clause
def test_empty_selector_means_no_filter():
"""'' and 'all' must produce no predicate — the whole corpus."""
assert _source_kind_clause("") == ""
assert _source_kind_clause("all") == ""
assert _source_kind_clause(" ") == ""
def test_named_kinds_produce_equality_predicate():
assert _source_kind_clause("internal_committee", "cl.") == (
"cl.source_kind = 'internal_committee'"
)
assert _source_kind_clause("external_upload") == "source_kind = 'external_upload'"
def test_all_committees_expands_to_both_shapes():
"""Committee decisions live under two historical shapes — cover both."""
clause = _source_kind_clause("all_committees", "cl.")
assert "cl.source_kind = 'internal_committee'" in clause
assert "cl.source_type = 'appeals_committee'" in clause
assert clause.startswith("(") and clause.endswith(")")
def test_unknown_selector_raises_rather_than_reaching_sql():
"""The value is f-string-interpolated, so the whitelist is the guard."""
with pytest.raises(ValueError, match="source_kind"):
_source_kind_clause("'; DROP TABLE case_law; --")
with pytest.raises(ValueError):
_source_kind_clause("internal")
def test_default_of_the_search_entry_points_is_whole_corpus():
"""A regression guard on the defaults themselves — this is the bug."""
import inspect
from legal_mcp.services import hybrid_search, precedent_library
from legal_mcp.services import db as db_mod
from legal_mcp.tools import precedent_library as plib_tool
for fn in (
db_mod.search_precedent_library_semantic,
db_mod.search_precedent_library_lexical,
db_mod.list_external_case_law,
hybrid_search.search_precedent_library_hybrid,
precedent_library.search_library,
precedent_library.list_precedents,
plib_tool.search_precedent_library,
plib_tool.precedent_library_list,
):
default = inspect.signature(fn).parameters["source_kind"].default
assert default == "", (
f"{fn.__module__}.{fn.__qualname__} defaults source_kind to "
f"{default!r} — that hides a corpus from every caller (#232)"
)

View File

@@ -26,6 +26,7 @@
| `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 = עבר). | ידני אחרי שינוי שכבת חיפוש |
| `eval_gold_bootstrap.py` | python | **FU-5 (GAP-11) — bootstrap ל-gold-set** של הערכת-אחזור ל-`data/eval/gold-set.jsonl`. שני מקורות: `--source citations` (cited==relevant מ-`search_relevance_feedback`; ריק עד שייצברו ציטוטים) ו-`--source known_item` (query=שם-תיק → relevant=עצמו; אות אמיתי היום). Idempotent — שומר שורות `source=chair`, מחדש `bootstrap_*`. דורש POSTGRES. | לפני eval; חוזר כשנצבר ground-truth |
| `eval_retrieval.py` | python | **FU-5 (GAP-11, INV-RET4/G8) — harness הערכת-אחזור** — מריץ את מסלול-האחזור בייצור (`search_library`/`search_internal`) על ה-gold-set, מחשב precision@k/recall@k/MRR/nDCG@k (k=5,10), מצרף overall+per-corpus+per-PA ל-`data/eval/eval-report-<ts>.{json,md}` + delta מול `data/eval/baseline.json` (מתעד retrieval_config). `--self-test` בודק את המטריקות offline; `--update-baseline` מאמץ snapshot. **שער-CI במשמעת:** הרץ לפני/אחרי כל שינוי בשכבת-האחזור באותו קונפיג. דורש POSTGRES+VOYAGE_API_KEY. | לפני/אחרי שינוי RRF/k/embedder/rerank |
| `legal-mcp-http.config.cjs` | pm2/js | **שרת ה-MCP חשוף ב-streamable-http** (#231) — `python -m legal_mcp.server` עם `MCP_TRANSPORT=streamable-http`, bound **`127.0.0.1:8790`**, Bearer `MCP_HTTP_SHARED_SECRET` מ-`~/.legal-mcp-http.env` (מקור-אמת: Infisical → All Infrastructure / `main` / `/apps/legal-ai`, תג `credentials`). **למה:** סוכנים המונעים דרך Agent Client Protocol מקבלים את שרתי-ה-MCP שלהם מהלקוח בפתיחת הסשן, והערוץ הזה נושא שרתי HTTP בלבד — ל-stdio אין מסלול לשם, ומכאן שהסוכנים נותרו בלי 108 הכלים. **אינו מחליף את stdio:** כל סשן אינטראקטיבי ממשיך דרך `~/.claude.json`; אותו קוד, אותו מרשם-כלים, שתי תחבורות (G2). **אבטחה:** loopback בלבד (צר יותר מ-`10.0.1.1` של legal-chat-service — שום קונטיינר לא צריך MCP), והשרת **מסרב לעלות בלי טוקן** (`services/http_auth.py`), כך שתקלת-הגדרה לא יכולה לייצר מאזין לא-מאומת. מראָה לדפוס `legal-chat-service.config.cjs`. התקנה: `pm2 start scripts/legal-mcp-http.config.cjs && pm2 save`. בדיקה: POST ל-`/mcp` → 401 בלי טוקן, 200 עם. | pm2 (host-side) |
| `legal-court-fetch-service.config.cjs` | pm2/js | **שירות-מארח Tier-1 לאחזור פסקי-דין מנט המשפט (X13)** — 2 apps: (א) `legal-court-fetch-xvfb` (Xvfb :99, צג-וירטואלי ל-Camoufox); (ב) `legal-court-fetch-service` (`python -m legal_mcp.court_fetch_service.server`, bound `10.0.1.1:8771`, Bearer `COURT_FETCH_SHARED_SECRET` מ-`~/.legal-court-fetch-service.env`, `DISPLAY=:99`). מריץ Camoufox דרך חבילת-הפייתון (in-process) כי הקונטיינר לא יכול דפדפן. תלות: `pip install -e "mcp-server[court-fetch]" && python -m camoufox fetch`. אחזור = ניווט→צופה→`GetImages`(X-Requested-With)→PDF, ללא CAPTCHA; כשל→`ok:false`→orchestrator מסלים ל-fallback אנושי. **אומת על עת"מ 46111-12-22 (34 עמ').** מראָה לדפוס `legal-chat-service.config.cjs`. ספ: `docs/spec/X13-court-fetch.md`. התקנה: `pm2 start scripts/legal-court-fetch-service.config.cjs && pm2 save`. בריאות: `curl http://10.0.1.1:8771/health`. | pm2 (host-side) |
| `drain_court_fetch.py` | python | **ריקון תור-אחזור הפסיקה (X13)** — קורא ל-`court_fetch_orchestrator.drain_pending(limit)` שמוריד+קולט כל job ממתין שהיומונים מילאו, וקושר חזרה ליומון. מקומי בלבד (ingest = claude CLI). no-op מהיר כשהתור ריק. הרצה ידנית: `mcp-server/.venv/bin/python scripts/drain_court_fetch.py [limit]`. | דרך `legal-court-fetch-drain.config.cjs` (pm2 cron) |
| `legal-court-fetch-drain.config.cjs` | pm2/js | **תזמון שעתי של `drain_court_fetch.py`** (cron `17 * * * *`, `COURT_FETCH_DRAIN_CRON` לעקיפה) — הופך את לולאת יומון→אחזור→קליטה ל-fully-autonomous. `autorestart:false` (one-shot per tick). דורש `legal-court-fetch-service` רץ. התקנה: `pm2 start scripts/legal-court-fetch-drain.config.cjs && pm2 save`. | pm2 cron (host-side) |
@@ -98,6 +99,7 @@
|--------|------|---------|-----------|
| `spec-guard.sh` | bash | **PreToolUse hook לאכיפת "פרוטוקול כתיבת-קוד"** (CLAUDE.md §פרוטוקול כתיבת-קוד) — בכל Edit/Write/MultiEdit על נתיב-קוד (`web/`, `mcp-server/`, `web-ui/src/`, `scripts/`, `adapters/`) מזריק תזכורת ל-Claude לקרוא את `docs/spec/00-constitution.md`+ספ-התחום ולוודא קיום G1G12 — לפני שכותבים. **+ leak-guard בזמן-אמת (G12):** על כתיבה ל-`mcp-server/src/*` בודק את התוכן-הנכתב (`new_string`/`content`) ומזהיר אם מוזרק מונח-Paperclip לשכבת-האינטליגנציה (לא-deduped). המקבילה האינטראקטיבית ל-INV-AG1. קלט JSON ב-stdin, פלט `hookSpecificOutput.additionalContext` (non-blocking, exit 0). Dedup פעם-בסשן לתזכורת-הספ. רשום ב-`.claude/settings.json`. | נקרא אוטומטית ע"י Claude Code (hook) |
| `leak_guard.py` | python | **המאכף הקנוני של INV-G12 (שער-הפלטפורמה / docs/spec/X15 §4 / R4).** שני כללים קשיחים: (1) `mcp-server/src` ללא סמלי-Paperclip (allowlist מנומק לפי substring); (2) רק `web/agent_platform_port.py` (+ קבצי-המעטפת) מייבאים את לקוח-Paperclip. stdlib-בלבד (אין venv). `leak_guard.py` = סריקת-repo (exit 1 על הפרה); `leak_guard.py <file>...` = קבצים נתונים (ל-hook). משותף ל-spec-guard.sh (hook), ל-CI (`.gitea/workflows/leak-guard.yaml`) ול-`mcp-server/tests/test_platform_port_leak_guard.py`. | CI + hook + pytest |
| `agent_tool_grants_guard.py` | python | **המאכף הקנוני של INV-AG3 (מפת-הרשאות הסוכנים / docs/spec/X4-agents.md §2א).** ה-frontmatter `tools:` של סוכן הוא **allow-list סגורה** — כלי הרשום בשרת-ה-MCP אך חסר ממנה אינו ניתן לקריאה, גם כשהשרת מחובר. ארבעה כללים קשיחים: (1) כל `mcp__legal-ai__X` המופיע ב-`web/` (delegation שיוצר issue לסוכן) מוענק לסוכן כלשהו; (2) כל `mcp__legal-ai__X` בגוף קובץ-סוכן מוענק ב-frontmatter של **אותו** קובץ; (3) אין הענקה לכלי שאינו רשום ב-`@mcp.tool`; (4) שם-כלי בגרשיים-הפוכים ללא תחילית — מוענק, או מסווג ב-`CONTRASTIVE_OK` עם נימוק (כולל בדיקת-התיישנות לסיווגים). מחריג קבצים שאינם סוכני-claude_local: `hermes-curator.md`, `legal-analyst-gemini-critique.md`, `HEARTBEAT.md`. **כלל 5 (host-only, `--check-bindings`):** allow-list נאכפת רק כשה-runtime בוחר את הסוכן (`--agent <name>`); בלעדיו אותו קובץ נמסר כ-`--append-system-prompt-file` — פרוזה, לא שער — וכל הכלים נשארים נגישים. הקשירה יושבת ב-DB של הפלטפורמה ולכן ה-CI לא רואה אותה; בלי הדגל השער **אומר זאת במפורש** במקום לרמוז על אכיפה שלא אימת. stdlib-בלבד. נבנה אחרי CMP-229 (2026-08-04) — `analyze_protocol` נרשם בשרת ב-2026-06-30 בלי הענקה, ו-#226 הורה למנתח להריץ אותו. CI: `.gitea/workflows/agent-tool-grants.yaml`. | CI |
| `check_undefined_names.py` | python | **CI gate ל-undefined names (מחלקת ה-NameError).** מריץ pyflakes על `web`, `mcp-server/src`, `scripts` ומפיל build (exit 1) רק על "undefined name"/"may be undefined" — לא על imports-לא-בשימוש/f-strings (רעש). זו בדיוק מחלקת-הבאג של PR #249 (שינוי-שם תיק → 500): שם שמופנה אך לא מיובא/מוגדר, חבוי בתוך `background_tasks` עד זמן-ריצה. דורש pyflakes (ה-workflow מתקין ל-venv זמני). משותף ל-CI (`.gitea/workflows/lint.yaml`). | CI |
| `auto-sync-cases.sh` | bash | סנכרון תיקי ערר ל-Gitea — רץ כל דקה | `* * * * *` (cron) |
| `host_sync.sh` | bash | מסנכרן את עץ-המארח `~/legal-ai` ל-origin/main (ff-only) כדי שקוד-המארח (כותב/פאנלים/MCP שרצים מהעץ, לא בקונטיינר) יתעדכן אחרי merge; restart מדויק ל-chat/court-fetch/reaper רק כשקבציהם משתנים. בטוח: אף-פעם לא force; tasks.json הדירטי נשמר. סוגר את פער-פריסת-המארח (TaskMaster #160) | `* * * * *` (cron, flock) |

View File

@@ -0,0 +1,319 @@
#!/usr/bin/env python3
"""INV-AG3 guard — every MCP tool an agent is TOLD to run must be GRANTED to it.
The canonical checker for INV-AG3 (docs/spec/X4-agents.md §2א): a Claude-Code
subagent's ``tools:`` frontmatter is a CLOSED allow-list. A tool that is
registered on the MCP server but absent from that list is *not callable* by the
agent, however well the server is connected.
Why this exists — the failure it is built to catch (2026-08-04):
``analyze_protocol`` shipped on 2026-06-30 (24e3e2f) touching 9 files, none of
them ``.claude/agents/*``. Later ``wake_analyst_for_protocol_analysis``
(web/paperclip_client.py, #226) started writing "הרץ
``mcp__legal-ai__analyze_protocol(...)``" straight into the analyst's issue.
The analyst therefore received an explicit instruction to run a tool it was
never granted, reported "tools exist on the connected server but aren't exposed
as callable in this session", and burned two runs working around it via raw
psql + a hand-written script. INV-AG3 already covered this on paper; its
enforcement was deferred ("אכיפה אוטומטית עתידית"), so the drift went unnoticed
for five weeks. This script is that deferred enforcement.
Three HARD rules:
1. **Backend delegation.** Every ``mcp__legal-ai__X`` named inside ``web/``
(the backend telling an agent what to run) must be granted to at least one
agent. This is the rule that catches the 2026-08-04 failure.
2. **Per-agent instructions.** Every ``mcp__legal-ai__X`` in an agent file's
BODY must be granted in that same file's frontmatter. Prefixed mentions are
imperative by convention ("הרץ `mcp__legal-ai__…`").
3. **No phantom grants.** Every granted tool must actually be registered on
the MCP server — catches typos and tools deleted out from under an agent.
Plus one reviewed-exception rule:
4. **Bare tool names.** An agent body may name a tool in backticks without the
``mcp__legal-ai__`` prefix (```get_legal_arguments```). Those are
ambiguous: some are real requirements, others are deliberately contrastive
("**לא** דרך `precedent_library_upload`"), a pointer at *another* agent's
job, or a DB column that merely shares a tool's name. Each is classified
once in ``CONTRASTIVE_OK`` below; anything new fails until reviewed.
And one host-only rule, opt-in via ``--check-bindings``:
5. **Bindings.** Rules 14 compare files to files, which says nothing about
whether an allow-list is *enforced*. It is only enforced when the runtime
selects that agent (``--agent <name>``); without the flag the same file is
delivered as ``--append-system-prompt-file`` — prose, not a gate — and every
tool stays reachable. Found on 2026-08-05: one agent declared 41 grants with
no ``--agent`` flag, so the largest allow-list in the system was inert while
this guard reported OK. The binding lives in the platform DB, so CI cannot
see it; without the flag the guard now says so out loud instead of implying
enforcement it never verified.
NOT AGENTS (no frontmatter by design — the adapter sends the file as a raw
prompt, so YAML would leak into it): ``hermes-curator.md`` (deepseek_local),
``legal-analyst-gemini-critique.md`` (gemini_local). ``HEARTBEAT.md`` is a
shared checklist, not an agent. All three are skipped.
Usage:
agent_tool_grants_guard.py # exit 1 on any violation
"""
from __future__ import annotations
import json
import os
import subprocess
import re
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
AGENTS_DIR = REPO / ".claude" / "agents"
MCP_SRC = REPO / "mcp-server" / "src"
BACKEND_DIR = REPO / "web"
# Files under .claude/agents/ that are not claude_local subagent definitions.
NOT_AGENTS = {
"HEARTBEAT.md",
"hermes-curator.md",
"legal-analyst-gemini-critique.md",
}
# Bare (unprefixed) tool names in an agent body that are NOT requirements.
# Each entry is (agent file, tool, why) — reviewed 2026-08-04. Adding to this
# map is a deliberate act: it asserts "the agent is not being told to call this".
CONTRASTIVE_OK = {
("legal-analyst.md", "case_create"): "prose about the cases.practice_area CHECK constraint, not a call",
("legal-analyst.md", "search_internal_decisions"): "names the filter surface when contrasting Axis A/B",
("legal-ceo.md", "search_decisions"): "contrast — 'search_decisions = only Dafna' vs the granted search_internal_decisions",
("legal-ceo.md", "precedent_library_upload"): "explicitly the forbidden path ('לא דרך …', citation guard rejects)",
("legal-ceo.md", "document_update"): "describes the tagging chaim must fix, not a CEO call",
("legal-proofreader.md", "extraction_status"): "the documents.extraction_status DB column — name collides with a tool",
("legal-qa.md", "precedent_attach"): "explicitly the researcher's job ('דרך precedent_attach של ה-researcher')",
("legal-writer.md", "revise_draft"): "the CEO calls it ('CEO יקרא ל-revise_draft'), not the writer",
("legal-writer.md", "search_case_precedents"): "a do-not-confuse disambiguation note ('שונה! … לא לבלבל')",
}
TOOL_RE = re.compile(r"mcp__legal-ai__(\w+)")
REGISTER_RE = re.compile(r"@mcp\.tool\([^)]*\)\s*(?:async\s+)?def\s+(\w+)")
# `tool_name(` or `tool_name` inside backticks.
BARE_RE = re.compile(r"`(\w+)[(`]")
def server_tools() -> set[str]:
"""Tool names registered on the MCP server."""
out: set[str] = set()
for path in MCP_SRC.rglob("*.py"):
out |= set(REGISTER_RE.findall(path.read_text(encoding="utf-8", errors="ignore")))
return out
def split_frontmatter(text: str) -> tuple[str, str]:
"""Return (frontmatter, body). Empty frontmatter when the file has none."""
if not text.startswith("---"):
return "", text
parts = text.split("---")
if len(parts) < 3:
return "", text
return parts[1], "---".join(parts[2:])
def agent_files() -> list[Path]:
return sorted(p for p in AGENTS_DIR.glob("*.md") if p.name not in NOT_AGENTS)
def check_bindings() -> list[tuple[str, str]]:
"""Return [(agent file, why)] for agents whose allow-list nothing enforces.
A ``tools:`` list is only an allow-list when the runtime is told which agent
to be. The local adapter enforces it under ``--agent <name>``; without that
flag the very same file is delivered as ``--append-system-prompt-file``, i.e.
prose the model may follow or ignore, and every tool stays reachable.
Found the hard way on 2026-08-05: one agent carried 41 grants and no
``--agent`` flag, so the largest allow-list in the system was inert — and
this guard had been reporting OK on it, because Rules 14 only ever compare
files to files.
Host-only. The binding lives in the platform's database, which CI cannot
reach, so this shells out to ``psql`` rather than adding a driver dependency
that would break the stdlib-only property the CI path relies on. Returns []
when the database is unreachable — an unreachable DB is "not checked", not
"no violations", and the caller prints that distinction.
"""
sql = (
"select adapter_config->>'instructionsEntryFile', "
"coalesce(adapter_config->>'extraArgs','') "
"from agents where adapter_type='claude_local' "
"and adapter_config->>'instructionsEntryFile' is not null;"
)
try:
out = subprocess.run(
["psql", "-h", "localhost", "-p", "54329", "-U", "paperclip",
"-d", "paperclip", "-X", "-A", "-t", "-F", "\t", "-c", sql],
capture_output=True, text=True, timeout=20,
env={**os.environ, "PGPASSWORD": os.environ.get("PGPASSWORD", "paperclip")},
)
except (OSError, subprocess.SubprocessError):
return []
if out.returncode != 0:
return []
bad: dict[str, str] = {}
for line in out.stdout.splitlines():
if "\t" not in line:
continue
entry_file, extra = line.split("\t", 1)
entry_file = entry_file.strip()
if not entry_file or entry_file in NOT_AGENTS:
continue
want = entry_file[:-3] if entry_file.endswith(".md") else entry_file
try:
args = json.loads(extra) if extra.strip() else []
except json.JSONDecodeError:
args = []
# Only a literal ["--agent", "<name>"] pair binds the allow-list.
ok = any(
a == "--agent" and i + 1 < len(args) and args[i + 1] == want
for i, a in enumerate(args)
)
if not ok:
bad[entry_file] = (
"extraArgs is empty" if not args
else f"extraArgs={extra.strip()} does not select '{want}'"
)
# Only report agents that actually declare grants — an agent with no tools:
# list has nothing to enforce and is not a finding.
result = []
for path in agent_files():
if path.name in bad:
fm, _ = split_frontmatter(path.read_text(encoding="utf-8", errors="ignore"))
if TOOL_RE.findall(fm):
result.append((path.name, bad[path.name]))
return sorted(result)
def main() -> int:
registered = server_tools()
if not registered:
print("agent-tool-grants: FAIL — no @mcp.tool registrations found; is the tree complete?")
return 1
grants: dict[str, set[str]] = {}
bodies: dict[str, str] = {}
for path in agent_files():
fm, body = split_frontmatter(path.read_text(encoding="utf-8", errors="ignore"))
grants[path.name] = set(TOOL_RE.findall(fm))
bodies[path.name] = body
all_granted: set[str] = set().union(*grants.values()) if grants else set()
violations: list[str] = []
# Rule 1 — backend delegation must land on a granted tool.
for path in sorted(BACKEND_DIR.rglob("*.py")):
text = path.read_text(encoding="utf-8", errors="ignore")
for tool in sorted(set(TOOL_RE.findall(text))):
if tool not in all_granted:
rel = path.relative_to(REPO)
violations.append(
f"[1 backend] {rel} instructs an agent to run "
f"mcp__legal-ai__{tool}, but NO agent grants it.\n"
f" fix: add `- mcp__legal-ai__{tool}` to the tools: "
f"frontmatter of the agent that receives that issue."
)
# Rule 2 — a prefixed mention in an agent body is an instruction to that agent.
for name, body in bodies.items():
for tool in sorted(set(TOOL_RE.findall(body))):
if tool not in grants[name]:
violations.append(
f"[2 instructions] .claude/agents/{name} tells the agent to run "
f"mcp__legal-ai__{tool}, which its own tools: list omits.\n"
f" fix: add `- mcp__legal-ai__{tool}` to that frontmatter."
)
# Rule 3 — no grant may point at a tool the server does not register.
for name, granted in grants.items():
for tool in sorted(granted - registered):
violations.append(
f"[3 phantom] .claude/agents/{name} grants mcp__legal-ai__{tool}, "
f"which is not registered on the MCP server.\n"
f" fix: correct the name, or drop the grant if the tool was removed."
)
# Rule 4 — every bare tool name is either granted or classified as contrastive.
for name, body in bodies.items():
bare = {m for m in BARE_RE.findall(body) if m in registered}
for tool in sorted(bare - grants[name]):
if (name, tool) in CONTRASTIVE_OK:
continue
violations.append(
f"[4 bare name] .claude/agents/{name} mentions `{tool}` — a real MCP "
f"tool it is not granted.\n"
f" fix: grant it if the agent must call it, otherwise add "
f"(\"{name}\", \"{tool}\") to CONTRASTIVE_OK with the reason."
)
# Stale exceptions: a classification that no longer matches the text is noise.
for (name, tool), _why in sorted(CONTRASTIVE_OK.items()):
if name not in bodies:
violations.append(
f"[4 stale] CONTRASTIVE_OK names {name}, which is not an agent file."
)
elif tool not in {m for m in BARE_RE.findall(bodies[name])}:
violations.append(
f"[4 stale] CONTRASTIVE_OK ({name}, {tool}) no longer appears in that "
f"file — drop the exception."
)
# Rule 5 — a grant list only binds if the runtime actually selects that agent.
unenforced = check_bindings() if "--check-bindings" in sys.argv else None
if unenforced:
for name, detail in unenforced:
violations.append(
f"[5 binding] .claude/agents/{name} declares a tools: allow-list, but "
f"the runtime does not select that agent — {detail}.\n"
f" The list is inert: it is delivered as prompt text only, so "
f"every tool remains callable.\n"
f" fix: set adapter_config.extraArgs to "
f'["--agent", "{name[:-3]}"], or drop tools: and document the agent as '
f"unrestricted. Not both."
)
if violations:
print(f"INV-AG3 agent-tool-grants guard: {len(violations)} violation(s)\n")
for v in violations:
print(f"{v}")
print(
"\ndocs/spec/X4-agents.md §2א INV-AG3 — the frontmatter tools: list is a "
"CLOSED allow-list.\nA tool missing from it is not callable, no matter that "
"the MCP server is connected."
)
return 1
print(
f"INV-AG3 agent-tool-grants guard: OK "
f"({len(agent_files())} agents, {len(all_granted)} distinct grants, "
f"{len(registered)} tools registered)"
)
if unenforced is None:
# Say plainly what was NOT checked. A guard that prints a bare "OK" invites
# the reader to conclude the allow-lists are enforced; this one has only
# compared files to files. Enforcement is a runtime property (see Rule 5),
# and on 2026-08-05 exactly one agent was found declaring 41 grants that
# nothing enforces — while this guard reported OK.
print(
" note: file-level only. Whether each allow-list is actually ENFORCED "
"depends on the\n runtime passing --agent <name>, which needs the platform "
"DB — re-run with --check-bindings\n on the host to verify."
)
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -166,17 +166,33 @@ def aggregate_cell(per_run: list[dict]) -> dict:
"""Mean each metric across repeated generations of the same (case, block, effort)."""
if not per_run:
return {"distance": 1.0, "anti_pattern_total": 0.0, "change_percent": 100.0,
"golden_ratio_deviation_pp": None, "n": 0}
"golden_ratio_deviation_pp": None, "anti_by_pattern": {}, "n": 0}
ratios = [r["golden_ratio_deviation_pp"] for r in per_run if r.get("golden_ratio_deviation_pp") is not None]
return {
"distance": round(mean(r["distance"] for r in per_run), 4),
"anti_pattern_total": round(mean(r["anti_pattern_total"] for r in per_run), 2),
"change_percent": round(mean(r["change_percent"] for r in per_run), 2),
"golden_ratio_deviation_pp": round(mean(ratios), 2) if ratios else None,
"anti_by_pattern": _mean_by_pattern(per_run),
"n": len(per_run),
}
def _mean_by_pattern(per_run: list[dict]) -> dict:
"""Mean hits PER anti-pattern name across runs — the 'which rule broke' view.
A pattern absent from a run counts as 0 (count_anti_patterns omits zero-hit
keys), so the mean is over ALL runs, not only the ones that tripped it.
"""
names: set[str] = set()
for r in per_run:
names |= set((r.get("anti_by_pattern") or {}).keys())
return {
name: round(mean((r.get("anti_by_pattern") or {}).get(name, 0) for r in per_run), 2)
for name in sorted(names)
}
def _current_default(block_id: str) -> str | None:
from legal_mcp.services.block_writer import BLOCK_CONFIG, DEFAULT_EFFORT
cfg = BLOCK_CONFIG.get(block_id, {})
@@ -420,13 +436,30 @@ async def _finals_for_calibration(case_filter: str | None) -> list[dict]:
async def _score_cell(case_id, block_id: str, effort: str, final_section: str,
final_total_words: int, outcome: str, repeats: int) -> dict:
"""Generate `block_id` at `effort` `repeats` times; score each vs the final section."""
final_total_words: int, outcome: str, repeats: int,
model: str | None = None, instructions: str = "") -> dict:
"""Generate `block_id` at `effort` `repeats` times; score each vs the final section.
`model` (optional) A/Bs the generation model via write_block(model_override=…).
None ⇒ the pinned GENERATION_MODEL, i.e. the production path unchanged.
`instructions` (optional) is appended to the block prompt for EVERY cell in
the run — a prompt-variant A/B (e.g. an explicit formatting rule). It is
applied to all models so the comparison stays a model comparison rather
than silently becoming a prompt comparison.
"""
from legal_mcp.services import block_writer
from legal_mcp.services.style_distance import block_distance_to_final
runs: list[dict] = []
models_used: list[str] = []
for _ in range(repeats):
res = await block_writer.write_block(case_id, block_id, effort_override=effort)
res = await block_writer.write_block(
case_id, block_id, instructions=instructions,
effort_override=effort, model_override=model,
)
# Record what the CLI was actually asked to run, so a silent fallback to
# a different build is visible in the report rather than mis-attributed.
models_used.append(res.get("model_used") or "?")
scored = block_distance_to_final(
block_id, res.get("content", ""), final_section, outcome,
section_target_total_words=final_total_words,
@@ -434,6 +467,8 @@ async def _score_cell(case_id, block_id: str, effort: str, final_section: str,
runs.append(scored)
agg = aggregate_cell(runs)
agg["effort"] = effort
agg["model"] = model
agg["models_used"] = sorted(set(models_used))
agg["runs"] = runs
return agg
@@ -446,6 +481,7 @@ async def _run(args, ts: str) -> dict:
efforts = args.efforts
blocks = args.blocks
models = args.models
finals = await _finals_for_calibration(args.case)
cases_meta = []
@@ -468,11 +504,15 @@ async def _run(args, ts: str) -> dict:
section = _BLOCK_TO_SECTION.get(block_id)
plan[block_id] = [c for c in cases_meta if section and c["sections"].get(section)]
total_cells = sum(len(plan[b]) for b in blocks) * len(efforts) * args.repeats
total_cells = sum(len(plan[b]) for b in blocks) * len(efforts) * args.repeats * len(models)
grid_summary = {
"n_finals": len(cases_meta),
"finals": [c["case_number"] for c in cases_meta],
"blocks": blocks, "efforts": efforts, "repeats": args.repeats,
"models": models,
# Provenance: a prompt-variant run is NOT comparable to a baseline run,
# so the instruction text is recorded in the report, not just the shell.
"instructions": getattr(args, "instructions", "") or "",
"total_generations": total_cells,
"per_block_n": {b: len(plan[b]) for b in blocks},
}
@@ -480,6 +520,27 @@ async def _run(args, ts: str) -> dict:
if args.dry_run:
return {"dry_run": True, "grid": grid_summary, "by_block": {}}
by_model: dict[str, dict] = {}
for model in models:
by_block = await _run_blocks_for_model(
model, blocks, efforts, plan, args, ts, grid_summary, by_model, _BLOCK_TO_SECTION,
)
by_model[model] = by_block
# `by_block` stays the single-model shape (first model) so --rerank and the
# existing per-block report path keep working unchanged (G2 — no second
# result schema); multi-model runs additionally carry by_model.
out = {"dry_run": False, "grid": grid_summary, "by_block": by_model[models[0]]}
if len(models) > 1:
out["by_model"] = by_model
return out
async def _run_blocks_for_model(model, blocks, efforts, plan, args, ts, grid_summary,
by_model_so_far, _BLOCK_TO_SECTION) -> dict:
"""The per-block × per-effort grid for ONE generation model."""
from uuid import UUID
by_block: dict[str, dict] = {}
for block_id in blocks:
section = _BLOCK_TO_SECTION.get(block_id)
@@ -497,11 +558,12 @@ async def _run(args, ts: str) -> dict:
cell = await _score_cell(
UUID(c["case_id"]), block_id, effort, final_section,
c["final_total_words"], c["outcome"], args.repeats,
model=model, instructions=getattr(args, "instructions", "") or "",
)
except Exception as exc: # noqa: BLE001 — harness must survive any cell failure
logger.warning(
"calibration cell skipped: case=%s block=%s effort=%s%s",
c["case_number"], block_id, effort, exc,
"calibration cell skipped: case=%s block=%s effort=%s model=%s%s",
c["case_number"], block_id, effort, model, exc,
)
continue
per_effort_runs[effort].append(cell)
@@ -523,6 +585,7 @@ async def _run(args, ts: str) -> dict:
"anti_pattern_total": round(mean(r["anti_pattern_total"] for r in rows), 2),
"change_percent": round(mean(r["change_percent"] for r in rows), 2),
"golden_ratio_deviation_pp": round(mean(ratios), 2) if ratios else None,
"anti_by_pattern": _mean_by_pattern(rows),
"n": len(rows),
})
rec = recommend_effort(effort_rows)
@@ -532,6 +595,11 @@ async def _run(args, ts: str) -> dict:
"recommended": rec["effort"] if rec else None,
"confidence": rec["confidence"] if rec else None,
"confidence_margin": rec.get("confidence_margin") if rec else None,
"model": model,
# Model builds the CLI actually reported across this block's cells —
# a mismatch vs `model` means a silent fallback, not a real A/B.
"models_used": sorted({m for e in per_effort_runs.values()
for cell in e for m in cell.get("models_used", [])}),
"efforts": effort_rows,
"per_case": per_case,
}
@@ -541,11 +609,15 @@ async def _run(args, ts: str) -> dict:
# Blocks not yet done are simply absent from by_block; _write_report tolerates
# partial results. main() does the final flush once the loop finishes.
try:
_write_report({"dry_run": False, "grid": grid_summary, "by_block": by_block}, ts)
snap = {"dry_run": False, "grid": grid_summary, "by_block": by_block}
if by_model_so_far or len(grid_summary.get("models", [])) > 1:
snap["by_model"] = {**by_model_so_far, model: by_block}
_write_report(snap, ts)
except Exception as exc: # noqa: BLE001 — a write hiccup must not abort the run
logger.warning("incremental report write failed after block=%s%s", block_id, exc)
logger.warning("incremental report write failed after block=%s model=%s %s",
block_id, model, exc)
return {"dry_run": False, "grid": grid_summary, "by_block": by_block}
return by_block
IL_TZ = ZoneInfo("Asia/Jerusalem")
@@ -578,7 +650,10 @@ def _write_report(result: dict, ts: str) -> tuple[Path, Path]:
"ההמלצה אדוויזורית; ההכרעה בידי היו\"ר/המפעיל.\n",
f"- בלוקים: {', '.join(g['blocks'])}",
f"- efforts: {', '.join(g['efforts'])} · repeats/cell: {g['repeats']}",
f"- models: {', '.join(m or 'pinned-default' for m in g.get('models', [None]))}",
f"- סך ייצורי-מודל: {g['total_generations']}",
(f"- ⚠️ **וריאנט-פרומפט** (לא בר-השוואה לריצת-בסיס): `{g['instructions']}`"
if g.get("instructions") else "- וריאנט-פרומפט: — (פרומפט ייצור כפי-שהוא)"),
"",
]
if result.get("dry_run"):
@@ -613,6 +688,54 @@ def _write_report(result: dict, ts: str) -> tuple[Path, Path]:
f"| {r['effort']}{star} | {r['distance']:.4f} | {r['anti_pattern_total']} | "
f"{r['change_percent']} | {ratio if ratio is not None else ''} | {r['n']} |")
lines.append("")
by_model = result.get("by_model") or {}
if len(by_model) > 1:
lines += ["## השוואת-מודלים (אותו block, אותו effort, אותם סופיים)\n",
"| block | effort | model | anti_total | change% | ratioΔpp | distance | n |",
"|---|---|---|---|---|---|---|---|"]
for b in g["blocks"]:
for eff in g["efforts"]:
rows = []
for m, bb in by_model.items():
for r in (bb.get(b) or {}).get("efforts", []):
if r["effort"] == eff:
rows.append((m, r))
if len(rows) < 2:
continue # nothing to compare for this cell — don't fake a row
best = min(rows, key=lambda mr: (mr[1]["anti_pattern_total"],
mr[1]["golden_ratio_deviation_pp"] or 0,
mr[1]["distance"]))[0]
for m, r in rows:
ratio = r["golden_ratio_deviation_pp"]
star = "" if m == best else ""
lines.append(
f"| {b} | {eff} | {m}{star} | {r['anti_pattern_total']} | "
f"{r['change_percent']} | {ratio if ratio is not None else ''} | "
f"{r['distance']:.4f} | {r['n']} |")
lines.append("")
# WHICH rule broke — a total alone can't tell you what to fix.
bd_rows = [(b, eff, m, r) for b in g["blocks"] for eff in g["efforts"]
for m, bb in by_model.items()
for r in (bb.get(b) or {}).get("efforts", []) if r["effort"] == eff]
if any(r.get("anti_by_pattern") for *_, r in bd_rows):
names = sorted({n for *_, r in bd_rows for n in (r.get("anti_by_pattern") or {})})
lines += ["### פילוח אנטי-דפוסים (איזה כלל הופר)\n",
"| block | effort | model | " + " | ".join(names) + " |",
"|---|---|---|" + "---|" * len(names)]
for b, eff, m, r in bd_rows:
cells = " | ".join(str((r.get("anti_by_pattern") or {}).get(n, 0)) for n in names)
lines.append(f"| {b} | {eff} | {m} | {cells} |")
lines.append("")
# A silent CLI fallback would make the whole comparison meaningless — surface it.
for m, bb in by_model.items():
for b, bd in bb.items():
used = bd.get("models_used") or []
if used and any(not u.startswith(str(m)) for u in used):
lines.append(f"> ⚠️ **{b} / {m}**: ה-CLI דיווח `{', '.join(used)}` — "
"ייתכן fallback שקט; ההשוואה לתא זה אינה תקפה.\n")
lines.append("")
lines.append("> דירוג-ההמלצה **style-clean** (#213): anti_total ראשי → ratioΔ → distance (tiebreak). "
"**change% מדווח-לא-מדורג** — מערבב סגנון עם שלמות-תוכן (07-learning §0.7), "
"anti_total הוא הסיגנל הנקי-לסגנון. confidence=⚠weak ⇒ הבחירה בתוך-הרעש "
@@ -633,6 +756,12 @@ async def main() -> int:
help="comma block ids to calibrate")
ap.add_argument("--case", default=None, help="restrict to a single case_number")
ap.add_argument("--repeats", type=int, default=1, help="generations per cell (avg out gen noise)")
ap.add_argument("--models", default="",
help="comma generation-model ids to A/B (e.g. claude-opus-4-8,claude-opus-5). "
"Empty (default) = the pinned GENERATION_MODEL, i.e. production unchanged.")
ap.add_argument("--instructions", default="",
help="extra prompt instruction appended to EVERY cell (prompt-variant A/B). "
"Applied to all models — the run stays a model comparison. Recorded in the report.")
args = ap.parse_args()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
@@ -653,6 +782,9 @@ async def main() -> int:
if bad_b:
print(f"non-calibratable block(s): {bad_b}. valid: {VALID_BLOCKS}", file=sys.stderr)
return 2
# [None] = "use the pinned GENERATION_MODEL" — keeps the default run byte-identical
# to the pre-#models behaviour instead of hard-coding the id in a second place (G2).
args.models = [m.strip() for m in args.models.split(",") if m.strip()] or [None]
ts = _ts()
result = await _run(args, ts)

View File

@@ -0,0 +1,96 @@
/**
* pm2 ecosystem entry for legal-mcp-http — the legal-ai MCP server exposed over
* streamable-http (TaskMaster #231).
*
* Why it exists
* Agents driven over the Agent Client Protocol get their MCP servers from the
* *client* at session start, and that channel carries HTTP servers only. A
* stdio server has no path into such a session, which is how platform-driven
* agents ended up with none of the 108 tools. This service is the HTTP end
* they can actually be pointed at.
*
* It does NOT replace the stdio path. Every interactive Claude Code session
* still reaches the same server through the `legal-ai` entry in
* ~/.claude.json, spawned per session. Same code, same tool registry, two
* transports (G2) — this is a second *door*, not a second server.
*
* Security
* The registry includes case_delete, precedent_library_delete, document_upload
* and every block-writing tool, so an open port here is a delete-any-case
* endpoint. Two defences, both required:
* 1. Bind 127.0.0.1 — the platform runs on this host, so loopback suffices.
* Deliberately narrower than legal-chat-service's 10.0.1.1: nothing in a
* container needs to call MCP.
* 2. Bearer token — MCP_HTTP_SHARED_SECRET, loaded below. The server
* REFUSES TO START without it (services/http_auth.py), so a
* misconfiguration cannot silently produce an unauthenticated listener.
*
* Secret
* Source of truth: Infisical, project "All Infrastructure", env `main`,
* /apps/legal-ai/MCP_HTTP_SHARED_SECRET (tag: credentials). The file read
* below is a chmod-600 runtime copy, same arrangement as
* legal-chat-service.config.cjs. Rotate in Infisical first, then refresh the
* file and `pm2 restart legal-mcp-http`.
*
* Install (once):
* pm2 start /home/chaim/legal-ai/scripts/legal-mcp-http.config.cjs
* pm2 save
*
* Smoke test — expect 401 without the token, 200 with it:
* curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:8790/mcp \
* -H 'Content-Type: application/json' \
* -H 'Accept: application/json, text/event-stream' \
* -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}'
*
* Update: pm2 restart legal-mcp-http --update-env
* Stop: pm2 stop legal-mcp-http
*/
const fs = require("fs");
const ENV_FILE = "/home/chaim/.legal-mcp-http.env";
const env = {
HOME: "/home/chaim",
PATH: "/home/chaim/.local/bin:/usr/local/bin:/usr/bin:/bin",
PYTHONUNBUFFERED: "1",
// Same DB/data wiring the stdio server gets from ~/.claude.json, so both
// transports read exactly the same corpus.
DOTENV_PATH: "/home/chaim/.env",
DATA_DIR: "/home/chaim/legal-ai/data",
MCP_TRANSPORT: "streamable-http",
MCP_HTTP_HOST: "127.0.0.1",
MCP_HTTP_PORT: "8790",
};
try {
const text = fs.readFileSync(ENV_FILE, "utf8");
for (const line of text.split("\n")) {
if (!line || line.trim().startsWith("#")) continue;
const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/);
if (m) env[m[1]] = m[2];
}
} catch (e) {
// Warn, but do not fabricate a token. The server's own gate turns a missing
// secret into a refusal to boot, which is the outcome we want — pm2 will
// surface it as a crash loop rather than serve unauthenticated traffic.
console.error(`legal-mcp-http: failed to load ${ENV_FILE}: ${e.message}`);
console.error("Service will refuse to start without MCP_HTTP_SHARED_SECRET.");
}
module.exports = {
apps: [
{
name: "legal-mcp-http",
cwd: "/home/chaim/legal-ai/mcp-server",
script: "/home/chaim/legal-ai/mcp-server/.venv/bin/python",
args: "-m legal_mcp.server",
env,
restart_delay: 5000,
// Low ceiling on purpose: if the token is missing the process exits
// immediately, and we want pm2 to stop retrying and leave an obvious
// errored entry rather than loop forever on a config mistake.
max_restarts: 10,
autorestart: true,
max_memory_restart: "800M",
},
],
};

View File

@@ -18,6 +18,13 @@ import { useCasePrecedents } from "@/lib/api/precedents";
* the main case page (X17 #3). Lives inside the "טיעונים ועמדות" tab above the
* collapsible by-party aggregated arguments. The 12-block editor + citation
* verification moved to their own top-level tabs; /compose was deleted.
*
* The threshold-claim / issue cards deliberately stack in a SINGLE column.
* They were a two-column CSS grid, but grid rows share a height: expanding one
* card grew its row and shoved every card below it down — in *both* columns.
* A single column keeps the jump local to what sits underneath, and gives the
* expanded body (fields + chair editor + supporting precedents) full width
* instead of half. Do not reintroduce `lg:grid-cols-2` here (chair, 2026-08-04).
*/
function ProseSection({ title, content }: { title: string; content?: string }) {
@@ -203,7 +210,7 @@ export function PositionsPanel({ caseNumber }: { caseNumber: string }) {
{analysis.data.threshold_claims.length}
</span>
</div>
<div className="grid gap-3 lg:grid-cols-2 items-start">
<div className="space-y-3">
{analysis.data.threshold_claims.map((tc) => (
<SubsectionCard
key={tc.id}
@@ -225,7 +232,7 @@ export function PositionsPanel({ caseNumber }: { caseNumber: string }) {
{analysis.data.issues.length}
</span>
</div>
<div className="grid gap-3 lg:grid-cols-2 items-start">
<div className="space-y-3">
{analysis.data.issues.map((iss) => (
<SubsectionCard
key={iss.id}

View File

@@ -45,8 +45,10 @@ export function ChairEditor({
if (trimmed === lastSaved.current.trim()) return;
setState({ kind: "saving" });
try {
await mutate.mutateAsync({ sectionId, position: trimmed });
lastSaved.current = trimmed;
const res = await mutate.mutateAsync({ sectionId, position: trimmed });
/* Track what the backend read back, not what we sent — "✓ נשמר" must
mean "persisted and re-readable", or a later blur skips the save. */
lastSaved.current = res?.position ?? trimmed;
setState({ kind: "saved", at: new Date() });
} catch (e) {
setState({

View File

@@ -59,20 +59,31 @@ export function useResearchAnalysis(caseNumber: string | undefined) {
});
}
export type SaveChairPositionResult = {
saved: boolean;
section_id: string;
/** What the backend read back out of the file after writing — the truth. */
position: string;
timestamp?: string;
};
export function useSaveChairPosition(caseNumber: string | undefined) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (vars: { sectionId: string; position: string }) =>
apiRequest<unknown>(
apiRequest<SaveChairPositionResult>(
`/api/cases/${caseNumber}/research/analysis/chair-position`,
{
method: "PATCH",
body: { section_id: vars.sectionId, position: vars.position },
},
),
onSuccess: (_res, vars) => {
onSuccess: (res, vars) => {
/* Locally patch the cached analysis so other consumers stay in sync
without an immediate refetch that would steal focus from the editor. */
without an immediate refetch that would steal focus from the editor.
Cache the value the server read back, never the value we sent — the
two diverged silently while the parser could not see bulleted fields. */
const persisted = res?.position ?? vars.position;
qc.setQueryData<ResearchAnalysis | undefined>(
researchKeys.analysis(caseNumber ?? ""),
(prev) => {
@@ -80,7 +91,7 @@ export function useSaveChairPosition(caseNumber: string | undefined) {
const patch = (arr?: ResearchSubsection[]) =>
arr?.map((s) =>
s.id === vars.sectionId
? { ...s, chair_position: vars.position }
? { ...s, chair_position: persisted }
: s,
);
return {

View File

@@ -6992,7 +6992,7 @@ async def precedent_library_list(
precedent_level: str = "",
source_type: str = "",
search: str = "",
source_kind: str = "external_upload",
source_kind: str = "",
limit: int = 100,
offset: int = 0,
):
@@ -7020,6 +7020,7 @@ async def precedent_library_search(
subject_tag: str = "",
limit: int = 10,
include_halachot: bool = True,
source_kind: str = "",
):
if not q or len(q.strip()) < 2:
return {"items": [], "count": 0}
@@ -7032,6 +7033,7 @@ async def precedent_library_search(
subject_tag=subject_tag,
limit=limit,
include_halachot=include_halachot,
source_kind=source_kind,
)
return {"items": results, "count": len(results)}