tabtree-mcp 1.1.0 → 1.2.1

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.
Files changed (3) hide show
  1. package/README.md +24 -12
  2. package/package.json +2 -2
  3. package/tabtree-mcp.mjs +168 -38
package/README.md CHANGED
@@ -7,17 +7,22 @@ Let Claude read your TabTree library and build new maps for you.
7
7
  > "Draw our database schema as a board."
8
8
  > "Add the risks we just discussed to my Q3 plan."
9
9
 
10
- The map appears as a file in your backup folder. You pull it into TabTree from
11
- *My maps* → **🛟** → **"Bring maps back from this folder…"**.
10
+ **You need TabTree itself.** This package is the bridge, not the app. It works
11
+ with both editions of TabTree, and you pick the mode with ONE environment variable:
12
12
 
13
- The connector **only ever creates new files.** It cannot overwrite a map, cannot
14
- delete one, and never touches your browser storage. There is no account, no
15
- server, no upload: it runs on your machine and reads one folder you choose.
13
+ | Edition | What Claude talks to | Set |
14
+ | --- | --- | --- |
15
+ | **TabTree** (subscription — your maps live in your account) | your account, through TabTree's API | `TABTREE_API_KEY` — the key from ⚙️ Settings → Account → **Claude connector** |
16
+ | **TabTree Classic** (the local file, bought once) | the 🛟 backup folder on your disk | `TABTREE_DIR` — that folder's path |
16
17
 
