@lamemind/loom-deck 0.24.0 → 0.26.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.
@@ -0,0 +1,139 @@
1
+ // T61 — conteggio delle task Done potabili ("archiviabili").
2
+ //
3
+ // Il deck NON pota: mostra solo quante task Done hanno superato la soglia
4
+ // d'età, così la policy di retention — manuale per scelta, nessuna GC
5
+ // automatica — ha almeno un segnale che la renda esercitabile. La potatura
6
+ // resta `loom-works:clean-tasks`, invocata da un umano.
7
+ //
8
+ // D1 (preflight) — la regola d'età è RICALCOLATA qui, non delegata a
9
+ // `cleanup-done-tasks.sh`: il deck è un binario npm globale spawnato da Ptyxis,
10
+ // fuori dal processo Claude Code, quindi non riceve `CLAUDE_PLUGIN_ROOT` e non
11
+ // ha modo di ricavare il path dello script — che vive sotto
12
+ // `~/.claude/plugins/cache/…/<version>/`, version-pinned e riscritto a ogni
13
+ // `plugin update`. La duplicazione della regola fra i due è un costo accettato:
14
+ // è una data meno un'altra. Il prezzo è che le due implementazioni devono
15
+ // concordare sul confine — vedi `ageDays` e `DEFAULT_ARCHIVABLE_DAYS`.
16
+ import { execFile } from 'node:child_process';
17
+ import { readFileSync } from 'node:fs';
18
+ import { promisify } from 'node:util';
19
+ import { findTaskFile, parseTaskDetail } from './tasks.js';
20
+ const execFileAsync = promisify(execFile);
21
+ const MS_PER_DAY = 86_400_000;
22
+ /**
23
+ * D3 (preflight) — metà della policy di purge (`cleanup-done-tasks.sh --days`
24
+ * ha default 60). Il contatore fa quindi da PREAVVISO, non da predizione: chi
25
+ * vede `N archiviabili` e lancia `clean-tasks` senza argomenti ne pota zero.
26
+ * I due numeri sono deliberatamente diversi.
27
+ */
28
+ export const DEFAULT_ARCHIVABLE_DAYS = 30;
29
+ /** Ogni 6 ore. L'età di una task cambia una volta al giorno: agganciare lo
30
+ * scan al poll da 1,5s di `tasks.md` costerebbe N letture di file al secondo
31
+ * per un dato che non si muove. Due scale di refresh distinte, stesso processo. */
32
+ export const SCAN_INTERVAL_MS = 6 * 60 * 60 * 1000;
33
+ const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
34
+ /** Un SHA che iniziasse per `-` verrebbe letto da git come flag: qui non passa. */
35
+ const SHA_RE = /^[0-9a-f]{7,40}$/i;
36
+ const DONE_AT_RE = /Done at (\d{4}-\d{2}-\d{2})/;
37
+ /**
38
+ * Età in giorni interi di una data ISO. `null` se non parsabile.
39
+ *
40
+ * D5 (preflight) — allineata a `cleanup-done-tasks.sh`, che fa
41
+ * `age_days=$(( (NOW_EPOCH - done_epoch) / 86400 ))` e poi `age_days < DAYS →
42
+ * skip`: divisione intera troncata, confronto `>=`. Due implementazioni della
43
+ * stessa regola (D1) devono almeno concordare su dove cade il confine.
44
+ *
45
+ * Una data NUDA (`2026-07-20`, la forma di `Done at`) si ancora alla mezzanotte
46
+ * LOCALE, non UTC: è ciò che fa `date -d 2026-07-20` nello script. `Date.parse`
47
+ * su una data nuda darebbe mezzanotte UTC, cioè fino a mezza giornata di
48
+ * scarto — abbastanza per spostare di 1 il conteggio proprio sul confine.
49
+ * I timestamp completi che arrivano da `git --format=%cI` portano l'offset e
50
+ * passano invece da `Date.parse`.
51
+ */
52
+ export function ageDays(iso, now) {
53
+ let t;
54
+ if (DATE_ONLY_RE.test(iso)) {
55
+ const [y, m, d] = iso.split('-').map(Number);
56
+ t = new Date(y, m - 1, d).getTime();
57
+ }
58
+ else {
59
+ t = Date.parse(iso);
60
+ }
61
+ if (Number.isNaN(t))
62
+ return null;
63
+ return Math.floor((now - t) / MS_PER_DAY);
64
+ }
65
+ /**
66
+ * Data di chiusura di una task, per cascata a tre gradini + skip finale.
67
+ * Ordine e semantica IDENTICI a `cleanup-done-tasks.sh` (D2 del preflight):
68
+ * sul repo reale il solo gradino ① copriva 22 Done su 29, quindi un contatore
69
+ * fermo al primo gradino avrebbe sottostimato di un quarto senza dirlo.
70
+ *
71
+ * Il gradino ③ è un'approssimazione già accettata dallo script: `git log -1`
72
+ * dà la data dell'ULTIMO commit che tocca il file, non quella di chiusura —
73
+ * una ri-edit post-done ringiovanisce la task e la fa uscire dal conteggio.
74
+ * L'errore cade sempre dal lato sicuro (mai una task contata come più vecchia
75
+ * di quel che è), che è la stessa ragione per cui il gradino ④ è uno skip.
76
+ */
77
+ async function resolveDoneDate(id, content, taskFile, projectRoot) {
78
+ // `fields` è first-match-wins come il `grep -m1` dello script: se un task
79
+ // file ripete `Progress` nel body (residuo di template) vince quello header.
80
+ const { fields } = parseTaskDetail(id, content);
81
+ // ① `- **Progress**: ✔️ Done at YYYY-MM-DD` — sorgente deterministica.
82
+ const doneAt = DONE_AT_RE.exec(fields['Progress'] ?? '');
83
+ if (doneAt)
84
+ return doneAt[1];
85
+ // ② `- **Last tracked commit**: <sha>` → data del commit.
86
+ // Primo token soltanto: il campo ammette un'annotazione inline dopo il valore.
87
+ const sha = (fields['Last tracked commit'] ?? '').split(/\s+/)[0];
88
+ if (SHA_RE.test(sha)) {
89
+ const d = await gitOut(['show', '-s', '--format=%cI', sha], projectRoot);
90
+ if (d)
91
+ return d;
92
+ }
93
+ // ③ ultimo commit che tocca il task file. `--` protegge il path da un nome
94
+ // che somigli a un flag.
95
+ return gitOut(['log', '-1', '--format=%cI', '--', taskFile], projectRoot);
96
+ }
97
+ /** git muto (repo assente, sha sconosciuto, git non installato) → `null`, mai
98
+ * un throw: un contatore informativo non può rompere il deck. */
99
+ async function gitOut(args, cwd) {
100
+ try {
101
+ const { stdout } = await execFileAsync('git', args, { cwd });
102
+ return stdout.trim() || null;
103
+ }
104
+ catch {
105
+ return null;
106
+ }
107
+ }
108
+ /**
109
+ * Quante fra le task Done passate sono oltre soglia. Le indeterminate (nessuno
110
+ * dei tre gradini risolve) e le righe orfane (Done in tasks.md ma task file
111
+ * assente) NON entrano nel conteggio: il deck non chiama "vecchia" una task di
112
+ * cui non sa l'età.
113
+ *
114
+ * Riceve gli id già filtrati sul glifo Done invece della lista completa: lo
115
+ * scan resta così proporzionale al vecchiume, non alla lunghezza della lista.
116
+ */
117
+ export async function countArchivable(doneIds, opts) {
118
+ const now = opts.now ?? Date.now();
119
+ let n = 0;
120
+ for (const id of doneIds) {
121
+ const taskFile = findTaskFile(opts.tasksDir, id);
122
+ if (!taskFile)
123
+ continue;
124
+ let content;
125
+ try {
126
+ content = readFileSync(taskFile, 'utf8');
127
+ }
128
+ catch {
129
+ continue;
130
+ }
131
+ const iso = await resolveDoneDate(id, content, taskFile, opts.projectRoot);
132
+ if (!iso)
133
+ continue;
134
+ const age = ageDays(iso, now);
135
+ if (age !== null && age >= opts.days)
136
+ n++;
137
+ }
138
+ return n;
139
+ }
package/dist/cli.js CHANGED
@@ -12,10 +12,11 @@ import { resolveTasksPath, resolveTasksDir, loadTasks, loadTaskDetail, } from '.
12
12
  import { discoverProjectSessions } from './sessions.js';
