tabtree-mcp 1.2.1 → 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 -203
  2. package/package.json +4 -4
  3. package/tabtree-mcp.mjs +966 -98
package/tabtree-mcp.mjs CHANGED
@@ -7,21 +7,33 @@
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
 
21
- import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, mkdirSync } from "node:fs";
22
- import { join, basename } from "node:path";
25
+ import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
26
+ import { join, basename, resolve } from "node:path";
27
+ import { execFileSync } from "node:child_process";
28
+ import { inflateSync, deflateSync } from "node:zlib";
29
+ import { tmpdir, platform } from "node:os";
23
30
 
24
- 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;
25
37
  // ---------------------------------------------------------------------------
26
38
  // DEUX SOURCES, UN CONNECTEUR (2026-09-08). Le dossier 🛟 sert TabTree Classic (le fichier
27
39
  // local) ; le CLOUD sert l'abonné TabTree SaaS. La source est choisie par la présence d'une
@@ -36,7 +48,11 @@ let API_KEY = String(process.env.TABTREE_API_KEY || "").trim();
36
48
  // Trousseau et liée à l'installation précédente). `CLOUD = !!API_KEY` prenait ce littéral pour
37
49
  // une clé, l'envoyait, et le serveur répondait « Unknown or revoked connector key » : un
38
50
  // message FAUX, qui envoie chercher une clé révoquée là où il n'y a simplement rien de saisi.
39
- if(/^\$\{.*\}$/.test(API_KEY)) API_KEY = "";
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 = "";
40
56
  const API_URL = String(process.env.TABTREE_API_URL || "https://aynmiptyvisxoslzkzxn.supabase.co/functions/v1/mcp").trim();
41
57
  const CLOUD = !!API_KEY;
42
58
  // La FORME d'une clé se vérifie ici, avant tout aller-retour : l'app en fabrique une en
@@ -54,7 +70,9 @@ async function cloudCall(action, body){
54
70
  try{
55
71
  r = await fetch(API_URL, { method:"POST",
56
72
  headers:{ "content-type":"application/json", "x-tabtree-key": API_KEY },
57
- 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 || {})) });
58
76
  }catch(e){ throw new Error("Could not reach the TabTree cloud (" + (e && e.message || e) + ")."); }
59
77
  let j = null; try{ j = await r.json(); }catch(e){}
60
78
  if(!r.ok || !j || j.ok === false) throw new Error((j && j.error) || ("The TabTree cloud answered HTTP " + r.status + "."));
@@ -72,6 +90,7 @@ const PROP_DIR = "Propositions";
72
90
  // ---------------------------------------------------------------------------
73
91
  function dirOk(){
74
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.");
75
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.)");
76
95
  if(!existsSync(DIR)) throw new Error("The TABTREE_DIR folder does not exist: " + DIR);
77
96
  }
@@ -157,9 +176,17 @@ const ART_IDS_MCP = [
157
176
  "house", "pin", "plane", "coffee", "plant", "sun",
158
177
  "camera", "music", "lock", "key", "health", "gift",
159
178
  "manager", "client", "expert", "assistant", "coach", "learner",
160
- "developer", "seller", "support", "speaker", "freelancer", "group"
179
+ "developer", "seller", "support", "speaker", "freelancer", "group",
180
+ "uilogin", "uifeed", "uichat", "uidash", "uichart", "uishop",
181
+ "uimap", "uivideo", "uislide", "uiprofile", "uiboard", "uitable",
182
+ "atom", "dna", "flask", "microscope", "brain", "planet",
183
+ "pencil", "gradcap", "calc", "palette", "ruler", "backpack"
161
184
  ];
162
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);
163
190
  const EMO_PIC_PAT = "\\p{Extended_Pictographic}(?:\\uFE0F|\\uFE0E)?(?:\\u200D\\p{Extended_Pictographic}(?:\\uFE0F|\\uFE0E)?)*";
164
191
  const PIC_EMO_RE = (()=>{
165
192
  try{ return new RegExp("^(?:" + EMO_PIC_PAT + ")$", "u"); }
@@ -169,7 +196,7 @@ const ART = Object.fromEntries(ART_IDS_MCP.map(id=>[id, true]));
169
196
  })();
170
197
  function okPic(v){
171
198
  if(typeof v !== "string" || !v || v.length > PIC_MAX) return "";
172
- if(v.startsWith("a:")) return ART[v.slice(2)] ? v : "";
199
+ if(v.startsWith("a:")) return artHas(v.slice(2)) ? v : "";
173
200
  if(v.startsWith("e:")){
174
201
  const ch = v.slice(2);
175
202
  return (ch && PIC_EMO_RE.test(ch)) ? "e:" + ch : "";
@@ -185,6 +212,276 @@ function tplPicAssign(n){
185
212
  })(n);
186
213
  }
187
214
 
