@lamemind/loom-deck 0.57.2 → 0.58.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,66 @@
1
+ // T136 — data dell'ultimo commit che ha toccato ogni task file, in UNA
2
+ // passata di `git log` invece di uno spawn per file (vedi Description T136).
3
+ //
4
+ // `--format=#%ct` mette un prefisso che nessun path porta: la riga vuota
5
+ // compare anche FRA il timestamp e i path (non solo a fine blocco), quindi non
6
+ // può fare da separatore, e «tutte cifre» sarebbe una regola che un path
7
+ // potrebbe violare. Un merge commit senza `-m` non elenca path e produce un
8
+ // blocco senza righe path: si salta da sé, nessun ramo dedicato serve.
9
+ //
10
+ // git log esce in ordine cronologico DISCENDENTE: il primo hit di un path è
11
+ // già il suo ultimo commit, quindi si tiene solo il primo e si ignorano gli
12
+ // hit successivi dello stesso id.
13
+ import { execFile } from 'node:child_process';
14
+ import { basename } from 'node:path';
15
+ import { promisify } from 'node:util';
16
+ const execFileAsync = promisify(execFile);
17
+ const FORMAT_RE = /^#(\d+)$/;
18
+ // Il basename del task file, non il path intero (che cambia con la docs-root)
19
+ // e non `TASK_ID_RE` di tasks.ts (ancorata all'id nudo, non matcha `T136-x.md`).
20
+ const TASK_FILE_RE = /^(T\d+)-.*\.md$/;
21
+ /**
22
+ * Parsing puro dell'output `git log --format=#%ct --name-only`. Isolato da
23
+ * `commitTimes` perché è l'unica parte che vale la pena collaudare: invocare
24
+ * git davvero misurerebbe la storia di QUESTO repo, un valore che si muove a
25
+ * ogni commit.
26
+ */
27
+ export function parseCommitLog(output) {
28
+ const out = new Map();
29
+ let epoch = null;
30
+ for (const raw of output.split('\n')) {
31
+ const line = raw.trim();
32
+ const m = FORMAT_RE.exec(line);
33
+ if (m) {
34
+ epoch = Number(m[1]);
35
+ continue;
36
+ }
37
+ if (!line || epoch === null)
38
+ continue;
39
+ const idm = TASK_FILE_RE.exec(basename(line));
40
+ if (!idm)
41
+ continue;
42
+ const id = idm[1];
43
+ if (!out.has(id))
44
+ out.set(id, epoch); // primo hit = il più recente
45
+ }
46
+ return out;
47
+ }
48
+ /**
49
+ * `Map<taskId, epoch>` dell'ultimo commit di ogni task file sotto `tasksDir`.
50
+ *
51
+ * git muto (repo assente, path fuori dal repo, git non installato) → mappa
52
+ * vuota, mai un throw, come `archivable.ts`: un dato di ordinamento non può
53
+ * rompere il deck. Nessun `--since`/`-n`: un cap renderebbe la mappa
54
+ * incompleta proprio per le task vecchie mai più toccate, quelle che sotto
55
+ * `desc` finiscono in coda — dove un timestamp assente e uno vecchio si
56
+ * confonderebbero.
57
+ */
58
+ export async function commitTimes(tasksDir, projectRoot) {
59
+ try {
60
+ const { stdout } = await execFileAsync('git', ['log', '--format=#%ct', '--name-only', '--', tasksDir], { cwd: projectRoot });
61
+ return parseCommitLog(stdout);
62
+ }
63
+ catch {
64
+ return new Map();
65
+ }
66
+ }
@@ -14,7 +14,7 @@
14
14
  // la ragione per cui i loro conti di parametri restano leggibili.
15
15
  import { useEffect, useMemo, useState } from 'react';
16
16
  import { loadArchivableDays, loadIdentity, loadLaunch } from './config.js';
17
- import { useArchivable, useDirtyFolders, useInboxScan, useSessions, useTaskDetail, useTasks, } from './hooks.js';
17
+ import { useArchivable, useCommitTimes, useDirtyFolders, useInboxScan, useSessions, useTaskDetail, useTasks, } from './hooks.js';
18
18
  import { DEFAULT_INBOX_STALE_HOURS, staleCount } from './inbox.js';
