28 Commits

Author SHA1 Message Date
314745d7a7 Merge pull request 'fix(sync): onWebhook statusLabels נגזרת מ-/api/status-model (legal-ai #616)' (#13) from fix/616-onwebhook-status-labels into main 2026-09-01 20:34:59 +00:00
510f276717 fix(sync): onWebhook גוזר תוויות-סטטוס מ-/api/status-model (legal-ai #616)
מפת-התוויות העברית הקשיחה ב-onWebhook הייתה מקור-האמת השני לאותו enum
של סטטוסי-תיק, והספיקה לסטות: חסרו בה analyst_verified ו-research_complete
(שנוספו ל-case_status_model.py ב-30.6), והיו בה חמישה מפתחות מתים —
uploading, brainstorming, drafting, in_progress ו-qa_failed. #604 תיקן את
המפה הראשונה (CASE_STATUS_TO_ISSUE_STATUS); זו השנייה.

- statusLabels נמחקת; התווית נגזרת מ-GET /api/status-model דרך labelFor()
  הקיימת — אותו SSOT שהג'וב sync-case-status כבר צורך (G2).
- resolveStatusLabel() חדשה ב-sync-target.ts (המודול הטהור): מרכיבה את
  labelFor ומחזירה גם את **סיבת** הנפילה-לגולמי, כדי שההיגיון יהיה
  בר-בדיקה — worker.ts אינו ניתן ל-import בטסט (runWorker ברמת-המודול).
- אין בליעה שקטה (כלל-הנדסה §6): סטטוס שנטען ואינו במודל → logger.warn עם
  מספר-התיק והסטטוס; מודל שלא נטען כלל (כשל-רשת או LegalApi חסר) → שני
  logger.error נבדלים. בכל המקרים התגובה עדיין מתפרסמת והערת-ה-CEO עדיין
  נורית — תווית חסרה אינה מפילה webhook.
- oldStatus מתורגם אף הוא לתווית (היה מודפס כמפתח-אנגלית בתוך משפט עברי).
- legalApi עשה hoist ליד pluginCtx ומועבר מפורשות ל-handleCaseStatusWebhook
  — אותו מופע, לא שני.
- AC2: הוסרו שתי רשימות-הסטטוסים הקשיחות הנוספות ב-worker.ts — ה-enum
  המיושן ב-legal_case_update (חסם 8 סטטוסים חוקיים והתיר את in_progress
  שאינו case.status; המניפסט כבר מגדיר את השדה כמחרוזת חופשית, והוולידציה
  האמיתית היא server-side) והמנייה בתיאור legal_case_list.

Invariants: G2 (מקור-אמת אחד לתוויות, בשני הצרכנים) · G1 (נרמול-במקור —
הפלאגין מושך מה-SSOT במקום להחזיק העתק סטטי) · G12 (legal-ai לא נגע כלל;
אפס סמל ספציפי-Paperclip נכנס אליו) · X7 INV-INT9 (onWebhook עדיין אינו
כותב issue.status — המשבצת השמורה נשארת שמורה) · כלל-הנדסה §6.

tsc --noEmit נקי · biome check src/ נקי · node --test 39/39.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 23:33:15 +03:00
a6bd02c237 Merge pull request 'feat(sync): מוני-ריצה מובחנים ל-sync-case-status — "אין מה לעשות" מובחן מ"שבור" (legal-ai #617)' (#12) from feat/617-sync-status-run-counters into main 2026-08-26 13:53:24 +00:00
237d829844 feat(sync): מוני-ריצה מובחנים ל-sync-case-status (legal-ai #617)
הג'וב סיים עד כה בשורה אחת — `casesChecked` — שאינה מבחינה בין "אין מה
לעשות", "נדחה בכוונה" ו"נפל". 13,872 ריצות דיווחו בריאות ירוקה, ובפועל
היו בהן 389 כתיבות בלבד; האבחנה ב-#604 נבנתה על היעדר-ראיה.

כל ריצה פולטת מעתה: scanned · matched · written · declined{no_linked_issues,
no_writable_root, ambiguous_writable_roots, unknown_status, already_matching}.
הפליטה יושבת ב-`finally` וזורקת את השגיאה המקורית הלאה, כך שריצה שנפלה
נשארת `failed` ב-`plugin_job_runs` **וגם** נושאת את המונים שהספיקה לצבור.

שני משטחים קיימים וצורכים, שנמדדו:
- `ctx.logger.info` → stdout של pm2 — שם בוצע בפועל האבחון של #626/#637.
- `ctx.metrics.write` → `plugin_logs` (level='metric') — הטבלה שפאנל
  "Recent Logs" בדף-הפלאגין מרנדר, וניתנת לשאילתת-SQL (AC4).

המונים בתוך מחרוזת-ההודעה ולא רק ב-meta, כי שני המשטחים מתעלמים מ-meta
בפועל (הפאנל מרנדר createdAt/level/message בלבד; ומ-pino נמדד ששדה
`{error:…}` נופל). נדרשה הרשאת `metrics.write` ב-manifest — זו הדרך
היחידה לכתוב ל-`plugin_logs`.

אפס שינוי בהתנהגות-הכתיבה: `sync-target.ts` לא נגע כלל, ובכלל זה
`NON_WRITABLE_STATUSES` (הכרעת-יו"ר, #626).

Closes ezer-mishpati/legal-ai#617

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 16:51:48 +03:00
7cbb3d1c03 fix(sync): company-scope לג'ובים מתוזמנים — הפסקת בליעת-כשל (legal-ai #637) (#11)
Co-authored-by: Chaim Marcus <chaim@marcus-law.co.il>
Co-committed-by: Chaim Marcus <chaim@marcus-law.co.il>
2026-08-26 13:13:48 +00:00
41305fbb36 Merge pull request 'feat(sync): שחרור in_review מ-NON_WRITABLE_STATUSES — הכרעת-יו"ר שיטתית (legal-ai #626)' (#10) from feat/626-non-writable-in-review into main 2026-08-26 10:36:55 +00:00
511d5c6e34 feat(sync): שחרור in_review מ-NON_WRITABLE_STATUSES — הכרעת-יו"ר שיטתית (legal-ai #626)
הג'וב sync-case-status יכתוב מעתה גם על שורש-issue ב-in_review. blocked
ו-CLOSED_ISSUE_STATUSES (done/cancelled — הגנת #446) נותרים כפי שהם.
ההערה מעל הסט מתעדת את ההכרעה, את מחירה (issue ממתין-ליו"ר עשוי לרדת
מתור-הביקורת תוך 15 דק') ואת מסלול-החזרה.

Refs ezer-mishpati/legal-ai#626
2026-08-26 13:09:52 +03:00
10d6216bec Merge pull request 'fix(sync): מיפוי-הסטטוסים מ-/api/status-model + סריקת כל החברות (legal-ai #604)' (#9) from fix/604-paperclip-status-sync into main 2026-08-25 22:34:30 +00:00
0a134d0134 fix(sync): מיפוי-הסטטוסים נגזר מ-/api/status-model, וסריקה בכל החברות (legal-ai #604)
שלושה כשלים בג'וב sync-case-status, שהשביתו אותו בשקט מאז 22.7:

1. המפה הקשיחה CASE_STATUS_TO_ISSUE_STATUS נותקה מ-case_status_model.py
   (מקור-האמת בצד legal-ai): חסרו analyst_verified ו-research_complete
   שנוספו ב-851a4fb, ונשארו בה 3 מפתחות מתים (uploading/brainstorming/drafting).
   סטטוס חסר נפל ל-`continue` — בלי לוג ובלי שגיאה. כעת המיפוי נגזר מ-
   GET /api/status-model דרך resolveIssueStatus/labelFor: terminal→done,
   הסטטוס הראשון בסדר→todo, השאר→in_progress. סטטוס חדש בצד legal-ai לא
   דורש עוד עריכה כאן (G2 — מקור-אמת אחד).
2. companies[0] בלבד — מתוך שתי החברות, אחת מעולם לא נסרקה (נמדד: 135 מול
   67 issues מקושרי-תיק). כעת נסרקות כל החברות, וה-companyId של המועמד
   שנבחר הוא זה שנכתב אליו — לא החברה הראשונה גורפת.
3. דילוג שקט על סטטוס לא-מוכר → ctx.logger.warn עם שם-הסטטוס ומספר-התיק.

אימות מול השרת החי: 0 סטיות מול המפה הישנה על כל 10 הסטטוסים שכיסתה,
+2 שכוסו לראשונה. סימולציה על הנתונים החיים: דילוג-שקט 3→0, שורות-לוג 3→6,
כתיבות בפועל 0→0 — כלומר אין נסיגה ל-flip-flop של #446.

לוגיקת בחירת-היעד (#446) לא שונתה: pickSyncTargetIssue, isWritableStatus,
CLOSED_ISSUE_STATUSES ו-NON_WRITABLE_STATUSES זהות; ל-SyncCandidate נוסף שדה
companyId בלבד (passthrough). 8 הטסטים הקיימים עוברים ללא שינוי באסרשנים,
+7 חדשים.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 01:32:24 +03:00
602b42ca5e Merge pull request 'fix(sync): sync-case-status יכתוב רק על issue-שורש פתוח (legal-ai #446)' (#8) from fix/446-sync-target-root-issue into main 2026-08-24 15:07:36 +00:00
378e5657b5 fix(sync): סנכרון-הסטטוס יכתוב רק על issue-שורש פתוח, לא על sub-task שנסגר (#446)
הג'וב sync-case-status כתב על כל issue מקושר-לתיק שסטטוסו שונה מהיעד — השומר
היחיד היה 'issue.status !== targetStatus'. לכן הוא החזיר sub-tasks שנסגרו
(done) ל-in_progress, כפי שתועד ב-CMPA-140 / תיק 8125-09-24.

בחירת-היעד חולצה ל-src/sync-target.ts: נבחר שורש יחיד (parentId === null)
שאינו done/cancelled ואינו in_review/blocked; אפס או יותר-מאחד → לא נכתב
דבר, ונרשם לוג עם הסיבה. שני הקבועים מובחנים בכוונה — CLOSED_ISSUE_STATUSES
הוא ההגדרה היחידה של 'סגור' (מראה של legal-ai/web/paperclip_client.py:561),
ו-NON_WRITABLE_STATUSES הוא 'מצב בבעלות Paperclip/בהמתנה-לאדם': כתיבת
in_progress על issue ב-in_review מזמינה auto-block תוך דקה
(legal-ai/docs/paperclip-quirks.md §3) וגונבת אותו מתור-הביקורת של היו"ר.

הלולאה הכפולה הוחלפה במעבר אחד + קיבוץ למפת case_number→מועמדים; הקיבוץ
נדרש כדי לבחור 'השורש היחיד', וכתוצר-לוואי O(NxM)→O(M) קריאות state.get.

מבחן-רגרסיה (node --test, ללא תלויות חדשות) משחזר את 11 ה-issues שנמדדו
לתיק 8125-09-24 ומוכיח את הדלתא: הכלל הישן היה כותב על CMPA-140, החדש לא.

בבעלות ריפו זה בהחלטת פאנל — העברת הלוגיקה ל-legal-ai נפסלה כי היא מוסיפה
UPDATE ישיר ל-DB של Paperclip, בהפרת INV-INT4 (GAP-24/25).
2026-08-24 07:41:32 +03:00
6721c0a4ed chore(manifest): מחיקת plugin.json — קובץ מת שמטעה
plugin.json הצהיר 16 כלים ולא נקרא ע"י אף אחד: המניפסט האמיתי הוא
src/manifest.ts → dist/manifest.js, שנפתר דרך package.json →
paperclipPlugin.manifest. הפער ניכר בכך שהקובץ חסר את
legal_predecessor_context, שנוסף למניפסט האמיתי ב-b9906f6.

ה-16 השגוי הזה הוא מקור ה-"16 tools" ב-~/CLAUDE.md, בעוד שה-loader
רושם 9 — המספר שבמניפסט האמיתי ובמאניפסט השמור ב-DB.

אומת לפני המחיקה: אין הפניה לקובץ בקוד, בבנייה, או בקוד השרת.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 17:17:06 +00:00
3da37e1519 Merge pull request 'fix(config): קריאת config מותאמת ל-scope של חברה (Paperclip 2026.722.0)' (#7) from fix/company-scoped-plugin-config into main 2026-07-25 16:42:19 +00:00
6294d94bed fix(config): קריאת config מותאמת ל-scope של חברה (Paperclip 2026.722.0)
מיגרציה 0164_plugin_config_company_scope הפכה את config של פלאגינים
ל-company-scoped, ו-`ctx.config.get()` בלי companyId זורק
"company context is required" כשה-host לא יכול לגזור חברה מההקשר.
ב-setup() אין חברה בהגדרה, ולכן הקריאה שם הפילה את הפעלת הפלאגין כולו
בשדרוג מ-2026.609.0 (status=error, 0 כלים רשומים).

- setup() כבר לא קורא config; LegalApi מקבל resolver עצל במקום URL קבוע
  (baseUrl שימש במקום אחד בלבד, ולכן 17 ה-tool handlers לא נגעו)
- resolveApiBase() פותר לפי הסדר: companyId מפורש → גזירת ה-host
  מההקשר → סריקת החברות ב-CEO_AGENT_IDS → ברירת מחדל
- data handlers מעבירים params.companyId; jobs מתוזמנים חסרי חברה
  (PluginJobContext לא כולל companyId) ולכן נשענים על הסריקה
- שדרוג @paperclipai/plugin-sdk ל-2026.722.0

מאומת: הפלאגין עולה עם אותה חתימת רישום כמו לפני השדרוג —
eventSubscriptions:2, jobs:4, webhooks:1, tools:9, בלי warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 16:18:30 +00:00
d0669a0878 Merge pull request 'fix(manifest): הצהרת legal_predecessor_context ב-manifest (חשיפת הכלי)' (#6) from fix/predecessor-manifest into main 2026-07-07 05:16:43 +00:00
b9906f6047 fix(manifest): הצהרת legal_predecessor_context ב-manifest (חשיפת הכלי)
ה-manifest.tools הוא המקור-הקובע לרישום/חשיפת כלים ב-Paperclip — ctx.tools.register
ב-worker.ts נותן רק את ה-handler. בלי הצהרה ב-manifest, הכלי לא נחשף (הלוגים הראו
toolCount=8, בלי legal_predecessor_context). מוסיף את ההצהרה → toolCount=9, הכלי חשוף.

אומת חי: reinstall+restart → logs מראים toolCount:9 + legal_predecessor_context רשום.
(המשך #220; משלים את PR #5.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 05:16:18 +00:00
8d933386d3 Merge pull request 'feat(tools): כלי legal_predecessor_context — הקשר מריצות-קודמות לסוכן (#220)' (#5) from feat/predecessor-context into main 2026-07-07 05:12:18 +00:00
0b6117cd7e feat(tools): כלי legal_predecessor_context — הקשר מריצות-קודמות לסוכן (#220)
מוסיף כלי-plugin שהסוכן קורא בעת continuation כדי לראות מה ריצות קודמות על התיק
הסיקו (ה-summary של כל heartbeat), במקום לגלות-מחדש הקשר מאפס (blind heartbeat).

- legal-api.ts: getPredecessorForCase(caseNumber) → GET
  /api/operations/cases/{case}/predecessor (endpoint שנוסף ב-legal-ai #410).
- worker.ts: רישום הכלי (case_number param) — מעצב digest קריא (agent·issue·when·
  status + summary חתוך ל-600 תווים, newest-first).

ארכיטקטורה (ההכרעה מהדיון): הכלי חי ב-plugin — המעטפת המוצהרת שמותר לה לדבר גם
Paperclip וגם legal-ai web — ולא ב-mcp-server (הליבה נשארת עיוורת-פלטפורמה, G12).
זו הסיבה שזה plugin-tool ולא mcp→web seam.

בנייה: tsc + biome נקיים. פריסה (ידנית, pm2): npm run build → plugin install →
pm2 restart paperclip. המשך: הוראת-HEARTBEAT לקרוא בעת continuation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 05:11:44 +00:00
fc5d8725f1 Merge pull request 'fix(sweep): בחירת הערת-יו"ר אחרונה לפי זמן, לא לפי מיקום-מערך' (#4) from fix/sweep-comment-ordering into main 2026-06-30 11:41:32 +00:00
b9a5bb6e80 fix(sweep): pick newest chair comment by timestamp, not array position
The sweep assumed listComments returns oldest-first and took
comments[length-1] as "the last comment". listComments actually returns
newest-first, so the sweep routed the OLDEST user comment instead of the
newest — and routed it whether or not an agent had already answered. Caught
live on CMPA-116: the sweep routed a stale 6-day-old "agent didn't activate"
gripe (already answered minutes later) instead of the chair's real pending
request ("I attached case law — go back to the precedent-researcher and check
the docs"), so the CEO correctly no-op'd and the real request stayed dropped.

Fix: order-independent. Compute the newest USER comment and newest AGENT
comment by createdAt; route only when the chair spoke last among real
participants (newest user > newest agent, or no agent comment), so an agent
reply — but NOT an automated system "draft ready" line — counts as handled.
Also fix the same latent array-order assumption in the event-handler body
fallback (reduce-by-createdAt instead of comments[length-1]).

Self-heals the stuck marker on CMPA-116 (newest-user id differs from the
wrongly-stamped one). Refs legal-ai #164.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 11:41:02 +00:00
0bf2e46212 Merge pull request 'feat(routing): ערובת at-least-once להערות-יו"ר → CEO (sweep-פיוס)' (#3) from feat/comment-delivery-guarantee into main 2026-06-30 10:40:44 +00:00
aeffbe65ee feat(routing): at-least-once guarantee for chair comments → CEO
The event-driven comment→CEO route (issue.comment.created) is best-effort:
a failed/coalesced/cancelled CEO invoke, or a comment that lands while an
agent is mid-run, can leave a chair comment unrouted with nothing to retry.
This is exactly how a chair comment on CMP-190 (2026-06-30) was silently
dropped — the host-native wake was cancelled (issue_execution_promoted /
assignee_changed) and no plugin route fired.

Adds a reconciliation sweep (route-pending-comments, every 2 min) that
re-routes any active issue whose LAST comment is the chair's (no agent reply
after it) and whose id ≠ the per-issue `last-routed-comment-id` marker.
Both the fast path and the sweep stamp the marker → idempotent, no
double-routing of healthy comments. "Last comment is user" precisely targets
the failure (chair commented, no agent response) and skips historically
answered comments, so first-run-after-deploy footprint is 0.

- worker.ts: extract shared routeCommentToCeo() + markCommentRouted();
  event handler stamps marker (incl. the native-handles-it branch);
  register route-pending-comments sweep with per-company/per-issue guards.
- manifest.ts: declare route-pending-comments job (*/2 * * * *).

Invariants: G12 (Paperclip touch only in the declared wrapper — plugin),
G2 (extends the existing route, no parallel path). Refs legal-ai #164.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 10:18:38 +00:00
0a8790b088 fix(routing): use bodySnippet fallback + warn log when listComments fails
When ctx.issues.listComments() throws, the previous catch silently
replaced commentBody with "(שגיאה בקריאת התגובה)" — causing CEO to
receive garbage text instead of an actionable instruction.

Now: falls back to payload.bodySnippet (always present in Paperclip
webhooks), logs a proper warning with the real error for diagnosis,
and if even bodySnippet is empty, sends a clear meta-instruction
telling CEO to read comments directly instead of corrupted content.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 14:53:31 +00:00
b6bf726570 Merge pull request 'fix(routing): comment→CEO wakeup reads issue id from event.entityId' (#2) from fix/comment-routing-ceo-entityid into main 2026-06-17 04:55:46 +00:00
2d6dd8a072 fix(routing): comment→CEO wakeup read issue id from event.entityId, not payload.issueId
The `issue.comment.created` handler read `payload.issueId`, which the current
Paperclip host never sends — the issue id arrives in `event.entityId` and the
payload carries `commentId`/`bodySnippet`/`reopened` instead. So the handler
hit "missing issueId, skipping" on every user comment and the documented
"user comment → CEO" routing was silently dead.

Diagnosed on case 8124-09-24 (CMPA): a question commented on an analyst's
`done` sub-issue never reached the CEO. The plugin skipped; Paperclip's native
reopen-on-comment wake then targeted the sub-issue's assignee (the analyst) and
the queued CEO run was cancelled with `issue_assignee_changed`.

Fix:
- issueId from `event.entityId` (fallback `payload.issueId` for host-version skew).
- Resolve full comment body by matching `payload.commentId` in listComments
  (payload only has a truncated `bodySnippet`); fall back to latest/snippet.
- Dedup guard: when the comment reopened an issue ALREADY assigned to the CEO,
  the host's native wake covers it — skip to avoid double-running the CEO.
  For issues owned by any other agent we still route to the CEO.

Invariants: upholds the "user comment routes through CEO" contract (CLAUDE.md);
touches only the platform-port shell (plugin), per G12/X15. No new parallel path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 04:55:08 +00:00
f70023fccc feat(ui): Phase 2 — issue detail tab + dashboard widget
Plugin now mounts two React components inside Paperclip via the
SDK's UI slot mechanism. Both are read-only views over data the
plugin worker fetches from legal-ai on demand.

## Slots

* **LegalCaseTab** (type: detailTab, entityTypes: ["issue"])
  Mounted as a "ערר" tab on every issue page. Shows case summary
  (status / practice_area / appeal_subtype), legal_arguments
  grouped by party (עוררים/ועדה/משיבה/מבקשי היתר), attached
  precedents, and open missing_precedents.

* **LegalCasesWidget** (type: dashboardWidget)
  Dashboard tile with case counts by status + 7-day activity.

## Worker handlers (ctx.data.register)

Five handlers added at the end of setup() — all read-only over the
existing legal-ai HTTP API, all wrapped in try/catch so a transient
failure shows a placeholder instead of crashing the host:

- legal-case-summary           → /api/cases/{n}/details
- legal-case-arguments         → /api/cases/{n}/legal-arguments
- legal-case-precedents        → /api/cases/{n}/precedents
- legal-case-missing-precedents → /api/missing-precedents?case_number=&status=open
- legal-dashboard-stats        → in-memory aggregation over /api/cases

case_number is resolved from plugin state (scopeKind=issue,
stateKey=legal-case-number) — populated by legal_case_create.

## Build pipeline

- esbuild.ui.config.mjs uses createPluginBundlerPresets from the SDK
  to build src/ui/index.tsx → dist/ui/index.js (13.5kb, react +
  @paperclipai/plugin-sdk/ui externalized)
- package.json: build = "build:worker" (tsc) + "build:ui" (esbuild)
- tsconfig.json: jsx=react-jsx, lib += DOM
- New deps: react@19, @types/react, esbuild

## Manifest

- capabilities += ui.detailTab.register, ui.dashboardWidget.register
- entrypoints.ui = "dist/ui"
- ui.slots declared with entityTypes (not "entities" — fixed against
  PluginUiSlotDeclaration validator)

## Verified

- tsc + esbuild + biome clean
- Plugin re-installs (20 capabilities) and activates with worker
  + 8 tools + 3 jobs + 1 webhook + 2 event subs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 14:04:40 +00:00
0ca7831c53 feat: adopt SDK 525 features — askUserQuestions + documents.upsert
Two new event types accepted on the existing case-status webhook
(eventType discriminator added; legacy status_change still default):

* **missing_precedent_created** → ``ctx.issues.askUserQuestions``
  with a single-choice question {upload | irrelevant | defer}.
  continuationPolicy=wake_assignee_on_accept routes the chair's
  answer straight back to the CEO heartbeat without an extra hop.

* **export_complete** → ``ctx.issues.documents.upsert`` with a
  markdown "final-decision" doc that links back to the DOCX on
  legal-ai. (The SDK's documents API stores text only — binary
  attachment isn't natively supported here.)

Manifest: +issue.documents.write capability (issue.interactions.create
was already declared in the previous SDK upgrade).

Tested: plugin activates with all 18 capabilities, 8 tools + 3 jobs
+ 1 webhook + 2 event subs registered.
2026-05-26 13:28:43 +00:00
6388062cdc chore(sdk): upgrade @paperclipai/plugin-sdk to 2026.525.0
200-version bump from 2026.325.0 → 2026.525.0 (matches host).

**Why now**: host is already on 2026.525.0; staying on 325 was unnecessary
technical debt. Production logs showed real bugs ("missing, expired,
or unknown invocation scope") that newer SDK versions are known to
have fixed.

**Compatibility**:
- TS build clean with zero changes to worker.ts or legal-api.ts.
- apiVersion: 1 still the schema (no v2 yet).
- Plugin activated successfully — 8 tools + 3 jobs + 1 webhook + 2 event
  subs all registered, worker reports "Legal AI plugin ready".

**Manifest gaps fixed**: added 3 capabilities that the worker actually
uses (one of them was the root cause of repeated host-side errors):
* `agents.invoke` — required by `ctx.agents.invoke()` calls (CEO wakeup
  from event handlers + comment routing). Previously the plugin was
  invoking agents without the declared capability, raising
  "missing scope" errors on every issue.created event.
* `issue.comments.read` — required by `ctx.issues.listComments()`
  calls in the comment routing path.
* `issue.interactions.create` — forward-looking capability for the
  AskUserQuestion-like flows the SDK exposes via `createInteraction`.

**Backup**: full 5-layer snapshot at /tmp/plugin-legal-ai-backup-*.tar.gz
+ tag `pre-sdk-upgrade-2026-05-26` (pushed). Rollback: `git reset --hard
pre-sdk-upgrade-2026-05-26 && npm ci && npm run build && reinstall`.

Closes TaskMaster #26.
2026-05-26 12:19:04 +00:00
18 changed files with 3803 additions and 447 deletions

27
esbuild.ui.config.mjs Normal file
View File

@@ -0,0 +1,27 @@
import { build } from "esbuild";
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const presets = createPluginBundlerPresets({
pluginRoot: __dirname,
uiEntry: "src/ui/index.tsx",
outdir: "dist",
sourcemap: true,
minify: false,
});
if (!presets.esbuild.ui) {
throw new Error("UI preset missing — check createPluginBundlerPresets input");
}
await build({
...presets.esbuild.ui,
// Ensure JSX runtime is bundled-resolved at host runtime through React peer.
jsx: "automatic",
logLevel: "info",
});
console.log("[esbuild] UI bundle written to dist/ui/index.js");

529
package-lock.json generated
View File

@@ -8,11 +8,14 @@
"name": "@marcusgroup/plugin-legal-ai", "name": "@marcusgroup/plugin-legal-ai",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@paperclipai/plugin-sdk": "^2026.325.0" "@paperclipai/plugin-sdk": "^2026.722.0",
"react": "^19.0.0"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.4.11", "@biomejs/biome": "2.4.11",
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
"@types/react": "^19.0.0",
"esbuild": "^0.28.0",
"typescript": "^6.0.2" "typescript": "^6.0.2"
} }
}, },
@@ -179,13 +182,455 @@
"node": ">=14.21.3" "node": ">=14.21.3"
} }
}, },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@paperclipai/plugin-sdk": { "node_modules/@paperclipai/plugin-sdk": {
"version": "2026.325.0", "version": "2026.722.0",
"resolved": "https://registry.npmjs.org/@paperclipai/plugin-sdk/-/plugin-sdk-2026.325.0.tgz", "resolved": "https://registry.npmjs.org/@paperclipai/plugin-sdk/-/plugin-sdk-2026.722.0.tgz",
"integrity": "sha512-axAIK90QVrRihlCXcGoePDc0qaf0weqOwUagWsp88v6T2NXAWXALCzFtY4IipPnaVli6xZe5ZsuC+gTlPXhMKQ==", "integrity": "sha512-2rMJCBo8ZAouuMsapdaY4/l+oHmrXKHW/k/HfPOLCxFng7+g04c5JhoFq+/ptGfNcW3kXzs34y7brviDCml06Q==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@paperclipai/shared": "2026.325.0", "@paperclipai/shared": "2026.722.0",
"zod": "^3.24.2" "zod": "^3.24.2"
}, },
"bin": { "bin": {
@@ -201,9 +646,9 @@
} }
}, },
"node_modules/@paperclipai/shared": { "node_modules/@paperclipai/shared": {
"version": "2026.325.0", "version": "2026.722.0",
"resolved": "https://registry.npmjs.org/@paperclipai/shared/-/shared-2026.325.0.tgz", "resolved": "https://registry.npmjs.org/@paperclipai/shared/-/shared-2026.722.0.tgz",
"integrity": "sha512-l0ZeaQhswxjAyJ6G/TdgyrfbEgFKqdkly4zn4QgOPCu+x8j8Gma51Xf45M2Br3ILJlIx93HhT3hAzWKChdxgMg==", "integrity": "sha512-JciI6EbtNwHSeoyN5ZkshwqpyfIluyO46JHp8I7pPCvpJ7Uv7MSZEBs3lKXdXLadFEN6qzfwNmf39xjIFC8isA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"zod": "^3.24.2" "zod": "^3.24.2"
@@ -219,6 +664,74 @@
"undici-types": "~7.18.0" "undici-types": "~7.18.0"
} }
}, },
"node_modules/@types/react": {
"version": "19.2.15",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz",
"integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.0",
"@esbuild/android-arm": "0.28.0",
"@esbuild/android-arm64": "0.28.0",
"@esbuild/android-x64": "0.28.0",
"@esbuild/darwin-arm64": "0.28.0",
"@esbuild/darwin-x64": "0.28.0",
"@esbuild/freebsd-arm64": "0.28.0",
"@esbuild/freebsd-x64": "0.28.0",
"@esbuild/linux-arm": "0.28.0",
"@esbuild/linux-arm64": "0.28.0",
"@esbuild/linux-ia32": "0.28.0",
"@esbuild/linux-loong64": "0.28.0",
"@esbuild/linux-mips64el": "0.28.0",
"@esbuild/linux-ppc64": "0.28.0",
"@esbuild/linux-riscv64": "0.28.0",
"@esbuild/linux-s390x": "0.28.0",
"@esbuild/linux-x64": "0.28.0",
"@esbuild/netbsd-arm64": "0.28.0",
"@esbuild/netbsd-x64": "0.28.0",
"@esbuild/openbsd-arm64": "0.28.0",
"@esbuild/openbsd-x64": "0.28.0",
"@esbuild/openharmony-arm64": "0.28.0",
"@esbuild/sunos-x64": "0.28.0",
"@esbuild/win32-arm64": "0.28.0",
"@esbuild/win32-ia32": "0.28.0",
"@esbuild/win32-x64": "0.28.0"
}
},
"node_modules/react": {
"version": "19.2.6",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",

View File

@@ -7,7 +7,9 @@
"manifest": "dist/manifest.js" "manifest": "dist/manifest.js"
}, },
"scripts": { "scripts": {
"build": "tsc", "build": "tsc && node esbuild.ui.config.mjs",
"build:worker": "tsc",
"build:ui": "node esbuild.ui.config.mjs",
"dev": "paperclip-plugin-dev-server", "dev": "paperclip-plugin-dev-server",
"format": "biome format --write src/", "format": "biome format --write src/",
"format:check": "biome format src/", "format:check": "biome format src/",
@@ -15,11 +17,14 @@
"biome:fix": "biome check --write src/" "biome:fix": "biome check --write src/"
}, },
"dependencies": { "dependencies": {
"@paperclipai/plugin-sdk": "^2026.325.0" "@paperclipai/plugin-sdk": "^2026.722.0",
"react": "^19.0.0"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.4.11", "@biomejs/biome": "2.4.11",
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
"@types/react": "^19.0.0",
"esbuild": "^0.28.0",
"typescript": "^6.0.2" "typescript": "^6.0.2"
} }
} }

View File

@@ -1,87 +0,0 @@
{
"id": "marcusgroup.legal-ai",
"apiVersion": 1,
"version": "0.2.0",
"displayName": "Ezer Mishpati - Legal AI",
"description": "Integration with legal decision drafting system — case management, semantic search, workflow tracking, QA validation, and learning loop",
"author": "Marcus Group",
"categories": ["integration"],
"minimumHostVersion": "2026.325.0",
"capabilities": [
"events.subscribe",
"issues.read",
"issues.create",
"issues.update",
"issue.comments.create",
"issue.comments.read",
"agents.read",
"agents.invoke",
"agent.tools.register",
"http.outbound",
"plugin.state.read",
"plugin.state.write",
"jobs.schedule",
"activity.log.write",
"companies.read",
"projects.read",
"webhooks.receive"
],
"entrypoints": {
"worker": "dist/worker.js"
},
"instanceConfigSchema": {
"type": "object",
"properties": {
"legalApiBaseUrl": {
"type": "string",
"default": "http://localhost:8085",
"description": "Base URL for the Ezer Mishpati API"
}
}
},
"tools": [
{ "toolKey": "legal_case_list", "displayName": "רשימת תיקי ערר" },
{ "toolKey": "legal_case_get", "displayName": "פרטי תיק ערר" },
{ "toolKey": "legal_case_create", "displayName": "יצירת תיק ערר" },
{ "toolKey": "legal_case_update", "displayName": "עדכון תיק ערר" },
{ "toolKey": "legal_case_status", "displayName": "סטטוס תהליך עבודה" },
{ "toolKey": "legal_search", "displayName": "חיפוש תקדימים" },
{ "toolKey": "legal_case_template", "displayName": "תבנית החלטה" },
{ "toolKey": "legal_processing_status", "displayName": "סטטוס עיבוד כללי" },
{ "toolKey": "legal_document_list", "displayName": "רשימת מסמכים בתיק" },
{ "toolKey": "legal_set_outcome", "displayName": "הזנת תוצאת ערר" },
{ "toolKey": "legal_get_claims", "displayName": "טענות מחולצות" },
{ "toolKey": "legal_search_case", "displayName": "חיפוש בתוך תיק" },
{ "toolKey": "legal_find_similar", "displayName": "תקדימים דומים" },
{ "toolKey": "legal_run_qa", "displayName": "בדיקת איכות" },
{ "toolKey": "legal_trigger_learning", "displayName": "לולאת למידה" },
{ "toolKey": "legal_style_guide", "displayName": "מדריך סגנון" }
],
"jobs": [
{
"jobKey": "sync-case-status",
"displayName": "סנכרון סטטוס תיקים",
"description": "סנכרון סטטוס בין legal-ai ל-Paperclip כל 15 דקות",
"schedule": "*/15 * * * *"
},
{
"jobKey": "stale-case-reminder",
"displayName": "תזכורת תיקים תקועים",
"description": "מזהה תיקים שלא עודכנו 3+ ימים ומוסיף תגובה ל-issue",
"schedule": "0 8 * * *"
},
{
"jobKey": "weekly-feedback-analysis",
"displayName": "ניתוח פידבק שבועי",
"description": "מסכם פידבק יו\"ר מהשבוע האחרון ומעדכן את decision-lessons.md",
"schedule": "0 19 * * 0"
}
],
"webhooks": [
{
"endpointKey": "case-status",
"displayName": "עדכון סטטוס תיק",
"description": "מקבל עדכוני סטטוס מ-legal-ai ומפרסם תגובה על ה-issue המקושר"
}
]
}

274
src/company-scope.test.ts Normal file
View File

@@ -0,0 +1,274 @@
/// <reference types="node" />
import assert from "node:assert/strict";
import { test } from "node:test";
// Excluded from tsc (tsconfig.json) — never bundled/emitted, run natively via
// `node --test`, which requires the literal `.ts` extension (Node's ESM
// resolver does not remap `.js` specifiers to `.ts` files at runtime).
import { PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk";
import {
_resetApiBaseCacheMemo,
CompanyScopeUnavailableError,
isCompanyScopeDenied,
type ResolveApiBaseDeps,
resolveApiBaseFrom,
runJobHandler,
} from "./company-scope.ts";
// ── isCompanyScopeDenied ─────────────────────────────────────────────────
test("isCompanyScopeDenied: true עבור אובייקט עם code של INVOCATION_SCOPE_DENIED", () => {
const err = { code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED };
assert.equal(isCompanyScopeDenied(err), true);
});
test("isCompanyScopeDenied: false עבור שגיאה רגילה", () => {
assert.equal(isCompanyScopeDenied(new Error("boom")), false);
assert.equal(isCompanyScopeDenied({ code: -32002 }), false);
assert.equal(isCompanyScopeDenied("boom"), false);
assert.equal(isCompanyScopeDenied(null), false);
assert.equal(isCompanyScopeDenied(undefined), false);
});
// ── runJobHandler ────────────────────────────────────────────────────────
function makeLogger() {
const calls: Array<{ level: string; message: string; meta?: unknown }> = [];
return {
calls,
logger: {
error(message: string, meta?: Record<string, unknown>) {
calls.push({ level: "error", message, meta });
},
},
};
}
test("runJobHandler: הצלחה — לא זורק ולא מלוגג שגיאה", async () => {
const { calls, logger } = makeLogger();
await runJobHandler("test-job", logger, async () => {});
assert.equal(calls.length, 0);
});
test("runJobHandler: כשל בשגיאה רגילה — מלוגג error וזורק מחדש את המקורית", async () => {
const { calls, logger } = makeLogger();
const original = new Error("plain failure");
await assert.rejects(
() =>
runJobHandler("test-job", logger, async () => {
throw original;
}),
(err: unknown) => err === original,
);
assert.equal(calls.length, 1);
assert.equal(calls[0].message, "test-job: job failed");
});
test("runJobHandler: כשל בדחיית-scope — זורק CompanyScopeUnavailableError שמזכירה #637", async () => {
const { logger } = makeLogger();
const scopeErr = { code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED };
await assert.rejects(
() =>
runJobHandler("sync-case-status", logger, async () => {
throw scopeErr;
}),
(err: unknown) => {
assert.ok(err instanceof CompanyScopeUnavailableError);
assert.match(err.message, /#637/);
assert.equal(err.jobKey, "sync-case-status");
assert.equal(err.cause, scopeErr);
return true;
},
);
});
test("runJobHandler: כשל בדחיית-scope — ההודעה כוללת גם #637 וגם את טקסט השגיאה המקורית (זה מה שנכתב ל-plugin_job_runs.error)", async () => {
const { logger } = makeLogger();
// כמו JsonRpcCallError אמיתי מהמארח (protocol.js): מופע Error שה-`message`
// שלו נושא את שם-הפעולה שנדחתה.
const scopeErr = Object.assign(new Error("issues.list"), {
code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED,
});
await assert.rejects(
() =>
runJobHandler("route-pending-comments", logger, async () => {
throw scopeErr;
}),
(err: unknown) => {
assert.ok(err instanceof CompanyScopeUnavailableError);
assert.match(err.message, /#637/);
assert.match(err.message, /issues\.list/);
// method לא ידוע ל-runJobHandler — "unknown" לא אמור להופיע בהודעה.
assert.doesNotMatch(err.message, /"unknown"/);
return true;
},
);
});
// ── resolveApiBaseFrom ───────────────────────────────────────────────────
function makeDeps(
overrides: Partial<ResolveApiBaseDeps> & {
configByCompany?: Record<string, Record<string, unknown> | undefined>;
deniedCompanyIds?: readonly (string | undefined)[];
failingCompanyIds?: readonly (string | undefined)[];
} = {},
): ResolveApiBaseDeps & {
cacheWrites: string[];
infoLogs: unknown[];
warnLogs: unknown[];
} {
const {
configByCompany = {},
deniedCompanyIds = [],
failingCompanyIds = [],
...rest
} = overrides;
const cacheWrites: string[] = [];
const infoLogs: unknown[] = [];
const warnLogs: unknown[] = [];
let cacheValue: unknown = null;
const deniedKey = (id?: string) => (id === undefined ? "__undefined__" : id);
const deps: ResolveApiBaseDeps & {
cacheWrites: string[];
infoLogs: unknown[];
warnLogs: unknown[];
} = {
knownCompanyIds: [],
defaultBaseUrl: "http://localhost:8085",
async readConfig(companyId?: string) {
const key = deniedKey(companyId);
if (deniedCompanyIds.some((d) => deniedKey(d) === key)) {
throw { code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED };
}
if (failingCompanyIds.some((d) => deniedKey(d) === key)) {
throw new Error(`boom for ${key}`);
}
return configByCompany[key] ?? null;
},
async readCache() {
return cacheValue;
},
async writeCache(url: string) {
cacheValue = url;
cacheWrites.push(url);
},
logger: {
info(message: string, meta?: Record<string, unknown>) {
infoLogs.push({ message, meta });
},
warn(message: string, meta?: Record<string, unknown>) {
warnLogs.push({ message, meta });
},
},
cacheWrites,
infoLogs,
warnLogs,
...rest,
};
// Allow overriding cache seed via readCache in `rest`.
return deps;
}
test("resolveApiBaseFrom: companyId נתון ו-readConfig מחזיר legalApiBaseUrl — מחזיר וכותב cache", async () => {
_resetApiBaseCacheMemo();
const deps = makeDeps({
configByCompany: { "company-a": { legalApiBaseUrl: "https://a.example" } },
});
const result = await resolveApiBaseFrom({ ...deps, companyId: "company-a" });
assert.equal(result, "https://a.example");
assert.deepEqual(deps.cacheWrites, ["https://a.example"]);
});
test('resolveApiBaseFrom: אין companyId — readConfig(undefined) מוסק ע"י המארח ומחזיר ערך', async () => {
_resetApiBaseCacheMemo();
const deps = makeDeps({
configByCompany: {
__undefined__: { legalApiBaseUrl: "https://derived.example" },
},
});
const result = await resolveApiBaseFrom(deps);
assert.equal(result, "https://derived.example");
assert.deepEqual(deps.cacheWrites, ["https://derived.example"]);
});
test("resolveApiBaseFrom: כל הקריאות נדחות ב-scope, יש cache תקף — מחזיר cache ומלוגג info (לא warn)", async () => {
_resetApiBaseCacheMemo();
const deps = makeDeps({
knownCompanyIds: ["c1", "c2"],
deniedCompanyIds: [undefined, "c1", "c2"],
});
// לדמות מטמון קיים: לעקוף readCache
deps.readCache = async () => "https://cached.example";
const result = await resolveApiBaseFrom(deps);
assert.equal(result, "https://cached.example");
assert.equal(deps.warnLogs.length, 0);
assert.equal(deps.infoLogs.length, 1);
});
test("resolveApiBaseFrom: כל הקריאות נדחות ב-scope ואין cache — זורק CompanyScopeUnavailableError (לא default)", async () => {
_resetApiBaseCacheMemo();
const deps = makeDeps({
knownCompanyIds: ["c1"],
deniedCompanyIds: [undefined, "c1"],
});
await assert.rejects(
() => resolveApiBaseFrom(deps),
(err: unknown) => {
assert.ok(err instanceof CompanyScopeUnavailableError);
assert.equal(err.method, "config.get");
return true;
},
);
});
test("resolveApiBaseFrom: readConfig הצליח בכל מקום אך בלי legalApiBaseUrl — default + warn", async () => {
_resetApiBaseCacheMemo();
const deps = makeDeps({
knownCompanyIds: ["c1"],
configByCompany: { __undefined__: {}, c1: {} },
});
const result = await resolveApiBaseFrom(deps);
assert.equal(result, deps.defaultBaseUrl);
assert.equal(deps.warnLogs.length, 1);
});
test("resolveApiBaseFrom: readConfig נכשל בשגיאה שאינה דחיית-scope — warn וממשיך לנסות הבא", async () => {
_resetApiBaseCacheMemo();
const deps = makeDeps({
knownCompanyIds: ["c1"],
failingCompanyIds: [undefined],
configByCompany: { c1: { legalApiBaseUrl: "https://c1.example" } },
});
const result = await resolveApiBaseFrom(deps);
assert.equal(result, "https://c1.example");
// warn אחד על הכשל ב-readConfig(undefined) — לא בליעה שקטה
assert.ok(deps.warnLogs.length >= 1);
});
// ── writeCache dedup (מתוזמן פר-בקשה, לא לכתוב cache שלא השתנה) ──────────
test("resolveApiBaseFrom: כותב ל-cache רק כשהערך משתנה בפועל", async () => {
_resetApiBaseCacheMemo();
const deps = makeDeps({
configByCompany: {
"company-a": { legalApiBaseUrl: "https://dedup.example" },
},
});
// שתי פתירות רצופות של אותו URL — writeCache נקרא פעם אחת בלבד.
await resolveApiBaseFrom({ ...deps, companyId: "company-a" });
await resolveApiBaseFrom({ ...deps, companyId: "company-a" });
assert.deepEqual(deps.cacheWrites, ["https://dedup.example"]);
// פתירה של URL שונה אחריהן — writeCache נקרא שוב.
const deps2 = makeDeps({
configByCompany: {
"company-b": { legalApiBaseUrl: "https://dedup-2.example" },
},
});
await resolveApiBaseFrom({ ...deps2, companyId: "company-b" });
assert.deepEqual(deps2.cacheWrites, ["https://dedup-2.example"]);
});

260
src/company-scope.ts Normal file
View File

@@ -0,0 +1,260 @@
/**
* הפשטת company-scope לג'ובים מתוזמנים ול-webhook (legal-ai issue #637).
*
* המקור לבאג: מאז @paperclipai/server 2026.722.0 אין scope של חברה
* ל-`runJob`/`handleWebhook` — המארח מדפיס scope רק מ-`params.companyId` /
* `performAction.actorContext` / `executeTool.runContext` /
* `onEvent.event.companyId` (plugin-worker-manager.js:191-210), ו-
* `plugin-job-scheduler.js:184` שולח `runJob` בלי `companyId` כלל. לכן כל
* קריאה מתוך ג'וב ל-`ctx.config.get`/`ctx.issues.*`/`ctx.agents.invoke`/
* `ctx.events.emit` נדחית ע"י המארח עם `InvocationScopeDeniedError`
* (host-client-factory.js:253-293) — קוד `-32005`.
*
* המודול הזה טהור בכוונה — כמו `sync-target.ts` — כדי שאפשר יהיה לייבא
* אותו בטסט בלי להריץ את `runWorker(plugin, import.meta.url)` שקורה בזמן
* import של worker.ts. הייבוא היחיד מה-SDK הוא ייבוא-ערך של
* `PLUGIN_RPC_ERROR_CODES` — קבוע פרוטוקול נטול side-effects, לא ה-runtime
* של הפלאגין עצמו.
*/
import { PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk";
/** מפתח ה-`ctx.state` (scope `instance`) שבו נשמר ה-base URL האחרון שהצלחנו לפתור. */
export const LEGAL_API_BASE_CACHE_KEY = "legal-api-base-url";
/**
* זיכרון-תהליך (לא `ctx.state`) של ה-base URL האחרון שבאמת נכתב ל-cache.
* `LegalApi.request` (`legal-api.ts:19-32`) קורא ל-`resolveBaseUrl()` —
* וכך ל-`resolveApiBaseFrom` — בכל בקשת-API בודדת, אבל הערך כמעט לעולם
* לא משתנה; בלי המנגנון הזה כל קריאת-כלי/`usePluginData` הייתה מוסיפה
* `ctx.state.set` (=upsert ל-Postgres) מיותר.
*/
let lastWrittenApiBase: string | undefined;
/** מאפס את זיכרון-המטמון ברמת-המודול. **לשימוש טסטים בלבד.** */
export function _resetApiBaseCacheMemo(): void {
lastWrittenApiBase = undefined;
}
/**
* האם השגיאה היא דחיית-scope של המארח (קוד `-32005`,
* `PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED`). זיהוי **מבני** לפי
* שדה `code` — לא התאמת-מחרוזת על `err.message`, שיכולה להישבר בשקט אם
* המארח ינסח את ההודעה מחדש.
*/
export function isCompanyScopeDenied(err: unknown): boolean {
return (
typeof err === "object" &&
err !== null &&
"code" in err &&
(err as { code?: unknown }).code ===
PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED
);
}
/**
* מחלץ טקסט קריא מ-`cause` שגוי-scope. המארח שולח `JsonRpcCallError`
* (מופע `Error` עם `.message` תיאורי מהצד השני), אבל בטסטים/עתידית ייתכן
* גם אובייקט-שגיאה פשוט בלי prototype chain — לכן בדיקה מבנית ולא רק
* `instanceof Error`.
*/
function describeCause(cause: unknown): string {
if (cause instanceof Error) return cause.message;
if (
typeof cause === "object" &&
cause !== null &&
"message" in cause &&
typeof (cause as { message?: unknown }).message === "string"
) {
return (cause as { message: string }).message;
}
return String(cause);
}
/**
* נזרקת כש-handler של ג'וב/webhook נתקל בדחיית-scope שאין לו דרך לעקוף
* אותה. ההודעה עצמה היא האבחנה — היא מה שיישמר ב-`plugin_job_runs.error`
* (או `plugin_webhook_deliveries.error`), ו-`plugin_logs` ריק לחלוטין
* (נמדד), כך שזו למעשה עדות-הכשל היחידה שתישאר. **חובה** להעביר `cause`
* כשהוא ידוע — המתזמר של Paperclip (`plugin-job-scheduler.js:199`) רושם
* ל-DB רק את `err.message`, ואינו מטייל ב-`Error.cause`, כך שכל מידע
* שלא נכנס למחרוזת-ההודעה עצמה אובד.
*/
export class CompanyScopeUnavailableError extends Error {
readonly jobKey: string;
readonly method: string;
constructor(jobKey: string, method: string, options?: { cause?: unknown }) {
// method="unknown" קורה כש-runJobHandler תופס דחיית-scope בלי לדעת
// איזו קריאה נדחתה בפועל (ה-handler לא ציין method מפורש). לא לכתוב
// את המילה "unknown" להודעה — במקום זה מסתמכים על ה-cause בלבד.
const methodClause =
method === "unknown" ? "" : ` הפעולה שנדחתה: "${method}".`;
const sourceClause =
options?.cause !== undefined
? ` מקור: ${describeCause(options.cause)}.`
: "";
super(
`${jobKey}: נדרש הקשר-חברה (company scope) לפעולה זו, אבל ג'וב ` +
"מתוזמן/webhook אינו מקבל כזה מ-Paperclip (מאז server " +
`2026.722.0).${methodClause}${sourceClause} ראה legal-ai issue #637.`,
options,
);
this.name = "CompanyScopeUnavailableError";
this.jobKey = jobKey;
this.method = method;
}
}
/**
* מריץ handler של ג'וב, ובכשל — מלוגג **וזורק מחדש**. המתזמר של Paperclip
* (`plugin-job-scheduler.js:194-215`) כבר רושם `status:"failed"` כש-RPC
* נדחה; מה שהיה שבור זה ה-handlers שלנו, שבלעו את השגיאה ב-
* `catch (err) { ctx.logger.error(...) }` בלי `throw` — כך ה-RPC נפתר
* בהצלחה וה-run נרשם `succeeded` למרות שלא עשה כלום (13,928 ריצות כוזבות,
* נמדד). ה-README של ה-SDK: "Re-throw from the handler to mark the run as
* failed".
*
* דחיית-scope הופכת ל-`CompanyScopeUnavailableError` מאבחנת; כל שגיאה
* אחרת נזרקת כמות-שהיא.
*/
export async function runJobHandler(
jobKey: string,
logger: { error(message: string, meta?: Record<string, unknown>): void },
fn: () => Promise<void>,
): Promise<void> {
try {
await fn();
} catch (err) {
logger.error(`${jobKey}: job failed`, { error: String(err) });
if (isCompanyScopeDenied(err)) {
throw new CompanyScopeUnavailableError(jobKey, "unknown", {
cause: err,
});
}
throw err;
}
}
/** תלויות מוזרקות לפתירת `legalApiBaseUrl` — כדי שהלוגיקה תהיה בת-טסט בלי `ctx` אמיתי. */
export interface ResolveApiBaseDeps {
/** עוטף `ctx.config.get(companyId)`. **לא** בולע — זורק כשהמארח דוחה scope. */
readConfig(companyId?: string): Promise<Record<string, unknown> | null>;
/** עוטף `ctx.state.get` ב-scope `instance` על `LEGAL_API_BASE_CACHE_KEY`. */
readCache(): Promise<unknown>;
/** עוטף `ctx.state.set` ב-scope `instance` על `LEGAL_API_BASE_CACHE_KEY`. */
writeCache(url: string): Promise<void>;
/** מזהי כל החברות המוכרות לפלאגין (`Object.keys(CEO_AGENT_IDS)`). */
knownCompanyIds: readonly string[];
/** ברירת-המחדל כש-legal-ai לא הוגדר בכלל (אינסטנס לא-מוגדר, לא scope שנדחה). */
defaultBaseUrl: string;
logger: {
info(message: string, meta?: Record<string, unknown>): void;
warn(message: string, meta?: Record<string, unknown>): void;
};
/** ידוע רק כשהמארח מספק אחד (handler בהקשר-חברה) — אופציונלי. */
companyId?: string;
}
/**
* פותר `legalApiBaseUrl` מ-config בהיקף-חברה, עם נפילה-חזרה למטמון
* (`ctx.state`, scope `instance`) כשאין כלל הקשר-חברה זמין (ג'וב/webhook).
*
* ⚠️ כשכל ניסיונות ה-`readConfig` נדחים ב-scope **ואין** מטמון — זורקים
* `CompanyScopeUnavailableError`. **אסור** להחזיר `defaultBaseUrl` במקרה
* הזה: זה בדיוק מה שיצר את `TypeError: fetch failed` המטעה — `worker.ts`
* הישן בלע את דחיית-ה-scope כ"אין קונפיג" ונפל ל-
* `http://localhost:8085` שאין בו מאזין.
*/
export async function resolveApiBaseFrom(
deps: ResolveApiBaseDeps,
): Promise<string> {
const {
readConfig,
readCache,
writeCache,
knownCompanyIds,
defaultBaseUrl,
logger,
companyId,
} = deps;
let sawScopeDenied = false;
// כותב ל-cache רק כשהערך השתנה בפועל — ראה `lastWrittenApiBase` למעלה.
const writeCacheIfChanged = async (url: string): Promise<void> => {
if (url === lastWrittenApiBase) return;
await writeCache(url);
lastWrittenApiBase = url;
};
const tryRead = async (id?: string): Promise<string | null | undefined> => {
let cfg: Record<string, unknown> | null;
try {
cfg = await readConfig(id);
} catch (err) {
if (isCompanyScopeDenied(err)) {
sawScopeDenied = true;
return undefined; // נדחה — נסה את המקור הבא
}
// שגיאה אחרת: אין בליעה שקטה — מדווחים וממשיכים לנסות את הבא.
logger.warn("resolveApiBase: config.get failed", {
companyId: id ?? null,
error: String(err),
});
return undefined;
}
const url = cfg?.legalApiBaseUrl;
return typeof url === "string" && url.trim() ? url.trim() : null;
};
if (companyId) {
const scoped = await tryRead(companyId);
if (scoped) {
await writeCacheIfChanged(scoped);
return scoped;
}
}
// אין companyId ישיר (או שהקריאה מולו נדחתה) — אבל המארח מסיק בעצמו
// companyId כשהקריאה מתרחשת בתוך הקשר host-scoped (למשל tool handler).
// לנסות לפני נפילה-לניחוש. נשאר בהתנהגות המקורית: מנוסה תמיד, לא רק
// כשלא סופק companyId.
const derived = await tryRead(undefined);
if (derived) {
await writeCacheIfChanged(derived);
return derived;
}
// ג'וב מתוזמן/webhook ללא הקשר-חברה כלל: לנסות כל חברה מוכרת. בפועל
// כולן מצביעות על אותו מופע legal-ai, כך שהפגיעה הראשונה מנצחת.
for (const knownCompanyId of knownCompanyIds) {
const url = await tryRead(knownCompanyId);
if (url) {
await writeCacheIfChanged(url);
return url;
}
}
if (sawScopeDenied) {
// כל הניסיונות נדחו ב-scope (לא "לא-מוגדר") — לנסות את המטמון לפני
// שנכשל. מצב-צפוי בג'וב, ולכן `info` ולא `warn`.
const cached = await readCache();
if (typeof cached === "string" && cached.trim()) {
logger.info(
"resolveApiBase: config.get denied (no company scope) — using cached legalApiBaseUrl",
{ cachedBaseUrl: cached },
);
return cached;
}
throw new CompanyScopeUnavailableError("resolveApiBase", "config.get");
}
// כל הקריאות הצליחו (לא נדחו) אך אף אחת לא החזירה ערך — legal-ai פשוט
// לא הוגדר עדיין. זה מצב שונה מהותית מדחיית-scope: מותר ליפול לברירת-מחדל.
logger.warn("legalApiBaseUrl unresolved — using default", {
companyId: companyId ?? null,
fallback: defaultBaseUrl,
});
return defaultBaseUrl;
}

View File

@@ -2,11 +2,23 @@
* HTTP client for Ezer Mishpati legal-ai REST API. * HTTP client for Ezer Mishpati legal-ai REST API.
*/ */
/**
* Where the client gets its base URL. A plain string still works; a resolver is
* used when the URL comes from company-scoped plugin config, which cannot be
* read at worker startup (see worker.ts — `ctx.config.get` needs a company).
*/
export type BaseUrlSource = string | (() => string | Promise<string>);
export class LegalApi { export class LegalApi {
constructor(private baseUrl: string) {} constructor(private baseUrl: BaseUrlSource) {}
private async resolveBaseUrl(): Promise<string> {
return typeof this.baseUrl === "string" ? this.baseUrl : this.baseUrl();
}
private async request<T>(path: string, init?: RequestInit): Promise<T> { private async request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${this.baseUrl}${path}`, { const base = await this.resolveBaseUrl();
const res = await fetch(`${base}${path}`, {
...init, ...init,
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -24,6 +36,10 @@ export class LegalApi {
return this.request("/api/cases"); return this.request("/api/cases");
} }
async getStatusModel(): Promise<StatusModel> {
return this.request("/api/status-model");
}
async getCase(caseNumber: string): Promise<CaseDetails> { async getCase(caseNumber: string): Promise<CaseDetails> {
return this.request(`/api/cases/${encodeURIComponent(caseNumber)}/details`); return this.request(`/api/cases/${encodeURIComponent(caseNumber)}/details`);
} }
@@ -148,6 +164,20 @@ export class LegalApi {
// Return static style guide reference // Return static style guide reference
return "ראה skill-legal-decision/SKILL.md — מדריך סגנון מלא של דפנה תמיר"; return "ראה skill-legal-decision/SKILL.md — מדריך סגנון מלא של דפנה תמיר";
} }
/**
* Recent conclusions of prior heartbeat runs across a case's issues (#220,
* "seance"). Lets a resuming agent read what earlier sessions concluded
* instead of re-deriving context from scratch.
*/
async getPredecessorForCase(
caseNumber: string,
limit = 5,
): Promise<PredecessorResponse> {
return this.request(
`/api/operations/cases/${encodeURIComponent(caseNumber)}/predecessor?limit=${limit}`,
);
}
} }
// Types // Types
@@ -158,6 +188,21 @@ export interface CaseSummary {
status: string; status: string;
} }
export interface StatusModelEntry {
key: string;
label: string;
description: string;
phase: string;
selectable: boolean;
terminal: boolean;
on_enter: string | null;
}
export interface StatusModel {
statuses: StatusModelEntry[];
phases: Array<{ key: string; label: string }>;
}
export interface CaseDetails { export interface CaseDetails {
id: string; id: string;
case_number: string; case_number: string;
@@ -260,3 +305,21 @@ export interface QAResponse {
}>; }>;
status: string; status: string;
} }
export interface PredecessorRun {
run_id: string;
status: string;
started_at: string | null;
finished_at: string | null;
summary: string | null;
error_code: string | null;
session_id: string | null;
agent_name: string | null;
identifier: string | null;
}
export interface PredecessorResponse {
ok: boolean;
case_number: string;
runs: PredecessorRun[];
}

View File

@@ -12,19 +12,30 @@ export default {
"issues.read", "issues.read",
"issues.create", "issues.create",
"issues.update", "issues.update",
"issue.comments.read",
"issue.comments.create", "issue.comments.create",
"issue.interactions.create",
"issue.documents.write",
"agent.tools.register", "agent.tools.register",
"agents.invoke",
"http.outbound", "http.outbound",
"plugin.state.read", "plugin.state.read",
"plugin.state.write", "plugin.state.write",
"jobs.schedule", "jobs.schedule",
"activity.log.write", "activity.log.write",
// נדרש ל-`ctx.metrics.write` — סיכום-ריצת הג'וב `sync-case-status`
// (legal-ai issue #617). זו הדרך היחידה לכתוב ל-`plugin_logs`
// (level='metric'), הטבלה שפאנל "Recent Logs" בדף-הפלאגין מרנדר.
"metrics.write",
"companies.read", "companies.read",
"projects.read", "projects.read",
"webhooks.receive", "webhooks.receive",
"ui.detailTab.register",
"ui.dashboardWidget.register",
] as const, ] as const,
entrypoints: { entrypoints: {
worker: "dist/worker.js", worker: "dist/worker.js",
ui: "dist/ui",
}, },
instanceConfigSchema: { instanceConfigSchema: {
type: "object" as const, type: "object" as const,
@@ -149,6 +160,23 @@ export default {
description: "Get overall system processing status", description: "Get overall system processing status",
parametersSchema: { type: "object" as const, properties: {} }, parametersSchema: { type: "object" as const, properties: {} },
}, },
{
toolKey: "legal_predecessor_context",
name: "legal_predecessor_context",
displayName: "הקשר מריצות קודמות",
description:
"Recent conclusions from prior heartbeat runs on this case (the 'summary' each run left). Call this when RESUMING work on a case to see what earlier sessions already did/decided — instead of re-deriving context from scratch.",
parametersSchema: {
type: "object" as const,
properties: {
case_number: {
type: "string" as const,
description: "Case number (e.g. 1043-02-26)",
},
},
required: ["case_number"],
},
},
], ],
jobs: [ jobs: [
{ {
@@ -171,6 +199,13 @@ export default {
'מסכם פידבק יו"ר מהשבוע האחרון ומעדכן את decision-lessons.md', 'מסכם פידבק יו"ר מהשבוע האחרון ומעדכן את decision-lessons.md',
schedule: "0 19 * * 0", schedule: "0 19 * * 0",
}, },
{
jobKey: "route-pending-comments",
displayName: "ערובת מסירת-הערות (sweep)",
description:
"sweep פיוס: מנתב כל הערת-משתמש שטרם נותבה ל-CEO (at-least-once), כדי שהערה לא תיפול גם אם ה-wakeup התבטל/התלכד",
schedule: "*/2 * * * *",
},
], ],
webhooks: [ webhooks: [
{ {
@@ -180,4 +215,21 @@ export default {
"מקבל עדכוני סטטוס מ-legal-ai ומפרסם תגובה על ה-issue המקושר", "מקבל עדכוני סטטוס מ-legal-ai ומפרסם תגובה על ה-issue המקושר",
}, },
], ],
ui: {
slots: [
{
type: "detailTab" as const,
id: "legal-case-tab",
displayName: "ערר",
exportName: "LegalCaseTab",
entityTypes: ["issue" as const],
},
{
type: "dashboardWidget" as const,
id: "legal-cases-widget",
displayName: "תיקי ערר",
exportName: "LegalCasesWidget",
},
],
},
}; };

View File

@@ -0,0 +1,292 @@
/// <reference types="node" />
import assert from "node:assert/strict";
import { test } from "node:test";
// Excluded from tsc (tsconfig.json) — never bundled/emitted, run natively via
// `node --test`, which requires the literal `.ts` extension (Node's ESM
// resolver does not remap `.js` specifiers to `.ts` files at runtime).
import type { StatusModelEntry } from "./legal-api.ts";
import {
declinedTotal,
formatSyncRunSummary,
MAX_SUMMARY_LENGTH,
newSyncRunCounters,
recordDecline,
SYNC_RUN_SUMMARY_PREFIX,
type SyncRunCounters,
syncRunSummaryTags,
} from "./sync-run-summary.ts";
import {
pickSyncTargetIssue,
resolveIssueStatus,
type SyncCandidate,
} from "./sync-target.ts";
test("AC2: שלוש המחרוזות (ריק, נדחה, נכשל) שונות זו מזו", () => {
const empty = newSyncRunCounters();
const emptyLine = formatSyncRunSummary(empty, { outcome: "ok" });
const declined = newSyncRunCounters();
declined.scanned = 12;
// matched נשאר 0 — 12 התיקים נדחו ב-`no_writable_root`, כלומר
// `pickSyncTargetIssue` לא בחר להם יעד כלל. מצב עקבי עם האינvariant.
for (let i = 0; i < 12; i++) recordDecline(declined, "no_writable_root");
const declinedLine = formatSyncRunSummary(declined, { outcome: "ok" });
const failed = newSyncRunCounters();
failed.scanned = 3;
const failedLine = formatSyncRunSummary(failed, {
outcome: "failed",
error: "boom",
});
assert.notEqual(emptyLine, declinedLine);
assert.notEqual(emptyLine, failedLine);
assert.notEqual(declinedLine, failedLine);
// כל השלוש כתובות=0
assert.match(emptyLine, /written=0/);
assert.match(declinedLine, /written=0/);
assert.match(failedLine, /written=0/);
assert.match(emptyLine, /run=ok scanned=0 matched=0 written=0 declined=0/);
assert.match(
declinedLine,
/run=ok scanned=12 matched=0 written=0 declined=12 \[no_linked_issues=0 no_writable_root=12 ambiguous_writable_roots=0 unknown_status=0 already_matching=0\]/,
);
assert.match(failedLine, /run=failed scanned=3.*error=boom/);
});
test("מיפוי SyncTargetReason → דלי: כל reason מעלה את הדלי הנכון בלבד", () => {
const noLinked = newSyncRunCounters();
recordDecline(noLinked, "no_linked_issues");
assert.equal(noLinked.declined.no_linked_issues, 1);
assert.equal(declinedTotal(noLinked), 1);
const noRoot = newSyncRunCounters();
recordDecline(noRoot, "no_writable_root");
assert.equal(noRoot.declined.no_writable_root, 1);
assert.equal(declinedTotal(noRoot), 1);
const ambiguous = newSyncRunCounters();
recordDecline(ambiguous, "ambiguous_writable_roots");
assert.equal(ambiguous.declined.ambiguous_writable_roots, 1);
assert.equal(declinedTotal(ambiguous), 1);
// כל אחד מהשלושה לא נגע בדליים האחרים.
for (const c of [noLinked, noRoot, ambiguous]) {
const total =
c.declined.no_linked_issues +
c.declined.no_writable_root +
c.declined.ambiguous_writable_roots +
c.declined.unknown_status +
c.declined.already_matching;
assert.equal(total, 1);
}
});
/**
* פיקסצ'ר מצומצם של `/api/status-model` — מספיק כדי ש-`resolveIssueStatus`
* יחזיר todo/in_progress/done/null.
*/
const STATUS_MODEL: StatusModelEntry[] = [
{
key: "new",
label: "חדש",
description: "",
phase: "intake",
selectable: true,
terminal: false,
on_enter: null,
},
{
key: "drafted",
label: "טיוטה מוכנה",
description: "",
phase: "drafting",
selectable: true,
terminal: false,
on_enter: null,
},
{
key: "final",
label: "סופי",
description: "",
phase: "final",
selectable: true,
terminal: true,
on_enter: null,
},
];
interface SimulatedCase {
case_number: string;
status: string;
candidates: readonly SyncCandidate[];
}
/**
* מחקה את לולאת `worker.ts` (sync-case-status) — אותה סדרת-החלטות בדיוק,
* באמצעות `pickSyncTargetIssue`/`resolveIssueStatus` האמיתיים. יש תקדים
* לחיקוי-לולאה כזה ב-`sync-target.test.ts` (`legacyTargets`).
*/
function simulateSyncRun(cases: readonly SimulatedCase[]): SyncRunCounters {
const counters = newSyncRunCounters();
counters.scanned = cases.length;
for (const legalCase of cases) {
const targetStatus = resolveIssueStatus(STATUS_MODEL, legalCase.status);
if (targetStatus === null) {
recordDecline(counters, "unknown_status");
continue;
}
const { target, reason } = pickSyncTargetIssue(legalCase.candidates);
if (!target) {
// reason כאן הוא SyncTargetReason שאינו "ok" (target===null) —
// תת-קבוצה מובטחת-מהדר של SyncDeclineReason (ראה sync-run-summary.ts).
recordDecline(counters, reason as Exclude<typeof reason, "ok">);
continue;
}
counters.matched++;
if (target.status === targetStatus) {
recordDecline(counters, "already_matching");
continue;
}
counters.written++;
}
return counters;
}
test(ינvariant: matched === written + declined.already_matching, ו-scanned מתפרק במלואו", () => {
const cases: SimulatedCase[] = [
// scanned, unknown_status
{ case_number: "c-unknown", status: "totally_unknown", candidates: [] },
// no_linked_issues
{ case_number: "c-nolink", status: "new", candidates: [] },
// no_writable_root
{
case_number: "c-noroot",
status: "new",
candidates: [
{ id: "i-1", status: "blocked", parentId: null, companyId: "c1" },
],
},
// ambiguous_writable_roots
{
case_number: "c-ambiguous",
status: "new",
candidates: [
{ id: "i-2", status: "todo", parentId: null, companyId: "c1" },
{ id: "i-3", status: "in_progress", parentId: null, companyId: "c1" },
],
},
// matched + already_matching (target.status === targetStatus === todo)
{
case_number: "c-matching",
status: "new",
candidates: [
{ id: "i-4", status: "todo", parentId: null, companyId: "c1" },
],
},
// matched + written (target.status !== targetStatus)
{
case_number: "c-written",
status: "final",
candidates: [
{ id: "i-5", status: "in_progress", parentId: null, companyId: "c1" },
],
},
];
const counters = simulateSyncRun(cases);
assert.equal(counters.scanned, 6);
assert.equal(counters.matched, 2); // c-matching + c-written
assert.equal(counters.written, 1); // c-written בלבד
assert.equal(counters.declined.already_matching, 1); // c-matching
assert.equal(
counters.matched,
counters.written + counters.declined.already_matching,
);
assert.equal(
counters.scanned,
counters.matched +
counters.declined.no_linked_issues +
counters.declined.no_writable_root +
counters.declined.ambiguous_writable_roots +
counters.declined.unknown_status,
);
assert.equal(counters.declined.unknown_status, 1);
assert.equal(counters.declined.no_linked_issues, 1);
assert.equal(counters.declined.no_writable_root, 1);
assert.equal(counters.declined.ambiguous_writable_roots, 1);
});
test("חסם-אורך: שגיאה בת 5000 תווים לא שוברת את מבנה השורה", () => {
const counters = newSyncRunCounters();
counters.scanned = 42;
counters.matched = 3;
counters.written = 1;
recordDecline(counters, "already_matching");
recordDecline(counters, "already_matching");
const longError = "x".repeat(5000);
const line = formatSyncRunSummary(counters, {
outcome: "failed",
error: longError,
});
assert.ok(line.length <= MAX_SUMMARY_LENGTH);
assert.match(line, /scanned=42/);
assert.match(line, /written=1/);
assert.match(line, /already_matching=2/);
assert.ok(line.endsWith("…"));
});
test("tags: כל הערכים מחרוזות, וכל תשעת המונים + outcome נוכחים", () => {
const counters = newSyncRunCounters();
counters.scanned = 5;
counters.matched = 2;
counters.written = 1;
recordDecline(counters, "no_linked_issues");
const tags = syncRunSummaryTags(counters, { outcome: "ok" });
for (const value of Object.values(tags)) {
assert.equal(typeof value, "string");
}
const expectedKeys = [
"outcome",
"scanned",
"matched",
"written",
"declined",
"no_linked_issues",
"no_writable_root",
"ambiguous_writable_roots",
"unknown_status",
"already_matching",
];
for (const key of expectedKeys) {
assert.ok(key in tags, `missing tag key: ${key}`);
}
assert.equal(Object.keys(tags).length, expectedKeys.length);
});
test("קידומת יציבה: כל שורה מתחילה ב-SYNC_RUN_SUMMARY_PREFIX", () => {
const counters = newSyncRunCounters();
const okLine = formatSyncRunSummary(counters, { outcome: "ok" });
const failedLine = formatSyncRunSummary(counters, {
outcome: "failed",
error: "x",
});
assert.ok(okLine.startsWith(`${SYNC_RUN_SUMMARY_PREFIX} `));
assert.ok(failedLine.startsWith(`${SYNC_RUN_SUMMARY_PREFIX} `));
});

145
src/sync-run-summary.ts Normal file
View File

@@ -0,0 +1,145 @@
/**
* סיכום-ריצה של הג'וב `sync-case-status` (legal-ai issue #617).
*
* המקור לצורך: הג'וב היה מסיים בשורת-לוג יחידה
* (`"Case status sync completed", { casesChecked: cases.length }`) שאינה
* מבחינה בין "אין מה לעשות", "נדחה בכוונה" (`pickSyncTargetIssue`,
* `resolveIssueStatus`) ו"נכשל". המודול הזה טהור בכוונה — בלי import
* מה-SDK ובלי side effects — באותה רוח בדיוק כמו `sync-target.ts`, כדי
* שאפשר יהיה לייבא אותו בטסט בלי להריץ את `runWorker(...)`.
*
* ⚠️ **למה המונים בתוך מחרוזת-ההודעה ולא רק ב-`meta`:** נמדד ששני
* המשטחים היחידים שבהם סיכום-ריצה נראה לעין-אדם מתעלמים מ-`meta`.
* (1) `ctx.logger.*` מגיע רק ל-stdout של pm2 (pino) — ונמדד ש-`meta`
* נופל שם בפועל (שדה `{error: …}` שהקוד מעביר לא הופיע בשורה).
* (2) `ctx.metrics.write` נכתב ל-`plugin_logs` (level='metric'), וזו
* הטבלה שפאנל "Recent Logs" בדף-הפלאגין מרנדר — אבל הפאנל מרנדר רק
* `createdAt`/`level`/`message`, לא `meta`.
* לכן כל מונה חייב להופיע במחרוזת עצמה כדי שיהיה נראה בכל מקום שבו
* הסיכום בפועל נצפה.
*/
import type { SyncTargetReason } from "./sync-target.ts";
/** הקידומת היציבה של כל שורת-סיכום — עליה נשען `WHERE message LIKE`. */
export const SYNC_RUN_SUMMARY_PREFIX = "sync-case-status";
/** חסם אורך — `MAX_METRIC_NAME_LENGTH` של המארח (plugin-host-services.js:258). */
export const MAX_SUMMARY_LENGTH = 500;
export interface SyncDeclineCounters {
no_linked_issues: number;
no_writable_root: number;
ambiguous_writable_roots: number;
unknown_status: number;
already_matching: number;
}
export interface SyncRunCounters {
scanned: number;
matched: number;
written: number;
declined: SyncDeclineCounters;
}
export type SyncDeclineReason = keyof SyncDeclineCounters;
/**
* שלושת ה-reasons של `pickSyncTargetIssue` שאינם `"ok"` הם תת-קבוצה של
* `SyncDeclineReason` — ונאכף כאן במהדר, לא רק בתיעוד: אם `sync-target.ts`
* יוסיף `SyncTargetReason` חדש בלי דלי-מונה תואם כאן, שורת בדיקת-ההצבה
* הבאה תיכשל ב-`tsc` (הטיפוס בפועל לא יעמוד באילוץ `extends`), ולא
* תיבלע בשקט.
*/
export type SyncTargetDeclineReason = Exclude<SyncTargetReason, "ok">;
/** בדיקת-הצבה סטטית בלבד — לא נקרא בזמן ריצה, ואינו זקוק לערך. */
type AssertExtends<_Sub extends _Super, _Super> = true;
type _syncTargetDeclineReasonIsSubsetOfSyncDeclineReason = AssertExtends<
SyncTargetDeclineReason,
SyncDeclineReason
>;
export function newSyncRunCounters(): SyncRunCounters {
return {
scanned: 0,
matched: 0,
written: 0,
declined: {
no_linked_issues: 0,
no_writable_root: 0,
ambiguous_writable_roots: 0,
unknown_status: 0,
already_matching: 0,
},
};
}
export function recordDecline(
counters: SyncRunCounters,
reason: SyncDeclineReason,
): void {
counters.declined[reason]++;
}
export function declinedTotal(counters: SyncRunCounters): number {
const { declined } = counters;
return (
declined.no_linked_issues +
declined.no_writable_root +
declined.ambiguous_writable_roots +
declined.unknown_status +
declined.already_matching
);
}
export function formatSyncRunSummary(
counters: SyncRunCounters,
opts: { outcome: "ok" | "failed"; error?: string },
): string {
const { declined } = counters;
const head =
`${SYNC_RUN_SUMMARY_PREFIX} run=${opts.outcome} scanned=${counters.scanned} ` +
`matched=${counters.matched} written=${counters.written} ` +
`declined=${declinedTotal(counters)} ` +
`[no_linked_issues=${declined.no_linked_issues} ` +
`no_writable_root=${declined.no_writable_root} ` +
`ambiguous_writable_roots=${declined.ambiguous_writable_roots} ` +
`unknown_status=${declined.unknown_status} ` +
`already_matching=${declined.already_matching}]`;
if (opts.error === undefined) return head;
const full = `${head} error=${opts.error}`;
if (full.length <= MAX_SUMMARY_LENGTH) return full;
// חיתוך נופל רק על זנב-השגיאה — המונים תמיד שלמים וקריאים.
const ellipsis = "…";
const errorPrefixLen = `${head} error=`.length;
const budget = MAX_SUMMARY_LENGTH - errorPrefixLen - ellipsis.length;
if (budget <= 0) {
// אין מקום אפילו לתו אחד של error — נחתך ה-head עצמו (מקרה קיצון
// תיאורטי: זה יקרה רק אם head לבדו כבר עובר את MAX_SUMMARY_LENGTH).
return head.slice(0, MAX_SUMMARY_LENGTH - ellipsis.length) + ellipsis;
}
return `${head} error=${opts.error.slice(0, budget)}${ellipsis}`;
}
export function syncRunSummaryTags(
counters: SyncRunCounters,
opts: { outcome: "ok" | "failed" },
): Record<string, string> {
const { declined } = counters;
return {
outcome: opts.outcome,
scanned: String(counters.scanned),
matched: String(counters.matched),
written: String(counters.written),
declined: String(declinedTotal(counters)),
no_linked_issues: String(declined.no_linked_issues),
no_writable_root: String(declined.no_writable_root),
ambiguous_writable_roots: String(declined.ambiguous_writable_roots),
unknown_status: String(declined.unknown_status),
already_matching: String(declined.already_matching),
};
}

389
src/sync-target.test.ts Normal file
View File

@@ -0,0 +1,389 @@
/// <reference types="node" />
import assert from "node:assert/strict";
import { test } from "node:test";
// Excluded from tsc (tsconfig.json) — never bundled/emitted, run natively via
// `node --test`, which requires the literal `.ts` extension (Node's ESM
// resolver does not remap `.js` specifiers to `.ts` files at runtime).
import type { StatusModelEntry } from "./legal-api.ts";
import {
isWritableStatus,
labelFor,
pickSyncTargetIssue,
resolveIssueStatus,
resolveStatusLabel,
type SyncCandidate,
} from "./sync-target.ts";
/**
* הכלל הישן (worker.ts:894 לפני #446): כל issue מקושר שסטטוסו שונה מהיעד —
* נכתב, בלי סינון parentId ובלי סינון-סטטוס.
*/
function legacyTargets(
candidates: readonly SyncCandidate[],
targetStatus: string,
): string[] {
return candidates.filter((c) => c.status !== targetStatus).map((c) => c.id);
}
test("רגרסיית 8125-09-24 / CMPA-140: 11 ה-issues הממשיים של התיק, אף sub-task done לא נבחר", () => {
// נמדד ב-DB החי של Paperclip (2026-08-24) — כל ה-issues המקושרים לתיק
// 8125-09-24. CMPA-140 (זה שנדרס בפועל, done→in_progress) הוא **ילד** של
// CMPA-139, לא שורש. CMPA-139 עצמו אינו ברשימה בכוונה: נמדד שאין לו
// רשומת legal-case-number ב-plugin_state, ולכן הוא לא מגיע כמועמד כלל —
// לילד יש הורה שאינו מקושר.
const candidates: SyncCandidate[] = [
{ id: "CMPA-116", status: "cancelled", parentId: null, companyId: "c1" },
{ id: "CMPA-84", status: "cancelled", parentId: null, companyId: "c1" },
{ id: "CMPA-86", status: "cancelled", parentId: null, companyId: "c1" },
{ id: "CMPA-140", status: "done", parentId: "CMPA-139", companyId: "c1" }, // זה שנדרס בפועל
{
id: "CMPA-141",
status: "cancelled",
parentId: "CMPA-139",
companyId: "c1",
},
{ id: "CMPA-85", status: "done", parentId: "CMPA-84", companyId: "c1" },
{ id: "CMPA-146", status: "done", parentId: "CMPA-84", companyId: "c1" },
{ id: "CMPA-147", status: "done", parentId: "CMPA-84", companyId: "c1" },
{ id: "CMPA-87", status: "done", parentId: "CMPA-86", companyId: "c1" },
{ id: "CMPA-88", status: "done", parentId: "CMPA-86", companyId: "c1" },
{
id: "CMPA-89",
status: "cancelled",
parentId: "CMPA-86",
companyId: "c1",
},
];
// סטטוס-התיק היה drafted → in_progress (CASE_STATUS_TO_ISSUE_STATUS ב-worker.ts).
const targetStatus = "in_progress";
// הכלל הישן היה כותב על CMPA-140 — זה הבאג שתועד.
assert.ok(legacyTargets(candidates, targetStatus).includes("CMPA-140"));
const result = pickSyncTargetIssue(candidates);
// הכלל החדש אינו כותב על דבר.
assert.equal(result.target, null);
assert.equal(result.reason, "no_writable_root");
});
test("sub-task יחיד ב-done ומקושר: sub-task לעולם לא נבחר", () => {
const candidates: SyncCandidate[] = [
{ id: "sub-1", status: "done", parentId: "some-root", companyId: "c1" },
];
const result = pickSyncTargetIssue(candidates);
assert.equal(result.target, null);
assert.equal(result.reason, "no_writable_root");
});
test("sub-task יחיד פתוח (in_progress) + שורש cancelled: פתוח אינו מספיק, חייב שורש", () => {
const candidates: SyncCandidate[] = [
{ id: "root-1", status: "cancelled", parentId: null, companyId: "c1" },
{ id: "sub-1", status: "in_progress", parentId: "root-1", companyId: "c1" },
];
const result = pickSyncTargetIssue(candidates);
assert.equal(result.target, null);
assert.equal(result.reason, "no_writable_root");
});
test("שורש יחיד ב-todo: נבחר", () => {
const candidates: SyncCandidate[] = [
{ id: "root-1", status: "todo", parentId: null, companyId: "c1" },
];
const result = pickSyncTargetIssue(candidates);
assert.equal(result.target?.id, "root-1");
assert.equal(result.reason, "ok");
assert.equal(result.writableRoots, 1);
});
// הכרעת #626 (2026-08-26): in_review כבר לא ב-NON_WRITABLE_STATUSES —
// שורש יחיד ב-in_review עכשיו נבחר כיעד-כתיבה, במקום להיחסם.
test("שורש יחיד ב-in_review: נבחר (הכרעת #626 — 2026-08-26)", () => {
const candidates: SyncCandidate[] = [
{ id: "root-1", status: "in_review", parentId: null, companyId: "c1" },
];
const result = pickSyncTargetIssue(candidates);
assert.equal(result.target?.id, "root-1");
assert.equal(result.reason, "ok");
assert.equal(result.writableRoots, 1);
});
test("שורש יחיד ב-blocked: לא נכתב", () => {
const candidates: SyncCandidate[] = [
{ id: "root-1", status: "blocked", parentId: null, companyId: "c1" },
];
const result = pickSyncTargetIssue(candidates);
assert.equal(result.target, null);
assert.equal(result.reason, "no_writable_root");
});
test("isWritableStatus: הגבול המלא בין בר-כתיבה ללא-בר-כתיבה", () => {
assert.equal(isWritableStatus("in_review"), true);
assert.equal(isWritableStatus("blocked"), false);
assert.equal(isWritableStatus("done"), false);
assert.equal(isWritableStatus("cancelled"), false);
assert.equal(isWritableStatus("todo"), true);
});
test("שני שורשים ברי-כתיבה: ambiguous, לא נכתב", () => {
const candidates: SyncCandidate[] = [
{ id: "root-1", status: "todo", parentId: null, companyId: "c1" },
{ id: "root-2", status: "in_progress", parentId: null, companyId: "c1" },
];
const result = pickSyncTargetIssue(candidates);
assert.equal(result.target, null);
assert.equal(result.reason, "ambiguous_writable_roots");
assert.equal(result.writableRoots, 2);
});
test("שורש todo + ילד in_progress: השורש נבחר", () => {
const candidates: SyncCandidate[] = [
{ id: "root-1", status: "todo", parentId: null, companyId: "c1" },
{
id: "child-1",
status: "in_progress",
parentId: "root-1",
companyId: "c1",
},
];
const result = pickSyncTargetIssue(candidates);
assert.equal(result.target?.id, "root-1");
assert.equal(result.reason, "ok");
});
/**
* פיקסצ'ר ריאלי של `/api/status-model` (case_status_model.py) — הסדר קובע
* מי הוא "todo" (האינדקס הראשון), ו-`terminal: true` קובע מי "done".
*/
const STATUS_MODEL: StatusModelEntry[] = [
{
key: "new",
label: "חדש",
description: "תיק נפתח, טרם הועלו מסמכים",
phase: "intake",
selectable: true,
terminal: false,
on_enter: null,
},
{
key: "processing",
label: "בעיבוד",
description: "מסמכים בעיבוד",
phase: "intake",
selectable: false,
terminal: false,
on_enter: null,
},
{
key: "documents_ready",
label: "מסמכים מוכנים",
description: "העיבוד הושלם",
phase: "intake",
selectable: true,
terminal: false,
on_enter: null,
},
{
key: "analyst_verified",
label: 'אומת ע"י אנליסט',
description: "אנליסט אימת את התוכן",
phase: "analysis",
selectable: true,
terminal: false,
on_enter: null,
},
{
key: "research_complete",
label: "מחקר הושלם",
description: "מחקר תקדימים הושלם",
phase: "analysis",
selectable: true,
terminal: false,
on_enter: null,
},
{
key: "outcome_set",
label: "תוצאה נקבעה",
description: "תוצאה הוזנה",
phase: "analysis",
selectable: true,
terminal: false,
on_enter: null,
},
{
key: "direction_approved",
label: "כיוון אושר",
description: "כיוון הכתיבה אושר",
phase: "drafting",
selectable: true,
terminal: false,
on_enter: null,
},
{
key: "qa_review",
label: "בדיקת איכות",
description: "בבדיקת איכות",
phase: "drafting",
selectable: true,
terminal: false,
on_enter: null,
},
{
key: "drafted",
label: "טיוטה מוכנה",
description: "טיוטה נכתבה",
phase: "drafting",
selectable: true,
terminal: false,
on_enter: null,
},
{
key: "exported",
label: "יוצא ל-DOCX",
description: "יוצא כקובץ DOCX",
phase: "review",
selectable: true,
terminal: false,
on_enter: null,
},
{
key: "reviewed",
label: 'נבדק ע"י דפנה',
description: "דפנה הגיהה",
phase: "review",
selectable: true,
terminal: false,
on_enter: null,
},
{
key: "final",
label: "סופי",
description: "גרסה סופית",
phase: "final",
selectable: true,
terminal: true,
on_enter: null,
},
];
test("resolveIssueStatus: הסטטוס הראשון ברשימה (new) → todo", () => {
assert.equal(resolveIssueStatus(STATUS_MODEL, "new"), "todo");
});
test("resolveIssueStatus: סטטוס terminal (final) → done", () => {
assert.equal(resolveIssueStatus(STATUS_MODEL, "final"), "done");
});
test("resolveIssueStatus: analyst_verified (חסר במפה הישנה) → in_progress", () => {
assert.equal(
resolveIssueStatus(STATUS_MODEL, "analyst_verified"),
"in_progress",
);
});
test("resolveIssueStatus: research_complete → in_progress", () => {
assert.equal(
resolveIssueStatus(STATUS_MODEL, "research_complete"),
"in_progress",
);
});
test("resolveIssueStatus: סטטוס לא-מוכר → null (לא בליעה שקטה)", () => {
assert.equal(
resolveIssueStatus(STATUS_MODEL, "totally_unknown_status"),
null,
);
});
test("labelFor: מחזיר את התווית מהפיקסצ'ר, ו-null לסטטוס לא-מוכר", () => {
assert.equal(labelFor(STATUS_MODEL, "new"), "חדש");
assert.equal(labelFor(STATUS_MODEL, "totally_unknown_status"), null);
});
test("רגרסיה: companyId של המועמד-הנבחר שורד את pickSyncTargetIssue (לא companyId חיצוני קבוע)", () => {
const candidates: SyncCandidate[] = [
{
id: "issue-a",
status: "cancelled",
parentId: null,
companyId: "company-a",
},
{
id: "issue-b",
status: "todo",
parentId: null,
companyId: "company-b",
},
];
const result = pickSyncTargetIssue(candidates);
assert.equal(result.target?.id, "issue-b");
assert.equal(result.target?.companyId, "company-b");
});
test("resolveStatusLabel: סטטוס מוכר → תווית מה-SSOT, בלי fallback", () => {
assert.deepEqual(resolveStatusLabel(STATUS_MODEL, "new"), {
label: "חדש",
fallback: null,
});
});
test("resolveStatusLabel: סטטוס שאינו במודל → warning-worthy fallback + ערך גולמי, בלי זריקה", () => {
assert.doesNotThrow(() =>
resolveStatusLabel(STATUS_MODEL, "totally_unknown_status"),
);
assert.deepEqual(resolveStatusLabel(STATUS_MODEL, "totally_unknown_status"), {
label: "totally_unknown_status",
fallback: "unknown_status",
});
});
test("resolveStatusLabel: מודל לא-זמין (null) → model_unavailable, נבדל מסטטוס-לא-מוכר", () => {
assert.deepEqual(resolveStatusLabel(null, "processing"), {
label: "processing",
fallback: "model_unavailable",
});
});
test("רגרסיה #616: כל הסטטוסים שהיו במפה הקשיחה ועודם חיים מקבלים תווית מה-SSOT", () => {
const survivingKeys = [
"new",
"processing",
"documents_ready",
"outcome_set",
"direction_approved",
"qa_review",
"drafted",
"exported",
"reviewed",
"final",
].filter((key) => STATUS_MODEL.some((s) => s.key === key));
for (const key of survivingKeys) {
const result = resolveStatusLabel(STATUS_MODEL, key);
assert.equal(result.fallback, null, `${key}: expected no fallback`);
assert.equal(
typeof result.label,
"string",
`${key}: expected string label`,
);
assert.notEqual(result.label, "", `${key}: expected non-empty label`);
}
});

150
src/sync-target.ts Normal file
View File

@@ -0,0 +1,150 @@
/**
* בחירת יעד-הסנכרון של הג'וב `sync-case-status` (issue #446).
*
* המקור לבאג: הג'וב הישן כתב על **כל** issue מקושר-לתיק, כולל sub-issues
* ו-issues בסטטוסים סגורים/בהמתנה-לאדם. זה החזיר CMPA-140 מ-`done` ל-`in_progress`
* (8125-09-24) — כי sub-task שהושלם עדיין נמנה כ"מקושר".
*
* המודול הזה טהור בכוונה — בלי import מה-SDK ובלי side effects — כדי שאפשר
* יהיה לייבא אותו ישירות בטסט בלי להריץ את `runWorker(plugin, import.meta.url)`
* שקורה בזמן import של worker.ts. ה-import היחיד למטה הוא type-only (`import
* type`), כך שהוא לא נדרס בזמן ריצה ולא שובר את הטוהר הזה.
*/
import type { StatusModelEntry } from "./legal-api.ts";
/** "סגור" — ההגדרה היחידה. מראה של web/paperclip_client.py:561 ב-legal-ai. */
export const CLOSED_ISSUE_STATUSES: ReadonlySet<string> = new Set([
"done",
"cancelled",
]);
/**
* מצב בבעלות Paperclip — **לא** הגדרה שנייה של "סגור". `blocked` — כתיבה
* עליו מסתירה חוסם קיים.
*
* `in_review` **הוסר מהסט** בהכרעת חיים מ-2026-08-26 (legal-ai issue #626):
* נשאל במפורש אם השחרור נקודתי או שיטתי, והשיב "התכוונתי לשיטתי".
*
* המחיר, בכנות: `in_review` הוא מצב-ההמתנה-ליו"ר המכוון — CEO שמשאיר issue
* ב-`in_progress` מקבל auto-block מ-Paperclip תוך דקה, ולכן מעביר אותו
* ל-`in_review` כדי לחמוק מזה (legal-ai/docs/paperclip-quirks.md §3). מעתה
* issue שממתין לביקורת היו"ר עשוי להידרס בחזרה ל-`in_progress` בריצת-
* הסנכרון הבאה (כל 15 דק') ולרדת מתור-הביקורת. זה מכוון ומתועד, לא תקלה.
*
* מסלול-חזרה: להחזיר `"in_review"` לסט למטה + build + התקנה +
* `pm2 restart paperclip`.
*/
export const NON_WRITABLE_STATUSES: ReadonlySet<string> = new Set(["blocked"]);
export interface SyncCandidate {
id: string;
status: string;
parentId: string | null;
companyId: string;
}
export type SyncTargetReason =
| "ok"
| "no_linked_issues"
| "no_writable_root"
| "ambiguous_writable_roots";
export interface SyncTargetResult {
target: SyncCandidate | null;
reason: SyncTargetReason;
/** כמה שורשים ברי-כתיבה נמצאו — לצורך לוג. */
writableRoots: number;
}
export function isWritableStatus(status: string): boolean {
return (
!CLOSED_ISSUE_STATUSES.has(status) && !NON_WRITABLE_STATUSES.has(status)
);
}
export function pickSyncTargetIssue(
candidates: readonly SyncCandidate[],
): SyncTargetResult {
if (candidates.length === 0) {
return { target: null, reason: "no_linked_issues", writableRoots: 0 };
}
const roots = candidates.filter(
(c) => c.parentId === null && isWritableStatus(c.status),
);
if (roots.length === 1) {
return { target: roots[0], reason: "ok", writableRoots: 1 };
}
if (roots.length === 0) {
return { target: null, reason: "no_writable_root", writableRoots: 0 };
}
return {
target: null,
reason: "ambiguous_writable_roots",
writableRoots: roots.length,
};
}
/**
* Derive the coarse Paperclip issue status for a legal-ai case status, using
* only generic fields already returned by legal-ai's `/api/status-model`
* (the canonical `case_status_model.py` registry) — never a hardcoded list of
* case-status keys. This is what makes a new case status "just work" without
* editing this plugin: `terminal: true` → done; the very first status in the
* ordered list → todo (nothing has started yet); everything else → in_progress.
* Returns `null` when `caseStatus` is not present in `statuses` at all (a
* genuinely unknown status — the caller must log this loudly, never swallow
* it silently; see legal-ai issue #604).
*/
export function resolveIssueStatus(
statuses: readonly StatusModelEntry[],
caseStatus: string,
): "todo" | "in_progress" | "done" | null {
const index = statuses.findIndex((s) => s.key === caseStatus);
if (index === -1) return null;
if (statuses[index].terminal) return "done";
return index === 0 ? "todo" : "in_progress";
}
/** Hebrew label for a case status from the canonical model, or null if unknown. */
export function labelFor(
statuses: readonly StatusModelEntry[],
caseStatus: string,
): string | null {
return statuses.find((s) => s.key === caseStatus)?.label ?? null;
}
/** למה נפלנו לערך-הגולמי, או `null` כשהסטטוס מוכר. הקורא **חייב** לדווח על ערך לא-null. */
export type StatusLabelFallback = "unknown_status" | "model_unavailable" | null;
export interface StatusLabelResolution {
label: string;
fallback: StatusLabelFallback;
}
/**
* התווית להצגה עבור `caseStatus`, יחד עם **סיבת** נפילה-לגולמי אם הייתה.
*
* ההחלטה מופרדת כאן מהדיווח (`ctx.logger`) בכוונה: `worker.ts` אינו ניתן
* ל-import בטסט (הוא מריץ `runWorker(...)` ברמת-המודול), ולכן ההיגיון שחייב
* כיסוי-טסט חי במודול הטהור הזה — בדיוק כמו `pickSyncTargetIssue` (#446)
* ו-`resolveIssueStatus` (#604). legal-ai issue #616.
*
* `statuses === null` = מודל-הסטטוסים לא נטען כלל (כשל-רשת) — נבדל מסטטוס
* שנטען ולא נמצא, כי הן שתי תקלות שונות עם אבחון שונה.
*/
export function resolveStatusLabel(
statuses: readonly StatusModelEntry[] | null,
caseStatus: string,
): StatusLabelResolution {
if (statuses === null) {
return { label: caseStatus, fallback: "model_unavailable" };
}
const label = labelFor(statuses, caseStatus);
if (label === null) {
return { label: caseStatus, fallback: "unknown_status" };
}
return { label, fallback: null };
}

248
src/ui/LegalCaseTab.tsx Normal file
View File

@@ -0,0 +1,248 @@
import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui";
import { usePluginData } from "@paperclipai/plugin-sdk/ui/hooks";
import type {
LegalArgument,
LegalArgumentsResponse,
LegalCaseDetails,
MissingPrecedentRow,
PrecedentRow,
} from "./types.js";
const containerStyle: React.CSSProperties = {
padding: 16,
fontFamily:
"system-ui, -apple-system, 'Segoe UI', 'Noto Sans Hebrew', sans-serif",
color: "inherit",
};
const sectionStyle: React.CSSProperties = {
marginBottom: 24,
};
const headingStyle: React.CSSProperties = {
fontSize: 16,
fontWeight: 600,
margin: "0 0 8px 0",
borderBottom: "1px solid rgba(127,127,127,0.25)",
paddingBottom: 4,
};
const metaListStyle: React.CSSProperties = {
display: "grid",
gridTemplateColumns: "max-content 1fr",
columnGap: 12,
rowGap: 4,
fontSize: 14,
margin: 0,
};
const detailsBlockStyle: React.CSSProperties = {
margin: "6px 0",
padding: "6px 10px",
background: "rgba(127,127,127,0.08)",
borderRadius: 6,
};
const ulStyle: React.CSSProperties = {
listStyle: "none",
paddingInlineStart: 0,
margin: 0,
};
const liStyle: React.CSSProperties = {
padding: "4px 0",
fontSize: 14,
borderBottom: "1px dotted rgba(127,127,127,0.2)",
};
const mutedStyle: React.CSSProperties = {
color: "rgba(127,127,127,0.8)",
fontSize: 13,
};
function PartySection({
party,
args,
}: {
party: string;
args: LegalArgument[];
}) {
if (!args || args.length === 0) return null;
return (
<div style={{ marginBottom: 12 }}>
<strong style={{ fontSize: 14 }}>{party}</strong>
{args.map((a, idx) => {
const key = a.id ?? `${party}-${a.claim_index ?? idx}`;
const title =
a.argument_title ||
(a.argument_body || "").slice(0, 80) ||
"(ללא כותרת)";
const body = a.argument_body || "";
return (
<details key={key} style={detailsBlockStyle}>
<summary style={{ cursor: "pointer", fontSize: 14 }}>
{title}
</summary>
{body && (
<p style={{ margin: "6px 0 0 0", whiteSpace: "pre-wrap" }}>
{body}
</p>
)}
</details>
);
})}
</div>
);
}
export function LegalCaseTab({ context }: PluginDetailTabProps) {
const issueId = context.entityId;
const summary = usePluginData<LegalCaseDetails | null>("legal-case-summary", {
issueId,
});
const args = usePluginData<LegalArgumentsResponse | null>(
"legal-case-arguments",
{ issueId },
);
const precedents = usePluginData<PrecedentRow[] | null>(
"legal-case-precedents",
{ issueId },
);
const missing = usePluginData<MissingPrecedentRow[] | null>(
"legal-case-missing-precedents",
{ issueId },
);
if (summary.loading) {
return (
<div dir="rtl" style={containerStyle}>
<p>טוען נתוני תיק</p>
</div>
);
}
if (summary.error) {
return (
<div dir="rtl" style={containerStyle}>
<p style={{ color: "crimson" }}>
שגיאה בטעינת התיק: {summary.error.message}
</p>
</div>
);
}
if (!summary.data) {
return (
<div dir="rtl" style={containerStyle}>
<p style={mutedStyle}>
אין תיק ערר מקושר ל-issue זה. ניתן ליצור תיק חדש דרך CEO או דרך כלי
המערכת.
</p>
</div>
);
}
const caseDetails = summary.data;
const byParty = args.data?.by_party ?? {};
const argsTotal = args.data?.total ?? 0;
const precedentsList = precedents.data ?? [];
const missingList = missing.data ?? [];
return (
<div dir="rtl" style={containerStyle}>
<section style={sectionStyle}>
<h2 style={{ margin: "0 0 8px 0", fontSize: 20 }}>
תיק {caseDetails.case_number}
</h2>
<div style={metaListStyle}>
<span style={mutedStyle}>כותרת:</span>
<span>{caseDetails.title}</span>
<span style={mutedStyle}>סטטוס:</span>
<span>
<code>{caseDetails.status}</code>
</span>
{caseDetails.practice_area && (
<>
<span style={mutedStyle}>תחום:</span>
<span>{caseDetails.practice_area}</span>
</>
)}
{caseDetails.appeal_subtype && (
<>
<span style={mutedStyle}>סוג ערר:</span>
<span>{caseDetails.appeal_subtype}</span>
</>
)}
{caseDetails.expected_outcome && (
<>
<span style={mutedStyle}>תוצאה צפויה:</span>
<span>{caseDetails.expected_outcome}</span>
</>
)}
</div>
</section>
<section style={sectionStyle}>
<h3 style={headingStyle}>טיעונים משפטיים ({argsTotal})</h3>
{args.loading && <p style={mutedStyle}>טוען טיעונים</p>}
{args.error && (
<p style={{ color: "crimson" }}>שגיאה: {args.error.message}</p>
)}
{!args.loading && argsTotal === 0 && (
<p style={mutedStyle}>לא חולצו עדיין טיעונים מהמסמכים.</p>
)}
{Object.entries(byParty).map(([party, list]) => (
<PartySection key={party} party={party} args={list} />
))}
</section>
<section style={sectionStyle}>
<h3 style={headingStyle}>פסיקה מצורפת ({precedentsList.length})</h3>
{precedents.loading && <p style={mutedStyle}>טוען פסיקה</p>}
{precedents.error && (
<p style={{ color: "crimson" }}>שגיאה: {precedents.error.message}</p>
)}
{!precedents.loading && precedentsList.length === 0 && (
<p style={mutedStyle}>לא צורפה עדיין פסיקה לתיק.</p>
)}
{precedentsList.length > 0 && (
<ul style={ulStyle}>
{precedentsList.map((p) => (
<li key={p.id} style={liStyle}>
<strong>{p.citation}</strong>
{p.practice_area && (
<span style={mutedStyle}> {p.practice_area}</span>
)}
</li>
))}
</ul>
)}
</section>
<section style={sectionStyle}>
<h3 style={headingStyle}>פסיקה חסרה ({missingList.length})</h3>
{missing.loading && <p style={mutedStyle}>טוען רשימת חסרים</p>}
{missing.error && (
<p style={{ color: "crimson" }}>שגיאה: {missing.error.message}</p>
)}
{!missing.loading && missingList.length === 0 && (
<p style={mutedStyle}>אין פסיקה חסרה פתוחה בתיק זה.</p>
)}
{missingList.length > 0 && (
<ul style={ulStyle}>
{missingList.map((m) => (
<li key={m.id} style={liStyle}>
<strong>{m.citation}</strong>
<span style={mutedStyle}> {m.status}</span>
{m.legal_topic && (
<span style={mutedStyle}> ({m.legal_topic})</span>
)}
</li>
))}
</ul>
)}
</section>
</div>
);
}

132
src/ui/LegalCasesWidget.tsx Normal file
View File

@@ -0,0 +1,132 @@
import type { PluginWidgetProps } from "@paperclipai/plugin-sdk/ui";
import { usePluginData } from "@paperclipai/plugin-sdk/ui/hooks";
import type { DashboardStats } from "./types.js";
const widgetStyle: React.CSSProperties = {
padding: 12,
fontFamily:
"system-ui, -apple-system, 'Segoe UI', 'Noto Sans Hebrew', sans-serif",
color: "inherit",
};
const headingStyle: React.CSSProperties = {
margin: "0 0 8px 0",
fontSize: 15,
fontWeight: 600,
};
const totalStyle: React.CSSProperties = {
fontSize: 22,
fontWeight: 700,
margin: "4px 0",
};
const ulStyle: React.CSSProperties = {
listStyle: "none",
paddingInlineStart: 0,
margin: 0,
display: "grid",
gridTemplateColumns: "1fr max-content",
rowGap: 2,
fontSize: 13,
};
const labelStyle: React.CSSProperties = {
color: "rgba(127,127,127,0.85)",
};
const STATUS_LABELS: Record<string, string> = {
new: "תיק חדש",
uploading: "העלאת מסמכים",
processing: "עיבוד מסמכים",
documents_ready: "מסמכים מוכנים",
outcome_set: "תוצאה הוזנה",
brainstorming: "סיעור מוחות",
direction_approved: "כיוון אושר",
drafting: "כתיבה",
qa_review: "בדיקת איכות",
drafted: "טיוטה מוכנה",
exported: "DOCX נוצר",
reviewed: "נבדק",
final: "סופי",
};
export function LegalCasesWidget(_props: PluginWidgetProps) {
const { data, loading, error } = usePluginData<DashboardStats | null>(
"legal-dashboard-stats",
{},
);
if (loading) {
return (
<div dir="rtl" style={widgetStyle}>
<h3 style={headingStyle}>תיקי ערר</h3>
<p style={labelStyle}>טוען</p>
</div>
);
}
if (error) {
return (
<div dir="rtl" style={widgetStyle}>
<h3 style={headingStyle}>תיקי ערר</h3>
<p style={{ color: "crimson", fontSize: 13 }}>{error.message}</p>
</div>
);
}
if (!data) {
return (
<div dir="rtl" style={widgetStyle}>
<h3 style={headingStyle}>תיקי ערר</h3>
<p style={labelStyle}>אין נתונים זמינים.</p>
</div>
);
}
const entries = Object.entries(data.byStatus).sort(([, a], [, b]) => b - a);
return (
<div dir="rtl" style={widgetStyle}>
<h3 style={headingStyle}>תיקי ערר</h3>
<div style={totalStyle}>{data.totalCases}</div>
<div style={{ fontSize: 12, color: "rgba(127,127,127,0.85)" }}>
סה"כ תיקים פעילים
</div>
<hr
style={{
margin: "8px 0",
border: 0,
borderTop: "1px solid rgba(127,127,127,0.25)",
}}
/>
{entries.length > 0 ? (
<ul style={ulStyle}>
{entries.map(([status, count]) => (
<li
key={status}
style={{ display: "contents" }}
aria-label={`${STATUS_LABELS[status] ?? status}: ${count}`}
>
<span style={labelStyle}>{STATUS_LABELS[status] ?? status}</span>
<span>{count}</span>
</li>
))}
</ul>
) : (
<p style={labelStyle}>אין תיקים פעילים.</p>
)}
<hr
style={{
margin: "8px 0",
border: 0,
borderTop: "1px solid rgba(127,127,127,0.25)",
}}
/>
<p style={{ margin: 0, fontSize: 13 }}>
<span style={labelStyle}>פעילות שבועית: </span>
<strong>{data.weekActivity}</strong>
</p>
</div>
);
}

10
src/ui/index.tsx Normal file
View File

@@ -0,0 +1,10 @@
/**
* UI bundle entrypoint for the legal-ai Paperclip plugin.
*
* Each named export here corresponds to a `slot.exportName` in the manifest's
* `ui.slots` declaration. The host loads this module as an ES module and
* mounts the matching component into its slot.
*/
export { LegalCasesWidget } from "./LegalCasesWidget.js";
export { LegalCaseTab } from "./LegalCaseTab.js";

72
src/ui/types.ts Normal file
View File

@@ -0,0 +1,72 @@
/**
* Shared types for the legal-ai plugin UI bundle.
*
* Mirrors a minimal subset of the legal-ai backend response shapes so the UI
* components can type their props/data without pulling the full LegalApi
* client (which is a worker-side dependency).
*/
export interface LegalCaseSummary {
case_number: string;
title: string;
status: string;
practice_area?: string | null;
appeal_subtype?: string | null;
proceeding_type?: string | null;
updated_at?: string | null;
archived_at?: string | null;
}
export interface LegalCaseDetails {
id: string;
case_number: string;
title: string;
status: string;
practice_area?: string | null;
appeal_subtype?: string | null;
appellants?: string[];
respondents?: string[];
subject?: string;
property_address?: string;
expected_outcome?: string;
[key: string]: unknown;
}
export interface LegalArgument {
id?: string;
party: string;
argument_title?: string;
argument_body?: string;
claim_index?: number;
[key: string]: unknown;
}
export interface LegalArgumentsResponse {
case_number: string;
total: number;
by_party: Record<string, LegalArgument[]>;
arguments: LegalArgument[];
}
export interface PrecedentRow {
id: string;
citation: string;
section_id?: string | null;
practice_area?: string | null;
[key: string]: unknown;
}
export interface MissingPrecedentRow {
id: string;
citation: string;
status: string;
legal_topic?: string | null;
cited_by_party?: string | null;
[key: string]: unknown;
}
export interface DashboardStats {
byStatus: Record<string, number>;
weekActivity: number;
totalCases: number;
}

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,10 @@
"strict": true, "strict": true,
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true, "skipLibCheck": true,
"declaration": true "declaration": true,
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"]
}, },
"include": ["src"] "include": ["src"],
"exclude": ["src/**/*.test.ts"]
} }