215
+ // ---------------------------------------------------------------------------
216
+ // Une CAPTURE D'ÉCRAN sur un nœud (`node.img`, 2026-09-15)
217
+ //
218
+ // Demande de l'utilisateur : « générer une mindmap avec des screenshots tirés d'une app ».
219
+ // Le champ existait de bout en bout — `node.img` est la COUVERTURE d'un nœud, tenue dans les
220
+ // cinq places, peinte sur le canevas, dans l'export et sur le recto kanban. Ce qui manquait,
221
+ // c'est la PORTE : le connecteur ne savait poser qu'une désignation (`pic`), jamais des pixels.
222
+ //
223
+ // Deux contraintes décident de tout ce qui suit :
224
+ // 1. `okCover` (index.html) n'accepte qu'un `data:image/(png|jpeg|webp|gif)` de moins de
225
+ // COVER_MAX_BYTES caractères. Une capture d'écran Retina fait 2 à 5 Mo : embarquée telle
226
+ // quelle, l'app la REFUSE à l'ouverture et le nœud arrive nu — sans une erreur. Il faut donc
227
+ // ré-encoder ICI, avant d'écrire. La constante et la fonction sont portées MOT POUR MOT et le
228
+ // test les compare à l'app : si elles divergent, le connecteur écrit une image que l'app
229
+ // efface, et personne ne sait pourquoi.
230
+ // 2. Le connecteur est ZÉRO dépendance et Node n'a aucun codec d'image. Trois encodeurs, dans
231
+ // l'ordre : `sips` (livré avec macOS), `magick`/`convert` (ImageMagick, s'il est là), et un
232
+ // chemin PUR JS — décodeur PNG (zlib est natif), réduction par moyenne de zone, encodeur PNG.
233
+ // Le chemin JS ne lit que du PNG, ce qui est le format de TOUTES les captures que Claude
234
+ // produit (navigateur, computer-use, simulateur). Quand rien ne tient dans le budget, on
235
+ // REFUSE en nommant la limite — jamais une image tronquée, jamais un nœud silencieusement nu.
236
+ //
237
+ // Le côté long est ré-encodé à IMG_LONG = 840 px, soit 2 × COVER_MAX_DIM : l'app affiche une
238
+ // couverture à 420 px au plus, et 840 la garde nette sur un écran Retina. Au-delà on stockerait
239
+ // des pixels que personne ne voit. Le budget par image (IMG_BUDGET) est bien plus bas que
240
+ // COVER_MAX_BYTES, et c'est délibéré : vingt captures à 900 Ko feraient un document de 18 Mo, lu
241
+ // SYNCHRONIQUEMENT depuis le miroir localStorage de l'app. À 840 px en JPEG, une capture pèse
242
+ // 80 à 200 Ko ; en PNG (chemin JS) 150 à 400 Ko — d'où les trois paliers de repli.
243
+ // ---------------------------------------------------------------------------
244
+ const COVER_MAX_BYTES = 900000; // ~900 Ko de data URL : au-delà, on refuse
245
+ function okCover(v){
246
+ return (typeof v === "string" && v.length <= COVER_MAX_BYTES
247
+ && /^data:image\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/=]+$/.test(v)) ? v : "";
248
+ }
249
+ const IMG_LONG = 840; // côté long visé (2 × COVER_MAX_DIM de l'app)
250
+ const IMG_STEPS = [840, 600, 420]; // paliers de repli quand le budget ne tient pas
251
+ const IMG_BUDGET = 320000; // data URL par image — bien sous COVER_MAX_BYTES, voir ci-dessus
252
+ const IMG_FILE_MAX = 40 * 1024 * 1024; // un fichier plus gros n'est pas une capture d'écran
253
+ const IMG_JPEG_Q = 82;
254
+ const IMG_MIMES = { png:"image/png", jpeg:"image/jpeg", webp:"image/webp", gif:"image/gif" };
255
+
256
+ // Sonde les octets, jamais l'extension : un « .png » qui contient du JPEG s'ouvre partout, et
257
+ // c'est l'octet qui décide de ce que l'app saura décoder.
258
+ function imageInfo(buf){
259
+ if(!buf || buf.length < 12) return null;
260
+ if(buf[0] === 0x89 && buf.toString("latin1", 1, 4) === "PNG" && buf.length >= 24)
261
+ return { kind:"png", w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) };
262
+ if(buf[0] === 0xFF && buf[1] === 0xD8){
263
+ let i = 2;
264
+ while(i + 9 < buf.length){
265
+ if(buf[i] !== 0xFF){ i++; continue; }
266
+ const m = buf[i + 1];
267
+ if(m === 0xFF){ i++; continue; }
268
+ if(m >= 0xC0 && m <= 0xCF && m !== 0xC4 && m !== 0xC8 && m !== 0xCC)
269
+ return { kind:"jpeg", h: buf.readUInt16BE(i + 5), w: buf.readUInt16BE(i + 7) };
270
+ if(m === 0xD8 || (m >= 0xD0 && m <= 0xD7) || m === 0x01){ i += 2; continue; }
271
+ i += 2 + buf.readUInt16BE(i + 2);
272
+ }
273
+ return { kind:"jpeg", w:0, h:0 };
274
+ }
275
+ if(buf.toString("latin1", 0, 4) === "GIF8")
276
+ return { kind:"gif", w: buf.readUInt16LE(6), h: buf.readUInt16LE(8) };
277
+ if(buf.toString("latin1", 0, 4) === "RIFF" && buf.toString("latin1", 8, 12) === "WEBP" && buf.length >= 30){
278
+ const c = buf.toString("latin1", 12, 16);
279
+ if(c === "VP8 ") return { kind:"webp", w: buf.readUInt16LE(26) & 0x3fff, h: buf.readUInt16LE(28) & 0x3fff };
280
+ if(c === "VP8L"){ const b = buf.subarray(21, 25); return { kind:"webp", w: 1 + (((b[1] & 0x3F) << 8) | b[0]), h: 1 + (((b[3] & 0xF) << 10) | (b[2] << 2) | ((b[1] & 0xC0) >> 6)) }; }
281
+ if(c === "VP8X") return { kind:"webp", w: 1 + buf.readUIntLE(24, 3), h: 1 + buf.readUIntLE(27, 3) };
282
+ return { kind:"webp", w:0, h:0 };
283
+ }
284
+ return null;
285
+ }
286
+ function dataUrlOf(buf, kind){ return "data:" + IMG_MIMES[kind] + ";base64," + buf.toString("base64"); }
287
+
288
+ // --- Le chemin PUR JS : PNG → RGB, réduction, → PNG ------------------------------------
289
+ // 8 bits, non entrelacé, types 0/2/3/4/6 : c'est ce qu'écrivent Chrome, macOS et Windows pour
290
+ // une capture. Le reste (16 bits, entrelacé) est refusé en le disant. L'alpha est composité
291
+ // sur du BLANC — même règle qu'`avatarFromFile` dans l'app : une capture transparente sortirait
292
+ // noire dans un JPEG, et grise dans l'export.
293
+ function pngDecode(buf){
294
+ let i = 8, w = 0, h = 0, depth = 0, ct = 0, inter = 0, plte = null;
295
+ const idat = [];
296
+ while(i + 8 <= buf.length){
297
+ const len = buf.readUInt32BE(i), type = buf.toString("latin1", i + 4, i + 8);
298
+ const data = buf.subarray(i + 8, i + 8 + len);
299
+ if(type === "IHDR"){ w = data.readUInt32BE(0); h = data.readUInt32BE(4); depth = data[8]; ct = data[9]; inter = data[12]; }
300
+ else if(type === "PLTE") plte = data;
301
+ else if(type === "IDAT") idat.push(data);
302
+ else if(type === "IEND") break;
303
+ i += 12 + len;
304
+ }
305
+ if(!w || !h) throw new Error("the PNG has no IHDR");
306
+ if(depth !== 8) throw new Error("a " + depth + "-bit PNG — only 8-bit PNGs can be re-encoded here");
307
+ if(inter) throw new Error("an interlaced PNG cannot be re-encoded here");
308
+ const ch = { 0:1, 2:3, 3:1, 4:2, 6:4 }[ct];
309
+ if(!ch || (ct === 3 && !plte)) throw new Error("unsupported PNG colour type " + ct);
310
+ const raw = inflateSync(Buffer.concat(idat));
311
+ const stride = w * ch, rgb = new Uint8Array(w * h * 3);
312
+ let prev = new Uint8Array(stride), cur = new Uint8Array(stride), p = 0;
313
+ for(let y = 0; y < h; y++){
314
+ const f = raw[p++];
315
+ for(let x = 0; x < stride; x++){
316
+ const a = x >= ch ? cur[x - ch] : 0, b = prev[x], c = x >= ch ? prev[x - ch] : 0;
317
+ let v = raw[p++];
318
+ if(f === 1) v += a; else if(f === 2) v += b; else if(f === 3) v += (a + b) >> 1;
319
+ else if(f === 4){ const pa = Math.abs(b - c), pb = Math.abs(a - c), pc = Math.abs(a + b - 2 * c); v += (pa <= pb && pa <= pc) ? a : (pb <= pc ? b : c); }
320
+ cur[x] = v & 255;
321
+ }
322
+ for(let x = 0; x < w; x++){
323
+ const q = x * ch; let r, g, bl, al = 255;
324
+ if(ct === 0){ r = g = bl = cur[q]; }
325
+ else if(ct === 4){ r = g = bl = cur[q]; al = cur[q + 1]; }
326
+ else if(ct === 2){ r = cur[q]; g = cur[q + 1]; bl = cur[q + 2]; }
327
+ else if(ct === 6){ r = cur[q]; g = cur[q + 1]; bl = cur[q + 2]; al = cur[q + 3]; }
328
+ else { const k = cur[q] * 3; r = plte[k]; g = plte[k + 1]; bl = plte[k + 2]; }
329
+ if(al < 255){ r = (r * al + 255 * (255 - al)) / 255 | 0; g = (g * al + 255 * (255 - al)) / 255 | 0; bl = (bl * al + 255 * (255 - al)) / 255 | 0; }
330
+ const o = (y * w + x) * 3; rgb[o] = r; rgb[o + 1] = g; rgb[o + 2] = bl;
331
+ }
332
+ const t = prev; prev = cur; cur = t;
333
+ }
334
+ return { w, h, rgb };
335
+ }
336
+ // Moyenne de zone : chaque pixel de sortie est la moyenne du rectangle source qu'il couvre.
337
+ // C'est ce qui garde lisible le texte d'une capture réduite d'un facteur 3 — un simple
338
+ // sous-échantillonnage saute des lignes de texte entières.
339
+ function rgbScale(src, W, H){
340
+ const { w, h, rgb } = src, out = new Uint8Array(W * H * 3);
341
+ for(let Y = 0; Y < H; Y++){
342
+ const y0 = Math.floor(Y * h / H), y1 = Math.max(y0 + 1, Math.floor((Y + 1) * h / H));
343
+ for(let X = 0; X < W; X++){
344
+ const x0 = Math.floor(X * w / W), x1 = Math.max(x0 + 1, Math.floor((X + 1) * w / W));
345
+ let r = 0, g = 0, b = 0, n = 0;
346
+ for(let y = y0; y < y1; y++){ let o = (y * w + x0) * 3; for(let x = x0; x < x1; x++){ r += rgb[o]; g += rgb[o + 1]; b += rgb[o + 2]; o += 3; n++; } }
347
+ const q = (Y * W + X) * 3; out[q] = r / n | 0; out[q + 1] = g / n | 0; out[q + 2] = b / n | 0;
348
+ }
349
+ }
350
+ return { w: W, h: H, rgb: out };
351
+ }
352
+ const CRC_T = (()=>{ const t = new Int32Array(256); for(let n = 0; n < 256; n++){ let c = n; for(let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); t[n] = c; } return t; })();
353
+ function crc32(buf){ let c = -1; for(let i = 0; i < buf.length; i++) c = CRC_T[(c ^ buf[i]) & 255] ^ (c >>> 8); return (c ^ -1) >>> 0; }
354
+ function pngEncode(img){
355
+ const { w, h, rgb } = img;
356
+ const raw = Buffer.alloc((w * 3 + 1) * h);
357
+ for(let y = 0; y < h; y++){ raw[y * (w * 3 + 1)] = 0; raw.set(rgb.subarray(y * w * 3, (y + 1) * w * 3), y * (w * 3 + 1) + 1); }
358
+ const chunk = (type, data)=>{
359
+ const out = Buffer.alloc(12 + data.length);
360
+ out.writeUInt32BE(data.length, 0); out.write(type, 4, "latin1"); data.copy(out, 8);
361
+ out.writeUInt32BE(crc32(out.subarray(4, 8 + data.length)), 8 + data.length);
362
+ return out;
363
+ };
364
+ const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 2;
365
+ return Buffer.concat([Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]), chunk("IHDR", ihdr), chunk("IDAT", deflateSync(raw, { level: 9 })), chunk("IEND", Buffer.alloc(0))]);
366
+ }
367
+
368
+ // --- Les encodeurs, et l'ordre dans lequel on les essaie ---------------------------------
369
+ // `TABTREE_IMG_ENCODER` force un chemin (js | sips | magick) — c'est ce qui permet au test
370
+ // d'exercer le chemin pur JS sur une machine qui a `sips`, et à qui veut un rendu identique
371
+ // sur toutes ses machines de le demander.
372
+ function encoders(){
373
+ const want = String(process.env.TABTREE_IMG_ENCODER || "").trim();
374
+ if(want) return [want];
375
+ return platform() === "darwin" ? ["sips", "magick", "js"] : ["magick", "js"];
376
+ }
377
+ function withTmpFiles(fn){
378
+ const dir = mkdtempSync(join(tmpdir(), "tabtree-img-"));
379
+ try{ return fn(dir); }
380
+ finally{ try{ rmSync(dir, { recursive:true, force:true }); }catch(e){} }
381
+ }
382
+ // Rend { buf, kind, w, h } ou lève. `long` = côté long visé.
383
+ function encodeWith(name, buf, info, long){
384
+ if(name === "js"){
385
+ if(info.kind !== "png") throw new Error("the built-in encoder only reads PNG");
386
+ const src = pngDecode(buf);
387
+ const k = Math.min(1, long / Math.max(src.w, src.h));
388
+ const W = Math.max(1, Math.round(src.w * k)), H = Math.max(1, Math.round(src.h * k));
389
+ const out = pngEncode(k < 1 ? rgbScale(src, W, H) : src);
390
+ return { buf: out, kind:"png", w: W, h: H };
391
+ }
392
+ return withTmpFiles(dir=>{
393
+ const inp = join(dir, "in." + (info.kind === "jpeg" ? "jpg" : info.kind)), outp = join(dir, "out.jpg");
394
+ writeFileSync(inp, buf);
395
+ if(name === "sips"){
396
+ execFileSync("sips", ["-Z", String(long), "-s", "format", "jpeg", "-s", "formatOptions", String(IMG_JPEG_Q), inp, "--out", outp], { stdio:"ignore", timeout: 20000 });
397
+ } else if(name === "magick"){
398
+ const bin = ["magick", "convert"];
399
+ let ok = false, last = null;
400
+ for(const b of bin){
401
+ try{ execFileSync(b, [inp, "-resize", long + "x" + long + ">", "-quality", String(IMG_JPEG_Q), outp], { stdio:"ignore", timeout: 20000 }); ok = true; break; }
402
+ catch(e){ last = e; }
403
+ }
404
+ if(!ok) throw last || new Error("ImageMagick is not installed");
405
+ } else throw new Error("unknown encoder " + name);
406
+ const out = readFileSync(outp), oi = imageInfo(out);
407
+ if(!oi || oi.kind !== "jpeg") throw new Error(name + " produced no JPEG");
408
+ return { buf: out, kind:"jpeg", w: oi.w, h: oi.h };
409
+ });
410
+ }
411
+
412
+ // --- La porte : un fichier ou une data URL → une couverture que l'app acceptera ---------
413
+ // Rend { img, note } — `note` dit ce qui a été fait, pour la réponse : une image ré-encodée
414
+ // à l'insu de Claude serait une capture qu'il croit envoyer en pleine résolution.
415
+ function readImageSpec(spec, where){
416
+ if(!spec || typeof spec !== "object") throw new Error(`${where}: give \`file\` (a path on this machine) or \`data\` (a data:image/… URL).`);
417
+ if(typeof spec.data === "string" && spec.data){
418
+ const m = /^data:image\/(png|jpeg|jpg|webp|gif);base64,([A-Za-z0-9+/=\s]+)$/.exec(spec.data.trim());
419
+ if(!m) throw new Error(`${where}: \`data\` must be a data:image/(png|jpeg|webp|gif);base64,… URL.`);
420
+ return { buf: Buffer.from(m[2].replace(/\s+/g, ""), "base64"), label: "inline image" };
421
+ }
422
+ if(typeof spec.file === "string" && spec.file.trim()){
423
+ const p = resolve(spec.file.trim());
424
+ if(!existsSync(p)) throw new Error(`${where}: no file at ${p}. Save the screenshot to disk first (a PNG), then pass its full path.`);
425
+ const st = statSync(p);
426
+ if(!st.isFile()) throw new Error(`${where}: ${p} is not a file.`);
427
+ if(st.size > IMG_FILE_MAX) throw new Error(`${where}: ${basename(p)} is ${(st.size / 1048576).toFixed(0)} MB — too large for a screenshot (40 MB max).`);
428
+ return { buf: readFileSync(p), label: basename(p) };
429
+ }
430
+ throw new Error(`${where}: give \`file\` (a path on this machine) or \`data\` (a data:image/… URL).`);
431
+ }
432
+ function coverFrom(spec, where){
433
+ const { buf, label } = readImageSpec(spec, where);
434
+ const info = imageInfo(buf);
435
+ if(!info) throw new Error(`${where}: ${label} is not a PNG, JPEG, WebP or GIF image.`);
436
+ const long = Math.max(info.w, info.h);
437
+ const src = `${info.w}×${info.h} ${info.kind.toUpperCase()}`;
438
+ // Tel quel quand ça tient DÉJÀ — le budget et la taille : ré-encoder une image qui va bien
439
+ // ne ferait que la dégrader.
440
+ if(long && long <= IMG_LONG){
441
+ const d = dataUrlOf(buf, info.kind);
442
+ if(d.length <= IMG_BUDGET && okCover(d)) return { img: d, note: `${label} (${src}, ${Math.round(d.length / 1024)} KB, kept as is)` };
443
+ }
444
+ const tried = [];
445
+ for(const step of IMG_STEPS){
446
+ if(long && step > long && step !== IMG_STEPS[IMG_STEPS.length - 1]) continue; // ne pas « réduire » vers plus grand
447
+ for(const enc of encoders()){
448
+ let out;
449
+ try{ out = encodeWith(enc, buf, info, step); }
450
+ catch(e){ tried.push(enc + "@" + step + ": " + (e && e.message || e).split("\n")[0]); continue; }
451
+ const d = dataUrlOf(out.buf, out.kind);
452
+ if(d.length <= IMG_BUDGET && okCover(d))
453
+ return { img: d, note: `${label} (${src} → ${out.w}×${out.h} ${out.kind.toUpperCase()}, ${Math.round(d.length / 1024)} KB, via ${enc})` };
454
+ tried.push(enc + "@" + step + ": " + Math.round(d.length / 1024) + " KB, over the " + Math.round(IMG_BUDGET / 1024) + " KB budget");
455
+ }
456
+ }
457
+ throw new Error(`${where}: ${label} (${src}) could not be brought under ${Math.round(IMG_BUDGET / 1024)} KB. `
458
+ + (info.kind === "png" ? "Crop it to the part that matters, or " : "Save it as a PNG (the built-in encoder only reads PNG), or ")
459
+ + "install ImageMagick. Tried: " + tried.join("; "));
460
+ }
461
+ // Pose les captures d'une carte NEUVE : chaque entrée désigne un nœud par son texte (ou un
462
+ // chemin « Parent > Nœud »), exactement comme une proposition — une désignation qui touche deux
463
+ // nœuds est refusée, jamais devinée. Une capture REMPLACE une illustration `pic` : deux images
464
+ // sur un nœud seraient deux vérités pour une question, et c'est `deserialize` qui trancherait.
465
+ function applyImages(root, images){
466
+ if(images == null) return [];
467
+ if(!Array.isArray(images)) throw new Error("`images` must be a list of { target, file } (or { target, data }).");
468
+ if(images.length > 60) throw new Error("Too many screenshots in one map (60 max).");
469
+ const notes = [];
470
+ images.forEach((im, i)=>{
471
+ const where = "images[" + i + "]";
472
+ const target = String((im && im.target) == null ? "" : im.target);
473
+ const r = propFind(root, target);
474
+ if(r.error === "empty") throw new Error(`${where}: \`target\` is required — the exact text of the node the screenshot goes on.`);
475
+ if(r.error === "missing") throw new Error(`${where}: no node "${target}" in this outline. Use the exact wording of a line (without its bullet, checkbox or @-marker).`);
476
+ if(r.error === "ambiguous") throw new Error(`${where}: "${target}" matches ${r.count} nodes. Name an ancestor too, e.g. "Parent > ${parts_last(target)}".`);
477
+ const { img, note } = coverFrom(im, where);
478
+ r.node.img = img;
479
+ if(r.node.pic) delete r.node.pic;
480
+ notes.push("“" + String(r.node.text || "").slice(0, 60) + "” ← " + note);
481
+ });
482
+ return notes;
483
+ }
484
+
188
485
  // ---------------------------------------------------------------------------
