14 Commits

Author SHA1 Message Date
6721c0a4ed chore(manifest): מחיקת plugin.json — קובץ מת שמטעה
plugin.json הצהיר 16 כלים ולא נקרא ע"י אף אחד: המניפסט האמיתי הוא
src/manifest.ts → dist/manifest.js, שנפתר דרך package.json →
paperclipPlugin.manifest. הפער ניכר בכך שהקובץ חסר את
legal_predecessor_context, שנוסף למניפסט האמיתי ב-b9906f6.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 04:55:08 +00:00
6 changed files with 454 additions and 167 deletions

16
package-lock.json generated
View File

@@ -8,7 +8,7 @@
"name": "@marcusgroup/plugin-legal-ai", "name": "@marcusgroup/plugin-legal-ai",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@paperclipai/plugin-sdk": "^2026.525.0", "@paperclipai/plugin-sdk": "^2026.722.0",
"react": "^19.0.0" "react": "^19.0.0"
}, },
"devDependencies": { "devDependencies": {
@@ -625,12 +625,12 @@
} }
}, },
"node_modules/@paperclipai/plugin-sdk": { "node_modules/@paperclipai/plugin-sdk": {
"version": "2026.525.0", "version": "2026.722.0",
"resolved": "https://registry.npmjs.org/@paperclipai/plugin-sdk/-/plugin-sdk-2026.525.0.tgz", "resolved": "https://registry.npmjs.org/@paperclipai/plugin-sdk/-/plugin-sdk-2026.722.0.tgz",
"integrity": "sha512-7ivXbFSwH7cS+/2WNbPwQve5vVtLnqgxX5lmqTKn+i3fOY03d5KHaYF2bbI5zeTkAAwwEJ0Nz3rfBWNPguRRUw==", "integrity": "sha512-2rMJCBo8ZAouuMsapdaY4/l+oHmrXKHW/k/HfPOLCxFng7+g04c5JhoFq+/ptGfNcW3kXzs34y7brviDCml06Q==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@paperclipai/shared": "2026.525.0", "@paperclipai/shared": "2026.722.0",
"zod": "^3.24.2" "zod": "^3.24.2"
}, },
"bin": { "bin": {
@@ -646,9 +646,9 @@
} }
}, },
"node_modules/@paperclipai/shared": { "node_modules/@paperclipai/shared": {
"version": "2026.525.0", "version": "2026.722.0",
"resolved": "https://registry.npmjs.org/@paperclipai/shared/-/shared-2026.525.0.tgz", "resolved": "https://registry.npmjs.org/@paperclipai/shared/-/shared-2026.722.0.tgz",
"integrity": "sha512-fbVrEx96oxkxXbRBFDLAgW5Z0lRC7Ii+BeCNhKqKRCg9m2IwtP97CGoOeAROdRrvRpP8Neb3vgaaDbDjXT74lQ==", "integrity": "sha512-JciI6EbtNwHSeoyN5ZkshwqpyfIluyO46JHp8I7pPCvpJ7Uv7MSZEBs3lKXdXLadFEN6qzfwNmf39xjIFC8isA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"zod": "^3.24.2" "zod": "^3.24.2"

View File

@@ -17,7 +17,7 @@
"biome:fix": "biome check --write src/" "biome:fix": "biome check --write src/"
}, },
"dependencies": { "dependencies": {
"@paperclipai/plugin-sdk": "^2026.525.0", "@paperclipai/plugin-sdk": "^2026.722.0",
"react": "^19.0.0" "react": "^19.0.0"
}, },
"devDependencies": { "devDependencies": {

View File

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

View File

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

View File

@@ -156,6 +156,23 @@ export default {
description: "Get overall system processing status", description: "Get overall system processing status",
parametersSchema: { type: "object" as const, properties: {} }, parametersSchema: { type: "object" as const, properties: {} },
}, },
{
toolKey: "legal_predecessor_context",
name: "legal_predecessor_context",
displayName: "הקשר מריצות קודמות",
description:
"Recent conclusions from prior heartbeat runs on this case (the 'summary' each run left). Call this when RESUMING work on a case to see what earlier sessions already did/decided — instead of re-deriving context from scratch.",
parametersSchema: {
type: "object" as const,
properties: {
case_number: {
type: "string" as const,
description: "Case number (e.g. 1043-02-26)",
},
},
required: ["case_number"],
},
},
], ],
jobs: [ jobs: [
{ {
@@ -178,6 +195,13 @@ export default {
'מסכם פידבק יו"ר מהשבוע האחרון ומעדכן את decision-lessons.md', 'מסכם פידבק יו"ר מהשבוע האחרון ומעדכן את decision-lessons.md',
schedule: "0 19 * * 0", schedule: "0 19 * * 0",
}, },
{
jobKey: "route-pending-comments",
displayName: "ערובת מסירת-הערות (sweep)",
description:
"sweep פיוס: מנתב כל הערת-משתמש שטרם נותבה ל-CEO (at-least-once), כדי שהערה לא תיפול גם אם ה-wakeup התבטל/התלכד",
schedule: "*/2 * * * *",
},
], ],
webhooks: [ webhooks: [
{ {

View File

@@ -16,16 +16,79 @@ const CEO_AGENT_IDS: Record<string, string> = {
"cdbfa8bc-3d61-41a4-a2e7-677ec7d34562", // CMPA (היטלי השבחה) "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 have no company at all, so
* they fall back to probing the companies we know about.
*/
/** 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> {
const read = async (id?: string): Promise<string | null> => {
try {
const cfg = (await ctx.config.get(id)) as {
legalApiBaseUrl?: unknown;
} | null;
const url = cfg?.legalApiBaseUrl;
return typeof url === "string" && url.trim() ? url.trim() : null;
} catch {
return null;
}
};
if (companyId) {
const scoped = await read(companyId);
if (scoped) return scoped;
}
// No companyId passed in — but the host derives one itself when the call
// happens inside a host-scoped invocation (e.g. a tool handler). Ask before
// falling back to guesswork, so tools honour their own company's config.
const derived = await read(undefined);
if (derived) return derived;
// Genuinely no company (scheduled jobs): try each company we know of. All of
// them point at the same legal-ai instance in practice, so the first hit wins.
for (const knownCompanyId of Object.keys(CEO_AGENT_IDS)) {
const url = await read(knownCompanyId);
if (url) return url;
}
ctx.logger.warn("legalApiBaseUrl unresolved — using default", {
companyId: companyId ?? null,
fallback: DEFAULT_LEGAL_API_BASE,
});
return DEFAULT_LEGAL_API_BASE;
}
const plugin = definePlugin({ const plugin = definePlugin({
async setup(ctx) { async setup(ctx) {
pluginCtx = ctx; // save for onWebhook pluginCtx = ctx; // save for onWebhook
const config = (await ctx.config.get()) as {
legalApiBaseUrl?: string;
} | null;
const baseUrl = config?.legalApiBaseUrl || "http://localhost:8085";
const api = new LegalApi(baseUrl);
ctx.logger.info("Legal AI plugin starting", { url: baseUrl }); // 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));
ctx.logger.info("Legal AI plugin starting");
// ── Tools ────────────────────────────────────────────────────── // ── Tools ──────────────────────────────────────────────────────
@@ -507,6 +570,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 ───────────────────────────────────────────────────── // ── Events ─────────────────────────────────────────────────────
ctx.events.on("issue.created", async (event) => { ctx.events.on("issue.created", async (event) => {
@@ -535,32 +632,118 @@ const plugin = definePlugin({
// Route user comments through CEO agent — per company // 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) => { ctx.events.on("issue.comment.created", async (event) => {
// Only intercept human comments — not agent comments (prevents loops) // Only intercept human comments — not agent comments (prevents loops)
if (event.actorType !== "user") return; if (event.actorType !== "user") return;
if (!event.companyId) return; if (!event.companyId) return;
// entityId is the comment ID — fetch the comment to get issueId + body // Event/payload shape for `issue.comment.created` (Paperclip host):
const entityId = event.entityId; // event.entityId → the ISSUE id (the primary entity of the event)
if (!entityId) return; // event.payload.commentId → the comment id
// event.payload.bodySnippet → TRUNCATED body (not the full text)
// The event payload may contain the issueId directly // event.payload.reopened / reopenedFrom → set when the comment
const payload = event.payload as { // 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; issueId?: string;
commentId?: string;
body?: string; body?: string;
bodySnippet?: string;
reopened?: boolean;
reopenedFrom?: string;
} | null; } | null;
const issueId = payload?.issueId; // issueId: prefer entityId (current host), fall back to payload for
let commentBody = payload?.body; // forward/backward compatibility with other host versions.
const issueId = event.entityId || payload?.issueId;
// If issueId is not in payload, try to find it from the comment entity
if (!issueId) { if (!issueId) {
ctx.logger.warn( ctx.logger.warn(
"issue.comment.created event missing issueId in payload, skipping", "issue.comment.created event missing issueId (entityId+payload empty), skipping",
{ entityId, payload }, { entityId: event.entityId, payload },
); );
return; return;
} }
const commentId = payload?.commentId;
// Fetch issue details for context // Fetch issue details for context
const issue = await ctx.issues.get(issueId, event.companyId); const issue = await ctx.issues.get(issueId, event.companyId);
@@ -571,20 +754,6 @@ const plugin = definePlugin({
return; 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 // Wake the CEO agent for this company
const ceoAgentId = CEO_AGENT_IDS[event.companyId]; const ceoAgentId = CEO_AGENT_IDS[event.companyId];
if (!ceoAgentId) { if (!ceoAgentId) {
@@ -594,29 +763,73 @@ const plugin = definePlugin({
return; 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 { try {
const { runId } = await ctx.agents.invoke(ceoAgentId, event.companyId, { const comments = await ctx.issues.listComments(
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, issueId,
commentId: entityId, event.companyId,
runId, );
}); 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) { } catch (err) {
ctx.logger.error("Failed to invoke CEO agent for comment routing", { ctx.logger.warn("listComments failed, falling back to bodySnippet", {
issueId, issueId,
commentId,
error: String(err), 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 ─────────────────────────────────────────────────────── // ── Jobs ───────────────────────────────────────────────────────
@@ -709,9 +922,8 @@ const plugin = definePlugin({
ctx.jobs.register("stale-case-reminder", async (_job) => { ctx.jobs.register("stale-case-reminder", async (_job) => {
ctx.logger.info("stale-case-reminder: starting"); ctx.logger.info("stale-case-reminder: starting");
const config = await ctx.config.get(); // Scheduled job — no company context; resolveApiBase probes known companies.
const apiBase = const apiBase = await resolveApiBase(ctx);
(config.legalApiBaseUrl as string) ?? "http://localhost:8085";
let resp: Awaited<ReturnType<typeof ctx.http.fetch>>; let resp: Awaited<ReturnType<typeof ctx.http.fetch>>;
try { try {
@@ -783,9 +995,8 @@ const plugin = definePlugin({
ctx.jobs.register("weekly-feedback-analysis", async (_job) => { ctx.jobs.register("weekly-feedback-analysis", async (_job) => {
ctx.logger.info("weekly-feedback-analysis: starting"); ctx.logger.info("weekly-feedback-analysis: starting");
const config = await ctx.config.get(); // Scheduled job — no company context; resolveApiBase probes known companies.
const apiBase = const apiBase = await resolveApiBase(ctx);
(config.legalApiBaseUrl as string) ?? "http://localhost:8085";
const resp = await ctx.http.fetch( const resp = await ctx.http.fetch(
`${apiBase}/api/chair-feedback/weekly-summary`, `${apiBase}/api/chair-feedback/weekly-summary`,
@@ -842,6 +1053,107 @@ const plugin = definePlugin({
} }
}); });
// 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) ────────────────────────────────── // ── Data handlers (UI bridge) ──────────────────────────────────
// These back `usePluginData(key, params)` calls from the React UI bundle // These back `usePluginData(key, params)` calls from the React UI bundle
// in `dist/ui/index.js`. Errors are caught and rethrown with a clear // in `dist/ui/index.js`. Errors are caught and rethrown with a clear
@@ -878,9 +1190,7 @@ const plugin = definePlugin({
if (!issueId) return null; if (!issueId) return null;
const caseNumber = await resolveCaseNumber(issueId); const caseNumber = await resolveCaseNumber(issueId);
if (!caseNumber) return null; if (!caseNumber) return null;
const config2 = await ctx.config.get(); const apiBase = await resolveApiBase(ctx, companyIdOf(params));
const apiBase =
(config2.legalApiBaseUrl as string) ?? "http://localhost:8085";
try { try {
const resp = await ctx.http.fetch( const resp = await ctx.http.fetch(
`${apiBase}/api/cases/${encodeURIComponent(caseNumber)}/legal-arguments`, `${apiBase}/api/cases/${encodeURIComponent(caseNumber)}/legal-arguments`,
@@ -917,9 +1227,7 @@ const plugin = definePlugin({
if (!issueId) return []; if (!issueId) return [];
const caseNumber = await resolveCaseNumber(issueId); const caseNumber = await resolveCaseNumber(issueId);
if (!caseNumber) return []; if (!caseNumber) return [];
const config2 = await ctx.config.get(); const apiBase = await resolveApiBase(ctx, companyIdOf(params));
const apiBase =
(config2.legalApiBaseUrl as string) ?? "http://localhost:8085";
try { try {
const resp = await ctx.http.fetch( const resp = await ctx.http.fetch(
`${apiBase}/api/cases/${encodeURIComponent(caseNumber)}/precedents`, `${apiBase}/api/cases/${encodeURIComponent(caseNumber)}/precedents`,
@@ -955,9 +1263,7 @@ const plugin = definePlugin({
if (!issueId) return []; if (!issueId) return [];
const caseNumber = await resolveCaseNumber(issueId); const caseNumber = await resolveCaseNumber(issueId);
if (!caseNumber) return []; if (!caseNumber) return [];
const config2 = await ctx.config.get(); const apiBase = await resolveApiBase(ctx, companyIdOf(params));
const apiBase =
(config2.legalApiBaseUrl as string) ?? "http://localhost:8085";
try { try {
const url = new URL(`${apiBase}/api/missing-precedents`); const url = new URL(`${apiBase}/api/missing-precedents`);
url.searchParams.set("case_number", caseNumber); url.searchParams.set("case_number", caseNumber);