19
19
  import { cycleInboxView, inboxCounts as deriveInboxCounts, inboxView, selectInboxRows, } from './inbox-views.js';
20
20
  import { docsRootName } from './tasks.js';
@@ -159,9 +159,12 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, setNote, }) {
159
159
  // includeva l'owner.
160
160
  const projectCore = identity ? identity.name : null;
161
161
  const projectName = cwd.split('/').pop() || cwd;
162
+ // T136 — data dell'ultimo commit di ogni task file, per la chiave `commit`
163
+ // della chain di sort.
164
+ const commitAt = useCommitTimes(tasksDir, cwd);
162
165
  // La vista è una trasformazione DERIVATA, applicata a valle del load: il
163
166
  // polling di tasks.md continua a funzionare senza saperne nulla.
164
- const { visible: viewTasks, hidden: hiddenTasks } = useMemo(() => applyView(tasks, view), [tasks, view]);
167
+ const { visible: viewTasks, hidden: hiddenTasks } = useMemo(() => applyView(tasks, view, { commitAt }), [tasks, view, commitAt]);
165
168
  // T61 — il conteggio guarda la lista GREZZA, non `viewTasks`: le Done fuori
166
169
  // dai filtri della vista restano archiviabili, e un contatore che cambiasse
167
170
  // filtrando direbbe qualcosa sulla vista invece che sulla task list.
@@ -197,7 +200,9 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, setNote, }) {
197
200
  hidden: hiddenTasks,
198
201
  archivable: archivable.size,
199
202
  };
200
- const paneTasks = useMemo(() => taskViewId === 'tasks' ? viewTasks : selectTasks(tasks, taskViewId, { view, archivable }), [taskViewId, viewTasks, tasks, view, archivable]);
203
+ const paneTasks = useMemo(() => taskViewId === 'tasks'
204
+ ? viewTasks
205
+ : selectTasks(tasks, taskViewId, { view, archivable, commitAt }), [taskViewId, viewTasks, tasks, view, archivable, commitAt]);
201
206
  const isSpot = sel === SPOT;
202
207
  const isAll = sel === ALL;
203
208
  // T112 — quando `CANC` pota in BLOCCO invece della sola task selezionata.
@@ -361,6 +366,13 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, setNote, }) {
361
366
  const next = rightPane === 'inbox' ? 'sessions' : 'inbox';
362
367
  setRightPane(next);
363
368
  setFocus((f) => (f === 'tasks' ? f : next));
369
+ // Montare il pane RIMISURA la coda. Il periodico resta, come rete per il
370
+ // deck lasciato aperto sul pane inbox, ma non è più l'unico canale: senza
371
+ // questa chiamata chi apre il pane guarda l'ultima misura, che può essere
372
+ // vecchia quanto l'intero intervallo. Non serve il verso opposto — tornare
373
+ // alle sessioni non guarda la coda, e uno scan lì si pagherebbe per nessuno.
374
+ if (next === 'inbox')
375
+ inbox.scan();
364
376
  setNote(next === 'inbox' ? '^B → pane inbox' : '^B → pane sessioni');
365
377
  }