189
486
  // Désignation d'un nœud (partagé mot pour mot avec l'app — voir tabtree-mcp.test.mjs)
190
487
  //
@@ -226,14 +523,48 @@ function propFind(root, spec){
226
523
  // ---------------------------------------------------------------------------
227
524
  // Arbre → markdown (lecture d'une carte)
228
525
  // ---------------------------------------------------------------------------
229
- function nodeToMarkdown(n, depth){
526
+ // Les marqueurs ⟪…⟫ disent ce qu'un plan ne montre pas (2026-10-12) : les dépendances (par le
527
+ // TEXTE du nœud attendu, résolu ici — Claude ne lit pas des ids), la récurrence, le nombre de
528
+ // commentaires et l'estimation. Sans eux, Claude proposerait une date avant celle du
529
+ // prédécesseur ou cocherait une tâche qui repart aussitôt, sans rien savoir de ce qu'il fait.
530
+ const RECUR_LABEL = { d:"daily", wd:"weekdays", w:"weekly", "2w":"every 2 weeks", m:"monthly", q:"quarterly", y:"yearly" };
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"); }
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){
230
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;
540
+ const byId = new Map();
541
+ (function idx(node){ if(node && node.id) byId.set(node.id, node); (node.children||[]).forEach(idx); })(n);
231
542
  (function walk(node, d){
232
543
  let bullet;
233
544
  if(d===0) bullet = "# ";
234
545
  else bullet = " ".repeat(d-1) + (node.check!=null ? (node.check ? "- [x] " : "- [ ] ") : "- ");
235
- const body = node.link ? `[${node.text}](${node.link})` : node.text;
236
- lines.push(bullet + body + (node.note && node.note.trim() ? " ⟪note⟫" : ""));
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;
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(", ") + "⟫");
559
+ if(node.note && node.note.trim()) marks.push("⟪note⟫");
560
+ if(Array.isArray(node.deps) && node.deps.length){
561
+ const names = node.deps.map(id => byId.get(id)).filter(Boolean).map(p => String(p.text || "").trim()).filter(Boolean);
562
+ if(names.length) marks.push("⟪waits on: " + names.join(", ") + "⟫");
563
+ }
564
+ if(node.recur && RECUR_LABEL[node.recur]) marks.push("⟪repeats " + RECUR_LABEL[node.recur] + "⟫");
565
+ if(Array.isArray(node.cmts) && node.cmts.length) marks.push("⟪" + node.cmts.length + " comment" + (node.cmts.length > 1 ? "s" : "") + "⟫");
566
+ if(typeof node.est === "number" && node.est > 0) marks.push("⟪est " + durText(node.est) + "⟫");
567
+ lines.push(bullet + body + (marks.length ? " " + marks.join(" ") : ""));
237
568
  (node.children||[]).forEach(c=>walk(c, d+1));
238
569
  })(n, depth||0);
239
570
  return lines.join("\n");
@@ -454,8 +785,17 @@ function readManifest(){
454
785
  while(f && !seen.has(f.id) && out.length <= 16){ seen.add(f.id); out.unshift(f.name); f = f.parentId ? folders.get(f.parentId) : null; }
455
786
  return out.length ? out.join(" / ") : null;
456
787
  };
788
+ // Un ONGLET (2026-09-15) est un document CACHÉ de la bibliothèque : `sub` porte l'id de son
789
+ // classeur. Sans le lire, Claude verrait « Sheet 2 » comme une carte ordinaire, sans savoir
790
+ // de quel classeur elle est la seconde page — et proposerait de la ranger, ou de la lier.
791
+ const byId = new Map((m.maps||[]).filter(x=>x && x.id).map(x=>[x.id, x]));
792
+ const okId = v => typeof v === "string" && /^[A-Za-z0-9_]{1,64}$/.test(v);
457
793
  const byFile = new Map();
458
- (m.maps||[]).forEach(x=>{ if(x && x.file) byFile.set(x.file, { name:x.name, folder: pathOf(x.folderId) }); });
794
+ (m.maps||[]).forEach(x=>{
795
+ if(!x || !x.file) return;
796
+ const book = okId(x.sub) && x.sub !== x.id ? byId.get(x.sub) : null;
797
+ byFile.set(x.file, { name:x.name, folder: pathOf(x.folderId), tabOf: book ? (book.name || book.id) : null });
798
+ });
459
799
  return byFile;
460
800
  }catch(e){ return new Map(); }
