tabtree-mcp 1.1.0 → 1.2.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.
Files changed (3) hide show
  1. package/README.md +24 -12
  2. package/package.json +1 -1
  3. package/tabtree-mcp.mjs +151 -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,6 +1,6 @@
1
1
  {
2
2
  "name": "tabtree-mcp",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "mcpName": "app.tabtree/tabtree",
5
5
  "description": "Let Claude read your TabTree library and turn conversations into mind maps and whiteboards. Local files, no account, nothing uploaded.",
6
6
  "repository": {
package/tabtree-mcp.mjs CHANGED
@@ -22,6 +22,27 @@ 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
+ const API_KEY = String(process.env.TABTREE_API_KEY || "").trim();
33
+ const API_URL = String(process.env.TABTREE_API_URL || "https://aynmiptyvisxoslzkzxn.supabase.co/functions/v1/mcp").trim();
34
+ const CLOUD = !!API_KEY;
35
+ async function cloudCall(action, body){
36
+ let r;
37
+ try{
38
+ r = await fetch(API_URL, { method:"POST",
39
+ headers:{ "content-type":"application/json", "x-tabtree-key": API_KEY },
40
+ body: JSON.stringify(Object.assign({ action }, body || {})) });
41
+ }catch(e){ throw new Error("Could not reach the TabTree cloud (" + (e && e.message || e) + ")."); }
42
+ let j = null; try{ j = await r.json(); }catch(e){}
43
+ if(!r.ok || !j || j.ok === false) throw new Error((j && j.error) || ("The TabTree cloud answered HTTP " + r.status + "."));
44
+ return j;
45
+ }
25
46
  const MANIFEST = "bibliotheque.json";
26
47
  // Boîte aux lettres des propositions. Le connecteur reste EN AJOUT SEUL : il ne modifie
27
48
  // jamais un .tabtree, il dépose ici un fichier que l'app lit, montre, et n'applique que si
@@ -33,7 +54,8 @@ const PROP_DIR = "Propositions";
33
54
  // Utilitaires
34
55
  // ---------------------------------------------------------------------------
35
56
  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.)");
57
+ if(CLOUD) return; // la source est le compte, pas un dossier
58
+ 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
59
  if(!existsSync(DIR)) throw new Error("The TABTREE_DIR folder does not exist: " + DIR);
38
60
  }
39
61
  // Même règle de nommage que l'app (bkSafeName) : la restauration retire le
@@ -116,7 +138,9 @@ const ART_IDS_MCP = [
116
138
  "laptop", "phone", "mail", "folder", "book", "chart",
117
139
  "money", "calendar", "building", "car", "globe", "rocket",
118
140
  "house", "pin", "plane", "coffee", "plant", "sun",
119
- "camera", "music", "lock", "key", "health", "gift"
141
+ "camera", "music", "lock", "key", "health", "gift",
142
+ "manager", "client", "expert", "assistant", "coach", "learner",
143
+ "developer", "seller", "support", "speaker", "freelancer", "group"
120
144
  ];
121
145
  const ART = Object.fromEntries(ART_IDS_MCP.map(id=>[id, true]));
122
146
  const EMO_PIC_PAT = "\\p{Extended_Pictographic}(?:\\uFE0F|\\uFE0E)?(?:\\u200D\\p{Extended_Pictographic}(?:\\uFE0F|\\uFE0E)?)*";
@@ -267,7 +291,11 @@ function buildBoardDoc(name, elements, connections, style){
267
291
  a0: (e.kind==="arc" && Number.isFinite(+e.a0)) ? +e.a0 : null,
268
292
  a1: (e.kind==="arc" && Number.isFinite(+e.a1)) ? +e.a1 : null,
269
293
  ir: (e.kind==="arc" && Number.isFinite(+e.ir)) ? +e.ir : null,
270
- size: (+e.size>=9 && +e.size<=72) ? Math.round(+e.size) : null };
294
+ size: (+e.size>=9 && +e.size<=72) ? Math.round(+e.size) : null,
295
+ // L'image sans octets sur un élément de board (2026-09-03) : même frontière que
296
+ // pour un nœud — un id inventé retombe sur « aucune image », jamais sur du balisage.
297
+ // Pas sur un secteur, un gradin ou un chevron : leur silhouette n'a pas de « haut ».
298
+ pic: (e.pic != null && !["arc","trap","chev"].includes(kind)) ? okPic(String(e.pic)) : "" };
271
299
  });
272
300
  const byId = new Map();
