Files
plugin-legal-ai/src/worker.ts
Chaim Marcus 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

1796 lines
59 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
PluginContext,
PluginWebhookInput,
} from "@paperclipai/plugin-sdk";
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
import {
CompanyScopeUnavailableError,
isCompanyScopeDenied,
LEGAL_API_BASE_CACHE_KEY,
resolveApiBaseFrom,
runJobHandler,
} from "./company-scope.js";
import type { StatusModelEntry } from "./legal-api.js";
import { LegalApi } from "./legal-api.js";
import {
formatSyncRunSummary,
newSyncRunCounters,
recordDecline,
SYNC_RUN_SUMMARY_PREFIX,
type SyncRunCounters,
syncRunSummaryTags,
} from "./sync-run-summary.js";
import {
labelFor,
pickSyncTargetIssue,
resolveIssueStatus,
resolveStatusLabel,
type SyncCandidate,
} from "./sync-target.js";
// Hoisted so onWebhook can access the context after setup() completes.
let pluginCtx: PluginContext | null = null;
// Hoisted for the same reason as pluginCtx: onWebhook is a sibling of setup()
// and has no closure over the LegalApi instance created there. Same instance,
// not a second one — see legal-ai issue #616.
let legalApi: LegalApi | null = null;
// Per-company CEO agent IDs (shared between setup and onWebhook).
const CEO_AGENT_IDS: Record<string, string> = {
"42a7acd0-30c5-4cbd-ac97-7424f65df294":
"752cebdd-6748-4a04-aacd-c7ab0294ef33", // CMP (רישוי ובניה)
"8639e837-4c9d-47fa-a76b-95788d651896":
"cdbfa8bc-3d61-41a4-a2e7-677ec7d34562", // CMPA (היטלי השבחה)
};
const DEFAULT_LEGAL_API_BASE = "http://localhost:8085";
/**
* Resolve `legalApiBaseUrl` from company-scoped plugin config.
*
* ⚠️ Plugin config became company-scoped in @paperclipai/server 2026.722.0
* (migration `0164_plugin_config_company_scope`). A bare `ctx.config.get()` now
* throws `company context is required` unless the host can derive the company
* from the current invocation — which it CANNOT during `setup()`, because
* worker initialisation belongs to no company. Reading config at startup is what
* made the whole plugin fail to activate on the 722.0 upgrade.
*
* So: never call this from setup(); call it from a handler, passing the
* companyId that handler was given. Tool handlers get `runCtx.companyId`; data
* handlers get `params.companyId`.
*
* ⚠️ Scheduled jobs and `handleWebhook` have **no** company scope at all
* (legal-ai issue #637 — the host derives scope only from `params.companyId` /
* `actorContext` / `runContext` / `event.companyId`, none of which `runJob`
* carries). Every `ctx.config.get()` call from such a handler is rejected with
* `InvocationScopeDeniedError` (code `-32005`) — "probing the companies we
* know about" (the old comment here) never worked; it just swallowed the
* rejection silently and fell through to `DEFAULT_LEGAL_API_BASE`, which has
* nothing listening on it → the misleading `TypeError: fetch failed`. The
* actual logic (including the instance-scoped cache fallback for that case)
* now lives in `resolveApiBaseFrom` (`company-scope.ts`), which this is a
* thin `ctx`-binding wrapper around — kept bare (no try/catch) so a genuine
* scope denial surfaces to the caller instead of being swallowed here.
*/
/** Pull the companyId the host passes to `ctx.data` handlers, if present. */
function companyIdOf(params: unknown): string | undefined {
const id = (params as { companyId?: unknown } | null)?.companyId;
return typeof id === "string" && id.trim() ? id : undefined;
}
async function resolveApiBase(
ctx: PluginContext,
companyId?: string,
): Promise<string> {
return resolveApiBaseFrom({
readConfig: (id) =>
ctx.config.get(id) as Promise<Record<string, unknown> | null>,
readCache: () =>
ctx.state.get({
scopeKind: "instance",
stateKey: LEGAL_API_BASE_CACHE_KEY,
}),
writeCache: (url) =>
ctx.state.set(
{ scopeKind: "instance", stateKey: LEGAL_API_BASE_CACHE_KEY },
url,
),
knownCompanyIds: Object.keys(CEO_AGENT_IDS),
defaultBaseUrl: DEFAULT_LEGAL_API_BASE,
logger: ctx.logger,
companyId,
});
}
const plugin = definePlugin({
async setup(ctx) {
pluginCtx = ctx; // save for onWebhook
// Lazy: the URL is fetched on the first request, from inside a handler
// that has company context — not here. See resolveApiBase() above.
const api = new LegalApi(() => resolveApiBase(ctx));
legalApi = api;
ctx.logger.info("Legal AI plugin starting");
// ── Tools ──────────────────────────────────────────────────────
ctx.tools.register(
"legal_case_list",
{
displayName: "רשימת תיקי ערר",
description:
"List all appeal cases in the legal system. Returns case number, title, and status (canonical case-status keys — see GET /api/status-model).",
parametersSchema: {
type: "object",
properties: {},
},
},
async () => {
const cases = await api.listCases();
return {
content: JSON.stringify(cases, null, 2),
data: cases,
};
},
);
ctx.tools.register(
"legal_case_get",
{
displayName: "פרטי תיק ערר",
description:
"Get full details of a legal case including documents list. Provide the case number (e.g. 123/24).",
parametersSchema: {
type: "object",
properties: {
case_number: {
type: "string",
description: "Case number (e.g. 123/24)",
},
},
required: ["case_number"],
},
},
async (params) => {
const { case_number } = params as { case_number: string };
const result = await api.getCase(case_number);
return {
content: JSON.stringify(result, null, 2),
data: result,
};
},
);
ctx.tools.register(
"legal_case_create",
{
displayName: "יצירת תיק ערר",
description:
"Create a new appeal case. Case numbers: 1xxx=licensing, 8xxx=betterment levy, 9xxx=compensation. Also creates a linked Paperclip issue.",
parametersSchema: {
type: "object",
properties: {
case_number: {
type: "string",
description: "Case number (e.g. 1234/24)",
},
title: { type: "string", description: "Case title" },
appellants: {
type: "array",
items: { type: "string" },
description: "Appellant names",
},
respondents: {
type: "array",
items: { type: "string" },
description: "Respondent names",
},
subject: { type: "string", description: "Case subject" },
property_address: {
type: "string",
description: "Property address",
},
expected_outcome: {
type: "string",
enum: [
"rejection",
"partial_acceptance",
"full_acceptance",
"betterment_levy",
],
description: "Expected outcome type",
},
},
required: ["case_number", "title"],
},
},
async (params, runCtx) => {
const input = params as {
case_number: string;
title: string;
appellants?: string[];
respondents?: string[];
subject?: string;
property_address?: string;
expected_outcome?: string;
};
// Create case in legal-ai
const legalCase = await api.createCase(input);
// Create linked Paperclip issue
const issue = await ctx.issues.create({
companyId: runCtx.companyId,
title: `[ערר ${input.case_number}] ${input.title}`,
description: `תיק ערר חדש\ושא: ${input.subject || ""}\וצאה צפויה: ${input.expected_outcome || "לא הוגדרה"}`,
});
// Store mapping in plugin state
await ctx.state.set(
{
scopeKind: "issue",
scopeId: issue.id,
stateKey: "legal-case-number",
},
input.case_number,
);
await ctx.activity.log({
companyId: runCtx.companyId,
message: `נוצר תיק ערר ${input.case_number} וקושר ל-issue ${issue.id}`,
});
return {
content: `Case ${input.case_number} created and linked to Paperclip issue.\n\n${JSON.stringify(legalCase, null, 2)}`,
data: { legalCase, issueId: issue.id },
};
},
);
ctx.tools.register(
"legal_case_update",
{
displayName: "עדכון תיק ערר",
description:
"Update a legal case's status, title, subject, or expected outcome.",
parametersSchema: {
type: "object",
properties: {
case_number: {
type: "string",
description: "Case number (e.g. 123/24)",
},
// ללא enum בכוונה: הרישום קורה ב-setup(), שאין לו הקשר-חברה
// ולכן אינו יכול לשלוף את /api/status-model (ראה resolveApiBase
// למעלה) — והעתק סטטי היה נסחף שוב. המניפסט (manifest.ts) כבר
// מגדיר את השדה כמחרוזת חופשית, והוולידציה האמיתית היא
// server-side (case_status_model.py + השומר הקדימה-בלבד
// ב-tools/cases.py). legal-ai issue #616.
status: {
type: "string",
description:
"New case status — a canonical key from GET /api/status-model",
},
title: { type: "string" },
subject: { type: "string" },
notes: { type: "string" },
expected_outcome: {
type: "string",
enum: [
"rejection",
"partial_acceptance",
"full_acceptance",
"betterment_levy",
],
},
},
required: ["case_number"],
},
},
async (params) => {
const { case_number, ...updates } = params as {
case_number: string;
status?: string;
title?: string;
subject?: string;
notes?: string;
expected_outcome?: string;
};
const result = await api.updateCase(case_number, updates);
return {
content: JSON.stringify(result, null, 2),
data: result,
};
},
);
ctx.tools.register(
"legal_case_status",
{
displayName: "סטטוס תהליך עבודה",
description:
"Get full workflow status for a case: documents processed, chunks created, draft progress, and suggested next steps.",
parametersSchema: {
type: "object",
properties: {
case_number: {
type: "string",
description: "Case number (e.g. 123/24)",
},
},
required: ["case_number"],
},
},
async (params) => {
const { case_number } = params as { case_number: string };
const result = await api.getCaseStatus(case_number);
return {
content: JSON.stringify(result, null, 2),
data: result,
};
},
);
ctx.tools.register(
"legal_search",
{
displayName: "חיפוש תקדימים משפטיים",
description:
"Semantic search (RAG) across previous decisions and documents. Query in Hebrew for best results.",
parametersSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "Search query in Hebrew",
},
limit: { type: "number", description: "Max results (default 10)" },
section_type: {
type: "string",
description:
"Filter by section type: facts, legal_analysis, conclusion, ruling",
},
},
required: ["query"],
},
},
async (params) => {
const { query, limit, section_type } = params as {
query: string;
limit?: number;
section_type?: string;
};
const results = await api.search(
query,
limit || 10,
section_type || "",
);
return {
content: JSON.stringify(results, null, 2),
data: results,
};
},
);
ctx.tools.register(
"legal_case_template",
{
displayName: "תבנית החלטה",
description:
"Get an outcome-aware decision template for a case, with guidance for the 12-block structure.",
parametersSchema: {
type: "object",
properties: {
case_number: {
type: "string",
description: "Case number (e.g. 123/24)",
},
},
required: ["case_number"],
},
},
async (params) => {
const { case_number } = params as { case_number: string };
const result = await api.getTemplate(case_number);
return {
content: result.template,
data: result,
};
},
);
ctx.tools.register(
"legal_processing_status",
{
displayName: "סטטוס עיבוד כללי",
description:
"Get overall processing status: total cases, documents, pending processing, chunks, and style corpus entries.",
parametersSchema: {
type: "object",
properties: {},
},
},
async () => {
const result = await api.getProcessingStatus();
return {
content: JSON.stringify(result, null, 2),
data: result,
};
},
);
// ── New Tools (Phase 3) ─────────────────────────────────────────
ctx.tools.register(
"legal_document_list",
{
displayName: "רשימת מסמכים בתיק",
description:
"List all documents in a case with their extraction status.",
parametersSchema: {
type: "object",
properties: {
case_number: { type: "string", description: "Case number" },
},
required: ["case_number"],
},
},
async (params) => {
const { case_number } = params as { case_number: string };
const docs = await api.listDocuments(case_number);
return { content: JSON.stringify(docs, null, 2), data: docs };
},
);
ctx.tools.register(
"legal_set_outcome",
{
displayName: "הזנת תוצאת ערר",
description:
"Set the decision outcome (rejection/full_acceptance/partial_acceptance) and optional reasoning from Dafna.",
parametersSchema: {
type: "object",
properties: {
case_number: { type: "string", description: "Case number" },
outcome: {
type: "string",
enum: ["rejection", "full_acceptance", "partial_acceptance"],
description: "Decision outcome",
},
reasoning: {
type: "string",
description: "Optional reasoning from Dafna",
},
},
required: ["case_number", "outcome"],
},
},
async (params) => {
const { case_number, outcome, reasoning } = params as {
case_number: string;
outcome: string;
reasoning?: string;
};
const result = await api.setOutcome(case_number, outcome, reasoning);
return { content: JSON.stringify(result, null, 2), data: result };
},
);
ctx.tools.register(
"legal_get_claims",
{
displayName: "טענות מחולצות",
description: "Get extracted claims for a case, grouped by party role.",
parametersSchema: {
type: "object",
properties: {
case_number: { type: "string", description: "Case number" },
},
required: ["case_number"],
},
},
async (params) => {
const { case_number } = params as { case_number: string };
const result = await api.getClaims(case_number);
return { content: JSON.stringify(result, null, 2), data: result };
},
);
ctx.tools.register(
"legal_search_case",
{
displayName: "חיפוש בתוך תיק",
description: "Semantic search within a specific case's documents.",
parametersSchema: {
type: "object",
properties: {
case_number: { type: "string", description: "Case number" },
query: { type: "string", description: "Search query in Hebrew" },
},
required: ["case_number", "query"],
},
},
async (params) => {
const { case_number, query } = params as {
case_number: string;
query: string;
};
const results = await api.searchCase(case_number, query);
return { content: JSON.stringify(results, null, 2), data: results };
},
);
ctx.tools.register(
"legal_find_similar",
{
displayName: "תקדימים דומים",
description: "Find similar cases/precedents for a given case.",
parametersSchema: {
type: "object",
properties: {
case_number: { type: "string", description: "Case number" },
},
required: ["case_number"],
},
},
async (params) => {
const { case_number } = params as { case_number: string };
const results = await api.findSimilarCases(case_number);
return { content: JSON.stringify(results, null, 2), data: results };
},
);
ctx.tools.register(
"legal_run_qa",
{
displayName: "בדיקת איכות",
description:
"Run QA validation on a drafted decision. Checks: grounding, claims coverage, neutral background, weights.",
parametersSchema: {
type: "object",
properties: {
case_number: { type: "string", description: "Case number" },
},
required: ["case_number"],
},
},
async (params) => {
const { case_number } = params as { case_number: string };
const result = await api.runQA(case_number);
return { content: JSON.stringify(result, null, 2), data: result };
},
);
ctx.tools.register(
"legal_trigger_learning",
{
displayName: "הפעלת לולאת למידה",
description:
"Trigger the learning loop — compare draft to final signed version.",
parametersSchema: {
type: "object",
properties: {
case_number: { type: "string", description: "Case number" },
},
required: ["case_number"],
},
},
async (params) => {
const { case_number } = params as { case_number: string };
const result = await api.triggerLearning(case_number);
return { content: JSON.stringify(result, null, 2), data: result };
},
);
ctx.tools.register(
"legal_style_guide",
{
displayName: "מדריך סגנון",
description: "Get reference to Dafna's writing style guide.",
parametersSchema: {
type: "object",
properties: {},
},
},
async () => {
const guide = await api.getStyleGuide();
return { content: guide, data: { reference: guide } };
},
);
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) => {
// Auto-link issues with case number in title
if (!event.companyId || !event.entityId) return;
const issue = await ctx.issues.get(event.entityId, event.companyId);
if (!issue) return;
const match = issue.title.match(/ערר\s+(\d+\/\d+)/);
if (match) {
const caseNumber = match[1];
await ctx.state.set(
{
scopeKind: "issue",
scopeId: issue.id,
stateKey: "legal-case-number",
},
caseNumber,
);
ctx.logger.info("Auto-linked issue to legal case", {
issueId: issue.id,
caseNumber,
});
}
});
// 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;
// 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;
// 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 (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);
if (!issue) {
ctx.logger.warn("Could not fetch issue for comment routing", {
issueId,
});
return;
}
// Wake the CEO agent for this company
const ceoAgentId = CEO_AGENT_IDS[event.companyId];
if (!ceoAgentId) {
ctx.logger.warn("No CEO agent mapped for company", {
companyId: event.companyId,
});
return;
}
// 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 ───────────────────────────────────────────────────────
/**
* פולט את סיכום-ריצת `sync-case-status` לשני משטחים קיימים וצורכים:
* 1. `ctx.logger.info` → stdout של pm2 (שם בוצע בפועל האבחון של #626/#637).
* 2. `ctx.metrics.write` → טבלת `plugin_logs` (level='metric'), שאותה מרנדר
* פאנל "Recent Logs" בדף-הפלאגין של Paperclip, וניתן לשאול ב-SQL.
*
* ⚠️ הפונקציה **לעולם אינה זורקת** — היא נקראת מתוך `finally`, וזריקה ממנה
* הייתה מחליפה את שגיאת-הריצה המקורית ומסתירה את סיבת-הכשל האמיתית.
* כשל של `metrics.write` מדווח בקול (`logger.error`) עם טקסט-השגיאה **בתוך
* המחרוזת** — כי שדות-`meta` נופלים מפלט-pino (נמדד) — ולא נבלע.
*/
async function emitSyncRunSummary(
counters: SyncRunCounters,
failure: unknown,
): Promise<void> {
const outcome = failure === undefined ? "ok" : "failed";
const error = failure === undefined ? undefined : String(failure);
const line = formatSyncRunSummary(counters, { outcome, error });
const tags = syncRunSummaryTags(counters, { outcome });
ctx.logger.info(line, tags);
try {
await ctx.metrics.write(line, counters.written, tags);
} catch (err) {
ctx.logger.error(
`${SYNC_RUN_SUMMARY_PREFIX}: failed to persist run summary metric: ${String(err)}`,
);
}
}
ctx.jobs.register("sync-case-status", async (job) => {
ctx.logger.info("Starting case status sync", { runId: job.runId });
const counters = newSyncRunCounters();
let failure: unknown;
try {
// אין `try/catch` בולע כאן — `runJobHandler` (company-scope.ts) הוא
// שמלוגג ו**זורק מחדש**, כדי שהמתזמר ירשום `status:"failed"`
// כשה-scope נדחה (legal-ai issue #637). ה-`try/catch` הישן כאן בלע
// את הכשל, וה-run נרשם `succeeded` בלי לעשות כלום — 13,928 ריצות כוזבות.
await runJobHandler("sync-case-status", ctx.logger, async () => {
const cases = await api.listCases();
// נקבע מיד — לפני כל קריאת-מארח שעלולה ליפול. אחרת ריצה
// שנפלה באמצע (או `!companies.length`) הייתה מדווחת scanned=0
// ומאבדת בדיוק את האבחנה שה-issue הזה נועד לתת.
counters.scanned = cases.length;
const companies = await ctx.companies.list();
if (!companies.length) return;
const statusModel = await api.getStatusModel();
// מעבר אחד על ה-issues של כל חברה: state.get אחד ל-issue (לא לכל
// צירוף תיק×issue), וקיבוץ למפה case_number → מועמדים. הקיבוץ נדרש
// כדי לבחור "השורש היחיד". סורק את **כל** החברות (לא רק הראשונה) —
// legal-ai issue #604.
const byCase = new Map<string, SyncCandidate[]>();
for (const company of companies) {
const issues = await ctx.issues.list({ companyId: company.id });
for (const issue of issues) {
const linkedCase = await ctx.state.get({
scopeKind: "issue",
scopeId: issue.id,
stateKey: "legal-case-number",
});
if (typeof linkedCase !== "string" || !linkedCase) continue;
const list = byCase.get(linkedCase) ?? [];
list.push({
id: issue.id,
status: issue.status,
parentId: issue.parentId,
companyId: company.id,
});
byCase.set(linkedCase, list);
}
}
for (const legalCase of cases) {
const targetStatus = resolveIssueStatus(
statusModel.statuses,
legalCase.status,
);
if (targetStatus === null) {
recordDecline(counters, "unknown_status");
ctx.logger.warn(
"sync-case-status: unknown case status — not in /api/status-model (case_status_model.py)",
{
caseNumber: legalCase.case_number,
status: legalCase.status,
},
);
continue;
}
const candidates = byCase.get(legalCase.case_number) ?? [];
const { target, reason, writableRoots } =
pickSyncTargetIssue(candidates);
if (!target) {
// reason כאן הוא SyncTargetReason שאינו "ok" (target===null) —
// תת-קבוצה מובטחת-מהדר של SyncDeclineReason
// (ראה sync-run-summary.ts: SyncTargetDeclineReason).
recordDecline(counters, reason as Exclude<typeof reason, "ok">);
// אין בליעה שקטה: מדווח למה לא נכתב כלום.
ctx.logger.info("sync-case-status: no sync target", {
caseNumber: legalCase.case_number,
reason,
linked: candidates.length,
writableRoots,
});
continue;
}
counters.matched++;
if (target.status === targetStatus) {
recordDecline(counters, "already_matching");
continue;
}
const label =
labelFor(statusModel.statuses, legalCase.status) ??
legalCase.status;
await ctx.issues.update(
target.id,
{ status: targetStatus },
target.companyId,
);
await ctx.issues.createComment(
target.id,
`📋 ${label}`,
target.companyId,
);
counters.written++;
ctx.logger.info("Synced issue status", {
issueId: target.id,
caseNumber: legalCase.case_number,
newStatus: targetStatus,
});
}
});
} catch (err) {
failure = err;
throw err; // חובה — אחרת ה-run יירשם succeeded (זה בדיוק הבאג של #637).
} finally {
await emitSyncRunSummary(counters, failure);
}
});
ctx.jobs.register("stale-case-reminder", async (_job) => {
ctx.logger.info("stale-case-reminder: starting");
await runJobHandler("stale-case-reminder", ctx.logger, async () => {
// Scheduled job — no company scope at all (legal-ai issue #637);
// resolveApiBase falls back to the instance-scoped cache, then
// throws CompanyScopeUnavailableError rather than guessing.
const apiBase = await resolveApiBase(ctx);
// כשל-fetch/API אמיתי חייב **לזרוק**, לא `return` בשקט — אחרת
// ה-run נרשם `succeeded` בלי שקרה כלום (legal-ai issue #637).
let resp: Awaited<ReturnType<typeof ctx.http.fetch>>;
try {
resp = await ctx.http.fetch(`${apiBase}/api/cases/stale?days=30`);
} catch (err) {
throw new Error(
`stale-case-reminder: fetch failed against ${apiBase}: ${String(err)}`,
{ cause: err },
);
}
if (!resp.ok) {
throw new Error(`stale-case-reminder: API error ${resp.status}`);
}
const data = (await resp.json()) as {
cases: Array<{
case_number: string;
title: string;
status: string;
days_stale: number;
}>;
total: number;
};
// Build case→issue map once (O(companies × issues)) to avoid N×M RPCs per stale case
const companies = await ctx.companies.list();
const caseIssueMap = new Map<
string,
{ issueId: string; companyId: string }
>();
for (const company of companies) {
const issues = await ctx.issues.list({ companyId: company.id });
for (const issue of issues) {
const linkedCase = await ctx.state.get({
scopeKind: "issue",
scopeId: issue.id,
stateKey: "legal-case-number",
});
if (linkedCase && typeof linkedCase === "string") {
caseIssueMap.set(linkedCase, {
issueId: issue.id,
companyId: company.id,
});
}
}
}
// אין תיקים תקועים — "אין מה לעשות" תקין, לא כשל. ה-loop למטה
// פשוט לא ירוץ בלי `data.cases`.
let reminded = 0;
for (const staleCase of data.cases) {
const linked = caseIssueMap.get(staleCase.case_number);
if (!linked) continue;
await ctx.issues.createComment(
linked.issueId,
`⚠️ **תיק תקוע ${staleCase.case_number}** — ${staleCase.days_stale} ימים ללא עדכון (סטטוס: ${staleCase.status}). האם נדרשת פעולה?`,
linked.companyId,
);
reminded++;
ctx.logger.info(
`stale-case-reminder: reminded case ${staleCase.case_number} (${staleCase.days_stale}d)`,
);
}
ctx.logger.info(
`stale-case-reminder: done. ${reminded}/${data.total} cases reminded`,
);
});
});
ctx.jobs.register("weekly-feedback-analysis", async (_job) => {
ctx.logger.info("weekly-feedback-analysis: starting");
await runJobHandler("weekly-feedback-analysis", ctx.logger, async () => {
// Scheduled job — no company scope at all (legal-ai issue #637);
// resolveApiBase falls back to the instance-scoped cache, then
// throws CompanyScopeUnavailableError rather than guessing.
const apiBase = await resolveApiBase(ctx);
const resp = await ctx.http.fetch(
`${apiBase}/api/chair-feedback/weekly-summary`,
);
if (!resp.ok) {
throw new Error(`weekly-feedback-analysis: API error ${resp.status}`);
}
const data = (await resp.json()) as {
summary: string;
entry_count: number;
};
// אין פידבק השבוע — "אין מה לעשות" תקין, לא כשל.
if (data.entry_count === 0) {
ctx.logger.info(
"weekly-feedback-analysis: no feedback this week, skipping",
);
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 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),
);
// אין שום חברה עם CEO ממופה — זו תקלת-תצורה (לא "אין מה לעשות"),
// ולכן זריקה ולא return שקט.
if (mapped.length === 0) {
throw new Error(
"weekly-feedback-analysis: no company has a mapped CEO agent",
);
}
const { company, ceoId } = mapped[0];
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) => {
await runJobHandler("route-pending-comments", ctx.logger, async () => {
// כשל-מהיר: דחיית-scope (legal-ai issue #637) אינה "שגיאה
// פר-issue" שאפשר להתעלם ממנה — היא אומרת שה-run **כולו** לא
// יכול לעבוד. לזרוק מיד במקום לבלוע ולהמשיך ללולאה על 2
// חברות × 200 issues שכולן ייכשלו זהה. שגיאה אחרת (ולא scope)
// נשארת warn+continue — זו העמידות האמיתית פר-issue.
let companies: Awaited<ReturnType<typeof ctx.companies.list>>;
try {
companies = await ctx.companies.list();
} catch (err) {
if (isCompanyScopeDenied(err)) {
throw new CompanyScopeUnavailableError(
"route-pending-comments",
"companies.list",
{ cause: 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) {
if (isCompanyScopeDenied(err)) {
throw new CompanyScopeUnavailableError(
"route-pending-comments",
"issues.list",
{ cause: 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 apiBase = await resolveApiBase(ctx, companyIdOf(params));
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 apiBase = await resolveApiBase(ctx, companyIdOf(params));
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 apiBase = await resolveApiBase(ctx, companyIdOf(params));
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");
},
async onHealth() {
return { status: "ok" as const };
},
/**
* נתיב-חימום דטרמיניסטי למטמון `LEGAL_API_BASE_CACHE_KEY` (company-scope.ts):
* ל-`configChanged` יש הקשר-חברה תקין מה-מארח (בניגוד לג'וב מתוזמן/webhook —
* legal-ai issue #637), כך שהכתיבה כאן אף פעם לא נדחית ב-scope. עדיף על
* להסתמך על כך שסוכן יקרא כלי בהקשר-חברה כדי שהמטמון יתמלא — כאן זה קורה
* מיד כשהאופרטור שומר את ה-config, בלי תלות בתזמון-מקרי.
*/
async onConfigChanged(newConfig: Record<string, unknown>): Promise<void> {
if (!pluginCtx) return; // עדיין לפני setup()
const url = (newConfig as { legalApiBaseUrl?: unknown }).legalApiBaseUrl;
if (typeof url === "string" && url.trim()) {
await pluginCtx.state.set(
{ scopeKind: "instance", stateKey: LEGAL_API_BASE_CACHE_KEY },
url.trim(),
);
pluginCtx.logger.info("onConfigChanged: cached legalApiBaseUrl", {
legalApiBaseUrl: url.trim(),
});
}
},
async onWebhook(input: PluginWebhookInput): Promise<void> {
if (!pluginCtx) return; // not yet initialized
// Idempotency guard: skip duplicate deliveries within 5 minutes. The key
// is hoisted so the catch-block below can delete it on failure — see there.
const idempKey = input.requestId ? `webhook-idem-${input.requestId}` : null;
if (idempKey) {
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(),
);
}
try {
await handleCaseStatusWebhook(pluginCtx, legalApi, input);
} catch (err) {
// מחיקת סמן-האידמפוטנטיות **לפני** שהשגיאה יוצאת — אחרת מסירה
// חוזרת של אותו webhook תוך 5 דק' תדולג בשקט כ"כבר נשלח", למרות
// שהעיבוד מעולם לא הושלם בפועל (legal-ai issue #637).
if (idempKey) {
await pluginCtx.state.delete({
scopeKind: "instance",
stateKey: idempKey,
});
}
// אין בליעה — הזריקה חייבת להימשך החוצה כדי ש-
// `plugin_webhook_deliveries.error` יישא הודעה מאבחנת (ולא את
// הודעת-המארח האטומה) כשמדובר בדחיית-scope.
if (isCompanyScopeDenied(err)) {
throw new CompanyScopeUnavailableError("onWebhook", "issues.list", {
cause: err,
});
}
throw err;
}
},
});
/**
* גוף-העבודה של webhook `case-status` — מופרד מ-`onWebhook` כך ש-
* `try/catch` שם יכול לעטוף אותו כולו (כולל את דחיית-ה-scope שנתקלת בה
* `pluginCtx.issues.list` למטה — אין ל-webhook הקשר-חברה, כמו לג'וב
* מתוזמן; ראה legal-ai issue #637).
*/
async function handleCaseStatusWebhook(
pluginCtx: PluginContext,
legalApi: LegalApi | null,
input: PluginWebhookInput,
): Promise<void> {
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;
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, companyId } = payload;
const eventType = payload.eventType ?? "status_change";
if (!caseNumber || !companyId) {
pluginCtx.logger.warn("onWebhook: malformed payload", {
eventType,
caseNumber,
companyId,
});
return;
}
pluginCtx.logger.info(`Webhook: case ${caseNumber} eventType=${eventType}`, {
companyId,
});
// Find the Paperclip issue linked to this case number by scanning plugin state.
// State stores: issue.id → case_number (scopeKind=issue, stateKey=legal-case-number)
const issues = await pluginCtx.issues.list({ companyId });
let linkedIssueId: string | null = null;
for (const issue of issues) {
const linkedCase = await pluginCtx.state.get({
scopeKind: "issue",
scopeId: issue.id,
stateKey: "legal-case-number",
});
if (linkedCase === caseNumber) {
linkedIssueId = issue.id;
break;
}
}
if (!linkedIssueId) {
pluginCtx.logger.warn(
`onWebhook: no Paperclip issue linked to case ${caseNumber}`,
);
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;
}
// התוויות נגזרות מ-`GET /api/status-model` — אותו SSOT שהג'וב
// `sync-case-status` כבר צורך (#604). המפה הקשיחה שהייתה כאן כבר סטתה:
// חסרו בה מפתחות חדשים מהמודל, והיו בה מפתחות מתים שכבר לא קיימים בו.
// legal-ai issue #616.
let statuses: StatusModelEntry[] | null = null;
if (!legalApi) {
// לא אמור לקרות (מושם ב-setup, כמו pluginCtx) — אבל אם קרה, זו נפילה
// לערך-גולמי שחייבת עקבה, בדיוק כמו כשל-הרשת שמתחתיה.
pluginCtx.logger.error(
"onWebhook: LegalApi unavailable — labels fall back to raw keys",
{ caseNumber, newStatus },
);
} else {
try {
statuses = (await legalApi.getStatusModel()).statuses;
} catch (err) {
pluginCtx.logger.error(
"onWebhook: status-model fetch failed — labels fall back to raw keys",
{ caseNumber, newStatus, error: String(err) },
);
}
}
const resolved = resolveStatusLabel(statuses, newStatus);
if (resolved.fallback === "unknown_status") {
// לא בליעה שקטה: הסטטוס נטען ופשוט אינו במודל הקנוני.
pluginCtx.logger.warn(
"onWebhook: status not in canonical status-model — using raw key as label",
{ caseNumber, oldStatus, newStatus },
);
}
// `model_unavailable` כבר דווח כ-error למעלה — אין הכפלת-דיווח.
const label = resolved.label;
// `oldStatus` מתורגם אף הוא, אך **בלי** לוג: הוא ריק לגיטימית בתיק-חדש
// ועשוי להיות מפתח היסטורי שכבר אינו במודל — warn עליו היה רעש כרוני.
const oldLabel = oldStatus
? resolveStatusLabel(statuses, oldStatus).label
: oldStatus;
// Post a Hebrew status comment on the linked issue
await pluginCtx.issues.createComment(
linkedIssueId,
`**עדכון סטטוס תיק ${caseNumber}:** ${label} (היה: ${oldLabel})`,
companyId,
);
// Wake the CEO agent if QA failed
if (newStatus === "qa_failed") {
const ceoId = CEO_AGENT_IDS[companyId];
if (ceoId) {
try {
const { runId } = await pluginCtx.agents.invoke(ceoId, companyId, {
prompt: `תיק ${caseNumber} נכשל בבדיקת QA. עיין בתוצאות QA ותקן את הבעיות לפני שתמשיך.`,
reason: "qa_failed webhook",
});
pluginCtx.logger.info(`Invoked CEO agent for qa_failed`, {
caseNumber,
ceoId,
runId,
});
} catch (err) {
pluginCtx.logger.error("Failed to invoke CEO agent for qa_failed", {
caseNumber,
error: String(err),
});
}
} else {
pluginCtx.logger.warn("onWebhook: no CEO agent mapped for company", {
companyId,
});
}
}
}
export default plugin;
runWorker(plugin, import.meta.url);