tabtree-mcp 1.4.0 → 1.5.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 +100 -216
  2. package/package.json +4 -4
  3. package/tabtree-mcp.mjs +522 -124
package/tabtree-mcp.mjs CHANGED
@@ -7,14 +7,18 @@
7
7
  // sans passer par l'interface — génération depuis une conversation,
8
8
  // un document, un plan…
9
9
  //
10
- // Où : le serveur travaille sur le DOSSIER DE SAUVEGARDE AUTO de TabTree
11
- // (celui choisi via 🛟 dans « Mes cartes ») : 1 fichier .ygmind par carte
12
- // + bibliotheque.json (manifeste, propriété de l'app — jamais modifié ici).
13
- // Le MCP ne crée que de NOUVEAUX fichiers ; l'app les avale par le menu 🛟
14
- // (« Bring maps back from this folder… »), avec dédoublonnage et sans écrasement.
10
+ // Où (1.5.0, 2026-10-20) : le COMPTE TabTree. TabTree est 100 % en ligne depuis le 2026-10-20
11
+ // (Zemind seule source, la licence Classic arrêtée) : la source normale est la clé personnelle
12
+ // `TABTREE_API_KEY` et l'edge function `mcp` (supabase/functions/mcp), qui voit la bibliothèque
13
+ // du compte ET les espaces d'équipe dont il est membre (migration 0033).
15
14
  //
16
- // Config : TABTREE_DIR = chemin du dossier. YGMIND_DIR (ancien nom) reste accepté,
17
- // pour qu'un .mcp.json écrit avant le renommage continue de marcher sans retouche.
15
+ // Le DOSSIER 🛟 (TABTREE_DIR, ancien nom YGMIND_DIR) reste lu par compatibilité : les acheteurs
16
+ // Classic ont des connecteurs installés qui le pointent, et la fin de Classic n'est pas encore
17
+ // annoncée côté ventes. Il n'est plus documenté que comme ancien mode — ne pas y ajouter de
18
+ // fonction : tout ce qui est nouveau (espaces, application sans clic) n'existe qu'en compte.
19
+ //
20
+ // Ce fichier vit dans le dépôt Zemind depuis la 1.5.0 (mcp/) — il était dans le moteur figé, et
21
+ // son test de dérive comparait au moteur, plus à ce qui est en ligne. Il compare app/index.html.
18
22
  // Protocole : JSON-RPC 2.0, un message JSON par ligne sur stdin/stdout.
19
23
  // ============================================================================
20
24
 
@@ -24,7 +28,12 @@ import { execFileSync } from "node:child_process";
24
28
  import { inflateSync, deflateSync } from "node:zlib";
25
29
  import { tmpdir, platform } from "node:os";
26
30
 
27
- const DIR = process.env.TABTREE_DIR || process.env.YGMIND_DIR || "";
31
+ // ⚠️ LE GABARIT NON RÉSOLU vaut « vide », pour le dossier comme pour la clé (2026-10-20). Claude
32
+ // Desktop passe `${user_config.backup_folder}` TEL QUEL quand le champ (ancien, Classic) est vide :
33
+ // pris pour un chemin, il produisait « The TABTREE_DIR folder does not exist: ${user_config…} » —
34
+ // un message sur un dossier que l'utilisateur n'a jamais choisi, qui cachait la vraie cause (la clé).
35
+ const DIR_RAW = process.env.TABTREE_DIR || process.env.YGMIND_DIR || "";
36
+ const DIR = /^\$\{.*\}$/.test(String(DIR_RAW).trim()) ? "" : DIR_RAW;
28
37
  // ---------------------------------------------------------------------------
29
38
  // DEUX SOURCES, UN CONNECTEUR (2026-09-08). Le dossier 🛟 sert TabTree Classic (le fichier
30
39
  // local) ; le CLOUD sert l'abonné TabTree SaaS. La source est choisie par la présence d'une
@@ -39,7 +48,11 @@ let API_KEY = String(process.env.TABTREE_API_KEY || "").trim();
39
48
  // Trousseau et liée à l'installation précédente). `CLOUD = !!API_KEY` prenait ce littéral pour
40
49
  // une clé, l'envoyait, et le serveur répondait « Unknown or revoked connector key » : un
41
50
  // message FAUX, qui envoie chercher une clé révoquée là où il n'y a simplement rien de saisi.
42
- if(/^\$\{.*\}$/.test(API_KEY)) API_KEY = "";
51
+ // Mesuré de nouveau le 2026-10-20 (mise à jour 1.3.0 → 1.5.0 par la page /connector) : la clé
52
+ // était bien enregistrée — chiffrée, liée à l'installation précédente — et c'est le gabarit qui
53
+ // est arrivé. On s'en souvient pour DIRE la cause au lieu de « aucune source ».
54
+ const KEY_UNSENT = /^\$\{.*\}$/.test(API_KEY);
55
+ if(KEY_UNSENT) API_KEY = "";
43
56
  const API_URL = String(process.env.TABTREE_API_URL || "https://aynmiptyvisxoslzkzxn.supabase.co/functions/v1/mcp").trim();
44
57
  const CLOUD = !!API_KEY;
45
58
  // La FORME d'une clé se vérifie ici, avant tout aller-retour : l'app en fabrique une en
@@ -57,7 +70,9 @@ async function cloudCall(action, body){
57
70
  try{
58
71
  r = await fetch(API_URL, { method:"POST",
59
72
  headers:{ "content-type":"application/json", "x-tabtree-key": API_KEY },
60
- body: JSON.stringify(Object.assign({ action }, body || {})) });
73
+ // `v: 2` : le contrat 1.5.0 (espaces d'équipe, propriétaire de chaque carte, pagination).
74
+ // Une edge function plus ancienne l'ignore ; une plus récente sert les 1.2–1.4 sans `v` à l'identique.
75
+ body: JSON.stringify(Object.assign({ action, v: 2 }, body || {})) });
61
76
  }catch(e){ throw new Error("Could not reach the TabTree cloud (" + (e && e.message || e) + ")."); }
62
77
  let j = null; try{ j = await r.json(); }catch(e){}
63
78
  if(!r.ok || !j || j.ok === false) throw new Error((j && j.error) || ("The TabTree cloud answered HTTP " + r.status + "."));
@@ -75,6 +90,7 @@ const PROP_DIR = "Propositions";
75
90
  // ---------------------------------------------------------------------------
76
91
  function dirOk(){
77
92
  if(CLOUD) return; // la source est le compte, pas un dossier
93
+ if(!DIR && KEY_UNSENT) throw new Error("Claude Desktop did not pass your TabTree connector key to the connector. This happens after the extension is updated or reinstalled: the key saved in its settings belongs to the previous install. Open Claude Desktop → Settings → Extensions → TabTree, paste your key again (create one in TabTree → Settings → Account → Claude connector if you need to), then quit Claude Desktop completely and reopen it.");
78
94
  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.)");
79
95
  if(!existsSync(DIR)) throw new Error("The TABTREE_DIR folder does not exist: " + DIR);
80
96
  }
@@ -167,6 +183,10 @@ const ART_IDS_MCP = [
167
183
  "pencil", "gradcap", "calc", "palette", "ruler", "backpack"
168
184
  ];
169
185
  const ART = Object.fromEntries(ART_IDS_MCP.map(id=>[id, true]));
186
+ // ≡ `artHas` d'app/index.html : seule une propriété PROPRE compte. `ART["toString"]` est une
187
+ // fonction héritée, vraie au test de vérité — « a:toString » passait `okPic` ici alors que l'app
188
+ // le refusait déjà (dérive attrapée le 2026-10-20 en comparant enfin au fichier EN LIGNE).
189
+ const artHas = id => typeof id === "string" && Object.prototype.hasOwnProperty.call(ART, id);
170
190
  const EMO_PIC_PAT = "\\p{Extended_Pictographic}(?:\\uFE0F|\\uFE0E)?(?:\\u200D\\p{Extended_Pictographic}(?:\\uFE0F|\\uFE0E)?)*";
171
191
  const PIC_EMO_RE = (()=>{
172
192
  try{ return new RegExp("^(?:" + EMO_PIC_PAT + ")$", "u"); }
@@ -176,7 +196,7 @@ const ART = Object.fromEntries(ART_IDS_MCP.map(id=>[id, true]));
176
196
  })();