13
13
  import { buildRows, firstRowKey, moveRowSelection, rowIndexOfKey, searchSessions, selectedRow, DEFAULT_OPTIONS, MIN_QUERY, } from './search.js';
14
14
  import { appendNote, appendPin, appendSessionRecord, appendTaskBinding, loadSessionIndex, } from './task-index.js';
15
- import { assembleSessionList, firstSelectableId, moveSelection, neighborId, rowIndexOf, rowLabel, selectedSession, stripProjectCore, } from './session-list.js';
16
- import { cellWidth, launchLegend, loadIdentity, loadLaunch } from './config.js';
15
+ import { assembleSessionList, firstSelectableId, moveSelection, neighborId, rowIndexOf, rowLabel, selectedSession, sessionTitle, stripProjectCore, } from './session-list.js';
16
+ import { cellWidth, launchLegend, loadArchivableDays, loadIdentity, loadLaunch, } from './config.js';
17
+ import { countArchivable, SCAN_INTERVAL_MS } from './archivable.js';
17
18
  import { assignListCapacity, isCompact, layoutBudget, readerCapacity, searchListCapacity, searchPreviewCapacity, windowRange, } from './viewport.js';
18
- import { caretWindow, cut, sanitize, termWidth, wrapLines, wrapWithOffsets, } from './width.js';
19
+ import { caretWindow, cut, pad, sanitize, termWidth, wrapLines, wrapWithOffsets, } from './width.js';
19
20
  import { applyView, cycleSort, describeSort, priName, progName, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
20
21
  import { initialDetail, progressText, writeTaskEdit, PRI_GLYPH, PRI_LABEL, PROG_GLYPH, } from './task-edit.js';
21
22
  import { loadView, saveView, viewFilePath } from './view-store.js';
@@ -406,6 +407,48 @@ function useSessions(projectRoot) {
406
407
  }, [projectRoot]);
407
408
  return { ...state, reload: () => reloadRef.current() };
408
409
  }
410
+ // T61 — conteggio delle Done oltre soglia, su una scala di refresh TUTTA SUA.
411
+ //
412
+ // Non è appeso a POLL_MS (1,5s) come tasks.md e le sessioni: l'età di una task
413
+ // cambia una volta al giorno, e ogni giro costa la lettura di N task file più
414
+ // qualche spawn di git. Due trigger:
415
+ //
416
+ // · quando cambia l'INSIEME delle task Done (`doneSig`) — copre l'avvio, dove
417
+ // il primo render ha `tasks` ancora vuoto, e la chiusura di una task, dove
418
+ // il numero deve muoversi senza aspettare ore;
419
+ // · ogni SCAN_INTERVAL_MS — copre il caso opposto, in cui non cambia nulla
420
+ // sul disco ed è il calendario a far scattare una task oltre soglia.
421
+ //
422
+ // `doneSig` è una stringa, non l'array: `tasks` cambia identità a ogni re-read
423
+ // di tasks.md, e usarlo come dipendenza rimetterebbe lo scan sul tick da 1,5s
424
+ // per la via di dietro.
425
+ function useArchivable(doneSig, tasksDir, projectRoot, days) {
426
+ const [count, setCount] = useState(0);
427
+ useEffect(() => {
428
+ let alive = true;
429
+ const ids = doneSig ? doneSig.split(',') : [];
430
+ const scan = () => {
431
+ countArchivable(ids, { tasksDir, projectRoot, days })
432
+ .then((n) => {
433
+ if (alive)
434
+ setCount(n);
435
+ })
436
+ // Scan fallito (task file illeggibili, git muto) → 0, cioè segmento
437
+ // omesso. Un contatore informativo non merita un errore a schermo.
438
+ .catch(() => {
439
+ if (alive)
440
+ setCount(0);
441
+ });
442
+ };
443
+ scan();
444
+ const id = setInterval(scan, SCAN_INTERVAL_MS);
445
+ return () => {
446
+ alive = false;
447
+ clearInterval(id);
448
+ };
449
+ }, [doneSig, tasksDir, projectRoot, days]);
450
+ return count;
451
+ }
409
452
  // Legge il task file della task selezionata (Q1+B T20). On-id-change: navigare