17
- **You need TabTree itself.** This package is the bridge, not the app: it reads
18
- and writes map files in one folder, and TabTree is what opens them. TabTree is a
19
- single local HTML file you buy once and keep for life — no subscription, no
20
- account, works offline. See [tabtree.app](https://tabtree.app/).
18
+ In account mode a new map appears in TabTree within a minute, on every device you
19
+ are signed in on. In Classic mode it appears as a file in your backup folder, and
20
+ you pull it in from *My maps* → **🛟** → **"Bring maps back from this folder…"**.
21
+
22
+ In both modes the connector **only ever creates new maps.** It cannot overwrite a
23
+ map, cannot delete one, and never touches your browser storage. With a folder,
24
+ nothing is uploaded anywhere; with an account, it talks only to TabTree's own
25
+ API for that account.
21
26
 
22
27
  Changes to a map you already have are no exception. Claude never writes to it:
23
28
  it files a **proposal**, TabTree shows it as a banner on that map, and you tick
@@ -27,9 +32,16 @@ the changes you want. ⌘Z undoes them afterwards, like anything else you do.
27
32
 
28
33
  ## Before you start
29
34
 
30
- **1. TabTree needs a backup folder.** Open TabTree → *My maps* → 🛟 and pick a
31
- folder. That folder is what the connector reads and writes. If you have never
32
- set one up, do that first — the connector has nothing to talk to otherwise.
35
+ **1. Get your key, or your folder.** TabTree (subscription): open the app →
36
+ ⚙️ Settings → Account → **Claude connector** → *Create a connector key*. It is
37
+ shown once — copy it. TabTree Classic: open TabTree → *My maps* → 🛟 and pick a
38
+ folder; that folder is what the connector reads and writes.
39
+
40
+ Quickest test, account mode:
41
+
42
+ ```bash
43
+ TABTREE_API_KEY=tt_live_… npx tabtree-mcp
44
+ ```
33
45
 
34
46
  **2. Node.js 18+ — for the npm and Claude Code routes.** Claude Desktop ships
35
47
  its own Node, so the `.mcpb` install below needs nothing. For the other routes,
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "tabtree-mcp",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "mcpName": "app.tabtree/tabtree",
5
- "description": "Let Claude read your TabTree library and turn conversations into mind maps and whiteboards. Local files, no account, nothing uploaded.",
5
+ "description": "Let Claude read your TabTree library and turn conversations into mind maps and whiteboards — in your TabTree account, or from local files.",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/ai4xedu/tabtree-mcp.git"
package/tabtree-mcp.mjs CHANGED
@@ -22,6 +22,44 @@ import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, mkdirSy
22
22
  import { join, basename } from "node:path";
23
23
 
24
24
  const DIR = process.env.TABTREE_DIR || process.env.YGMIND_DIR || "";
25
+ // ---------------------------------------------------------------------------
26
+ // DEUX SOURCES, UN CONNECTEUR (2026-09-08). Le dossier 🛟 sert TabTree Classic (le fichier
27
+ // local) ; le CLOUD sert l'abonné TabTree SaaS. La source est choisie par la présence d'une
28
+ // clé personnelle `TABTREE_API_KEY` (fabriquée dans ⚙️ Account → Claude connector) : les six
29
+ // outils ne changent pas d'un mot de contrat, seule la tuyauterie de lecture/écriture change.
30
+ // `TABTREE_API_URL` n'est là que pour une préversion — la valeur par défaut est la production.
31
+ // ---------------------------------------------------------------------------
32
+ let API_KEY = String(process.env.TABTREE_API_KEY || "").trim();
33
+ // ⚠️ UN CHAMP NON RENSEIGNÉ N'EST PAS UNE CLÉ FAUSSE (2026-09-09). Claude Desktop passe le
34
+ // gabarit `${user_config.api_key}` TEL QUEL quand le champ est vide — mesuré le jour où la
35
+ // réinstallation du .mcpb a vidé la configuration (la clé est `sensitive`, donc rangée dans le
36
+ // Trousseau et liée à l'installation précédente). `CLOUD = !!API_KEY` prenait ce littéral pour
37
+ // une clé, l'envoyait, et le serveur répondait « Unknown or revoked connector key » : un
38
+ // message FAUX, qui envoie chercher une clé révoquée là où il n'y a simplement rien de saisi.
39
+ if(/^\$\{.*\}$/.test(API_KEY)) API_KEY = "";
40
+ const API_URL = String(process.env.TABTREE_API_URL || "https://aynmiptyvisxoslzkzxn.supabase.co/functions/v1/mcp").trim();
41
+ const CLOUD = !!API_KEY;
42
+ // La FORME d'une clé se vérifie ici, avant tout aller-retour : l'app en fabrique une en
43
+ // `tt_live_` + 43 caractères base64url (`connKeyMaterial`). Une valeur qui n'y ressemble pas est
44
+ // une faute de copie ou un champ mal rempli — le dire tout de suite nomme la bonne cause, et
45
+ // évite d'aller faire dire au serveur quelque chose qu'il ne sait pas.
46
+ const API_KEY_OK = /^tt_live_[A-Za-z0-9_-]{20,}$/.test(API_KEY);
47
+ async function cloudCall(action, body){
48
+ if(!API_KEY_OK) throw new Error(
49
+ "TABTREE_API_KEY does not look like a connector key — it must start with \"tt_live_\". "
50
+ + "If you just reinstalled the connector, its settings were reset: open TabTree → \u2699\ufe0f Settings "
51
+ + "\u2192 Account \u2192 Claude connector, create a key, paste it into the connector's settings, "
52
+ + "then restart the connector.");
53
+ let r;
54
+ try{
55
+ r = await fetch(API_URL, { method:"POST",
56
+ headers:{ "content-type":"application/json", "x-tabtree-key": API_KEY },
57
+ body: JSON.stringify(Object.assign({ action }, body || {})) });
58
+ }catch(e){ throw new Error("Could not reach the TabTree cloud (" + (e && e.message || e) + ")."); }
59
+ let j = null; try{ j = await r.json(); }catch(e){}
60
+ if(!r.ok || !j || j.ok === false) throw new Error((j && j.error) || ("The TabTree cloud answered HTTP " + r.status + "."));
61
+ return j;
62
+ }
25
63
  const MANIFEST = "bibliotheque.json";
26
64
  // Boîte aux lettres des propositions. Le connecteur reste EN AJOUT SEUL : il ne modifie
27
65
  // jamais un .tabtree, il dépose ici un fichier que l'app lit, montre, et n'applique que si
@@ -33,7 +71,8 @@ const PROP_DIR = "Propositions";
33
71
  // Utilitaires
34
72
  // ---------------------------------------------------------------------------
35
73
  function dirOk(){
36
- if(!DIR) throw new Error("TABTREE_DIR is not set. Point it at your TabTree backup folder — the one you picked with 🛟 on the My maps screen. (The older name YGMIND_DIR still works.)");
74
+ if(CLOUD) return; // la source est le compte, pas un dossier
75
+ if(!DIR) throw new Error("The connector has no source. For TabTree (subscription), set TABTREE_API_KEY to the connector key from ⚙️ Settings → Account → Claude connector. For TabTree Classic (the local file), set TABTREE_DIR to your 🛟 backup folder. (The older name YGMIND_DIR still works.)");
37
76
  if(!existsSync(DIR)) throw new Error("The TABTREE_DIR folder does not exist: " + DIR);
38
77
  }
39
78
  // Même règle de nommage que l'app (bkSafeName) : la restauration retire le
@@ -116,7 +155,9 @@ const ART_IDS_MCP = [
116
155
  "laptop", "phone", "mail", "folder", "book", "chart",
117
156
  "money", "calendar", "building", "car", "globe", "rocket",
118
157
  "house", "pin", "plane", "coffee", "plant", "sun",
119
- "camera", "music", "lock", "key", "health", "gift"
158
+ "camera", "music", "lock", "key", "health", "gift",
159
+ "manager", "client", "expert", "assistant", "coach", "learner",
160
+ "developer", "seller", "support", "speaker", "freelancer", "group"
120
161
  ];
121
162
  const ART = Object.fromEntries(ART_IDS_MCP.map(id=>[id, true]));
122
163
  const EMO_PIC_PAT = "\\p{Extended_Pictographic}(?:\\uFE0F|\\uFE0E)?(?:\\u200D\\p{Extended_Pictographic}(?:\\uFE0F|\\uFE0E)?)*";
@@ -267,7 +308,11 @@ function buildBoardDoc(name, elements, connections, style){
267
308
  a0: (e.kind==="arc" && Number.isFinite(+e.a0)) ? +e.a0 : null,
268
309
  a1: (e.kind==="arc" && Number.isFinite(+e.a1)) ? +e.a1 : null,
269
310
  ir: (e.kind==="arc" && Number.isFinite(+e.ir)) ? +e.ir : null,
270
- size: (+e.size>=9 && +e.size<=72) ? Math.round(+e.size) : null };
311
+ size: (+e.size>=9 && +e.size<=72) ? Math.round(+e.size) : null,
312
+ // L'image sans octets sur un élément de board (2026-09-03) : même frontière que
313
+ // pour un nœud — un id inventé retombe sur « aucune image », jamais sur du balisage.
314
+ // Pas sur un secteur, un gradin ou un chevron : leur silhouette n'a pas de « haut ».
315
+ pic: (e.pic != null && !["arc","trap","chev"].includes(kind)) ? okPic(String(e.pic)) : "" };
271
316
  });
