@lamemind/loom-deck 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -11,7 +11,8 @@ import { resolveTasksPath, resolveTasksDir, loadTasks, loadTaskDetail, } from '.
11
11
  import { discoverProjectSessions } from './sessions.js';
12
12
  import { appendTaskBinding, loadTaskBindings } from './task-index.js';
13
13
  import { loadLaunch } from './config.js';
14
- import { applyView, cycleSort, describeSort, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
14
+ import { applyView, cycleSort, describeSort, priName, progName, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
15
+ import { initialDetail, progressText, writeTaskEdit, PRI_GLYPH, PRI_LABEL, PROG_GLYPH, } from './task-edit.js';
15
16
  import { loadView, saveView, viewFilePath } from './view-store.js';
16
17
  // scripts/deck-run è un sibling della dir del bundle: src/ (dev, tsx) e dist/
17
18
  // (build, node) stanno entrambi sotto la package root → risalita di un livello.
@@ -27,6 +28,13 @@ const SPOT = Symbol('spot');
27
28
  // Modale sort a grammatica libera: un tasto per chiave, pressioni successive
28
29
  // ciclano asc → desc → fuori dalla chain.
29
30
  const SORT_TASTI = { p: 'pri', s: 'prog', i: 'id' };
31
+ // T41 — ordine dei valori nel modale edit. Deliberatamente DIVERSO da
32
+ // PRI_ENTRIES/PROG_ENTRIES (che seguono il rango di sort): qui si sceglie un
33
+ // valore, non si ordina, quindi vince l'ordine del CICLO DI VITA — da fare →
34
+ // in corso → chiusa → bloccata. La priorità resta alta→bassa, che è già
35
+ // l'ordine naturale di lettura.
36
+ const EDIT_PRI = ['high', 'med', 'low'];
37
+ const EDIT_PROG = ['todo', 'wip', 'done', 'locked'];
30
38
  function isDone(prog) {
31
39
  return prog.includes('✔');
32
40
  }
@@ -109,6 +117,24 @@ function spawnCreateTask(text, cwd, sessionId, onResult) {
109
117
  });
110
118
  return child;
111
119
  }
120
+ // T41 — Commit dell'edit. `git commit -- <paths>` committa lo stato working-tree
121
+ // SOLO di quei path, ignorando l'index: se l'utente ha altro in stage (o altri
122
+ // file sporchi) non finisce dentro per errore. NON detached: è un'operazione
123
+ // veloce e il suo esito va riportato nella nota. stderr raccolto per dire perché
124
+ // ha fallito (identità git assente, hook che rifiuta, …) invece di un generico ⚠.
125
+ function commitTaskEdit(cwd, paths, message, onResult) {
126
+ const child = spawn('git', ['commit', '-m', message, '--', ...paths], {
127
+ cwd,
128
+ stdio: ['ignore', 'ignore', 'pipe'],
129
+ });
130
+ let err = '';
131
+ child.stderr?.on('data', (c) => {
132
+ err += c.toString();
133
+ });
134
+ child.on('error', () => onResult(false, 'git non lanciabile'));
135
+ child.on('close', (code) => onResult(code === 0, err.trim().split('\n')[0] ?? ''));
136
+ return child;
137
+ }
112
138
  // Carica tasks.md e lo ri-legge quando cambia sotto (poll su mtime). Poll
113
139
  // (non fs.watch) perché i writer di tasks.md — checkpoint-task/create-task —
114
140
  // riscrivono il file (probabile replace atomico), che rompe il watch sull'inode