410
453
  // con ↑↓ ricarica il dettaglio; leggere un singolo file 4-9KB è I/O triviale,
411
454
  // niente debounce serve per la tastiera. Il refresh del contenuto a file fermo
@@ -430,10 +473,12 @@ const WARN = sanitize('⚠');
430
473
  const SESSION_SEP = '─'.repeat(16);
431
474
  // Prefisso del sessionId mostrato in lista: stesso dato e stessa lunghezza del
432
475
  // widget `⛓ <8 char>` della statusline, così le due superfici si confrontano a
433
- // occhio. `SID_W` include lo spazio che segue → è quello che il budget della
434
- // label deve scalare.
476
+ // occhio.
435
477
  const SID_CHARS = 8;
436
- const SID_W = SID_CHARS + 1;
478
+ /** T60 segnaposto della colonna task su una riga senza binding. Una cella
479
+ * vuota di soli spazi lascerebbe un buco che si legge come "colonna finita",
480
+ * e la riga tornerebbe a sembrare disallineata pur non essendolo. */
481
+ const TASK_EMPTY = '·';
437
482
  // Marker Done per il DISPLAY. `task.prog` resta il `✔️` letto da tasks.md —
438
483
  // `isDone()` e le lookup di `view.ts` ci confrontano sopra, e `task-edit` lo
439
484
  // riscrive sul file: è una chiave semantica, non testo. Qui `sanitize` lo
@@ -567,6 +612,12 @@ function Deck({ cwd, tasksPath, tasksDir }) {
567
612
  // La vista è una trasformazione DERIVATA, applicata a valle del load: il
568
613
  // polling di tasks.md continua a funzionare senza saperne nulla.
569
614
  const { visible: viewTasks, hidden: hiddenTasks } = useMemo(() => applyView(tasks, view), [tasks, view]);
615
+ // T61 — il conteggio guarda la lista GREZZA, non `viewTasks`: le Done fuori
616
+ // dai filtri della vista restano archiviabili, e un contatore che cambiasse
617
+ // filtrando direbbe qualcosa sulla vista invece che sulla task list.
618
+ const doneSig = useMemo(() => tasks.filter((t) => isDone(t.prog)).map((t) => t.id).join(','), [tasks]);
619
+ const archivableDays = useMemo(() => loadArchivableDays(cwd), [cwd]);
620
+ const archivable = useArchivable(doneSig, tasksDir, cwd, archivableDays);
570
621
  const isSpot = sel === SPOT;
571
622
  const isAll = sel === ALL;
572
623
  const projectName = cwd.split('/').pop() || cwd;
@@ -603,6 +654,29 @@ function Deck({ cwd, tasksPath, tasksDir }) {
603
654
  const assembled = useMemo(() => assembleSessionList(childSessions, sessions, pinned, isAll ? MAX_SESSIONS_ALL : MAX_SESSIONS), [childSessions, sessions, pinned, isAll]);
604
655
  const sessionRows = assembled.rows;
605
656
  const selSessionObj = selectedSession(sessionRows, selSessionId);
657
+ // T60 — larghezze delle colonne fisse della lista sessioni, misurate sulla
658
+ // lista INTERA e non sulla finestra visibile: derivarle dalle sole righe a
659
+ // schermo le farebbe cambiare a ogni scroll, cioè l'opposto di una tabella.
660
+ // La colonna task esiste solo nella vista "tutte" (altrove il binding è lo
661
+ // stesso su ogni riga e sta già nell'header del pane), e `0` la spegne.
662
+ const sessionCols = useMemo(() => {
663
+ let task = 0;
664
+ let age = 2;
665
+ for (const r of sessionRows) {
666
+ if (r.kind === 'separator')
667
+ continue;
668
+ if (isAll) {
669
+ const b = bindings.get(r.sessionId);
670
+ if (b)
671
+ task = Math.max(task, termWidth(b));
672
+ }
673
+ if (r.session)
674
+ age = Math.max(age, termWidth(relTime(r.session.ts)));
675
+ }
676
+ // La cella vuota deve poter entrare nella colonna, o le righe spot
677
+ // perderebbero il segnaposto e con lui l'allineamento.
678
+ return { task: task > 0 ? Math.max(task, termWidth(TASK_EMPTY)) : 0, age };
679
+ }, [sessionRows, bindings, isAll]);
606
680
  // T52 — ricerca EAGER: rigira a ogni carattere digitato, non su ⏎. È
607
681
  // sostenibile perché i corpi sono già in RAM dentro la cache mtime-keyed
608
682
  // dell'adapter (D5): misurato su questo progetto, 0,8 ms sui soli corpi IA e
@@ -1686,7 +1760,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1686
1760
  if (budget.compact) {
1687
1761
  return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, children: [' ', "\u00B7 ", viewTasks.length, " task \u00B7 sel ", selectedTaskId ?? parentLabel, " \u00B7 terminale ", rows, "\u00D7", columns, ": troppo basso, allarga"] })] }));
