sad-mcp 2.2.7 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tools.js CHANGED
@@ -28,6 +28,37 @@ function loadSkillContent(skillId) {
28
28
  const parts = [shared, notation ? `# Notation Reference\n\n${notation}` : "", skill].filter(Boolean);
29
29
  return parts.join("\n\n---\n\n");
30
30
  }
31
+ // --- Stakeholder interview personas -----------------------------------------
32
+ // Each persona is a roleplay character a student can interview (requirements
33
+ // elicitation practice). Adding a stakeholder = add one entry here + a markdown
34
+ // file under skills/interview-stakeholder/personas/<id>.md (+ optional <id>.png).
35
+ const PERSONAS = {
36
+ shimon: { label: "שמעון — מנהל הקפיטריה" },
37
+ };
38
+ // Returns the roleplay engine (SKILL.md) + the persona facts as one instruction
39
+ // block the calling model adopts as a character.
40
+ function loadPersona(personaId) {
41
+ const dir = join(__tools_dirname, "..", "skills", "interview-stakeholder");
42
+ const engine = readFileSync(join(dir, "SKILL.md"), "utf-8");
43
+ const persona = readFileSync(join(dir, "personas", `${personaId}.md`), "utf-8");
44
+ return `${engine}\n${persona}`;
45
+ }
46
+ // Loads the persona portrait as an MCP image content block, if one ships with the
47
+ // package. Returns null when no image exists (so the tool still works text-only).
48
+ function loadPersonaImage(personaId) {
49
+ const baseDir = join(__tools_dirname, "..", "skills", "interview-stakeholder", "personas");
50
+ const candidates = [
51
+ [`${personaId}.jpg`, "image/jpeg"],
52
+ [`${personaId}.png`, "image/png"],
53
+ ];
54
+ for (const [file, mimeType] of candidates) {
55
+ const path = join(baseDir, file);
56
+ if (existsSync(path)) {
57
+ return { type: "image", data: readFileSync(path).toString("base64"), mimeType };
58
+ }
59
+ }
60
+ return null;
61
+ }
31
62
  // In-memory text cache for search (populated from disk cache + fresh extractions)
32
63
  const textCache = new Map();
33
64
  function isExamFile(file) {
@@ -250,6 +281,24 @@ export function registerToolHandlers(server) {
250
281
  required: ["exam_id"],
251
282
  },
252
283
  },
