@lamemind/loom-deck 0.63.0 → 0.65.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 +35 -2
- package/dist/cli.js +19 -0
- package/dist/frame.js +42 -3
- package/dist/gitlink.js +210 -0
- package/dist/input.js +13 -0
- package/dist/overlays/gitlink.js +154 -0
- package/dist/spawn.js +47 -15
- package/package.json +1 -1
- package/scripts/deck-run +30 -4
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:
|
|
237
|
-
// destro perché è l'unico dei
|
|
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
|
];
|
package/dist/gitlink.js
ADDED
|
@@ -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/dist/spawn.js
CHANGED
|
@@ -191,20 +191,34 @@ export const ACTION_HOTKEYS = Object.fromEntries(DETAIL_ACTIONS.map((a, i) => [a
|
|
|
191
191
|
// fallback (`spawnForTask` in actions.ts) arriva il kind — già specializzato
|
|
192
192
|
// quando la chiamata viene dal detail (`recap-task`/`recap-epic`) — mai la
|
|
193
193
|
// label del catalogo.
|
|
194
|
-
// D2/P7 — `none` (label "open") non ha
|
|
195
|
-
// inventargliene
|
|
196
|
-
// P3 — le tre varianti di recap condividono
|
|
194
|
+
// D2/P7 — `none` (label "open") non ha un prefisso: è "nessuna azione", e
|
|
195
|
+
// inventargliene uno contraddirebbe l'intenzione dell'utente.
|
|
196
|
+
// P3 — le tre varianti di recap condividono il prefisso: la specializzazione
|
|
197
197
|
// sceglie quale skill parte, non cosa l'utente sta chiedendo.
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
198
|
+
//
|
|
199
|
+
// T156 — emoji invece della parola in maiuscolo (`PREFL`, `RUN`, …): il titolo
|
|
200
|
+
// si legge in lista deck e nella tab Ptyxis, dove l'azione va riconosciuta a
|
|
201
|
+
// colpo d'occhio e non letta.
|
|
202
|
+
// Le quattro sono ASTRALI (code point ≥ U+10000) per necessità, non per gusto:
|
|
203
|
+
// `sanitize` (`src/width.ts`) sostituisce con `·` ogni glifo su cui le due
|
|
204
|
+
// contabilità di larghezza divergono, e le emoji del BMP a presentazione testo
|
|
205
|
+
// (⚙️ ⏱️ 🛠️ …) divergono sempre — string-width le conta 2, VTE ne disegna 1, e
|
|
206
|
+
// il VS16 non sposta nessuno dei due verdetti. Un'emoji aggiunta qui va prima
|
|
207
|
+
// passata in `sanitize` per vedere se sopravvive.
|
|
208
|
+
// Le stesse quattro sono anche l'unica classe di emoji che `_sane_note` di
|
|
209
|
+
// `deck-run` lascia passare nel titolo della tab: la whitelist è una lista
|
|
210
|
+
// chiusa che nomina questi code point (P2 preflight), quindi cambiare una voce
|
|
211
|
+
// qui e non là riapre la divergenza fra titolo in lista e titolo in tab.
|
|
212
|
+
const ACTION_EMOJI = {
|
|
213
|
+
preflight: '📐',
|
|
214
|
+
run: '🚀',
|
|
215
|
+
recap: '📊',
|
|
216
|
+
'recap-task': '📊',
|
|
217
|
+
'recap-epic': '📊',
|
|
218
|
+
checkpoint: '🏁',
|
|
205
219
|
};
|
|
206
220
|
/**
|
|
207
|
-
* Titolo di fallback per una conversazione con nota vuota: `{
|
|
221
|
+
* Titolo di fallback per una conversazione con nota vuota: `[ {emoji} ] {slug}`,
|
|
208
222
|
* o il solo slug per `none` (D1/D2 preflight). Lo slug viene dal NOME del
|
|
209
223
|
* task file (`findTaskFile`), non dalla descrizione di `tasks.md`: il nome è
|
|
210
224
|
* già dentro l'alfabeto di `_sane_note` per costruzione — minuscolo, separato
|
|
@@ -212,6 +226,11 @@ const ACTION_WORD = {
|
|
|
212
226
|
* `/` che la riduzione toglie senza sostituto, saldando le parole (D1
|
|
213
227
|
* razionale).
|
|
214
228
|
*
|
|
229
|
+
* Il PREFISSO invece nell'alfabeto non ci sta per costruzione: emoji e quadre
|
|
230
|
+
* ci entrano solo perché la whitelist di `_sane_note` le nomina una per una
|
|
231
|
+
* (T156). È l'unica parte del titolo di fallback che dipende da una modifica
|
|
232
|
+
* fatta dall'altro lato.
|
|
233
|
+
*
|
|
215
234
|
* `null` quando il task file non si trova: il chiamante decide se ripiegare
|
|
216
235
|
* su nota vuota o su un altro fallback.
|
|
217
236
|
*/
|
|
@@ -220,8 +239,8 @@ export function fallbackTitle(tasksDir, id, kind) {
|
|
|
220
239
|
if (!path)
|
|
221
240
|
return null;
|
|
222
241
|
const slug = basename(path, '.md').slice(id.length + 1).replace(/-/g, ' ');
|
|
223
|
-
const
|
|
224
|
-
return
|
|
242
|
+
const emoji = ACTION_EMOJI[kind];
|
|
243
|
+
return emoji ? `[ ${emoji} ] ${slug}` : slug;
|
|
225
244
|
}
|
|
226
245
|
// Spawn detached: il deck spawna ma NON contiene la sessione (la possiede
|
|
227
246
|
// ptyxis-agent). unref + stdio ignore → ritorna subito, la TUI resta viva.
|
|
@@ -283,11 +302,24 @@ export function spawnDeck(id, cwd, sessionId, kind, model = MODEL_DEFAULT, spawn
|
|
|
283
302
|
// default (P2 preflight). Qui però non c'è un default da cablare: chi
|
|
284
303
|
// chiama ha già risolto il valore — il modello della conversazione d'origine,
|
|
285
304
|
// o quello scelto al suo posto — e lo passa come argomento obbligatorio.
|
|
305
|
+
// La NOTA sta PRIMA di `--resume`, non in coda, ed è l'unico argomento di questa
|
|
306
|
+
// forma che non segue l'ordine di lettura. La ragione è la riga di stato: il
|
|
307
|
+
// comando ci arriva tagliato al mezzo (`noteCommand`, che tiene un terzo di
|
|
308
|
+
// testa e due terzi di coda), e `--title-note` è l'unico argomento a lunghezza
|
|
309
|
+
// LIBERA — porta il titolo della conversazione, che l'utente scrive come vuole.
|
|
310
|
+
// In coda si mangia i due terzi riservati, e a spingere fuori è proprio
|
|
311
|
+
// `--resume <uuid>`, cioè l'unica cosa che distingue una ripresa da un'altra: su
|
|
312
|
+
// una conversazione dal titolo lungo la riga di stato finiva per dire quale
|
|
313
|
+
// modello e quale titolo, mai quale conversazione. Spostata qui, la coda porta
|
|
314
|
+
// sempre `--resume <uuid> --model <alias>` — sessanta colonne su un budget di
|
|
315
|
+
// settantasei, indipendenti dal titolo. `deck-run` legge i flag in qualunque
|
|
316
|
+
// ordine (un solo posizionale, il TaskID), quindi lo spostamento non cambia
|
|
317
|
+
// niente per chi esegue.
|
|
286
318
|
export function resumeArgs(taskId, sessionId, model, note) {
|
|
287
|
-
const args = taskId ? [taskId
|
|
288
|
-
args.push('--model', model);
|
|
319
|
+
const args = taskId ? [taskId] : ['--no-task'];
|
|
289
320
|
if (note)
|
|
290
321
|
args.push('--title-note', note);
|
|
322
|
+
args.push('--resume', sessionId, '--model', model);
|
|
291
323
|
return args;
|
|
292
324
|
}
|
|
293
325
|
export function spawnDeckResume(taskId, cwd, sessionId, model, note) {
|
package/package.json
CHANGED
package/scripts/deck-run
CHANGED
|
@@ -116,7 +116,9 @@ MODEL=""
|
|
|
116
116
|
# tab. È l'UNICO ingresso di testo libero nel titolo — gli altri componenti
|
|
117
117
|
# (emoji/name dal file committato, TaskID) sono controllati — quindi il testo non
|
|
118
118
|
# si quota, si RIDUCE a un alfabeto sicuro (vedi _sane_note): tutto ciò che non
|
|
119
|
-
# ci rientra sparisce, apici inclusi.
|
|
119
|
+
# ci rientra sparisce, apici inclusi. L'alfabeto ammette le quadre e quattro
|
|
120
|
+
# emoji (T156) perché il deck ci fa passare il prefisso del titolo di fallback
|
|
121
|
+
# (`[ 🚀 ] slug`); ogni altra emoji cade ancora.
|
|
120
122
|
# --model <fable|opus|sonnet|haiku> (T108): modello della sessione. Enum e non
|
|
121
123
|
# stringa libera perché il valore finisce dentro `bash -lc` e un refuso
|
|
122
124
|
# produrrebbe una tab con un comando che il CLI rifiuta. Le voci sono ALIAS e
|
|
@@ -267,8 +269,23 @@ WORKDIR="${LOOM_DECK_WORKDIR:-$PWD}"
|
|
|
267
269
|
# titolo finisce dentro apici singoli in `bash -lc "claude --name '…'"`, e la
|
|
268
270
|
# nota è testo digitato a mano — enumerare i caratteri pericolosi significa
|
|
269
271
|
# sbagliarne uno, enumerare quelli ammessi no. Restano lettere, cifre, accentate
|
|
270
|
-
# italiane, spazio, trattino, underscore
|
|
271
|
-
# `$`, backtick, `;`, emoji…) cade.
|
|
272
|
+
# italiane, spazio, trattino, underscore, le quadre e quattro emoji (sotto);
|
|
273
|
+
# tutto il resto (apici, backslash, `$`, backtick, `;`, ogni altra emoji…) cade.
|
|
274
|
+
#
|
|
275
|
+
# T156 — le quadre e le quattro emoji entrano per il PREFISSO del titolo di
|
|
276
|
+
# fallback che il deck genera quando il campo nota è vuoto (`[ 🚀 ] slug`,
|
|
277
|
+
# `ACTION_EMOJI` in `src/spawn.ts`). Senza di loro il prefisso spariva qui e
|
|
278
|
+
# restava nella lista del deck, che riceve la nota grezza dal sidecar
|
|
279
|
+
# `session-tasks.jsonl`: la stessa conversazione portava due titoli diversi,
|
|
280
|
+
# senza errore.
|
|
281
|
+
# La whitelist le nomina una per una e NON apre un intervallo di code point. Due
|
|
282
|
+
# ragioni: un'emoji arriva nella tab solo se il deck la mostra identica in lista,
|
|
283
|
+
# cioè se `sanitize` (`src/width.ts`) la lascia passare invece di renderla `·`, e
|
|
284
|
+
# quel verdetto si verifica un glifo alla volta (🗺️ è astrale e finisce comunque
|
|
285
|
+
# in `·`); e l'ordine di un intervallo dentro un bracket expression segue la
|
|
286
|
+
# collazione della locale, non i code point.
|
|
287
|
+
# `]` sta per PRIMO nel set negato, dove chiude la classe invece di aprirla.
|
|
288
|
+
#
|
|
272
289
|
# Il cap a 60 CHARACTER è largo di proposito: una tab che sfora tronca a destra
|
|
273
290
|
# da sola, quindi il limite serve solo a non spingere fuori vista la parte del
|
|
274
291
|
# titolo che compass matcha, non a far stare la nota nella tab.
|
|
@@ -278,8 +295,17 @@ _sane_note() { # <raw> → nota ridotta, spazi collassati, cap 60 char
|
|
|
278
295
|
# titolo). L'assegnazione rifà setlocale, l'uscita dalla funzione lo ripristina.
|
|
279
296
|
local LC_ALL=C.UTF-8
|
|
280
297
|
local s
|
|
298
|
+
# La locale va rimessa SUL `sed`, non ereditata da qui: `local LC_ALL` non
|
|
299
|
+
# porta l'attributo export quando `LC_ALL` non era già esportata nell'ambiente
|
|
300
|
+
# (misurato: il figlio la vede vuota), quindi `sed` ricade su `LANG` — sotto
|
|
301
|
+
# una locale `C` lavora a byte e un carattere multibyte fuori whitelist lascia
|
|
302
|
+
# nel titolo i byte che coincidono con quelli ammessi. Le emoji astrali, che
|
|
303
|
+
# condividono i primi due byte `f0 9f`, ne lasciano sempre due: `🔥 fuoco`
|
|
304
|
+
# diventa `\xf0\x9f fuoco`, cioè UTF-8 rotto dentro il titolo della tab.
|
|
305
|
+
# `tr` non ne ha bisogno: squeeza uno spazio ASCII, e 0x20 non compare mai
|
|
306
|
+
# come byte di continuazione di una sequenza UTF-8.
|
|
281
307
|
s="$(printf '%s' "$1" \
|
|
282
|
-
| sed 's/[^A-Za-z0-9 _
|
|
308
|
+
| LC_ALL=C.UTF-8 sed 's/[^]A-Za-z0-9 _àèéìòùÀÈÉÌÒÙ[📐🚀📊🏁-]//g' \
|
|
283
309
|
| tr -s ' ')"
|
|
284
310
|
s="${s# }"; s="${s% }"
|
|
285
311
|
printf '%s' "${s:0:60}"
|