@@ -255,6 +281,9 @@ function Deck({ cwd, tasksPath, tasksDir }) {
255
281
  const [view, setView] = useState(() => loadView(cwd));
256
282
  const [viewBackup, setViewBackup] = useState(null);
257
283
  const [filterCursor, setFilterCursor] = useState({ row: 0, col: 0 });
284
+ // T41 — bozza dell'edit (null fuori dal modale) e riga attiva della griglia.
285
+ const [edit, setEdit] = useState(null);
286
+ const [editRow, setEditRow] = useState(0);
258
287
  // Voci launch del progetto (T32): lette una volta, raggiunte per indice 1..9.
259
288
  const launch = useMemo(() => loadLaunch(cwd), [cwd]);
260
289
  // La vista è una trasformazione DERIVATA, applicata a valle del load: il
@@ -338,6 +367,53 @@ function Deck({ cwd, tasksPath, tasksDir }) {
338
367
  });
339
368
  child.on('error', () => setNote(`⚠ create-task: '${CLAUDE_CMD}' non lanciabile`));
340
369
  }
370
+ // T41 — apertura dell'edit: la bozza parte dai valori ATTUALI della task, non
371
+ // da default. La priorità arriva dal glifo di tasks.md (già in `selTask`), lo
372
+ // stato dal suo glifo Prog; il progresso arbitrario dal campo `Progress` del
373
+ // task file — ma solo se è davvero custom (vedi `initialDetail`).
374
+ function openEdit() {
375
+ if (!selTask)
376
+ return;
377
+ const prog = progName(selTask.prog) ?? 'todo';
378
+ setEdit({
379
+ pri: priName(selTask.pri) ?? 'med',
380
+ prog,
381
+ detail: initialDetail(detail?.fields['Progress'] ?? '', prog),
382
+ });
383
+ setEditRow(0);
384
+ setNote('');
385
+ setMode('edit');
386
+ }
387
+ // T41 — ⏎ nell'edit: scrive tasks.md + task file, poi committa. Il commit è
388
+ // immediato e non confermato (scelta esplicita: l'edit è una micro-modifica,
389
+ // la storia granulare vale più di un batch). Se nessuno dei due lati è stato
390
+ // scritto non si committa nulla — `paths` vuoto renderebbe `git commit --`
391
+ // un commit di TUTTO il working tree, che è l'opposto di ciò che vogliamo.
392
+ function submitEdit() {
393
+ const task = selTask;
394
+ const draft = edit;
395
+ setMode('normal');
396
+ setEdit(null);
397
+ if (!task || !draft)
398
+ return;
399
+ let res;
400
+ try {
401
+ res = writeTaskEdit({ tasksPath, tasksDir, id: task.id, ...draft });
402
+ }
403
+ catch (e) {
404
+ setNote(`⚠ ${task.id}: scrittura fallita (${e.message})`);
405
+ return;
406
+ }
407
+ if (res.paths.length === 0) {
408
+ setNote(`⚠ ${task.id}: nessun campo aggiornabile (riga o task file assenti)`);
409
+ return;
410
+ }
411
+ const summary = `${PRI_GLYPH[draft.pri]} ${PRI_LABEL[draft.pri]} · ${res.progress}`;
412
+ setNote(`⏳ ${task.id} → ${summary} · commit…`);
413
+ commitTaskEdit(cwd, res.paths, `chore(${task.id}): pri ${PRI_LABEL[draft.pri]} · stato ${res.progress}`, (ok, err) => {
414
+ setNote(ok ? `✔ ${task.id} → ${summary} · committato` : `⚠ ${task.id} salvato, commit fallito: ${err}`);
415
+ });
416
+ }
341
417
  // Chiusura di un modale di vista: `restore` rimette la fotografia scattata
342
418
  // all'apertura (esc = annulla), altrimenti tiene ciò che si è composto (⏎).
343
419
  function closeViewModal(restore) {
@@ -428,6 +504,48 @@ function Deck({ cwd, tasksPath, tasksDir }) {
428
504
  }
429
505
  return;
430
506
  }