1688
1762
  }
1689
- 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, wrap: "truncate-end", 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, wrap: "truncate-end", 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, wrap: "truncate-end", 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 === 'note' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nota conversazione \u00B7 ", _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva (vuoto = rimuove) \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'edit' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["edit \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " campo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " valore, o cursore sul testo \u00B7 ", _jsx(Text, { color: "yellow", children: "^A/^E" }), " inizio/fine \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^D" }), " canc \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva+commit \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: keyLegend })), mode === 'normal' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [surfaceLegend, legend.shown ? ` · ${legend.shown}` : '', legend.overflow > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 +", legend.overflow, " fuori riga"] })) : null, legend.unreachable > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 ", legend.unreachable, " oltre la 9\u00AA (non raggiungibili)"] })) : null] })) : null, 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 === 'note' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "\u270E \u203A " }), _jsx(Text, { children: noteDraft }), _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, columns: columns })) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: windowTasks, filtered: viewTasks.length, total: tasks.length, hidden: hiddenTasks, view: view, selected: selIndex, spotCount: spotCount, allCount: sessions.length, childCount: childCount, focused: focus === 'tasks', loadError: loadError, detail: detail, windowStart: taskWin.start, above: taskWin.start, below: viewTasks.length - taskWin.end, detailLines: budget.detailLines, columns: columns }), _jsx(SessionsPane, { parentLabel: parentLabel, isSpot: isSpot, isAll: isAll, bindings: bindings, rows: windowRows, total: assembled.pinnedCount + assembled.contextTotal, pinnedCount: assembled.pinnedCount, hidden: assembled.contextHidden, selectedId: selSessionId ?? undefined, focused: focus === 'sessions', above: sessionWin.start, below: sessionRows.length - sessionWin.end, detail: budget.sessionDetail ? selSessionObj : null, firstLines: budget.sessionFirstLines, lastLines: budget.sessionLastLines, columns: columns, forkOf: forkOf, sessionNotes: sessionNotes, projectCore: projectCore })] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
1763
+ 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, wrap: "truncate-end", 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, wrap: "truncate-end", 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, wrap: "truncate-end", 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 === 'note' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nota conversazione \u00B7 ", _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva (vuoto = rimuove) \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'edit' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["edit \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " campo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " valore, o cursore sul testo \u00B7 ", _jsx(Text, { color: "yellow", children: "^A/^E" }), " inizio/fine \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^D" }), " canc \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva+commit \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: keyLegend })), mode === 'normal' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [surfaceLegend, legend.shown ? ` · ${legend.shown}` : '', legend.overflow > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 +", legend.overflow, " fuori riga"] })) : null, legend.unreachable > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 ", legend.unreachable, " oltre la 9\u00AA (non raggiungibili)"] })) : null] })) : null, 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 === 'note' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "\u270E \u203A " }), _jsx(Text, { children: noteDraft }), _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, columns: columns })) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: windowTasks, filtered: viewTasks.length, total: tasks.length, hidden: hiddenTasks, archivable: archivable, view: view, selected: selIndex, spotCount: spotCount, allCount: sessions.length, childCount: childCount, focused: focus === 'tasks', loadError: loadError, detail: detail, windowStart: taskWin.start, above: taskWin.start, below: viewTasks.length - taskWin.end, detailLines: budget.detailLines, columns: columns }), _jsx(SessionsPane, { parentLabel: parentLabel, isSpot: isSpot, isAll: isAll, bindings: bindings, taskW: sessionCols.task, ageW: sessionCols.age, rows: windowRows, total: assembled.pinnedCount + assembled.contextTotal, pinnedCount: assembled.pinnedCount, hidden: assembled.contextHidden, selectedId: selSessionId ?? undefined, focused: focus === 'sessions', above: sessionWin.start, below: sessionRows.length - sessionWin.end, detail: budget.sessionDetail ? selSessionObj : null, firstLines: budget.sessionFirstLines, lastLines: budget.sessionLastLines, columns: columns, forkOf: forkOf, sessionNotes: sessionNotes, projectCore: projectCore })] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
1690
1764
  }
1691
1765
  const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
1692
1766
  // Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
@@ -1924,10 +1998,10 @@ function AssignScreen({ sessionId, label, current, filter, rows, selected, match
1924
1998
  return (_jsxs(Text, { inverse: sel, dimColor: !sel && isDone(task.prog), wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, task.id, " ", sanitize(task.pri), " ", displayProg(task.prog), " ", desc, tail] }, task.id));
1925
1999
  })] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
1926
2000
  }
1927
- function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount, allCount, childCount, focused, loadError, detail, windowStart, above, below, detailLines, columns, }) {
2001
+ function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount, allCount, childCount, focused, loadError, detail, windowStart, above, below, detailLines, columns, archivable, }) {
1928
2002
  const allSelected = selected === ROW_ALL;
1929
2003
  const spotSelected = selected === ROW_SPOT;
1930
- 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, wrap: "truncate-end", children: ["Tasks (", hidden > 0 ? `${filtered}/${total}` : filtered, ")", hidden > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 ", hidden, " nascoste"] }) : null, above > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2191", above] }) : null, below > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2193", below] }) : 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:", ' ', [
2004
+ 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, wrap: "truncate-end", children: ["Tasks (", hidden > 0 ? `${filtered}/${total}` : filtered, ")", hidden > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 ", hidden, " nascoste"] }) : null, above > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2191", above] }) : null, below > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2193", below] }) : null, archivable > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", archivable, " archiviabili"] }) : 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:", ' ', [
1931
2005
  ...PRI_ENTRIES.filter((e) => view.hiddenPri.includes(e.name)),
1932
2006
  ...PROG_ENTRIES.filter((e) => view.hiddenProg.includes(e.name)),
1933
2007
  ]
