@lamemind/loom-deck 0.63.0 → 0.64.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/README.md CHANGED
@@ -74,7 +74,7 @@ Regola unica, senza eccezioni — pensata per reggere l'aggiunta di nuove azioni
74
74
  | `⏎` | azione primaria del pane | Tasks → apre il **detail** della task selezionata · Sessions → riprende (`claude --resume`) la sessione selezionata |
75
75
  | **MAIUSCOLA** | **apre un modale** | cattura tutti i tasti; `esc` annulla, non esce |
76
76
  | minuscola | azione immediata, one-shot | |
77
- | `CTRL`+lettera | idiomi universali, toggle dentro i modali di testo, varianti di un'azione | `^F` = find, come ovunque · `^K`/`^P`/`^R` = spawn con un altro prompt · `^G`/`^O` = genera/apri il project status |
77
+ | `CTRL`+lettera | idiomi universali, toggle dentro i modali di testo, varianti di un'azione | `^F` = find, come ovunque · `^K`/`^P`/`^R` = spawn con un altro prompt · `^G`/`^O` = genera/apri il project status · `^U` = update del gitlink |
78
78
  | `1`…`9` | voce `launch` n-esima del progetto | da `.claude/loom-works.json` |
79
79
  | `esc` | chiude l'overlay aperto | in modalità normale è inerte: **nessuna lettera chiude il deck** |
80
80
  | `^C` `^C` | chiude il deck | la prima pressione avverte in riga di stato, la seconda entro **5 secondi** esce; passata la finestra si riparte dall'avviso. Da dentro un modale il primo `^C` riporta alla lista — l'avviso vive nella riga di stato, che le schermate a pieno frame non disegnano |
@@ -96,6 +96,7 @@ Assegnazioni correnti:
96
96
  | modale | `CANC` | **elimina** — la task selezionata; su una riga meta della vista `archiviabili`, l'intero insieme |
97
97
  | modale | `^O` | apre il **project status** in cache (viewer fullscreen) |
98
98
  | immediata | `^G` | **genera** il project status (skill headless, dura minuti) |
99
+ | immediata | `^U` | **bumpa il gitlink** dei submodule idonei (solo su un progetto con `.gitmodules`) |
99
100
  | immediata | `^K` | spawna la task selezionata col prompt di **recap** |
100
101
  | immediata | `^P` | spawna la task selezionata sul **preflight** |
101
102
  | immediata | `^R` | spawna la task selezionata in **esecuzione** |
@@ -108,7 +109,7 @@ Assegnazioni correnti:
108
109
  Il footer è **due righe con due nature diverse**:
109
110
 
110
111
  ```
111
- ⏎ detail · ^K/^P/^R spawn · ^G genera status · ^O apri status · ^F cerca · C nuova · E edit · S sort · F filtri · w salva
112
+ ⏎ detail · ^K/^P/^R spawn · ^G genera status · ^O apri status · ^U bumpa gitlink · ^F cerca · C nuova · E edit · S sort · F filtri · w salva
112
113
  t 💻 · c 🤖 · 1 📝 codium · 2 ☕ idea
113
114
  ```
114
115
 
@@ -274,6 +275,38 @@ Il recap vive in un file markdown sotto `/tmp/loom-deck-status-<uid>/`, col nome
274
275
 
275
276
  A generazione finita arriva una notifica desktop (`notify-send`): la generazione dura minuti e nel frattempo si guarda altrove, e la notifica di GNOME persiste nel cassetto — cosa che un suono non farebbe. Senza `notify-send` installato non succede **niente**: nessuna riga, nessun messaggio. L'assenza di un binario è una proprietà stabile della macchina, non un evento, e segnalarla a ogni generazione sarebbe rumore ripetuto su una funzione accessoria mentre il deliverable vero è arrivato comunque.
276
277
 
