16 Commits

Author SHA1 Message Date
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
a8999b70c2 chore: snapshot before SDK upgrade
Includes pending stale-case-reminder days fix (3→30) alongside
the backup commit, since both will be preserved by the same tag.
2026-05-26 12:13:52 +00:00
73e37df129 fix: add try-catch on agents.invoke and http.fetch in scheduled jobs
- stale-case-reminder: wrap http.fetch in try-catch so network errors
  log and return instead of crashing the job silently
- weekly-feedback-analysis: wrap agents.invoke in try-catch so CEO
  unavailability logs and returns instead of crashing the job

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 11:07:54 +00:00
6a93c606b9 fix: pick first mapped CEO for weekly-feedback-analysis; align reason string
- Replace companies[0] with first company that has a CEO_AGENT_IDS entry,
  so the job doesn't silently skip if the list order is non-deterministic
- Change reason to "weekly-feedback-job" to match the CEO routing condition
  added in legal-ceo.md (was "weekly-feedback-analysis scheduled job")
- decision-lessons.md is shared between companies so one CEO invocation suffices

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 10:54:39 +00:00
73078946f1 feat: add idempotency guard to onWebhook
Prevent duplicate comments on rapid Paperclip retries:
- Check plugin instance state for requestId before processing
- Skip if same requestId was seen within 5 minutes
- Store requestId with ISO timestamp after first successful delivery

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 10:52:12 +00:00
11 changed files with 1747 additions and 81 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",
"version": "0.1.0",
"dependencies": {
"@paperclipai/plugin-sdk": "^2026.325.0"
"@paperclipai/plugin-sdk": "^2026.525.0",
"react": "^19.0.0"
},
"devDependencies": {
"@biomejs/biome": "2.4.11",
"@types/node": "^25.5.0",
"@types/react": "^19.0.0",
"esbuild": "^0.28.0",
"typescript": "^6.0.2"
}
},
@@ -179,13 +182,455 @@
"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": {
"version": "2026.325.0",
"resolved": "https://registry.npmjs.org/@paperclipai/plugin-sdk/-/plugin-sdk-2026.325.0.tgz",
"integrity": "sha512-axAIK90QVrRihlCXcGoePDc0qaf0weqOwUagWsp88v6T2NXAWXALCzFtY4IipPnaVli6xZe5ZsuC+gTlPXhMKQ==",
"version": "2026.525.0",
"resolved": "https://registry.npmjs.org/@paperclipai/plugin-sdk/-/plugin-sdk-2026.525.0.tgz",
"integrity": "sha512-7ivXbFSwH7cS+/2WNbPwQve5vVtLnqgxX5lmqTKn+i3fOY03d5KHaYF2bbI5zeTkAAwwEJ0Nz3rfBWNPguRRUw==",
"license": "MIT",
"dependencies": {
"@paperclipai/shared": "2026.325.0",
"@paperclipai/shared": "2026.525.0",
"zod": "^3.24.2"
},
"bin": {
@@ -201,9 +646,9 @@
}
},
"node_modules/@paperclipai/shared": {
"version": "2026.325.0",
"resolved": "https://registry.npmjs.org/@paperclipai/shared/-/shared-2026.325.0.tgz",
"integrity": "sha512-l0ZeaQhswxjAyJ6G/TdgyrfbEgFKqdkly4zn4QgOPCu+x8j8Gma51Xf45M2Br3ILJlIx93HhT3hAzWKChdxgMg==",
"version": "2026.525.0",
"resolved": "https://registry.npmjs.org/@paperclipai/shared/-/shared-2026.525.0.tgz",
"integrity": "sha512-fbVrEx96oxkxXbRBFDLAgW5Z0lRC7Ii+BeCNhKqKRCg9m2IwtP97CGoOeAROdRrvRpP8Neb3vgaaDbDjXT74lQ==",
"license": "MIT",
"dependencies": {
"zod": "^3.24.2"
@@ -219,6 +664,74 @@
"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": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",

View File

@@ -7,7 +7,9 @@
"manifest": "dist/manifest.js"
},
"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",
"format": "biome format --write src/",
"format:check": "biome format src/",
@@ -15,11 +17,14 @@
"biome:fix": "biome check --write src/"
},
"dependencies": {
"@paperclipai/plugin-sdk": "^2026.325.0"
"@paperclipai/plugin-sdk": "^2026.525.0",
"react": "^19.0.0"
},
"devDependencies": {
"@biomejs/biome": "2.4.11",
"@types/node": "^25.5.0",
"@types/react": "^19.0.0",
"esbuild": "^0.28.0",
"typescript": "^6.0.2"
}
}