366
378
  /** `esc` in vista normale: torna alle sessioni. Solo in quel verso — `esc`
package/dist/hooks.js CHANGED
@@ -10,6 +10,7 @@ import { discoverProjectSessions } from './sessions.js';
10
10
  import { discoverLiveSessions, liveSig } from './live-sessions.js';
11
11
  import { loadSessionIndex } from './task-index.js';
12
12
  import { archivableIds, SCAN_INTERVAL_MS } from './archivable.js';
13
+ import { commitTimes } from './commit-times.js';
13
14
  import { scanInbox, INBOX_SCAN_INTERVAL_MS } from './inbox.js';
14
15
  import { mixedCount, readWrapCache, runWrapScan, wrapCacheFile, wrapCount, } from './wrap-scan.js';
15
16
  import { purgeTargets } from './purge.js';
@@ -67,6 +68,43 @@ export function useTasks(tasksPath) {
67
68
  }, [tasksPath]);
68
69
  return { tasks, loadError };
69
70
  }
71
+ /**
72
+ * T136 — data dell'ultimo commit di ogni task file, per la chiave di sort
73
+ * `commit`. D1 (preflight): sullo STESSO poll di `tasks.md` (`POLL_MS`), non
74
+ * su una scala propria come `useArchivable` — una passata di `git log` costa
75
+ * ~33ms ed è UNA invocazione per tick, indipendentemente dal numero di task.
76
+ *
77
+ * P5 (preflight): `commitTimes` ritorna una `Map` nuova a ogni tick anche
78
+ * quando il contenuto non cambia; passata nuda a una `useMemo` a valle ne
79
+ * romperebbe la memoizzazione ogni 1,5s. Si confronta per FIRMA prima di
80
+ * aggiornare lo stato, come `lastMtime` in `useTasks` e `lastSig` in
81
+ * `useSessions`: l'identità della mappa cambia solo dopo un commit vero.
82
+ */
83
+ export function useCommitTimes(tasksDir, projectRoot) {
84
+ const [commitAt, setCommitAt] = useState(() => new Map());
85
+ useEffect(() => {
86
+ let lastSig = '';
87
+ let alive = true;
88
+ const reload = () => {
89
+ commitTimes(tasksDir, projectRoot).then((next) => {
90
+ if (!alive)
91
+ return;
92
+ const sig = [...next.entries()].map(([id, ts]) => `${id}:${ts}`).sort().join(',');
93
+ if (sig === lastSig)
94
+ return;
95
+ lastSig = sig;
96
+ setCommitAt(next);
97
+ });
98
+ };
99
+ reload();
100
+ const id = setInterval(reload, POLL_MS);
101
+ return () => {
102
+ alive = false;
103
+ clearInterval(id);
104
+ };
105
+ }, [tasksDir, projectRoot]);
106
+ return commitAt;
107
+ }
70
108
  // Poll delle sessioni del progetto + binding sidecar. discoverProjectSessions
71
109
  // ha cache mtime-keyed interna → il poll è economico; qui si evita comunque il
72
110
  // re-render inutile con una signature (sessionId:ts + binding entries): setState
@@ -219,28 +257,53 @@ export function useDirtyFolders(idsSig, tasksDir, projectRoot) {
219
257
  return dirty;
220
258
  }