461
801
  }
@@ -486,6 +826,7 @@ function scanLibrary(){
486
826
  file: rel,
487
827
  name: meta.name || ent.name.replace(/\.(tabtree|ygmind|json)$/i, "").replace(/__map[A-Za-z0-9_]+$/, ""),
488
828
  folder: meta.folder || folder || null,
829
+ tabOf: meta.tabOf || null, // le classeur dont ce document est un onglet, sinon null
489
830
  kind: doc.board ? "board" : "map",
490
831
  nodes: countNodes(doc.root),
491
832
  elements: doc.board ? ((doc.shapes||[]).length + (doc.stickies||[]).length + (doc.arrows||[]).length + (doc.draws||[]).length) : undefined,
@@ -499,26 +840,56 @@ function scanLibrary(){
499
840
  return out;
500
841
  }
501
842
  // La bibliothèque du COMPTE, présentée exactement comme celle du dossier : mêmes champs, donc
502
- // les six outils n'ont rien à savoir de la source. `file` porte la clé de la carte (map_key),
503
- // que `propose_changes` renvoie telle quelle — pas de suffixe « __id » à analyser ici.
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
504
855
  async function cloudLibrary(){
505
- const j = await cloudCall("docs", {});
506
- const folders = new Map((j.folders||[]).filter(f=>f && f.folder_key).map(f=>[f.folder_key, f]));
507
- const pathOf = (k)=>{
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)=>{
508
871
  const out = [], seen = new Set();
509
- let f = folders.get(k);
510
- while(f && !seen.has(f.folder_key) && out.length <= 16){ seen.add(f.folder_key); out.unshift(f.name); f = f.parent_key ? folders.get(f.parent_key) : null; }
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; }
511
874
  return out.length ? out.join(" / ") : null;
512
875
  };
513
876
  const out = [];
514
- for(const r of (j.maps||[])){
877
+ for(const r of pages){
515
878
  const doc = r && r.doc;
516
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;
517
884
  out.push({
518
- 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,
519
888
  name: String(r.name || "") || String(doc.root.text || "").slice(0, 60) || r.map_key,
520
- folder: pathOf(r.folder_key),
521
- kind: doc.board ? "board" : "map",
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"),
522
893
  nodes: countNodes(doc.root),
523
894
  elements: doc.board ? ((doc.shapes||[]).length + (doc.stickies||[]).length + (doc.arrows||[]).length + (doc.draws||[]).length) : undefined,
524
895
  mtime: String(r.updated_at || "").slice(0,16).replace("T"," "),
@@ -573,19 +944,70 @@ async function findMap(file){
573
944
  // ---------------------------------------------------------------------------
574
945
  // Écriture (toujours un NOUVEAU fichier — jamais de modification en place)
575
946
  // ---------------------------------------------------------------------------
576
- async function writeDoc(name, doc, folder){
947
+ async function writeDoc(name, doc, folder, opts){
948
+ const o = opts || {};
577
949
  if(CLOUD){
578
- // Le dossier est un NOM de dossier de bibliothèque : on le résout en clé s'il existe déjà,
579
- // sinon la carte arrive à la racine et le texte de réponse le dit (« no silent caps »).
580
- let folder_key = null;
950
+ // Le dossier est un NOM ou un CHEMIN de dossier de bibliothèque : on le résout en clé s'il
951
+ // existe déjà, sinon la carte arrive à la racine et le texte de réponse le dit
952
+ // (« no silent caps »).
953
+ //
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;
581
980
  if(folder){
582
- const j = await cloudCall("list", {});
583
- const hit = (j.folders||[]).find(f=>f && String(f.name||"").trim().toLowerCase() === String(folder).trim().toLowerCase());
584
- if(hit) folder_key = hit.folder_key;
981
+ const want = norm(folder);
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; }
988
+ }
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);
998
+ let where = "";
999
+ if(f0){
1000
+ where = " (in “" + chain(f0).join(" / ") + "”" + (inSpace ? ", space “" + inSpace + "”" : "") + ")";
1001
+ if(folder_twins > 1) where += " ⚠ " + folder_twins + " folders are named “" + folder
1002
+ + "” in this account; the shallowest was used. Pass the full path (e.g. “" + folder + " / " + folder
1003
+ + "”) to choose another one.";
1004
+ } else if(folder){
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" : "") + ")";
585
1008
  }
586
- const kind = doc.board ? "board" : (doc.kanban ? "kanban" : "map");
587
- const r = await cloudCall("create", { name: String(name||"").slice(0,200), kind, doc, folder_key });
588
- return "cloud:" + r.map_key + (folder && !folder_key ? " (folder “" + folder + "” does not exist in this account — the map is at the root)" : "");
1009
+ const id = (owner && me && owner !== me) ? teamLocalId(owner, r.map_key) : r.map_key;
1010
+ return "cloud:" + (id || r.map_key) + where;
589
1011
  }
590
1012
  dirOk();
591
1013
  let target = DIR;
@@ -616,20 +1038,78 @@ function countForest(roots){ return roots.reduce((n,r)=>n + countNodes(r), 0); }
616
1038
  // connecteur la refuserait à l'écriture, ou l'app la jetterait à la lecture — dans les deux
617
1039
  // cas Claude croirait avoir proposé quelque chose qui n'arrive jamais chez l'utilisateur.
618
1040
  // C'est le défaut du losange, rejoué. Un test compare les deux listes en ensembles.
619
- const PROP_OPS = ["add","rename","note","check","delete","move","pic","persona"];
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)); }
1057
+ // Les dix étiquettes de couleur du kanban — ≡ `KB_TAGS` de index.html (le test compare les deux).
1058
+ const KB_TAG_KEYS = ["green","yellow","orange","red","purple","blue","sky","lime","pink","gray"];
620
1059
 
621
1060
  // Tout est vérifié ICI, contre la carte réelle, et un défaut fait ÉCHOUER l'appel. C'est
622
1061
  // délibéré : une cible introuvable remonte à Claude, qui peut relire la carte et corriger,
623
1062
  // alors qu'une proposition à moitié valide arriverait chez l'utilisateur sous forme de
624
1063
  // lignes barrées qu'il ne peut ni comprendre ni réparer. La boîte aux lettres ne reçoit
625
1064
  // que des propositions applicables.
626
- 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){
627
1070
  if(!Array.isArray(changes) || !changes.length) throw new Error("`changes` must be a non-empty list.");
628
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
+ };
629
1083
  return changes.map((c, i)=>{
630
1084
  const where = "change #" + (i+1);
631
1085
  const op = String((c && c.op) || "").trim();
632
- 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
+ }
633
1113
  if(op === "persona"){
634
1114
  // `persona` (2026-09-08) vise le DOCUMENT entier — la toile d'une work map — jamais un
635
1115
  // nœud : donc pas de `target`. C'est le relais skill → carte sans copier-coller : le skill
@@ -641,11 +1121,18 @@ function buildProposalOps(mapDoc, changes){
641
1121
  // profondeur — « une proposition à moitié valide arrive en lignes barrées » — ne
642
1122
  // s'applique pas : une op persona est ATOMIQUE, elle rebâtit la toile ou ne fait rien.
643
1123
  const P = c.persona;
644
- if(!P || typeof P !== "object" || !Array.isArray(P.modules) || !P.modules.length)
645
- throw new Error(`${where}: \`persona\` must be the interview object — { who, role, mission, tools, meetings, opener, modules:[{ name, procs:[{ name, h, after, freq, rep, data, stakes, lever, step, why }] }], trajectory, signs, parking }.`);
1124
+ // DEUX personas (2026-09-11) : celui d'une WORK MAP porte des `modules`, celui d'un SECOND
1125
+ // CERVEAU des `domains`. Même contrôle superficiel — `okBrain` juge à l'application.
1126
+ const isBrain = !!(P && typeof P === "object" && Array.isArray(P.domains) && P.domains.length);
1127
+ if(!P || typeof P !== "object" || (!isBrain && (!Array.isArray(P.modules) || !P.modules.length)))
1128
+ throw new Error(`${where}: \`persona\` must be the interview object — a work map { who, role, mission, tools, meetings, opener, modules:[{ name, procs:[{ name, h, after, freq, rep, data, stakes, lever, step, why }] }], trajectory, signs, parking }, or a second-brain blueprint { who, role, mission, tools, opener, domains:[{ name, kind, share, holds }], losses, outputs, sources, nevermix, cadence, review, ailevel, signs }.`);
1129
+ if(JSON.stringify(P).length > 60000) throw new Error(`${where}: the persona is too large (60000 characters max).`);
1130
+ if(isBrain){
1131
+ if(P.domains.length > 5) throw new Error(`${where}: the blueprint has ${P.domains.length} domains — five at most.`);
1132
+ return { op, persona: P, procs: 0, domains: P.domains.length };
1133
+ }
646
1134
  const n = P.modules.reduce((a, m)=>a + ((m && Array.isArray(m.procs)) ? m.procs.length : 0), 0);
647
1135
  if(n < 3) throw new Error(`${where}: the persona has ${n} process(es) — a work map needs at least three. Finish the interview first.`);
648
- if(JSON.stringify(P).length > 60000) throw new Error(`${where}: the persona is too large (60000 characters max).`);
649
1136
  return { op, persona: P, procs: n };
650
1137
  }
651
1138
  const target = String(c.target == null ? "" : c.target);
@@ -654,6 +1141,14 @@ function buildProposalOps(mapDoc, changes){
654
1141
  if(r.error === "missing") throw new Error(`${where}: no node "${target}" in this map. Run read_map first and copy the wording exactly.`);
655
1142
  if(r.error === "ambiguous") throw new Error(`${where}: "${target}" matches ${r.count} nodes. Name an ancestor too, e.g. "Parent > ${parts_last(target)}".`);
656
1143
  const out = { op, target, was: String(r.node.text||"") };
1144
+ // `why` (2026-10-12) : la RAISON d'un changement, en une ligne — l'app la montre sous la ligne
1145
+ // à approuver. ≡ la règle de `parseProposal` côté app (une ligne, 200 caractères) : une raison
1146
+ // trop longue est rognée, jamais refusée ; une raison qui n'est pas du texte, si.
1147
+ if(c.why != null){
1148
+ if(typeof c.why !== "string") throw new Error(`${where}: \`why\` must be text — one short line saying why this change.`);
1149
+ const w = c.why.replace(/[\x00-\x1f\x7f]/g, " ").replace(/\s+/g, " ").trim().slice(0, 200);
1150
+ if(w) out.why = w;
1151
+ }
657
1152
  if(op === "add"){
658
1153
  if(typeof c.markdown !== "string" || !c.markdown.trim()) throw new Error(`${where}: \`markdown\` is required for an "add".`);
659
1154
  if(c.markdown.length > 20000) throw new Error(`${where}: \`markdown\` is too long (20000 characters max).`);
@@ -661,18 +1156,35 @@ function buildProposalOps(mapDoc, changes){
661
1156
  if(!forest.length) throw new Error(`${where}: \`markdown\` has no usable structure (a bullet list, indented by 2 spaces).`);
662
1157
  out.markdown = c.markdown;
663
1158
  out.adds = countForest(forest);
1159
+ out.nid = newNid();
1160
+ // `col` facultatif (2026-10-12) : les cartes NEUVES vont dans cette colonne du kanban.
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);
1166
+ // `assign` facultatif (2026-10-12, 👥) : le RESPONSABLE des cartes neuves. Mêmes bornes que l'op
1167
+ // `assign` ; refusé ICI en le disant, plutôt que de laisser l'app le retirer en silence.
1168
+ if(c.assign != null && c.assign !== ""){
1169
+ if(typeof c.assign !== "string" || c.assign.length > 120 || /[\x00-\x1f\x7f]/.test(c.assign))
1170
+ throw new Error(`${where}: \`assign\` must be a name or e-mail (120 characters max) — the owner of the new cards.`);
1171
+ if(c.assign.trim()) out.assign = c.assign.trim();
1172
+ }
664
1173
  } else if(op === "rename"){
665
1174
  if(typeof c.text !== "string" || !c.text.trim()) throw new Error(`${where}: \`text\` is required for a "rename".`);
666
1175
  out.text = c.text.trim().slice(0, 2000);
1176
+ r.node.text = out.text;
667
1177
  } else if(op === "note"){
668
1178
  if(typeof c.note !== "string") throw new Error(`${where}: \`note\` is required for a "note" (an empty string clears it).`);
669
1179
  out.note = c.note.slice(0, 8000);
670
1180
  } else if(op === "check"){
671
- if(typeof c.value !== "boolean") throw new Error(`${where}: \`value\` must be true or false for a "check".`);
672
- 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;
673
1184
  } else if(op === "delete"){
674
1185
  if(r.node === mapDoc.root) throw new Error(`${where}: the centre of the map cannot be deleted.`);
675
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);
676
1188
  } else if(op === "pic"){
677
1189
  // Une désignation, jamais des pixels. La liste est FERMÉE : un id inventé est refusé ICI,
678
1190
  // contre la carte réelle, plutôt que d'arriver chez l'utilisateur sous forme d'une ligne
@@ -683,35 +1195,138 @@ function buildProposalOps(mapDoc, changes){
683
1195
  + `or "a:" followed by one of: ${ART_IDS_MCP.join(", ")} — or "" to remove it.`);
684
1196
  }
685
1197
  out.pic = pic;
1198
+ } else if(op === "img"){
1199
+ // Des PIXELS, cette fois — ré-encodés ICI pour passer `okCover` chez l'utilisateur. Une
1200
+ // capture trop lourde est refusée à l'écriture, en nommant la limite, plutôt que d'arriver
1201
+ // dans le panneau comme une ligne grisée. `img: ""` retire la capture.
1202
+ if(c.img === "" || c.img === null){ out.img = ""; }
1203
+ else {
1204
+ const { img, note } = coverFrom(c, where);
1205
+ out.img = img; out.shot = note;
1206
+ }
686
1207
  } else if(op === "move"){
687
1208
  // ⚠️ Dans le FICHIER les colonnes sont `{cols:[…]}` ; en mémoire, côté app, c'est un
688
1209
  // tableau nu. Confondre les deux donne un contrôle qui ne regarde rien.
689
- const cols = (mapDoc.kanban && Array.isArray(mapDoc.kanban.cols)) ? mapDoc.kanban.cols : null;
690
- 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.`);
691
- const col = String(c.col == null ? "" : c.col);
692
1210
  // Vide = retirer du tableau, comme le ✕ d'une carte. Toute autre valeur doit désigner une
693
- // colonne RÉELLE : une carte envoyée ailleurs disparaîtrait de l'écran sans un message.
694
- if(col && !cols.some(k => k.id === col)){
695
- const noms = cols.map(k => `"${k.name}" (${k.id})`).join(", ");
696
- throw new Error(`${where}: no column "${col}" on this board. Use one of: ${noms} — or "" to take the card off the board.`);
697
- }
698
- 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) : "";
1215
+ } else if(op === "tags"){
1216
+ // Les dix couleurs, liste FERMÉE — ≡ `okTags` de l'app. Une clé inventée est refusée ICI,
1217
+ // en nommant la liste, plutôt que de tomber en silence à la lecture. [] retire tout.
1218
+ if(!Array.isArray(c.tags)) throw new Error(`${where}: \`tags\` must be a list of label colours for a "tags" (an empty list clears them).`);
1219
+ const bad = c.tags.filter(t => !KB_TAG_KEYS.includes(t));
1220
+ if(bad.length) throw new Error(`${where}: unknown label colour "${bad[0]}". Use any of: ${KB_TAG_KEYS.join(", ")} (read_map shows the names the board gave them).`);
1221
+ out.tags = KB_TAG_KEYS.filter(k => c.tags.includes(k));
1222
+ } else if(op === "assign"){
1223
+ if(typeof c.assign !== "string") throw new Error(`${where}: \`assign\` is required for an "assign" — a name or e-mail (an empty string clears it).`);
1224
+ if(c.assign.length > 120 || /[\x00-\x1f\x7f]/.test(c.assign)) throw new Error(`${where}: \`assign\` is too long or contains control characters (120 characters max).`);
1225
+ out.assign = c.assign.trim();
1226
+ } else if(op === "priority"){
1227
+ const q = (c.priority == null) ? 0 : c.priority;
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.`);
1229
+ out.priority = 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;
1247
+ } else if(op === "attr"){
1248
+ if(typeof c.name !== "string" || !c.name.trim() || c.name.length > 60 || /[:\x00-\x1f]/.test(c.name))
1249
+ throw new Error(`${where}: \`name\` is required for an "attr" — the attribute's name, without a colon (60 characters max).`);
1250
+ if(typeof c.value !== "string" || c.value.length > 400 || /[\x00-\x1f]/.test(c.value))
1251
+ throw new Error(`${where}: \`value\` is required for an "attr" — its value as text (400 characters max; "" removes the attribute).`);
1252
+ out.name = c.name.trim(); out.value = c.value.trim();
1253
+ } else if(op === "comment"){
1254
+ // Un commentaire est AJOUTÉ au fil de la carte, jamais substitué : la seule op qui ne
1255
+ // réécrit rien. Signé « Claude » par l'app (le `by` de la proposition), borné comme
1256
+ // `CMT_TEXT_MAX` côté app — un fil est lu par des gens, pas par un tableur.
1257
+ if(typeof c.text !== "string" || !c.text.trim()) throw new Error(`${where}: \`text\` is required for a "comment" — what you want to say about this card.`);
1258
+ if(c.text.length > 2000) throw new Error(`${where}: the comment is too long (2000 characters max).`);
1259
+ out.text = c.text.trim();
1260
+ } else if(op === "est"){
1261
+ // Une DURÉE, dans les formes que `parseDur` lit côté app (« 2h », « 1h30 », « 45 » = minutes,
1262
+ // « 2:15 »), jamais une phrase : « soon » est refusé ICI, en nommant les formes, plutôt que
1263
+ // de tomber en silence à la lecture. Vide = retirer l'estimation.
1264
+ const q = (c.est == null) ? "" : String(c.est).trim().toLowerCase().replace(/\s+/g, "");
1265
+ if(q && !DUR_RE.test(q)) throw new Error(`${where}: \`est\` must be a duration — "2h", "1h30", "45" (minutes) or "2:15" — or "" to remove the estimate.`);
1266
+ out.est = q;
699
1267
  }