507
+ // T41 — modale edit: griglia a 3 righe. Righe 0/1 = scelta a valore singolo
508
+ // (←→ scorre), riga 2 = testo libero (ogni carattere stampabile entra nel
509
+ // progresso). Come gli altri modali cattura tutto: `esc` annulla senza
510
+ // scrivere né uscire dal deck.
511
+ if (mode === 'edit') {
512
+ if (key.escape) {
513
+ setMode('normal');
514
+ setEdit(null);
515
+ setNote('E → edit annullato');
516
+ }
517
+ else if (key.return) {
518
+ submitEdit();
519
+ }
520
+ else if (key.upArrow) {
521
+ setEditRow((r) => ((r + 2) % 3));
522
+ }
523
+ else if (key.downArrow) {
524
+ setEditRow((r) => ((r + 1) % 3));
525
+ }
526
+ else if (key.leftArrow || key.rightArrow) {
527
+ const d = key.leftArrow ? -1 : 1;
528
+ // Scorrimento CICLICO (wrap) e non clampato: le liste sono di 3-4 voci,
529
+ // arrivare in fondo e ripartire costa meno di invertire direzione.
530
+ if (editRow === 0) {
531
+ setEdit((e) => e ? { ...e, pri: EDIT_PRI[(EDIT_PRI.indexOf(e.pri) + d + EDIT_PRI.length) % EDIT_PRI.length] } : e);
532
+ }
533
+ else if (editRow === 1) {
534
+ setEdit((e) => e
535
+ ? { ...e, prog: EDIT_PROG[(EDIT_PROG.indexOf(e.prog) + d + EDIT_PROG.length) % EDIT_PROG.length] }
536
+ : e);
537
+ }
538
+ }
539
+ else if (editRow === 2) {
540
+ if (key.backspace || key.delete) {
541
+ setEdit((e) => (e ? { ...e, detail: e.detail.slice(0, -1) } : e));
542
+ }
543
+ else if (input && !key.ctrl && !key.meta) {
544
+ setEdit((e) => (e ? { ...e, detail: e.detail + input } : e));
545
+ }
546
+ }
547
+ return;
548
+ }
431
549
  if (key.leftArrow || key.rightArrow || key.tab) {
432
550
  setFocus((f) => (f === 'tasks' ? 'sessions' : 'tasks'));
433
551
  }
@@ -469,6 +587,13 @@ function Deck({ cwd, tasksPath, tasksDir }) {
469
587
  setNote('');
470
588
  setMode('create');
471
589
  }