View File

@@ -148,6 +148,20 @@ export class LegalApi {
// Return static style guide reference
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
@@ -260,3 +274,21 @@ export interface QAResponse {
}>;
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,8 +12,12 @@ export default {
"issues.read",
"issues.create",
"issues.update",
"issue.comments.read",
"issue.comments.create",
"issue.interactions.create",
"issue.documents.write",
"agent.tools.register",
"agents.invoke",
"http.outbound",
"plugin.state.read",
"plugin.state.write",
@@ -22,9 +26,12 @@ export default {
"companies.read",
"projects.read",
"webhooks.receive",
"ui.detailTab.register",
"ui.dashboardWidget.register",
] as const,
entrypoints: {
worker: "dist/worker.js",
ui: "dist/ui",
},
instanceConfigSchema: {
type: "object" as const,
@@ -161,7 +168,7 @@ export default {
{
jobKey: "stale-case-reminder",
displayName: "תזכורת תיקים תקועים",
description: "מזהה תיקים שלא עודכנו 3+ ימים ומוסיף תגובה ל-issue",
description: "מזהה תיקים שלא עודכנו 30+ ימים ומוסיף תגובה ל-issue",
schedule: "0 8 * * *",
},
{
@@ -171,6 +178,13 @@ export default {
'מסכם פידבק יו"ר מהשבוע האחרון ומעדכן את decision-lessons.md',
schedule: "0 19 * * 0",
},
{
jobKey: "route-pending-comments",
displayName: "ערובת מסירת-הערות (sweep)",
description:
"sweep פיוס: מנתב כל הערת-משתמש שטרם נותבה ל-CEO (at-least-once), כדי שהערה לא תיפול גם אם ה-wakeup התבטל/התלכד",
schedule: "*/2 * * * *",
},
],
webhooks: [
{
@@ -180,4 +194,21 @@ export default {
"מקבל עדכוני סטטוס מ-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",
},
],
},
};

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;
}

View File

@@ -507,6 +507,40 @@ const plugin = definePlugin({
},
);
ctx.tools.register(
"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. Provide the case number.",
parametersSchema: {
type: "object",
properties: {
case_number: {
type: "string",
description: "Case number (e.g. 1043-02-26)",
},
},
required: ["case_number"],
},
},
async (params) => {
const { case_number } = params as { case_number: string };
const result = await api.getPredecessorForCase(case_number);
// Readable digest for the agent — each prior run's conclusion,
// newest-first, summaries trimmed to keep the wake context lean.
const lines = result.runs.map((r) => {
const summary = (r.summary ?? "").trim().slice(0, 600);
const when = r.finished_at ?? r.started_at ?? "";
return `${r.agent_name ?? "?"} · ${r.identifier ?? ""} · ${when} · ${r.status}\n${summary}`;
});
const content = lines.length
? lines.join("\n\n")
: "אין ריצות קודמות עם מסקנות לתיק זה.";
return { content, data: result };
},
);
// ── Events ─────────────────────────────────────────────────────
ctx.events.on("issue.created", async (event) => {
@@ -535,32 +569,118 @@ const plugin = definePlugin({
// Route user comments through CEO agent — per company
// Marker: the id of the last user comment already routed to the CEO for
// an issue. BOTH the event-driven route below and the reconciliation
// sweep (`route-pending-comments`) set it, so the sweep never re-routes a
// comment the fast path already handled. This is what turns best-effort
// event delivery into an at-least-once guarantee.
const LAST_ROUTED_COMMENT_KEY = "last-routed-comment-id";
async function markCommentRouted(
issueId: string,
commentId: string | undefined,
): Promise<void> {
if (!commentId) return;
try {
await ctx.state.set(
{
scopeKind: "issue",
scopeId: issueId,
stateKey: LAST_ROUTED_COMMENT_KEY,
},
commentId,
);
} catch (err) {
ctx.logger.warn("Failed to persist last-routed-comment marker", {
issueId,
commentId,
error: String(err),
});
}
}
// Invoke the company CEO to handle a user comment on an issue. Shared by
// the event-driven route and the reconciliation sweep. On success it
// stamps the marker so the comment is not routed twice. Returns true when
// the CEO was invoked.
async function routeCommentToCeo(args: {
issue: { id: string; title: string; identifier?: string | null };
companyId: string;
ceoAgentId: string;
commentBody: string;
commentId?: string;
source: "event" | "sweep";
}): Promise<boolean> {
const { issue, companyId, ceoAgentId, commentBody, commentId, source } =
args;
const promptBody = commentBody
? commentBody
: "(לא ניתן לקרוא את תוכן התגובה — קרא את ה-comments האחרונים על ה-issue ישירות)";
try {
const { runId } = await ctx.agents.invoke(ceoAgentId, companyId, {
prompt: [
`תגובה חדשה מחיים על issue "${issue.title}" (${issue.identifier || issue.id}):`,
"",
promptBody,
"",
`קרא את ה-comments האחרונים על ה-issue, הבן מה חיים מבקש, והחלט מה לעשות.`,
`אם ההוראה ברורה — נתב לסוכן המתאים. אם לא ברור — שאל את חיים.`,
].join("\n"),
reason: `user_commented_on_${issue.identifier || issue.id}`,
});
await markCommentRouted(issue.id, commentId);
ctx.logger.info("Routed user comment to CEO agent", {
issueId: issue.id,
commentId: commentId || null,
runId,
source,
});
return true;
} catch (err) {
ctx.logger.error("Failed to invoke CEO agent for comment routing", {
issueId: issue.id,
source,
error: String(err),
});
return false;
}
}
ctx.events.on("issue.comment.created", async (event) => {
// Only intercept human comments — not agent comments (prevents loops)
if (event.actorType !== "user") return;
if (!event.companyId) return;
// entityId is the comment ID — fetch the comment to get issueId + body
const entityId = event.entityId;
if (!entityId) return;
// The event payload may contain the issueId directly
const payload = event.payload as {
// Event/payload shape for `issue.comment.created` (Paperclip host):
// event.entityId → the ISSUE id (the primary entity of the event)
// event.payload.commentId → the comment id
// event.payload.bodySnippet → TRUNCATED body (not the full text)
// event.payload.reopened / reopenedFrom → set when the comment
// reopened a `done` issue (host then natively wakes its assignee)
// NOTE: the host does NOT send `payload.issueId` and does NOT send the
// full `body`. The earlier code read `payload.issueId` (always absent)
// and skipped every event — silently disabling comment→CEO routing.
// See @paperclipai/plugin-sdk index.d.ts: `issueId: event.entityId`.
const payload = (event.payload ?? null) as {
issueId?: string;
commentId?: string;
body?: string;
bodySnippet?: string;
reopened?: boolean;
reopenedFrom?: string;
} | null;
const issueId = payload?.issueId;
let commentBody = payload?.body;
// If issueId is not in payload, try to find it from the comment entity
// issueId: prefer entityId (current host), fall back to payload for
// forward/backward compatibility with other host versions.
const issueId = event.entityId || payload?.issueId;
if (!issueId) {
ctx.logger.warn(
"issue.comment.created event missing issueId in payload, skipping",
{ entityId, payload },
"issue.comment.created event missing issueId (entityId+payload empty), skipping",
{ entityId: event.entityId, payload },
);
return;
}
const commentId = payload?.commentId;
// Fetch issue details for context
const issue = await ctx.issues.get(issueId, event.companyId);
@@ -571,20 +691,6 @@ const plugin = definePlugin({
return;
}
// If comment body not in payload, fetch from API
if (!commentBody) {
try {
const comments = await ctx.issues.listComments(
issueId,
event.companyId,
);
const latest = comments[comments.length - 1];
commentBody = latest?.body || "(לא ניתן לקרוא את התגובה)";
} catch {
commentBody = "(שגיאה בקריאת התגובה)";
}
}
// Wake the CEO agent for this company
const ceoAgentId = CEO_AGENT_IDS[event.companyId];
if (!ceoAgentId) {
@@ -594,29 +700,73 @@ const plugin = definePlugin({
return;
}
try {
const { runId } = await ctx.agents.invoke(ceoAgentId, event.companyId, {
prompt: [
`תגובה חדשה מחיים על issue "${issue.title}" (${issue.identifier || issueId}):`,
"",
commentBody,
"",
`קרא את ה-comments האחרונים על ה-issue, הבן מה חיים מבקש, והחלט מה לעשות.`,
`אם ההוראה ברורה — נתב לסוכן המתאים. אם לא ברור — שאל את חיים.`,
].join("\n"),
reason: `user_commented_on_${issue.identifier || issueId}`,
});
ctx.logger.info("Routed user comment to CEO agent", {
issueId,
commentId: entityId,
runId,
});
} catch (err) {
ctx.logger.error("Failed to invoke CEO agent for comment routing", {
issueId,
error: String(err),
});
// Dedup against the host's native reopen-on-comment wake: when a comment
// reopens a `done` issue, the host already wakes that issue's assignee.
// If the assignee IS this company's CEO, that native wake covers us —
// invoking again would double-run the CEO. Skip ONLY in that exact case.
// For issues owned by any other agent (e.g. an analyst sub-task), the
// native wake targets the wrong agent (and the queued run is cancelled
// with `issue_assignee_changed`), so we MUST route the comment to the CEO.
if (issue.assigneeAgentId === ceoAgentId && payload?.reopened === true) {
ctx.logger.info(
"Comment reopened CEO-owned issue; native wake handles it, skipping plugin route",
{ issueId, ceoAgentId },
);
// The comment IS being handled (natively) — stamp the marker so the
// reconciliation sweep does not re-route it a second time.
await markCommentRouted(issueId, commentId);
return;
}
// Resolve the full comment body. The payload only carries a truncated
// snippet, so fetch the comment list and match by id (fall back to the
// latest comment, then the snippet).
let commentBody = payload?.body;
if (!commentBody) {
try {
const comments = await ctx.issues.listComments(
issueId,
event.companyId,
);
const matched = commentId
? comments.find((c) => c.id === commentId)
: undefined;
// Newest by createdAt — do NOT rely on listComments array order.
const latest = comments.reduce<(typeof comments)[number] | null>(
(acc, c) =>
!acc ||
new Date(c.createdAt).getTime() >
new Date(acc.createdAt).getTime()
? c
: acc,
null,
);
commentBody =
matched?.body ||
latest?.body ||
payload?.bodySnippet ||
"(לא ניתן לקרוא את התגובה)";
} catch (err) {
ctx.logger.warn("listComments failed, falling back to bodySnippet", {
issueId,
commentId,
error: String(err),
});
commentBody = payload?.bodySnippet ?? "";
}
}
// Route through the shared helper (also used by the sweep). It stamps
// the `last-routed-comment-id` marker on success so the reconciliation
// sweep does not re-deliver this comment.
await routeCommentToCeo({
issue,
companyId: event.companyId,
ceoAgentId,
commentBody: commentBody ?? "",
commentId,
source: "event",
});
});
// ── Jobs ───────────────────────────────────────────────────────
@@ -713,7 +863,15 @@ const plugin = definePlugin({
const apiBase =
(config.legalApiBaseUrl as string) ?? "http://localhost:8085";
const resp = await ctx.http.fetch(`${apiBase}/api/cases/stale?days=3`);
let resp: Awaited<ReturnType<typeof ctx.http.fetch>>;
try {
resp = await ctx.http.fetch(`${apiBase}/api/cases/stale?days=30`);
} catch (err) {
ctx.logger.error("stale-case-reminder: fetch failed", {
error: String(err),
});
return;
}
if (!resp.ok) {
ctx.logger.error(`stale-case-reminder: API error ${resp.status}`);
return;
@@ -799,26 +957,306 @@ const plugin = definePlugin({
return;
}
// Pick the first company with a known CEO mapping — decision-lessons.md is
// shared between companies, so a single CEO invocation is sufficient.
const companies = await ctx.companies.list();
const company = companies[0];
if (!company) return;
const mapped = companies
.map((c) => ({ company: c, ceoId: CEO_AGENT_IDS[c.id] }))
.filter((x): x is { company: (typeof companies)[0]; ceoId: string } =>
Boolean(x.ceoId),
);
const ceoId = CEO_AGENT_IDS[company.id];
if (!ceoId) {
if (mapped.length === 0) {
ctx.logger.warn(
`weekly-feedback-analysis: no CEO agent for company ${company.id}`,
"weekly-feedback-analysis: no company has a mapped CEO agent — skipping",
);
return;
}
await ctx.agents.invoke(ceoId, company.id, {
prompt: `ניתוח פידבק שבועי יו"ר (${data.entry_count} פריטים):\n\n${data.summary}\n\nהמשימה: עדכן את /home/chaim/legal-ai/docs/legal-decision-lessons.md עם הלקחים החדשים שעולים מהפידבק. הוסף רק לקחים חדשים שלא קיימים כבר. קבץ לפי נושא.`,
reason: "weekly-feedback-analysis scheduled job",
});
const { company, ceoId } = mapped[0];
ctx.logger.info(
`weekly-feedback-analysis: invoked CEO ${ceoId} with ${data.entry_count} feedback entries`,
);
try {
await ctx.agents.invoke(ceoId, company.id, {
prompt: `ניתוח פידבק שבועי יו"ר (${data.entry_count} פריטים):\n\n${data.summary}\n\nהמשימה: עדכן את /home/chaim/legal-ai/docs/legal-decision-lessons.md עם הלקחים החדשים שעולים מהפידבק. הוסף רק לקחים חדשים שלא קיימים כבר. קבץ לפי נושא.`,
reason: "weekly-feedback-job",
});
ctx.logger.info(
`weekly-feedback-analysis: invoked CEO ${ceoId} (company ${company.id}) with ${data.entry_count} feedback entries`,
);
} catch (err) {
ctx.logger.error("weekly-feedback-analysis: failed to invoke CEO", {
error: String(err),
ceoId,
companyId: company.id,
});
}
});
// Reconciliation sweep — the at-least-once guarantee for user comments.
// The event-driven route above is best-effort: a failed/coalesced CEO
// invoke, or a comment that lands while an agent is mid-run, can leave a
// user comment unrouted with nothing to retry (this is exactly how a
// chair comment was silently dropped — see legal-ai task #164). This sweep
// re-routes any issue whose newest user comment id does not match the
// `last-routed-comment-id` marker, so no comment falls through. Idempotent:
// the fast path stamps the marker, so a healthy comment is never
// double-routed. Runs every 2 minutes (manifest schedule).
ctx.jobs.register("route-pending-comments", async (_job) => {
let companies: Awaited<ReturnType<typeof ctx.companies.list>>;
try {
companies = await ctx.companies.list();
} catch (err) {
ctx.logger.warn("route-pending-comments: companies.list failed", {
error: String(err),
});
return;
}
for (const company of companies) {
const ceoAgentId = CEO_AGENT_IDS[company.id];
if (!ceoAgentId) continue;
let issues: Awaited<ReturnType<typeof ctx.issues.list>>;
try {
issues = await ctx.issues.list({ companyId: company.id, limit: 200 });
} catch (err) {
ctx.logger.warn("route-pending-comments: issues.list failed", {
companyId: company.id,
error: String(err),
});
continue;
}
for (const issue of issues) {
// Terminal issues need no routing. A fresh user comment on a
// `done` issue reopens it natively → it becomes active and is
// caught on the next sweep.
if (issue.status === "done" || issue.status === "cancelled") continue;
try {
const comments = await ctx.issues.listComments(
issue.id,
company.id,
);
if (comments.length === 0) continue;
// "Pending" = the chair spoke last among real participants: the
// newest USER comment is newer than the newest AGENT comment (or
// no agent has commented yet), so an agent never responded to it.
// Order-independent (sort by createdAt, do NOT rely on the array
// order of listComments) and system comments are ignored — an
// automated "draft ready" line is not an agent response. This
// targets the exact failure (chair commented, no agent reply) and
// skips historically-answered comments.
const ts = (c: { createdAt: string | Date }) =>
new Date(c.createdAt).getTime();
let newestUser: (typeof comments)[number] | null = null;
let newestAgent: (typeof comments)[number] | null = null;
for (const c of comments) {
if (c.authorType === "user") {
if (!newestUser || ts(c) > ts(newestUser)) newestUser = c;
} else if (c.authorType === "agent") {
if (!newestAgent || ts(c) > ts(newestAgent)) newestAgent = c;
}
}
if (!newestUser) continue; // no chair comment
// An agent has already responded after the chair — handled.
if (newestAgent && ts(newestAgent) >= ts(newestUser)) continue;
const marker = await ctx.state.get({
scopeKind: "issue",
scopeId: issue.id,
stateKey: LAST_ROUTED_COMMENT_KEY,
});
// Already routed (CEO may still be working) — don't re-fire.
if (marker === newestUser.id) continue;
ctx.logger.info(
"route-pending-comments: re-routing unrouted user comment",
{ issueId: issue.id, commentId: newestUser.id },
);
await routeCommentToCeo({
issue,
companyId: company.id,
ceoAgentId,
commentBody: newestUser.body,
commentId: newestUser.id,
source: "sweep",
});
} catch (err) {
ctx.logger.warn("route-pending-comments: failed for issue", {
issueId: issue.id,
error: String(err),
});
}
}
}
});
// ── Data handlers (UI bridge) ──────────────────────────────────
// These back `usePluginData(key, params)` calls from the React UI bundle
// in `dist/ui/index.js`. Errors are caught and rethrown with a clear
// message so the UI shows them through PluginBridgeError.
// Resolve the legal-ai case number linked to an issue via plugin state.
async function resolveCaseNumber(issueId: string): Promise<string | null> {
const value = await ctx.state.get({
scopeKind: "issue",
scopeId: issueId,
stateKey: "legal-case-number",
});
return typeof value === "string" && value.length > 0 ? value : null;
}
ctx.data.register("legal-case-summary", async (params) => {
const issueId = String((params as { issueId?: string }).issueId ?? "");
if (!issueId) return null;
const caseNumber = await resolveCaseNumber(issueId);
if (!caseNumber) return null;
try {
return await api.getCase(caseNumber);
} catch (err) {
ctx.logger.warn("legal-case-summary: getCase failed", {
caseNumber,
error: String(err),
});
throw err;
}
});
ctx.data.register("legal-case-arguments", async (params) => {
const issueId = String((params as { issueId?: string }).issueId ?? "");
if (!issueId) return null;
const caseNumber = await resolveCaseNumber(issueId);
if (!caseNumber) return null;
const config2 = await ctx.config.get();
const apiBase =
(config2.legalApiBaseUrl as string) ?? "http://localhost:8085";
try {
const resp = await ctx.http.fetch(
`${apiBase}/api/cases/${encodeURIComponent(caseNumber)}/legal-arguments`,
);
if (!resp.ok) {
ctx.logger.warn("legal-case-arguments: API error", {
caseNumber,
status: resp.status,
});
return {
case_number: caseNumber,
total: 0,
by_party: {},
arguments: [],
};
}
return await resp.json();
} catch (err) {
ctx.logger.warn("legal-case-arguments: fetch failed", {
caseNumber,
error: String(err),
});
return {
case_number: caseNumber,
total: 0,
by_party: {},
arguments: [],
};
}
});
ctx.data.register("legal-case-precedents", async (params) => {
const issueId = String((params as { issueId?: string }).issueId ?? "");
if (!issueId) return [];
const caseNumber = await resolveCaseNumber(issueId);
if (!caseNumber) return [];
const config2 = await ctx.config.get();
const apiBase =
(config2.legalApiBaseUrl as string) ?? "http://localhost:8085";
try {
const resp = await ctx.http.fetch(
`${apiBase}/api/cases/${encodeURIComponent(caseNumber)}/precedents`,
);
if (!resp.ok) {
ctx.logger.warn("legal-case-precedents: API error", {
caseNumber,
status: resp.status,
});
return [];
}
const data = await resp.json();
// Endpoint may return a flat array or `{ precedents: [...] }`.
if (Array.isArray(data)) return data;
if (
data &&
Array.isArray((data as { precedents?: unknown[] }).precedents)
) {
return (data as { precedents: unknown[] }).precedents;
}
return [];
} catch (err) {
ctx.logger.warn("legal-case-precedents: fetch failed", {
caseNumber,
error: String(err),
});
return [];
}
});
ctx.data.register("legal-case-missing-precedents", async (params) => {
const issueId = String((params as { issueId?: string }).issueId ?? "");
if (!issueId) return [];
const caseNumber = await resolveCaseNumber(issueId);
if (!caseNumber) return [];
const config2 = await ctx.config.get();
const apiBase =
(config2.legalApiBaseUrl as string) ?? "http://localhost:8085";
try {
const url = new URL(`${apiBase}/api/missing-precedents`);
url.searchParams.set("case_number", caseNumber);
url.searchParams.set("status", "open");
const resp = await ctx.http.fetch(url.toString());
if (!resp.ok) {
ctx.logger.warn("legal-case-missing-precedents: API error", {
caseNumber,
status: resp.status,
});
return [];
}
const data = (await resp.json()) as { items?: unknown[] };
return Array.isArray(data.items) ? data.items : [];
} catch (err) {
ctx.logger.warn("legal-case-missing-precedents: fetch failed", {
caseNumber,
error: String(err),
});
return [];
}
});
ctx.data.register("legal-dashboard-stats", async () => {
try {
const cases = await api.listCases();
const byStatus: Record<string, number> = {};
let weekActivity = 0;
const weekAgoMs = Date.now() - 7 * 24 * 60 * 60 * 1000;
for (const c of cases) {
byStatus[c.status] = (byStatus[c.status] ?? 0) + 1;
const updated = (c as { updated_at?: string | null }).updated_at;
if (updated) {
const t = Date.parse(updated);
if (!Number.isNaN(t) && t >= weekAgoMs) weekActivity += 1;
}
}
return {
byStatus,
weekActivity,
totalCases: cases.length,
};
} catch (err) {
ctx.logger.warn("legal-dashboard-stats: listCases failed", {
error: String(err),
});
return { byStatus: {}, weekActivity: 0, totalCases: 0 };
}
});
ctx.logger.info("Legal AI plugin ready");
@@ -831,34 +1269,73 @@ const plugin = definePlugin({
async onWebhook(input: PluginWebhookInput): Promise<void> {
if (!pluginCtx) return; // not yet initialized
// TODO: add idempotency guard using input.requestId to prevent duplicate
// comments on rapid retries (store requestId in plugin state with ~60s TTL)
// Idempotency guard: skip duplicate deliveries within 5 minutes
if (input.requestId) {
const idempKey = `webhook-idem-${input.requestId}`;
const seenAt = await pluginCtx.state.get({
scopeKind: "instance",
stateKey: idempKey,
});
if (seenAt && typeof seenAt === "string") {
const ageMs = Date.now() - new Date(seenAt).getTime();
if (ageMs < 5 * 60 * 1000) {
pluginCtx.logger.info(
`onWebhook: skipping duplicate requestId ${input.requestId} (age ${Math.round(ageMs / 1000)}s)`,
);
return;
}
}
await pluginCtx.state.set(
{ scopeKind: "instance", stateKey: idempKey },
new Date().toISOString(),
);
}
const { endpointKey, parsedBody } = input;
if (endpointKey !== "case-status") return;
// Webhook payload is a discriminated union by `eventType`:
// • undefined / "status_change" — legacy case-status update (default)
// • "missing_precedent_created" — ask Daphna to upload a missing citation
// • "export_complete" — attach a generated DOCX to the linked issue
const payload = parsedBody as {
eventType?: string;
caseNumber: string;
oldStatus: string;
newStatus: string;
companyId: string | null;
timestamp: string;
// status_change fields
oldStatus?: string;
newStatus?: string;
// missing_precedent_created fields
missingPrecedent?: {
id: string;
citation: string;
citedByParty?: string;
citedByPartyName?: string;
legalTopic?: string;
legalIssue?: string;
};
// export_complete fields
docxBase64?: string;
docxFilename?: string;
docxTitle?: string;
};
const { caseNumber, oldStatus, newStatus, companyId } = payload;
const { caseNumber, companyId } = payload;
const eventType = payload.eventType ?? "status_change";
if (!caseNumber || !newStatus || !companyId) {
if (!caseNumber || !companyId) {
pluginCtx.logger.warn("onWebhook: malformed payload", {
eventType,
caseNumber,
newStatus,
companyId,
});
return;
}
pluginCtx.logger.info(
`Webhook: case ${caseNumber} ${oldStatus}${newStatus}`,
`Webhook: case ${caseNumber} eventType=${eventType}`,
{
companyId,
},
@@ -887,6 +1364,123 @@ const plugin = definePlugin({
return;
}
// ── Branch by eventType ────────────────────────────────────────
// EVENT: missing_precedent_created — ask Daphna to upload via SDK
// interaction (ask_user_questions). Avoids plain-text comment for
// a decision that has a clear set of choices.
if (eventType === "missing_precedent_created" && payload.missingPrecedent) {
const mp = payload.missingPrecedent;
const partyLabel = mp.citedByPartyName || mp.citedByParty || "אחד הצדדים";
try {
await pluginCtx.issues.askUserQuestions(
linkedIssueId,
{
continuationPolicy: "wake_assignee_on_accept",
payload: {
version: 1,
title: `פסיקה חסרה בקורפוס: ${mp.citation}`,
questions: [
{
id: `missing_precedent:${mp.id}`,
prompt: `יש להעלות את ${mp.citation}`,
helpText: [
`הציטוט הוזכר על-ידי ${partyLabel} בתיק ${caseNumber}.`,
mp.legalTopic ? `נושא: ${mp.legalTopic}` : "",
mp.legalIssue ? `סוגיה: ${mp.legalIssue}` : "",
"",
"כדי שהמערכת תוכל לבחון את הטענה מול הפסיקה — יש להעלות את ה-PDF/DOCX לדף /missing-precedents.",
]
.filter(Boolean)
.join("\n"),
selectionMode: "single",
required: true,
options: [
{ id: "upload", label: "אני מעלה PDF/DOCX" },
{ id: "irrelevant", label: "ההלכה לא רלוונטית" },
{ id: "defer", label: "אכריע מאוחר יותר" },
],
},
],
},
},
companyId,
);
pluginCtx.logger.info(
"askUserQuestions: missing_precedent prompt sent",
{
caseNumber,
missingPrecedentId: mp.id,
},
);
} catch (err) {
pluginCtx.logger.error(
"askUserQuestions failed for missing_precedent",
{
caseNumber,
missingPrecedentId: mp.id,
error: String(err),
},
);
}
return;
}
// EVENT: export_complete — attach a markdown "final decision"
// document to the issue, with a link back to the DOCX in legal-ai.
// The SDK's `documents.upsert` stores text (markdown), so we keep
// the binary DOCX in legal-ai and reference it; the issue page
// shows the document as a discoverable artifact.
if (eventType === "export_complete" && payload.docxFilename) {
const filename = payload.docxFilename;
const title = payload.docxTitle || `החלטה סופית — ${caseNumber}`;
const docxUrl = `https://legal-ai.nautilus.marcusgroup.org/api/cases/${encodeURIComponent(caseNumber)}/export/download?filename=${encodeURIComponent(filename)}`;
const markdownBody = [
`# ${title}`,
"",
`**תיק:** ${caseNumber}`,
`**קובץ:** \`${filename}\``,
`**הופק:** ${payload.timestamp}`,
"",
`[הורדה](${docxUrl})`,
"",
"---",
"",
'החלטה זו יוצאה אוטומטית ע"י legal-ai והוצמדה ל-issue זה.',
].join("\n");
try {
await pluginCtx.issues.documents.upsert({
issueId: linkedIssueId,
companyId,
key: `final-decision:${caseNumber}`,
body: markdownBody,
title,
format: "markdown",
changeSummary: `Auto-attached final DOCX (${filename})`,
});
pluginCtx.logger.info("documents.upsert: final-decision attached", {
caseNumber,
filename,
});
} catch (err) {
pluginCtx.logger.error("documents.upsert failed for export_complete", {
caseNumber,
filename,
error: String(err),
});
}
return;
}
// EVENT: status_change (default) — legacy behavior.
const { oldStatus = "", newStatus = "" } = payload;
if (!newStatus) {
pluginCtx.logger.warn("onWebhook status_change: missing newStatus", {
caseNumber,
});
return;
}
// Status label map (Hebrew)
const statusLabels: Record<string, string> = {
new: "📂 תיק חדש",

View File

@@ -8,7 +8,9 @@
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true
"declaration": true,
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"]
},
"include": ["src"]
}