700
1268
  return out;
701
1269
  });
702
1270
  }
1271
+ // 👥 L'ÉQUIPE du tableau (`kanban.team`, 2026-10-12) — qui fait quoi. Montrée par read_map pour la
1272
+ // même raison que dans l'app : c'est elle qui permet de proposer un RESPONSABLE (assign) au lieu de
1273
+ // deviner à partir d'un prénom écrit dans un titre. Lecture seule, bornée comme `okKbTeam` côté app
1274
+ // (30 personnes, textes rognés) : le fichier vient du disque, c'est-à-dire de n'importe qui.
1275
+ function teamToText(doc){
1276
+ const t = doc && doc.kanban && doc.kanban.team;
1277
+ if(!t || typeof t !== "object" || !Array.isArray(t.people)) return "";
1278
+ const line = (v, max) => String(typeof v === "string" ? v : "").replace(/[\x00-\x1f\x7f]+/g, " ").replace(/\s+/g, " ").trim().slice(0, max);
1279
+ const people = t.people.filter(p => p && typeof p.who === "string" && line(p.who, 120)).slice(0, 30);
1280
+ if(!people.length) return "";
1281
+ let out = "\n\n👥 Team — who does what (pick owners from here; `assign` takes the exact name):";
1282
+ const goal = line(t.goal, 800);
1283
+ if(goal) out += "\n Team objectives: " + goal;
1284
+ for(const p of people){
1285
+ out += "\n• " + line(p.who, 120) + (line(p.role, 80) ? " — " + line(p.role, 80) : "");
1286
+ if(line(p.does, 800)) out += "\n owns: " + line(p.does, 800);
1287
+ if(line(p.goals, 800)) out += "\n objectives: " + line(p.goals, 800);
1288
+ }
1289
+ return out;
1290
+ }
1291
+ // ≡ les formes que `parseDur` accepte dans index.html (hh:mm, Nh, NhMM, Nm, Ns, N = minutes).
1292
+ const DUR_RE = /^(\d{1,5}:[0-5]\d|\d{1,5}h([0-5]?\d)?m?|\d{1,7}m(in)?|\d{1,9}s(ec)?|\d{1,7})$/;
703
1293
  function parts_last(spec){ const p = String(spec||"").split(">"); return p[p.length-1].trim(); }