273
301
  for(const e of els){
@@ -332,10 +360,11 @@ function buildBoardDoc(name, elements, connections, style){
332
360
  for(const e of els){
333
361
  if(e.kind==="sticky"){
334
362
  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 });
363
+ 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
364
  } else {
337
365
  refOf.set(e.lid, "sh:" + shapes.length);
338
366
  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,
367
+ pic: e.pic||undefined,
339
368
  tk: e.tk != null ? e.tk : undefined,
340
369
  a0: e.a0 != null ? e.a0 : undefined,
341
370
  a1: e.a1 != null ? e.a1 : undefined,
@@ -452,6 +481,36 @@ function scanLibrary(){
452
481
  out.sort((a,b)=>b.mtime.localeCompare(a.mtime));
453
482
  return out;
454
483
  }
484
+ // La bibliothèque du COMPTE, présentée exactement comme celle du dossier : mêmes champs, donc
485
+ // les six outils n'ont rien à savoir de la source. `file` porte la clé de la carte (map_key),
486
+ // que `propose_changes` renvoie telle quelle — pas de suffixe « __id » à analyser ici.
487
+ async function cloudLibrary(){
488
+ const j = await cloudCall("docs", {});
489
+ const folders = new Map((j.folders||[]).filter(f=>f && f.folder_key).map(f=>[f.folder_key, f]));
490
+ const pathOf = (k)=>{
491
+ const out = [], seen = new Set();
492
+ let f = folders.get(k);
493
+ 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; }
494
+ return out.length ? out.join(" / ") : null;
495
+ };
496
+ const out = [];
497
+ for(const r of (j.maps||[])){
498
+ const doc = r && r.doc;
499
+ if(!doc || !doc.root) continue;
500
+ out.push({
501
+ file: r.map_key,
502
+ name: String(r.name || "") || String(doc.root.text || "").slice(0, 60) || r.map_key,
503
+ folder: pathOf(r.folder_key),
504
+ kind: doc.board ? "board" : "map",
505
+ nodes: countNodes(doc.root),
506
+ elements: doc.board ? ((doc.shapes||[]).length + (doc.stickies||[]).length + (doc.arrows||[]).length + (doc.draws||[]).length) : undefined,
507
+ mtime: String(r.updated_at || "").slice(0,16).replace("T"," "),
508
+ doc
509
+ });
510
+ }
511
+ return out;
512
+ }
513
+ async function loadLibrary(){ return CLOUD ? cloudLibrary() : scanLibrary(); }
455
514
  function collectTexts(doc){
456
515
  const texts = [];
457
516
  (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 +545,9 @@ function boardToText(doc){
486
545
  if((doc.draws||[]).length) lines.push(`Pencil strokes: ${doc.draws.length}`);
487
546
  return lines.join("\n");
488
547
  }
489
- function findMap(file){
548
+ async function findMap(file){
490
549
  checkRelPath(file);
491
- const all = scanLibrary();
550
+ const all = await loadLibrary();
492
551
  const hit = all.find(m=>m.file===file) || all.find(m=>m.file.endsWith(file)) || all.find(m=>m.name===file);
493
552
  if(!hit) throw new Error("No such map: " + file + ". Use list_maps to see what is available.");
494
553
  return hit;
@@ -497,7 +556,20 @@ function findMap(file){
497
556
  // ---------------------------------------------------------------------------
498
557
  // Écriture (toujours un NOUVEAU fichier — jamais de modification en place)
499
558
  // ---------------------------------------------------------------------------
500
- function writeDoc(name, doc, folder){
559
+ async function writeDoc(name, doc, folder){
560
+ if(CLOUD){
561
+ // Le dossier est un NOM de dossier de bibliothèque : on le résout en clé s'il existe déjà,
562
+ // sinon la carte arrive à la racine et le texte de réponse le dit (« no silent caps »).
563
+ let folder_key = null;
564
+ if(folder){
565
+ const j = await cloudCall("list", {});
566
+ const hit = (j.folders||[]).find(f=>f && String(f.name||"").trim().toLowerCase() === String(folder).trim().toLowerCase());
567
+ if(hit) folder_key = hit.folder_key;
568
+ }
569
+ const kind = doc.board ? "board" : (doc.kanban ? "kanban" : "map");
570
+ const r = await cloudCall("create", { name: String(name||"").slice(0,200), kind, doc, folder_key });
571
+ return "cloud:" + r.map_key + (folder && !folder_key ? " (folder “" + folder + "” does not exist in this account — the map is at the root)" : "");
572
+ }
501
573
  dirOk();
502
574
  let target = DIR;
503
575
  if(folder){
@@ -527,7 +599,7 @@ function countForest(roots){ return roots.reduce((n,r)=>n + countNodes(r), 0); }
527
599
  // connecteur la refuserait à l'écriture, ou l'app la jetterait à la lecture — dans les deux
528
600
  // cas Claude croirait avoir proposé quelque chose qui n'arrive jamais chez l'utilisateur.
529
601
  // 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"];
602
+ const PROP_OPS = ["add","rename","note","check","delete","move","pic","persona"];
531
603
 
532
604
  // Tout est vérifié ICI, contre la carte réelle, et un défaut fait ÉCHOUER l'appel. C'est
533
605
  // délibéré : une cible introuvable remonte à Claude, qui peut relire la carte et corriger,
@@ -541,6 +613,24 @@ function buildProposalOps(mapDoc, changes){
541
613
  const where = "change #" + (i+1);
542
614
  const op = String((c && c.op) || "").trim();
543
615
  if(!PROP_OPS.includes(op)) throw new Error(`${where}: unknown op "${op}". Use one of: ${PROP_OPS.join(", ")}.`);
616
+ if(op === "persona"){
617
+ // `persona` (2026-09-08) vise le DOCUMENT entier — la toile d'une work map — jamais un
618
+ // nœud : donc pas de `target`. C'est le relais skill → carte sans copier-coller : le skill
619
+ // « ma cartographie du travail » dépose ici l'objet de l'entretien, l'app propose de
620
+ // l'appliquer d'un clic. Contrôle SUPERFICIEL, et c'est une décision : `okPersona`, la
621
+ // frontière de confiance de l'app (soixante lignes de bornes), juge à l'application et
622
+ // nomme son refus dans le panneau. La dupliquer ici serait une seconde règle à figer
623
+ // caractère par caractère comme `okPic`. La raison qui fait valider les autres ops en
624
+ // profondeur — « une proposition à moitié valide arrive en lignes barrées » — ne
625
+ // s'applique pas : une op persona est ATOMIQUE, elle rebâtit la toile ou ne fait rien.
626
+ const P = c.persona;
627
+ if(!P || typeof P !== "object" || !Array.isArray(P.modules) || !P.modules.length)
628
+ 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 }.`);
629
+ const n = P.modules.reduce((a, m)=>a + ((m && Array.isArray(m.procs)) ? m.procs.length : 0), 0);
630
+ if(n < 3) throw new Error(`${where}: the persona has ${n} process(es) — a work map needs at least three. Finish the interview first.`);
631
+ if(JSON.stringify(P).length > 60000) throw new Error(`${where}: the persona is too large (60000 characters max).`);
632
+ return { op, persona: P, procs: n };
633
+ }
544
634
  const target = String(c.target == null ? "" : c.target);
545
635
  const r = propFind(mapDoc.root, target);
546
636
  if(r.error === "empty") throw new Error(`${where}: \`target\` is required — the node the change applies to.`);
@@ -595,17 +685,24 @@ function buildProposalOps(mapDoc, changes){
595
685
  }
596
686
  function parts_last(spec){ const p = String(spec||"").split(">"); return p[p.length-1].trim(); }
597
687
 
598
- function writeProposal(map, note, ops){
688
+ async function writeProposal(map, note, ops){
689
+ const payload = (id)=>({
690
+ v: 1, kind: "tabtree-proposal", mapId: id, mapFile: CLOUD ? map.file : basename(map.file), mapName: map.name,
691
+ createdAt: Date.now(), by: "Claude", note: String(note||"").slice(0, 2000), ops
692
+ });
693
+ if(CLOUD){
694
+ // La boîte aux lettres cloud : la table `proposals`, que l'app relit au sondage suivant.
695
+ // Même charge utile que le fichier — c'est le même `parseProposal` qui la lit côté app.
696
+ const r = await cloudCall("propose", { map_key: map.file, payload: payload(map.file) });
697
+ return "cloud:" + (r.id || "proposal");
698
+ }
599
699
  dirOk();
600
700
  const target = join(DIR, PROP_DIR);
601
701
  if(!existsSync(target)) mkdirSync(target);
602
702
  const id = mapIdOf(map.file);
603
703
  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
704
  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");
705
+ writeFileSync(join(target, file), JSON.stringify(payload(id), null, 2), "utf8");
609
706
  return PROP_DIR + "/" + file;
610
707
  }
611
708
 
@@ -619,6 +716,8 @@ function writeProposal(map, note, ops){
619
716
  // Tout répond « ✅ » et rien n'apparaît. Donner le nom du dossier rend l'écart
620
717
  // visible en une seconde, sans que personne ait à soupçonner quoi que ce soit.
621
718
  function importHint(){
719
+ if(CLOUD) return "Saved to the user's TabTree account. It appears in TabTree within a minute (the app polls "
720
+ + "its account while open), or on the next sign-in on any device. Nothing is overwritten.";
622
721
  return "Written to the folder “" + basename(DIR) + "”.\n"
623
722
  + "To see it in TabTree: My maps → 🛟 → “Bring maps back from this folder…”. "
624
723
  + "(Or press ⌘K and type “bring my maps back”.) Nothing is overwritten.\n"
@@ -674,6 +773,7 @@ const TOOLS = [
674
773
  x:{ type:"number" }, y:{ type:"number" }, w:{ type:"number" }, h:{ type:"number" },
675
774
  color:{ type:"string", description:"Hex fill colour (shapes) or sticky-note colour" },
676
775
  size:{ type:"number", description:"Text size 9-72 (defaults to 15)" },
776
+ 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
777
  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
778
  a0:{ type:"number", description:"arc only — start angle in degrees, 0 at 12 o'clock, clockwise" },
679
779
  a1:{ type:"number", description:"arc only — end angle in degrees (a1 > a0; a full circle is a0=0, a1=360)" },
@@ -696,34 +796,35 @@ const TOOLS = [
696
796
  },
697
797
  {
698
798
  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.",
799
+ 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
800
  inputSchema: { type:"object", properties:{
701
801
  file:{ type:"string", description:"The map to change — a file name (or map name) from list_maps" },
702
802
  note:{ type:"string", description:"One line telling the user what this proposal does and why. Shown above the changes." },
703
803
  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" },
804
+ 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" },
805
+ target:{ type:"string", description:"Exact node text, or \"Ancestor > Node\" if ambiguous (not used by persona)" },
806
+ 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
807
  markdown:{ type:"string", description:"add: the new branch as a bullet list, 2 spaces per level" },
707
808
  text:{ type:"string", description:"rename: the new text" },
708
809
  note:{ type:"string", description:"note: the note body (empty string clears it)" },
709
810
  value:{ type:"boolean", description:"check: true to tick, false to untick" },
710
811
  col:{ type:"string", description:"move: the kanban column id (read_map shows them), or \"\" to take the card off the board" },
711
812
  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 } }
813
+ }, required:["op"], additionalProperties:false } }
713
814
  }, required:["file","changes"], additionalProperties:false }
714
815
  }
715
816
  ];
716
817
 
717
818
  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 + ").";
819
+ async list_maps(){
820
+ const maps = await loadLibrary();
821
+ 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
822
  return maps.map(m=>
722
823
  `• ${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
824
  ).join("\n");
724
825
  },
725
- read_map(args){
726
- const m = findMap(args.file);
826
+ async read_map(args){
827
+ const m = await findMap(args.file);
727
828
  const head = `${m.name} [${m.kind}] — file: ${m.file}\n`;
728
829
  if(m.kind === "board") return head + boardToText(m.doc);
729
830
  // Le rappel sur propose_changes vit ici et pas dans la description de l'outil : c'est au
@@ -731,11 +832,11 @@ const HANDLERS = {
731
832
  return head + nodeToMarkdown(m.doc.root, 0)
732
833
  + "\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
834
  },
734
- search_maps(args){
835
+ async search_maps(args){
735
836
  const q = String(args.query||"").toLowerCase();
736
837
  if(!q) throw new Error("`query` is empty.");
737
838
  const hits = [];
738
- for(const m of scanLibrary()){
839
+ for(const m of await loadLibrary()){
739
840
  const found = [];
740
841
  if(m.name.toLowerCase().includes(q)) found.push("(map name)");
741
842
  for(const t of collectTexts(m.doc)){
@@ -746,7 +847,7 @@ const HANDLERS = {
746
847
  }
747
848
  return hits.length ? hits.join("\n") : "Nothing found for \"" + args.query + "\".";
748
849
  },
749
- create_mindmap(args){
850
+ async create_mindmap(args){
750
851
  // Sans cette garde, un appel où « markdown » manque donnait String(undefined) =
751
852
  // la chaîne "undefined" : une carte à un seul nœud, écrite sur le disque sans erreur.
752
853
  if(typeof args.markdown !== "string" || !args.markdown.trim())
@@ -764,12 +865,12 @@ const HANDLERS = {
764
865
  const doc = { v:1, root, images:[], stickies:[] };
765
866
  const st = okStyleMcp(args.style);
766
867
  if(st) doc.style = st;
767
- const file = writeDoc(args.name || root.text, doc, args.folder);
868
+ const file = await writeDoc(args.name || root.text, doc, args.folder);
768
869
  return `✅ Map created (${countNodes(root)} nodes): ${file}\n${importHint()}`;
769
870
  },
770
- create_board(args){
871
+ async create_board(args){
771
872
  const { doc, warnings } = buildBoardDoc(args.name || "Board", args.elements, args.connections, args.style);
772
- const file = writeDoc(args.name || "Board", doc, args.folder);
873
+ const file = await writeDoc(args.name || "Board", doc, args.folder);
773
874
  const n = doc.shapes.length + doc.stickies.length;
774
875
  // Les avertissements passent AVANT l'astuce d'import : c'est le seul moment où le texte
775
876
  // peut encore être raccourci, et une ligne noyée en fin de réponse ne se lit pas.
@@ -781,12 +882,23 @@ const HANDLERS = {
781
882
  : "";
782
883
  return `✅ Board created (${n} elements, ${doc.arrows.length} connection(s)): ${file}\n${warn}${importHint()}`;
783
884
  },
784
- propose_changes(args){
785
- const m = findMap(args.file);
786
- if(m.kind === "board")
885
+ async propose_changes(args){
886
+ const m = await findMap(args.file);
887
+ // Un persona est la SEULE op qui vise un board — la work map en est un — et il ne vise que ça.
888
+ const allPersona = Array.isArray(args.changes) && args.changes.length > 0 && args.changes.every(c=>c && c.op === "persona");
889
+ if(m.kind === "board" && !allPersona)
787
890
  throw new Error("Proposals only apply to mind maps. A board has free positions and drawings, which a change list cannot describe.");
891
+ if(allPersona && m.kind !== "board")
892
+ 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
893
  const ops = buildProposalOps(m.doc, args.changes);
789
- const file = writeProposal(m, args.note, ops);
894
+ const file = await writeProposal(m, args.note, ops);
895
+ if(allPersona){
896
+ return `📮 Interview filed for “${m.name}” — ${ops[0].procs} processes: ${file}\n`
897
+ + "NOTHING has been changed yet. The canvas file is untouched.\n"
898
+ + (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?”. "
899
+ + "One click rebuilds the canvas AND its companion sheet from these hours; Cmd+Z undoes it.\n"
900
+ + "Tell the user to look at TabTree — do not claim the map has been re-cast.";
901
+ }
790
902
  const adds = ops.reduce((n,o)=>n + (o.adds||0), 0);
791
903
  const drops = ops.reduce((n,o)=>n + (o.drops||0), 0);
792
904
  return `📮 Proposal filed for “${m.name}” — ${ops.length} change(s)`
@@ -794,7 +906,7 @@ const HANDLERS = {
794
906
  + (drops ? `, ${drops} node(s) to remove` : "")
795
907
  + `: ${file}\n`
796
908
  + "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). "
909
+ + (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
910
  + "The user ticks the changes they want, applies them, and Cmd+Z undoes the lot.\n"
799
911
  + "Tell the user to look at TabTree — do not claim the map has been updated.";
800
912
  }
@@ -818,7 +930,7 @@ function handle(msg){
818
930
  // déjà dérivé (1.0.0 ici, 1.1.0 dans le manifeste) sans que rien ne le
819
931
  // signale : un client affiche l'une, le registre publie l'autre. Un
820
932
  // autotest les compare désormais toutes les quatre.
821
- serverInfo: { name: "tabtree", version: "1.1.0" }
933
+ serverInfo: { name: "tabtree", version: "1.2.0" }
822
934
  });
823
935
  } else if(method === "notifications/initialized" || (method||"").startsWith("notifications/")){
824
936
  // notification : pas de réponse
@@ -830,12 +942,13 @@ function handle(msg){
830
942
  const name = params && params.name;
831
943
  const fn = HANDLERS[name];
832
944
  if(!fn){ replyErr(id, -32602, "Outil inconnu : " + name); return; }
833
- try{
834
- const text = fn((params && params.arguments) || {});
945
+ // Les gestionnaires sont ASYNCHRONES depuis la source cloud (2026-09-08) : un `fn` qui
946
+ // lève ou qui rejette rend le même message d'erreur — Claude n'a pas à distinguer les deux.
947
+ Promise.resolve().then(()=>fn((params && params.arguments) || {})).then(text=>{
835
948
  reply(id, { content: [{ type:"text", text }] });
836
- }catch(e){
949
+ }, e=>{
837
950
  reply(id, { content: [{ type:"text", text: "Erreur : " + (e && e.message || e) }], isError: true });
838
- }
951
+ });
839
952
  } else if(id !== undefined){
840
953
  replyErr(id, -32601, "Méthode non supportée : " + method);
841
954
  }