177
197
  function okPic(v){
178
198
  if(typeof v !== "string" || !v || v.length > PIC_MAX) return "";
179
- if(v.startsWith("a:")) return ART[v.slice(2)] ? v : "";
199
+ if(v.startsWith("a:")) return artHas(v.slice(2)) ? v : "";
180
200
  if(v.startsWith("e:")){
181
201
  const ch = v.slice(2);
182
202
  return (ch && PIC_EMO_RE.test(ch)) ? "e:" + ch : "";
@@ -509,16 +529,33 @@ function propFind(root, spec){
509
529
  // prédécesseur ou cocherait une tâche qui repart aussitôt, sans rien savoir de ce qu'il fait.
510
530
  const RECUR_LABEL = { d:"daily", wd:"weekdays", w:"weekly", "2w":"every 2 weeks", m:"monthly", q:"quarterly", y:"yearly" };
511
531
  function durText(sec){ const m = Math.round(sec / 60), h = Math.floor(m / 60); return h ? (h + "h" + (m % 60 ? String(m % 60).padStart(2, "0") : "")) : (m + "m"); }
512
- function nodeToMarkdown(n, depth){
532
+ // `ctx` (1.5.0) : ce que la carte sait du KANBAN et des autres cartes — la colonne de chaque carte,
533
+ // son responsable, ses dates, sa priorité, ses étiquettes, et le NOM de la carte qu'un lien `map:`
534
+ // ouvre (un board attaché à une carte kanban se lit « ⟪opens “Vision” [board]⟫ », pas « map:x7 »).
535
+ function nodeToMarkdown(n, depth, ctx){
513
536
  const lines = [];
537
+ const cx = ctx || {};
538
+ const colName = new Map(((cx.cols) || []).map(k => [k.id, k]));
539
+ const doneCol = ((cx.cols) || []).find(k => k.done) || null;
514
540
  const byId = new Map();
515
541
  (function idx(node){ if(node && node.id) byId.set(node.id, node); (node.children||[]).forEach(idx); })(n);
516
542
  (function walk(node, d){
517
543
  let bullet;
518
544
  if(d===0) bullet = "# ";
519
545
  else bullet = " ".repeat(d-1) + (node.check!=null ? (node.check ? "- [x] " : "- [ ] ") : "- ");
520
- const body = node.link ? `[${node.text}](${node.link})` : node.text;
546
+ const mlink = /^map:([A-Za-z0-9_]+)$/.exec(String(node.link || ""));
547
+ const linked = mlink && cx.maps ? cx.maps.get(mlink[1]) : null;
548
+ const body = node.link && !mlink ? `[${node.text}](${node.link})` : node.text;
521
549
  const marks = [];
550
+ if(mlink) marks.push(linked ? "⟪opens “" + linked.name + "” [" + linked.kind + "]⟫" : "⟪opens another map (" + mlink[1] + ")⟫");
551
+ if(d > 0 && colName.size){
552
+ if(node.check === true && doneCol && node.col) marks.push("⟪column: " + doneCol.name + "⟫");
553
+ else if(node.col && colName.has(node.col)) marks.push("⟪column: " + colName.get(node.col).name + "⟫");
554
+ }
555
+ if(typeof node.assign === "string" && node.assign.trim()) marks.push("⟪owner: " + node.assign.trim() + "⟫");
556
+ if(node.start || node.due) marks.push("⟪" + (node.start && node.due ? node.start + " → " + node.due : node.due ? "due " + node.due : "starts " + node.start) + "⟫");
557
+ if(Number.isInteger(node.priority) && node.priority > 0) marks.push("⟪P" + node.priority + "⟫");
558
+ if(Array.isArray(node.tags) && node.tags.length) marks.push("⟪labels: " + node.tags.map(t => (cx.tagNames && cx.tagNames[t]) ? t + " “" + cx.tagNames[t] + "”" : t).join(", ") + "⟫");
522
559
  if(node.note && node.note.trim()) marks.push("⟪note⟫");
523
560
  if(Array.isArray(node.deps) && node.deps.length){
524
561
  const names = node.deps.map(id => byId.get(id)).filter(Boolean).map(p => String(p.text || "").trim()).filter(Boolean);
@@ -803,26 +840,56 @@ function scanLibrary(){
803
840
  return out;
804
841
  }
805
842
  // La bibliothèque du COMPTE, présentée exactement comme celle du dossier : mêmes champs, donc
806
- // les six outils n'ont rien à savoir de la source. `file` porte la clé de la carte (map_key),
807
- // que `propose_changes` renvoie telle quelle — pas de suffixe « __id » à analyser ici.
843
+ // les outils n'ont rien à savoir de la source. `file` est l'identifiant que l'APP donne à la carte
844
+ // (≡ `teamLocalId`) : sa clé pour une carte à moi, « T<10 hexa du propriétaire>_<clé> » pour une
845
+ // carte d'équipe — c'est lui qu'une proposition doit porter, l'app le cherche dans SA bibliothèque.
846
+ //
847
+ // ⚠️ PAGINÉE (1.5.0) : la version précédente lisait 300 documents et s'arrêtait EN SILENCE — la
848
+ // recherche ratait des cartes sur un gros compte sans le dire. On boucle jusqu'au `total`.
849
+ function teamLocalId(owner, key){
850
+ const t = String(owner || "").replace(/-/g, "").toLowerCase().slice(0, 10);
851
+ if(!/^[0-9a-f]{10}$/.test(t) || !/^[A-Za-z0-9_]{1,52}$/.test(String(key || ""))) return null;
852
+ return "T" + t + "_" + key;
853
+ }
854
+ let cloudSpacesCache = null; // les espaces vus au dernier chargement — la création les résout par nom
808
855
  async function cloudLibrary(){
809
- const j = await cloudCall("docs", {});
810
- const folders = new Map((j.folders||[]).filter(f=>f && f.folder_key).map(f=>[f.folder_key, f]));
811
- const pathOf = (k)=>{
856
+ const pages = [];
857
+ let total = Infinity, off = 0, j = null;
858
+ while(off < total && off < 5000){
859
+ j = await cloudCall("docs", { limit: 100, offset: off });
860
+ const got = j.maps || [];
861
+ pages.push(...got);
862
+ total = typeof j.total === "number" ? j.total : got.length; // edge function d'avant la 1.5.0 : une seule page
863
+ if(!got.length || typeof j.total !== "number") break;
864
+ off += got.length;
865
+ }
866
+ const me = (j && j.me) || null;
867
+ cloudSpacesCache = (j && j.spaces) || [];
868
+ const spaceName = (owner, key)=>{ const sp = cloudSpacesCache.find(x=>x.owner === owner && x.space_key === key); return sp ? sp.name : null; };
869
+ const folders = new Map((j && j.folders || []).filter(f=>f && f.folder_key).map(f=>[(f.owner || "") + "|" + f.folder_key, f]));
870
+ const pathOf = (owner, k)=>{
812
871
  const out = [], seen = new Set();
813
- let f = folders.get(k);
814
- 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; }
872
+ let f = folders.get((owner || "") + "|" + k) || folders.get("|" + k);
873
+ 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((owner || "") + "|" + f.parent_key) || folders.get("|" + f.parent_key)) : null; }
815
874
  return out.length ? out.join(" / ") : null;
816
875
  };
817
876
  const out = [];
818
- for(const r of (j.maps||[])){
877
+ for(const r of pages){
819
878
  const doc = r && r.doc;
820
879
  if(!doc || !doc.root) continue;
880
+ const owner = r.owner || me;
881
+ const team = !!(owner && me && owner !== me);
882
+ const file = team ? teamLocalId(owner, r.map_key) : r.map_key;
883
+ if(!file) continue;
821
884
  out.push({
822
- file: r.map_key,
885
+ file, key: r.map_key, owner, team,
886
+ role: r.role || "owner",
887
+ space: r.space_key ? spaceName(owner, r.space_key) : null,
823
888
  name: String(r.name || "") || String(doc.root.text || "").slice(0, 60) || r.map_key,
824
- folder: pathOf(r.folder_key),
825
- kind: doc.board ? "board" : "map",
889
+ folder: pathOf(owner, r.folder_key),
890
+ // La SORTE de l'index (kanban, note, gantt, sheet) quand le compte la dit : c'est elle qui décide
891
+ // de la vue d'ouverture dans l'app. Le document seul ne distingue que board / carte.
892
+ kind: doc.board ? "board" : (r.kind && r.kind !== "board" ? r.kind : "map"),
826
893
  nodes: countNodes(doc.root),
827
894
  elements: doc.board ? ((doc.shapes||[]).length + (doc.stickies||[]).length + (doc.arrows||[]).length + (doc.draws||[]).length) : undefined,
828
895
  mtime: String(r.updated_at || "").slice(0,16).replace("T"," "),
@@ -877,52 +944,70 @@ async function findMap(file){
877
944
  // ---------------------------------------------------------------------------
878
945
  // Écriture (toujours un NOUVEAU fichier — jamais de modification en place)
879
946
  // ---------------------------------------------------------------------------
880
- async function writeDoc(name, doc, folder){
947
+ async function writeDoc(name, doc, folder, opts){
948
+ const o = opts || {};
881
949
  if(CLOUD){
882
950
  // Le dossier est un NOM ou un CHEMIN de dossier de bibliothèque : on le résout en clé s'il
883
951
  // existe déjà, sinon la carte arrive à la racine et le texte de réponse le dit
884
952
  // (« no silent caps »).
885
953
  //
886
- // ⚠️ 2026-09-23 — LE NOM SEUL NE SUFFIT PAS. Cette résolution comparait `f.name` et prenait
887
- // le PREMIER trouvé. Un compte qui a « Ai4X » à la racine ET un « Ai4X » imbriqué dedans
888
- // (ce qui arrive dès qu'on range un dossier dans un autre) faisait alors tirer au sort :
889
- // les cartes atterrissaient dans « Ai4X / Ai4X » pendant que leur auteur les cherchait dans
890
- // « Ai4X », et concluait que le connecteur n'avait rien écrit. Deux règles maintenant :
891
- // un CHEMIN complet (« Ai4X / Ai4X ») gagne toujours, et un nom seul prend le dossier le
892
- // MOINS PROFOND — celui qu'on a en tête quand on écrit « Ai4X ». L'ambiguïté est dite.
893
- let folder_key = null, folder_path = null, folder_twins = 0;
954
+ // ⚠️ 2026-09-23 — LE NOM SEUL NE SUFFIT PAS. Un compte qui a « Ai4X » à la racine ET un « Ai4X »
955
+ // imbriqué dedans faisait tirer au sort. Deux règles : un CHEMIN complet (« Ai4X / Ai4X ») gagne
956
+ // toujours, et un nom seul prend le dossier le MOINS PROFOND. L'ambiguïté est dite.
957
+ //
958
+ // 👥 1.5.0 — LES ESPACES D'ÉQUIPE. `space` (un nom d'espace) dit OÙ : un de mes espaces, ou un
959
+ // espace dont je suis membre — la carte vit alors chez son PROPRIÉTAIRE, comme toute carte
960
+ // d'équipe. Les dossiers candidats sont ceux de cet espace ; sans `space`, mes dossiers d'abord,
961
+ // puis ceux des espaces d'équipe (un dossier d'équipe se désigne aussi par son chemin).
962
+ const j = await cloudCall("list", {});
963
+ const me = j.me || null;
964
+ const spaces = (j.spaces || []).filter(x=>x && x.space_key);
965
+ const all = (j.folders || []).filter(f=>f && f.folder_key);
966
+ const byKey = new Map(all.map(f=>[(f.owner || "") + "|" + f.folder_key, f]));
967
+ const chain = (f0)=>{ const out=[]; const seen=new Set(); let f=f0;
968
+ while(f && !seen.has(f.folder_key) && out.length<=16){ seen.add(f.folder_key); out.unshift(f.name); f = f.parent_key ? byKey.get((f.owner || "") + "|" + f.parent_key) : null; }
969
+ return out; };
970
+ const norm = (x)=>String(x||"").split("/").map(t=>t.trim()).filter(Boolean).join(" / ").toLowerCase();
971
+ let sp = null;
972
+ if(o.space){
973
+ const want = norm(o.space);
974
+ const hits = spaces.filter(x=>norm(x.name) === want);
975
+ if(!hits.length) throw new Error("No space named “" + o.space + "” in this account" + (spaces.length ? ". Spaces you can use: " + spaces.map(x=>"“" + x.name + "”" + (x.owner !== me ? " (team, " + x.role + ")" : "")).join(", ") : " — it has no spaces") + ".");
976
+ sp = hits.find(x=>x.owner === me) || hits[0];
977
+ if(!sp.writable) throw new Error("You cannot add maps to the space “" + sp.name + "” — " + (sp.role === "viewer" ? "your role there is viewer. Ask its owner for the editor role." : "its owner's plan has ended, so it is read-only."));
978
+ }
979
+ let f0 = null, folder_twins = 0;
894
980
  if(folder){
895
- const j = await cloudCall("list", {});
896
- const all = (j.folders||[]).filter(f=>f && f.folder_key);
897
- const byKey = new Map(all.map(f=>[f.folder_key, f]));
898
- const chain = (k)=>{ const out=[]; const seen=new Set(); let f=byKey.get(k);
899
- while(f && !seen.has(f.folder_key) && out.length<=16){ seen.add(f.folder_key); out.unshift(f.name); f = f.parent_key ? byKey.get(f.parent_key) : null; }
900
- return out; };
901
- const norm = (x)=>String(x||"").split("/").map(t=>t.trim()).filter(Boolean).join(" / ").toLowerCase();
902
981
  const want = norm(folder);
903
- let hits = all.filter(f=>norm(chain(f.folder_key).join(" / ")) === want); // chemin exact
904
- if(!hits.length) hits = all.filter(f=>norm(f.name) === want) // nom de feuille
905
- .sort((a,b)=>chain(a.folder_key).length - chain(b.folder_key).length);
906
- if(hits.length){
907
- folder_key = hits[0].folder_key;
908
- folder_path = chain(folder_key).join(" / ");
909
- folder_twins = hits.length;
910
- }
982
+ let cands = all.filter(f=>!sp || (f.owner === sp.owner && f.space_key === sp.space_key));
983
+ if(!sp) cands = cands.slice().sort((a,b)=>(a.owner === me ? 0 : 1) - (b.owner === me ? 0 : 1));
984
+ let hits = cands.filter(f=>norm(chain(f).join(" / ")) === want); // chemin exact
985
+ if(!hits.length) hits = cands.filter(f=>norm(f.name) === want) // nom de feuille
986
+ .sort((a,b)=>((a.owner === me ? 0 : 1) - (b.owner === me ? 0 : 1)) || (chain(a).length - chain(b).length));
987
+ if(hits.length){ f0 = hits[0]; folder_twins = hits.length; }
911
988
  }
912
- const kind = doc.board ? "board" : (doc.kanban ? "kanban" : "map");
913
- const r = await cloudCall("create", { name: String(name||"").slice(0,200), kind, doc, folder_key });
914
- // On DIT toujours où la carte a atterri : le chemin complet, pas le nom demandé. C'est ce
915
- // qui permet de la retrouver du premier coup au lieu de fouiller la bibliothèque.
989
+ const owner = f0 ? (f0.owner || me) : (sp ? sp.owner : me);
990
+ const kind = o.kind || (doc.board ? "board" : (doc.kanban ? "kanban" : "map"));
991
+ const body = { name: String(name||"").slice(0,200), kind, doc, folder_key: f0 ? f0.folder_key : null };
992
+ if(owner && me && owner !== me) body.owner = owner;
993
+ if(!f0 && sp) body.space_key = sp.space_key;
994
+ const r = await cloudCall("create", body);
995
+ // On DIT toujours où la carte a atterri : l'espace, le chemin complet — pas le nom demandé.
996
+ const spaceOf = (own, key)=>{ const x = spaces.find(y=>y.owner === own && y.space_key === key); return x ? x.name : null; };
997
+ const inSpace = f0 ? spaceOf(f0.owner, f0.space_key) : (sp ? sp.name : null);
916
998
  let where = "";
917
- if(folder_key){
918
- where = " (in “" + folder_path + "”)";
999
+ if(f0){
1000
+ where = " (in “" + chain(f0).join(" / ") + "”" + (inSpace ? ", space “" + inSpace + "”" : "") + ")";
919
1001
  if(folder_twins > 1) where += " ⚠ " + folder_twins + " folders are named “" + folder
920
1002
  + "” in this account; the shallowest was used. Pass the full path (e.g. “" + folder + " / " + folder
921
1003
  + "”) to choose another one.";
922
1004
  } else if(folder){
923
- where = " (folder “" + folder + "” does not exist in this account — the map is at the root)";
1005
+ where = " (folder “" + folder + "” does not exist" + (sp ? " in the space “" + sp.name + "”" : " in this account") + " — the map is at the root" + (sp ? " of that space" : "") + ")";
1006
+ } else if(sp){
1007
+ where = " (space “" + sp.name + "”" + (owner !== me ? " — a team space: every member sees it" : "") + ")";
924
1008
  }
925
- return "cloud:" + r.map_key + where;
1009
+ const id = (owner && me && owner !== me) ? teamLocalId(owner, r.map_key) : r.map_key;
1010
+ return "cloud:" + (id || r.map_key) + where;
926
1011
  }
927
1012
  dirOk();
928
1013
  let target = DIR;
@@ -953,7 +1038,22 @@ function countForest(roots){ return roots.reduce((n,r)=>n + countNodes(r), 0); }
953
1038
  // connecteur la refuserait à l'écriture, ou l'app la jetterait à la lecture — dans les deux
954
1039
  // cas Claude croirait avoir proposé quelque chose qui n'arrive jamais chez l'utilisateur.
955
1040
  // C'est le défaut du losange, rejoué. Un test compare les deux listes en ensembles.
956
- const PROP_OPS = ["add","rename","note","check","delete","move","pic","img","persona","tags","assign","priority","due","attr","comment","est"];
1041
+ const PROP_OPS = ["add","rename","note","check","delete","move","pic","img","persona","tags","assign","priority","due","attr","comment","est","start","cols","link"];
1042
+ // ⚡ Les ops que l'app applique D'ELLE-MÊME (1.5.0, décidé « hybride » le 2026-10-20) — ≡
1043
+ // `PROP_AUTO_OPS` d'app/index.html (le test compare les deux). Plus `cols` quand le tableau n'a
1044
+ // pas encore de colonnes (`seed`). C'est l'APP qui décide : le connecteur ne fait que demander
1045
+ // (`auto:true`) et découper le lot pour que le reste parte à la revue.
1046
+ const PROP_AUTO_OPS = ["add","move","check","tags","assign","priority","due","start","est","attr","comment","pic","img","link"];
1047
+ // Les ops que Claude peut ÉCRIRE et qui n'existent pas telles quelles dans l'app : `lever` devient
1048
+ // un attribut « Lever: <clé> » (c'est ainsi que l'app le range, `KB_LEVER_ATTR`), `columns` est le
1049
+ // nom lisible de `cols`.
1050
+ const INPUT_OPS = PROP_OPS.concat(["lever", "columns"]);
1051
+ // Les quatre leviers Ai4X — ≡ les clés de `KB_LEVERS` d'app/index.html (le test compare).
1052
+ const KB_LEVER_KEYS_MCP = ["human","think","accelerate","automate"];
1053
+ // Un identifiant de nœud neuf, de la forme qu'`okNodeId` accepte. Posé sur chaque `add` (`nid`) :
1054
+ // c'est ce qui rend l'ajout IDEMPOTENT dans l'app — rejoué par deux appareils, il ne double pas.
1055
+ function newNid(){ return "mcp" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8); }
1056
+ function cloneDoc(d){ return JSON.parse(JSON.stringify(d)); }
957
1057
  // Les dix étiquettes de couleur du kanban — ≡ `KB_TAGS` de index.html (le test compare les deux).
958
1058
  const KB_TAG_KEYS = ["green","yellow","orange","red","purple","blue","sky","lime","pink","gray"];
959
1059
 
@@ -962,13 +1062,54 @@ const KB_TAG_KEYS = ["green","yellow","orange","red","purple","blue","sky","lime
962
1062
  // alors qu'une proposition à moitié valide arriverait chez l'utilisateur sous forme de
963
1063
  // lignes barrées qu'il ne peut ni comprendre ni réparer. La boîte aux lettres ne reçoit
964
1064
  // que des propositions applicables.
965
- function buildProposalOps(mapDoc, changes){
1065
+ // ⚠️ SÉQUENTIEL depuis la 1.5.0 : chaque op est validée contre la carte TELLE QUE LES OPS
1066
+ // PRÉCÉDENTES L'ONT LAISSÉE (une copie, `work`). Sans ça, « ajoute la carte X, puis mets-lui une
1067
+ // échéance » échouait : X n'existait pas encore dans la carte lue. L'app applique dans le même
1068
+ // ordre, donc la cible validée ici est celle qu'elle trouvera.
1069
+ function buildProposalOps(mapDoc0, changes){
966
1070
  if(!Array.isArray(changes) || !changes.length) throw new Error("`changes` must be a non-empty list.");
967
1071
  if(changes.length > 100) throw new Error("Too many changes in one proposal (max 100). Send several smaller ones — the user reviews them one by one.");
1072
+ const mapDoc = cloneDoc(mapDoc0);
1073
+ const colsNow = ()=>(mapDoc.kanban && Array.isArray(mapDoc.kanban.cols) && mapDoc.kanban.cols.length) ? mapDoc.kanban.cols : null;
1074
+ // Une colonne se désigne par son id OU par son nom (« Doing ») — Claude lit les noms.
1075
+ const colRef = (raw, where)=>{
1076
+ const cols = colsNow();
1077
+ const v = String(raw);
1078
+ if(!cols) throw new Error(`${where}: this map has no kanban board yet. Add a "columns" change first (e.g. { op: "columns", columns: [{ name: "To do" }, { name: "Doing" }, { name: "Done", done: true }] }), then refer to those column names.`);
1079
+ const hit = cols.find(k => k.id === v) || cols.find(k => String(k.name).trim().toLowerCase() === v.trim().toLowerCase());
1080
+ if(!hit) throw new Error(`${where}: no column "${v}" on this board. Use one of: ${cols.map(k => `"${k.name}" (${k.id})`).join(", ")}.`);
1081
+ return hit.id;
1082
+ };
968
1083
  return changes.map((c, i)=>{
969
1084
  const where = "change #" + (i+1);
970
1085
  const op = String((c && c.op) || "").trim();
971
- if(!PROP_OPS.includes(op)) throw new Error(`${where}: unknown op "${op}". Use one of: ${PROP_OPS.join(", ")}.`);
1086
+ if(!INPUT_OPS.includes(op)) throw new Error(`${where}: unknown op "${op}". Use one of: ${INPUT_OPS.filter(x=>x !== "cols").join(", ")}.`);
1087
+ if(op === "columns" || op === "cols"){
1088
+ // Les colonnes du tableau, en un bloc. Un tableau qui n'en a PAS reçoit `seed` : l'app pose ces
1089
+ // colonnes d'elle-même, sans écraser celles qu'un collègue aurait posées entre-temps. Un tableau
1090
+ // qui en a déjà : un REMPLACEMENT, qui part à la revue (les cartes d'une colonne retirée
1091
+ // quittent le tableau). Un id existant est GARDÉ quand le nom revient — sinon chaque carte de
1092
+ // « Doing » perdrait sa colonne au passage.
1093
+ const list = Array.isArray(c.columns) ? c.columns : (Array.isArray(c.cols) ? c.cols : null);
1094
+ if(!list || !list.length || list.length > 12) throw new Error(`${where}: \`columns\` must list 1 to 12 columns — { name, done?, wip? }.`);
1095
+ const before = colsNow() || [];
1096
+ const used = new Set();
1097
+ const cols = list.map((k, j)=>{
1098
+ const name = String((k && (k.name != null ? k.name : k)) || "").replace(/[\x00-\x1f\x7f]/g, " ").trim().slice(0, 60);
1099
+ if(!name) throw new Error(`${where}: column #${j+1} has no name.`);
1100
+ const prev = before.find(b => String(b.name).trim().toLowerCase() === name.toLowerCase() && !used.has(b.id));
1101
+ let id = prev ? prev.id : (k && typeof k.id === "string" && /^[\w-]{1,24}$/.test(k.id) && !before.some(b=>b.id === k.id) ? k.id : "c" + (j+1));
1102
+ while(used.has(id) || (!prev && before.some(b => b.id === id))) id = "c" + Math.random().toString(36).slice(2, 7);
1103
+ used.add(id);
1104
+ const wip = parseInt(k && k.wip, 10);
1105
+ return Object.assign({ id, name }, k && k.done ? { done: true } : {}, (wip >= 1 && wip <= 99) ? { wip } : {});
1106
+ });
1107
+ if(cols.filter(k => k.done).length > 1) throw new Error(`${where}: only one column can be the "done" column.`);
1108
+ const out = { op: "cols", cols };
1109
+ if(!before.length) out.seed = true;
1110
+ mapDoc.kanban = Object.assign({}, mapDoc.kanban || {}, { cols });
1111
+ return out;
1112
+ }
972
1113
  if(op === "persona"){
973
1114
  // `persona` (2026-09-08) vise le DOCUMENT entier — la toile d'une work map — jamais un
974
1115
  // nœud : donc pas de `target`. C'est le relais skill → carte sans copier-coller : le skill
@@ -1015,15 +1156,13 @@ function buildProposalOps(mapDoc, changes){
1015
1156
  if(!forest.length) throw new Error(`${where}: \`markdown\` has no usable structure (a bullet list, indented by 2 spaces).`);
1016
1157
  out.markdown = c.markdown;
1017
1158
  out.adds = countForest(forest);
1159
+ out.nid = newNid();
1018
1160
  // `col` facultatif (2026-10-12) : les cartes NEUVES vont dans cette colonne du kanban.
1019
- // Validé comme pour `move` — une colonne inventée est refusée ICI, en nommant les vraies.
1020
- if(c.col != null && c.col !== ""){
1021
- const cols = (mapDoc.kanban && Array.isArray(mapDoc.kanban.cols)) ? mapDoc.kanban.cols : null;
1022
- const col = String(c.col);
1023
- if(!cols || !cols.some(k => k.id === col))
1024
- throw new Error(`${where}: no column "${col}" on this board${cols ? ". Use one of: " + cols.map(k => `"${k.name}" (${k.id})`).join(", ") : " — it has no kanban yet"}.`);
1025
- out.col = col;
1026
- }
1161
+ // Validé comme pour `move` — une colonne inventée est refusée ICI, en nommant les vraies ;
1162
+ // un NOM de colonne est accepté et traduit en id (1.5.0).
1163
+ if(c.col != null && c.col !== "") out.col = colRef(c.col, where);
1164
+ // La copie de travail reçoit les nœuds : une op suivante du même lot peut les viser.
1165
+ r.node.children = (r.node.children || []).concat(forest);
1027
1166
  // `assign` facultatif (2026-10-12, 👥) : le RESPONSABLE des cartes neuves. Mêmes bornes que l'op
1028
1167
  // `assign` ; refusé ICI en le disant, plutôt que de laisser l'app le retirer en silence.
1029
1168
  if(c.assign != null && c.assign !== ""){
@@ -1034,15 +1173,18 @@ function buildProposalOps(mapDoc, changes){
1034
1173
  } else if(op === "rename"){
1035
1174
  if(typeof c.text !== "string" || !c.text.trim()) throw new Error(`${where}: \`text\` is required for a "rename".`);
1036
1175
  out.text = c.text.trim().slice(0, 2000);
1176
+ r.node.text = out.text;
1037
1177
  } else if(op === "note"){
1038
1178
  if(typeof c.note !== "string") throw new Error(`${where}: \`note\` is required for a "note" (an empty string clears it).`);
1039
1179
  out.note = c.note.slice(0, 8000);
1040
1180
  } else if(op === "check"){
1041
- if(typeof c.value !== "boolean") throw new Error(`${where}: \`value\` must be true or false for a "check".`);
1042
- out.value = c.value;
1181
+ const v = c.value === "true" ? true : c.value === "false" ? false : c.value;
1182
+ if(typeof v !== "boolean") throw new Error(`${where}: \`value\` must be true or false for a "check".`);
1183
+ out.value = v;
1043
1184
  } else if(op === "delete"){
1044
1185
  if(r.node === mapDoc.root) throw new Error(`${where}: the centre of the map cannot be deleted.`);
1045
1186
  out.drops = countNodes(r.node);
1187
+ (function cut(n){ const k = (n.children || []).indexOf(r.node); if(k >= 0){ n.children.splice(k, 1); return true; } return (n.children || []).some(cut); })(mapDoc.root);
1046
1188
  } else if(op === "pic"){
1047
1189
  // Une désignation, jamais des pixels. La liste est FERMÉE : un id inventé est refusé ICI,
1048
1190
  // contre la carte réelle, plutôt que d'arriver chez l'utilisateur sous forme d'une ligne
@@ -1065,16 +1207,11 @@ function buildProposalOps(mapDoc, changes){
1065
1207
  } else if(op === "move"){
1066
1208
  // ⚠️ Dans le FICHIER les colonnes sont `{cols:[…]}` ; en mémoire, côté app, c'est un
1067
1209
  // tableau nu. Confondre les deux donne un contrôle qui ne regarde rien.
1068
- const cols = (mapDoc.kanban && Array.isArray(mapDoc.kanban.cols)) ? mapDoc.kanban.cols : null;
1069
- if(!cols || !cols.length) throw new Error(`${where}: this map has no kanban board yet. Open it in TabTree and press 🃏 once, then try again.`);
1070
- const col = String(c.col == null ? "" : c.col);
1071
1210
  // Vide = retirer du tableau, comme le ✕ d'une carte. Toute autre valeur doit désigner une
1072
- // colonne RÉELLE : une carte envoyée ailleurs disparaîtrait de l'écran sans un message.
1073
- if(col && !cols.some(k => k.id === col)){
1074
- const noms = cols.map(k => `"${k.name}" (${k.id})`).join(", ");
1075
- throw new Error(`${where}: no column "${col}" on this board. Use one of: ${noms} — or "" to take the card off the board.`);
1076
- }
1077
- out.col = col;
1211
+ // colonne RÉELLE (id ou nom) : une carte envoyée ailleurs disparaîtrait de l'écran sans un message.
1212
+ const raw = String(c.col == null ? "" : c.col);
1213
+ if(!colsNow()) colRef(raw || "?", where); // lève le message « pas encore de kanban »
1214
+ out.col = raw ? colRef(raw, where) : "";
1078
1215
  } else if(op === "tags"){
1079
1216
  // Les dix couleurs, liste FERMÉE — ≡ `okTags` de l'app. Une clé inventée est refusée ICI,
1080
1217
  // en nommant la liste, plutôt que de tomber en silence à la lecture. [] retire tout.
@@ -1090,10 +1227,23 @@ function buildProposalOps(mapDoc, changes){
1090
1227
  const q = (c.priority == null) ? 0 : c.priority;
1091
1228
  if(!Number.isInteger(q) || q < 0 || q > 6) throw new Error(`${where}: \`priority\` must be a whole number from 1 (highest) to 6, or 0 to remove it.`);
1092
1229
  out.priority = q;
1093
- } else if(op === "due"){
1094
- const q = (c.due == null) ? "" : String(c.due);
1095
- if(q && !/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/.test(q)) throw new Error(`${where}: \`due\` must be a day written YYYY-MM-DD (or "" to remove the due date).`);
1096
- out.due = q;
1230
+ } else if(op === "due" || op === "start"){
1231
+ const q = (c[op] == null) ? "" : String(c[op]);
1232
+ if(q && !/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/.test(q)) throw new Error(`${where}: \`${op}\` must be a day written YYYY-MM-DD (or "" to remove it).`);
1233
+ out[op] = q;
1234
+ } else if(op === "link"){
1235
+ // La liste blanche de l'app (`okOpLink`), en plus étroit : une page web, un e-mail, ou une
1236
+ // autre carte (`map:<id>` — l'id que list_maps donne). Jamais `javascript:` ni `data:`.
1237
+ const q = (c.link == null) ? "" : String(c.link).trim();
1238
+ if(q && !/^(https?:\/\/[^\s\x00-\x1f]+|mailto:[^\s\x00-\x1f]+|map:[A-Za-z0-9_]+)$/i.test(q))
1239
+ throw new Error(`${where}: \`link\` must be an https:// address, a mailto: address, or "map:<file>" to open another map from list_maps (or "" to remove it).`);
1240
+ out.link = q;
1241
+ } else if(op === "lever"){
1242
+ // Le levier Ai4X d'une carte. L'app le range en ATTRIBUT « Lever: <clé> » (`kbLeverSet` →
1243
+ // `shAttrSet`) : on écrit exactement ça, par l'op `attr`, que l'app sait appliquer.
1244
+ const q = String(c.lever == null ? "" : c.lever).trim().toLowerCase();
1245
+ if(q && !KB_LEVER_KEYS_MCP.includes(q)) throw new Error(`${where}: \`lever\` must be one of ${KB_LEVER_KEYS_MCP.join(", ")} (or "" to remove it).`);
1246
+ out.op = "attr"; out.name = "Lever"; out.value = q;
1097
1247
  } else if(op === "attr"){
1098
1248
  if(typeof c.name !== "string" || !c.name.trim() || c.name.length > 60 || /[:\x00-\x1f]/.test(c.name))
1099
1249
  throw new Error(`${where}: \`name\` is required for an "attr" — the attribute's name, without a colon (60 characters max).`);
@@ -1146,19 +1296,37 @@ function parts_last(spec){ const p = String(spec||"").split(">"); return p[p.len
1146
1296
  // mot, puisqu'elle ne peut pas savoir ce qu'il contenait. Avec des captures d'écran dedans, la
1147
1297
  // borne se rencontre : on refuse ICI en nommant le geste, jamais un dépôt qui n'arrive pas.
1148
1298
  const PROP_FILE_MAX = 1400000;
1149
- async function writeProposal(map, note, ops){
1150
- const payload = (id)=>({
1299
+ // ⚡ LE DÉCOUPAGE « HYBRIDE » (1.5.0). Le début du lot fait d'ops sûres part avec `auto:true` —
1300
+ // l'app l'applique d'elle-même ; à partir de la PREMIÈRE op qui demande une revue, tout le reste
1301
+ // part dans une seconde proposition, à revoir. Découper AU PREMIER refus, jamais op par op : une
1302
+ // op sûre qui suit un renommage vise souvent le texte renommé, et l'appliquer avant lui la ferait
1303
+ // échouer chez l'utilisateur.
1304
+ function autoOk(o){ return o.op === "cols" ? o.seed === true : PROP_AUTO_OPS.includes(o.op); }
1305
+ function splitAuto(ops){
1306
+ let k = 0;
1307
+ while(k < ops.length && autoOk(ops[k])) k++;
1308
+ return { auto: ops.slice(0, k), review: ops.slice(k) };
1309
+ }
1310
+ async function writeProposal(map, note, ops, opts){
1311
+ const o = opts || {};
1312
+ const payload = (id, list, auto)=>Object.assign({
1151
1313
  v: 1, kind: "tabtree-proposal", mapId: id, mapFile: CLOUD ? map.file : basename(map.file), mapName: map.name,
1152
- createdAt: Date.now(), by: "Claude", note: String(note||"").slice(0, 2000), ops
1153
- });
1154
- const shots = ops.filter(o=>o.op === "img" && o.img).length;
1155
- if(JSON.stringify(payload("x")).length > PROP_FILE_MAX)
1314
+ createdAt: Date.now(), by: "Claude", note: String(note||"").slice(0, 2000), ops: list
1315
+ }, auto ? { auto: true } : {});
1316
+ const shots = ops.filter(x=>x.op === "img" && x.img).length;
1317
+ if(JSON.stringify(payload("x", ops)).length > PROP_FILE_MAX)
1156
1318
  throw new Error("This proposal is too large for TabTree's mailbox (" + Math.round(PROP_FILE_MAX / 1024) + " KB max) — it carries " + shots + " screenshot(s). Send it as several proposals with fewer screenshots each.");
1157
1319
  if(CLOUD){
1158
1320
  // La boîte aux lettres cloud : la table `proposals`, que l'app relit au sondage suivant.
1159
1321
  // Même charge utile que le fichier — c'est le même `parseProposal` qui la lit côté app.
1160
- const r = await cloudCall("propose", { map_key: map.file, payload: payload(map.file) });
1161
- return "cloud:" + (r.id || "proposal");
1322
+ // Une carte d'équipe : la clé chez son propriétaire, et `mapId` = l'id que l'app lui donne.
1323
+ const parts = o.review ? { auto: [], review: ops } : splitAuto(ops);
1324
+ const base = { map_key: map.key || map.file };
1325
+ if(map.team && map.owner) base.owner = map.owner;
1326
+ const ids = {};
1327
+ if(parts.auto.length){ const r = await cloudCall("propose", Object.assign({ payload: payload(map.file, parts.auto, true) }, base)); ids.auto = r.id || "proposal"; }
1328
+ if(parts.review.length){ const r = await cloudCall("propose", Object.assign({ payload: payload(map.file, parts.review, false) }, base)); ids.review = r.id || "proposal"; }
1329
+ return { auto: parts.auto, review: parts.review, ids };
1162
1330
  }
1163
1331
  dirOk();
1164
1332
  const target = join(DIR, PROP_DIR);
@@ -1166,8 +1334,8 @@ async function writeProposal(map, note, ops){
1166
1334
  const id = mapIdOf(map.file);
1167
1335
  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.");
1168
1336
  const file = "prop_" + id + "__" + Date.now().toString(36) + ".json";
1169
- writeFileSync(join(target, file), JSON.stringify(payload(id), null, 2), "utf8");
1170
- return PROP_DIR + "/" + file;
1337
+ writeFileSync(join(target, file), JSON.stringify(payload(id, ops, false), null, 2), "utf8");
1338
+ return { auto: [], review: ops, ids: { review: PROP_DIR + "/" + file } };
1171
1339
  }
1172
1340
 
1173
1341
  // Ce texte est lu par CHAQUE acheteur, à chaque création. Il a déjà été faux une
@@ -1189,13 +1357,147 @@ function importHint(){
1189
1357
  + "the connector is pointed at a different library — that is why a map can be created and never appear.";
1190
1358
  }
1191
1359
 
1360
+ // ---------------------------------------------------------------------------
1361
+ // 🃏 Le KANBAN (1.5.0) — lire ses colonnes, et en fabriquer un d'un seul appel
1362
+ // ---------------------------------------------------------------------------
1363
+ function kanbanToText(doc){
1364
+ const k = doc && doc.kanban;
1365
+ if(!k || !Array.isArray(k.cols) || !k.cols.length) return "";
1366
+ const cols = k.cols.map(c => "“" + c.name + "” (" + c.id + (c.done ? ", done" : "") + (c.wip ? ", WIP " + c.wip : "") + ")").join(" · ");
1367
+ const tags = (k.tags && typeof k.tags === "object") ? Object.entries(k.tags)
1368
+ .filter(([key, v]) => KB_TAG_KEYS.includes(key) && typeof v === "string" && v.trim())
1369
+ .map(([key, v]) => key + " = “" + v.replace(/[\x00-\x1f\x7f]+/g, " ").trim().slice(0, 40) + "”") : [];
1370
+ return "\n\n🃏 Kanban columns: " + cols + (tags.length ? "\n🏷 Labels: " + tags.join(", ") : "")
1371
+ + "\n(Cards are the nodes marked ⟪column: …⟫. Move one with propose_changes op \"move\" and `col` = a column name or id; a ticked card [x] shows in the done column.)";
1372
+ }
1373
+ function kbTagNames(doc){
1374
+ const t = doc && doc.kanban && doc.kanban.tags;
1375
+ const out = {};
1376
+ if(t && typeof t === "object") for(const k of KB_TAG_KEYS) if(typeof t[k] === "string" && t[k].trim()) out[k] = t[k].trim().slice(0, 40);
1377
+ return out;
1378
+ }
1379
+ // Une durée en SECONDES, depuis les formes que `parseDur` lit dans l'app (« 2h », « 1h30 », « 45 »
1380
+ // = minutes, « 2:15 », « 30m », « 90s »). L'estimation d'un nœud est stockée en secondes (`okSecs`).
1381
+ function durSecs(v){
1382
+ const q = String(v == null ? "" : v).trim().toLowerCase().replace(/\s+/g, "");
1383
+ if(!q || !DUR_RE.test(q)) return null;
1384
+ let m;
1385
+ if((m = /^(\d+):([0-5]\d)$/.exec(q))) return (+m[1] * 60 + +m[2]) * 60;
1386
+ if((m = /^(\d+)h(\d+)?m?$/.exec(q))) return (+m[1] * 60 + (+m[2] || 0)) * 60;
1387
+ if((m = /^(\d+)m(in)?$/.exec(q))) return +m[1] * 60;
1388
+ if((m = /^(\d+)s(ec)?$/.exec(q))) return +m[1];
1389
+ return +q * 60;
1390
+ }
1391
+ const DAY_RE = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;
1392
+ // Un identifiant sur chaque nœud créé : l'app en donnerait un à l'ouverture, mais la signature du
1393
+ // document changerait alors entre le serveur et l'app (un aller-retour de synchro pour rien), et
1394
+ // une proposition suivante ne pourrait viser une carte que par son texte.
1395
+ function assignIds(n){ if(!n.id) n.id = "n" + Math.random().toString(36).slice(2, 12); (n.children || []).forEach(assignIds); return n; }
1396
+ // Les colonnes demandées, validées et munies d'ids — même forme qu'`okKanbanCols` côté app.
1397
+ function kanbanColsFrom(list, where){
1398
+ const src = Array.isArray(list) && list.length ? list : [{ name:"To do" }, { name:"Doing" }, { name:"Done", done:true }];
1399
+ if(src.length > 12) throw new Error(where + ": a kanban has 12 columns at most.");
1400
+ const cols = src.map((k, j)=>{
1401
+ const name = String((k && (k.name != null ? k.name : k)) || "").replace(/[\x00-\x1f\x7f]/g, " ").trim().slice(0, 60);
1402
+ if(!name) throw new Error(where + ": column #" + (j+1) + " has no name.");
1403
+ const wip = parseInt(k && k.wip, 10);
1404
+ return Object.assign({ id: "c" + (j+1), name }, k && k.done ? { done: true } : {}, (wip >= 1 && wip <= 99) ? { wip } : {});
1405
+ });
1406
+ if(cols.filter(k => k.done).length > 1) throw new Error(where + ": only one column can be the \"done\" column.");
1407
+ return cols;
1408
+ }
1409
+ // UN appel, un tableau complet : colonnes, cartes, et sur chaque carte ce que l'app affiche au recto
1410
+ // et au dos — responsable, dates, priorité, étiquettes, estimation, levier Ai4X, note, lien,
1411
+ // sous-tâches. Tout passe par les mêmes bornes que les propositions : une carte qui serait refusée
1412
+ // par l'app à l'ouverture est refusée ICI, en le disant.
1413
+ function buildKanbanDoc(args){
1414
+ const name = String(args.name || "").trim() || "Kanban";
1415
+ const cols = kanbanColsFrom(args.columns, "columns");
1416
+ const work = cols.filter(k => !k.done), done = cols.find(k => k.done) || null;
1417
+ const firstWork = (work[0] || cols[0]).id;
1418
+ const colOf = (raw, where)=>{
1419
+ if(raw == null || raw === "") return firstWork;
1420
+ const v = String(raw).trim().toLowerCase();
1421
+ const hit = cols.find(k => k.id === String(raw)) || cols.find(k => k.name.toLowerCase() === v);
1422
+ if(!hit) throw new Error(where + ": no column \"" + raw + "\". Columns: " + cols.map(k => "“" + k.name + "”").join(", ") + ".");
1423
+ return hit.id;
1424
+ };
1425
+ const cards = Array.isArray(args.cards) ? args.cards : [];
1426
+ if(cards.length > 300) throw new Error("A kanban created in one call holds 300 cards at most — create it, then add more with propose_changes.");
1427
+ const root = makeNode(name);
1428
+ root.color = "#dbeafe";
1429
+ cards.forEach((c, i)=>{
1430
+ const where = "card #" + (i+1);
1431
+ const text = String((c && c.text) || "").replace(/[\x00-\x1f\x7f]/g, " ").trim();
1432
+ if(!text) throw new Error(where + ": `text` is required.");
1433
+ const n = makeNode(text.slice(0, 2000));
1434
+ const col = colOf(c.column, where);
1435
+ if(done && col === done.id){ n.col = firstWork; n.check = true; } // la règle de l'app : Done COCHE, la colonne reste de travail
1436
+ else { n.col = col; n.check = false; }
1437
+ if(c.note != null){ if(typeof c.note !== "string") throw new Error(where + ": `note` must be text."); n.note = c.note.slice(0, 8000); }
1438
+ if(c.link){
1439
+ const l = String(c.link).trim();
1440
+ if(!/^(https?:\/\/[^\s\x00-\x1f]+|mailto:[^\s\x00-\x1f]+|map:[A-Za-z0-9_]+)$/i.test(l)) throw new Error(where + ": `link` must be an https:// or mailto: address, or map:<file>.");
1441
+ n.link = l;
1442
+ }
1443
+ if(c.assign){
1444
+ if(typeof c.assign !== "string" || c.assign.length > 120 || /[\x00-\x1f\x7f]/.test(c.assign)) throw new Error(where + ": `assign` must be a name or e-mail (120 characters max).");
1445
+ n.assign = c.assign.trim();
1446
+ }
1447
+ for(const k of ["start", "due"]) if(c[k]){ if(!DAY_RE.test(String(c[k]))) throw new Error(where + ": `" + k + "` must be YYYY-MM-DD."); n[k] = String(c[k]); }
1448
+ if(n.start && n.due && n.due < n.start){ const t = n.start; n.start = n.due; n.due = t; } // la règle du fichier : remis en ordre, pas refusé
1449
+ if(c.priority != null && c.priority !== 0){
1450
+ if(!Number.isInteger(c.priority) || c.priority < 1 || c.priority > 6) throw new Error(where + ": `priority` must be 1 (highest) to 6.");
1451
+ n.priority = c.priority;
1452
+ }
1453
+ const tags = c.labels != null ? c.labels : c.tags;
1454
+ if(tags != null){
1455
+ if(!Array.isArray(tags)) throw new Error(where + ": `labels` must be a list of colours.");
1456
+ const bad = tags.filter(t => !KB_TAG_KEYS.includes(t));
1457
+ if(bad.length) throw new Error(where + ": unknown label colour \"" + bad[0] + "\". Use any of: " + KB_TAG_KEYS.join(", ") + ".");
1458
+ if(tags.length) n.tags = KB_TAG_KEYS.filter(k => tags.includes(k));
1459
+ }
1460
+ if(c.est != null && c.est !== ""){
1461
+ const sec = durSecs(c.est);
1462
+ if(sec == null) throw new Error(where + ": `est` must be a duration — \"2h\", \"1h30\", \"45\" (minutes) or \"2:15\".");
1463
+ if(sec > 0) n.est = sec;
1464
+ }
1465
+ if(c.pic){ if(!okPic(String(c.pic))) throw new Error(where + ": `pic` must be \"a:<id>\" (" + ART_IDS_MCP.slice(0, 8).join(", ") + "…) or \"e:<emoji>\"."); n.pic = String(c.pic); }
1466
+ // Les sous-tâches sont des ENFANTS cochables, sans colonne — la règle du dos de la carte
1467
+ // (`kbAddSub`) : jamais des cartes sœurs.
1468
+ const subs = Array.isArray(c.subtasks) ? c.subtasks : [];
1469
+ if(subs.length > 50) throw new Error(where + ": 50 sub-tasks at most.");
1470
+ for(const t of subs){
1471
+ const st = String(t || "").replace(/[\x00-\x1f\x7f]/g, " ").trim();
1472
+ if(!st) continue;
1473
+ const k = makeNode(st.slice(0, 2000)); k.check = false; n.children.push(k);
1474
+ }
1475
+ // Le levier Ai4X : un attribut « Lever: <clé> », exactement ce que pose le dos de la carte.
1476
+ if(c.lever){
1477
+ const lv = String(c.lever).trim().toLowerCase();
1478
+ if(!KB_LEVER_KEYS_MCP.includes(lv)) throw new Error(where + ": `lever` must be one of " + KB_LEVER_KEYS_MCP.join(", ") + ".");
1479
+ n.children.push(makeNode("Lever: " + lv));
1480
+ }
1481
+ root.children.push(n);
1482
+ });
1483
+ assignIds(root);
1484
+ const kanban = { cols };
1485
+ const names = {};
1486
+ if(args.labels && typeof args.labels === "object") for(const k of KB_TAG_KEYS) if(typeof args.labels[k] === "string" && args.labels[k].trim()) names[k] = args.labels[k].replace(/[\x00-\x1f\x7f]/g, " ").trim().slice(0, 40);
1487
+ if(Object.keys(names).length) kanban.tags = names;
1488
+ const doc = { v:1, root, images:[], stickies:[], kanban };
1489
+ const st = okStyleMcp(args.style);
1490
+ if(st) doc.style = st;
1491
+ return doc;
1492
+ }
1493
+
1192
1494
  // ---------------------------------------------------------------------------
1193
1495
  // Définition des outils
1194
1496
  // ---------------------------------------------------------------------------
1195
1497
  const TOOLS = [
1196
1498
  {
1197
1499
  name: "list_maps",
1198
- description: "Lists every mind map and board in the TabTree library (name, type, folder, size, last modified).",
1500
+ description: "Lists every mind map, kanban, note and board in the TabTree library — the user's own and those of the team spaces they belong to (name, type, space, folder, size, last modified).",
1199
1501
  inputSchema: { type:"object", properties:{}, additionalProperties:false }
1200
1502
  },
1201
1503
  {
@@ -1215,6 +1517,13 @@ const TOOLS = [
1215
1517
  name:{ type:"string", description:"Name of the map" },
1216
1518
  markdown:{ type:"string", description:"Markdown outline (headings and/or bullet list). End any line with \" @a:<id>\" to give that node a built-in illustration, or \" @e:<emoji>\" for one large emoji — e.g. \"Launch @a:rocket\". 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. An unknown id is left as plain text rather than dropped." },
1217
1519
  folder:{ type:"string", description:"Optional TabTree folder. A NAME (\"From Claude\") or a full PATH (\"Ai4X / Programmes\"). When several folders share a name, the shallowest is used and the answer says which — pass the full path to choose another." },
1520
+ space:{ type:"string", description:"Optional TabTree SPACE to put it in, by name — one of the user's spaces, or a TEAM space they are an editor of (every member then sees it). list_maps shows the spaces. Omit for the user's own library." },
1521
+ view:{ type:"string", enum:["map","note","gantt"], description:"How TabTree opens it: map (default, a mind map), note (a page of text — the outline becomes headings and paragraphs, for meeting notes or a document), gantt (a timeline — give `dates`). It stays one document: the user can switch views at any time." },
1522
+ dates:{ type:"array", description:"Optional start/due days on nodes — what the Gantt view draws (and what the kanban and calendar show). One entry per node.", items:{ type:"object", properties:{
1523
+ target:{ type:"string", description:"Exact node text, or \"Ancestor > Node\"" },
1524
+ start:{ type:"string", description:"YYYY-MM-DD" },
1525
+ due:{ type:"string", description:"YYYY-MM-DD" }
1526
+ }, required:["target"], additionalProperties:false } },
1218
1527
  images:{ type:"array", description:"Optional screenshots, one per node: { target, file } or { target, data }. `target` = the exact text of a line of the outline (or \"Ancestor > Node\" when the wording repeats); `file` = full path of a PNG/JPEG/WebP/GIF on this machine (take the screenshot, save it, pass the path); `data` = a data:image/… URL instead of a file. The image is re-encoded to about 840 px and becomes the node's cover picture (it replaces any @a:/@e: illustration on that node). 60 max.", items:{ type:"object", properties:{
1219
1528
  target:{ type:"string", description:"Exact node text, or \"Ancestor > Node\"" },
1220
1529
  file:{ type:"string", description:"Path of the image file on this machine" },
@@ -1252,6 +1561,7 @@ const TOOLS = [
1252
1561
  from:{ type:"string" }, to:{ type:"string" }
1253
1562
  }, required:["from","to"], additionalProperties:false } },
1254
1563
  folder:{ type:"string", description:"Optional TabTree folder. A NAME or a full PATH (\"Ai4X / Programmes\"). When several folders share a name, the shallowest is used and the answer says which." },
1564
+ space:{ type:"string", description:"Optional TabTree SPACE to put it in, by name — one of the user's spaces, or a TEAM space they are an editor of (every member then sees it). list_maps shows the spaces. Omit for the user's own library." },
1255
1565
  style:{ type:"object", description:"Optional look of the document. Omit it for the default.", properties:{
1256
1566
  font:{ type:"string", enum:["","grotesk","humanist","condensed","rounded","serif","garamond","didone","slab","typewriter","mono","hand"], description:"Type family: grotesk (corporate), humanist (warm), condensed (dense), rounded (friendly), serif/garamond (editorial), didone (elegant), slab (sturdy), typewriter, mono (technical), hand (handwritten)." },
1257
1567
  shape:{ type:"string", enum:["","line","plain"], description:"Node shape: cards (default), line = underlined, plain = plain text." },
@@ -1263,21 +1573,53 @@ const TOOLS = [
1263
1573
  }, additionalProperties:false }
1264
1574
  }, required:["name","elements"], additionalProperties:false }
1265
1575
  },
1576
+ {
1577
+ name: "create_kanban",
1578
+ description: "Creates a NEW TabTree kanban board in one call — columns and cards, each card with what TabTree shows on it: owner, start/due days, priority, colour labels, time estimate, Ai4X lever, note (links in it become clickable — a Google Meet link, a doc), a link, and sub-tasks. It opens directly as a kanban in TabTree, on every device signed in to the account, and in a TEAM space every member sees it. To add or move cards on an EXISTING kanban, use propose_changes instead.",
1579
+ inputSchema: { type:"object", properties:{
1580
+ name:{ type:"string", description:"Name of the board (e.g. \"Sprint W43\", \"Launch\")" },
1581
+ columns:{ type:"array", description:"The columns, left to right. Omit for To do / Doing / Done. Mark ONE column done:true: a card put there is ticked, and unticking it sends it back. 12 max.", items:{ type:"object", properties:{
1582
+ name:{ type:"string" }, done:{ type:"boolean" }, wip:{ type:"integer", description:"Optional work-in-progress limit 1–99 (the column turns red above it — nothing is blocked)" }
1583
+ }, required:["name"], additionalProperties:false } },
1584
+ cards:{ type:"array", description:"The cards, in order within each column. 300 max.", items:{ type:"object", properties:{
1585
+ text:{ type:"string", description:"The card's title" },
1586
+ column:{ type:"string", description:"Column NAME (or id). Default: the first column" },
1587
+ assign:{ type:"string", description:"Owner — a name or e-mail" },
1588
+ start:{ type:"string", description:"YYYY-MM-DD" },
1589
+ due:{ type:"string", description:"YYYY-MM-DD" },
1590
+ priority:{ type:"integer", description:"1 (highest) to 6" },
1591
+ labels:{ type:"array", items:{ type:"string" }, description:"Colour labels from: green, yellow, orange, red, purple, blue, sky, lime, pink, gray (name them with `labels` at the board level)" },
1592
+ est:{ type:"string", description:"Time estimate: \"2h\", \"1h30\", \"45\" (minutes), \"2:15\"" },
1593
+ lever:{ type:"string", enum:["human","think","accelerate","automate"], description:"Ai4X lever: human (stays human), think (think with Claude), accelerate (Claude Code or a connector), automate (a skill or a scheduled task)" },
1594
+ note:{ type:"string", description:"The back of the card — plain text; http(s) and mailto links become clickable" },
1595
+ link:{ type:"string", description:"An https:// or mailto: link on the card, or map:<file> to open another map from list_maps" },
1596
+ subtasks:{ type:"array", items:{ type:"string" }, description:"Sub-tasks, ticked on the back of the card (the card shows n/m)" },
1597
+ pic:{ type:"string", description:"\"a:<id>\" built-in illustration or \"e:<emoji>\"" }
1598
+ }, required:["text"], additionalProperties:false } },
1599
+ labels:{ type:"object", description:"Optional names for the colour labels, e.g. { \"red\": \"Bug\", \"green\": \"Quick win\" }", additionalProperties:{ type:"string" } },
1600
+ folder:{ type:"string", description:"Optional folder, by NAME or full PATH." },
1601
+ space:{ type:"string", description:"Optional TabTree SPACE to put it in, by name — one of the user's spaces, or a TEAM space they are an editor of (every member then sees it). list_maps shows the spaces. Omit for the user's own library." },
1602
+ style:{ type:"object", description:"Optional look (same keys as create_mindmap: font, background, skin…).", additionalProperties:true }
1603
+ }, required:["name"], additionalProperties:false }
1604
+ },
1266
1605
  {
1267
1606
  name: "propose_changes",
1268
- 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, put a screenshot on a node (op \"img\" with `file`) — 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.",
1607
+ description: "Changes an EXISTING mind map or kanban (also one in a team space). HYBRID, by design: SAFE changes — add cards or branches, move a card to a column, tick it, set its owner, dates, priority, labels, estimate, Ai4X lever, an attribute, a link, a comment, a picture, and set up the columns of a board that has none — are APPLIED BY TABTREE ITSELF within seconds when TabTree is open on one of the user's devices (otherwise the next time it opens), and Cmd+Z undoes them. Changes that rewrite or remove what someone wrote — rename, note, delete, replacing existing columns, a persona re-cast — appear as a banner for the user to review and approve. In one call, the safe changes BEFORE the first review-only change apply by themselves and the rest goes to review, in order. Name each target node by its exact text, or by a path \"Ancestor > Node\" when the same wording appears twice; a later change may target a node added earlier in the same call. Read the map first so the wording matches.",
1269
1608
  inputSchema: { type:"object", properties:{
1270
1609
  file:{ type:"string", description:"The map to change — a file name (or map name) from list_maps" },
1271
1610
  note:{ type:"string", description:"One line telling the user what this proposal does and why. Shown above the changes." },
1272
1611
  changes:{ type:"array", description:"The proposed changes, applied in order", items:{ type:"object", properties:{
1273
- op:{ type:"string", enum:["add","rename","note","check","delete","move","pic","img","persona","tags","assign","priority","due","attr","comment","est"], 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; img = put a SCREENSHOT on it (give `file`, the path of the image on this machine — or `data`; `img: \"\"` removes it); persona = re-cast a work map canvas (a board whose name starts with “Work map —” / “Cartographie —”) or a Second Brain Blueprint (“Second brain —”) from an interview — no target; tags = set the card's colour labels; assign = who the task is for; priority = 1 (highest) to 6; due = the due day; attr = set a “Name: value” attribute on the node (an estimate, a budget…); comment = add a signed comment to the card's thread (give `text`); est = set the time ESTIMATE of the task (give `est`, e.g. \"2h\", \"45\" = 45 minutes, \"\" removes it)" },
1612
+ op:{ type:"string", enum:["add","rename","note","check","delete","move","pic","img","persona","tags","assign","priority","due","start","attr","comment","est","lever","link","columns"], description:"columns = set the kanban columns (give `columns`; no target) — on a map with no kanban yet this creates the board; 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; img = put a SCREENSHOT on it (give `file`, the path of the image on this machine — or `data`; `img: \"\"` removes it); persona = re-cast a work map canvas (a board whose name starts with “Work map —” / “Cartographie —”) or a Second Brain Blueprint (“Second brain —”) from an interview — no target; tags = set the card's colour labels; assign = who the task is for; priority = 1 (highest) to 6; due = the due day; attr = set a “Name: value” attribute on the node (an estimate, a budget…); comment = add a signed comment to the card's thread (give `text`); est = set the time ESTIMATE of the task (give `est`, e.g. \"2h\", \"45\" = 45 minutes, \"\" removes it); start = the start day; lever = the card's Ai4X lever (give `lever`); link = put a link on the node (give `link`)" },
1274
1613
  target:{ type:"string", description:"Exact node text, or \"Ancestor > Node\" if ambiguous (not used by persona)" },
1275
1614
  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." },
1276
1615
  markdown:{ type:"string", description:"add: the new branch as a bullet list, 2 spaces per level" },
1277
1616
  text:{ type:"string", description:"rename: the new text. comment: the comment body (plain text; http/mailto links become clickable)" },
1278
1617
  note:{ type:"string", description:"note: the note body (empty string clears it)" },
1279
- value:{ type:"boolean", description:"check: true to tick, false to untick" },
1280
- col:{ type:"string", description:"move: the kanban column id (read_map shows them), or \"\" to take the card off the board. add: optional — the column the NEW cards go to" },
1618
+ col:{ type:"string", description:"move: the kanban column — its NAME (\"Doing\") or id, both shown by read_map — or \"\" to take the card off the board. add: optional — the column the NEW cards go to" },
1619
+ columns:{ type:"array", description:"columns: the board's columns left to right — [{ name, done?, wip? }]; mark ONE done:true. Existing columns keep their cards when their name is kept", items:{ type:"object", properties:{ name:{ type:"string" }, done:{ type:"boolean" }, wip:{ type:"integer" } }, required:["name"], additionalProperties:false } },
1620
+ start:{ type:"string", description:"start: the start day as YYYY-MM-DD (\"\" removes it)" },
1621
+ lever:{ type:"string", description:"lever: human, think, accelerate or automate (\"\" removes it)" },
1622
+ link:{ type:"string", description:"link: an https:// or mailto: address, or map:<file> to open another map (\"\" removes it)" },
1281
1623
  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." },
1282
1624
  file:{ type:"string", description:"img: full path of the screenshot (PNG/JPEG/WebP/GIF) on this machine. It is re-encoded to about 840 px and becomes the node's cover picture." },
1283
1625
  data:{ type:"string", description:"img: a data:image/…;base64 URL instead of `file`" },
@@ -1288,7 +1630,10 @@ const TOOLS = [
1288
1630
  priority:{ type:"integer", description:"priority: 1 (highest) to 6, or 0 to remove it" },
1289
1631
  due:{ type:"string", description:"due: the due day as YYYY-MM-DD (\"\" removes it)" },
1290
1632
  name:{ type:"string", description:"attr: the attribute's name, e.g. \"Estimate\"" },
1291
- value:{ type:"string", description:"attr: its value, e.g. \"2h\" (\"\" removes the attribute)" },
1633
+ // ⚠️ UNE seule déclaration de `value` (1.5.0) : il y en avait deux (booléen pour check, texte pour
1634
+ // attr) et la seconde écrasait la première — le schéma disait donc « texte » pour un check, et
1635
+ // Claude envoyait "true", que le contrôle d'un check refuse.
1636
+ value:{ type:["boolean","string"], description:"check: true to tick, false to untick. attr: its value as text, e.g. \"2h\" (\"\" removes the attribute)" },
1292
1637
  est:{ type:"string", description:"est: the estimate as a duration — \"2h\", \"1h30\", \"45\" (minutes), \"2:15\" — or \"\" to remove it" }
1293
1638
  }, required:["op"], additionalProperties:false } }
1294
1639
  }, required:["file","changes"], additionalProperties:false }
@@ -1299,18 +1644,35 @@ const HANDLERS = {
1299
1644
  async list_maps(){
1300
1645
  const maps = await loadLibrary();
1301
1646
  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 + ").";
1302
- return maps.map(m=>
1303
- `• ${m.name} [${m.kind}]${m.folder ? " 📁 " + m.folder : ""}${m.tabOf ? " ↳ tab of “" + m.tabOf + "”" : ""}\n file: ${m.file}\n ${m.kind==="board" ? m.elements + " elements" : m.nodes + " nodes"} · modified ${m.mtime}`
1304
- ).join("\n");
1647
+ const lines = maps.map(m=>
1648
+ `• ${m.name} [${m.kind}]${m.space ? " " + (m.team ? "👥 " : "◻︎ ") + m.space + (m.team ? " (" + m.role + ")" : "") : ""}${m.folder ? " 📁 " + m.folder : ""}${m.tabOf ? " ↳ tab of “" + m.tabOf + "”" : ""}\n file: ${m.file}\n ${m.kind==="board" ? m.elements + " elements" : m.nodes + " nodes"} · modified ${m.mtime}`
1649
+ );
1650
+ // Les espaces où Claude peut créer (`space` des outils de création) — y compris ceux qui sont VIDES,
1651
+ // qu'aucune carte ne ferait apparaître dans la liste.
1652
+ const sps = CLOUD && cloudSpacesCache && cloudSpacesCache.length
1653
+ ? "\n\nSpaces: " + cloudSpacesCache.map(x=>"“" + x.name + "”" + (x.role !== "owner" ? " (team, " + x.role + (x.writable ? "" : ", read-only") + ")" : "")).join(", ")
1654
+ : "";
1655
+ return lines.join("\n") + sps;
1305
1656
  },
1306
1657
  async read_map(args){
1307
- const m = await findMap(args.file);
1308
- const head = `${m.name} [${m.kind}] — file: ${m.file}\n`;
1658
+ checkRelPath(args.file);
1659
+ const all = await loadLibrary();
1660
+ const m = all.find(x=>x.file===args.file) || all.find(x=>x.file.endsWith(args.file)) || all.find(x=>x.name===args.file);
1661
+ if(!m) throw new Error("No such map: " + args.file + ". Use list_maps to see what is available.");
1662
+ const head = `${m.name} [${m.kind}]${m.space ? " — space “" + m.space + "”" + (m.team ? " (team, " + m.role + ")" : "") : ""} — file: ${m.file}\n`;
1309
1663
  if(m.kind === "board") return head + boardToText(m.doc);
1664
+ // Les cartes qu'un lien `map:` peut ouvrir, par l'id que l'app leur donne (clé, ou id d'équipe).
1665
+ const maps = new Map();
1666
+ for(const x of all){ maps.set(x.file, { name: x.name, kind: x.kind }); if(x.key) maps.set(x.key, { name: x.name, kind: x.kind }); }
1667
+ const ctx = { cols: (m.doc.kanban && Array.isArray(m.doc.kanban.cols)) ? m.doc.kanban.cols : [], tagNames: kbTagNames(m.doc), maps };
1310
1668
  // Le rappel sur propose_changes vit ici et pas dans la description de l'outil : c'est au
1311
1669
  // moment où le plan est sous les yeux qu'il est utile, et il porte la règle de désignation.
1312
- return head + nodeToMarkdown(m.doc.root, 0) + teamToText(m.doc)
1313
- + "\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.)";
1670
+ const ro = m.team && m.role === "viewer";
1671
+ return head + nodeToMarkdown(m.doc.root, 0, ctx) + kanbanToText(m.doc) + teamToText(m.doc)
1672
+ + (ro ? "\n\n(You can read this team map but not change it — the user's role in its space is viewer.)"
1673
+ : "\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. "
1674
+ + (CLOUD ? "Safe changes (add, move, tick, owner, dates, labels, lever…) are applied by TabTree itself; rename, note and delete wait for the user's approval.)"
1675
+ : "Nothing is applied until the user approves it in TabTree.)"));
1314
1676
  },
1315
1677
  async search_maps(args){
1316
1678
  const q = String(args.query||"").toLowerCase();
@@ -1345,16 +1707,36 @@ const HANDLERS = {
1345
1707
  // Les captures d'écran APRÈS les marqueurs : une désignation se retire du texte avant que le
1346
1708
  // texte serve de cible, et une capture remplace l'illustration du même nœud.
1347
1709
  const shots = applyImages(root, args.images);
1710
+ // Les DATES (1.5.0) : ce que la vue Gantt dessine. Une cible introuvable ou ambiguë est refusée
1711
+ // en le disant — une date posée sur le mauvais nœud fausserait toute la frise.
1712
+ const dated = [];
1713
+ if(args.dates != null){
1714
+ if(!Array.isArray(args.dates)) throw new Error("`dates` must be a list of { target, start, due }.");
1715
+ args.dates.forEach((d, i)=>{
1716
+ const where = "dates #" + (i+1);
1717
+ const r = propFind(root, String((d && d.target) || ""));
1718
+ if(r.error === "empty") throw new Error(where + ": `target` is required.");
1719
+ if(r.error === "missing") throw new Error(where + ": no node \"" + d.target + "\" in this outline.");
1720
+ if(r.error === "ambiguous") throw new Error(where + ": \"" + d.target + "\" matches " + r.count + " nodes — use \"Ancestor > Node\".");
1721
+ for(const k of ["start", "due"]) if(d[k]){ if(!DAY_RE.test(String(d[k]))) throw new Error(where + ": `" + k + "` must be YYYY-MM-DD."); r.node[k] = String(d[k]); }
1722
+ if(r.node.start && r.node.due && r.node.due < r.node.start){ const t = r.node.start; r.node.start = r.node.due; r.node.due = t; }
1723
+ dated.push(r.node);
1724
+ });
1725
+ }
1726
+ const view = ["note", "gantt"].includes(args.view) ? args.view : "map";
1727
+ if(view === "gantt" && !dated.length) throw new Error("A Gantt needs dates — pass `dates` with a start and/or due day for the nodes to draw.");
1728
+ assignIds(root);
1348
1729
  const doc = { v:1, root, images:[], stickies:[] };
1349
1730
  const st = okStyleMcp(args.style);
1350
1731
  if(st) doc.style = st;
1351
- const file = await writeDoc(args.name || root.text, doc, args.folder);
1732
+ const file = await writeDoc(args.name || root.text, doc, args.folder, { kind: view, space: args.space });
1352
1733
  const shotLines = shots.length ? `\n📷 ${shots.length} screenshot(s) on the map:\n` + shots.map(x=>" • " + x).join("\n") + "\n" : "";
1353
- return `✅ Map created (${countNodes(root)} nodes): ${file}\n${shotLines}${importHint()}`;
1734
+ const label = view === "note" ? "Note" : view === "gantt" ? "Gantt" : "Map";
1735
+ return `✅ ${label} created (${countNodes(root)} nodes${dated.length ? ", " + dated.length + " dated" : ""}): ${file}\n${shotLines}${importHint()}`;
1354
1736
  },
1355
1737
  async create_board(args){
1356
1738
  const { doc, warnings } = buildBoardDoc(args.name || "Board", args.elements, args.connections, args.style);
1357
- const file = await writeDoc(args.name || "Board", doc, args.folder);
1739
+ const file = await writeDoc(args.name || "Board", doc, args.folder, { space: args.space });
1358
1740
  const n = doc.shapes.length + doc.stickies.length;
1359
1741
  // Les avertissements passent AVANT l'astuce d'import : c'est le seul moment où le texte
1360
1742
  // peut encore être raccourci, et une ligne noyée en fin de réponse ne se lit pas.
@@ -1366,6 +1748,15 @@ const HANDLERS = {
1366
1748
  : "";
1367
1749
  return `✅ Board created (${n} elements, ${doc.arrows.length} connection(s)): ${file}\n${warn}${importHint()}`;
1368
1750
  },
1751
+ async create_kanban(args){
1752
+ if(typeof args.name !== "string" || !args.name.trim()) throw new Error("The `name` parameter is required (the board's name).");
1753
+ const doc = buildKanbanDoc(args);
1754
+ const file = await writeDoc(args.name, doc, args.folder, { kind: "kanban", space: args.space });
1755
+ const cards = doc.root.children;
1756
+ const per = doc.kanban.cols.map(k => k.name + " " + cards.filter(c => k.done ? c.check === true : (c.col === k.id && c.check !== true)).length).join(" · ");
1757
+ return `✅ Kanban created — ${cards.length} card(s): ${per}\n${file}\n${importHint()}`
1758
+ + (CLOUD ? "\nIt opens as a kanban. To add or move cards later, use propose_changes on this file — those changes apply by themselves." : "");
1759
+ },
1369
1760
  async propose_changes(args){
1370
1761
  const m = await findMap(args.file);
1371
1762
  // Un persona est la SEULE op qui vise un board — la work map en est un — et il ne vise que ça.
@@ -1374,28 +1765,35 @@ const HANDLERS = {
1374
1765
  throw new Error("Proposals only apply to mind maps. A board has free positions and drawings, which a change list cannot describe.");
1375
1766
  if(allPersona && m.kind !== "board")
1376
1767
  throw new Error("A persona re-casts a WORK MAP or a SECOND BRAIN BLUEPRINT canvas, which is a board. “" + m.name + "” is a mind map — pick the person's canvas from list_maps (its name starts with “Work map —”, “Cartographie —” or “Second brain —”).");
1768
+ if(m.team && m.role === "viewer") throw new Error("“" + m.name + "” is in a team space where the user is a viewer — it can be read, not changed. Ask the space's owner for the editor role.");
1377
1769
  const ops = buildProposalOps(m.doc, args.changes);
1378
- const file = await writeProposal(m, args.note, ops);
1770
+ const res = await writeProposal(m, args.note, ops, { review: allPersona });
1379
1771
  if(allPersona){
1380
- return `📮 Interview filed for “${m.name}” — ${ops[0].domains ? ops[0].domains + " domains" : ops[0].procs + " processes"}: ${file}\n`
1772
+ return `📮 Interview filed for “${m.name}” — ${ops[0].domains ? ops[0].domains + " domains" : ops[0].procs + " processes"}: ${res.ids.review}\n`
1381
1773
  + "NOTHING has been changed yet. The canvas file is untouched.\n"
1382
1774
  + (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?”. "
1383
1775
  + "One click rebuilds the canvas AND its companion sheet from these hours; Cmd+Z undoes it.\n"
1384
1776
  + "Tell the user to look at TabTree — do not claim the map has been re-cast.";
1385
1777
  }
1386
- const adds = ops.reduce((n,o)=>n + (o.adds||0), 0);
1387
- const drops = ops.reduce((n,o)=>n + (o.drops||0), 0);
1388
- const shots = ops.filter(o=>o.op === "img" && o.img);
1389
- return `📮 Proposal filed for “${m.name}” — ${ops.length} change(s)`
1390
- + (adds ? `, ${adds} node(s) to add` : "")
1391
- + (drops ? `, ${drops} node(s) to remove` : "")
1392
- + (shots.length ? `, ${shots.length} screenshot(s)` : "")
1393
- + `: ${file}\n`
1394
- + (shots.length ? shots.map(o=>" 📷 “" + String(o.was || o.target).slice(0, 60) + "” ← " + o.shot).join("\n") + "\n" : "")
1395
- + "NOTHING has been changed yet. The map file is untouched.\n"
1396
- + (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). ")
1397
- + "The user ticks the changes they want, applies them, and Cmd+Z undoes the lot.\n"
1398
- + "Tell the user to look at TabTree — do not claim the map has been updated.";
1778
+ const tally = (list)=>{
1779
+ const adds = list.reduce((n,o)=>n + (o.adds||0), 0), drops = list.reduce((n,o)=>n + (o.drops||0), 0);
1780
+ const shots = list.filter(o=>o.op === "img" && o.img).length;
1781
+ return list.length + " change(s)" + (adds ? `, ${adds} node(s) to add` : "") + (drops ? `, ${drops} node(s) to remove` : "") + (shots ? `, ${shots} screenshot(s)` : "");
1782
+ };
1783
+ const shotLines = ops.filter(o=>o.op === "img" && o.img).map(o=>" 📷 “" + String(o.was || o.target).slice(0, 60) + "” ← " + o.shot).join("\n");
1784
+ let out = "";
1785
+ if(res.auto.length){
1786
+ out += `⚡ ${tally(res.auto)} sent to “${m.name}” — TabTree applies them BY ITSELF within seconds when it is open on one of the user's devices (signed in), otherwise the next time it opens. `
1787
+ + "Cmd+Z undoes them. They have not been applied yet at this instant: do not read the map back expecting them — tell the user they are on their way.\n";
1788
+ }
1789
+ if(res.review.length){
1790
+ out += `📮 Proposal filed for “${m.name}” — ${tally(res.review)}${res.auto.length ? " (everything from the first change that rewrites or removes something)" : ""}: ${CLOUD ? "cloud:" + res.ids.review : res.ids.review}\n`
1791
+ + "NOTHING has been changed yet by these. The map is untouched until the user approves them.\n"
1792
+ + (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). ")
1793
+ + "The user ticks the changes they want, applies them, and Cmd+Z undoes the lot.\n"
1794
+ + "Tell the user to look at TabTree — do not claim those changes are made.\n";
1795
+ }
1796
+ return out + (shotLines ? shotLines + "\n" : "");
1399
1797
  }
1400
1798
  };
1401
1799
 
@@ -1417,7 +1815,7 @@ function handle(msg){
1417
1815
  // déjà dérivé (1.0.0 ici, 1.1.0 dans le manifeste) sans que rien ne le
1418
1816
  // signale : un client affiche l'une, le registre publie l'autre. Un
1419
1817
  // autotest les compare désormais toutes les quatre.
1420
- serverInfo: { name: "tabtree", version: "1.4.0" }
1818
+ serverInfo: { name: "tabtree", version: "1.5.0" }
1421
1819
  });
1422
1820
  } else if(method === "notifications/initialized" || (method||"").startsWith("notifications/")){
1423
1821
  // notification : pas de réponse