@@ -1950,7 +2024,7 @@ function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount,
1950
2024
  return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: !sel && isDone(task.prog), wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, task.id, " ", sanitize(task.pri), " ", displayProg(task.prog), " ", desc, tail] }, task.id));
1951
2025
  })), detail && detailLines > 0 ? (_jsx(DetailPane, { detail: detail, maxLines: detailLines, columns: columns })) : null] }));
1952
2026
  }
1953
- function SessionsPane({ parentLabel, isSpot, isAll, bindings, rows, total, pinnedCount, hidden, selectedId, focused, above, below, detail, firstLines, lastLines, columns, forkOf, sessionNotes, projectCore, }) {
2027
+ function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW, rows, total, pinnedCount, hidden, selectedId, focused, above, below, detail, firstLines, lastLines, columns, forkOf, sessionNotes, projectCore, }) {
1954
2028
  return (_jsxs(Box, { flexDirection: "column", width: "50%", borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsxs(Text, { bold: true, color: focused ? 'cyan' : undefined, wrap: "truncate-end", children: ["Sessions \u00B7 ", parentLabel, " (", total, ")", pinnedCount > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 \uD83D\uDCCC", pinnedCount] }) : null, hidden > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 +", hidden, " pi\u00F9 vecchie"] }) : null, above > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2191", above] }) : null, below > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2193", below] }) : null] }), total === 0 ? (_jsx(Text, { color: "yellow", wrap: "truncate-end", children: isAll
1955
2029
  ? 'nessuna conversazione nel progetto'
1956
2030
  : isSpot
@@ -1965,49 +2039,62 @@ function SessionsPane({ parentLabel, isSpot, isAll, bindings, rows, total, pinne
1965
2039
  // T50 — pin stale: transcript sparito, nessuna Session da mostrare.
1966
2040
  // Riga navigabile e spinnabile (`p`), marcata, mai un crash.
1967
2041
  if (row.kind === 'pinned' && row.stale) {
1968
- return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: true, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, _jsx(Text, { color: "yellow", children: WARN }), " pin stale", ' ', _jsx(Text, { dimColor: true, children: row.sessionId.slice(0, 8) }), sessionNotes.get(row.sessionId) ? (_jsxs(Text, { color: "yellow", children: [" \u00AB", cut(sessionNotes.get(row.sessionId), 30), "\u00BB"] })) : null] }, row.sessionId));
2042
+ // T60 anche qui la nota si taglia sul budget DERIVATO, non su un
2043
+ // 30 inchiodato: su un pane stretto quel valore fisso mandava la
2044
+ // riga oltre il bordo, e a ripararla arrivava `cli-truncate` (che
2045
+ // sfora di una colonna per emoji e mangia il bordo stesso).
2046
+ const staleNote = sessionNotes.get(row.sessionId);
2047
+ const staleW = Math.max(0, paneTextWidth(columns) - (2 /* caret */ + termWidth(`${WARN} pin stale `) + SID_CHARS + 3 /* spazio + caporali */));
2048
+ return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: true, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, _jsx(Text, { color: "yellow", children: WARN }), " pin stale", ' ', _jsx(Text, { dimColor: true, children: row.sessionId.slice(0, SID_CHARS) }), staleNote ? _jsxs(Text, { color: "yellow", children: [" \u00AB", cut(staleNote, staleW), "\u00BB"] }) : null] }, row.sessionId));
1969
2049
  }
1970
2050
  const s = row.session; // non-stale → session presente
1971
2051
  const isPinnedRow = row.kind === 'pinned';
1972
2052
  // T28 — un ramo eredita il titolo dell'origine: senza marcatore le due
1973
- // righe sarebbero identiche a occhio. `⑂` sta PRIMA del titolo, dove
1974
- // la troncatura non arriva mai.
2053
+ // righe sarebbero identiche a occhio.
1975
2054
  const forked = forkOf.has(s.sessionId);
1976
- // T53 — con una nota il prefisso di progetto sparisce e le sue colonne
1977
- // passano alla nota; senza, il titolo resta com'è (vedi `rowLabel`).
1978
- //
1979
- // Invariante ③: il budget è DERIVATO da `columns`, non inchiodato.
1980
- // Con un valore fisso (erano 44) su un terminale stretto la riga
1981
- // superava il pane, e a troncarla finiva `cli-truncate` — che sfora
1982
- // di una colonna per emoji e si mangia il bordo destro.
1983
2055
  // T59 D2 — nella vista "tutte" il marker è PER-SESSIONE (binding letto
1984
2056
  // dal sidecar) e non deciso dal parent: la lista mescola scoped e spot,
1985
2057
  // quindi un marker uniforme mentirebbe su metà delle righe. E il solo
1986
2058
  // glifo direbbe *che* la conversazione è legata senza dire *a cosa* —
1987
2059
  // informazione monca proprio qui, l'unica vista dove l'appartenenza
1988
- // non è scritta da nessun'altra parte dello schermo. Da qui il task id
1989
- // inline, che costa colonne al titolo solo in questa vista.
2060
+ // non è scritta da nessun'altra parte dello schermo: da qui la colonna
2061
+ // task accanto, che esiste solo in questa vista.
1990
2062
  const bound = bindings.get(s.sessionId) ?? null;
1991
- const idTag = isAll && bound ? `${bound} ` : '';
1992
2063
  const linked = isAll ? Boolean(bound) : !isSpot;
