2 Commits

Author SHA1 Message Date
378e5657b5 fix(sync): סנכרון-הסטטוס יכתוב רק על issue-שורש פתוח, לא על sub-task שנסגר (#446)
הג'וב sync-case-status כתב על כל issue מקושר-לתיק שסטטוסו שונה מהיעד — השומר
היחיד היה 'issue.status !== targetStatus'. לכן הוא החזיר sub-tasks שנסגרו
(done) ל-in_progress, כפי שתועד ב-CMPA-140 / תיק 8125-09-24.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 17:17:06 +00:00
5 changed files with 304 additions and 150 deletions

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 המקושר"
}
]
}

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

@@ -0,0 +1,134 @@
/// <reference types="node" />
import assert from "node:assert/strict";
import { test } from "node:test";
// Excluded from tsc (tsconfig.json) — never bundled/emitted, run natively via
// `node --test`, which requires the literal `.ts` extension (Node's ESM
// resolver does not remap `.js` specifiers to `.ts` files at runtime).
import { pickSyncTargetIssue, type SyncCandidate } from "./sync-target.ts";
/**
* הכלל הישן (worker.ts:894 לפני #446): כל issue מקושר שסטטוסו שונה מהיעד —
* נכתב, בלי סינון parentId ובלי סינון-סטטוס.
*/
function legacyTargets(
candidates: readonly SyncCandidate[],
targetStatus: string,
): string[] {
return candidates.filter((c) => c.status !== targetStatus).map((c) => c.id);
}
test("רגרסיית 8125-09-24 / CMPA-140: 11 ה-issues הממשיים של התיק, אף sub-task done לא נבחר", () => {
// נמדד ב-DB החי של Paperclip (2026-08-24) — כל ה-issues המקושרים לתיק
// 8125-09-24. CMPA-140 (זה שנדרס בפועל, done→in_progress) הוא **ילד** של
// CMPA-139, לא שורש. CMPA-139 עצמו אינו ברשימה בכוונה: נמדד שאין לו
// רשומת legal-case-number ב-plugin_state, ולכן הוא לא מגיע כמועמד כלל —
// לילד יש הורה שאינו מקושר.
const candidates: SyncCandidate[] = [
{ id: "CMPA-116", status: "cancelled", parentId: null },
{ id: "CMPA-84", status: "cancelled", parentId: null },
{ id: "CMPA-86", status: "cancelled", parentId: null },
{ id: "CMPA-140", status: "done", parentId: "CMPA-139" }, // זה שנדרס בפועל
{ id: "CMPA-141", status: "cancelled", parentId: "CMPA-139" },
{ id: "CMPA-85", status: "done", parentId: "CMPA-84" },
{ id: "CMPA-146", status: "done", parentId: "CMPA-84" },
{ id: "CMPA-147", status: "done", parentId: "CMPA-84" },
{ id: "CMPA-87", status: "done", parentId: "CMPA-86" },
{ id: "CMPA-88", status: "done", parentId: "CMPA-86" },
{ id: "CMPA-89", status: "cancelled", parentId: "CMPA-86" },
];
// סטטוס-התיק היה drafted → in_progress (CASE_STATUS_TO_ISSUE_STATUS ב-worker.ts).
const targetStatus = "in_progress";
// הכלל הישן היה כותב על CMPA-140 — זה הבאג שתועד.
assert.ok(legacyTargets(candidates, targetStatus).includes("CMPA-140"));
const result = pickSyncTargetIssue(candidates);
// הכלל החדש אינו כותב על דבר.
assert.equal(result.target, null);
assert.equal(result.reason, "no_writable_root");
});
test("sub-task יחיד ב-done ומקושר: sub-task לעולם לא נבחר", () => {
const candidates: SyncCandidate[] = [
{ id: "sub-1", status: "done", parentId: "some-root" },
];
const result = pickSyncTargetIssue(candidates);
assert.equal(result.target, null);
assert.equal(result.reason, "no_writable_root");
});
test("sub-task יחיד פתוח (in_progress) + שורש cancelled: פתוח אינו מספיק, חייב שורש", () => {
const candidates: SyncCandidate[] = [
{ id: "root-1", status: "cancelled", parentId: null },
{ id: "sub-1", status: "in_progress", parentId: "root-1" },
];
const result = pickSyncTargetIssue(candidates);
assert.equal(result.target, null);
assert.equal(result.reason, "no_writable_root");
});
test("שורש יחיד ב-todo: נבחר", () => {
const candidates: SyncCandidate[] = [
{ id: "root-1", status: "todo", parentId: null },
];
const result = pickSyncTargetIssue(candidates);
assert.equal(result.target?.id, "root-1");
assert.equal(result.reason, "ok");
assert.equal(result.writableRoots, 1);
});
test("שורש יחיד ב-in_review: לא נכתב (הגנת auto-block)", () => {
const candidates: SyncCandidate[] = [
{ id: "root-1", status: "in_review", parentId: null },
];
const result = pickSyncTargetIssue(candidates);
assert.equal(result.target, null);
assert.equal(result.reason, "no_writable_root");
});
test("שורש יחיד ב-blocked: לא נכתב", () => {
const candidates: SyncCandidate[] = [
{ id: "root-1", status: "blocked", parentId: null },
];
const result = pickSyncTargetIssue(candidates);
assert.equal(result.target, null);
assert.equal(result.reason, "no_writable_root");
});
test("שני שורשים ברי-כתיבה: ambiguous, לא נכתב", () => {
const candidates: SyncCandidate[] = [
{ id: "root-1", status: "todo", parentId: null },
{ id: "root-2", status: "in_progress", parentId: null },
];
const result = pickSyncTargetIssue(candidates);
assert.equal(result.target, null);
assert.equal(result.reason, "ambiguous_writable_roots");
assert.equal(result.writableRoots, 2);
});
test("שורש todo + ילד in_progress: השורש נבחר", () => {
const candidates: SyncCandidate[] = [
{ id: "root-1", status: "todo", parentId: null },
{ id: "child-1", status: "in_progress", parentId: "root-1" },
];
const result = pickSyncTargetIssue(candidates);
assert.equal(result.target?.id, "root-1");
assert.equal(result.reason, "ok");
});

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