590
+ else if (input === 'E') {
591
+ // L'edit ha senso solo su una task reale: la riga meta "spot" non ne è una.
592
+ if (isSpot || !selTask)
593
+ setNote('E → nessuna task selezionata');
594
+ else
595
+ openEdit();
596
+ }
472
597
  else if (input === 'S') {
473
598
  setViewBackup(view);
474
599
  setNote('');
@@ -509,7 +634,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
509
634
  const parentLabel = isSpot ? 'spot' : selectedTaskId ?? '—';
510
635
  const canSpawn = focus === 'tasks' && !isSpot;
511
636
  const launchHint = launch.length > 0 ? `1-${Math.min(9, launch.length)} launch · ` : '';
512
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), mode === 'create' ? (_jsxs(Text, { dimColor: true, children: ["nuova task \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " crea \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'sort' ? (_jsxs(Text, { dimColor: true, children: ["sort \u00B7 ", _jsx(Text, { color: "yellow", children: "p" }), " pri ", _jsx(Text, { color: "yellow", children: "s" }), " stato", ' ', _jsx(Text, { color: "yellow", children: "i" }), " id (asc\u2192desc\u2192off) \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'filter' ? (_jsxs(Text, { dimColor: true, children: ["filtri \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193\u2190\u2192" }), " naviga \u00B7 ", _jsx(Text, { color: "yellow", children: "spazio" }), ' ', "mostra/nascondi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsxs(Text, { dimColor: true, children: ["\u2191\u2193 naviga \u00B7 \u2190\u2192 pane \u00B7 \u23CE ", canSpawn ? 'spawn' : '—', " \u00B7 C nuova \u00B7 S sort \u00B7 F filtri \u00B7 w salva \u00B7", ' ', launchHint, "q esci \u00B7 focus: ", _jsx(Text, { color: "cyan", children: focus })] })), mode === 'create' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "C \u203A " }), _jsx(Text, { children: draft }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'sort' ? _jsx(SortModal, { sort: view.sort }) : null, mode === 'filter' ? _jsx(FilterModal, { view: view, cursor: filterCursor }) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: viewTasks, total: tasks.length, hidden: hiddenTasks, view: view, selected: selIndex, spotCount: spotCount, childCount: childCount, focused: focus === 'tasks', loadError: loadError, detail: detail }), _jsx(SessionsPane, { parentLabel: parentLabel, isSpot: isSpot, sessions: visibleSessions, total: childSessions.length, hidden: hiddenSessions, selectedId: selSessionId, focused: focus === 'sessions' })] }), note ? _jsx(Text, { color: "green", children: note }) : null] }));
637
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), mode === 'create' ? (_jsxs(Text, { dimColor: true, children: ["nuova task \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " crea \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'sort' ? (_jsxs(Text, { dimColor: true, children: ["sort \u00B7 ", _jsx(Text, { color: "yellow", children: "p" }), " pri ", _jsx(Text, { color: "yellow", children: "s" }), " stato", ' ', _jsx(Text, { color: "yellow", children: "i" }), " id (asc\u2192desc\u2192off) \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'filter' ? (_jsxs(Text, { dimColor: true, children: ["filtri \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193\u2190\u2192" }), " naviga \u00B7 ", _jsx(Text, { color: "yellow", children: "spazio" }), ' ', "mostra/nascondi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'edit' ? (_jsxs(Text, { dimColor: true, children: ["edit \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " campo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " valore \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva+commit \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsxs(Text, { dimColor: true, children: ["\u2191\u2193 naviga \u00B7 \u2190\u2192 pane \u00B7 \u23CE ", canSpawn ? 'spawn' : '—', " \u00B7 C nuova \u00B7 E edit \u00B7 S sort \u00B7 F filtri \u00B7 w salva \u00B7 ", launchHint, "q esci \u00B7 focus: ", _jsx(Text, { color: "cyan", children: focus })] })), mode === 'create' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "C \u203A " }), _jsx(Text, { children: draft }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'sort' ? _jsx(SortModal, { sort: view.sort }) : null, mode === 'filter' ? _jsx(FilterModal, { view: view, cursor: filterCursor }) : null, mode === 'edit' && edit && selTask ? (_jsx(EditModal, { id: selTask.id, draft: edit, row: editRow })) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: viewTasks, total: tasks.length, hidden: hiddenTasks, view: view, selected: selIndex, spotCount: spotCount, childCount: childCount, focused: focus === 'tasks', loadError: loadError, detail: detail }), _jsx(SessionsPane, { parentLabel: parentLabel, isSpot: isSpot, sessions: visibleSessions, total: childSessions.length, hidden: hiddenSessions, selectedId: selSessionId, focused: focus === 'sessions' })] }), note ? _jsx(Text, { color: "green", children: note }) : null] }));
513
638
  }
514
639
  const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
515
640
  // Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
@@ -531,6 +656,14 @@ function FilterModal({ view, cursor }) {
531
656
  return (_jsxs(Text, { inverse: here, color: on ? 'green' : 'gray', dimColor: !on, children: [' ', "[", on ? 'x' : ' ', "] ", forceEmojiWidth(e.glyph)] }, e.name));
532
657
  })] }, row.label)))] }));
533
658
  }