1993
- const meta = ` · ${s.gitBranch || '-'} · ${relTime(s.ts)}`;
1994
- // Il pavimento è `0`, non un minimo di cortesia: questo numero è un
1995
- // TETTO (le colonne che restano), non una preferenza. Un `Math.max(12,
1996
- // …)` lo alza sopra lo spazio reale appena il resto della riga cresce
1997
- // e la riga esce dal pane mangiandosi il bordo, cioè il difetto che
1998
- // l'invariante esiste per impedire. Con poco spazio è il titolo a
1999
- // sparire: hash, task id, branch e data restano, e il frame regge.
2000
- const labelBudget = Math.max(0, paneTextWidth(columns) -
2064
+ // T60 colonne VERE: ogni cella fissa è larga esattamente quanto
2065
+ // dichiara, riempita di spazi con `pad` (che misura in colonne, non in
2066
+ // caratteri). Il marker va portato a 2 anche quando è `○`, largo 1:
2067
+ // era lui a far slittare a sinistra di una colonna tutta la riga di
2068
+ // ogni sessione spot.
2069
+ const age = relTime(s.ts);
2070
+ // Il taglio del titolo è ciò che RESTA, calcolato per sottrazione: le
2071
+ // colonne fisse sono note, quindi l'unica cella elastica prende il
2072
+ // resto. Pavimento `0` e non un minimo di cortesia — è un tetto, non
2073
+ // una preferenza: alzarlo sopra lo spazio reale fa uscire la riga dal
2074
+ // pane e le mangia il bordo (invariante ③).
2075
+ const titleW = Math.max(0, paneTextWidth(columns) -
2001
2076
  (2 /* caret */ +
2002
- 2 /* icona */ +
2003
- 1 /* spazio */ +
2004
- SID_W /* hash + spazio */ +
2005
- termWidth(idTag) +
2006
- (forked ? 2 : 0)) -
2007
- termWidth(meta) -
2008
- 1 /* spazio prima del meta */);
2009
- const label = rowLabel(s.title, sessionNotes.get(s.sessionId), projectCore, labelBudget);
2010
- return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, isPinnedRow ? (_jsx(Text, { color: "yellow", children: "\uD83D\uDCCC" })) : linked ? (_jsx(Text, { color: "green", children: "\uD83D\uDD17" })) : (_jsx(Text, { dimColor: true, children: "\u25CB" })), ' ', _jsx(Text, { color: "cyan", children: s.sessionId.slice(0, SID_CHARS) }), ' ', idTag ? _jsx(Text, { color: "green", children: idTag }) : null, forked ? _jsx(Text, { color: "magenta", children: "\u2442 " }) : null, label.note ? (_jsxs(Text, { color: "yellow", bold: true, children: ["\u00AB", label.note, "\u00BB"] })) : null, label.note && label.rest ? ' ' : null, label.rest ? _jsx(Text, { dimColor: Boolean(label.note), children: label.rest }) : null, ' ', _jsxs(Text, { dimColor: true, children: ["\u00B7 ", s.gitBranch || '-', " \u00B7 ", relTime(s.ts)] })] }, s.sessionId));
2077
+ 2 /* marker */ +
2078
+ 1 /* gutter */ +
2079
+ SID_CHARS +
2080
+ 1 /* gutter */ +
2081
+ (taskW > 0 ? taskW + 1 : 0) +
2082
+ 1 /* gutter prima della data */ +
2083
+ ageW));
2084
+ // T28 `⑂` sta DENTRO la cella titolo, non in una colonna sua: una
2085
+ // colonna dedicata costerebbe 2 spazi vuoti su ogni riga non-fork, e
2086
+ // metterlo fuori cella sposterebbe il bordo del titolo solo sui rami —
2087
+ // cioè rimetterebbe lo slittamento che le colonne tolgono.
2088
+ const forkMark = forked ? '⑂ ' : '';
2089
+ const inner = Math.max(0, titleW - termWidth(forkMark));
2090
+ // T60 — il testo arriva già ripulito di ciò che le colonne accanto
2091
+ // dicono già (progetto e task id): senza, la cella conterrebbe
2092
+ // `🧵 loom-works · T59` accanto a una colonna che dice `T59`.
2093
+ const label = rowLabel(sessionTitle(s, projectCore, bound), sessionNotes.get(s.sessionId), inner);
2094
+ const used = (label.note ? termWidth(label.note) + 2 : 0) +
2095
+ (label.note && label.rest ? 1 : 0) +
2096
+ termWidth(label.rest);
2097
+ return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, isPinnedRow ? (_jsx(Text, { color: "yellow", children: pad('📌', 2) })) : linked ? (_jsx(Text, { color: "green", children: pad('🔗', 2) })) : (_jsx(Text, { dimColor: true, children: pad('○', 2) })), ' ', _jsx(Text, { color: "cyan", children: s.sessionId.slice(0, SID_CHARS) }), ' ', taskW > 0 ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: bound ? 'green' : undefined, dimColor: !bound, children: pad(bound ?? TASK_EMPTY, taskW) }), ' '] })) : null, forkMark ? _jsx(Text, { color: "magenta", children: forkMark }) : null, label.note ? (_jsxs(Text, { color: "yellow", bold: true, children: ["\u00AB", label.note, "\u00BB"] })) : null, label.note && label.rest ? ' ' : null, label.rest ? _jsx(Text, { dimColor: Boolean(label.note), children: label.rest }) : null, ' '.repeat(Math.max(0, inner - used)), ' ', _jsx(Text, { dimColor: true, children: pad(age, ageW, 'right') })] }, s.sessionId));
2011
2098
  })), detail ? (_jsx(SessionDetailPane, { s: detail, firstLines: firstLines, lastLines: lastLines, columns: columns, origin: forkOf.get(detail.sessionId) ?? null, note: sessionNotes.get(detail.sessionId) ?? '' })) : null] }));
2012
2099
  }
2013
2100
  // T49 — detail pane della sessione selezionata (hover), gemello del DetailPane
@@ -2022,7 +2109,7 @@ function SessionDetailPane({ s, firstLines, lastLines, columns, origin, note, })
2022
2109
  const width = detailTextWidth(columns);
2023
2110
  const first = s.customTitle && firstLines > 0 ? wrapLines(s.firstPrompt, width, firstLines) : [];
2024
2111
  const last = s.lastReply && lastLines > 0 ? wrapLines(s.lastReply, width, lastLines) : [];