278
+ ### `^U` — il gitlink dei submodule
279
+
280
+ Su un progetto **cappello** (un repo che aggrega altri repo come submodule) ogni membro è pinnato a un commit fisso. Quando il membro avanza — un commit nel deck, nel plugin — quel puntatore resta indietro finché qualcuno non lo bumpa, e il gesto vive nella memoria di chi committa. Il deck lo mette sotto gli occhi e lo rende un tasto.
281
+
282
+ L'indicatore sta in coda alla riga della legenda, accanto agli altri due:
283
+
284
+ ```
285
+ gitlink 2 wrap 77 [ Inbox 📄 3/1/2 ]
286
+ ```
287
+
288
+ | Corpo | Significato |
289
+ |---|---|
290
+ | _(assente)_ | il progetto non ha `.gitmodules`: nessun timer, nessuno spawn, nessuna colonna spesa |
291
+ | `missing` | prima misura non ancora tornata |
292
+ | `?` | plugin loom-works non installato su questa macchina, o script in errore |
293
+ | `ok` | ogni membro coincide col proprio gitlink |
294
+ | `2` | due membri col checkout diverso dal gitlink |
295
+ | `⚠` in coda | l'ultimo bump è fallito, **oppure** un membro richiede un gesto che il bump non fa |
296
+
297
+ Nel numero entrano solo i membri che un bump può sistemare. Un submodule non inizializzato o in conflitto di merge resta fuori e si dice col glifo: sommarlo darebbe un contatore che il tasto non porta mai a zero, e un contatore così smette di essere letto.
298
+
299
+ **`^U` bumpa**, senza conferma: il commit è locale e si annulla con un `git reset` che non tocca nessun remote. Il deck non pusha il cappello — quel gesto resta al flusso normale, perché un gitlink pushato per sbaglio è già nella copia di chiunque abbia pullato.
300
+
301
+ Il deck **non classifica da sé**: spawna `bump-gitlink.sh` del plugin (in `--dry-run` per la misura, nudo per il bump) e mostra il TSV che ne esce. Nessuna sessione Claude in mezzo — è un `execFile` con esito sincrono, come il contatore inbox e lo scan hard-wrap. Lo stesso script si lancia a mano da shell, ed è lì che vivono le due guardie che rendono il bump sicuro:
302
+
303
+ - **il verso** — `git submodule status` marca con `+` un checkout diverso dal gitlink **in entrambi i versi**, senza distinguerli. Un cappello pullato senza `git submodule update` ha il checkout più vecchio del gitlink e porta lo stesso `+`: bumparlo pinnerebbe il cappello a un commit precedente. Quel membro si salta, e la riga di stato nomina `git submodule update`.
304
+ - **il remote** — il cappello può pinnare solo commit già raggiungibili dal remote del membro. Un gitlink verso un commit locale produce un cappello che si clona ma non si ricostruisce, e il difetto è invisibile a chi lo introduce: nella sua copia quel commit esiste. Quel membro si salta, e la riga di stato nomina `git -C <membro> push`.
305
+
306
+ La riga di stato dopo il tasto riporta **un esito per membro**, non un totale: nel caso misto — uno pushato, uno no — un riassunto direbbe «1 bumpato» e lascerebbe fuori proprio il comando che sblocca l'altro.
307
+
308
+ La misura gira ogni 30 secondi e subito dopo un bump. Il `--dry-run` non contatta la rete: il `fetch` che precede la guardia sul remote gira solo nel bump, quindi il contatore può dire «bumpabile» di un commit che il bump poi salta perché sparito dal remote. La guardia sta sull'azione, e un contatore non è un'autorizzazione.
309
+
277
310
  ## Vista: filtri e ordinamenti
278
311
 
279
312
  La lista è una **vista** sulla `tasks.md`: si filtra e si ordina senza toccare il file.
package/dist/cli.js CHANGED
@@ -32,6 +32,7 @@ import { useAssignOverlay } from './overlays/assign.js';
32
32
  import { useProjectStatus } from './overlays/status.js';
33
33
  import { useInboxOverlay } from './overlays/inbox.js';
34
34
  import { useWrapOverlay } from './overlays/wrap.js';
35
+ import { useGitlink } from './overlays/gitlink.js';
35
36
  import { usePurgeOverlay } from './overlays/purge.js';
36
37
  import { useTextModals, useViewModals } from './overlays/modals.js';
37
38
  import { useTerminalSize } from './hooks.js';
@@ -110,6 +111,11 @@ function Deck({ cwd, tasksPath, tasksDir }) {
110
111
  setNote,
111
112
  onApply: actions.unwrapPath,
112
113
  });
