diff --git a/src/sync-target.test.ts b/src/sync-target.test.ts
new file mode 100644
index 0000000..ed3f2bf
--- /dev/null
+++ b/src/sync-target.test.ts
@@ -0,0 +1,134 @@
+///
+
+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");
+});
diff --git a/src/sync-target.ts b/src/sync-target.ts
new file mode 100644
index 0000000..ccea907
--- /dev/null
+++ b/src/sync-target.ts
@@ -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 = 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 = 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,
+ };
+}
diff --git a/src/worker.ts b/src/worker.ts
index 2a82af6..f52f7d3 100644
--- a/src/worker.ts
+++ b/src/worker.ts
@@ -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 = {
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 = {
+ 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,71 +883,60 @@ const plugin = definePlugin({
const companyId = companies[0].id;
const issues = await ctx.issues.list({ companyId });
+ // מעבר אחד על ה-issues: state.get אחד ל-issue (לא לכל צירוף תיק×issue),
+ // וקיבוץ למפה case_number → מועמדים. הקיבוץ נדרש כדי לבחור "השורש היחיד".
+ const byCase = new Map();
+ 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);
+ }
+
for (const legalCase of cases) {
- for (const issue of issues) {
- const linkedCase = await ctx.state.get({
- scopeKind: "issue",
- scopeId: issue.id,
- stateKey: "legal-case-number",
+ const targetStatus = CASE_STATUS_TO_ISSUE_STATUS[legalCase.status];
+ if (!targetStatus) continue;
+
+ const candidates = byCase.get(legalCase.case_number) ?? [];
+ const { target, reason, writableRoots } =
+ pickSyncTargetIssue(candidates);
+
+ if (!target) {
+ // אין בליעה שקטה: מדווח למה לא נכתב כלום.
+ ctx.logger.info("sync-case-status: no sync target", {
+ caseNumber: legalCase.case_number,
+ reason,
+ linked: candidates.length,
+ writableRoots,
});
-
- if (linkedCase === legalCase.case_number) {
- // Map 13 legal-ai statuses to Paperclip issue status
- const statusMap: Record =
- {
- 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 statusLabels: Record = {
- new: "תיק חדש",
- uploading: "העלאת מסמכים",
- processing: "עיבוד מסמכים",
- documents_ready: "מסמכים מוכנים — הזן תוצאה",
- outcome_set: "תוצאה הוזנה — נדרש סיעור מוחות",
- brainstorming: "גיבוש כיוון בתהליך",
- direction_approved: "כיוון אושר — מוכן לכתיבה",
- drafting: "כתיבת החלטה בתהליך",
- qa_review: "בדיקת איכות",
- drafted: "טיוטה מוכנה — בדוק ושלח לדפנה",
- exported: "DOCX נוצר — ממתין לדפנה",
- reviewed: "דפנה הגיהה — העלה גרסה סופית",
- final: "גרסה סופית — לולאת למידה",
- };
-
- const targetStatus = statusMap[legalCase.status];
- const label = statusLabels[legalCase.status] || legalCase.status;
-
- if (targetStatus && issue.status !== targetStatus) {
- await ctx.issues.update(
- issue.id,
- { status: targetStatus },
- companyId,
- );
- await ctx.issues.createComment(
- issue.id,
- `📋 ${label}`,
- companyId,
- );
- ctx.logger.info("Synced issue status", {
- issueId: issue.id,
- caseNumber: legalCase.case_number,
- newStatus: targetStatus,
- });
- }
- }
+ continue;
}
+
+ if (target.status === targetStatus) continue;
+
+ const label =
+ CASE_STATUS_LABELS[legalCase.status] || legalCase.status;
+
+ await ctx.issues.update(
+ target.id,
+ { status: targetStatus },
+ companyId,
+ );
+ await ctx.issues.createComment(target.id, `📋 ${label}`, companyId);
+ ctx.logger.info("Synced issue status", {
+ issueId: target.id,
+ caseNumber: legalCase.case_number,
+ newStatus: targetStatus,
+ });
}
ctx.logger.info("Case status sync completed", {
diff --git a/tsconfig.json b/tsconfig.json
index 66ec067..2cc8cc3 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -12,5 +12,6 @@
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"]
},
- "include": ["src"]
+ "include": ["src"],
+ "exclude": ["src/**/*.test.ts"]
}