221
259
  export function useInboxScan(projectRoot, docsRoot) {
222
- const [state, setState] = useState({ files: [], ok: true, scanned: false });
260
+ const [state, setState] = useState({
261
+ files: [],
262
+ ok: true,
263
+ scanned: false,
264
+ });
265
+ const [epoch, setEpoch] = useState(0);
266
+ // Il flag di corsa sta in un ref e non in stato, come in `useWrapScan`: due
267
+ // aperture ravvicinate del pane girano nello stesso tick di React, e un
268
+ // valore di stato arriverebbe al render dopo — cioè troppo tardi per
269
+ // impedire il secondo spawn.
270
+ const busy = useRef(false);
271
+ // `alive` vive fuori dall'effect perché `run` sopravvive al suo giro: la
272
+ // richiesta on-demand è scatenata da un gesto, non dall'effect che la ospita.
273
+ const alive = useRef(true);
223
274
  useEffect(() => {
224
- let alive = true;
225
- const scan = () => {
226
- scanInbox(projectRoot, docsRoot).then((res) => {
227
- if (!alive)
228
- return;
229
- // Un tentativo fallito NON butta via l'esito dell'ultimo riuscito: la
230
- // lista precedente resta vera e ancora apribile, e il guasto si dice
231
- // col glifo di allerta accanto ai contatori. È la stessa regola già
232
- // scritta per il project status (`finish` non riscrive la cache).
233
- setState((prev) => res.ok ? { files: res.files, ok: true, scanned: true } : { ...prev, ok: false, scanned: true });
234
- });
235
- };
236
- scan();
237
- const id = setInterval(scan, INBOX_SCAN_INTERVAL_MS);
275
+ alive.current = true;
238
276
  return () => {
239
- alive = false;
240
- clearInterval(id);
277
+ alive.current = false;
241
278
  };
279
+ }, []);
280
+ const run = useCallback(() => {
281
+ if (busy.current)
282
+ return;
283
+ busy.current = true;
284
+ scanInbox(projectRoot, docsRoot).then((res) => {
285
+ busy.current = false;
286
+ if (!alive.current)
287
+ return;
288
+ // Un tentativo fallito NON butta via l'esito dell'ultimo riuscito: la
289
+ // lista precedente resta vera e ancora apribile, e il guasto si dice
290
+ // col glifo di allerta accanto ai contatori. È la stessa regola già
291
+ // scritta per il project status (`finish` non riscrive la cache).
292
+ setState((prev) => res.ok ? { files: res.files, ok: true, scanned: true } : { ...prev, ok: false, scanned: true });
293
+ });
242
294
  }, [projectRoot, docsRoot]);
243
- return state;
295
+ useEffect(() => {
296
+ run();
297
+ const id = setInterval(run, INBOX_SCAN_INTERVAL_MS);
298
+ return () => clearInterval(id);
299
+ }, [run, epoch]);
300
+ // Lo scan a richiesta RESETTA il timer, come quello del wrap: senza,
301
+ // l'intervallo continuerebbe a scorrere dall'ultimo tick automatico e potrebbe
302
+ // rimisurare pochi istanti dopo un dato appena misurato.
303
+ const scan = useCallback(() => {
304
+ setEpoch((e) => e + 1);
305
+ }, []);
306
+ return { ...state, scan };
244
307
  }