114
+ // T155 — il gitlink dei submodule. Terzo sensore con lo stato in vista
115
+ // normale, dopo il project status e l'hard-wrap: qui però non c'è nessuna
116
+ // schermata da aprire — l'unica superficie è l'indicatore, e l'unica azione
117
+ // committa.
118
+ const gitlink = useGitlink({ cwd, setNote });
113
119
  const purge = usePurgeOverlay({
114
120
  setMode,
115
121
  setNote,
@@ -139,6 +145,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
139
145
  status,
140
146
  inbox,
141
147
  wrap,
148
+ gitlink,
142
149
  purge,
143
150
  view: viewModals,
144
151
  text: textModals,
@@ -172,6 +179,17 @@ function Deck({ cwd, tasksPath, tasksDir }) {
172
179
  // PRIMA della legenda perché è la loro larghezza a decidere quanto ne resta:
173
180
  // hanno la precedenza sul budget (D5 preflight).
174
181
  const indicators = indicatorRow({
182
+ // T155 — `null` chiude il gate: su un progetto senza `.gitmodules` il
183
+ // segmento non esiste, e la riga legenda si riprende quelle colonne.
184
+ gitlink: gitlink.enabled
185
+ ? {
186
+ count: gitlink.count,
187
+ attention: gitlink.attention,
188
+ scanned: gitlink.scanned,
189
+ ok: gitlink.ok,
190
+ failed: gitlink.failed,
191
+ }
192
+ : null,
175
193
  inbox: {
176
194
  counts: model.inboxCounts,
177
195
  stale: model.inboxStale,
@@ -251,6 +269,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
251
269
  purgeBulk: model.purgeBulk,
252
270
  inboxPane: model.rightPane === 'inbox',
253
271
  hasInbox: model.selInbox !== null,
272
+ gitlink: gitlink.enabled,
254
273
  }), indicators: indicators, columns: columns }), frame.launchLine ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [launch.segments.map((s) => s.text).join(LAUNCH_SEP), launch.overflow > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 +", launch.overflow, " fuori riga"] })) : null, launch.unreachable > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 ", launch.unreachable, " oltre la 9\u00AA (non raggiungibili)"] })) : null, launch.filler, BARE_LABEL.text, ' ', bareButtons.nodes] })) : null, mode === 'create' ? _jsx(TextBox, { glyph: "C", value: textModals.draft }) : null, mode === 'note' ? _jsx(TextBox, { glyph: "\u270E", value: textModals.noteDraft }) : null, mode === 'sort' ? _jsx(SortModal, { sort: model.view.sort }) : null, mode === 'filter' ? (_jsx(FilterModal, { view: model.view, cursor: viewModals.filterCursor })) : null, mode === 'edit' && textModals.edit && textModals.editTask ? (_jsx(EditModal, { id: textModals.editTask.id, draft: textModals.edit, row: textModals.editCursor.row, caret: textModals.editCursor.caret, columns: columns })) : null, mode === 'purge' && purge.draft ? (_jsx(PurgeModal, { draft: purge.draft, columns: columns })) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: frame.windowTasks, counts: model.taskCounts, activeView: model.taskViewId, paneCount: model.paneTasks.length, view: model.view, selected: model.selIndex, spotCount: model.spotCount, allCount: model.sessions.length, idW: model.taskCols.id, tailW: model.taskCols.tail, focused: model.focus === 'tasks', loadError: model.loadError, windowStart: frame.taskWin.start, above: frame.taskWin.start, below: model.paneTasks.length - frame.taskWin.end, columns: columns, data: model.taskRowData, blockMark: model.blockMark }), model.rightPane === 'inbox' ? (_jsx(InboxPane, { files: frame.windowInbox, counts: model.inboxCounts, activeView: model.inboxViewId, paneCount: model.inboxFiles.length, selectedPath: model.selInboxPath, focused: model.focus === 'inbox', above: frame.inboxWin.start, below: model.inboxFiles.length - frame.inboxWin.end, columns: columns, ok: model.inboxOk, scanned: model.inboxScanned })) : (_jsx(SessionsPane, { parentLabel: model.parentLabel, isSpot: model.isSpot, isAll: model.isAll, bindings: model.bindings, taskW: model.sessionCols.task, ageW: model.sessionCols.age, rows: frame.windowRows, counts: model.sessionCounts, activeView: model.sessionViewId, paneCount: model.sessionRows.length, selectedId: model.selSessionId ?? undefined, focused: model.focus === 'sessions', above: frame.sessionWin.start, below: model.sessionRows.length - frame.sessionWin.end, columns: columns, forkOf: model.forkOf, sessionNotes: model.sessionNotes, projectCore: model.projectCore, live: model.live }))] }), frame.budget.preview && previewKind === 'task' && model.detail ? (_jsx(PreviewPane, { kind: "task", detail: model.detail, maxLines: frame.budget.detailLines, columns: columns })) : frame.budget.preview && previewKind === 'inbox' && model.selInbox ? (_jsx(PreviewPane, { kind: "inbox", file: model.selInbox, columns: columns })) : frame.budget.preview && previewKind === 'session' && model.selSessionObj ? (_jsx(PreviewPane, { kind: "session", s: model.selSessionObj, firstLines: frame.budget.sessionFirstLines, lastLines: frame.budget.sessionLastLines, columns: columns, origin: model.forkOf.get(model.selSessionObj.sessionId) ?? null, note: model.sessionNotes.get(model.selSessionObj.sessionId) ?? '', live: model.live.get(model.selSessionObj.sessionId) ?? null, resumeModel: model.resumeModel })) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
255
274
  }
256
275
  const cwd = process.cwd();
