@lamemind/loom-deck 0.25.0 → 0.27.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/archivable.js +139 -0
- package/dist/cli.js +71 -7
- package/dist/config.js +41 -0
- package/package.json +1 -1
- package/scripts/deck-run +49 -1
|
@@ -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';
|
|
@@ -156,8 +157,19 @@ function spawnDeck(id, cwd, sessionId, kind) {
|
|
|
156
157
|
// task). Spot → `--no-task --resume`: resume nudo, solo label progetto. Nessun
|
|
157
158
|
// prompt iniziale in entrambi i casi: riprendere una conversazione significa
|
|
158
159
|
// continuarla, non iniettarle un messaggio (lo salta deck-run).
|
|
159
|
-
|
|
160
|
+
//
|
|
161
|
+
// T64 — la NOTA della conversazione (se c'è) viaggia nel titolo della tab. Più
|
|
162
|
+
// sessioni sulla stessa task hanno oggi titoli identici (`label · T81`): la nota
|
|
163
|
+
// è già ciò con cui l'utente le distingue in lista, quindi è anche ciò che
|
|
164
|
+
// distingue le tab. La passa il DECK e non la legge deck-run perché la nota vive
|
|
165
|
+
// nel sidecar `session-tasks.jsonl`, che deck-run non tocca (legge solo
|
|
166
|
+
// `.claude/loom-works.json`): tenerlo così evita di dare al primitive un secondo
|
|
167
|
+
// file da conoscere. Il titolo si congela qui — `claude --name` lo setta una
|
|
168
|
+
// volta sola, quindi una nota cambiata DOPO non ri-titola la tab già aperta.
|
|
169
|
+
function spawnDeckResume(taskId, cwd, sessionId, note) {
|
|
160
170
|
const args = taskId ? [taskId, '--resume', sessionId] : ['--no-task', '--resume', sessionId];
|
|
171
|
+
if (note)
|
|
172
|
+
args.push('--title-note', note);
|
|
161
173
|
const child = spawnOut(DECK_RUN, args, { cwd, detached: true, stdio: 'ignore' });
|
|
162
174
|
child.unref();
|
|
163
175
|
return child;
|
|
@@ -170,6 +182,10 @@ function spawnDeckResume(taskId, cwd, sessionId) {
|
|
|
170
182
|
// conoscerlo prima che la sessione esista, e senza conoscerlo non si possono
|
|
171
183
|
// scrivere né il binding task né il record di lineage (il transcript del fork
|
|
172
184
|
// non nomina da nessuna parte la sessione d'origine).
|
|
185
|
+
// Nessun `--title-note` (T64): il ramo nasce con un sessionId proprio e SENZA
|
|
186
|
+
// nota nel sidecar — ereditare quella dell'origine metterebbe nel titolo una
|
|
187
|
+
// maniglia che nella lista non compare, cioè una promessa falsa. Il fork si
|
|
188
|
+
// distingue col suo marcatore, `· fork`.
|
|
173
189
|
function spawnDeckFork(taskId, cwd, originId, newId) {
|
|
174
190
|
const args = [
|
|
175
191
|
...(taskId ? [taskId] : ['--no-task']),
|
|
@@ -406,6 +422,48 @@ function useSessions(projectRoot) {
|
|
|
406
422
|
}, [projectRoot]);
|
|
407
423
|
return { ...state, reload: () => reloadRef.current() };
|
|
408
424
|
}
|
|
425
|
+
// T61 — conteggio delle Done oltre soglia, su una scala di refresh TUTTA SUA.
|
|
426
|
+
//
|
|
427
|
+
// Non è appeso a POLL_MS (1,5s) come tasks.md e le sessioni: l'età di una task
|
|
428
|
+
// cambia una volta al giorno, e ogni giro costa la lettura di N task file più
|
|
429
|
+
// qualche spawn di git. Due trigger:
|
|
430
|
+
//
|
|
431
|
+
// · quando cambia l'INSIEME delle task Done (`doneSig`) — copre l'avvio, dove
|
|
432
|
+
// il primo render ha `tasks` ancora vuoto, e la chiusura di una task, dove
|
|
433
|
+
// il numero deve muoversi senza aspettare ore;
|
|
434
|
+
// · ogni SCAN_INTERVAL_MS — copre il caso opposto, in cui non cambia nulla
|
|
435
|
+
// sul disco ed è il calendario a far scattare una task oltre soglia.
|
|
436
|
+
//
|
|
437
|
+
// `doneSig` è una stringa, non l'array: `tasks` cambia identità a ogni re-read
|
|
438
|
+
// di tasks.md, e usarlo come dipendenza rimetterebbe lo scan sul tick da 1,5s
|
|
439
|
+
// per la via di dietro.
|
|
440
|
+
function useArchivable(doneSig, tasksDir, projectRoot, days) {
|
|
441
|
+
const [count, setCount] = useState(0);
|
|
442
|
+
useEffect(() => {
|
|
443
|
+
let alive = true;
|
|
444
|
+
const ids = doneSig ? doneSig.split(',') : [];
|
|
445
|
+
const scan = () => {
|
|
446
|
+
countArchivable(ids, { tasksDir, projectRoot, days })
|
|
447
|
+
.then((n) => {
|
|
448
|
+
if (alive)
|
|
449
|
+
setCount(n);
|
|
450
|
+
})
|
|
451
|
+
// Scan fallito (task file illeggibili, git muto) → 0, cioè segmento
|
|
452
|
+
// omesso. Un contatore informativo non merita un errore a schermo.
|
|
453
|
+
.catch(() => {
|
|
454
|
+
if (alive)
|
|
455
|
+
setCount(0);
|
|
456
|
+
});
|
|
457
|
+
};
|
|
458
|
+
scan();
|
|
459
|
+
const id = setInterval(scan, SCAN_INTERVAL_MS);
|
|
460
|
+
return () => {
|
|
461
|
+
alive = false;
|
|
462
|
+
clearInterval(id);
|
|
463
|
+
};
|
|
464
|
+
}, [doneSig, tasksDir, projectRoot, days]);
|
|
465
|
+
return count;
|
|
466
|
+
}
|
|
409
467
|
// Legge il task file della task selezionata (Q1+B T20). On-id-change: navigare
|
|
410
468
|
// con ↑↓ ricarica il dettaglio; leggere un singolo file 4-9KB è I/O triviale,
|
|
411
469
|
// niente debounce serve per la tastiera. Il refresh del contenuto a file fermo
|
|
@@ -569,6 +627,12 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
569
627
|
// La vista è una trasformazione DERIVATA, applicata a valle del load: il
|
|
570
628
|
// polling di tasks.md continua a funzionare senza saperne nulla.
|
|
571
629
|
const { visible: viewTasks, hidden: hiddenTasks } = useMemo(() => applyView(tasks, view), [tasks, view]);
|
|
630
|
+
// T61 — il conteggio guarda la lista GREZZA, non `viewTasks`: le Done fuori
|
|
631
|
+
// dai filtri della vista restano archiviabili, e un contatore che cambiasse
|
|
632
|
+
// filtrando direbbe qualcosa sulla vista invece che sulla task list.
|
|
633
|
+
const doneSig = useMemo(() => tasks.filter((t) => isDone(t.prog)).map((t) => t.id).join(','), [tasks]);
|
|
634
|
+
const archivableDays = useMemo(() => loadArchivableDays(cwd), [cwd]);
|
|
635
|
+
const archivable = useArchivable(doneSig, tasksDir, cwd, archivableDays);
|
|
572
636
|
const isSpot = sel === SPOT;
|
|
573
637
|
const isAll = sel === ALL;
|
|
574
638
|
const projectName = cwd.split('/').pop() || cwd;
|
|
@@ -967,7 +1031,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
967
1031
|
}
|
|
968
1032
|
if (row.kind === 'session') {
|
|
969
1033
|
const bound = bindings.get(row.session.sessionId) ?? null;
|
|
970
|
-
const child = spawnDeckResume(bound, cwd, row.session.sessionId);
|
|
1034
|
+
const child = spawnDeckResume(bound, cwd, row.session.sessionId, sessionNotes.get(row.session.sessionId));
|
|
971
1035
|
child.on('error', () => setNote(`⚠ resume fallito (${DECK_RUN})`));
|
|
972
1036
|
setNote(`⏎ resume ${row.session.sessionId.slice(0, 8)} → tab CC${bound ? ` (${bound})` : ' (spot)'}`);
|
|
973
1037
|
return;
|
|
@@ -1417,7 +1481,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1417
1481
|
}
|
|
1418
1482
|
else {
|
|
1419
1483
|
const bound = bindings.get(s.sessionId) ?? null;
|
|
1420
|
-
const child = spawnDeckResume(bound, cwd, s.sessionId);
|
|
1484
|
+
const child = spawnDeckResume(bound, cwd, s.sessionId, sessionNotes.get(s.sessionId));
|
|
1421
1485
|
child.on('error', () => setNote(`⚠ resume fallito (${DECK_RUN})`));
|
|
1422
1486
|
setNote(`⏎ resume ${s.sessionId.slice(0, 8)} → tab CC${bound ? ` (${bound})` : ' (spot)'}`);
|
|
1423
1487
|
}
|
|
@@ -1711,7 +1775,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1711
1775
|
if (budget.compact) {
|
|
1712
1776
|
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
1777
|
}
|
|
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] }));
|
|
1778
|
+
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
1779
|
}
|
|
1716
1780
|
const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
|
|
1717
1781
|
// Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
|
|
@@ -1949,10 +2013,10 @@ function AssignScreen({ sessionId, label, current, filter, rows, selected, match
|
|
|
1949
2013
|
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
2014
|
})] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
|
|
1951
2015
|
}
|
|
1952
|
-
function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount, allCount, childCount, focused, loadError, detail, windowStart, above, below, detailLines, columns, }) {
|
|
2016
|
+
function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount, allCount, childCount, focused, loadError, detail, windowStart, above, below, detailLines, columns, archivable, }) {
|
|
1953
2017
|
const allSelected = selected === ROW_ALL;
|
|
1954
2018
|
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:", ' ', [
|
|
2019
|
+
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
2020
|
...PRI_ENTRIES.filter((e) => view.hiddenPri.includes(e.name)),
|
|
1957
2021
|
...PROG_ENTRIES.filter((e) => view.hiddenProg.includes(e.name)),
|
|
1958
2022
|
]
|
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
package/scripts/deck-run
CHANGED
|
@@ -49,6 +49,7 @@ SESSION_ID=""
|
|
|
49
49
|
RESUME_ID=""
|
|
50
50
|
NO_TASK=0
|
|
51
51
|
FORK=0
|
|
52
|
+
TITLE_NOTE=""
|
|
52
53
|
# Vuoto = flag non passato (≠ un default già risolto): serve a distinguere
|
|
53
54
|
# "kind implicito" da "kind chiesto", perché con --no-task il primo è legittimo
|
|
54
55
|
# (nessun prompt, come sempre) e il secondo è un errore d'uso.
|
|
@@ -76,8 +77,17 @@ PROMPT_KIND=""
|
|
|
76
77
|
# dentro apici singoli in `bash -lc`, quindi il testo va tenuto in un posto solo
|
|
77
78
|
# e verificato una volta, invece di spostare il rischio di quoting su ogni
|
|
78
79
|
# chiamante. Assente → `recap`, così ogni invocazione preesistente è invariata.
|
|
80
|
+
# --title-note <testo> (T64): appende la nota della conversazione al titolo della
|
|
81
|
+
# tab. È l'UNICO ingresso di testo libero nel titolo — gli altri componenti
|
|
82
|
+
# (emoji/name dal file committato, TaskID) sono controllati — quindi il testo non
|
|
83
|
+
# si quota, si RIDUCE a un alfabeto sicuro (vedi _sane_note): tutto ciò che non
|
|
84
|
+
# ci rientra sparisce, apici inclusi.
|
|
79
85
|
while [[ $# -gt 0 ]]; do
|
|
80
86
|
case "$1" in
|
|
87
|
+
--title-note)
|
|
88
|
+
TITLE_NOTE="${2:-}"; shift 2 ;;
|
|
89
|
+
--title-note=*)
|
|
90
|
+
TITLE_NOTE="${1#*=}"; shift ;;
|
|
81
91
|
--prompt-kind)
|
|
82
92
|
PROMPT_KIND="${2:-}"; shift 2 ;;
|
|
83
93
|
--prompt-kind=*)
|
|
@@ -112,7 +122,10 @@ USAGE="uso: deck-run <TaskID> [--prompt-kind <kind>] [--session-id <uuid>]
|
|
|
112
122
|
none nessun prompt — sessione aperta sulla task, a mani nude
|
|
113
123
|
recap recap stato task <TaskID>
|
|
114
124
|
preflight /loom-works:preflight-task <TaskID>
|
|
115
|
-
run /loom-works:run-task <TaskID> (run-doc sulle task D)
|
|
125
|
+
run /loom-works:run-task <TaskID> (run-doc sulle task D)
|
|
126
|
+
|
|
127
|
+
--title-note nota della conversazione, appesa al titolo tab come «nota»
|
|
128
|
+
(ridotta a lettere/cifre/spazi/-/_, cap 60 char)"
|
|
116
129
|
|
|
117
130
|
if [[ $NO_TASK -eq 1 && -n "$TASK" ]]; then
|
|
118
131
|
echo "--no-task e <TaskID> sono mutuamente esclusivi" >&2
|
|
@@ -167,6 +180,29 @@ WORKDIR="${LOOM_DECK_WORKDIR:-$PWD}"
|
|
|
167
180
|
# suffisso "· <task>" per restare distinguibili fra più tab claude (il suffisso
|
|
168
181
|
# non rompe il match: è .includes, non equals — stesso pattern del "· deck").
|
|
169
182
|
# Fallback su "cc <task>" se manca il file o jq (progetto non loom-registered).
|
|
183
|
+
|
|
184
|
+
# Riduzione della nota a un alfabeto sicuro (T64). Whitelist, non blacklist: il
|
|
185
|
+
# titolo finisce dentro apici singoli in `bash -lc "claude --name '…'"`, e la
|
|
186
|
+
# nota è testo digitato a mano — enumerare i caratteri pericolosi significa
|
|
187
|
+
# sbagliarne uno, enumerare quelli ammessi no. Restano lettere, cifre, accentate
|
|
188
|
+
# italiane, spazio, trattino, underscore; tutto il resto (apici, backslash,
|
|
189
|
+
# `$`, backtick, `;`, emoji…) cade.
|
|
190
|
+
# Il cap a 60 CHARACTER è largo di proposito: una tab che sfora tronca a destra
|
|
191
|
+
# da sola, quindi il limite serve solo a non spingere fuori vista la parte del
|
|
192
|
+
# titolo che compass matcha, non a far stare la nota nella tab.
|
|
193
|
+
_sane_note() { # <raw> → nota ridotta, spazi collassati, cap 60 char
|
|
194
|
+
# LC_ALL locale alla funzione: serve a bash per tagliare a 60 CARATTERI e non
|
|
195
|
+
# a 60 byte (a byte, un cap che cade a metà di una `à` lascia UTF-8 rotto nel
|
|
196
|
+
# titolo). L'assegnazione rifà setlocale, l'uscita dalla funzione lo ripristina.
|
|
197
|
+
local LC_ALL=C.UTF-8
|
|
198
|
+
local s
|
|
199
|
+
s="$(printf '%s' "$1" \
|
|
200
|
+
| sed 's/[^A-Za-z0-9 _àèéìòùÀÈÉÌÒÙ-]//g' \
|
|
201
|
+
| tr -s ' ')"
|
|
202
|
+
s="${s# }"; s="${s% }"
|
|
203
|
+
printf '%s' "${s:0:60}"
|
|
204
|
+
}
|
|
205
|
+
|
|
170
206
|
_find_project_root() { # <start-dir> → dir con .claude/loom-works.json, o vuoto
|
|
171
207
|
local d="${1%/}"
|
|
172
208
|
while [[ -n "$d" && "$d" != "/" ]]; do
|
|
@@ -198,6 +234,18 @@ if [[ -n "$_cfg" ]]; then
|
|
|
198
234
|
if [[ $NO_TASK -eq 1 ]]; then TITLE="${_label}"; else TITLE="${_label} · ${TASK}"; fi
|
|
199
235
|
fi
|
|
200
236
|
fi
|
|
237
|
+
# Suffisso nota (T64): distingue fra loro le tab di più conversazioni sulla
|
|
238
|
+
# STESSA task, che altrimenti condividono un titolo identico (`label · T81`) —
|
|
239
|
+
# la nota è già la maniglia con cui l'utente le distingue in lista, e le stesse
|
|
240
|
+
# `«…»` della lista la rendono riconoscibile a colpo d'occhio anche nella tab
|
|
241
|
+
# bar. In coda per la solita ragione degli altri suffissi: il match compass è
|
|
242
|
+
# `.includes(label)`, la label deve restare intatta in TESTA.
|
|
243
|
+
# Nota ridotta a vuoto (era tutta emoji/punteggiatura) → nessun suffisso, non
|
|
244
|
+
# `«»` a vuoto.
|
|
245
|
+
if [[ -n "$TITLE_NOTE" ]]; then
|
|
246
|
+
_note="$(_sane_note "$TITLE_NOTE")"
|
|
247
|
+
[[ -n "$_note" ]] && TITLE="${TITLE} «${_note}»"
|
|
248
|
+
fi
|
|
201
249
|
# Suffisso fork (T28): un ramo eredita task e label dell'origine, quindi senza
|
|
202
250
|
# marcatore le due tab risulterebbero omonime nella stessa window. Suffisso e
|
|
203
251
|
# non prefisso perché il match compass è `.includes(label)` e la label deve
|