659
+ // T41 — modale edit, in flusso come gli altri (spinge giù i pane invece di
660
+ // coprirli: la riga che stai modificando resta visibile sopra la lista).
661
+ // La riga di anteprima mostra il testo ESATTO che finirà nel campo `Progress`
662
+ // del task file — così il default (`✔️ Done at <oggi>`) non è una sorpresa.
663
+ function EditModal({ id, draft, row }) {
664
+ const mark = (r) => (row === r ? '▶ ' : ' ');
665
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { color: "yellow", children: ["E \u203A ", id, " \u00B7 priorit\u00E0 e stato"] }), _jsxs(Text, { children: [mark(0), _jsx(Text, { dimColor: true, children: "pri " }), EDIT_PRI.map((p) => (_jsxs(Text, { inverse: draft.pri === p, color: draft.pri === p ? 'green' : 'gray', children: [' ', forceEmojiWidth(PRI_GLYPH[p]), " ", PRI_LABEL[p]] }, p)))] }), _jsxs(Text, { children: [mark(1), _jsx(Text, { dimColor: true, children: "stato" }), EDIT_PROG.map((p) => (_jsxs(Text, { inverse: draft.prog === p, color: draft.prog === p ? 'green' : 'gray', children: [' ', forceEmojiWidth(PROG_GLYPH[p]), " ", p] }, p)))] }), _jsxs(Text, { children: [mark(2), _jsx(Text, { dimColor: true, children: "prog " }), _jsxs(Text, { children: [' ', draft.detail] }), row === 2 ? _jsx(Text, { inverse: true, children: " " }) : null, !draft.detail && row !== 2 ? _jsx(Text, { dimColor: true, children: "(default)" }) : null] }), _jsxs(Text, { dimColor: true, children: ["\u21B3 ", progressText(draft.prog, draft.detail)] })] }));
666
+ }
534
667
  function TasksPane({ tasks, total, hidden, view, selected, spotCount, childCount, focused, loadError, detail, }) {
535
668
  const spotSelected = selected === 0;
536
669
  return (_jsxs(Box, { flexDirection: "column", width: "50%", marginRight: 1, borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsxs(Text, { bold: true, color: focused ? 'cyan' : undefined, children: ["Tasks (", hidden > 0 ? `${tasks.length}/${total}` : tasks.length, ")", hidden > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 ", hidden, " nascoste"] }) : null] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["sort: ", describeSort(view.sort), view.hiddenPri.length + view.hiddenProg.length > 0 ? (_jsxs(Text, { children: [' ', "\u00B7 filtri:", ' ', [
@@ -0,0 +1,160 @@
1
+ // T41 — Edit inline di priorità/stato dal deck.
2
+ // Le funzioni di rewrite sono PURE (stringa → stringa): la scrittura su disco è
3
+ // isolata in `writeTaskEdit`, così la grammatica delle sostituzioni è testabile
4
+ // senza toccare il filesystem.
5
+ import { readFileSync, writeFileSync } from 'node:fs';
6
+ import { findTaskFile } from './tasks.js';
7
+ // Il task file scrive la priorità per NOME (`- **Priority**: Med`), tasks.md la
8
+ // scrive come GLIFO (colonna Pri). Due rappresentazioni dello stesso valore →
9
+ // due mappe, entrambe keyed sul nome canonico di view.ts (unica fonte del rango).
10
+ export const PRI_LABEL = { high: 'High', med: 'Med', low: 'Low' };
11
+ export const PRI_GLYPH = { high: '🔥', med: '⚡', low: '🔹' };
12
+ // `✔️` CON VS16: è la forma usata nelle righe Done già presenti in tasks.md e nei
13
+ // task file. view.ts normalizza via VS16 in lettura, quindi scrivere la forma
14
+ // lunga resta riconosciuto da priName/progName/isDone.
15
+ export const PROG_GLYPH = {
16
+ todo: '🔵',
17
+ wip: '🟡',
18
+ done: '✔️',
19
+ locked: '🔒',
20
+ };
21
+ // Testo di default per stato, usato quando l'utente non digita un progresso
22
+ // arbitrario. Il campo `Progress` del task file è testo libero (`🟡 85%`,
23
+ // `🔵 Todo`, `✔️ Done at 2026-07-20`) → il glifo è il prefisso, il resto è prosa.
24
+ const PROG_DEFAULT = {
25
+ todo: 'Todo',
26
+ wip: 'In Progress',
27
+ done: 'Done',
28
+ locked: 'Locked',
29
+ };
30
+ /** Data locale `YYYY-MM-DD` (non UTC: `toISOString` sposterebbe il giorno la sera). */
31
+ export function today(d = new Date()) {
32
+ const p = (n) => String(n).padStart(2, '0');
33
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
34
+ }
35
+ /**
36
+ * Riga `Progress` del task file. `detail` è il progresso ARBITRARIO digitato
37
+ * dall'utente: se c'è vince sempre (anche su done/locked — nessuna forma è
38
+ * preclusa), se manca si cade sul default dello stato. Solo `done` senza detail
39
+ * data-stampa, coerente con le righe già presenti (`✔️ Done at YYYY-MM-DD`).
40
+ */
41
+ export function progressText(prog, detail, date = today()) {
42
+ const glyph = PROG_GLYPH[prog];
43
+ const d = detail.trim();
44
+ if (d)
45
+ return `${glyph} ${d}`;
46
+ if (prog === 'done')
47
+ return `${glyph} Done at ${date}`;
48
+ return `${glyph} ${PROG_DEFAULT[prog]}`;
49
+ }
50
+ /**
51
+ * Toglie il glifo di testa da un valore `Progress` (`🟡 85%` → `85%`).
52
+ * Il glifo lo ri-deriva `progressText` dallo stato selezionato.
53
+ */
54
+ export function stripProgGlyph(s) {
55
+ return s.replace(/^[\p{Extended_Pictographic}✔️\s]+/u, '').trim();
56
+ }
57
+ /**
58
+ * Valore iniziale del campo "progresso arbitrario" all'apertura del modale.
59
+ *
60
+ * NON è semplicemente il testo corrente spogliato del glifo: se quel testo è
61
+ * esattamente ciò che il default già produrrebbe (`🔵 Todo`, `🟡 In Progress`)
62
+ * non c'è nulla di custom da preservare, e pre-riempirlo lo renderebbe
63
+ * APPICCICOSO — cambiando stato la scritta vecchia resterebbe attaccata al
64
+ * glifo nuovo (`🔵 Todo` → wip → `🟡 Todo`). Si parte quindi da vuoto e il
65
+ * default segue lo stato scelto.
66
+ *
67
+ * Un testo che il default NON sa riprodurre è invece informazione dell'utente e
68
+ * va tenuta: `🟡 85%` → `85%`, e `✔️ Done at 2026-07-14` → `Done at 2026-07-14`
69
+ * (la data storica sopravvive, non viene ri-stampata a oggi).
70
+ */
71
+ export function initialDetail(current, prog, date = today()) {
72
+ const s = current.trim();
73
+ if (!s || s === progressText(prog, '', date))
74
+ return '';
75
+ return stripProgGlyph(s);
76
+ }
77
+ /**
78
+ * Riscrive le celle Pri (col 2) e Prog (col 4) della riga `| Tnn | … |` in
79
+ * tasks.md. Solo la PRIMA riga con quell'id — l'overview è unica, un secondo
80
+ * match sarebbe un duplicato da non propagare. Le altre celle (K, descrizione)
81
+ * restano i token grezzi originali: nessun re-flow della tabella, il diff resta
82
+ * di una riga. `ok:false` = id assente → il chiamante non scrive nulla.
83
+ *
84
+ * L'id deve rispettare `^T\d+$`, stesso gate di `parseTasks`: senza, un id
85
+ * arbitrario matcherebbe la riga di HEADER (`| ID | Pri | K | Prog |`) o quella
86
+ * di separatore, riscrivendone le celle e sfondando la tabella.
87
+ */
88
+ const TASK_ID_RE = /^T\d+$/;
89
+ export function updateTasksMdRow(content, id, priGlyph, progGlyph) {
90
+ if (!TASK_ID_RE.test(id))
91
+ return { content, ok: false };
92
+ let ok = false;
93
+ const lines = content.split('\n').map((line) => {
94
+ if (ok)
95
+ return line;
96
+ if (!line.trim().startsWith('|'))
97
+ return line;
98
+ const cells = line.split('|');
99
+ if ((cells[1] ?? '').trim() !== id)
100
+ return line;
101
+ if (cells.length < 6)
102
+ return line; // riga malformata → non toccarla
103
+ ok = true;
104
+ cells[2] = ` ${priGlyph} `;
105
+ cells[4] = ` ${progGlyph} `;
106
+ return cells.join('|');
107
+ });
108
+ return { content: ok ? lines.join('\n') : content, ok };
109
+ }
110
+ /**
111
+ * Riscrive i bullet header `- **Priority**:` e `- **Progress**:` del task file.
112
+ * First-match-wins per chiave, stessa regola di `parseTaskDetail`: se un campo
113
+ * ricompare nel body (residuo template) vince quello dell'header — così ciò che
114
+ * il deck mostra e ciò che scrive restano la stessa riga.
115
+ */
116
+ export function updateTaskFileFields(content, priLabel, progress) {
117
+ let priDone = false;
118
+ let progDone = false;
119
+ const lines = content.split('\n').map((line) => {
120
+ if (!priDone && /^-\s*\*\*Priority\*\*:/.test(line)) {
121
+ priDone = true;
122
+ return `- **Priority**: ${priLabel}`;
123
+ }
124
+ if (!progDone && /^-\s*\*\*Progress\*\*:/.test(line)) {
125
+ progDone = true;
126
+ return `- **Progress**: ${progress}`;
127
+ }
128
+ return line;
129
+ });
130
+ const ok = priDone || progDone;
131
+ return { content: ok ? lines.join('\n') : content, ok };
132
+ }
133
+ /**
134
+ * Scrive i DUE lati della task: la riga di tasks.md (vista d'insieme) e i campi
135
+ * del task file (dettaglio). Sono due fonti che devono restare allineate — il
136
+ * deck legge la prima e mostra la seconda — quindi si toccano insieme.
137
+ * Un lato mancante (id fuori overview, o nessun task file) non blocca l'altro:
138
+ * si scrive ciò che esiste e il risultato dice cosa è stato toccato.
139
+ */
140
+ export function writeTaskEdit(input) {
141
+ const { tasksPath, tasksDir, id, pri, prog, detail } = input;
142
+ const progress = progressText(prog, detail);
143
+ const paths = [];
144
+ const row = updateTasksMdRow(readFileSync(tasksPath, 'utf8'), id, PRI_GLYPH[pri], PROG_GLYPH[prog]);
145
+ if (row.ok) {
146
+ writeFileSync(tasksPath, row.content, 'utf8');
147
+ paths.push(tasksPath);
148
+ }
149
+ let fileUpdated = false;
150
+ const taskFile = findTaskFile(tasksDir, id);
151
+ if (taskFile) {
152
+ const upd = updateTaskFileFields(readFileSync(taskFile, 'utf8'), PRI_LABEL[pri], progress);
153
+ if (upd.ok) {
154
+ writeFileSync(taskFile, upd.content, 'utf8');
155
+ paths.push(taskFile);
156
+ fileUpdated = true;
157
+ }
158
+ }
159
+ return { paths, rowUpdated: row.ok, fileUpdated, progress };
160
+ }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Deck TUI Ink per-progetto della famiglia loom: legge tasks.md e spawna sessioni Claude Code bound via LOOM_TASK",
5
5
  "type": "module",
6
6
  "bin": {
7
- "loom-deck": "./dist/cli.js"
7
+ "loom-deck": "dist/cli.js"
8
8
  },
9
9
  "files": [
10
10
  "dist",