2025
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: [note ? _jsxs(Text, { color: "yellow", children: ["\u00AB", note, "\u00BB "] }) : null, _jsx(Text, { dimColor: Boolean(note), children: s.title })] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [fmtSize(s.sizeBytes), " \u00B7 ", s.turns, " turni \u00B7 ", fmtDateTime(s.ts), origin ? ` · ⑂ da ${origin.slice(0, 8)}` : ''] }), first.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '» ' : ' ', line] }, `f${i}`))), last.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '« ' : ' ', line] }, `l${i}`)))] }));
2112
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: [note ? _jsxs(Text, { color: "yellow", children: ["\u00AB", note, "\u00BB "] }) : null, _jsx(Text, { dimColor: Boolean(note), children: s.title })] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [fmtSize(s.sizeBytes), " \u00B7 ", s.turns, " turni \u00B7 ", fmtDateTime(s.ts), " \u00B7 ", s.gitBranch || '-', origin ? ` · ⑂ da ${origin.slice(0, 8)}` : ''] }), first.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '» ' : ' ', line] }, `f${i}`))), last.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '« ' : ' ', line] }, `l${i}`)))] }));
2026
2113
  }
2027
2114
  /**
2028
2115
  * Righe non-wrappabili del dettaglio (titolo + meta + commit) e loro conteggio.
package/dist/config.js CHANGED
@@ -6,6 +6,7 @@
6
6
  import { readFileSync } from 'node:fs';
7
7
  import { join } from 'node:path';
8
8
  import { sanitize } from './width.js';
9
+ import { DEFAULT_ARCHIVABLE_DAYS } from './archivable.js';
9
10
  export function configFilePath(projectRoot) {
10
11
  return join(projectRoot, '.claude', 'loom-works.json');
11
12
  }
@@ -99,6 +100,46 @@ export function launchLegend(entries, columns, reserved = 0) {
99
100
  taken = fit(10);
100
101
  return { shown: taken.join(' · '), overflow: parts.length - taken.length, unreachable };
101
102
  }
103
+ // T61 — soglia d'età del contatore archiviabili, campo `archivableDays`.
104
+ //
105
+ // È il PRIMO scalare che il lato TypeScript legge dal file config: `launch` e
106
+ // `identity` sopra sono strutture, e `docsRoot` arriva ancora dalla sola env
107
+ // `LOOM_DECK_DOCS_ROOT` (vedi `tasks.ts`). La catena da percorrere si ferma
108
+ // qui: nessun passaggio da `lib-config.sh`/`reg_pull`/dconf, perché il solo
109
+ // consumer è il deck e il file ce l'ha sotto mano. Il precedente esatto è
110
+ // `permissionMode`, che vive nel file, lo legge `deck-run` via jq e non è
111
+ // propagato al registry.
112
+ /** Interi positivi soltanto: uno 0 spegnerebbe la soglia (tutte le Done
113
+ * archiviabili), un negativo o un decimale sono un typo. Valore fuori dominio
114
+ * → default, mai passaggio cieco di un numero senza senso al conteggio. */
115
+ export function parseArchivableDays(raw) {
116
+ if (!raw || typeof raw !== 'object')
117
+ return null;
118
+ const v = raw.archivableDays;
119
+ if (typeof v !== 'number' || !Number.isInteger(v) || v <= 0)
120
+ return null;
121
+ return v;
122
+ }
123
+ /**
124
+ * Precedenza `env → file → default`, la stessa che `deck-run` applica a
125
+ * `permissionMode`. L'env esiste per i test e per la verifica manuale (con la
126
+ * soglia reale il segmento resta invisibile per settimane su un progetto
127
+ * giovane): la configurazione vera è il campo nel file, che viaggia col repo.
128
+ */
129
+ export function loadArchivableDays(projectRoot) {
130
+ const env = Number(process.env.LOOM_DECK_ARCHIVABLE_DAYS);
131
+ if (Number.isInteger(env) && env > 0)
132
+ return env;
133
+ try {
134
+ const fromFile = parseArchivableDays(JSON.parse(readFileSync(configFilePath(projectRoot), 'utf8')));
135
+ if (fromFile !== null)
136
+ return fromFile;
137
+ }
138
+ catch {
139
+ // file assente o malformato → default, come loadLaunch/loadIdentity
140
+ }
141
+ return DEFAULT_ARCHIVABLE_DAYS;
142
+ }
102
143
  export function parseIdentity(raw) {
103
144
  if (!raw || typeof raw !== 'object')
104
145
  return null;
@@ -38,6 +38,48 @@ export function stripProjectCore(title, core) {
38
38
  }
39
39
  return t.replace(/^[\s·]+/, '').trim();
40
40
  }