704
1294
 
705
- async function writeProposal(map, note, ops){
706
- const payload = (id)=>({
1295
+ // L'app ignore un fichier de proposition au-delà de PROP_FILE_MAX octets (`propScan`) — sans un
1296
+ // mot, puisqu'elle ne peut pas savoir ce qu'il contenait. Avec des captures d'écran dedans, la
1297
+ // borne se rencontre : on refuse ICI en nommant le geste, jamais un dépôt qui n'arrive pas.
1298
+ const PROP_FILE_MAX = 1400000;
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({
707
1313
  v: 1, kind: "tabtree-proposal", mapId: id, mapFile: CLOUD ? map.file : basename(map.file), mapName: map.name,
708
- createdAt: Date.now(), by: "Claude", note: String(note||"").slice(0, 2000), ops
709
- });
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)
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.");
710
1319
  if(CLOUD){
711
1320
  // La boîte aux lettres cloud : la table `proposals`, que l'app relit au sondage suivant.
712
1321
  // Même charge utile que le fichier — c'est le même `parseProposal` qui la lit côté app.
713
- const r = await cloudCall("propose", { map_key: map.file, payload: payload(map.file) });
714
- return "cloud:" + (r.id || "proposal");
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 };
715
1330
  }
716
1331
  dirOk();
717
1332
  const target = join(DIR, PROP_DIR);
@@ -719,8 +1334,8 @@ async function writeProposal(map, note, ops){
719
1334
  const id = mapIdOf(map.file);
720
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.");
721
1336
  const file = "prop_" + id + "__" + Date.now().toString(36) + ".json";
722
- writeFileSync(join(target, file), JSON.stringify(payload(id), null, 2), "utf8");
723
- 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 } };
724
1339
  }
