@lamemind/loom-deck 0.55.0 → 0.56.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/actions.js +291 -0
- package/dist/cli.js +130 -1584
- package/dist/deck-model.js +348 -0
- package/dist/frame.js +178 -0
- package/dist/inbox.js +135 -0
- package/dist/input.js +469 -0
- package/dist/overlays/modals.js +231 -0
- package/dist/overlays/purge.js +44 -0
- package/dist/plugin-cache.js +106 -0
- package/dist/task-ops.js +235 -0
- package/dist/ui/screens.js +201 -0
- package/dist/wrap-scan.js +122 -0
- package/package.json +1 -1
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
// Il MODELLO del deck: ciò che si sta guardando e come lo si naviga.
|
|
2
|
+
//
|
|
3
|
+
// Tiene insieme le fonti (task, conversazioni, archiviabili, dettaglio), lo
|
|
4
|
+
// stato di navigazione (focus, selezione dei due pane, vista attiva di ognuno) e
|
|
5
|
+
// le derivazioni che la schermata consuma. Non spawna, non scrive su disco, non
|
|
6
|
+
// legge la tastiera: chi agisce è `actions.ts`, chi dispaccia è `input.ts`.
|
|
7
|
+
//
|
|
8
|
+
// T131 — la superficie di ritorno è larga (~35 campi), e va dichiarato invece
|
|
9
|
+
// che nascosto. La ragione per cui va bene qui e non altrove è che questo È il
|
|
10
|
+
// modello: l'insieme di ciò che la vista consuma, e un modello con trenta campi
|
|
11
|
+
// resta tale che sia scritto in un `return` o sparso nel corpo di un
|
|
12
|
+
// componente. Quello che non va fatto è passarlo PEZZO PER PEZZO ai
|
|
13
|
+
// consumatori — gli attuatori e il dispatch ne ricevono l'oggetto intero, ed è
|
|
14
|
+
// la ragione per cui i loro conti di parametri restano leggibili.
|
|
15
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
16
|
+
import { loadArchivableDays, loadIdentity, loadLaunch } from './config.js';
|
|
17
|
+
import { useArchivable, useDirtyFolders, useSessions, useTaskDetail, useTasks, } from './hooks.js';
|
|
18
|
+
import { isDone } from './layout.js';
|
|
19
|
+
import { TASK_EMPTY, relTime } from './glyphs.js';
|
|
20
|
+
import { assembleSessionList, firstSelectableId, rowIndexOf, selectedSession, } from './session-list.js';
|
|
21
|
+
import { cycleSessionView, cycleTaskView, selectSessionRows, selectTasks, sessionView, taskView, } from './pane-views.js';
|
|
22
|
+
import { ALL, MAX_SESSIONS, MAX_SESSIONS_ALL, META_ROWS, ROW_ALL, ROW_SPOT, SPOT, } from './model.js';
|
|
23
|
+
import { applyView, taskColumns } from './view.js';
|
|
24
|
+
import { loadView } from './view-store.js';
|
|
25
|
+
import { termWidth } from './width.js';
|
|
26
|
+
/**
|
|
27
|
+
* Conteggio dei figli per task, più il rollup delle VIVE e le conversazioni
|
|
28
|
+
* senza binding.
|
|
29
|
+
*
|
|
30
|
+
* T124 — il rollup delle vive si deriva NELLO STESSO CICLO del conteggio, non
|
|
31
|
+
* da un secondo passaggio sul registry dei processi: quel registry conosce una
|
|
32
|
+
* conversazione PRIMA che abbia scritto il suo primo record di transcript,
|
|
33
|
+
* quindi due derivazioni indipendenti renderebbero producibile `1/0` — una viva
|
|
34
|
+
* senza totale — che non è un caso limite teorico ma la finestra normale fra lo
|
|
35
|
+
* spawn e il primo turno. Intersecando qui, **`vive ≤ totali` è garantito per
|
|
36
|
+
* costruzione**: la conversazione appena nata resta invisibile a entrambi i
|
|
37
|
+
* numeri finché non scrive, e il segnale arriva in ritardo di un turno invece
|
|
38
|
+
* che sbagliato.
|
|
39
|
+
*/
|
|
40
|
+
export function rollupChildren(sessions, bindings, live) {
|
|
41
|
+
const childCount = new Map();
|
|
42
|
+
const taskLive = new Map();
|
|
43
|
+
let spotCount = 0;
|
|
44
|
+
for (const s of sessions) {
|
|
45
|
+
const bound = bindings.get(s.sessionId);
|
|
46
|
+
if (!bound) {
|
|
47
|
+
spotCount++;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
childCount.set(bound, (childCount.get(bound) ?? 0) + 1);
|
|
51
|
+
const entry = live.get(s.sessionId);
|
|
52
|
+
if (!entry)
|
|
53
|
+
continue;
|
|
54
|
+
const prev = taskLive.get(bound);
|
|
55
|
+
// Rollup a stato misto: vince `busy`. Fra N vive, quella che sta lavorando è
|
|
56
|
+
// la ragione per cui si guarda la riga.
|
|
57
|
+
taskLive.set(bound, {
|
|
58
|
+
count: (prev?.count ?? 0) + 1,
|
|
59
|
+
status: prev?.status === 'busy' || entry.status === 'busy' ? 'busy' : 'idle',
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return { childCount, taskLive, spotCount };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* T60 — larghezze delle colonne fisse della lista sessioni, misurate sulla
|
|
66
|
+
* lista INTERA e non sulla finestra visibile: derivarle dalle sole righe a
|
|
67
|
+
* schermo le farebbe cambiare a ogni scroll, cioè l'opposto di una tabella.
|
|
68
|
+
*
|
|
69
|
+
* La colonna task esiste solo nella vista "tutte" (altrove il binding è lo
|
|
70
|
+
* stesso su ogni riga e sta già nell'header del pane), e `0` la spegne.
|
|
71
|
+
*/
|
|
72
|
+
export function sessionColumns(rows, bindings, isAll) {
|
|
73
|
+
let task = 0;
|
|
74
|
+
let age = 2;
|
|
75
|
+
for (const r of rows) {
|
|
76
|
+
// T133 D10 — la colonna serve dove l'appartenenza NON è già scritta
|
|
77
|
+
// altrove sullo schermo, cioè nella sola vista "tutte": ogni altra lista
|
|
78
|
+
// contiene le figlie del parent selezionato, che l'header nomina.
|
|
79
|
+
if (isAll) {
|
|
80
|
+
const b = bindings.get(r.sessionId);
|
|
81
|
+
if (b)
|
|
82
|
+
task = Math.max(task, termWidth(b));
|
|
83
|
+
}
|
|
84
|
+
if (r.kind === 'session')
|
|
85
|
+
age = Math.max(age, termWidth(relTime(r.session.ts)));
|
|
86
|
+
}
|
|
87
|
+
// La cella vuota deve poter entrare nella colonna, o le righe spot
|
|
88
|
+
// perderebbero il segnaposto e con lui l'allineamento.
|
|
89
|
+
return { task: task > 0 ? Math.max(task, termWidth(TASK_EMPTY)) : 0, age };
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Le conversazioni figlie della selezione: tutte quelle del progetto (`≡
|
|
93
|
+
* tutte`), quelle bound alla task selezionata, oppure (spot) quelle senza
|
|
94
|
+
* binding. `sessions` è già ts desc → l'ordine si eredita in tutti e tre i rami.
|
|
95
|
+
*/
|
|
96
|
+
export function childSessionsOf(sessions, bindings, selectedTaskId, isAll) {
|
|
97
|
+
if (isAll)
|
|
98
|
+
return sessions;
|
|
99
|
+
return sessions.filter((s) => {
|
|
100
|
+
const bound = bindings.get(s.sessionId);
|
|
101
|
+
return selectedTaskId ? bound === selectedTaskId : !bound;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
export function useDeckModel({ cwd, tasksPath, tasksDir, setNote, }) {
|
|
105
|
+
const { tasks, loadError } = useTasks(tasksPath);
|
|
106
|
+
// `notes` esce dall'indice come `sessionNotes`: nel deck `note` è già la riga
|
|
107
|
+
// di STATO in fondo al frame (il feedback di un'azione). Due concetti diversi
|
|
108
|
+
// a una lettera di distanza sarebbero una trappola di lettura — e di
|
|
109
|
+
// scrittura, visto che `setNote` compare in quasi ogni ramo.
|
|
110
|
+
const { sessions, bindings, forkOf, pinned, notes: sessionNotes, live, reload: reloadSessions, } = useSessions(cwd);
|
|
111
|
+
const [focus, setFocus] = useState('tasks');
|
|
112
|
+
// T39 — selezione KEYED SU ID, non su indice. Con una vista trasformata
|
|
113
|
+
// (filtro/sort) l'indice non identifica più la stessa task: leggere l'array
|
|
114
|
+
// grezzo per posizione spawnerebbe la task sbagliata, in silenzio.
|
|
115
|
+
// T59 — e le righe meta sono sentinelle, non `null`: gli stati sono tre.
|
|
116
|
+
// D4 — si apre su `≡ tutte`: la vista più ampia in cima, poi si scende verso
|
|
117
|
+
// i sottoinsiemi. La selezione non è persistita (a differenza di `view`, T39),
|
|
118
|
+
// quindi questo atterraggio vale a ogni avvio.
|
|
119
|
+
const [sel, setSel] = useState(ALL);
|
|
120
|
+
// T50 — selezione del pane sessioni KEYED SU sessionId (non indice): la lista
|
|
121
|
+
// a due gruppi + separatore è una vista trasformata, un indice grezzo punterebbe
|
|
122
|
+
// alla riga sbagliata dopo un pin o un cambio di contesto (stesso trap T39).
|
|
123
|
+
const [selSessionId, setSelSessionId] = useState(null);
|
|
124
|
+
// T39 — vista corrente (filtri + sort). Vive nel modello e non nell'hook dei
|
|
125
|
+
// modali che la editano: è ciò che `applyView` consuma per produrre la lista,
|
|
126
|
+
// è persistita su disco e la rilegge il tasto `w`. È stato del modello che due
|
|
127
|
+
// overlay scrivono, non stato di un overlay.
|
|
128
|
+
const [view, setView] = useState(() => loadView(cwd));
|
|
129
|
+
// T100 — vista attiva di ciascun pane, navigata con `tab`. VOLATILE per
|
|
130
|
+
// decisione (D3 create): non entra in `deck-view.json`, il deck riapre sempre
|
|
131
|
+
// su `Tasks` e su `{parent}`. Il criterio è il rischio di leggere una lista
|
|
132
|
+
// parziale credendola completa — un filtro salvato lo si è scelto, una vista
|
|
133
|
+
// riaperta a freddo si legge come la lista intera.
|
|
134
|
+
const [taskViewId, setTaskViewId] = useState('tasks');
|
|
135
|
+
const [sessionViewId, setSessionViewId] = useState('context');
|
|
136
|
+
// Voci launch del progetto (T32): lette una volta, raggiunte per indice 1..9.
|
|
137
|
+
const launch = useMemo(() => loadLaunch(cwd), [cwd]);
|
|
138
|
+
// Identità (T37): titolo delle tab terminale spawnate col tasto `t`.
|
|
139
|
+
const identity = useMemo(() => loadIdentity(cwd), [cwd]);
|
|
140
|
+
// T53 — il core che ogni titolo di tab porta, quindi la colonna costante da
|
|
141
|
+
// togliere quando serve spazio. Hoistato perché lo consumano DUE schermate
|
|
142
|
+
// (lista e ricerca): calcolarlo su ogni call site è il modo in cui le due
|
|
143
|
+
// smettono di togliere la stessa cosa.
|
|
144
|
+
// T58 — il core è il solo `name`, non `<emoji> <name>` come la chiave di match
|
|
145
|
+
// di compass: qui non serve entropia (è un taglio cosmetico, non un matcher) e
|
|
146
|
+
// il nome nudo ripulisce anche i titoli storici, scritti quando la formula
|
|
147
|
+
// includeva l'owner.
|
|
148
|
+
const projectCore = identity ? identity.name : null;
|
|
149
|
+
const projectName = cwd.split('/').pop() || cwd;
|
|
150
|
+
// La vista è una trasformazione DERIVATA, applicata a valle del load: il
|
|
151
|
+
// polling di tasks.md continua a funzionare senza saperne nulla.
|
|
152
|
+
const { visible: viewTasks, hidden: hiddenTasks } = useMemo(() => applyView(tasks, view), [tasks, view]);
|
|
153
|
+
// T61 — il conteggio guarda la lista GREZZA, non `viewTasks`: le Done fuori
|
|
154
|
+
// dai filtri della vista restano archiviabili, e un contatore che cambiasse
|
|
155
|
+
// filtrando direbbe qualcosa sulla vista invece che sulla task list.
|
|
156
|
+
const doneSig = useMemo(() => tasks.filter((t) => isDone(t.prog)).map((t) => t.id).join(','), [tasks]);
|
|
157
|
+
const archivableDays = useMemo(() => loadArchivableDays(cwd), [cwd]);
|
|
158
|
+
const archivable = useArchivable(doneSig, tasksDir, cwd, archivableDays);
|
|
159
|
+
// T112 — secondo asse di stato della vista `archiviabili`: quali fra le
|
|
160
|
+
// eliminabili hanno una folder che `git rm` non svuoterebbe. Si misura solo
|
|
161
|
+
// su quelle, non su tutta la lista: fuori da quella vista `CANC` agisce su
|
|
162
|
+
// una task sola e il dato lo ricalcola al momento.
|
|
163
|
+
const archivableSig = useMemo(() => [...archivable].sort().join(','), [archivable]);
|
|
164
|
+
const dirtyFolders = useDirtyFolders(archivableSig, tasksDir, cwd);
|
|
165
|
+
// T100 — le task effettivamente a schermo: la vista principale coincide con
|
|
166
|
+
// `viewTasks` (nessun ricalcolo sul cammino di default), le altre due passano
|
|
167
|
+
// dal predicato del catalogo. I CONTATORI restano misurati sulla vista di
|
|
168
|
+
// default, o navigare cambierebbe i numeri che si sta navigando.
|
|
169
|
+
const taskCounts = {
|
|
170
|
+
filtered: viewTasks.length,
|
|
171
|
+
total: tasks.length,
|
|
172
|
+
hidden: hiddenTasks,
|
|
173
|
+
archivable: archivable.size,
|
|
174
|
+
};
|
|
175
|
+
const paneTasks = useMemo(() => taskViewId === 'tasks' ? viewTasks : selectTasks(tasks, taskViewId, { view, archivable }), [taskViewId, viewTasks, tasks, view, archivable]);
|
|
176
|
+
const isSpot = sel === SPOT;
|
|
177
|
+
const isAll = sel === ALL;
|
|
178
|
+
// T112 — quando `CANC` pota in BLOCCO invece della sola task selezionata.
|
|
179
|
+
//
|
|
180
|
+
// La SELEZIONE batte la vista, non il contrario: con una riga task sotto il
|
|
181
|
+
// caret il tasto tocca quella task e basta, in ogni vista. Il bulk vive solo
|
|
182
|
+
// sulle righe meta della vista `archiviabili`, cioè richiede di essere usciti
|
|
183
|
+
// da ogni riga task — così l'azione di massa non è mai a un tasto di distanza
|
|
184
|
+
// da quella singola, e non si preme guardando una task evidenziata credendo
|
|
185
|
+
// che il bersaglio sia lei.
|
|
186
|
+
//
|
|
187
|
+
// Derivato una volta e letto sia dal ramo di apertura sia dalla legenda: due
|
|
188
|
+
// condizioni scritte due volte direbbero due cose diverse sullo stesso tasto
|
|
189
|
+
// alla prima modifica.
|
|
190
|
+
const purgeBulk = taskViewId === 'archivable' && (isAll || isSpot);
|
|
191
|
+
// Unica fonte della selezione: si legge SEMPRE dalla vista, mai dall'array
|
|
192
|
+
// grezzo — è l'invariante che tiene allineati dettaglio mostrato e spawn.
|
|
193
|
+
const selTask = typeof sel === 'string' ? paneTasks.find((t) => t.id === sel) ?? null : null;
|
|
194
|
+
const selectedTaskId = selTask?.id ?? null;
|
|
195
|
+
const selIndex = selTask ? paneTasks.indexOf(selTask) + META_ROWS : isAll ? ROW_ALL : ROW_SPOT;
|
|
196
|
+
const detail = useTaskDetail(tasksDir, selectedTaskId ?? undefined);
|
|
197
|
+
// Il parent delle conversazioni: l'asse che sceglie il pane task, ortogonale
|
|
198
|
+
// alla vista che sceglie l'header (D2 create).
|
|
199
|
+
const parentLabel = isAll ? 'tutte' : isSpot ? 'spot' : selectedTaskId ?? '—';
|
|
200
|
+
const { childCount, taskLive, spotCount } = rollupChildren(sessions, bindings, live);
|
|
201
|
+
const taskRowData = { childCount, live: taskLive, dirty: dirtyFolders };
|
|
202
|
+
// T118 — colonne fisse della lista task, misurate su `paneTasks` (la vista
|
|
203
|
+
// attiva INTERA) e non sulla finestra visibile: gemelle di `sessionCols` e
|
|
204
|
+
// per la stessa ragione, che una larghezza derivata dallo schermo si muove a
|
|
205
|
+
// ogni scroll.
|
|
206
|
+
const taskCols = taskColumns(paneTasks, taskRowData);
|
|
207
|
+
// Memoizzato così `sessionRows` resta stabile fra render che non cambiano gli
|
|
208
|
+
// input: l'effect di validità della selezione non rigira a vuoto.
|
|
209
|
+
const childSessions = useMemo(() => childSessionsOf(sessions, bindings, selectedTaskId, isAll), [sessions, bindings, selectedTaskId, isAll]);
|
|
210
|
+
// T133 — lista unica: le figlie del parent, `ts desc`, cap su tutte. Le
|
|
211
|
+
// pinnate del progetto escono a parte (`pinnedRows`), per la sola vista `📌`.
|
|
212
|
+
// Core PURO in session-list.ts (testabile senza Ink).
|
|
213
|
+
const assembled = useMemo(() => assembleSessionList(childSessions, sessions, pinned, isAll ? MAX_SESSIONS_ALL : MAX_SESSIONS), [childSessions, sessions, pinned, isAll]);
|
|
214
|
+
// T62 — contato sulla lista INTERA (stessa ragione delle larghezze di colonna
|
|
215
|
+
// qui sotto): derivarlo dalla finestra visibile lo farebbe cambiare a ogni
|
|
216
|
+
// scroll, cioè un contatore che conta lo schermo invece della lista.
|
|
217
|
+
// T100 — «intera» ora vuol dire la lista della vista di DEFAULT (`assembled`),
|
|
218
|
+
// non quella a schermo: il contatore di una voce del catalogo non può
|
|
219
|
+
// dipendere da quale voce è selezionata, o navigare muoverebbe i numeri.
|
|
220
|
+
const liveCount = useMemo(() => assembled.rows.filter((r) => live.has(r.sessionId)).length, [assembled, live]);
|
|
221
|
+
const sessionCounts = {
|
|
222
|
+
total: assembled.contextTotal,
|
|
223
|
+
live: liveCount,
|
|
224
|
+
pinned: assembled.pinnedRows.length,
|
|
225
|
+
older: assembled.overflowRows.length,
|
|
226
|
+
};
|
|
227
|
+
// T100 — le righe a schermo sono quelle della vista attiva.
|
|
228
|
+
const sessionRows = useMemo(() => selectSessionRows(sessionViewId, { assembled, isLive: (id) => live.has(id) }), [sessionViewId, assembled, live]);
|
|
229
|
+
const selSessionObj = selectedSession(sessionRows, selSessionId);
|
|
230
|
+
const sessionCols = useMemo(() => sessionColumns(sessionRows, bindings, isAll), [sessionRows, bindings, isAll]);
|
|
231
|
+
// T39 — selezione stabile sotto trasformazione. Se la task selezionata esce
|
|
232
|
+
// dalla vista (filtro appena attivato, oppure sparita da tasks.md), si cade
|
|
233
|
+
// sulla prima visibile — fallback deterministico, mai una posizione a caso.
|
|
234
|
+
useEffect(() => {
|
|
235
|
+
if (typeof sel === 'string' && !paneTasks.some((t) => t.id === sel)) {
|
|
236
|
+
setSel(paneTasks[0]?.id ?? ALL);
|
|
237
|
+
}
|
|
238
|
+
}, [paneTasks, sel]);
|
|
239
|
+
// T50 — la selezione (id) resta valida sotto la vista a due gruppi: se l'id
|
|
240
|
+
// non è più una riga selezionabile (cambio parent, lista mutata, pin rimosso,
|
|
241
|
+
// sessione sparita) cade sulla prima riga — fallback deterministico, mai una
|
|
242
|
+
// posizione a caso. Sostituisce il reset-a-0 e il clamp index-based.
|
|
243
|
+
useEffect(() => {
|
|
244
|
+
if (rowIndexOf(sessionRows, selSessionId) < 0) {
|
|
245
|
+
setSelSessionId(firstSelectableId(sessionRows));
|
|
246
|
+
}
|
|
247
|
+
}, [sessionRows, selSessionId]);
|
|
248
|
+
/** Selezione per INDICE nella vista, riconvertita subito in sentinella o id.
|
|
249
|
+
* T21 — la chiama anche il click su una riga del pane task. */
|
|
250
|
+
function selectTaskRow(index) {
|
|
251
|
+
if (index === ROW_ALL)
|
|
252
|
+
setSel(ALL);
|
|
253
|
+
else if (index === ROW_SPOT)
|
|
254
|
+
setSel(SPOT);
|
|
255
|
+
else
|
|
256
|
+
setSel(paneTasks[index - META_ROWS]?.id ?? SPOT);
|
|
257
|
+
}
|
|
258
|
+
// Sposta la selezione di `delta` righe nella VISTA (0 = tutte, 1 = spot,
|
|
259
|
+
// 2..N+1 = task visibili) e la riconverte subito in sentinella o id: l'indice
|
|
260
|
+
// non sopravvive a un cambio di filtro, l'id sì.
|
|
261
|
+
function moveTaskSel(delta) {
|
|
262
|
+
selectTaskRow(Math.max(0, Math.min(paneTasks.length + META_ROWS - 1, selIndex + delta)));
|
|
263
|
+
}
|
|
264
|
+
// T21 — per VISTA e non per passo: il click sull'header nomina una voce, e
|
|
265
|
+
// `tab` la raggiunge ciclando. Sulla vista già attiva non si fa niente — un
|
|
266
|
+
// click che azzerasse la selezione alla riga in alto sarebbe un effetto che
|
|
267
|
+
// la tastiera non ha mai avuto, perché `tab` non può restare ferma.
|
|
268
|
+
function selectTaskView(next) {
|
|
269
|
+
if (next === taskViewId)
|
|
270
|
+
return;
|
|
271
|
+
setTaskViewId(next);
|
|
272
|
+
setSel(ALL);
|
|
273
|
+
setNote(`vista task: ${taskView(next).label(taskCounts)}`);
|
|
274
|
+
}
|
|
275
|
+
function selectSessionView(next) {
|
|
276
|
+
if (next === sessionViewId)
|
|
277
|
+
return;
|
|
278
|
+
setSessionViewId(next);
|
|
279
|
+
setSelSessionId(null);
|
|
280
|
+
setNote(`vista sessioni: ${sessionView(next).label(sessionCounts, parentLabel)}`);
|
|
281
|
+
}
|
|
282
|
+
// T100 — `tab` naviga il catalogo viste del pane in focus. Il reset della
|
|
283
|
+
// selezione è la regola letterale «prima riga in alto», senza eccezioni: sul
|
|
284
|
+
// pane task è `ROW_ALL` (D2 preflight — le righe meta non si saltano, e il
|
|
285
|
+
// parent delle sessioni che torna a `tutte` è un effetto accettato); sul pane
|
|
286
|
+
// sessioni basta invalidare l'id, e l'effect di validità atterra sulla prima
|
|
287
|
+
// riga selezionabile della vista nuova.
|
|
288
|
+
function cycleView(delta) {
|
|
289
|
+
if (focus === 'tasks')
|
|
290
|
+
selectTaskView(cycleTaskView(taskViewId, delta));
|
|
291
|
+
else
|
|
292
|
+
selectSessionView(cycleSessionView(sessionViewId, delta));
|
|
293
|
+
}
|
|
294
|
+
return {
|
|
295
|
+
// fonti
|
|
296
|
+
tasks,
|
|
297
|
+
loadError,
|
|
298
|
+
sessions,
|
|
299
|
+
bindings,
|
|
300
|
+
forkOf,
|
|
301
|
+
pinned,
|
|
302
|
+
sessionNotes,
|
|
303
|
+
live,
|
|
304
|
+
reloadSessions,
|
|
305
|
+
detail,
|
|
306
|
+
// progetto
|
|
307
|
+
launch,
|
|
308
|
+
identity,
|
|
309
|
+
projectCore,
|
|
310
|
+
projectName,
|
|
311
|
+
// stato di navigazione
|
|
312
|
+
focus,
|
|
313
|
+
setFocus,
|
|
314
|
+
sel,
|
|
315
|
+
selSessionId,
|
|
316
|
+
setSelSessionId,
|
|
317
|
+
view,
|
|
318
|
+
setView,
|
|
319
|
+
taskViewId,
|
|
320
|
+
sessionViewId,
|
|
321
|
+
// derivazioni del pane task
|
|
322
|
+
viewTasks,
|
|
323
|
+
hiddenTasks,
|
|
324
|
+
taskCounts,
|
|
325
|
+
paneTasks,
|
|
326
|
+
isSpot,
|
|
327
|
+
isAll,
|
|
328
|
+
purgeBulk,
|
|
329
|
+
selTask,
|
|
330
|
+
selectedTaskId,
|
|
331
|
+
selIndex,
|
|
332
|
+
parentLabel,
|
|
333
|
+
taskRowData,
|
|
334
|
+
taskCols,
|
|
335
|
+
spotCount,
|
|
336
|
+
// derivazioni del pane sessioni
|
|
337
|
+
sessionRows,
|
|
338
|
+
sessionCounts,
|
|
339
|
+
selSessionObj,
|
|
340
|
+
sessionCols,
|
|
341
|
+
// mutatori di navigazione
|
|
342
|
+
selectTaskRow,
|
|
343
|
+
moveTaskSel,
|
|
344
|
+
selectTaskView,
|
|
345
|
+
selectSessionView,
|
|
346
|
+
cycleView,
|
|
347
|
+
};
|
|
348
|
+
}
|
package/dist/frame.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// La GEOMETRIA di un frame del deck: cosa entra a schermo, dove, e quali righe
|
|
2
|
+
// sono cliccabili.
|
|
3
|
+
//
|
|
4
|
+
// Modulo PURO — nessun React, nessuno stato, nessun effetto. Prende le misure
|
|
5
|
+
// del terminale e le liste intere, restituisce il budget d'altezza, le finestre
|
|
6
|
+
// di rendering e la mappa dell'hit-test. Ne discende che è testabile a unità,
|
|
7
|
+
// che è la ragione principale per cui è uscito da `cli.tsx`: qui vivono due
|
|
8
|
+
// invarianti che prima erano affidate a un commento — la legenda che non deve
|
|
9
|
+
// mentire sul bersaglio di `CANC`, e le righe cliccabili che devono essere
|
|
10
|
+
// esattamente quelle disegnate.
|
|
11
|
+
//
|
|
12
|
+
// T131 — l'ordine di calcolo è vincolante rispetto al cablaggio dell'input.
|
|
13
|
+
// `frameGeometry` va invocata PRIMA di `useDeckInput`, perché l'hook del mouse
|
|
14
|
+
// riceve `listGeometry` come valore e non più attraverso una closure che la
|
|
15
|
+
// leggeva dichiarata seicento righe più in basso. Il costo è calcolare il
|
|
16
|
+
// budget anche quando il render esce presto su una schermata sostitutiva:
|
|
17
|
+
// aritmetica su array già in memoria, e `layoutBudget` accetta ogni `Mode`.
|
|
18
|
+
import { headerItems, sessionHeaderParts, taskHeaderParts } from './pane-header.js';
|
|
19
|
+
import { FRAME_TEXT_COL, inlineRegions, LAUNCH_ROW, PANE_TEXT_PAD, paneSpans, rowRegions, } from './mouse.js';
|
|
20
|
+
import { cellWidth, launchLegend, LAUNCH_SEP } from './config.js';
|
|
21
|
+
import { layoutBudget, windowRange } from './viewport.js';
|
|
22
|
+
import { sanitize, termWidth } from './width.js';
|
|
23
|
+
import { META_ROWS } from './model.js';
|
|
24
|
+
import { rowIndexOf } from './session-list.js';
|
|
25
|
+
import { VERSION } from './version.js';
|
|
26
|
+
// Le due surface built-in del cappello, in testa alla riga launch. Non stanno
|
|
27
|
+
// fra i tasti perché hanno la stessa natura delle voci `launch` — fire-once,
|
|
28
|
+
// cwd = project root, nessuno stato — e la differenza è solo che sono
|
|
29
|
+
// universali (nessun progetto le dichiara) invece che custom. Emoji del menu
|
|
30
|
+
// compass: 🤖 = nuova sessione claude. Per il terminale compass usa 🖥️, che nel
|
|
31
|
+
// frame Ink NON passa — `sanitize` lo sostituisce (VTE lo disegna largo 1,
|
|
32
|
+
// string-width dice 2: discordante, invariante ① di width.ts) e resterebbe un
|
|
33
|
+
// `·` muto. 💻 è il gemello concorde; il `sanitize` qui rende il vincolo
|
|
34
|
+
// automatico invece che da ricordare.
|
|
35
|
+
//
|
|
36
|
+
// T21 — `key` è il tasto che la superficie rappresenta: un click su di essa
|
|
37
|
+
// entra nell'handler di tastiera con quel tasto, invece di chiamare l'azione
|
|
38
|
+
// per conto proprio. È ciò che tiene click e tasto per costruzione allineati.
|
|
39
|
+
export const SURFACE_SEGMENTS = [
|
|
40
|
+
{ key: 't', text: sanitize('t 💻') },
|
|
41
|
+
{ key: 'c', text: sanitize('c 🤖') },
|
|
42
|
+
];
|
|
43
|
+
/**
|
|
44
|
+
* La legenda della modalità normale. Elenca SOLO i tasti che fanno qualcosa qui
|
|
45
|
+
* e ora: le voci contestuali compaiono quando il pane a fuoco le rende possibili
|
|
46
|
+
* e altrimenti spariscono, invece di annunciarsi inerti con un `—`.
|
|
47
|
+
*
|
|
48
|
+
* Fuori: la navigazione (`↑↓` `←→`), universale in qualunque TUI, e
|
|
49
|
+
* l'indicatore `focus:` — il pane a fuoco si vede già dall'evidenziazione, e
|
|
50
|
+
* ridirlo a parole costava colonne su una riga che tronca in silenzio.
|
|
51
|
+
* Nessuna voce di uscita: non esiste più un tasto che chiuda il deck.
|
|
52
|
+
*/
|
|
53
|
+
export function deckLegend(state) {
|
|
54
|
+
const canSpawn = state.focus === 'tasks' && state.hasTask;
|
|
55
|
+
const canResume = state.focus === 'sessions' && state.hasSession;
|
|
56
|
+
// T50 — il pin agisce su qualunque riga selezionata (anche stale, per
|
|
57
|
+
// spinnarla); basta il focus sul pane e una selezione.
|
|
58
|
+
const canPin = state.focus === 'sessions' && state.hasSessionId;
|
|
59
|
+
return sanitize([
|
|
60
|
+
...(canSpawn ? ['⏎ detail', '^K/^P/^R spawn'] : canResume ? ['⏎ resume'] : []),
|
|
61
|
+
// T112 — la voce nomina il BERSAGLIO, che cambia di taglia senza che
|
|
62
|
+
// cambi il tasto. Legge `purgeBulk`, la stessa condizione del ramo di
|
|
63
|
+
// apertura: una legenda che annunciasse «tutte» dove il tasto ne pota una
|
|
64
|
+
// sola sarebbe peggio di nessuna legenda.
|
|
65
|
+
...(state.focus === 'tasks' ? [state.purgeBulk ? 'CANC elimina tutte' : 'CANC elimina'] : []),
|
|
66
|
+
...(canResume ? ['f fork'] : []),
|
|
67
|
+
// T117 · D3 — «titolo» anche qui: è lo stesso valore che il detail chiede
|
|
68
|
+
// allo spawn, e due nomi per una cosa sola li paga chi legge le due
|
|
69
|
+
// schermate. Rename di sola ETICHETTA: il dato resta `note` nel sidecar e
|
|
70
|
+
// `--title-note` in deck-run, dove rinominarlo sarebbe un breaking.
|
|
71
|
+
...(canPin ? ['p pin', 'N titolo', 'A assegna'] : []),
|
|
72
|
+
// T121 — le due voci nominano il GESTO e non l'oggetto: «status» da solo
|
|
73
|
+
// sarebbe indistinguibile dal recap della task su `^K`, che è un'altra
|
|
74
|
+
// cosa e sta a due voci di distanza.
|
|
75
|
+
'^G genera status',
|
|
76
|
+
'^O apri status',
|
|
77
|
+
'^F cerca',
|
|
78
|
+
'C nuova',
|
|
79
|
+
'E edit',
|
|
80
|
+
'S sort',
|
|
81
|
+
'F filtri',
|
|
82
|
+
'w salva',
|
|
83
|
+
].join(' · '));
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* T21 — la riga launch come DATO, non come stringa: gli stessi segmenti
|
|
87
|
+
* compongono il testo renderizzato e le colonne dell'hit-test. Derivare le
|
|
88
|
+
* seconde ri-splittando il primo sarebbe un conto parallelo, che diverge alla
|
|
89
|
+
* prima label che contenga il separatore.
|
|
90
|
+
*
|
|
91
|
+
* Le celle delle surface (più il ` · ` che le separa dalle voci) sono già spese
|
|
92
|
+
* sulla riga → vanno riservate, o le voci launch la sfonderebbero di quel tanto.
|
|
93
|
+
*/
|
|
94
|
+
export function launchRow(launch, columns) {
|
|
95
|
+
const surfaceLegend = SURFACE_SEGMENTS.map((s) => s.text).join(LAUNCH_SEP);
|
|
96
|
+
const legend = launchLegend(launch, columns, cellWidth(surfaceLegend) + cellWidth(LAUNCH_SEP));
|
|
97
|
+
const segments = [
|
|
98
|
+
...SURFACE_SEGMENTS,
|
|
99
|
+
...legend.taken.map((text, i) => ({ key: String(i + 1), text })),
|
|
100
|
+
];
|
|
101
|
+
return {
|
|
102
|
+
segments,
|
|
103
|
+
regions: rowRegions(segments, LAUNCH_SEP, FRAME_TEXT_COL),
|
|
104
|
+
overflow: legend.overflow,
|
|
105
|
+
unreachable: legend.unreachable,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Il budget d'altezza e le finestre di rendering.
|
|
110
|
+
*
|
|
111
|
+
* Il frame deve restare sotto `rows`, sempre: oltre quella soglia Ink smette di
|
|
112
|
+
* aggiornare per differenza e pulisce lo schermo a ogni redraw, che su Ptyxis
|
|
113
|
+
* significa un frame intero versato nello scrollback per ogni tick del poll.
|
|
114
|
+
* Tutto ciò che varia in altezza (le due liste e la descrizione del dettaglio)
|
|
115
|
+
* riceve qui la propria capienza.
|
|
116
|
+
*
|
|
117
|
+
* Le liste "logiche" restano intere: navigazione, selezione e spawn continuano
|
|
118
|
+
* a ragionare su quelle, la finestra è solo ciò che finisce a schermo.
|
|
119
|
+
*/
|
|
120
|
+
export function frameGeometry(input) {
|
|
121
|
+
// Le surface built-in `t`/`c` la rendono sempre presente in modalità normale:
|
|
122
|
+
// non dipende da quante voci `launch` il progetto dichiara.
|
|
123
|
+
const launchLine = input.mode === 'normal';
|
|
124
|
+
const budget = layoutBudget({
|
|
125
|
+
rows: input.rows,
|
|
126
|
+
mode: input.mode,
|
|
127
|
+
launchLine,
|
|
128
|
+
noteLine: input.hasNote,
|
|
129
|
+
preview: input.previewKind,
|
|
130
|
+
detailMetaLines: input.detailMetaLines,
|
|
131
|
+
// Riservo righe di anteprima solo per i blocchi che davvero renderizzano: il
|
|
132
|
+
// primo prompt aggiunge info solo con un titolo custom (senza, titolo ===
|
|
133
|
+
// primo prompt); l'ultima risposta solo se il modello ha già risposto.
|
|
134
|
+
sessionHasFirstPreview: input.sessionHasFirstPreview,
|
|
135
|
+
sessionHasLastPreview: input.sessionHasLastPreview,
|
|
136
|
+
});
|
|
137
|
+
const taskWin = windowRange(input.paneTasks.length, input.selIndex - META_ROWS, budget.taskRows);
|
|
138
|
+
const windowTasks = input.paneTasks.slice(taskWin.start, taskWin.end);
|
|
139
|
+
const selRowIndex = rowIndexOf(input.sessionRows, input.selSessionId);
|
|
140
|
+
const sessionWin = windowRange(input.sessionRows.length, selRowIndex, budget.sessionRows);
|
|
141
|
+
const windowRows = input.sessionRows.slice(sessionWin.start, sessionWin.end);
|
|
142
|
+
// T21 — geometria delle liste per l'hit-test del click, dalla STESSA
|
|
143
|
+
// aritmetica che disegna i pane: le parti degli header escono dal modulo che
|
|
144
|
+
// `ui/panes.tsx` consuma per renderle, le righe cliccabili sono le finestre
|
|
145
|
+
// appena calcolate.
|
|
146
|
+
const spans = paneSpans(input.columns);
|
|
147
|
+
const listGeometry = budget.compact
|
|
148
|
+
? null
|
|
149
|
+
: {
|
|
150
|
+
columns: input.columns,
|
|
151
|
+
taskHeader: inlineRegions(headerItems(taskHeaderParts(input.taskCounts, input.taskViewId, taskWin.start, input.paneTasks.length - taskWin.end, input.columns)), spans.tasks.start + PANE_TEXT_PAD),
|
|
152
|
+
sessionHeader: inlineRegions(headerItems(sessionHeaderParts(input.parentLabel, input.sessionCounts, input.sessionViewId, sessionWin.start, input.sessionRows.length - sessionWin.end, input.columns)), spans.sessions.start + PANE_TEXT_PAD),
|
|
153
|
+
// Con un errore di caricamento al posto delle task c'è la riga rossa:
|
|
154
|
+
// restano cliccabili le sole righe meta.
|
|
155
|
+
taskRows: META_ROWS + (input.hasLoadError ? 0 : windowTasks.length),
|
|
156
|
+
sessionRows: windowRows.length,
|
|
157
|
+
};
|
|
158
|
+
return {
|
|
159
|
+
budget,
|
|
160
|
+
taskWin,
|
|
161
|
+
windowTasks,
|
|
162
|
+
sessionWin,
|
|
163
|
+
windowRows,
|
|
164
|
+
listGeometry,
|
|
165
|
+
// Dimensione del terminale in CELLE (colonne×righe, mai pixel — un processo
|
|
166
|
+
// dentro un terminale vede solo la griglia di caratteri) e versione. La
|
|
167
|
+
// parte sinistra della testata riceve un budget derivato da questa stringa e
|
|
168
|
+
// non da una lunghezza fissa: la risoluzione cambia a ogni resize, quindi
|
|
169
|
+
// anche la larghezza che occupa.
|
|
170
|
+
headerRight: `${input.columns}×${input.rows} · v${VERSION}`,
|
|
171
|
+
launchLine,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
/** Il budget della testata sinistra, per sottrazione dal segmento destro. */
|
|
175
|
+
export function headlineWidth(columns, headerRight) {
|
|
176
|
+
return Math.max(4, columns - 4 - termWidth(headerRight) - 1);
|
|
177
|
+
}
|
|
178
|
+
export { LAUNCH_ROW };
|
package/dist/inbox.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// T134 — la coda inbox come dato del deck.
|
|
2
|
+
//
|
|
3
|
+
// La misura non la fa il deck: la fa `doc-metrics.sh --inbox`, che per ogni file
|
|
4
|
+
// invoca `inbox.sh parse` e ne riporta natura e marcatori. È il contrario della
|
|
5
|
+
// scelta fatta per le task archiviabili (`archivable.ts`), dove il criterio —
|
|
6
|
+
// una data meno un'altra — era abbastanza piccolo da replicarlo: qui si
|
|
7
|
+
// replicherebbe la grammatica intera del marker inbox, e una divergenza fra le
|
|
8
|
+
// due copie non si vedrebbe finché non conta i file sbagliati.
|
|
9
|
+
//
|
|
10
|
+
// Lettore puro + spawn: nessun React, nessuna resa. È l'unico modo di provarlo
|
|
11
|
+
// senza pseudo-terminale.
|
|
12
|
+
import { execFile } from 'node:child_process';
|
|
13
|
+
import { promisify } from 'node:util';
|
|
14
|
+
import { basename } from 'node:path';
|
|
15
|
+
import { pluginScript } from './plugin-cache.js';
|
|
16
|
+
const execFileAsync = promisify(execFile);
|
|
17
|
+
export const INBOX_METRICS_SCRIPT = 'scripts/docs/doc-metrics.sh';
|
|
18
|
+
/** Le tre nature che una skill sa consumare, più il file che non si parsa. */
|
|
19
|
+
export const NATURE = ['nozioni', 'derivazione', 'sweep'];
|
|
20
|
+
/**
|
|
21
|
+
* `si`/`no` è la grafia dello script. Qualunque altra cosa (colonna vuota su un
|
|
22
|
+
* file malformato) è `false`: un marcatore che non si è potuto leggere non vale
|
|
23
|
+
* come presente.
|
|
24
|
+
*/
|
|
25
|
+
function flag(cell) {
|
|
26
|
+
return cell === 'si';
|
|
27
|
+
}
|
|
28
|
+
function num(cell) {
|
|
29
|
+
const n = Number(cell);
|
|
30
|
+
return Number.isFinite(n) ? n : 0;
|
|
31
|
+
}
|
|
32
|
+
function natura(cell) {
|
|
33
|
+
return NATURE.includes(cell) ? cell : 'malformato';
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Il TSV di `doc-metrics.sh --inbox --format tsv`, riga di intestazione
|
|
37
|
+
* compresa. Colonne, nell'ordine emesso dallo script:
|
|
38
|
+
*
|
|
39
|
+
* PATH · NATURA · INDEXED · DRAINABLE · BRANCH · NOZIONI · APERTE · CHAR ·
|
|
40
|
+
* CREATED · AGE_DAYS · CAPPELLO
|
|
41
|
+
*
|
|
42
|
+
* `AGE_DAYS` si scarta di proposito: è troncato al giorno, e la soglia della
|
|
43
|
+
* sirena è in ORE — con i giorni interi 47 e 49 ore sarebbero lo stesso numero.
|
|
44
|
+
* L'età la ricalcola `ageHours` da `CREATED`.
|
|
45
|
+
*/
|
|
46
|
+
export function parseInboxTsv(stdout) {
|
|
47
|
+
const out = [];
|
|
48
|
+
for (const line of stdout.split('\n')) {
|
|
49
|
+
if (!line.trim())
|
|
50
|
+
continue;
|
|
51
|
+
const c = line.split('\t');
|
|
52
|
+
if (c[0] === 'PATH' || c.length < 11)
|
|
53
|
+
continue;
|
|
54
|
+
out.push({
|
|
55
|
+
path: c[0],
|
|
56
|
+
basename: basename(c[0]),
|
|
57
|
+
natura: natura(c[1]),
|
|
58
|
+
indexed: flag(c[2]),
|
|
59
|
+
drainable: flag(c[3]),
|
|
60
|
+
branch: c[4] ?? '',
|
|
61
|
+
nozioni: num(c[5]),
|
|
62
|
+
aperte: num(c[6]),
|
|
63
|
+
chars: num(c[7]),
|
|
64
|
+
created: num(c[8]),
|
|
65
|
+
cappello: c[10] ?? '',
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
// Dal più vecchio in cima (D8): i più vecchi sono i più urgenti. Lo script
|
|
69
|
+
// emette già in quest'ordine — riordinare qui costa nulla e toglie di mezzo
|
|
70
|
+
// la dipendenza da una garanzia scritta altrove.
|
|
71
|
+
return out.sort((a, b) => a.created - b.created);
|
|
72
|
+
}
|
|
73
|
+
/** Ore intere trascorse da `created` (epoch in secondi). */
|
|
74
|
+
export function ageHours(created, now) {
|
|
75
|
+
return Math.floor((now - created * 1000) / 3_600_000);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Il file promette lavoro che una skill può prendere DA SOLA, senza che nessuno
|
|
79
|
+
* lo nomini: `drainable` e nessun branch che lo congeli.
|
|
80
|
+
*
|
|
81
|
+
* Non è un criterio di ammissibilità dell'azione, ed è la distinzione che tiene
|
|
82
|
+
* in piedi D5: le tre skill dichiarano tutte che un file NOMINATO si esegue
|
|
83
|
+
* anche senza `drainable` — nominarlo è già la decisione che il token
|
|
84
|
+
* dichiarerebbe. Replicare qui quel criterio come guardia impedirebbe ciò che
|
|
85
|
+
* l'utente ha appena chiesto. Serve solo a decidere cosa entra nei contatori.
|
|
86
|
+
*/
|
|
87
|
+
export function isQueued(f) {
|
|
88
|
+
return f.drainable && !f.branch && f.natura !== 'malformato';
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Un contatore per natura, sui soli file in coda. Un `malformato` non ha natura
|
|
92
|
+
* e non entra in nessuno dei tre — ma resta in lista, perché nasconderlo
|
|
93
|
+
* produrrebbe un contatore che mente al ribasso proprio quando qualcosa si è
|
|
94
|
+
* rotto.
|
|
95
|
+
*/
|
|
96
|
+
export function countByNatura(files) {
|
|
97
|
+
const counts = { nozioni: 0, derivazione: 0, sweep: 0 };
|
|
98
|
+
for (const f of files)
|
|
99
|
+
if (isQueued(f))
|
|
100
|
+
counts[f.natura]++;
|
|
101
|
+
return counts;
|
|
102
|
+
}
|
|
103
|
+
export function totalQueued(counts) {
|
|
104
|
+
return counts.nozioni + counts.derivazione + counts.sweep;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* D10 — 48 ore. Un campionamento a 6 ore sbaglia al massimo di un ottavo, cioè
|
|
108
|
+
* entro il rumore di una soglia scelta a mano.
|
|
109
|
+
*/
|
|
110
|
+
export const DEFAULT_INBOX_STALE_HOURS = 48;
|
|
111
|
+
/** Quanti file in coda hanno passato la soglia: è il numero accanto alla sirena. */
|
|
112
|
+
export function staleCount(files, hours, now) {
|
|
113
|
+
return files.filter((f) => isQueued(f) && ageHours(f.created, now) >= hours).length;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Invoca la misura e ne parsa l'output.
|
|
117
|
+
*
|
|
118
|
+
* I modi di non avere il dato — plugin non installato su questa macchina,
|
|
119
|
+
* script che esce male — collassano in un solo `ok: false` (D2): chi vede
|
|
120
|
+
* l'allerta chiede a Claude di indagare, e distinguerli a schermo non
|
|
121
|
+
* cambierebbe la prima mossa. Nessuno dei due è un throw: un contatore
|
|
122
|
+
* informativo non può rompere il deck.
|
|
123
|
+
*/
|
|
124
|
+
export async function scanInbox(projectRoot, docsRoot) {
|
|
125
|
+
const script = pluginScript(INBOX_METRICS_SCRIPT);
|
|
126
|
+
if (!script)
|
|
127
|
+
return { files: [], ok: false };
|
|
128
|
+
try {
|
|
129
|
+
const { stdout } = await execFileAsync(script, ['--docs-root', docsRoot, '--inbox', '--format', 'tsv'], { cwd: projectRoot, maxBuffer: 4 * 1024 * 1024 });
|
|
130
|
+
return { files: parseInboxTsv(stdout), ok: true };
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return { files: [], ok: false };
|
|
134
|
+
}
|
|
135
|
+
}
|