@@ -0,0 +1,79 @@
/**
* בחירת יעד-הסנכרון של הג'וב `sync-case-status` (issue #446).
*
* המקור לבאג: הג'וב הישן כתב על **כל** issue מקושר-לתיק, כולל sub-issues
* ו-issues בסטטוסים סגורים/בהמתנה-לאדם. זה החזיר CMPA-140 מ-`done` ל-`in_progress`
* (8125-09-24) — כי sub-task שהושלם עדיין נמנה כ"מקושר".
*
* המודול הזה טהור בכוונה — בלי import מה-SDK ובלי side effects — כדי שאפשר
* יהיה לייבא אותו ישירות בטסט בלי להריץ את `runWorker(plugin, import.meta.url)`
* שקורה בזמן import של worker.ts.
*/
/** "סגור" — ההגדרה היחידה. מראה של web/paperclip_client.py:561 ב-legal-ai. */
export const CLOSED_ISSUE_STATUSES: ReadonlySet<string> = new Set([
"done",
"cancelled",
]);
/**
* מצב בבעלות Paperclip / בהמתנה-לאדם — **לא** הגדרה שנייה של "סגור".
* `in_review` הוא מצב-ההמתנה-ליו"ר המכוון: CEO שמשאיר issue ב-`in_progress`
* מקבל auto-block מ-Paperclip תוך דקה, ולכן הוא מעביר ל-`in_review`
* (legal-ai/docs/paperclip-quirks.md §3). כתיבת-סטטוס אוטומטית עליו גונבת את
* ה-issue מתור-הביקורת של היו"ר ומזמינה קרב-סטטוסים.
* `blocked` — כתיבה עליו מסתירה חוסם קיים.
*/
export const NON_WRITABLE_STATUSES: ReadonlySet<string> = new Set([
"in_review",
"blocked",
]);
export interface SyncCandidate {
id: string;
status: string;
parentId: string | null;
}
export type SyncTargetReason =
| "ok"
| "no_linked_issues"
| "no_writable_root"
| "ambiguous_writable_roots";
export interface SyncTargetResult {
target: SyncCandidate | null;
reason: SyncTargetReason;
/** כמה שורשים ברי-כתיבה נמצאו — לצורך לוג. */
writableRoots: number;
}
export function isWritableStatus(status: string): boolean {
return (
!CLOSED_ISSUE_STATUSES.has(status) && !NON_WRITABLE_STATUSES.has(status)
);
}
export function pickSyncTargetIssue(
candidates: readonly SyncCandidate[],
): SyncTargetResult {
if (candidates.length === 0) {
return { target: null, reason: "no_linked_issues", writableRoots: 0 };
}
const roots = candidates.filter(
(c) => c.parentId === null && isWritableStatus(c.status),
);
if (roots.length === 1) {
return { target: roots[0], reason: "ok", writableRoots: 1 };
}
if (roots.length === 0) {
return { target: null, reason: "no_writable_root", writableRoots: 0 };
}
return {
target: null,
reason: "ambiguous_writable_roots",
writableRoots: roots.length,
};
}

View File