package/dist/frame.js CHANGED
@@ -106,6 +106,9 @@ export function deckLegend(state) {
106
106
  // cosa e sta a due voci di distanza.
107
107
  '^G genera status',
108
108
  '^O apri status',
109
+ // T155 — la voce nomina il GESTO, come le due sopra: «gitlink» da solo
110
+ // direbbe l'oggetto e non che il tasto lo allinea con un commit.
111
+ ...(state.gitlink ? ['^U bumpa gitlink'] : []),
109
112
  // T134 — la voce nomina il pane che il tasto MONTA, non quello montato:
110
113
  // `^B` scambia i due, e annunciare quello che si sta già guardando
111
114
  // direbbe il contrario di ciò che il tasto fa. Stessa regola di `CANC
@@ -232,11 +235,47 @@ export function wrapIndicator(state) {
232
235
  : String(state.count);
233
236
  return sanitize(`wrap ${body}${state.ok ? '' : ` ${WARN}`}`);
234
237
  }
238
+ /**
239
+ * T155 — l'indicatore del gitlink: `gitlink 2`.
240
+ *
241
+ * Conta i membri il cui checkout NON coincide col gitlink registrato, in
242
+ * entrambi i versi (`disalignedCount` in `gitlink.ts`). Fuori dal numero restano
243
+ * i membri che nessun bump sistema — non inizializzati, in conflitto — perché un
244
+ * contatore che il tasto non può portare a zero smette di essere letto: è la
245
+ * stessa regola per cui `misto` non entra nel contatore dell'hard-wrap.
246
+ *
247
+ * Quattro corpi distinti, come per gli altri due indicatori: `missing` finché la
248
+ * prima misura non è tornata, `?` quando lo script non è raggiungibile (plugin
249
+ * non installato su questa macchina, o uscito male), il numero quando c'è
250
+ * qualcosa da bumpare, `ok` quando tutti i membri coincidono.
251
+ *
252
+ * Il glifo di allerta si AGGIUNGE al corpo invece di sostituirlo, e copre i due
253
+ * fatti che il numero non dice: l'ultimo bump è fallito, oppure un membro
254
+ * richiede un gesto che il bump non fa. Entrambi restano veri accanto al
255
+ * conteggio, e chi lo vede preme il tasto per leggere la riga di stato, che
256
+ * nomina i membri.
257
+ */
258
+ export function gitlinkIndicator(state) {
259
+ const body = !state.scanned
260
+ ? STATUS_MISSING
261
+ : !state.ok
262
+ ? '?'
263
+ : state.count > 0
264
+ ? String(state.count)
265
+ : 'ok';
266
+ const warn = state.failed || state.attention > 0 ? ` ${WARN}` : '';
267
+ return sanitize(`gitlink ${body}${warn}`);
268
+ }
235
269
  export function indicatorRow(state, columns) {
236
- // Ordine di lettura: l'indicatore, poi il bottone. Il bottone sta all'estremo
237
- // destro perché è l'unico dei due che MONTA qualcosa restando in vista
238
- // normale — la lista del wrap è una schermata che si apre e si chiude.
270
+ // Ordine di lettura: gli indicatori, poi il bottone. Il bottone sta
271
+ // all'estremo destro perché è l'unico dei tre che MONTA qualcosa restando in
272
+ // vista normale — la lista del wrap è una schermata che si apre e si chiude.
273
+ //
274
+ // Il gitlink sta in TESTA al blocco, che è ancorato al bordo destro: è l'unico
275
+ // segmento che può mancare del tutto (il gate su `.gitmodules`), e da lì la
276
+ // sua assenza non sposta gli altri due.
239
277
  const segments = [
278
+ ...(state.gitlink ? [{ key: '^u', text: gitlinkIndicator(state.gitlink) }] : []),
240
279
  { key: '^w', text: wrapIndicator(state.wrap) },
241
280
  { key: '^b', text: inboxButton(state.inbox) },
242
281
  ];
@@ -0,0 +1,210 @@
1
+ // T155 — il gitlink dei submodule come dato del deck.
2
+ //
3
+ // Il cappello pinna ogni membro a un commit fisso (gitlink `160000`). Quando un
4
+ // submodule avanza — un commit nel deck, nel plugin, in compass — il gitlink
5
+ // resta indietro finché qualcuno non lo bumpa a mano.
6
+ //
7
+ // LA CLASSIFICAZIONE NON STA QUI (P1 preflight). Il deck spawna
8
+ // `bump-gitlink.sh --dry-run` del plugin e mostra il TSV che ne esce: la stessa
9
+ // sequenza che poi committa è quella che ha misurato, quindi il sensore non può
10
+ // dire «bumpabile» in base a un criterio diverso da quello del bump. Replicarla
11
+ // in TypeScript darebbe due implementazioni delle due guardie — quella sul verso
12
+ // e quella sul remote — e una divergenza fra le due si vedrebbe solo come un
13
+ // cappello pinnato su un commit sbagliato, giorni dopo, nel clone di qualcun
14
+ // altro. Stesso confine di `inbox.ts` e `wrap-scan.ts`, e non quello di
15
+ // `purge.ts`, che passa da una sessione Claude: qui non c'è nessun prompt, solo
16
+ // un `execFile` con esito sincrono.
17
+ //
18
+ // Lettore puro + spawn: nessun React, nessuna resa. È l'unico modo di provarlo
19
+ // senza pseudo-terminale.
20
+ import { execFile } from 'node:child_process';
21
+ import { existsSync } from 'node:fs';
22
+ import { join } from 'node:path';
23
+ import { promisify } from 'node:util';
24
+ import { pluginScript } from './plugin-cache.js';
25
+ const execFileAsync = promisify(execFile);
26
+ export const GITLINK_SCRIPT = 'scripts/utils/bump-gitlink.sh';
27
+ /**
28
+ * Ogni 30 secondi (P2 preflight).
29
+ *
30
+ * Il gate che regge gli altri poll stretti del deck qui NON funziona: il
31
+ * disallineamento nasce da un commit dentro il submodule, che lascia fermi sia
32
+ * `HEAD` del cappello (`commit-times.ts`) sia la mtime dei task file
33
+ * (`epic-hierarchy.ts`) — non c'è niente di economico da misurare prima. Resta
34
+ * il costo nudo, misurato sul cappello a tre submodule: 0,03s per giro, dentro
35
+ * il budget che T153 ha fissato per il loop.
36
+ *
37
+ * Il dry-run NON contatta la rete (P11): la guardia col `fetch` gira solo nel
38
+ * bump. Ne discende che il sensore può contare «bumpabile» un commit che il
39
+ * bump poi salta perché sparito dal remote — la guardia sta sull'AZIONE, e un
40
+ * contatore non è un'autorizzazione.
41
+ */
42
+ export const GITLINK_SCAN_INTERVAL_MS = 30 * 1000;
43
+ /**
44
+ * Gli stati che lo script emette, più `sconosciuto` che è del solo lettore.
45
+ *
46
+ * I primi tre vengono dai prefissi di `git submodule status`; gli altri
47
+ * spacchettano il prefisso `+`, che da solo NON dice il verso — `man
48
+ * git-submodule`: «`+` if the currently checked out submodule commit does not
49
+ * match the SHA-1 found in the index». Un checkout più vecchio del gitlink
50
+ * (cappello pullato senza `git submodule update`) porta lo stesso `+` di un
51
+ * commit nuovo nel membro, e le due situazioni hanno rimedi opposti.
52
+ *
53
+ * `bumpato` esiste solo nell'output del bump: nel dry-run un membro idoneo resta
54
+ * `avanti`. Sono due fatti diversi — «lo bumperei» e «l'ho bumpato» — e un solo
55
+ * nome per entrambi renderebbe la riga di stato indistinguibile da un'anteprima.
56
+ */
57
+ export const GITLINK_STATES = [
58
+ 'allineato',
59
+ 'non-inizializzato',
60
+ 'conflitto',
61
+ 'avanti',
62
+ 'indietro',
63
+ 'non-pushato',
64
+ 'remote-irraggiungibile',
65
+ 'bumpato',
66
+ ];
67
+ const KNOWN = new Set(GITLINK_STATES);
68
+ /**
69
+ * Il TSV di `bump-gitlink.sh`: una riga per submodule, `path ⇥ stato ⇥ dettaglio`.
70
+ * Nessuna riga di intestazione — il primo campo è già un path.
71
+ *
72
+ * Uno stato che il deck non conosce diventa `sconosciuto` invece di sparire: la
73
+ * riga la si è comunque ricevuta, e scartarla farebbe calare il contatore ogni
74
+ * volta che lo script guadagna uno stato nuovo — cioè proprio quando il deck ha
75
+ * più bisogno di dirlo. Il dettaglio è l'ULTIMO campo e si ricompone: può
76
+ * contenere un comando, e un comando può contenere un tab.
77
+ */
78
+ export function parseGitlinkTsv(stdout) {
79
+ const out = [];
80
+ for (const line of stdout.split('\n')) {
81
+ if (!line.trim())
82
+ continue;
83
+ const c = line.split('\t');
84
+ if (c.length < 2 || !c[0])
85
+ continue;
86
+ out.push({
87
+ path: c[0],
88
+ state: KNOWN.has(c[1]) ? c[1] : 'sconosciuto',
89
+ detail: c.slice(2).join('\t'),
90
+ });
91
+ }
92
+ return out;
93
+ }
94
+ /**
95
+ * Gli stati che il contatore somma: i membri il cui checkout non coincide col
96
+ * gitlink, in entrambi i versi.
97
+ *
98
+ * `non-inizializzato` e `conflitto` restano FUORI per decisione: nessun bump li
99
+ * sistema — il primo vuole un `git submodule update --init`, il secondo la
100
+ * risoluzione di un merge — e sommarli darebbe un numero che il tasto non può
101
+ * portare a zero. Un contatore che non arriva mai a zero smette di essere letto
102
+ * (stessa regola di `misto` nell'indicatore hard-wrap). Si dicono comunque, col
103
+ * glifo di allerta accanto al numero.
104
+ */
105
+ const DISALIGNED = new Set([
106
+ 'avanti',
107
+ 'indietro',
108
+ 'non-pushato',
109
+ 'remote-irraggiungibile',
110
+ ]);
111
+ /** Quanti membri hanno il checkout diverso dal gitlink: è il numero a schermo. */
112
+ export function disalignedCount(members) {
113
+ return members.filter((m) => DISALIGNED.has(m.state)).length;
114
+ }
115
+ /** Quanti sono bumpabili adesso: il solo `avanti` passa entrambe le guardie. */
116
+ export function bumpableCount(members) {
117
+ return members.filter((m) => m.state === 'avanti').length;
118
+ }
119
+ /**
120
+ * Qualcosa richiede attenzione ma nessun bump lo sistema: un membro non
121
+ * inizializzato, un conflitto di merge, uno stato che questo deck non conosce.
122
+ * Vale il glifo di allerta, mai una cifra nel contatore.
123
+ */
124
+ export function attentionCount(members) {
125
+ return members.filter((m) => m.state === 'non-inizializzato' || m.state === 'conflitto' || m.state === 'sconosciuto').length;
126
+ }
127
+ /**
128
+ * Gli stati che il bump ha SALTATO: il membro è disallineato ma una delle due
129
+ * guardie l'ha fermato, e il rimedio non è un bump ma il gesto che il dettaglio
130
+ * nomina.
131
+ */
132
+ const SKIPPED = new Set(['indietro', 'non-pushato', 'remote-irraggiungibile']);
133
+ /**
134
+ * La riga di stato dopo un bump: un esito per membro, non un totale.
135
+ *
136
+ * Il caso che rende necessario nominarli è il MISTO — un membro pushato e uno
137
+ * no. Lì un messaggio riassuntivo direbbe «1 bumpato» e lascerebbe fuori proprio
138
+ * l'informazione che serve ad agire: quale membro è rimasto indietro e con quale
139
+ * comando lo si sblocca. Il gesto arriva già scritto dallo script, che è dove la
140
+ * guardia lo ha deciso: comporlo qui darebbe due frasi da tenere allineate.
141
+ *
142
+ * I bumpati vengono per primi — è l'esito che il tasto prometteva — e i saltati
143
+ * dopo, ognuno col proprio glifo di allerta. I membri allineati non compaiono:
144
+ * la riga di stato dice cosa è successo, non cosa esiste.
145
+ */
146
+ export function gitlinkNote(members) {
147
+ const done = members
148
+ .filter((m) => m.state === 'bumpato')
149
+ .map((m) => `✔ ${m.path} ${m.detail}`.trim());
150
+ const skipped = members
151
+ .filter((m) => SKIPPED.has(m.state))
152
+ .map((m) => `⚠ ${m.path}: ${m.detail}`);
153
+ return [...done, ...skipped].join(' · ');
154
+ }
155
+ /**
156
+ * Il progetto ha submodule da misurare.
157
+ *
158
+ * È il gate di ATTIVAZIONE dell'intero sensore (DLV2): senza `.gitmodules` non
159
+ * parte nessun timer, non si spawna niente e l'indicatore non compare affatto —
160
+ * non «0», che occuperebbe colonne per dire che la funzione non si applica.
161
+ *
162
+ * Il file, non `git submodule status`: rispondere alla domanda «ci sono
163
+ * submodule?» con lo stesso spawn che si vuole evitare sarebbe la misura che il
164
+ * gate esiste per non pagare.
165
+ */
166
+ export function hasGitmodules(projectRoot) {
167
+ return existsSync(join(projectRoot, '.gitmodules'));
168
+ }
169
+ /**
170
+ * Invoca lo script e ne parsa l'output.
171
+ *
172
+ * I modi di non avere il dato — plugin non installato su questa macchina,
173
+ * script in errore, git muto — collassano in un solo `ok: false`, come per la
174
+ * coda inbox: a schermo non cambiano la prima mossa di chi li vede. Nessuno dei
175
+ * due è un throw, un indicatore informativo non può rompere il deck.
176
+ *
177
+ * Lo stdout si legge ANCHE quando l'exit è non-zero: lo script classifica tutti
178
+ * i membri prima di agire, quindi su un errore parziale (un `git` che fallisce
179
+ * su un membro) le righe già emesse restano vere e dicono più di una schermata
180
+ * vuota.
181
+ */
182
+ async function run(projectRoot, args) {
183
+ const script = pluginScript(GITLINK_SCRIPT);
184
+ if (!script)
185
+ return { members: [], ok: false };
186
+ try {
187
+ const { stdout } = await execFileAsync(script, args, {
188
+ cwd: projectRoot,
189
+ maxBuffer: 1024 * 1024,
190
+ });
191
+ return { members: parseGitlinkTsv(stdout), ok: true };
192
+ }
193
+ catch (e) {
194
+ const stdout = e.stdout ?? '';
195
+ return { members: parseGitlinkTsv(stdout), ok: false };
196
+ }
197
+ }
198
+ /** La misura, senza toccare niente: nessun fetch, nessun commit. */
199
+ export function scanGitlink(projectRoot) {
200
+ return run(projectRoot, ['--dry-run']);
201
+ }
202
+ /**
203
+ * Il bump vero: fetch, due guardie, commit unico coi soli membri idonei. Il
204
+ * cappello NON viene pushato — quel gesto resta al flusso normale, perché un
205
+ * gitlink committato per sbaglio si annulla con un `git reset` che non tocca
206
+ * nessun remote, mentre uno pushato è già nella copia di chiunque abbia pullato.
207
+ */
208
+ export function bumpGitlink(projectRoot) {
209
+ return run(projectRoot, []);
210
+ }
package/dist/input.js CHANGED
@@ -322,6 +322,19 @@ export function useDeckInput({ cwd, tasksDir, mode, setMode, setNote, model, act
322
322
  else if (input === 'e') {
323
323
  overlays.wrap.scan();
324
324
  }
325
+ else if (input === 'u') {
326
+ // T155/P5 preflight — `^U` (update del gitlink) bumpa i submodule
327
+ // idonei. Non è la coppia genera/apri di `^G`/`^O` e `^W`/`^E`: qui non
328
+ // c'è nessuna schermata da aprire, la misura gira da sé ogni 30 secondi
329
+ // e l'unico gesto è l'azione. Nessuna conferma, perché il commit è
330
+ // locale e reversibile (P6).
331
+ //
332
+ // `^B` era la mnemonica migliore per «bump» ed è di T134 (scambia i due
333
+ // pane dello slot destro). `^U` svuota un campo dentro i modi capturing,
334
+ // ma quelli consumano l'input per intero e non vedono mai questo ramo —
335
+ // stesso doppio significato per contesto già in esercizio su `^F`.
336
+ overlays.gitlink.bump();
337
+ }
325
338
  else if (input === 'b') {
326
339
  // T134/D8 preflight — `^B` (box/bacheca) scambia i due pane dello slot
327
340
  // destro. Non è un modale né una schermata: il pane resta uno dei due
@@ -0,0 +1,154 @@
1
+ // T155 — il sensore del gitlink: stato, gate di attivazione, cadenza.
2
+ //
3
+ // Non è un overlay come `sheet` o `search` — non apre nessuna schermata e non
4
+ // entra in `MODE_KEYS`. Sta qui per la stessa ragione di `useProjectStatus` e
5
+ // `useWrapOverlay`: tiene uno stato che si legge in vista normale e possiede
6
+ // l'azione che quello stato abilita, e `hooks.ts` è già a 487 righe (P3
7
+ // preflight, D1/P12 — nessuno split dentro questa task).
8
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
9
+ import { attentionCount, bumpableCount, bumpGitlink, disalignedCount, gitlinkNote, GITLINK_SCAN_INTERVAL_MS, hasGitmodules, scanGitlink, } from '../gitlink.js';
10
+ export function useGitlink(deps) {
11
+ const { cwd, setNote } = deps;
12
+ /**
13
+ * IL GATE. Senza `.gitmodules` il progetto non ha submodule, quindi non c'è
14
+ * niente da misurare: nessun timer parte, nessuno spawn viene fatto, e
15
+ * l'indicatore non compare affatto — non «0», che occuperebbe colonne della
16
+ * riga legenda per dire che la funzione non si applica.
17
+ *
18
+ * Misurato UNA VOLTA per project root e non a ogni giro: un repo non guadagna
19
+ * submodule mentre il deck è aperto, e se lo facesse basta riavviarlo. È lo
20
+ * stesso limite accettato dalla cache del plugin (`plugin-cache.ts`), scritto
21
+ * qui invece che scoperto dopo.
22
+ */
23
+ const enabled = useMemo(() => hasGitmodules(cwd), [cwd]);
24
+ const [members, setMembers] = useState([]);
25
+ const [ok, setOk] = useState(true);
26
+ /** Almeno un tentativo è stato fatto: distingue «non ho ancora misurato» da
27
+ * «ho misurato e sono tutti allineati», che a schermo sono due cose diverse. */
28
+ const [scanned, setScanned] = useState(false);
29
+ const [epoch, setEpoch] = useState(0);
30
+ const [bumping, setBumping] = useState(false);
31
+ /** L'ultimo bump si è fermato prima di committare. STICKY: un giro di misura
32
+ * riuscito non lo azzera — dice com'è andata l'ultima AZIONE, che è un fatto
33
+ * diverso da com'è andata l'ultima misura, e i due restano entrambi veri.
34
+ * Stessa regola del `failed` del project status. */
35
+ const [failed, setFailed] = useState(false);
36
+ // Il flag di corsa sta in un ref e non in stato, come in `useInboxScan`: due
37
+ // richieste ravvicinate girano nello stesso tick di React, e un valore di
38
+ // stato arriverebbe al render dopo — cioè troppo tardi per impedire il
39
+ // secondo spawn.
40
+ const busy = useRef(false);
41
+ // `alive` vive fuori dall'effect perché la richiesta forzata sopravvive al
42
+ // giro che l'ha ospitata: la scatena un gesto, non l'effect.
43
+ const alive = useRef(true);
44
+ useEffect(() => {
45
+ alive.current = true;
46
+ return () => {
47
+ alive.current = false;
48
+ };
49
+ }, []);
50
+ const run = useCallback(() => {
51
+ if (busy.current)
52
+ return;
53
+ busy.current = true;
54
+ scanGitlink(cwd).then((res) => {
55
+ busy.current = false;
56
+ if (!alive.current)
57
+ return;
58
+ // Un tentativo fallito NON butta via l'esito dell'ultimo riuscito: le
59
+ // righe precedenti restano vere, e il guasto si dice col glifo di allerta
60
+ // accanto al numero. Stessa regola del project status e della coda inbox.
61
+ setOk(res.ok);
62
+ setScanned(true);
63
+ if (res.ok || res.members.length > 0)
64
+ setMembers(res.members);
65
+ });
66
+ }, [cwd]);
67
+ useEffect(() => {
68
+ if (!enabled)
69
+ return;
70
+ run();
71
+ const id = setInterval(run, GITLINK_SCAN_INTERVAL_MS);
72
+ return () => clearInterval(id);
73
+ }, [enabled, run, epoch]);
74
+ /**
75
+ * Rimisura adesso e riparte col timer da zero.
76
+ *
77
+ * Il reset non è cosmetico: senza, l'intervallo continuerebbe a scorrere
78
+ * dall'ultimo tick automatico e potrebbe rimisurare pochi istanti dopo un
79
+ * dato appena misurato — costo pieno, risultato identico. Stessa forma di
80
+ * `useWrapScan` e `useInboxScan`.
81
+ */
82
+ const refresh = useCallback(() => {
83
+ setEpoch((e) => e + 1);
84
+ }, []);
85
+ const count = disalignedCount(members);
86
+ const bumpable = bumpableCount(members);
87
+ /**
88
+ * Il bump vero — l'unica azione del sensore.
89
+ *
90
+ * NESSUNA CONFERMA (P6 preflight): il commit è locale e si annulla con un
91
+ * `git reset` che non tocca nessun remote, e lo script non pusha il cappello.
92
+ * È la stessa forma di `^G`, che genera senza chiedere.
93
+ *
94
+ * I quattro rifiuti dicono SEMPRE perché (AC: il tasto su zero disallineati
95
+ * non spawna niente e lo dice). Un tasto che non facesse niente in silenzio
96
+ * sarebbe indistinguibile da un binding mancante.
97
+ */
98
+ function bump() {
99
+ if (!enabled) {
100
+ setNote('^U → questo progetto non ha submodule: nessun gitlink da bumpare');
101
+ return;
102
+ }
103
+ if (busy.current || bumping) {
104
+ setNote('^U → bump del gitlink già in corso');
105
+ return;
106
+ }
107
+ if (!scanned) {
108
+ setNote('^U → gitlink non ancora misurato');
109
+ return;
110
+ }
111
+ if (bumpable === 0) {
112
+ const why = gitlinkNote(members);
113
+ setNote(count === 0
114
+ ? '^U → gitlink già allineato: niente da bumpare'
115
+ : `^U → nessun membro bumpabile · ${why}`);
116
+ return;
117
+ }
118
+ busy.current = true;
119
+ setBumping(true);
120
+ setNote(`⏳ bump del gitlink su ${bumpable} membro${bumpable > 1 ? 'i' : ''}…`);
121
+ bumpGitlink(cwd).then((res) => {
122
+ busy.current = false;
123
+ if (!alive.current)
124
+ return;
125
+ setBumping(false);
126
+ setOk(res.ok);
127
+ setScanned(true);
128
+ setFailed(!res.ok);
129
+ if (res.members.length > 0)
130
+ setMembers(res.members);
131
+ const note = gitlinkNote(res.members);
132
+ setNote(note || (res.ok ? '✔ gitlink: nessun membro bumpato' : '⚠ bump del gitlink fallito'));
133
+ // Rimisura: l'output del bump porta già gli stati nuovi, ma il giro
134
+ // successivo li conferma da `git submodule status` invece che dalla
135
+ // memoria di ciò che si è appena fatto — ed è ciò che fa scendere il
136
+ // contatore senza riavviare il deck anche quando il commit ha toccato più
137
+ // di quanto lo script credeva.
138
+ refresh();
139
+ });
140
+ }
141
+ return {
142
+ enabled,
143
+ members,
144
+ ok,
145
+ scanned,
146
+ bumping,
147
+ failed,
148
+ count,
149
+ bumpable,
150
+ attention: attentionCount(members),
151
+ bump,
152
+ refresh,
153
+ };
154
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.63.0",
3
+ "version": "0.64.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": {