41
+ /**
42
+ * Toglie il token del task id dal titolo — quello e solo quello, confrontato
43
+ * per token intero e non per substring (`T5` non deve mordere dentro `T59`).
44
+ *
45
+ * Va in coppia con `stripProjectCore`: un titolo di tab è `<emoji> <progetto> ·
46
+ * <task>`, quindi tolto il progetto resta quasi sempre il solo task id.
47
+ */
48
+ export function stripTaskId(title, taskId) {
49
+ if (!taskId)
50
+ return title;
51
+ return title
52
+ .split(/\s+/)
53
+ .filter((tok) => tok !== taskId)
54
+ .join(' ')
55
+ .replace(/^[\s·]+|[\s·]+$/g, '')
56
+ .trim();
57
+ }
58
+ /**
59
+ * Il testo che DISTINGUE una conversazione dalle altre — tolto tutto ciò che è
60
+ * già scritto altrove nella stessa riga.
61
+ *
62
+ * Il titolo di una sessione è la label della tab Ptyxis quando esiste
63
+ * (`🧵 loom-works · T59`), e il primo prompt dell'utente quando non esiste. I
64
+ * due casi non vanno trattati allo stesso modo, ed è il punto di questa
65
+ * funzione:
66
+ *
67
+ * - **con titolo custom** → è composto di pezzi che la riga mostra già in
68
+ * colonna: il progetto (costante su ogni riga di un deck per-progetto) e il
69
+ * task id (colonna sua). Tolti quelli non resta quasi mai nulla → si cade sul
70
+ * primo prompt, che è l'unica cosa lì dentro a dire di cosa si parlava.
71
+ * - **senza titolo custom** → `title` È già il primo prompt: intatto.
72
+ *
73
+ * `title` (non `customTitle`) è la fonte anche nel primo ramo: è la stessa
74
+ * stringa già sanificata al confine dell'adapter, e usare il campo grezzo
75
+ * rimetterebbe in circolo glifi che il frame non sa disegnare.
76
+ */
77
+ export function sessionTitle(s, core, taskId) {
78
+ if (!s.customTitle)
79
+ return s.title;
80
+ const rest = stripTaskId(stripProjectCore(s.title, core), taskId);
81
+ return rest || s.firstPrompt || '';
82
+ }
41
83
  /** Colonne minime perché un residuo dopo la nota valga la riga: sotto questa
42
84
  * soglia si vedrebbero due lettere e un'ellissi, cioè rumore. */
43
85
  const MIN_REST = 6;
@@ -45,27 +87,25 @@ const MIN_REST = 6;
45
87
  * è la parte scelta da un umano, non è lei a cedere il posto per prima. */
46
88
  const MIN_NOTE = 14;
47
89
  /**
48
- * Cosa scrivere sulla riga di una conversazione, dentro `budget` colonne.
49
- *
50
- * Due regimi, ed è una decisione deliberata che NON siano lo stesso:
90
+ * Come dividere `budget` colonne fra la nota umana e il testo della
91
+ * conversazione, quando ci sono entrambi.
51
92
  *
52
- * - **senza nota** il titolo resta INTATTO, prefisso di progetto incluso.
53
- * Togliere il core qui lascerebbe `T52` o il nulla (i titoli di tab non
54
- * contengono altro), cioè meno informazione di prima, non più spazio.
55
- * - **con nota** il core se ne va: la nota dice già quale conversazione è,
56
- * quindi le colonne del prefisso passano a lei. Il residuo (tipicamente la
57
- * task, `T52`) segue dimmato se ci sta; se non resta nulla va benissimo.
93
+ * `text` arriva GIÀ ripulito da `sessionTitle`: qui non si decide più *cosa*
94
+ * mostrare, solo *quanto*. Prima lo strip del prefisso di progetto avveniva
95
+ * dentro questa funzione e solo sul ramo con nota — asimmetria nata quando il
96
+ * task id non aveva una colonna sua e togliere il core avrebbe lasciato il
97
+ * nulla. Con la riga incolonnata quella premessa è caduta, e tenere due regimi
98
+ * diversi avrebbe reso la colonna titolo larga in un caso e stretta nell'altro.
58
99
  *
59
- * Il budget si divide dando la precedenza alla nota, ma senza affamare il
60
- * residuo: con entrambi presenti la nota cede fino a `MIN_NOTE` per lasciare
61
- * spazio al residuo, e il residuo sparisce del tutto sotto `MIN_REST` invece di
62
- * ridursi a un moncone.
100
+ * Il budget la precedenza alla nota, ma senza affamare il resto: con
101
+ * entrambi presenti la nota cede fino a `MIN_NOTE`, e il resto sparisce del
102
+ * tutto sotto `MIN_REST` invece di ridursi a un moncone.
63
103
  */
64
- export function rowLabel(title, note, core, budget) {
104
+ export function rowLabel(text, note, budget) {
65
105
  if (!note)
66
- return { note: '', rest: cut(title, Math.max(0, budget)) };
106
+ return { note: '', rest: cut(text, Math.max(0, budget)) };
67
107
  // 2 colonne per i caporali « », 1 per lo spazio prima del residuo.
68
- const rest = stripProjectCore(title, core);
108
+ const rest = text;
69
109
  const noteBudget = Math.max(0, budget - 2);
70
110
  if (!rest)
71
111
  return { note: cut(note, noteBudget), rest: '' };
package/dist/width.js CHANGED
@@ -202,6 +202,26 @@ export function cut(s, cols) {
202
202
  // informazione, tolgono una colonna di testo.
203
203
  return out.trimEnd().replace(/…$/, '') + '…';
204
204
  }
205
+ /**
206
+ * Cella di larghezza ESATTA `cols`: taglia se eccede, riempie di spazi se manca.
207
+ *
208
+ * È il gemello di `cut` e serve a una cosa sola: fare colonne vere. Una lista
209
+ * incolonnata a mano allinea solo finché ogni cella misura davvero quanto
210
+ * dichiara — e la misura è in COLONNE del terminale (`termWidth`), non in
211
+ * caratteri: `'○'.padEnd(2)` e `'🔗'.padEnd(2)` danno due stringhe che
212
+ * `String.length` giura uguali e che il terminale disegna larghe 2 e 3. È
213
+ * esattamente lo slittamento di una colonna che rende ragged una lista.
214
+ *
215
+ * `align: 'right'` mette il riempimento davanti: serve ai campi ancorati al
216
+ * margine destro (la data), dove ad allinearsi sono le unità, non l'inizio.
217
+ */
218
+ export function pad(s, cols, align = 'left') {
219
+ if (cols <= 0)
220
+ return '';
221
+ const t = termWidth(s) > cols ? cut(s, cols) : s;
222
+ const fill = ' '.repeat(Math.max(0, cols - termWidth(t)));
223
+ return align === 'right' ? fill + t : t + fill;
224
+ }
205
225
  /**
206
226
  * Larghezza in colonne di UN carattere **dopo** `sanitize`.
207
227
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.24.0",
3
+ "version": "0.26.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": {