@@ -4,6 +4,7 @@ import type {
} from "@paperclipai/plugin-sdk";
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
import { LegalApi } from "./legal-api.js";
import { pickSyncTargetIssue, type SyncCandidate } from "./sync-target.js";
// Hoisted so onWebhook can access the context after setup() completes.
let pluginCtx: PluginContext | null = null;
@@ -18,6 +19,43 @@ const CEO_AGENT_IDS: Record<string, string> = {
const DEFAULT_LEGAL_API_BASE = "http://localhost:8085";
// Map 13 legal-ai case statuses to Paperclip issue status (used by the
// `sync-case-status` job).
const CASE_STATUS_TO_ISSUE_STATUS: Record<
string,
"todo" | "in_progress" | "done"
> = {
new: "todo",
uploading: "todo",
processing: "in_progress",
documents_ready: "in_progress",
outcome_set: "in_progress",
brainstorming: "in_progress",
direction_approved: "in_progress",
drafting: "in_progress",
qa_review: "in_progress",
drafted: "in_progress",
exported: "in_progress",
reviewed: "in_progress",
final: "done",
};
const CASE_STATUS_LABELS: Record<string, string> = {
new: "תיק חדש",
uploading: "העלאת מסמכים",
processing: "עיבוד מסמכים",
documents_ready: "מסמכים מוכנים — הזן תוצאה",
outcome_set: "תוצאה הוזנה — נדרש סיעור מוחות",
brainstorming: "גיבוש כיוון בתהליך",
direction_approved: "כיוון אושר — מוכן לכתיבה",
drafting: "כתיבת החלטה בתהליך",
qa_review: "בדיקת איכות",
drafted: "טיוטה מוכנה — בדוק ושלח לדפנה",
exported: "DOCX נוצר — ממתין לדפנה",
reviewed: "דפנה הגיהה — העלה גרסה סופית",
final: "גרסה סופית — לולאת למידה",
};
/**
* Resolve `legalApiBaseUrl` from company-scoped plugin config.
*
@@ -845,72 +883,61 @@ const plugin = definePlugin({
const companyId = companies[0].id;
const issues = await ctx.issues.list({ companyId });
for (const legalCase of cases) {
// מעבר אחד על ה-issues: state.get אחד ל-issue (לא לכל צירוף תיק×issue),
// וקיבוץ למפה case_number → מועמדים. הקיבוץ נדרש כדי לבחור "השורש היחיד".
const byCase = new Map<string, SyncCandidate[]>();
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,
});
byCase.set(linkedCase, list);
}
if (linkedCase === legalCase.case_number) {
// Map 13 legal-ai statuses to Paperclip issue status
const statusMap: Record<string, "todo" | "in_progress" | "done"> =
{
new: "todo",
uploading: "todo",
processing: "in_progress",
documents_ready: "in_progress",
outcome_set: "in_progress",
brainstorming: "in_progress",
direction_approved: "in_progress",
drafting: "in_progress",
qa_review: "in_progress",
drafted: "in_progress",
exported: "in_progress",
reviewed: "in_progress",
final: "done",
};
for (const legalCase of cases) {
const targetStatus = CASE_STATUS_TO_ISSUE_STATUS[legalCase.status];
if (!targetStatus) continue;
const statusLabels: Record<string, string> = {
new: "תיק חדש",
uploading: "העלאת מסמכים",
processing: "עיבוד מסמכים",
documents_ready: "מסמכים מוכנים — הזן תוצאה",
outcome_set: "תוצאה הוזנה — נדרש סיעור מוחות",
brainstorming: "גיבוש כיוון בתהליך",
direction_approved: "כיוון אושר — מוכן לכתיבה",
drafting: "כתיבת החלטה בתהליך",
qa_review: "בדיקת איכות",
drafted: "טיוטה מוכנה — בדוק ושלח לדפנה",
exported: "DOCX נוצר — ממתין לדפנה",
reviewed: "דפנה הגיהה — העלה גרסה סופית",
final: "גרסה סופית — לולאת למידה",
};
const candidates = byCase.get(legalCase.case_number) ?? [];
const { target, reason, writableRoots } =
pickSyncTargetIssue(candidates);
const targetStatus = statusMap[legalCase.status];
const label = statusLabels[legalCase.status] || legalCase.status;
if (!target) {
// אין בליעה שקטה: מדווח למה לא נכתב כלום.
ctx.logger.info("sync-case-status: no sync target", {
caseNumber: legalCase.case_number,
reason,
linked: candidates.length,
writableRoots,
});
continue;
}
if (target.status === targetStatus) continue;
const label =
CASE_STATUS_LABELS[legalCase.status] || legalCase.status;
if (targetStatus && issue.status !== targetStatus) {
await ctx.issues.update(
issue.id,
target.id,
{ status: targetStatus },
companyId,
);
await ctx.issues.createComment(
issue.id,
`📋 ${label}`,
companyId,
);
await ctx.issues.createComment(target.id, `📋 ${label}`, companyId);
ctx.logger.info("Synced issue status", {
issueId: issue.id,
issueId: target.id,
caseNumber: legalCase.case_number,
newStatus: targetStatus,
});
}
}
}
}
ctx.logger.info("Case status sync completed", {
casesChecked: cases.length,

View File

@@ -12,5 +12,6 @@
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"]
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}