272
317
  const byId = new Map();
273
318
  for(const e of els){
@@ -332,10 +377,11 @@ function buildBoardDoc(name, elements, connections, style){
332
377
  for(const e of els){
333
378
  if(e.kind==="sticky"){
334
379
  refOf.set(e.lid, "st:" + stickies.length);
335
- stickies.push({ text:e.text, x:e.x, y:e.y, w:e.w, h:e.h, color:e.color, size:e.size||undefined });
380
+ stickies.push({ text:e.text, x:e.x, y:e.y, w:e.w, h:e.h, color:e.color, size:e.size||undefined, pic:e.pic||undefined });
336
381
  } else {
337
382
  refOf.set(e.lid, "sh:" + shapes.length);
338
383
  shapes.push({ kind:e.kind, x:e.x, y:e.y, w:e.w, h:e.h, color:e.kind==="text" ? "#ffffff" : e.color, text:e.text, size:e.size||undefined,
384
+ pic: e.pic||undefined,
339
385
  tk: e.tk != null ? e.tk : undefined,
340
386
  a0: e.a0 != null ? e.a0 : undefined,
341
387
  a1: e.a1 != null ? e.a1 : undefined,
@@ -452,6 +498,36 @@ function scanLibrary(){
452
498
  out.sort((a,b)=>b.mtime.localeCompare(a.mtime));
453
499
  return out;
454
500
  }
501
+ // La bibliothèque du COMPTE, présentée exactement comme celle du dossier : mêmes champs, donc
502
+ // les six outils n'ont rien à savoir de la source. `file` porte la clé de la carte (map_key),
503
+ // que `propose_changes` renvoie telle quelle — pas de suffixe « __id » à analyser ici.
504
+ async function cloudLibrary(){
505
+ const j = await cloudCall("docs", {});
506
+ const folders = new Map((j.folders||[]).filter(f=>f && f.folder_key).map(f=>[f.folder_key, f]));
507
+ const pathOf = (k)=>{
508
+ const out = [], seen = new Set();
509
+ let f = folders.get(k);
510
+ while(f && !seen.has(f.folder_key) && out.length <= 16){ seen.add(f.folder_key); out.unshift(f.name); f = f.parent_key ? folders.get(f.parent_key) : null; }
511
+ return out.length ? out.join(" / ") : null;
512
+ };
513
+ const out = [];
514
+ for(const r of (j.maps||[])){
515
+ const doc = r && r.doc;
516
+ if(!doc || !doc.root) continue;
517
+ out.push({
518
+ file: r.map_key,
519
+ name: String(r.name || "") || String(doc.root.text || "").slice(0, 60) || r.map_key,
520
+ folder: pathOf(r.folder_key),
521
+ kind: doc.board ? "board" : "map",
522
+ nodes: countNodes(doc.root),
523
+ elements: doc.board ? ((doc.shapes||[]).length + (doc.stickies||[]).length + (doc.arrows||[]).length + (doc.draws||[]).length) : undefined,
524
+ mtime: String(r.updated_at || "").slice(0,16).replace("T"," "),
525
+ doc
526
+ });
527
+ }
528
+ return out;
529
+ }
530
+ async function loadLibrary(){ return CLOUD ? cloudLibrary() : scanLibrary(); }
455
531
  function collectTexts(doc){
456
532
  const texts = [];
457
533
  (function walk(n){ if(n.text) texts.push(n.text); if(n.note) texts.push(n.note); (n.children||[]).forEach(walk); })(doc.root);
@@ -486,9 +562,9 @@ function boardToText(doc){
486
562
  if((doc.draws||[]).length) lines.push(`Pencil strokes: ${doc.draws.length}`);
487
563
  return lines.join("\n");
488
564
  }
489
- function findMap(file){
565
+ async function findMap(file){
490
566
  checkRelPath(file);
491
- const all = scanLibrary();
567
+ const all = await loadLibrary();
492
568
  const hit = all.find(m=>m.file===file) || all.find(m=>m.file.endsWith(file)) || all.find(m=>m.name===file);
493
569
  if(!hit) throw new Error("No such map: " + file + ". Use list_maps to see what is available.");
494
570
  return hit;
@@ -497,7 +573,20 @@ function findMap(file){
497
573
  // ---------------------------------------------------------------------------
498
574
  // Écriture (toujours un NOUVEAU fichier — jamais de modification en place)
499
575
  // ---------------------------------------------------------------------------
500
- function writeDoc(name, doc, folder){
576
+ async function writeDoc(name, doc, folder){
577
+ if(CLOUD){
578
+ // Le dossier est un NOM de dossier de bibliothèque : on le résout en clé s'il existe déjà,
579
+ // sinon la carte arrive à la racine et le texte de réponse le dit (« no silent caps »).
580
+ let folder_key = null;
581
+ if(folder){
582
+ const j = await cloudCall("list", {});
583
+ const hit = (j.folders||[]).find(f=>f && String(f.name||"").trim().toLowerCase() === String(folder).trim().toLowerCase());
584
+ if(hit) folder_key = hit.folder_key;
585
+ }
586
+ const kind = doc.board ? "board" : (doc.kanban ? "kanban" : "map");
587
+ const r = await cloudCall("create", { name: String(name||"").slice(0,200), kind, doc, folder_key });
588
+ return "cloud:" + r.map_key + (folder && !folder_key ? " (folder “" + folder + "” does not exist in this account — the map is at the root)" : "");
589
+ }
501
590
  dirOk();
502
591
  let target = DIR;
503
592
  if(folder){
@@ -527,7 +616,7 @@ function countForest(roots){ return roots.reduce((n,r)=>n + countNodes(r), 0); }
527
616
  // connecteur la refuserait à l'écriture, ou l'app la jetterait à la lecture — dans les deux
528
617
  // cas Claude croirait avoir proposé quelque chose qui n'arrive jamais chez l'utilisateur.
529
618
  // C'est le défaut du losange, rejoué. Un test compare les deux listes en ensembles.
530
- const PROP_OPS = ["add","rename","note","check","delete","move","pic"];
619
+ const PROP_OPS = ["add","rename","note","check","delete","move","pic","persona"];
531
620
 
532
621
  // Tout est vérifié ICI, contre la carte réelle, et un défaut fait ÉCHOUER l'appel. C'est
533
622
  // délibéré : une cible introuvable remonte à Claude, qui peut relire la carte et corriger,
@@ -541,6 +630,24 @@ function buildProposalOps(mapDoc, changes){
541
630
  const where = "change #" + (i+1);
542
631
  const op = String((c && c.op) || "").trim();
543
632
  if(!PROP_OPS.includes(op)) throw new Error(`${where}: unknown op "${op}". Use one of: ${PROP_OPS.join(", ")}.`);
633
+ if(op === "persona"){
634
+ // `persona` (2026-09-08) vise le DOCUMENT entier — la toile d'une work map — jamais un
635
+ // nœud : donc pas de `target`. C'est le relais skill → carte sans copier-coller : le skill
636
+ // « ma cartographie du travail » dépose ici l'objet de l'entretien, l'app propose de
637
+ // l'appliquer d'un clic. Contrôle SUPERFICIEL, et c'est une décision : `okPersona`, la
638
+ // frontière de confiance de l'app (soixante lignes de bornes), juge à l'application et
639
+ // nomme son refus dans le panneau. La dupliquer ici serait une seconde règle à figer
640
+ // caractère par caractère comme `okPic`. La raison qui fait valider les autres ops en
641
+ // profondeur — « une proposition à moitié valide arrive en lignes barrées » — ne
642
+ // s'applique pas : une op persona est ATOMIQUE, elle rebâtit la toile ou ne fait rien.
643
+ const P = c.persona;
644
+ if(!P || typeof P !== "object" || !Array.isArray(P.modules) || !P.modules.length)
645
+ throw new Error(`${where}: \`persona\` must be the interview object — { who, role, mission, tools, meetings, opener, modules:[{ name, procs:[{ name, h, after, freq, rep, data, stakes, lever, step, why }] }], trajectory, signs, parking }.`);
646
+ const n = P.modules.reduce((a, m)=>a + ((m && Array.isArray(m.procs)) ? m.procs.length : 0), 0);
647
+ if(n < 3) throw new Error(`${where}: the persona has ${n} process(es) — a work map needs at least three. Finish the interview first.`);
648
+ if(JSON.stringify(P).length > 60000) throw new Error(`${where}: the persona is too large (60000 characters max).`);
649
+ return { op, persona: P, procs: n };
650
+ }
544
651
  const target = String(c.target == null ? "" : c.target);
545
652
  const r = propFind(mapDoc.root, target);
546
653
  if(r.error === "empty") throw new Error(`${where}: \`target\` is required — the node the change applies to.`);
@@ -595,17 +702,24 @@ function buildProposalOps(mapDoc, changes){
595
702
  }
596
703
  function parts_last(spec){ const p = String(spec||"").split(">"); return p[p.length-1].trim(); }
597
704
 
598
- function writeProposal(map, note, ops){
705
+ async function writeProposal(map, note, ops){
706
+ const payload = (id)=>({
707
+ v: 1, kind: "tabtree-proposal", mapId: id, mapFile: CLOUD ? map.file : basename(map.file), mapName: map.name,
708
+ createdAt: Date.now(), by: "Claude", note: String(note||"").slice(0, 2000), ops
709
+ });
710
+ if(CLOUD){
711
+ // La boîte aux lettres cloud : la table `proposals`, que l'app relit au sondage suivant.
712
+ // Même charge utile que le fichier — c'est le même `parseProposal` qui la lit côté app.
713
+ const r = await cloudCall("propose", { map_key: map.file, payload: payload(map.file) });
714
+ return "cloud:" + (r.id || "proposal");
715
+ }
599
716
  dirOk();
600
717
  const target = join(DIR, PROP_DIR);
601
718
  if(!existsSync(target)) mkdirSync(target);
602
719
  const id = mapIdOf(map.file);
603
720
  if(!id) throw new Error("This map's file has no id suffix (“…__map123.tabtree”), so the app cannot match a proposal to it. Open it once in TabTree with the backup folder turned on, then try again.");
604
721
  const file = "prop_" + id + "__" + Date.now().toString(36) + ".json";
605
- writeFileSync(join(target, file), JSON.stringify({
606
- v: 1, kind: "tabtree-proposal", mapId: id, mapFile: basename(map.file), mapName: map.name,
607
- createdAt: Date.now(), by: "Claude", note: String(note||"").slice(0, 2000), ops
608
- }, null, 2), "utf8");
722
+ writeFileSync(join(target, file), JSON.stringify(payload(id), null, 2), "utf8");
609
723
  return PROP_DIR + "/" + file;
610
724
  }
611
725
 
@@ -619,6 +733,8 @@ function writeProposal(map, note, ops){
619
733
  // Tout répond « ✅ » et rien n'apparaît. Donner le nom du dossier rend l'écart
620
734
  // visible en une seconde, sans que personne ait à soupçonner quoi que ce soit.
621
735
  function importHint(){
736
+ if(CLOUD) return "Saved to the user's TabTree account. It appears in TabTree within a minute (the app polls "
737
+ + "its account while open), or on the next sign-in on any device. Nothing is overwritten.";
622
738
  return "Written to the folder “" + basename(DIR) + "”.\n"
623
739
  + "To see it in TabTree: My maps → 🛟 → “Bring maps back from this folder…”. "
624
740
  + "(Or press ⌘K and type “bring my maps back”.) Nothing is overwritten.\n"
@@ -674,6 +790,7 @@ const TOOLS = [
674
790
  x:{ type:"number" }, y:{ type:"number" }, w:{ type:"number" }, h:{ type:"number" },
675
791
  color:{ type:"string", description:"Hex fill colour (shapes) or sticky-note colour" },
676
792
  size:{ type:"number", description:"Text size 9-72 (defaults to 15)" },
793
+ pic:{ type:"string", description:"Optional picture drawn INSIDE the element, no file needed — \"a:<id>\" for a built-in illustration (roles: manager, client, expert, assistant, coach, learner, developer, seller, support, speaker, freelancer, group — plus person, team, idea, target, rocket…) or \"e:<emoji>\". Not on arc/trap/chev." },
677
794
  tk:{ type:"number", description:"trap only — small-side/large-side ratio, 0..0.95 (0 = triangle); NEGATIVE puts the narrow side at the bottom (funnel tier)" },
678
795
  a0:{ type:"number", description:"arc only — start angle in degrees, 0 at 12 o'clock, clockwise" },
679
796
  a1:{ type:"number", description:"arc only — end angle in degrees (a1 > a0; a full circle is a0=0, a1=360)" },
@@ -696,34 +813,35 @@ const TOOLS = [
696
813
  },
697
814
  {
698
815
  name: "propose_changes",
699
- description: "Proposes changes to an EXISTING mind map: add branches, rename a node, write a note, tick a task, remove a branch, illustrate a node. Nothing is applied — the proposal appears in TabTree as a banner on that map, the user reviews it change by change and picks what to keep (and can undo with Cmd+Z afterwards). Name each target node by its exact text, or by a path \"Ancestor > Node\" when the same wording appears twice. Read the map first so the wording matches.",
816
+ description: "Proposes changes to an EXISTING mind map: add branches, rename a node, write a note, tick a task, remove a branch, illustrate a node — or, with op \"persona\", re-cast a WORK MAP canvas from a finished interview (the JSON block of the « ma cartographie du travail » skill). Nothing is applied — the proposal appears in TabTree as a banner on that map, the user reviews it change by change and picks what to keep (and can undo with Cmd+Z afterwards). Name each target node by its exact text, or by a path \"Ancestor > Node\" when the same wording appears twice. Read the map first so the wording matches.",
700
817
  inputSchema: { type:"object", properties:{
701
818
  file:{ type:"string", description:"The map to change — a file name (or map name) from list_maps" },
702
819
  note:{ type:"string", description:"One line telling the user what this proposal does and why. Shown above the changes." },
703
820
  changes:{ type:"array", description:"The proposed changes, applied in order", items:{ type:"object", properties:{
704
- op:{ type:"string", enum:["add","rename","note","check","delete","move","pic"], description:"add = new children under `target`; rename = change its text; note = set its note; check = tick/untick the task; delete = remove it and its children; move = send the card to a kanban column; pic = put an illustration on it" },
705
- target:{ type:"string", description:"Exact node text, or \"Ancestor > Node\" if ambiguous" },
821
+ op:{ type:"string", enum:["add","rename","note","check","delete","move","pic","persona"], description:"add = new children under `target`; rename = change its text; note = set its note; check = tick/untick the task; delete = remove it and its children; move = send the card to a kanban column; pic = put an illustration on it; persona = re-cast a work map canvas (a board whose name starts with “Work map —” / “Cartographie —”) from an interview — no target" },
822
+ target:{ type:"string", description:"Exact node text, or \"Ancestor > Node\" if ambiguous (not used by persona)" },
823
+ persona:{ type:"object", description:"persona: the interview object exactly as the skill returns it — who, role, mission, tools, meetings, opener, modules[{name, procs[{name,h,after,freq,rep,data,stakes,lever,step,why}]}], trajectory, signs, parking. TabTree rebuilds the canvas AND its companion sheet from it." },
706
824
  markdown:{ type:"string", description:"add: the new branch as a bullet list, 2 spaces per level" },
707
825
  text:{ type:"string", description:"rename: the new text" },
708
826
  note:{ type:"string", description:"note: the note body (empty string clears it)" },
709
827
  value:{ type:"boolean", description:"check: true to tick, false to untick" },
710
828
  col:{ type:"string", description:"move: the kanban column id (read_map shows them), or \"\" to take the card off the board" },
711
829
  pic:{ type:"string", description:"pic: \"a:<id>\" for a built-in illustration, or \"e:<emoji>\" for one large emoji, or \"\" to remove it. Ids: person, team, chat, idea, target, trophy, star, heart, warning, done, flag, clock, laptop, phone, mail, folder, book, chart, money, calendar, building, car, globe, rocket, house, pin, plane, coffee, plant, sun, camera, music, lock, key, health, gift." }
712
- }, required:["op","target"], additionalProperties:false } }
830
+ }, required:["op"], additionalProperties:false } }
713
831
  }, required:["file","changes"], additionalProperties:false }
714
832
  }
715
833
  ];
716
834
 
717
835
  const HANDLERS = {
718
- list_maps(){
719
- const maps = scanLibrary();
720
- if(!maps.length) return "The library is empty (or TABTREE_DIR points at the wrong folder: " + DIR + ").";
836
+ async list_maps(){
837
+ const maps = await loadLibrary();
838
+ if(!maps.length) return CLOUD ? "This TabTree account has no maps yet." : "The library is empty (or TABTREE_DIR points at the wrong folder: " + DIR + ").";
721
839
  return maps.map(m=>
722
840
  `• ${m.name} [${m.kind}]${m.folder ? " 📁 " + m.folder : ""}\n file: ${m.file}\n ${m.kind==="board" ? m.elements + " elements" : m.nodes + " nodes"} · modified ${m.mtime}`
723
841
  ).join("\n");
724
842
  },
725
- read_map(args){
726
- const m = findMap(args.file);
843
+ async read_map(args){
844
+ const m = await findMap(args.file);
727
845
  const head = `${m.name} [${m.kind}] — file: ${m.file}\n`;
728
846
  if(m.kind === "board") return head + boardToText(m.doc);
729
847
  // Le rappel sur propose_changes vit ici et pas dans la description de l'outil : c'est au
@@ -731,11 +849,11 @@ const HANDLERS = {
731
849
  return head + nodeToMarkdown(m.doc.root, 0)
732
850
  + "\n\n(To change this map, use propose_changes with `file: \"" + m.file + "\"`. Name a node by the exact text above, or \"Ancestor > Node\" when it appears twice. Nothing is applied until the user approves it in TabTree.)";
733
851
  },
734
- search_maps(args){
852
+ async search_maps(args){
735
853
  const q = String(args.query||"").toLowerCase();
736
854
  if(!q) throw new Error("`query` is empty.");
737
855
  const hits = [];
738
- for(const m of scanLibrary()){
856
+ for(const m of await loadLibrary()){
739
857
  const found = [];
740
858
  if(m.name.toLowerCase().includes(q)) found.push("(map name)");
741
859
  for(const t of collectTexts(m.doc)){
@@ -746,7 +864,7 @@ const HANDLERS = {
746
864
  }
747
865
  return hits.length ? hits.join("\n") : "Nothing found for \"" + args.query + "\".";
748
866
  },
749
- create_mindmap(args){
867
+ async create_mindmap(args){
750
868
  // Sans cette garde, un appel où « markdown » manque donnait String(undefined) =
751
869
  // la chaîne "undefined" : une carte à un seul nœud, écrite sur le disque sans erreur.
752
870
  if(typeof args.markdown !== "string" || !args.markdown.trim())
@@ -764,12 +882,12 @@ const HANDLERS = {
764
882
  const doc = { v:1, root, images:[], stickies:[] };
765
883
  const st = okStyleMcp(args.style);
766
884
  if(st) doc.style = st;
767
- const file = writeDoc(args.name || root.text, doc, args.folder);
885
+ const file = await writeDoc(args.name || root.text, doc, args.folder);
768
886
  return `✅ Map created (${countNodes(root)} nodes): ${file}\n${importHint()}`;
769
887
  },
770
- create_board(args){
888
+ async create_board(args){
771
889
  const { doc, warnings } = buildBoardDoc(args.name || "Board", args.elements, args.connections, args.style);
772
- const file = writeDoc(args.name || "Board", doc, args.folder);
890
+ const file = await writeDoc(args.name || "Board", doc, args.folder);
773
891
  const n = doc.shapes.length + doc.stickies.length;
774
892
  // Les avertissements passent AVANT l'astuce d'import : c'est le seul moment où le texte
775
893
  // peut encore être raccourci, et une ligne noyée en fin de réponse ne se lit pas.
@@ -781,12 +899,23 @@ const HANDLERS = {
781
899
  : "";
782
900
  return `✅ Board created (${n} elements, ${doc.arrows.length} connection(s)): ${file}\n${warn}${importHint()}`;
783
901
  },
784
- propose_changes(args){
785
- const m = findMap(args.file);
786
- if(m.kind === "board")
902
+ async propose_changes(args){
903
+ const m = await findMap(args.file);
904
+ // Un persona est la SEULE op qui vise un board — la work map en est un — et il ne vise que ça.
905
+ const allPersona = Array.isArray(args.changes) && args.changes.length > 0 && args.changes.every(c=>c && c.op === "persona");
906
+ if(m.kind === "board" && !allPersona)
787
907
  throw new Error("Proposals only apply to mind maps. A board has free positions and drawings, which a change list cannot describe.");
908
+ if(allPersona && m.kind !== "board")
909
+ throw new Error("A persona re-casts a WORK MAP canvas, which is a board. “" + m.name + "” is a mind map — pick the person's work map from list_maps (its name starts with “Work map —” or “Cartographie —”).");
788
910
  const ops = buildProposalOps(m.doc, args.changes);
789
- const file = writeProposal(m, args.note, ops);
911
+ const file = await writeProposal(m, args.note, ops);
912
+ if(allPersona){
913
+ return `📮 Interview filed for “${m.name}” — ${ops[0].procs} processes: ${file}\n`
914
+ + "NOTHING has been changed yet. The canvas file is untouched.\n"
915
+ + (CLOUD ? "TabTree shows a banner on that work map within a minute (app open, signed in): " : "TabTree shows a banner on that work map within a few seconds (backup folder connected, app open): ") + "“Your Claude interview is ready — re-cast this work map?”. "
916
+ + "One click rebuilds the canvas AND its companion sheet from these hours; Cmd+Z undoes it.\n"
917
+ + "Tell the user to look at TabTree — do not claim the map has been re-cast.";
918
+ }
790
919
  const adds = ops.reduce((n,o)=>n + (o.adds||0), 0);
791
920
  const drops = ops.reduce((n,o)=>n + (o.drops||0), 0);
792
921
  return `📮 Proposal filed for “${m.name}” — ${ops.length} change(s)`
@@ -794,7 +923,7 @@ const HANDLERS = {
794
923
  + (drops ? `, ${drops} node(s) to remove` : "")
795
924
  + `: ${file}\n`
796
925
  + "NOTHING has been changed yet. The map file is untouched.\n"
797
- + "TabTree shows a banner on that map within a few seconds (the backup folder must be connected, and the app open). "
926
+ + (CLOUD ? "TabTree shows a banner on that map within a minute (the app open and signed in). " : "TabTree shows a banner on that map within a few seconds (the backup folder must be connected, and the app open). ")
798
927
  + "The user ticks the changes they want, applies them, and Cmd+Z undoes the lot.\n"
799
928
  + "Tell the user to look at TabTree — do not claim the map has been updated.";
800
929
  }
@@ -818,7 +947,7 @@ function handle(msg){
818
947
  // déjà dérivé (1.0.0 ici, 1.1.0 dans le manifeste) sans que rien ne le
819
948
  // signale : un client affiche l'une, le registre publie l'autre. Un
820
949
  // autotest les compare désormais toutes les quatre.
821
- serverInfo: { name: "tabtree", version: "1.1.0" }
950
+ serverInfo: { name: "tabtree", version: "1.2.1" }
822
951
  });
823
952
  } else if(method === "notifications/initialized" || (method||"").startsWith("notifications/")){
824
953
  // notification : pas de réponse
@@ -830,12 +959,13 @@ function handle(msg){
830
959
  const name = params && params.name;
831
960
  const fn = HANDLERS[name];
832
961
  if(!fn){ replyErr(id, -32602, "Outil inconnu : " + name); return; }
833
- try{
834
- const text = fn((params && params.arguments) || {});
962
+ // Les gestionnaires sont ASYNCHRONES depuis la source cloud (2026-09-08) : un `fn` qui
963
+ // lève ou qui rejette rend le même message d'erreur — Claude n'a pas à distinguer les deux.
964
+ Promise.resolve().then(()=>fn((params && params.arguments) || {})).then(text=>{
835
965
  reply(id, { content: [{ type:"text", text }] });
836
- }catch(e){
966
+ }, e=>{
837
967
  reply(id, { content: [{ type:"text", text: "Erreur : " + (e && e.message || e) }], isError: true });
838
- }
968
+ });
839
969
  } else if(id !== undefined){
840
970
  replyErr(id, -32601, "Méthode non supportée : " + method);
841
971
  }