725
1340
 
726
1341
  // Ce texte est lu par CHAQUE acheteur, à chaque création. Il a déjà été faux une
@@ -742,13 +1357,147 @@ function importHint(){
742
1357
  + "the connector is pointed at a different library — that is why a map can be created and never appear.";
743
1358
  }
744
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
+
745
1494
  // ---------------------------------------------------------------------------
746
1495
  // Définition des outils
747
1496
  // ---------------------------------------------------------------------------
748
1497
  const TOOLS = [
749
1498
  {
750
1499
  name: "list_maps",
751
- 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).",
752
1501
  inputSchema: { type:"object", properties:{}, additionalProperties:false }
753
1502
  },
754
1503
  {
@@ -763,11 +1512,23 @@ const TOOLS = [
763
1512
  },
764
1513
  {
765
1514
  name: "create_mindmap",
766
- description: "Creates a NEW TabTree mind map from a markdown outline (#/## headings, bullet lists indented by 2 spaces, [x]/[ ] checkboxes, [title](url) links, and \" @a:<id>\" / \" @e:<emoji>\" at the end of a line for an illustration). The first top-level line becomes the centre. Writes a .tabtree file into the backup folder; the user pulls it into the app from the 🛟 menu.",
1515
+ description: "Creates a NEW TabTree mind map from a markdown outline (#/## headings, bullet lists indented by 2 spaces, [x]/[ ] checkboxes, [title](url) links, and \" @a:<id>\" / \" @e:<emoji>\" at the end of a line for an illustration). The first top-level line becomes the centre. SCREENSHOTS: pass `images` — a list of { target, file } where `file` is the path of a PNG/JPEG on this machine (a screenshot you saved) and `target` the exact text of the node it goes on; the picture is re-encoded to fit and drawn as the node's cover. Writes a .tabtree file into the backup folder; the user pulls it into the app from the 🛟 menu.",
767
1516
  inputSchema: { type:"object", properties:{
768
1517
  name:{ type:"string", description:"Name of the map" },
769
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." },
770
- folder:{ type:"string", description:"Optional TabTree subfolder (e.g. \"From Claude\")" },
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 } },
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:{
1528
+ target:{ type:"string", description:"Exact node text, or \"Ancestor > Node\"" },
1529
+ file:{ type:"string", description:"Path of the image file on this machine" },
1530
+ data:{ type:"string", description:"A data:image/(png|jpeg|webp|gif);base64,… URL — instead of `file`" }
1531
+ }, required:["target"], additionalProperties:false } },
771
1532
  style:{ type:"object", description:"Optional look of the document. Omit it for the default.", properties:{
772
1533
  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)." },
773
1534
  shape:{ type:"string", enum:["","line","plain"], description:"Node shape: cards (default), line = underlined, plain = plain text." },
@@ -799,7 +1560,8 @@ const TOOLS = [
799
1560
  connections:{ type:"array", description:"Arrows anchored between elements (by id)", items:{ type:"object", properties:{
800
1561
  from:{ type:"string" }, to:{ type:"string" }
801
1562
  }, required:["from","to"], additionalProperties:false } },
802
- folder:{ type:"string", description:"Optional TabTree subfolder" },
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." },
803
1565
  style:{ type:"object", description:"Optional look of the document. Omit it for the default.", properties:{
804
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)." },
805
1567
  shape:{ type:"string", enum:["","line","plain"], description:"Node shape: cards (default), line = underlined, plain = plain text." },
@@ -811,22 +1573,68 @@ const TOOLS = [
811
1573
  }, additionalProperties:false }
812
1574
  }, required:["name","elements"], additionalProperties:false }
