@lamemind/loom-deck 0.25.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
@@ -13,7 +13,8 @@ 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
15
  import { assembleSessionList, firstSelectableId, moveSelection, neighborId, rowIndexOf, rowLabel, selectedSession, sessionTitle, stripProjectCore, } from './session-list.js';
16
- import { cellWidth, launchLegend, loadIdentity, loadLaunch } from './config.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
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';
@@ -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
@@ -569,6 +612,12 @@ function Deck({ cwd, tasksPath, tasksDir }) {
569
612
  // La vista è una trasformazione DERIVATA, applicata a valle del load: il
570
613
  // polling di tasks.md continua a funzionare senza saperne nulla.
571
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);
572
621
  const isSpot = sel === SPOT;
573
622
  const isAll = sel === ALL;
574
623
  const projectName = cwd.split('/').pop() || cwd;
@@ -1711,7 +1760,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1711
1760
  if (budget.compact) {
1712
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"] })] }));
1713
1762
  }
1714
- 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, 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] }));
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] }));
1715
1764
  }
1716
1765
  const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
1717
1766
  // Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
@@ -1949,10 +1998,10 @@ function AssignScreen({ sessionId, label, current, filter, rows, selected, match
1949
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));
1950
1999
  })] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
1951
2000
  }
1952
- 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, }) {
1953
2002
  const allSelected = selected === ROW_ALL;
1954
2003
  const spotSelected = selected === ROW_SPOT;
1955
- 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:", ' ', [
1956
2005
  ...PRI_ENTRIES.filter((e) => view.hiddenPri.includes(e.name)),
1957
2006
  ...PROG_ENTRIES.filter((e) => view.hiddenProg.includes(e.name)),
1958
2007
  ]
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.25.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": {