245
308
  export function useWrapScan(projectRoot) {
246
309
  const path = useMemo(() => wrapCacheFile(projectRoot), [projectRoot]);
package/dist/model.js CHANGED
@@ -33,7 +33,7 @@ export const ROW_SPOT = 1;
33
33
  export const META_ROWS = 2;
34
34
  // Modale sort a grammatica libera: un tasto per chiave, pressioni successive
35
35
  // ciclano asc → desc → fuori dalla chain.
36
- export const SORT_TASTI = { p: 'pri', s: 'prog', i: 'id' };
36
+ export const SORT_TASTI = { p: 'pri', s: 'prog', i: 'id', c: 'commit' };
37
37
  // T52 — toggle del modale ricerca, tutti su CTRL (D2).
38
38
  //
39
39
  // I due campi di testo mangiano ogni lettera nuda, quindi un toggle non può
@@ -57,7 +57,7 @@ export const TASK_VIEWS = [
57
57
  export function selectTasks(tasks, id, ctx) {
58
58
  const entry = taskView(id);
59
59
  const picked = tasks.filter((t) => entry.has(t, ctx));
60
- picked.sort((a, b) => compareTasks(a, b, ctx.view.sort));
60
+ picked.sort((a, b) => compareTasks(a, b, ctx.view.sort, ctx));
61
61
  return picked;
62
62
  }
63
63
  /** Glifo della sessione viva, gemello di `LIVE_IDLE` del render. Duplicato qui
package/dist/ui/modals.js CHANGED
@@ -9,7 +9,7 @@ import { CARET, CARET_OFF, WARN } from '../glyphs.js';
9
9
  import { EDIT_PRI, EDIT_PROG } from '../model.js';
10
10
  import { PRI_ENTRIES, PROG_ENTRIES } from '../view.js';
11
11
  import { progressText, PRI_GLYPH, PRI_LABEL, PROG_GLYPH } from '../task-edit.js';
12
- export const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
12
+ export const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id', commit: 'commit' };
13
13
  // Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
14
14
  // spingono giù i pane invece di coprirli, così la lista che stai filtrando
15
15
  // resta sempre visibile mentre la componi.
@@ -71,7 +71,7 @@ export function HintBar({ mode, purge, keyLegend, indicators, columns, }) {
71
71
  return (_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"] }));
72
72
  }
73
73
  if (mode === 'sort') {
74
- return (_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"] }));
74
+ return (_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 ", _jsx(Text, { color: "yellow", children: "c" }), " commit (asc\u2192desc\u2192off) \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] }));
75
75
  }
76
76
  if (mode === 'filter') {
77
77
  return (_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"] }));
@@ -10,7 +10,7 @@ import { DEFAULT_VIEW, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
10
10
  export function viewFilePath(projectRoot) {
11
11
  return join(projectRoot, '.claude', 'loom', 'deck-view.json');
12
12
  }
13
- const SORT_KEYS = ['pri', 'prog', 'id'];
13
+ const SORT_KEYS = ['pri', 'prog', 'id', 'commit'];
14
14
  const PRI_NAMES = PRI_ENTRIES.map((e) => e.name);
15
15
  const PROG_NAMES = PROG_ENTRIES.map((e) => e.name);
16
16
  // Il file è editabile a mano e sopravvive ai cambi di schema: si tiene solo ciò
package/dist/view.js CHANGED
@@ -116,11 +116,16 @@ export function taskColumns(tasks, data) {
116
116
  }
117
117
  return { id: idColumnWidth(tasks), tail: tail > 0 ? tail + 1 : 0 };
118
118
  }
119
- function rankOf(task, key) {
119
+ // La chiave `commit` non ha un glifo: il rango è l'epoch stesso. Assente →
120
+ // UNKNOWN_RANK (0), sempre più basso di un epoch reale → coda sotto `desc`,
121
+ // stessa semantica del glifo non riconosciuto per `pri`/`prog`.
122
+ function rankOf(task, key, ctx) {
120
123
  if (key === 'pri')
121
124
  return priRank(task.pri);
122
125
  if (key === 'prog')
123
126
  return progRank(task.prog);
127
+ if (key === 'commit')
128
+ return ctx.commitAt.get(task.id) ?? UNKNOWN_RANK;
124
129
  return idNum(task.id);
125
130
  }
126
131
  /**
@@ -129,9 +134,9 @@ function rankOf(task, key) {
129
134
  * SEMPRE deterministico (mai instabile fra re-render). Se `id` è già una chiave
130
135
  * esplicita della chain il fallback non serve: l'id è unico, la parità è totale.
131
136
  */
132
- export function compareTasks(a, b, sort) {
137
+ export function compareTasks(a, b, sort, ctx) {
133
138
  for (const entry of sort) {
134
- const diff = rankOf(a, entry.key) - rankOf(b, entry.key);
139
+ const diff = rankOf(a, entry.key, ctx) - rankOf(b, entry.key, ctx);
135
140
  if (diff !== 0)
136
141
  return entry.dir === 'asc' ? diff : -diff;
137
142
  }
@@ -174,14 +179,14 @@ export function isVisible(task, view) {
174
179
  return true;
175
180
  }
176
181
  /** Filtra poi ordina. Non muta l'input: il polling di tasks.md resta ignaro. */
177
- export function applyView(tasks, view) {
182
+ export function applyView(tasks, view, ctx) {
178
183
  const visible = tasks.filter((t) => isVisible(t, view));
179
- visible.sort((a, b) => compareTasks(a, b, view.sort));
184
+ visible.sort((a, b) => compareTasks(a, b, view.sort, ctx));
180
185
  return { visible, hidden: tasks.length - visible.length };
181
186
  }
182
187
  export const PRI_ENTRIES = PRI_TABLE.map((e) => ({ name: e.name, glyph: e.glyph }));
183
188
  export const PROG_ENTRIES = PROG_TABLE.map((e) => ({ name: e.name, glyph: e.glyph }));
184
- const SORT_LABEL = { pri: 'pri', prog: 'stato', id: 'id' };
189
+ const SORT_LABEL = { pri: 'pri', prog: 'stato', id: 'id', commit: 'commit' };
185
190
  /** Riassunto della chain per l'header ("pri↓ id↑"); vuota → "—". */
186
191
  export function describeSort(sort) {
187
192
  if (sort.length === 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.57.2",
3
+ "version": "0.58.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": {