813
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
+ },
814
1605
  {
815
1606
  name: "propose_changes",
816
- description: "Proposes changes to an EXISTING mind map: add branches, rename a node, write a note, tick a task, remove a branch, illustrate a node — or, with op \"persona\", re-cast a WORK MAP canvas from a finished interview (the JSON block of the « ma cartographie du travail » skill). Nothing is applied — the proposal appears in TabTree as a banner on that map, the user reviews it change by change and picks what to keep (and can undo with Cmd+Z afterwards). Name each target node by its exact text, or by a path \"Ancestor > Node\" when the same wording appears twice. Read the map first so the wording matches.",
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.",
817
1608
  inputSchema: { type:"object", properties:{
818
1609
  file:{ type:"string", description:"The map to change — a file name (or map name) from list_maps" },
819
1610
  note:{ type:"string", description:"One line telling the user what this proposal does and why. Shown above the changes." },
820
1611
  changes:{ type:"array", description:"The proposed changes, applied in order", items:{ type:"object", properties:{
821
- op:{ type:"string", enum:["add","rename","note","check","delete","move","pic","persona"], description:"add = new children under `target`; rename = change its text; note = set its note; check = tick/untick the task; delete = remove it and its children; move = send the card to a kanban column; pic = put an illustration on it; persona = re-cast a work map canvas (a board whose name starts with “Work map —” / “Cartographie —”) from an interview — no target" },
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`)" },
822
1613
  target:{ type:"string", description:"Exact node text, or \"Ancestor > Node\" if ambiguous (not used by persona)" },
823
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." },
824
1615
  markdown:{ type:"string", description:"add: the new branch as a bullet list, 2 spaces per level" },
825
- text:{ type:"string", description:"rename: the new text" },
1616
+ text:{ type:"string", description:"rename: the new text. comment: the comment body (plain text; http/mailto links become clickable)" },
826
1617
  note:{ type:"string", description:"note: the note body (empty string clears it)" },
827
- value:{ type:"boolean", description:"check: true to tick, false to untick" },
828
- col:{ type:"string", description:"move: the kanban column id (read_map shows them), or \"\" to take the card off the board" },
829
- pic:{ type:"string", description:"pic: \"a:<id>\" for a built-in illustration, or \"e:<emoji>\" for one large emoji, or \"\" to remove it. Ids: person, team, chat, idea, target, trophy, star, heart, warning, done, flag, clock, laptop, phone, mail, folder, book, chart, money, calendar, building, car, globe, rocket, house, pin, plane, coffee, plant, sun, camera, music, lock, key, health, gift." }
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)" },
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." },
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." },
1625
+ data:{ type:"string", description:"img: a data:image/…;base64 URL instead of `file`" },
1626
+ img:{ type:"string", description:"img: pass \"\" to REMOVE the screenshot from that node (otherwise give `file` or `data`)" },
1627
+ tags:{ type:"array", items:{ type:"string" }, description:"tags: the colour labels to set, from: green, yellow, orange, red, purple, blue, sky, lime, pink, gray — read_map shows the name the board gave each colour. [] clears them." },
1628
+ assign:{ type:"string", description:"assign: a name or e-mail (\"\" clears it). add: optional — the owner of the NEW cards. When read_map shows a 👥 Team, pick from it, by role, what each person owns and their objectives" },
1629
+ why:{ type:"string", description:"Any change: optional — one short line saying WHY (e.g. \"Owns the website\"). Shown to the user next to the change, so they can judge it" },
1630
+ priority:{ type:"integer", description:"priority: 1 (highest) to 6, or 0 to remove it" },
1631
+ due:{ type:"string", description:"due: the due day as YYYY-MM-DD (\"\" removes it)" },
1632
+ name:{ type:"string", description:"attr: the attribute's name, e.g. \"Estimate\"" },
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)" },
1637
+ est:{ type:"string", description:"est: the estimate as a duration — \"2h\", \"1h30\", \"45\" (minutes), \"2:15\" — or \"\" to remove it" }
830
1638
  }, required:["op"], additionalProperties:false } }
831
1639
  }, required:["file","changes"], additionalProperties:false }
832
1640
  }
@@ -836,18 +1644,35 @@ const HANDLERS = {
836
1644
  async list_maps(){
837
1645
  const maps = await loadLibrary();
838
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 + ").";
839
- return maps.map(m=>
840
- `• ${m.name} [${m.kind}]${m.folder ? " 📁 " + m.folder : ""}\n file: ${m.file}\n ${m.kind==="board" ? m.elements + " elements" : m.nodes + " nodes"} · modified ${m.mtime}`
841
- ).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;
842
1656
  },
843
1657
  async read_map(args){
844
- const m = await findMap(args.file);
845
- 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`;
846
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 };
847
1668
  // Le rappel sur propose_changes vit ici et pas dans la description de l'outil : c'est au
848
1669
  // moment où le plan est sous les yeux qu'il est utile, et il porte la règle de désignation.
849
- return head + nodeToMarkdown(m.doc.root, 0)
850
- + "\n\n(To change this map, use propose_changes with `file: \"" + m.file + "\"`. Name a node by the exact text above, or \"Ancestor > Node\" when it appears twice. Nothing is applied until the user approves it in TabTree.)";
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.)"));
851
1676
  },
852
1677
  async search_maps(args){
853
1678
  const q = String(args.query||"").toLowerCase();
@@ -879,15 +1704,39 @@ const HANDLERS = {
879
1704
  // comparé caractère par caractère avec celui de l'app, et lui ajouter une syntaxe le ferait
880
1705
  // diverger en silence. Même partage des rôles que côté modèles.
881
1706
  tplPicAssign(root);
1707
+ // Les captures d'écran APRÈS les marqueurs : une désignation se retire du texte avant que le
1708
+ // texte serve de cible, et une capture remplace l'illustration du même nœud.
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);
882
1729
  const doc = { v:1, root, images:[], stickies:[] };
883
1730
  const st = okStyleMcp(args.style);
884
1731
  if(st) doc.style = st;
885
- const file = await writeDoc(args.name || root.text, doc, args.folder);
886
- return `✅ Map created (${countNodes(root)} nodes): ${file}\n${importHint()}`;
1732
+ const file = await writeDoc(args.name || root.text, doc, args.folder, { kind: view, space: args.space });
1733
+ const shotLines = shots.length ? `\n📷 ${shots.length} screenshot(s) on the map:\n` + shots.map(x=>" • " + x).join("\n") + "\n" : "";
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()}`;
887
1736
  },
888
1737
  async create_board(args){
889
1738
  const { doc, warnings } = buildBoardDoc(args.name || "Board", args.elements, args.connections, args.style);
890
- const file = await writeDoc(args.name || "Board", doc, args.folder);
1739
+ const file = await writeDoc(args.name || "Board", doc, args.folder, { space: args.space });
891
1740
  const n = doc.shapes.length + doc.stickies.length;
892
1741
  // Les avertissements passent AVANT l'astuce d'import : c'est le seul moment où le texte
893
1742
  // peut encore être raccourci, et une ligne noyée en fin de réponse ne se lit pas.
@@ -899,6 +1748,15 @@ const HANDLERS = {
899
1748
  : "";
900
1749
  return `✅ Board created (${n} elements, ${doc.arrows.length} connection(s)): ${file}\n${warn}${importHint()}`;
901
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
+ },
902
1760
  async propose_changes(args){
903
1761
  const m = await findMap(args.file);
904
1762
  // Un persona est la SEULE op qui vise un board — la work map en est un — et il ne vise que ça.
@@ -906,26 +1764,36 @@ const HANDLERS = {
906
1764
  if(m.kind === "board" && !allPersona)
907
1765
  throw new Error("Proposals only apply to mind maps. A board has free positions and drawings, which a change list cannot describe.");
908
1766
  if(allPersona && m.kind !== "board")
909
- throw new Error("A persona re-casts a WORK MAP canvas, which is a board. “" + m.name + "” is a mind map — pick the person's work map from list_maps (its name starts with “Work map —” or “Cartographie —”).");
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.");
910
1769
  const ops = buildProposalOps(m.doc, args.changes);
911
- const file = await writeProposal(m, args.note, ops);
1770
+ const res = await writeProposal(m, args.note, ops, { review: allPersona });
912
1771
  if(allPersona){
913
- return `📮 Interview filed for “${m.name}” — ${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`
914
1773
  + "NOTHING has been changed yet. The canvas file is untouched.\n"
915
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?”. "
916
1775
  + "One click rebuilds the canvas AND its companion sheet from these hours; Cmd+Z undoes it.\n"
917
1776
  + "Tell the user to look at TabTree — do not claim the map has been re-cast.";
918
1777
  }
919
- const adds = ops.reduce((n,o)=>n + (o.adds||0), 0);
920
- const drops = ops.reduce((n,o)=>n + (o.drops||0), 0);
921
- return `📮 Proposal filed for “${m.name}” — ${ops.length} change(s)`
922
- + (adds ? `, ${adds} node(s) to add` : "")
923
- + (drops ? `, ${drops} node(s) to remove` : "")
924
- + `: ${file}\n`
925
- + "NOTHING has been changed yet. The map file is untouched.\n"
926
- + (CLOUD ? "TabTree shows a banner on that map within a minute (the app open and signed in). " : "TabTree shows a banner on that map within a few seconds (the backup folder must be connected, and the app open). ")
927
- + "The user ticks the changes they want, applies them, and Cmd+Z undoes the lot.\n"
928
- + "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" : "");
929
1797
  }
930
1798
  };
931
1799
 
@@ -947,7 +1815,7 @@ function handle(msg){
947
1815
  // déjà dérivé (1.0.0 ici, 1.1.0 dans le manifeste) sans que rien ne le
948
1816
  // signale : un client affiche l'une, le registre publie l'autre. Un
949
1817
  // autotest les compare désormais toutes les quatre.
950
- serverInfo: { name: "tabtree", version: "1.2.1" }
1818
+ serverInfo: { name: "tabtree", version: "1.5.0" }
951
1819
  });
952
1820
  } else if(method === "notifications/initialized" || (method||"").startsWith("notifications/")){
953
1821
  // notification : pas de réponse