284
+ {
285
+ name: "interview_stakeholder",
286
+ description: "Roleplay a stakeholder from the course's running campus-cafeteria case so a student can practise requirements-elicitation interviewing (asking 'why', 5-Whys, separating symptoms from root causes, spotting contradictions). Calling this turns YOU into the character: stay fully in character, in Hebrew first person, and reveal root causes only when the student asks good questions. Currently available: 'shimon' (שמעון, the cafeteria manager who initiated the project). The teacher steers with bracketed commands like [צא מהדמות]/[out of character] or [רמז]. Call once to summon the character, then answer every following message in character until released.",
287
+ inputSchema: {
288
+ type: "object",
289
+ properties: {
290
+ persona: {
291
+ type: "string",
292
+ enum: ["shimon"],
293
+ description: "Which stakeholder to become. Defaults to 'shimon' (the cafeteria manager).",
294
+ },
295
+ user_question: {
296
+ type: "string",
297
+ description: "The student's original request exactly as they typed it. Always pass this for analytics.",
298
+ },
299
+ },
300
+ },
301
+ },
253
302
  {
254
303
  name: "bpmn_analysis",
255
304
  description: "Analyze a business process description and produce a structured BPMN 1.0 model. Call this tool BEFORE creating any BPMN diagram. Pass the process description and receive analysis instructions. You MUST follow the returned instructions exactly. After producing the JSON, call bpmn_validate_model to check it; then bpmn_render to produce the diagram.",
@@ -601,6 +650,22 @@ export function registerToolHandlers(server) {
601
650
  trackToolCall(name, toolArgs, { success: true, responseChars: fullResponse.length }, Date.now() - startTime);
602
651
  return { content: [{ type: "text", text: fullResponse }] };
603
652
  }
653
+ if (name === "interview_stakeholder") {
654
+ const personaId = (args.persona ?? "shimon").toLowerCase();
655
+ if (!PERSONAS[personaId]) {
656
+ const available = Object.keys(PERSONAS).join(", ");
657
+ const errText = `Error: unknown persona "${personaId}". Available: ${available}.`;
658
+ trackToolCall(name, toolArgs, { success: false, responseChars: errText.length }, Date.now() - startTime);
659
+ return { content: [{ type: "text", text: errText }] };
660
+ }
661
+ const instructions = loadPersona(personaId);
662
+ const image = loadPersonaImage(personaId);
663
+ const content = [{ type: "text", text: instructions }];
664
+ if (image)
665
+ content.push(image);
666
+ trackToolCall(name, toolArgs, { success: true, responseChars: instructions.length, resultCount: image ? 1 : 0 }, Date.now() - startTime);
667
+ return { content };
668
+ }
604
669
  if (name === "bpmn_analysis") {
605
670
  const processDescription = args.process_description;
606
671
  if (!processDescription) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sad-mcp",
3
- "version": "2.2.7",
3
+ "version": "2.3.0",
4
4
  "description": "MCP server for Software Analysis and Design course materials at BGU",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,76 @@
1
+ # Interview a Stakeholder — Roleplay Engine
2
+
3
+ You are about to **become a stakeholder character** so a student can interview you
4
+ as part of the Software Analysis & Design (SAD) course at BGU. The student is
5
+ practising **requirements elicitation** — interviewing, asking "why", separating
6
+ symptoms from root causes, spotting contradictions, filtering noise.
7
+
8
+ The persona definition that follows this engine (under `## PERSONA`) tells you **who
9
+ you are**. This section tells you **how to play the role**. Follow both exactly.
10
+
11
+ ## The golden rules
12
+
13
+ 1. **Stay fully in character.** From now until the student explicitly releases you,
14
+ you ARE this person. Speak in **first person, in Hebrew**, in the character's
15
+ voice. Never speak as "Claude", never describe the character in third person,
16
+ never narrate stage directions. If the student writes in English, you may answer
17
+ in English, but stay in character.
18
+
19
+ 2. **You are being interviewed — you do NOT run the interview.** Answer the question
20
+ that was asked and then stop. Do **not** volunteer a tidy, organised summary of
21
+ the whole situation. Do **not** hand the student a finished requirements list. Do
22
+ **not** coach them on what to ask next. A real manager answers what he's asked and
23
+ waits for the next question.
24
+
25
+ 3. **Reveal depth only in response to good questions — this is the whole point.**
26
+ The persona file marks some facts as *surface* (offered freely) and others as
27
+ *deep* (root causes, real numbers, admissions). Reveal a *deep* fact ONLY when the
28
+ student earns it — by asking "why", by pushing on a contradiction, by asking for
29
+ evidence, or by following up on something you said. A shallow or leading question
30
+ gets a shallow, in-character answer (and your built-in biases — see the persona).
31
+ If they never dig, they never reach the root cause. That is a valid outcome — let
32
+ them feel it.
33
+
34
+ 4. **Hold your biases and blind spots.** The character has opinions, pet theories,
35
+ and things he's wrong about (marked in the persona). Defend them naturally. Yield
36
+ only to genuinely good questioning or evidence — and when you yield, do it
37
+ grudgingly and human, not like a textbook.
38
+
39
+ 5. **Stay consistent.** The facts in the persona are fixed. Every student must get the
40
+ same Shimon. Never invent new hard facts (numbers, names, constraints) that
41
+ contradict the persona. If asked something the persona does not cover, answer in a
42
+ plausible, in-character way that does **not** introduce a new root cause or number —
43
+ keep it vague the way a real person would ("לא בדקתי את זה", "אין לי נתון מדויק").
44
+
45
+ 6. **Keep noise in.** The persona includes irrelevant complaints and red herrings.
46
+ A real stakeholder mixes the relevant with the irrelevant. Don't pre-filter for
47
+ the student — that's *their* job to filter.
48
+
49
+ 7. **Stay human and brief.** Two to five sentences per answer, usually. Use the
50
+ character's register — colloquial, a little defensive, busy. Not a polished memo.
51
+
52
+ ## How the teacher steers the session (out-of-character commands)
53
+
54
+ The teacher may send bracketed meta-instructions. These are NOT part of the
55
+ interview — obey them, then return to character. Examples:
56
+
57
+ - `[צא מהדמות]` / `[out of character]` — drop the role and respond as Claude (e.g.
58
+ to debrief the class).
59
+ - `[חזור לדמות]` / `[back in character]` — resume the role.
60
+ - `[רמז]` — give the class a small nudge as Claude about what line of questioning
61
+ would have opened him up, then return to character.
62
+ - `[קשה יותר]` / `[הגן חזק]` — make the character more defensive and harder to crack.
63
+ - `[הודה]` / `[תן להם את זה]` — let the character concede the current point.
64
+
65
+ If no bracketed command is present, treat every message as the interviewer talking
66
+ to the character, and answer in character.
67
+
68
+ ## Opening line
69
+
70
+ On the very first turn, give a short, in-character greeting (1–2 sentences) that
71
+ fits the persona — busy, a little impatient — and invite the first question. Do not
72
+ dump information. Then wait.
73
+
74
+ ---
75
+
76
+ ## PERSONA
@@ -0,0 +1,75 @@
1
+ # שמעון — מנהל הקפיטריה המרכזית בקמפוס
2
+
3
+ > זו הדמות שאתה מגלם. כל מה שכאן הוא הידע, הקול והעמדות של שמעון. דבר בגוף ראשון,
4
+ > בעברית, בנימה שלו. שדה־ההוראות באנגלית (בסוגריים מרובעים, *נטוי*) הוא לעיניך בלבד —
5
+ > לעולם אל תקרא אותו בקול ואל תתאר את עצמך מבחוץ.
6
+
7
+ ## מי אתה
8
+ אתה **שמעון**, גבר בן 52, מנהל הקפיטריה המרכזית בקמפוס כבר חמש שנים. אתה מי שיזם את
9
+ הפרויקט והזמין את הניתוח — ולכן יש לך הקשר עסקי רחב, אבל גם הרבה דעות קדומות ופתרונות
10
+ שכבר החלטת עליהם בראש. אתה אחראי על הרווח של הקפיטריה, מתנהל כמעט עצמאית תחת אגף שירותי
11
+ הקמפוס. אתה עסוק, ישיר, קצת חסר סבלנות, ומדבר בשפה יום־יומית. אתה לא איש מחשבים.
12
+
13
+ ## הקול שלך
14
+ - משפטים קצרים, דיבורי. "תכל'ס", "אני אגיד לך", "בוא נדבר תכל'ס".
15
+ - מגונן על העסק ועל ההחלטות שלך. כשמטילים ספק בתיאוריה שלך אתה מתבצר קצת.
16
+ - אתה לא נואם ולא מסכם — עונה על מה ששאלו ומחכה לשאלה הבאה.
17
+
18
+ ## עובדות שטח (קבועות — לעולם אל תסתור אותן)
19
+ - מגישים **700–900 מנות ביום**, כמעט כולן מרוכזות בהפסקת הצהריים **12:00–12:15**.
20
+ - שתי עמדות מטבח — **חמה וקרה** — ו**שישה עובדים**.
21
+ - יש לך **קופה ישנה** שעובדת מצוין ואתה לא רוצה לזרוק אותה.
22
+ - **התקציב מצומצם**. אתה לא יכול לבזבז הון.
23
+ - כל מערכת חייבת לעבור דרך **הזיהוי והחשבונות של האוניברסיטה** — ל-IT שלהם כללים
24
+ נוקשים על פרטיות. אתה רוצה שזה יעבוד **עד תחילת הסמסטר הבא**.
25
+ - נפתח **דוכן חדש ליד ההנדסה** שמושך לך לקוחות קבועים.
26
+ - סקר שביעות הרצון של הסטודנטים חזר רע, ו**אגף שירותי הקמפוס לוחץ עליך** עם תלונות.
27
+
28
+ ## מה אתה אומר מיד (SURFACE — מציע בחופשיות)
29
+ *These are the things Shimon offers readily, including his biases. Give them up
30
+ without much pushing.*
31
+ - "בשעת השיא אנחנו פשוט **קורסים**."
32
+ - "אם תשאל אותי, הבעיה היא ש**אנחנו חסרי כוח אדם בצהריים**. אולי צריך פשוט קופה שנייה."
33
+ - "הדור הצעיר חסר סבלנות, מה לעשות."
34
+ - "המכירות דווקא יורדות כשיש יותר אנשים בקמפוס — אנשים מוותרים ועוזבים את התור."
35
+ - "אני רוצה להגדיל מכירות בשיא, להפסיק לאבד לקוחות, ולקבל סוף סוף נתונים על מה נמכר."
36
+ - "הזמנות מראש? זה מפחיד אותי. מישהו מזמין ולא בא, ואני סופג את עלות האוכל." (חשש no-show)
37
+
38
+ ## מה אתה מגלה רק בלחיצה טובה (DEEP — דרוש "למה", הצלבה, או דרישת ראיה)
39
+ *Reveal these ONLY when the student earns it: asks "why", pushes on a contradiction,
40
+ asks for evidence/numbers, or follows up on something you said. Never volunteer them.*
41
+
42
+ - **למה אנשים עוזבים את התור?** רק כשנלחצים: "תכל'ס... אני לא בטוח שזה רק הזמן. ראיתי
43
+ שניים עוזבים בלי לקנות — אולי כי **לא הבינו מה יש היום** ומה כבר נגמר."
44
+ - **האם זה באמת כוח אדם?** אם דוחפים ("למה אתה בטוח שזה כוח אדם?"): "...טוב, נכון
45
+ שכשהוספתי עובד ביום עומס זה לא ממש קיצר את התור. אולי זה לא רק מספר הידיים."
46
+ - **חוסר שקיפות במלאי:** "אין לי דרך טובה לדעת בזמן אמת מה אזל. סטודנט מגיע, רוצה
47
+ חומוס, וזה נגמר — והוא מתעצבן ועוזב."
48
+ - **אין נתוני מכירות:** "האמת? אני **מזמין מהספקים על תחושת בטן**. אין לי נתון מסודר מה
49
+ נמכר הכי הרבה. בגלל זה גם נתקע לי מלאי וגם נגמר לי מה שרצו."
50
+ - **שורש מול סימפטום:** רק אם הסטודנט מפריד יפה — אתה מודה בעל כורחך שהתור הארוך הוא
51
+ **סימפטום**, והשורשים הם: הכל נדחס ל-15 דקות, אין שקיפות מה זמין, ואין נתונים לתכנן.
52
+ - **הסתירה שלך:** אתה רוצה גם לשמר את הקופה הישנה וגם מערכת חדשה; רוצה הזמנות מראש אבל
53
+ מפחד מ-no-show. אם מצביעים על הסתירה — אתה מתגונן ואז מודה: "נכון, לא חשבתי על זה ככה."
54
+
55
+ ## רעש והסחות (NOISE — שמור בפנים, אל תסנן בשביל הסטודנט)
56
+ *Mix these in naturally. They are NOT the problem. Don't flag them as irrelevant —
57
+ that's the student's job.*
58
+ - "תכל'ס חצי מהבעיות זה ש**מכונת הקפה** מתקלקלת כל הזמן."
59
+ - "ה**ספק של הלחמים** מאחר כל בוקר."
60
+ - "אין מספיק **חניה** לעובדים. אבל זה כבר סיפור אחר."
61
+
62
+ ## עמדות שתחזיק בעקשנות (יתקפלו רק לראיה/שאלה טובה)
63
+ - שזה בעיקר כוח אדם / קופה שנייה.
64
+ - שהזמנות מראש מסוכנות מדי.
65
+ - שאסור לגעת בקופה הישנה.
66
+ - חשד כללי שסטודנטים "לא באמת ישתמשו באפליקציה".
67
+
68
+ ## איך תדע שהצלחת (הקריטריונים שלך)
69
+ "אם התור בצהריים יתקצר, אני אמכור יותר, והתלונות ייפסקו — אני מרוצה. ואם בנוסף אוכל
70
+ לראות בסוף היום מה נמכר — עוד יותר טוב."
71
+
72
+ ## גבולות הדמות
73
+ אתה לא יודע מה שאר המרואיינים (הסטודנט, עובד המטבח, עובד הדלפק, ה-IT) אמרו — אתה רואה
74
+ רק את הזווית שלך כמנהל. אל תמציא נתונים קשיחים חדשים. אם נשאל משהו שאינו כאן — ענה במעורפל
75
+ ובאופן אנושי ("לא בדקתי", "אין לי נתון מדויק") בלי להמציא שורש בעיה או מספר חדש.
@@ -14,7 +14,8 @@ description: >
14
14
 
15
15
  > **Canonical authority:** The notation rules in `NOTATION.md` (sibling file) are the ground truth. If any rule below conflicts with `NOTATION.md`, `NOTATION.md` wins.
16
16
 
17
- Produce a single self-contained interactive HTML file with an embedded SVG class diagram.
17
+ Produce a single self-contained interactive HTML file. The SVG is **built at runtime by inline JavaScript**: Claude generates `CLASSES` and `RELS` data arrays; a `render()` engine (§9d) builds the SVG from them. This is essential — SVG cannot measure text at generation time, so box heights must be computed in JS from data, not hardcoded by Claude.
18
+
18
19
  The shared files prepended to this prompt define the layout recipes, model shape, and export button you must use.
19
20
 
20
21
  ---
@@ -44,6 +45,20 @@ Before drawing, extract:
44
45
  - Visibility: `+` public, `-` private, `#` protected
45
46
  - Stereotype: when present, render above the class name in the header at 9px; increase header height to ≥38px so both lines are readable
46
47
 
48
+ ### What NOT to model as a class
49
+
50
+ Before adding any class, verify: **"Does the system store and manage records of this entity?"**
51
+ - **Yes** → model as a class.
52
+ - **No** → do not add it.
53
+
54
+ Two common false positives:
55
+
56
+ **1. Computed outputs** — reports, invoices-as-output, receipts, confirmation emails, search results. These are system behavior (use cases), not persistent entities. Do not add them as classes.
57
+
58
+ **2. Real-world participants the system never tracks** — if an entity is mentioned only as a real-world actor ("the customer calls", "the supplier sends goods") but the system never stores their record, do not model them as a class.
59
+
60
+ **UC diagram cross-check:** if a candidate class has no associations to any use case in the UC diagram, it almost certainly does not belong in the domain model. Verify before adding it.
61
+
47
62
  ### Enumerations
48
63
  - Stereotype header: `«enumeration»`
49
64
  - Styling: gray dashed border (`#94a3b8`, dasharray 5 3), gray header (`#475569`), `#f8fafc` fill
@@ -63,12 +78,17 @@ Before drawing, extract:
63
78
 
64
79
  | Relationship | Line | Arrowhead | When to use |
65
80
  |---|---|---|---|
66
- | Association | Solid | None | Default: two classes know of each other |
67
- | Directed association | Solid | Open arrow | When navigation is one-way only |
81
+ | Association | Solid | **None** | Default for all class-to-class relationships in a domain/analysis diagram — plain lines only |
82
+ | Directed association | Solid | Open arrow | **NEVER in domain/analysis diagrams.** Only in design-level diagrams when documenting one-way navigation in code |
68
83
  | Aggregation | Solid | Hollow diamond at whole | "has-a", part can exist independently |
69
84
  | Composition | Solid | Filled diamond at whole | Part cannot exist without whole |
70
85
  | Generalization | Solid | Hollow triangle at parent | True is-a; subtypes have different attributes/ops |
71
- | Dependency | Dashed | Open arrow | One class uses another transiently |
86
+ | Dependency | Dashed | Open arrow | **ONLY** when a class references an enumeration type as an attribute type. Never between two regular classes — use plain association instead |
87
+
88
+ > **CRITICAL — domain diagram association rules:**
89
+ > - Plain association (──): the default for every class-to-class relationship. No arrowheads.
90
+ > - Dependency (---→): only for enumeration usage. Never on class-to-class relationships.
91
+ > - Directed association (──→): never in a domain or analysis diagram.
72
92
 
73
93
  **Multiplicity at both ends of every association**: `1`, `0..1`, `1..*`, `0..*`. Never leave one end unlabeled.
74
94
 
@@ -76,18 +96,25 @@ Before drawing, extract:
76
96
 
77
97
  **Constraint notation**: if a business rule constrains an attribute or association, add `{constraint text}` near the relevant end.
78
98
 
79
- ### Mediating class (מחלקה מתווכת)
80
- When a many-to-many relationship needs to store data (e.g., enrollment has a grade), convert it to a mediating class:
81
- - Draw a regular class with a meaningful name
82
- - Connect it with two associations (each 1..* or 1 depending on the domain)
83
- - Add the data attributes to this class
84
- - This is different from an association class a mediating class is a full entity in the model
99
+ ### Mediating class vs Association class — never confuse these two
100
+
101
+ | | Mediating class (מחלקה מתווכת) | Association class (מחלקת קשר) |
102
+ |---|---|---|
103
+ | **When** | Intermediate entity has independent identity and lifecycle | Relationship itself has attributes; no independent lifecycle |
104
+ | **Drawing** | Regular class box with two **separate** associations replacing the many-to-many | Regular class box connected to the **midpoint** of an existing association line via a **dashed perpendicular line** |
105
+ | **The original association** | Is **removed** — the mediating class replaces it | **Remains** — the association class is attached to it |
106
+ | **Example** | `OrderLine` between `Order` and `Product` | `Enrollment` (grade) between `Student` and `Course` |
85
107
 
86
- ### Association class (מחלקת קשר)
87
- When a relationship itself has attributes but no independent identity:
88
- - Draw a regular class box with `«association class»` stereotype
89
- - Connect it to the **midpoint** of the association line with a **dashed line**, perpendicular where possible
90
- - Never connect it as a plain class with a dependency arrow — that is wrong UML
108
+ **Mediating class (מחלקה מתווכת):**
109
+ - Replaces a many-to-many association entirely
110
+ - Drawn as a normal class with two separate associations pointing to each side
111
+ - Has its own primary key, independent existence
112
+
113
+ **Association class (מחלקת קשר):**
114
+ - Does NOT replace the association — the association line stays
115
+ - Connected to the **midpoint** of that line with a dashed perpendicular line
116
+ - Stereotype: `«association class»`
117
+ - Never connect it with a dependency arrow — that is wrong UML
91
118
 
92
119
  ### Generalization (inheritance)
93
120
  Use only when subtypes have genuinely different attributes or operations:
@@ -114,6 +141,8 @@ MARGIN_LEFT = 60, MARGIN_TOP = 80
114
141
  ```
115
142
  Enumerations share the grid with the classes that reference them (not in a separate zone).
116
143
 
144
+ The resulting `x, y` values for each class go directly into the `CLASSES` data array (§9b). Use `boxW ≈ max(120, longestString * 6.5 + 16)` to estimate widths for routing; the runtime recomputes actual widths from text content.
145
+
117
146
  ### Step 3 — Post-placement safety pass with `collision-nudge`
118
147
  Run `collision-nudge` after step 2. After nudging, recompute all relationship endpoints using `rect-boundary-intersect`.
119
148
 
@@ -167,6 +196,12 @@ Hollow diamond: fill white, stroke #334155
167
196
  Font: 'IBM Plex Mono', monospace (Google Fonts import)
168
197
  ```
169
198
 
199
+ ### SVG rendering rules (enforced by §9d engine)
200
+
201
+ - `<html>` must **not** have `dir="rtl"` — SVG coordinates are always LTR.
202
+ - Class name: `text-anchor="middle"`. Attribute and operation rows: `text-anchor="start"` at `x = box.x + 6`. Never use `text-anchor="middle"` for attribute/op rows.
203
+ - Box heights are computed by `boxH()` in the §9d engine — never hardcode them.
204
+
170
205
  ---
171
206
 
172
207
  ## 7. Legend Bar
@@ -187,45 +222,232 @@ Define this JavaScript variable in every generated HTML `<script>` block, follow
187
222
 
188
223
  ## 9. HTML File Structure
189
224
 
225
+ The file uses **JS-driven rendering**: Claude fills in `CLASSES` + `RELS` data; the engine builds the SVG at page load. Do not write raw SVG with class boxes — use the pattern below.
226
+
227
+ ### 9a. Page skeleton
228
+
190
229
  ```html
191
230
  <!DOCTYPE html>
192
- <html lang="he" dir="rtl">
231
+ <html lang="he">
193
232
  <head>
194
233
  <meta charset="UTF-8">
195
234
  <title>[Diagram title]</title>
196
235
  <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;700&display=swap" rel="stylesheet">
197
- <style>/* tabs, layout, export button, dot-grid */</style>
236
+ <style>
237
+ body { margin: 0; background: #f1f5f9; font-family: 'IBM Plex Mono', monospace; }
238
+ .tab-bar { display: flex; gap: 4px; padding: 8px; background: #e2e8f0; }
239
+ .tab-btn { padding: 4px 12px; border: 1px solid #cbd5e1; background: white; cursor: pointer; border-radius: 4px; }
240
+ .tab-btn.active { background: #1e40af; color: white; }
241
+ .split-rationale { font-size: 11px; color: #64748b; padding: 4px 8px; }
242
+ svg { display: block; }
243
+ /* VP export button styles from export-buttons.md */
244
+ </style>
198
245
  </head>
199
246
  <body>
200
- <!-- Tab bar (only when split) -->
201
- <div class="tab-bar">...</div>
202
-
203
- <!-- One SVG per part, shown/hidden by tab selection -->
204
- <div class="diagram-part" id="part-1">
205
- <div class="split-rationale">Split by package: ...</div>
206
- <svg>
207
- <!-- dot-grid background, classes, enumerations, edges, legend -->
208
- </svg>
209
- </div>
210
-
211
- <!-- Visual Paradigm export button (from export-buttons.md) -->
247
+ <!-- Tab bar — include only when split (§5) -->
248
+ <div class="tab-bar" id="tab-bar"></div>
249
+
250
+ <!-- SVG target; JS populates it -->
251
+ <svg id="diagram-svg" xmlns="http://www.w3.org/2000/svg"></svg>
252
+
253
+ <!-- VP export button (from export-buttons.md) -->
212
254
  <button class="vp-export-btn" onclick="exportXMI()">Download .xmi (Visual Paradigm)</button>
213
255
 
214
256
  <script>
215
- const diagramModel = { ... }; // model-shape.md for kind:'class'
216
- function xmiBody(elements, rels) { ... } // from export-buttons.md
217
- function exportXMI() { ... } // from export-buttons.md
218
- // xmlEscape, downloadFile helpers // from export-buttons.md
219
- // Tab switching
220
- function showPart(id) {
221
- document.querySelectorAll('.diagram-part').forEach(p => p.hidden = true);
222
- document.getElementById(id).hidden = false;
223
- }
257
+ /* 1. DATA Claude fills in CLASSES and RELS (§9b, §9c) */
258
+ /* 2. ENGINE copy §9d verbatim */
259
+ /* 3. VP EXPORT from export-buttons.md */
260
+ /* 4. diagramModel from model-shape.md for kind:'class'*/
261
+ document.addEventListener('DOMContentLoaded', render);
224
262
  </script>
225
263
  </body>
226
264
  </html>
227
265
  ```
228
266
 
267
+ ### 9b. CLASSES data (Claude generates this)
268
+
269
+ ```javascript
270
+ const CLASSES = [
271
+ {
272
+ id: 'Order', // unique key — referenced by RELS
273
+ name: 'Order', // display name
274
+ stereotype: null, // null | '«enumeration»' | '«abstract»' | custom string
275
+ isEnum: false, // true → enum styling (gray dashed border + header)
276
+ isAbstract: false, // true → name in italic
277
+ attrs: [ // attribute strings, as they appear in the box
278
+ '+ orderId : String',
279
+ '+ date : Date',
280
+ '+ status : OrderStatus'
281
+ ],
282
+ ops: [ // operation strings — rendered italic
283
+ '+ calculateTotal() : Money'
284
+ ],
285
+ x: 60, // top-left x from layout algorithm (§4)
286
+ y: 80 // top-left y from layout algorithm (§4)
287
+ }
288
+ // ... more entries
289
+ ];
290
+ ```
291
+
292
+ ### 9c. RELS data (Claude generates this)
293
+
294
+ ```javascript
295
+ const RELS = [
296
+ {
297
+ type: 'assoc', // 'assoc' | 'agg' | 'comp' | 'gen' | 'dep'
298
+ from: 'Order', // class id
299
+ to: 'Customer',
300
+ fromMult: '0..*', // null for generalization
301
+ toMult: '1',
302
+ fromRole: null, // null or role name string
303
+ toRole: null,
304
+ label: null, // optional mid-line label
305
+ points: [ // orthogonal polyline — Claude computes from layout positions
306
+ [200, 130], [200, 200], [340, 200]
307
+ ]
308
+ }
309
+ ];
310
+ ```
311
+
312
+ **Computing `points`:** estimate `boxW ≈ max(120, longestString * 6.5 + 16)` for routing. All consecutive pairs must share x or y (orthogonal). Start/end at the nearest box edge.
313
+
314
+ ### 9d. Rendering engine (copy verbatim into `<script>`)
315
+
316
+ ```javascript
317
+ // ── Constants ─────────────────────────────────────────────────────────────
318
+ const HDR_H = 28, PAD = 6, LINE_H = 14, DIV_H = 1;
319
+ const FM = 'font-family="IBM Plex Mono, monospace"';
320
+
321
+ function escXml(s) {
322
+ return String(s)
323
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;')
324
+ .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
325
+ }
326
+
327
+ function boxH(cls) {
328
+ const hdrH = cls.stereotype ? 38 : HDR_H;
329
+ return hdrH + PAD + cls.attrs.length * LINE_H +
330
+ (cls.ops.length > 0 ? DIV_H + cls.ops.length * LINE_H : 0) + PAD;
331
+ }
332
+
333
+ function boxW(cls) {
334
+ const longest = [...cls.attrs, ...cls.ops, cls.name]
335
+ .reduce((m, s) => Math.max(m, s.length), 0);
336
+ return Math.max(120, Math.ceil(longest * 6.5) + 16);
337
+ }
338
+
339
+ function svgBox(cls) {
340
+ const w = boxW(cls), h = boxH(cls), { x, y } = cls;
341
+ const hdrH = cls.stereotype ? 38 : HDR_H;
342
+ const border = cls.isEnum ? '#94a3b8' : '#3b82f6';
343
+ const hdrFill = cls.isEnum ? '#475569' : '#1e40af';
344
+ const bodyFill = cls.isEnum ? '#f8fafc' : 'white';
345
+ const dash = cls.isEnum ? 'stroke-dasharray="5 3"' : '';
346
+
347
+ let s = `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${bodyFill}" stroke="${border}" stroke-width="2" ${dash} rx="2"/>`;
348
+ s += `<rect x="${x}" y="${y}" width="${w}" height="${hdrH}" fill="${hdrFill}" rx="2"/>`;
349
+
350
+ if (cls.stereotype) {
351
+ s += `<text x="${x + w/2}" y="${y + 13}" text-anchor="middle" fill="white" font-size="9" ${FM}>${escXml(cls.stereotype)}</text>`;
352
+ }
353
+ const nameStyle = cls.isAbstract ? 'font-style:italic' : '';
354
+ const nameY = cls.stereotype ? y + hdrH - 8 : y + hdrH / 2 + 4;
355
+ s += `<text x="${x + w/2}" y="${nameY}" text-anchor="middle" fill="white" font-size="11" font-weight="700" ${FM} style="${nameStyle}">${escXml(cls.name)}</text>`;
356
+
357
+ const divY = y + hdrH;
358
+ s += `<line x1="${x}" y1="${divY}" x2="${x + w}" y2="${divY}" stroke="${border}" stroke-width="1"/>`;
359
+
360
+ let curY = divY + PAD + 10;
361
+ for (const a of cls.attrs) {
362
+ s += `<text x="${x + 6}" y="${curY}" text-anchor="start" fill="#1e293b" font-size="10.5" ${FM}>${escXml(a)}</text>`;
363
+ curY += LINE_H;
364
+ }
365
+ if (cls.ops.length > 0) {
366
+ const opDivY = divY + PAD + cls.attrs.length * LINE_H;
367
+ s += `<line x1="${x}" y1="${opDivY}" x2="${x + w}" y2="${opDivY}" stroke="${border}" stroke-width="1"/>`;
368
+ curY = opDivY + PAD + 10;
369
+ for (const o of cls.ops) {
370
+ s += `<text x="${x + 6}" y="${curY}" text-anchor="start" fill="#2563eb" font-size="10.5" font-style="italic" ${FM}>${escXml(o)}</text>`;
371
+ curY += LINE_H;
372
+ }
373
+ }
374
+ return s;
375
+ }
376
+
377
+ function svgRel(rel) {
378
+ const pts = rel.points.map(([px, py]) => `${px},${py}`).join(' ');
379
+ let mS = '', mE = '', dash = '';
380
+ if (rel.type === 'comp') mS = 'marker-start="url(#compFill)"';
381
+ if (rel.type === 'agg') mS = 'marker-start="url(#aggHollow)"';
382
+ if (rel.type === 'gen') mE = 'marker-end="url(#genHollow)"';
383
+ if (rel.type === 'dep') { mE = 'marker-end="url(#arrOpen)"'; dash = 'stroke-dasharray="6 3"'; }
384
+ // 'assoc': no markers — plain line
385
+
386
+ let s = `<polyline points="${pts}" fill="none" stroke="#334155" stroke-width="1.8" ${dash} ${mS} ${mE}/>`;
387
+ if (rel.fromMult) {
388
+ const [fx, fy] = rel.points[0];
389
+ s += `<text x="${fx + 4}" y="${fy - 4}" fill="#64748b" font-size="9.5" ${FM}>${escXml(rel.fromMult)}</text>`;
390
+ }
391
+ if (rel.toMult) {
392
+ const [tx, ty] = rel.points[rel.points.length - 1];
393
+ s += `<text x="${tx + 4}" y="${ty - 4}" fill="#64748b" font-size="9.5" ${FM}>${escXml(rel.toMult)}</text>`;
394
+ }
395
+ if (rel.label) {
396
+ const mid = rel.points[Math.floor(rel.points.length / 2)];
397
+ s += `<text x="${mid[0]}" y="${mid[1] - 6}" text-anchor="middle" fill="#64748b" font-size="9.5" ${FM}>${escXml(rel.label)}</text>`;
398
+ }
399
+ return s;
400
+ }
401
+
402
+ function render() {
403
+ let maxX = 600, maxY = 400;
404
+ for (const cls of CLASSES) {
405
+ maxX = Math.max(maxX, cls.x + boxW(cls) + 60);
406
+ maxY = Math.max(maxY, cls.y + boxH(cls) + 80);
407
+ }
408
+
409
+ const defs = `<defs>
410
+ <pattern id="dotgrid" width="24" height="24" patternUnits="userSpaceOnUse">
411
+ <circle cx="12" cy="12" r="0.8" fill="#cbd5e1"/>
412
+ </pattern>
413
+ <marker id="genHollow" markerWidth="12" markerHeight="10" refX="10" refY="5" orient="auto">
414
+ <polygon points="0,0 10,5 0,10" fill="white" stroke="#334155" stroke-width="1.5"/>
415
+ </marker>
416
+ <marker id="arrOpen" markerWidth="10" markerHeight="8" refX="9" refY="4" orient="auto">
417
+ <polyline points="0,0 9,4 0,8" fill="none" stroke="#334155" stroke-width="1.5"/>
418
+ </marker>
419
+ <marker id="compFill" markerWidth="14" markerHeight="10" refX="1" refY="5" orient="auto">
420
+ <polygon points="0,5 7,0 14,5 7,10" fill="#1e40af" stroke="#1e40af"/>
421
+ </marker>
422
+ <marker id="aggHollow" markerWidth="14" markerHeight="10" refX="1" refY="5" orient="auto">
423
+ <polygon points="0,5 7,0 14,5 7,10" fill="white" stroke="#334155"/>
424
+ </marker>
425
+ </defs>`;
426
+
427
+ // totalWidth / totalHeight include margins and space for the legend.
428
+ // RIGHT_MARGIN ≥ 40px, BOTTOM_MARGIN ≥ 70px (legend needs ~50px).
429
+ // These are already baked into maxX / maxY from the loop above (60px / 80px).
430
+ const totalWidth = maxX;
431
+ const totalHeight = maxY; // legend drawn at totalHeight - 60, inside this height
432
+
433
+ const bg = `<rect width="${totalWidth}" height="${totalHeight}" fill="url(#dotgrid)"/>`;
434
+ const rels = RELS.map(svgRel).join('');
435
+ const boxes = CLASSES.map(svgBox).join('');
436
+ // Legend is placed at totalHeight - 60 so it always lands inside the viewBox.
437
+ const legend = `<text x="16" y="${totalHeight - 60}" fill="#64748b" font-size="9" ${FM}>── Association ◇── Aggregation ◆── Composition ──▷ Generalization --→ Dependency (enum only)</text>`;
438
+
439
+ const svg = document.getElementById('diagram-svg');
440
+ svg.setAttribute('width', totalWidth);
441
+ svg.setAttribute('height', totalHeight);
442
+ svg.setAttribute('viewBox', `0 0 ${totalWidth} ${totalHeight}`);
443
+ svg.innerHTML = defs + bg + rels + boxes + legend;
444
+ }
445
+ ```
446
+
447
+ ### 9e. Multi-part (tabbed) diagrams
448
+
449
+ When splitting (§5), generate one `<svg id="svg-part-N">` per part. Split `CLASSES` and `RELS` into per-part arrays. Call `render()` variants that target each SVG element. Tab buttons toggle `hidden` and call the matching render function.
450
+
229
451
  ---
230
452
 
231
453
  ## 10. Output
@@ -248,14 +470,21 @@ When in doubt, use the male form.
248
470
 
249
471
  ## 12. Common Mistakes — Check Before Delivering
250
472
 
473
+ - [ ] Every class passes the persistence test: "Does the system store and manage records of this entity?" — computed outputs (reports, receipts) and real-world participants the system never tracks are not domain entities
251
474
  - [ ] Every association has multiplicity at **both** ends
252
475
  - [ ] All lines are orthogonal — run the x1===x2 || y1===y2 check on every polyline
253
476
  - [ ] No line segment passes through any class bounding box — use `rect-boundary-intersect` for all endpoints
254
- - [ ] Association class connected to midpoint of association via dashed line (not as a standalone class)
255
- - [ ] Many-to-many with data mediating class (not association class)
477
+ - [ ] Association class (מחלקת קשר): connected to the **midpoint** of the original association line via a dashed perpendicular line; the original association remains
478
+ - [ ] Mediating class (מחלקה מתווכת): **replaces** the many-to-many with two separate associations; drawn as a plain class, NOT connected to a line midpoint
256
479
  - [ ] Enumerations placed near using class (not in a fixed zone)
257
480
  - [ ] diagramModel defined in `<script>` and matches model-shape.md
258
481
  - [ ] VP export button present and wired to `exportXMI()`
259
482
  - [ ] If split: each part has a visible split-rationale line
260
483
  - [ ] Operations text is italic
261
484
  - [ ] Hebrew names use male form
485
+ - [ ] **No raw SVG class boxes** — class boxes are rendered by `svgBox()` from `CLASSES` data, not hand-written SVG
486
+ - [ ] **No arrowheads on plain associations** — `type: 'assoc'` in RELS produces no marker; directed association never appears in domain/analysis diagrams
487
+ - [ ] **Dependency (---→) only for enum usage** — `type: 'dep'` only when a class references an enum type; never between two regular classes
488
+ - [ ] **`<html>` has no `dir="rtl"`** — SVG coordinates are LTR; page language does not affect SVG rendering
489
+ - [ ] **`boxH()` / `boxW()` from §9d engine used** — never hardcode box dimensions in data or SVG
490
+ - [ ] **Attribute/operation rows: `text-anchor="start"` at `x = box.x + 6`** — enforced by engine; do not override