@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,231 @@
|
|
|
1
|
+
// I modali IN FLUSSO: quelli che si disegnano in un box dentro la cornice
|
|
2
|
+
// invece di sostituirla. Sono due famiglie e stanno nello stesso file perché
|
|
3
|
+
// hanno lo stesso mestiere — tenere una bozza mentre si digita — e perché il
|
|
4
|
+
// loro gemello di vista esiste già e si chiama `ui/modals.tsx`. La simmetria
|
|
5
|
+
// `ui/modals.tsx` (vista) ↔ `overlays/modals.ts` (stato e input) è essa stessa
|
|
6
|
+
// un'informazione: chi cerca l'uno trova l'altro.
|
|
7
|
+
//
|
|
8
|
+
// `useViewModals` governa sort e filtri, che compongono la VISTA. Lo stato
|
|
9
|
+
// `view` non è loro e vive nel modello (`deck-model.ts`): è ciò che `applyView`
|
|
10
|
+
// consuma, è persistito su disco e lo rilegge il tasto `w`. Qui resta ciò che è
|
|
11
|
+
// davvero del modale — la fotografia per l'annullamento e la posizione nella
|
|
12
|
+
// griglia dei filtri.
|
|
13
|
+
//
|
|
14
|
+
// `useTextModals` governa create, nota ed edit, cioè i tre campi di testo. Le
|
|
15
|
+
// scritture che ne conseguono escono come callback (deroga T104: un hook di
|
|
16
|
+
// overlay non esce dal deck).
|
|
17
|
+
import { useState } from 'react';
|
|
18
|
+
import { caretAtEnd, fieldsKey } from '../fields.js';
|
|
19
|
+
import { sanitizeTyped } from '../glyphs.js';
|
|
20
|
+
import { EDIT_FIELDS, EDIT_PRI, EDIT_PROG, editTextField, SORT_TASTI, } from '../model.js';
|
|
21
|
+
import { cycleSort, describeSort, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from '../view.js';
|
|
22
|
+
export function useViewModals({ view, setView, hiddenTasks, setMode, setNote, }) {
|
|
23
|
+
// T39 — fotografia della vista all'apertura di un modale: la lista si
|
|
24
|
+
// aggiorna dal vivo, quindi `esc` deve poter ripristinare.
|
|
25
|
+
const [backup, setBackup] = useState(null);
|
|
26
|
+
const [filterCursor, setFilterCursor] = useState({ row: 0, col: 0 });
|
|
27
|
+
/** Chiusura: `restore` rimette la fotografia (esc = annulla), altrimenti tiene ciò che si è composto (⏎). */
|
|
28
|
+
function close(restore) {
|
|
29
|
+
if (restore && backup) {
|
|
30
|
+
const snapshot = backup;
|
|
31
|
+
setView(() => snapshot);
|
|
32
|
+
}
|
|
33
|
+
setBackup(null);
|
|
34
|
+
setMode('normal');
|
|
35
|
+
}
|
|
36
|
+
function openSort() {
|
|
37
|
+
setBackup(view);
|
|
38
|
+
setNote('');
|
|
39
|
+
setMode('sort');
|
|
40
|
+
}
|
|
41
|
+
function openFilter() {
|
|
42
|
+
setBackup(view);
|
|
43
|
+
setNote('');
|
|
44
|
+
setMode('filter');
|
|
45
|
+
}
|
|
46
|
+
function onSortKey(input, key) {
|
|
47
|
+
if (key.escape) {
|
|
48
|
+
close(true);
|
|
49
|
+
setNote('S → sort annullato');
|
|
50
|
+
}
|
|
51
|
+
else if (key.return) {
|
|
52
|
+
close(false);
|
|
53
|
+
setNote(`S → sort: ${describeSort(view.sort)}`);
|
|
54
|
+
}
|
|
55
|
+
else if (input) {
|
|
56
|
+
// useInput consegna il CHUNK letto da stdin, non un tasto: digitando
|
|
57
|
+
// veloce (o incollando) `ppi` arriva come stringa unica. Si cicla su
|
|
58
|
+
// ogni carattere, così la chain esce identica a battitura lenta.
|
|
59
|
+
const keys = [...input].map((ch) => SORT_TASTI[ch]).filter(Boolean);
|
|
60
|
+
if (keys.length > 0) {
|
|
61
|
+
setView((v) => ({ ...v, sort: keys.reduce(cycleSort, v.sort) }));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function onFilterKey(input, key) {
|
|
66
|
+
const rowLen = (r) => (r === 0 ? PRI_ENTRIES.length : PROG_ENTRIES.length);
|
|
67
|
+
if (key.escape) {
|
|
68
|
+
close(true);
|
|
69
|
+
setNote('F → filtri annullati');
|
|
70
|
+
}
|
|
71
|
+
else if (key.return) {
|
|
72
|
+
close(false);
|
|
73
|
+
setNote(hiddenTasks > 0 ? `F → ${hiddenTasks} task nascoste` : 'F → nessun filtro attivo');
|
|
74
|
+
}
|
|
75
|
+
else if (key.upArrow || key.downArrow) {
|
|
76
|
+
setFilterCursor((c) => {
|
|
77
|
+
const row = c.row === 0 ? 1 : 0;
|
|
78
|
+
return { row, col: Math.min(c.col, rowLen(row) - 1) };
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
else if (key.leftArrow) {
|
|
82
|
+
setFilterCursor((c) => ({ ...c, col: Math.max(0, c.col - 1) }));
|
|
83
|
+
}
|
|
84
|
+
else if (key.rightArrow) {
|
|
85
|
+
setFilterCursor((c) => ({ ...c, col: Math.min(rowLen(c.row) - 1, c.col + 1) }));
|
|
86
|
+
}
|
|
87
|
+
else if (input === ' ') {
|
|
88
|
+
const { row, col } = filterCursor;
|
|
89
|
+
setView((v) => row === 0
|
|
90
|
+
? { ...v, hiddenPri: toggleHidden(v.hiddenPri, PRI_ENTRIES[col].name) }
|
|
91
|
+
: { ...v, hiddenProg: toggleHidden(v.hiddenProg, PROG_ENTRIES[col].name) });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { filterCursor, openSort, openFilter, onSortKey, onFilterKey };
|
|
95
|
+
}
|
|
96
|
+
export function useTextModals({ setMode, setNote, onCreate, onNote, onEdit, editDraftFor, currentNote, }) {
|
|
97
|
+
const [draft, setDraft] = useState('');
|
|
98
|
+
// T53 — bozza della nota sulla conversazione selezionata. Si apre PRECARICATA
|
|
99
|
+
// con la nota esistente: annotare due volte è quasi sempre correggere, non
|
|
100
|
+
// riscrivere da zero, e un campo vuoto costringerebbe a ridigitare tutto per
|
|
101
|
+
// cambiare una parola. Confermare il campo vuoto cancella la nota.
|
|
102
|
+
const [noteDraft, setNoteDraft] = useState('');
|
|
103
|
+
const [noteSid, setNoteSid] = useState(null);
|
|
104
|
+
// T41 — bozza dell'edit (null fuori dal modale) e posizione nell'area di
|
|
105
|
+
// compilazione: riga in fuoco + caret dentro la riga di testo attiva.
|
|
106
|
+
const [edit, setEdit] = useState(null);
|
|
107
|
+
const [editTask, setEditTask] = useState(null);
|
|
108
|
+
const [editCursor, setEditCursor] = useState({ row: 0, caret: 0 });
|
|
109
|
+
function openCreate() {
|
|
110
|
+
setNote('');
|
|
111
|
+
setMode('create');
|
|
112
|
+
}
|
|
113
|
+
function openNote(sid) {
|
|
114
|
+
setNoteSid(sid);
|
|
115
|
+
setNoteDraft(currentNote(sid));
|
|
116
|
+
setNote('');
|
|
117
|
+
setMode('note');
|
|
118
|
+
}
|
|
119
|
+
function openEdit(task) {
|
|
120
|
+
setEditTask(task);
|
|
121
|
+
setEdit(editDraftFor(task));
|
|
122
|
+
// Si apre sulla riga 0 (priorità), che non è un campo di testo: il caret
|
|
123
|
+
// prende la sua posizione entrando in una riga di testo con ↑↓.
|
|
124
|
+
setEditCursor({ row: 0, caret: 0 });
|
|
125
|
+
setNote('');
|
|
126
|
+
setMode('edit');
|
|
127
|
+
}
|
|
128
|
+
function onCreateKey(input, key) {
|
|
129
|
+
if (key.escape) {
|
|
130
|
+
setMode('normal');
|
|
131
|
+
setDraft('');
|
|
132
|
+
setNote('C → create annullato');
|
|
133
|
+
}
|
|
134
|
+
else if (key.return) {
|
|
135
|
+
const text = draft.trim();
|
|
136
|
+
setMode('normal');
|
|
137
|
+
setDraft('');
|
|
138
|
+
onCreate(text);
|
|
139
|
+
}
|
|
140
|
+
else if (key.backspace || key.delete) {
|
|
141
|
+
setDraft((d) => d.slice(0, -1));
|
|
142
|
+
}
|
|
143
|
+
else if (input && !key.ctrl && !key.meta) {
|
|
144
|
+
setDraft((d) => d + input);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function onNoteKey(input, key) {
|
|
148
|
+
if (key.escape) {
|
|
149
|
+
setMode('normal');
|
|
150
|
+
setNoteDraft('');
|
|
151
|
+
setNote('N → titolo annullato');
|
|
152
|
+
}
|
|
153
|
+
else if (key.return) {
|
|
154
|
+
const sid = noteSid;
|
|
155
|
+
const text = noteDraft.trim();
|
|
156
|
+
setMode('normal');
|
|
157
|
+
setNoteDraft('');
|
|
158
|
+
if (sid)
|
|
159
|
+
onNote(sid, text);
|
|
160
|
+
}
|
|
161
|
+
else if (key.ctrl && input === 'u') {
|
|
162
|
+
// Svuota il campo in un colpo. NON è una scorciatoia di comodo: il
|
|
163
|
+
// backspace tenuto premuto cancella UN carattere per CHUNK letto da
|
|
164
|
+
// stdin, non per pressione (`useInput` consegna il chunk, e per una
|
|
165
|
+
// raffica di DEL Ink alza `key.backspace` una volta sola) — misurato,
|
|
166
|
+
// 30 pressioni → 2 caratteri. Siccome «campo vuoto» qui è l'unico modo
|
|
167
|
+
// di CANCELLARE una nota, dipendere dal backspace renderebbe
|
|
168
|
+
// l'operazione praticamente non eseguibile. `^U` è il kill-line delle
|
|
169
|
+
// shell, quindi il gesto è già nelle dita di chi usa un terminale.
|
|
170
|
+
setNoteDraft('');
|
|
171
|
+
}
|
|
172
|
+
else if (key.backspace || key.delete) {
|
|
173
|
+
setNoteDraft((d) => d.slice(0, -1));
|
|
174
|
+
}
|
|
175
|
+
else if (input && !key.ctrl && !key.meta) {
|
|
176
|
+
// Sanificazione dei byte di controllo, come `typeIntoField` della
|
|
177
|
+
// ricerca: `useInput` consegna il CHUNK letto da stdin, quindi un
|
|
178
|
+
// incollaggio porta dentro newline e control char. Invisibili a schermo
|
|
179
|
+
// ma contati da Ink nella larghezza della riga — e qui finirebbero
|
|
180
|
+
// scritti su disco, dove resterebbero a sporcare la riga per sempre.
|
|
181
|
+
// Le newline diventano spazio: incollare due righe deve separare le
|
|
182
|
+
// parole, non fonderle.
|
|
183
|
+
setNoteDraft((d) => d + sanitizeTyped(input));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// Il ponte fra le quattro righe del modale e la bozza. I valori restano
|
|
187
|
+
// tipizzati (`PriName`, `ProgName`) invece di finire in un record generico:
|
|
188
|
+
// `fields.ts` governa la grammatica dei tasti, non il modello dati.
|
|
189
|
+
const editIO = {
|
|
190
|
+
text: (row) => edit?.[editTextField(row)] ?? '',
|
|
191
|
+
setText: (row, next) => setEdit((e) => (e ? { ...e, [editTextField(row)]: next } : e)),
|
|
192
|
+
choice: (row) => row === 0
|
|
193
|
+
? Math.max(0, EDIT_PRI.indexOf(edit?.pri ?? 'med'))
|
|
194
|
+
: Math.max(0, EDIT_PROG.indexOf(edit?.prog ?? 'todo')),
|
|
195
|
+
setChoice: (row, index) => setEdit((e) => e ? (row === 0 ? { ...e, pri: EDIT_PRI[index] } : { ...e, prog: EDIT_PROG[index] }) : e),
|
|
196
|
+
};
|
|
197
|
+
function onEditKey(input, key) {
|
|
198
|
+
if (key.escape) {
|
|
199
|
+
setMode('normal');
|
|
200
|
+
setEdit(null);
|
|
201
|
+
setNote('E → edit annullato');
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (key.return) {
|
|
205
|
+
const task = editTask;
|
|
206
|
+
const current = edit;
|
|
207
|
+
setMode('normal');
|
|
208
|
+
setEdit(null);
|
|
209
|
+
if (task && current)
|
|
210
|
+
onEdit(task, current);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
// Tutto il resto è dell'area di compilazione, che è la STESSA del detail
|
|
214
|
+
// (T117): una grammatica sola, non due copie da tenere allineate a mano.
|
|
215
|
+
fieldsKey(input, key, EDIT_FIELDS, editCursor, setEditCursor, editIO);
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
draft,
|
|
219
|
+
noteDraft,
|
|
220
|
+
edit,
|
|
221
|
+
editTask,
|
|
222
|
+
editCursor,
|
|
223
|
+
openCreate,
|
|
224
|
+
openNote,
|
|
225
|
+
openEdit,
|
|
226
|
+
onCreateKey,
|
|
227
|
+
onNoteKey,
|
|
228
|
+
onEditKey,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
export { caretAtEnd };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// T112 — l'overlay di conferma dell'eliminazione (`CANC`).
|
|
2
|
+
//
|
|
3
|
+
// T131 — è nato dentro `cli.tsx` e ci è rimasto per centosessantacinque righe,
|
|
4
|
+
// pur avendo da subito la forma esatta degli altri overlay: un modo dichiarato
|
|
5
|
+
// in `CAPTURING_MODES`, uno stato proprio, un handler proprio, un'apertura con
|
|
6
|
+
// precondizioni. È la prova che `MODE_KEYS` non basta come custode — quel
|
|
7
|
+
// `Record` obbliga un modo nuovo ad avere un handler, non a tenerlo fuori dal
|
|
8
|
+
// componente. Il gate che lo pretende è `test/input-wiring.test.ts`.
|
|
9
|
+
//
|
|
10
|
+
// La deroga di T104 vale anche qui: l'hook non esce dal deck. Costruire la
|
|
11
|
+
// bozza e ordinarne l'esecuzione sono due cose diverse — la prima arriva come
|
|
12
|
+
// `draftFor`, la seconda parte come `onSubmit`, ed entrambe vivono in
|
|
13
|
+
// `actions.ts`. Quello che resta qui è lo stato del modale e i suoi tasti.
|
|
14
|
+
import { useState } from 'react';
|
|
15
|
+
export function usePurgeOverlay({ setMode, setNote, draftFor, onSubmit, }) {
|
|
16
|
+
// La bozza della conferma (null fuori dal modale).
|
|
17
|
+
const [draft, setDraft] = useState(null);
|
|
18
|
+
function open() {
|
|
19
|
+
const next = draftFor();
|
|
20
|
+
if (!next)
|
|
21
|
+
return;
|
|
22
|
+
setDraft(next);
|
|
23
|
+
setNote('');
|
|
24
|
+
setMode('purge');
|
|
25
|
+
}
|
|
26
|
+
function onKey(_input, key) {
|
|
27
|
+
if (key.escape) {
|
|
28
|
+
setMode('normal');
|
|
29
|
+
setDraft(null);
|
|
30
|
+
setNote('CANC → eliminazione annullata');
|
|
31
|
+
}
|
|
32
|
+
else if (key.return) {
|
|
33
|
+
const current = draft;
|
|
34
|
+
setMode('normal');
|
|
35
|
+
setDraft(null);
|
|
36
|
+
if (current)
|
|
37
|
+
onSubmit(current);
|
|
38
|
+
}
|
|
39
|
+
else if ((key.leftArrow || key.rightArrow) && draft?.ignored) {
|
|
40
|
+
setDraft((p) => (p ? { ...p, ignored: p.ignored === 'keep' ? 'purge' : 'keep' } : p));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return { draft, open, onKey };
|
|
44
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// T134 — dove sta il plugin loom-works su questa macchina, e quale versione.
|
|
2
|
+
//
|
|
3
|
+
// Terza volta che il deck incontra l'assenza di `CLAUDE_PLUGIN_ROOT`: è una env
|
|
4
|
+
// che esiste solo dentro un processo Claude Code, e il deck è un binario npm
|
|
5
|
+
// spawnato da Ptyxis. Le prime due volte (età delle task archiviabili in
|
|
6
|
+
// `archivable.ts`, gate `--ignored-files` in `purge.ts`) hanno RIPLICATO un
|
|
7
|
+
// criterio di poche righe. Qui si risolve invece di duplicare, perché la
|
|
8
|
+
// superficie da replicare sarebbe troppo grande — la grammatica del marker
|
|
9
|
+
// inbox da un lato, le tre euristiche tarate dello scanner di wrap dall'altro —
|
|
10
|
+
// e una divergenza fra le due copie non si vedrebbe finché non conta i file
|
|
11
|
+
// sbagliati.
|
|
12
|
+
//
|
|
13
|
+
// L'allow `Read(~/.claude/plugins/cache/…/**)` che `init` scrive in
|
|
14
|
+
// `.claude/settings.json` non c'entra: è un permesso per gli agent di Claude
|
|
15
|
+
// Code. Il deck non passa dal motore dei permessi e non ha niente da farsi
|
|
16
|
+
// autorizzare — gli manca soltanto di sapere quale versione eseguire.
|
|
17
|
+
import { readdirSync } from 'node:fs';
|
|
18
|
+
import { homedir } from 'node:os';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
const MARKETPLACE_OWNER = 'lamemind';
|
|
21
|
+
const PLUGIN_NAME = 'loom-works';
|
|
22
|
+
/** Radice delle cache version-pinned del plugin. */
|
|
23
|
+
export function pluginCacheRoot() {
|
|
24
|
+
const env = process.env.LOOM_DECK_PLUGIN_CACHE_ROOT;
|
|
25
|
+
if (env)
|
|
26
|
+
return env;
|
|
27
|
+
return join(homedir(), '.claude', 'plugins', 'cache', MARKETPLACE_OWNER, PLUGIN_NAME);
|
|
28
|
+
}
|
|
29
|
+
const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
30
|
+
/**
|
|
31
|
+
* Confronto semver NUMERICO per componente: `-1` se `a` precede `b`, `1` se lo
|
|
32
|
+
* segue, `0` se pari.
|
|
33
|
+
*
|
|
34
|
+
* È il punto di tutto il modulo. In ordine lessicografico `10.1.3` precede
|
|
35
|
+
* `7.9.1` — `'1' < '7'` al primo carattere — quindi un `sort()` ingenuo su
|
|
36
|
+
* questa cartella sceglierebbe `9.0.0` fra le quaranta presenti, cioè una cache
|
|
37
|
+
* di due major fa, e lo farebbe senza dare alcun segno di sbagliare: gli script
|
|
38
|
+
* ci sono, girano, e rispondono col contratto di allora.
|
|
39
|
+
*/
|
|
40
|
+
export function compareSemver(a, b) {
|
|
41
|
+
const pa = SEMVER_RE.exec(a);
|
|
42
|
+
const pb = SEMVER_RE.exec(b);
|
|
43
|
+
if (!pa || !pb)
|
|
44
|
+
return 0;
|
|
45
|
+
for (let i = 1; i <= 3; i++) {
|
|
46
|
+
const diff = Number(pa[i]) - Number(pb[i]);
|
|
47
|
+
if (diff !== 0)
|
|
48
|
+
return diff < 0 ? -1 : 1;
|
|
49
|
+
}
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
/** La più alta fra le versioni passate; `null` se nessuna è un semver X.Y.Z. */
|
|
53
|
+
export function pickLatestVersion(names) {
|
|
54
|
+
let best = null;
|
|
55
|
+
for (const name of names) {
|
|
56
|
+
if (!SEMVER_RE.test(name))
|
|
57
|
+
continue;
|
|
58
|
+
if (best === null || compareSemver(name, best) > 0)
|
|
59
|
+
best = name;
|
|
60
|
+
}
|
|
61
|
+
return best;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Cache installata più recente, o `null` se il plugin non è installato su questa
|
|
65
|
+
* macchina (radice assente, o presente ma senza una sola cartella semver).
|
|
66
|
+
*
|
|
67
|
+
* `null` non è un errore da riportare: un deck su una macchina senza il plugin
|
|
68
|
+
* deve restare muto sugli indicatori che ne dipendono, non rompersi.
|
|
69
|
+
*/
|
|
70
|
+
export function resolvePluginCache() {
|
|
71
|
+
const root = pluginCacheRoot();
|
|
72
|
+
let names;
|
|
73
|
+
try {
|
|
74
|
+
names = readdirSync(root, { withFileTypes: true })
|
|
75
|
+
.filter((e) => e.isDirectory())
|
|
76
|
+
.map((e) => e.name);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
const version = pickLatestVersion(names);
|
|
82
|
+
if (version === null)
|
|
83
|
+
return null;
|
|
84
|
+
return { root: join(root, version), version };
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* La versione si risolve UNA VOLTA e resta quella per tutta la vita del
|
|
88
|
+
* processo. La cache cambia solo a un `plugin update`, quindi un deck aperto
|
|
89
|
+
* durante l'update continua a leggere la versione di prima fino al riavvio.
|
|
90
|
+
* È il limite accettato, e sta scritto qui invece di essere scoperto dopo.
|
|
91
|
+
*/
|
|
92
|
+
let resolved;
|
|
93
|
+
export function pluginCache() {
|
|
94
|
+
if (resolved === undefined)
|
|
95
|
+
resolved = resolvePluginCache();
|
|
96
|
+
return resolved;
|
|
97
|
+
}
|
|
98
|
+
/** Path assoluto di uno script del plugin, `null` se il plugin non c'è. */
|
|
99
|
+
export function pluginScript(relative) {
|
|
100
|
+
const cache = pluginCache();
|
|
101
|
+
return cache ? join(cache.root, relative) : null;
|
|
102
|
+
}
|
|
103
|
+
/** Solo per i test: dimentica la versione risolta al primo accesso. */
|
|
104
|
+
export function resetPluginCache() {
|
|
105
|
+
resolved = undefined;
|
|
106
|
+
}
|
package/dist/task-ops.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// Le operazioni su una TASK: crearla, editarla, potarla.
|
|
2
|
+
//
|
|
3
|
+
// T131 — nate insieme agli spawn dentro `cli.tsx` e separate da quelli qui,
|
|
4
|
+
// sulla linea di frattura che l'analisi dello split aveva indicato: ciò che
|
|
5
|
+
// apre una conversazione da una parte, ciò che ordina un lavoro sulla task list
|
|
6
|
+
// dall'altra. Il discriminante operativo è l'OGGETTO — una sessione contro una
|
|
7
|
+
// riga di `tasks.md` — e si vede dagli effetti: qui si scrive il task file, si
|
|
8
|
+
// committa, si delega a una skill headless.
|
|
9
|
+
//
|
|
10
|
+
// Il deck non rimuove e non riscrive mai la task list da sé: `createTask` e
|
|
11
|
+
// `purgeTasks` ordinano a `loom-works:create-task` e `loom-works:clean-tasks`,
|
|
12
|
+
// che quelle sequenze le implementano già una volta. Averne due significherebbe
|
|
13
|
+
// due creazioni e due rimozioni capaci di divergere.
|
|
14
|
+
//
|
|
15
|
+
// Le stringhe di `setNote` di questo file sono ASSERITE dal gate
|
|
16
|
+
// `test/modes-smoke.test.ts` (`eliminare N task?`, `nessun push`, `scartate`,
|
|
17
|
+
// `tasks.md`): si copiano verbatim, non si migliorano di passaggio.
|
|
18
|
+
import { randomUUID } from 'node:crypto';
|
|
19
|
+
import { loadTasks } from './tasks.js';
|
|
20
|
+
import { appendTaskBinding } from './task-index.js';
|
|
21
|
+
import { cut, sanitize } from './width.js';
|
|
22
|
+
import { idList } from './ui/modals.js';
|
|
23
|
+
import { purgeTargets, splitTargets } from './purge.js';
|
|
24
|
+
import { initialDetail, writeTaskEdit, PRI_GLYPH, PRI_LABEL } from './task-edit.js';
|
|
25
|
+
import { priName, progName } from './view.js';
|
|
26
|
+
import { commitTaskEdit, spawnCleanTasks, spawnCreateTask, CLAUDE_CMD, } from './spawn.js';
|
|
27
|
+
export function useTaskOps({ cwd, tasksPath, tasksDir, model, setNote, selectedTaskOr, }) {
|
|
28
|
+
// T30 — il taskId nasce DOPO create-task (lo assegna la skill scrivendo
|
|
29
|
+
// tasks.md) → non è noto allo spawn. Il sessionId invece è pinnato qui:
|
|
30
|
+
// snapshot degli id PRIMA, poi al completamento re-leggo tasks.md e il diff dà
|
|
31
|
+
// il nuovo id → appendTaskBinding lega la sessione (scoped).
|
|
32
|
+
function createTask(text) {
|
|
33
|
+
if (!text) {
|
|
34
|
+
setNote('C → create annullato (vuoto)');
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const sid = randomUUID();
|
|
38
|
+
const beforeIds = new Set(model.tasks.map((t) => t.id));
|
|
39
|
+
setNote(`⏳ creando task… "${cut(text, 40)}" (sid ${sid.slice(0, 8)})`);
|
|
40
|
+
const child = spawnCreateTask(text, cwd, sid, (ok) => {
|
|
41
|
+
if (!ok) {
|
|
42
|
+
setNote(`⚠ create-task fallito (${CLAUDE_CMD} -p)`);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
let newId;
|
|
46
|
+
try {
|
|
47
|
+
newId = loadTasks(tasksPath).find((t) => !beforeIds.has(t.id))?.id;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// tasks.md illeggibile → id non rilevato, sotto
|
|
51
|
+
}
|
|
52
|
+
if (newId) {
|
|
53
|
+
appendTaskBinding(cwd, sid, newId);
|
|
54
|
+
setNote(`✔ ${newId} creata · sessione scoped (sid ${sid.slice(0, 8)})`);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
setNote(`✔ task creata (id non rilevato) · sid ${sid.slice(0, 8)}`);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
child.on('error', () => setNote(`⚠ create-task: '${CLAUDE_CMD}' non lanciabile`));
|
|
61
|
+
}
|
|
62
|
+
// T41 — scrive tasks.md + task file, poi committa. Il commit è immediato e non
|
|
63
|
+
// confermato (scelta esplicita: l'edit è una micro-modifica, la storia
|
|
64
|
+
// granulare vale più di un batch). Se nessuno dei due lati è stato scritto non
|
|
65
|
+
// si committa nulla — `paths` vuoto renderebbe `git commit --` un commit di
|
|
66
|
+
// TUTTO il working tree, che è l'opposto di ciò che vogliamo.
|
|
67
|
+
function writeEdit(task, draft) {
|
|
68
|
+
// Il titolo si scrive solo se è CAMBIATO davvero: rimandarlo identico
|
|
69
|
+
// riscriverebbe comunque la cella (collassando spazi ed escape) e sporcherebbe
|
|
70
|
+
// il diff di una riga per un edit di sola priorità. Vuoto → scartato: una
|
|
71
|
+
// task senza descrizione in overview non è più riconoscibile.
|
|
72
|
+
const title = draft.title.trim();
|
|
73
|
+
const titleChanged = title.length > 0 && title !== task.rawDesc.trim();
|
|
74
|
+
let res;
|
|
75
|
+
try {
|
|
76
|
+
res = writeTaskEdit({
|
|
77
|
+
tasksPath,
|
|
78
|
+
tasksDir,
|
|
79
|
+
id: task.id,
|
|
80
|
+
pri: draft.pri,
|
|
81
|
+
prog: draft.prog,
|
|
82
|
+
detail: draft.detail,
|
|
83
|
+
title: titleChanged ? title : undefined,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
catch (e) {
|
|
87
|
+
setNote(`⚠ ${task.id}: scrittura fallita (${e.message})`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (res.paths.length === 0) {
|
|
91
|
+
setNote(`⚠ ${task.id}: nessun campo aggiornabile (riga o task file assenti)`);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const summary = `${PRI_GLYPH[draft.pri]} ${PRI_LABEL[draft.pri]} · ${res.progress}${titleChanged ? ` · "${cut(sanitize(title), 32)}"` : ''}`;
|
|
95
|
+
setNote(`⏳ ${task.id} → ${summary} · commit…`);
|
|
96
|
+
commitTaskEdit(cwd, res.paths, `chore(${task.id}): pri ${PRI_LABEL[draft.pri]} · stato ${res.progress}${titleChanged ? ' · titolo' : ''}`, (ok, err) => {
|
|
97
|
+
setNote(ok
|
|
98
|
+
? `✔ ${task.id} → ${summary} · committato`
|
|
99
|
+
: `⚠ ${task.id} salvato, commit fallito: ${err}`);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* La bozza di conferma per `CANC`, o `null` se non c'è niente da confermare
|
|
104
|
+
* (in tal caso la nota dice già perché).
|
|
105
|
+
*
|
|
106
|
+
* Il tasto è uno e il bersaglio ha due taglie: la task selezionata, o
|
|
107
|
+
* l'insieme intero della vista `archiviabili`. A discriminare è `purgeBulk` —
|
|
108
|
+
* la selezione prima della vista.
|
|
109
|
+
*
|
|
110
|
+
* Il bersaglio del bulk si legge da `paneTasks`, cioè dalla stessa fonte che
|
|
111
|
+
* disegna le righe e alimenta il contatore in header (D6): mai il `Set` grezzo
|
|
112
|
+
* di `archivable.ts`, mai un secondo filtro sullo stato. T100 ha fissato che
|
|
113
|
+
* ciò che si conta e ciò che si mostra siano lo stesso insieme per
|
|
114
|
+
* costruzione; qui l'invariante si estende a ciò che si pota.
|
|
115
|
+
*/
|
|
116
|
+
function purgeDraftFor() {
|
|
117
|
+
// La guardia di focus sta QUI e non solo dentro `selectedTaskOr`: il ramo
|
|
118
|
+
// bulk non passa da quella, perché il suo oggetto è la vista e non la
|
|
119
|
+
// selezione. Senza, `CANC` col focus sulle sessioni potrebbe potare in
|
|
120
|
+
// blocco senza che nessuna task fosse selezionata.
|
|
121
|
+
if (model.focus !== 'tasks') {
|
|
122
|
+
setNote('CANC → eliminare: seleziona una task (← per il pane)');
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
const bulk = model.purgeBulk;
|
|
126
|
+
const ids = bulk ? model.paneTasks.map((t) => t.id) : [];
|
|
127
|
+
if (!bulk) {
|
|
128
|
+
const task = selectedTaskOr('CANC', 'eliminare');
|
|
129
|
+
if (!task)
|
|
130
|
+
return null;
|
|
131
|
+
ids.push(task.id);
|
|
132
|
+
}
|
|
133
|
+
if (ids.length === 0) {
|
|
134
|
+
setNote('CANC → nessuna task in vista da eliminare');
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
// Ricalcolo al momento dell'AZIONE, non lettura del campionamento in lista:
|
|
138
|
+
// una folder sporcata dopo l'ultimo scan si presenterebbe come eliminabile.
|
|
139
|
+
const { clean, dirty } = splitTargets(purgeTargets(ids, tasksDir, cwd));
|
|
140
|
+
if (bulk) {
|
|
141
|
+
// Il gate del plugin esce 2 PRIMA di toccare qualsiasi cosa, quindi una
|
|
142
|
+
// sola folder sporca annullerebbe il purge di tutte le altre: si scartano
|
|
143
|
+
// a monte e il modale nomina sia le potate sia le scartate.
|
|
144
|
+
if (clean.length === 0) {
|
|
145
|
+
setNote(`CANC → ${dirty.length} task con file non tracciati in folder: eliminale una per una`);
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
ids: clean.map((t) => t.id),
|
|
150
|
+
skipped: dirty.map((t) => t.id),
|
|
151
|
+
bulk: true,
|
|
152
|
+
ignored: null,
|
|
153
|
+
survivors: 0,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
// Singola con superstiti → conferma a TRE uscite (D3): la scelta keep/purge
|
|
157
|
+
// è rara e distruttiva in modo diverso dal purge normale, e qui è visibile
|
|
158
|
+
// invece di stare davanti a ogni potatura.
|
|
159
|
+
const one = clean[0] ?? dirty[0];
|
|
160
|
+
return {
|
|
161
|
+
ids: [one.id],
|
|
162
|
+
skipped: [],
|
|
163
|
+
bulk: false,
|
|
164
|
+
ignored: one.survivors > 0 ? 'keep' : null,
|
|
165
|
+
survivors: one.survivors,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
// ⏎ nella conferma: ordina la potatura a `loom-works:clean-tasks` e la
|
|
169
|
+
// osserva. Il deck non rimuove niente da sé — nessun `git rm`, nessuna
|
|
170
|
+
// riscrittura di tasks.md, nessuna `unlink`: quella sequenza è già
|
|
171
|
+
// implementata una volta, e averne due significherebbe due rimozioni capaci
|
|
172
|
+
// di divergere.
|
|
173
|
+
//
|
|
174
|
+
// La lista si riallinea al primo tick del poll e il set delle archiviabili
|
|
175
|
+
// allo scan che `doneSig` fa scattare quando la popolazione Done cambia; la
|
|
176
|
+
// selezione, keyed su id, cade sulla prima riga della vista (effect di
|
|
177
|
+
// validità) invece che su una posizione residua.
|
|
178
|
+
function purgeTasks(draft) {
|
|
179
|
+
const sid = randomUUID();
|
|
180
|
+
setNote(`⏳ eliminando ${draft.ids.length} task… ${idList(draft.ids, 6)}`);
|
|
181
|
+
const child = spawnCleanTasks(draft.ids, cwd, sid, draft.ignored, (ok, detail) => {
|
|
182
|
+
// L'esito si misura su `tasks.md`, NON sul solo `is_error`. Misurato: col
|
|
183
|
+
// gate `--ignored-files` che blocca, la skill spiega il blocco e chiude
|
|
184
|
+
// comunque `is_error: false` — un successo dichiarato su zero rimozioni.
|
|
185
|
+
// Il segnale robusto è quali degli ID bersaglio non hanno più una riga:
|
|
186
|
+
// deterministico, e indipendente da come la skill racconta sé stessa.
|
|
187
|
+
let survived = draft.ids;
|
|
188
|
+
try {
|
|
189
|
+
const now = new Set(loadTasks(tasksPath).map((t) => t.id));
|
|
190
|
+
survived = draft.ids.filter((id) => now.has(id));
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
// tasks.md illeggibile → nessuna verifica possibile, e allora si dice
|
|
194
|
+
// che non è stata fatta invece di dedurre un esito.
|
|
195
|
+
setNote(`⚠ ${draft.ids.length} task: esito non verificabile (tasks.md illeggibile)`);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const removed = draft.ids.length - survived.length;
|
|
199
|
+
if (removed === draft.ids.length) {
|
|
200
|
+
// Il push non c'è, per scelta della skill: finché nessuno pusha, un
|
|
201
|
+
// altro worktree continua a vedere le task potate in tasks.md. Non
|
|
202
|
+
// dirlo lascerebbe leggere l'assenza di push come «fatto».
|
|
203
|
+
setNote(`✔ ${removed} task eliminate · commit locali, nessun push`);
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
// Il gate `--ignored-files` (exit 2) può scattare lo stesso: la
|
|
207
|
+
// dirtiness è un dato campionato e una folder può sporcarsi fra il
|
|
208
|
+
// ricalcolo e l'apply. Il testo del result event è l'unica cosa che dice
|
|
209
|
+
// PERCHÉ, quindi entra nella riga invece di un ⚠ muto.
|
|
210
|
+
setNote(`⚠ ${removed}/${draft.ids.length} eliminate · restano ${idList(survived, 4)} · ${cut(detail || (ok ? '' : `${CLAUDE_CMD} -p`), 56)}`);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
child?.on('error', () => setNote(`⚠ clean-tasks: '${CLAUDE_CMD}' non lanciabile`));
|
|
214
|
+
}
|
|
215
|
+
/** T41 — la bozza dell'edit seminata dai valori ATTUALI della task. */
|
|
216
|
+
function editDraftFor(task) {
|
|
217
|
+
// La priorità arriva dal glifo di tasks.md (già in `selTask`), lo stato dal
|
|
218
|
+
// suo glifo Prog; il progresso arbitrario dal campo `Progress` del task file
|
|
219
|
+
// — ma solo se è davvero custom (vedi `initialDetail`).
|
|
220
|
+
//
|
|
221
|
+
// Il titolo si semina dalla riga di tasks.md e non dall'H1 del task file per
|
|
222
|
+
// due ragioni: è la fonte che esiste SEMPRE (un task file può mancare), ed è
|
|
223
|
+
// il testo che l'utente sta guardando in lista quando preme `E`. Grezzo
|
|
224
|
+
// (`rawDesc`), non sanificato: rimandare a disco la forma sanificata
|
|
225
|
+
// riscriverebbe i glifi anche senza toccare il campo.
|
|
226
|
+
const prog = progName(task.prog) ?? 'todo';
|
|
227
|
+
return {
|
|
228
|
+
pri: priName(task.pri) ?? 'med',
|
|
229
|
+
prog,
|
|
230
|
+
detail: initialDetail(model.detail?.fields['Progress'] ?? '', prog),
|
|
231
|
+
title: task.rawDesc,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
return { createTask, editDraftFor, writeEdit, purgeDraftFor, purgeTasks };
|
|
235
|
+
}
|