@lamemind/loom-deck 0.67.0 → 0.68.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 +26 -10
- package/dist/actions.js +79 -33
- package/dist/cli.js +51 -6
- package/dist/deck-model.js +3 -3
- package/dist/frame.js +4 -0
- package/dist/glyphs.js +5 -5
- package/dist/hooks.js +36 -0
- package/dist/inbox.js +6 -12
- package/dist/input-modes.js +1 -0
- package/dist/input.js +16 -0
- package/dist/overlays/sheet.js +24 -23
- package/dist/overlays/spawn.js +240 -0
- package/dist/overlays/status.js +2 -2
- package/dist/overlays/wrap.js +2 -2
- package/dist/prompt-catalog.js +1 -1
- package/dist/sane-note.js +115 -0
- package/dist/spawn-catalog.js +338 -0
- package/dist/spawn-config.js +280 -0
- package/dist/spawn.js +46 -157
- package/dist/tasks.js +21 -1
- package/dist/ui/detail-screen.js +1 -1
- package/dist/ui/preview.js +1 -1
- package/dist/ui/screens.js +12 -1
- package/dist/ui/spawn-screen.js +99 -0
- package/dist/viewport.js +35 -0
- package/dist/wrap-scan.js +14 -33
- package/package.json +1 -1
- package/scripts/deck-run +26 -10
- package/scripts/prompt-catalog +17 -3
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
// Il CATALOGO delle azioni di spawn: per ogni cosa che il deck sa aprire, un id
|
|
2
|
+
// stabile e la terna titolo/modello/prompt con la sorgente di ciascun valore.
|
|
3
|
+
//
|
|
4
|
+
// Prima di questo modulo un'azione di spawn non era un'entità: era un tasto che
|
|
5
|
+
// chiamava una funzione, e ogni funzione decideva da sé — nel proprio punto di
|
|
6
|
+
// chiamata — quale modello e quale prompt passare. Il modello veniva da quattro
|
|
7
|
+
// sorgenti diverse (il catalogo `prompt-catalog` per kind, il transcript per
|
|
8
|
+
// resume e fork, un selettore vivo per la nuda, un letterale in `actions.ts` per
|
|
9
|
+
// drain e srotolamento) e non esisteva nessun posto in cui leggerne l'elenco.
|
|
10
|
+
// Senza quell'elenco non c'è nessuna tabella da mostrare, e nessuna riga che un
|
|
11
|
+
// override di progetto possa indirizzare.
|
|
12
|
+
//
|
|
13
|
+
// DATO PURO: nessuna lettura di file, nessun React, nessun import di
|
|
14
|
+
// `spawn.ts`. La dipendenza va in un verso solo (coding-standards: nessun modulo
|
|
15
|
+
// estratto importa il file da cui è stato estratto) — `spawn.ts`, `actions.ts`,
|
|
16
|
+
// `sheet.ts`, `glyphs.ts` e `prompt-catalog.ts` importano da qui.
|
|
17
|
+
//
|
|
18
|
+
// Chi COMPONE i default con gli override di progetto è `spawn-config.ts`: lì
|
|
19
|
+
// vivono la lettura del file, la validazione al confine e la risoluzione della
|
|
20
|
+
// terna. Qui ci sono solo i default del deck.
|
|
21
|
+
/**
|
|
22
|
+
* `recap` → la sotto-skill giusta, quando chi spawna SA se la task è un cappello.
|
|
23
|
+
*
|
|
24
|
+
* `recap` resta il kind onesto per chi non lo sa: punta al dispatcher, che
|
|
25
|
+
* risolve la task e classifica da sé. È il caso degli acceleratori della lista,
|
|
26
|
+
* dove il deck ha in mano solo `tasks.md` — e lì il `Size` non c'è. Il DETAIL
|
|
27
|
+
* invece il task file l'ha già letto, quindi può saltare il giro e pagare un
|
|
28
|
+
* turno di modello in meno.
|
|
29
|
+
*
|
|
30
|
+
* Non è una classificazione duplicata: il criterio (`Size: Epic`) resta uno solo
|
|
31
|
+
* e sta in `taskIsEpic`, che legge lo stesso campo che leggerebbe il dispatcher.
|
|
32
|
+
* Quello che si evita è il RITARDO, non il giudizio.
|
|
33
|
+
*
|
|
34
|
+
* T161 — la stessa funzione è anche il ponte dal detail al catalogo: gli id
|
|
35
|
+
* delle sette azioni su task SONO i sette kind, quindi specializzare un kind
|
|
36
|
+
* significa già scegliere la riga di catalogo (e l'override che la indirizza).
|
|
37
|
+
*
|
|
38
|
+
* Ogni kind diverso da `recap` passa intatto: la specializzazione è un caso, non
|
|
39
|
+
* una trasformazione da applicare a tutti.
|
|
40
|
+
*/
|
|
41
|
+
export function specializeRecap(kind, epic) {
|
|
42
|
+
if (kind !== 'recap')
|
|
43
|
+
return kind;
|
|
44
|
+
return epic ? 'recap-epic' : 'recap-task';
|
|
45
|
+
}
|
|
46
|
+
// L'ordine È il giro di `tab` nel detail, non una preferenza di lettura:
|
|
47
|
+
// cambiarlo sposta le voci sotto le dita di chi le ha imparate. Fino a T111 era
|
|
48
|
+
// anche il binding delle cifre `1`-`4`, passate poi al campo nota.
|
|
49
|
+
export const MODELS = ['fable', 'opus', 'sonnet', 'haiku'];
|
|
50
|
+
/** Il valore è uno dei quattro alias? Guardia del confine: la usano il parser
|
|
51
|
+
* del catalogo dati e quello del blocco `spawn` del file di progetto, che
|
|
52
|
+
* leggono entrambi testo scritto a mano. */
|
|
53
|
+
export function isModelKind(v) {
|
|
54
|
+
return typeof v === 'string' && MODELS.includes(v);
|
|
55
|
+
}
|
|
56
|
+
// Default del selettore e FALLBACK ultimo di ogni risoluzione. Duplicato del
|
|
57
|
+
// default di deck-run e non letto da lì: il deck deve poter MOSTRARE la
|
|
58
|
+
// selezione iniziale prima di spawnare alcunché, e un valore che si conosce solo
|
|
59
|
+
// a spawn avvenuto non è mostrabile.
|
|
60
|
+
//
|
|
61
|
+
// T161 — non è più il default di nessuna azione in particolare: ogni riga del
|
|
62
|
+
// catalogo porta il proprio (o lo prende dal catalogo dati), e qui si cade solo
|
|
63
|
+
// quando nessuna delle due sorgenti ha una voce.
|
|
64
|
+
export const MODEL_DEFAULT = 'fable';
|
|
65
|
+
// T152 — `recap-status-project` non è una chiave di `prompt-catalog` (le chiavi
|
|
66
|
+
// lì sono i valori di `--prompt-kind`, e lo spawn headless non ne passa mai
|
|
67
|
+
// uno): il suo default vive nella riga `project-status` di questo catalogo.
|
|
68
|
+
// Resta `opus` mentre i tre recap stanno su fable, ed è l'unico recap che gira
|
|
69
|
+
// in headless (`-p`), senza nessuno che ne legga l'esito mentre si forma e possa
|
|
70
|
+
// rilanciarlo — il testo prodotto è il deliverable, e viene riletto da disco
|
|
71
|
+
// anche giorni dopo.
|
|
72
|
+
export const PROJECT_STATUS_MODEL = 'opus';
|
|
73
|
+
// T66 — le azioni del detail. Non sono un catalogo a parte: ognuna nomina una
|
|
74
|
+
// riga di questo catalogo, per kind, e tutte passano dallo stesso `spawnForTask`
|
|
75
|
+
// dei CTRL della lista — una superficie in più, zero percorsi di spawn in più.
|
|
76
|
+
//
|
|
77
|
+
// L'etichetta è distinta dal kind dove il kind è il nome del MECCANISMO e
|
|
78
|
+
// l'etichetta quello dell'INTENZIONE: `none` è "aprire la task a mani nude",
|
|
79
|
+
// `recap` è "vedere a che punto sta".
|
|
80
|
+
export const DETAIL_ACTIONS = [
|
|
81
|
+
{ kind: 'none', label: 'open' },
|
|
82
|
+
{ kind: 'preflight', label: 'preflight' },
|
|
83
|
+
{ kind: 'run', label: 'run' },
|
|
84
|
+
{ kind: 'recap', label: 'status' },
|
|
85
|
+
{ kind: 'checkpoint', label: 'checkpoint' },
|
|
86
|
+
];
|
|
87
|
+
// T117 — selezione DIRETTA di un'azione con la sua iniziale (`o p r s c`),
|
|
88
|
+
// accanto allo scorrimento `←→`. Derivate dalle label e non cablate: una voce
|
|
89
|
+
// aggiunta al catalogo porta con sé la propria lettera, invece di lasciare
|
|
90
|
+
// indietro una seconda lista.
|
|
91
|
+
// Il vincolo che la derivazione impone al catalogo: le iniziali devono restare
|
|
92
|
+
// DISTINTE fra loro. Due label con la stessa lettera renderebbero la seconda
|
|
93
|
+
// irraggiungibile, e in silenzio — chi aggiunge una voce lo controlla qui.
|
|
94
|
+
export const ACTION_HOTKEYS = Object.fromEntries(DETAIL_ACTIONS.map((a, i) => [a.label[0], i]));
|
|
95
|
+
export const SPAWN_FIELDS = ['title', 'model', 'prompt'];
|
|
96
|
+
/**
|
|
97
|
+
* Il prompt cablato della sessione che srotola l'hard-wrap di un path.
|
|
98
|
+
*
|
|
99
|
+
* Le tre cose che dice sono le tre che rendono l'operazione reversibile e
|
|
100
|
+
* verificabile — backup fuori dal repo, rapporto, commit solo se il diff è
|
|
101
|
+
* chiaramente innocuo. Era cablato in `wrap-scan.ts` e da T161 è il DEFAULT di
|
|
102
|
+
* una riga del catalogo: resta la stessa frase, ma ora è sovrascrivibile dal
|
|
103
|
+
* progetto invece che dal solo sorgente.
|
|
104
|
+
*
|
|
105
|
+
* Nessun apice singolo, come i template di `prompt-catalog`: il testo finisce
|
|
106
|
+
* dentro `'…'` nel comando passato a `bash -lc`, e `deck-run` lo quota, ma un
|
|
107
|
+
* apice qui sarebbe comunque una scrittura da rileggere due volte.
|
|
108
|
+
*/
|
|
109
|
+
const UNWRAP_PROMPT = 'lancia md-wrap modo apply su {path} e backup in cartella tmp dedicata. ' +
|
|
110
|
+
'verifica risultato e fai rapporto. se le modifiche sono tutte chiaramente safe, ' +
|
|
111
|
+
'puoi committare direttamente';
|
|
112
|
+
/**
|
|
113
|
+
* Le UNDICI azioni configurabili, nell'ordine in cui la pagina le elenca.
|
|
114
|
+
*
|
|
115
|
+
* Il perimetro è quello di D1: entra una riga se ha almeno una cella editabile.
|
|
116
|
+
* Ne restano fuori resume e fork — lì il modello non è una preferenza ma
|
|
117
|
+
* un'EREDITÀ, letta dal transcript della conversazione d'origine, e un override
|
|
118
|
+
* sarebbe una cella che mente — insieme a `create-task` e `clean-tasks`, che non
|
|
119
|
+
* passano `--model` e girano sul default del CLI (dargliene una sarebbe un
|
|
120
|
+
* cambio di comportamento fatto per riempire una riga), al terminale e alle voci
|
|
121
|
+
* `launch`, che non sono sessioni Claude e non hanno né modello né prompt.
|
|
122
|
+
*
|
|
123
|
+
* T156 — le emoji dei titoli sono ASTRALI (code point ≥ U+10000) per necessità,
|
|
124
|
+
* non per gusto: `sanitize` (`src/width.ts`) sostituisce con `·` ogni glifo su
|
|
125
|
+
* cui le due contabilità di larghezza divergono, e le emoji del BMP a
|
|
126
|
+
* presentazione testo divergono sempre. Le stesse sei stanno nella whitelist di
|
|
127
|
+
* `_sane_note` in `deck-run` e nel suo gemello `src/sane-note.ts`: un'emoji
|
|
128
|
+
* cambiata qui e non là sparisce fra la tabella e la tab, senza errore.
|
|
129
|
+
*/
|
|
130
|
+
export const SPAWN_ACTIONS = [
|
|
131
|
+
{
|
|
132
|
+
id: 'none',
|
|
133
|
+
label: 'apri la task',
|
|
134
|
+
surface: 'detail o · ⏎⏎',
|
|
135
|
+
kind: 'none',
|
|
136
|
+
// D2 di T150 — nessun prefisso: `open` è "nessuna azione", e inventargliene
|
|
137
|
+
// uno contraddirebbe l'intenzione di chi la sceglie.
|
|
138
|
+
title: '{slug}',
|
|
139
|
+
model: null,
|
|
140
|
+
prompt: '',
|
|
141
|
+
holes: ['TASK', 'slug'],
|
|
142
|
+
fixed: {},
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
id: 'recap',
|
|
146
|
+
label: 'stato · dispatcher',
|
|
147
|
+
surface: 'lista ^K',
|
|
148
|
+
kind: 'recap',
|
|
149
|
+
// P3 di T150 — le tre varianti di recap condividono il prefisso: la
|
|
150
|
+
// specializzazione sceglie quale skill parte, non cosa si sta chiedendo.
|
|
151
|
+
title: '📊 {slug}',
|
|
152
|
+
model: null,
|
|
153
|
+
prompt: null,
|
|
154
|
+
holes: ['TASK', 'slug'],
|
|
155
|
+
fixed: {},
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
id: 'recap-task',
|
|
159
|
+
label: 'stato task',
|
|
160
|
+
surface: 'detail s',
|
|
161
|
+
kind: 'recap-task',
|
|
162
|
+
title: '📊 {slug}',
|
|
163
|
+
model: null,
|
|
164
|
+
prompt: null,
|
|
165
|
+
holes: ['TASK', 'slug'],
|
|
166
|
+
fixed: {},
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
id: 'recap-epic',
|
|
170
|
+
label: 'stato epica',
|
|
171
|
+
surface: 'detail s · cappello',
|
|
172
|
+
kind: 'recap-epic',
|
|
173
|
+
title: '📊 {slug}',
|
|
174
|
+
model: null,
|
|
175
|
+
prompt: null,
|
|
176
|
+
holes: ['TASK', 'slug'],
|
|
177
|
+
fixed: {},
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
id: 'preflight',
|
|
181
|
+
label: 'preflight',
|
|
182
|
+
surface: 'detail p · lista ^P',
|
|
183
|
+
kind: 'preflight',
|
|
184
|
+
title: '📐 {slug}',
|
|
185
|
+
model: null,
|
|
186
|
+
prompt: null,
|
|
187
|
+
holes: ['TASK', 'slug'],
|
|
188
|
+
fixed: {},
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
id: 'run',
|
|
192
|
+
label: 'run',
|
|
193
|
+
surface: 'detail r · lista ^R',
|
|
194
|
+
kind: 'run',
|
|
195
|
+
title: '🚀 {slug}',
|
|
196
|
+
model: null,
|
|
197
|
+
prompt: null,
|
|
198
|
+
holes: ['TASK', 'slug'],
|
|
199
|
+
fixed: {},
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
id: 'checkpoint',
|
|
203
|
+
label: 'checkpoint',
|
|
204
|
+
surface: 'detail c',
|
|
205
|
+
kind: 'checkpoint',
|
|
206
|
+
title: '🏁 {slug}',
|
|
207
|
+
model: null,
|
|
208
|
+
prompt: null,
|
|
209
|
+
holes: ['TASK', 'slug'],
|
|
210
|
+
fixed: {},
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
// T42/T154 — la sessione a mani nude. Configurabile sul solo modello (P12):
|
|
214
|
+
// non pinna un `sessionId` e non scrive nel sidecar, quindi un titolo
|
|
215
|
+
// comparirebbe nella tab e non in lista — la divergenza che `bareArgs`
|
|
216
|
+
// esiste per non avere — e un prompt la renderebbe non nuda.
|
|
217
|
+
id: 'bare',
|
|
218
|
+
label: 'sessione nuda',
|
|
219
|
+
surface: 'tasto c',
|
|
220
|
+
kind: null,
|
|
221
|
+
title: null,
|
|
222
|
+
model: MODEL_DEFAULT,
|
|
223
|
+
prompt: '',
|
|
224
|
+
holes: [],
|
|
225
|
+
fixed: {
|
|
226
|
+
title: 'nessun sessionId pinnato',
|
|
227
|
+
prompt: 'la renderebbe non nuda',
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
// T134 — il drain di un file inbox. Il prompt lo compone `inboxPrompt`
|
|
232
|
+
// (`src/inbox.ts`), che è l'unico posto in cui vive la mappa natura →
|
|
233
|
+
// skill: un template unico non saprebbe esprimerla, e spezzare la riga in
|
|
234
|
+
// una per natura contraddirebbe il perimetro di D1.
|
|
235
|
+
id: 'drain',
|
|
236
|
+
label: 'drain di un file inbox',
|
|
237
|
+
surface: 'pane inbox ⏎⏎',
|
|
238
|
+
kind: null,
|
|
239
|
+
title: '🧹 {file}',
|
|
240
|
+
model: MODEL_DEFAULT,
|
|
241
|
+
prompt: null,
|
|
242
|
+
holes: ['file'],
|
|
243
|
+
fixed: { prompt: 'natura del file' },
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
// T134 — lo srotolamento dell'hard-wrap. `sonnet` e non il default: è una
|
|
247
|
+
// passata meccanica con un verificatore deterministico dietro (`md-wrap
|
|
248
|
+
// --apply` confronta le due versioni normalizzate e rimette indietro il
|
|
249
|
+
// file se differiscono), quindi il giudizio chiesto al modello è leggere un
|
|
250
|
+
// diff e decidere se committarlo — non progettare niente.
|
|
251
|
+
id: 'unwrap',
|
|
252
|
+
label: 'srotolamento hard-wrap',
|
|
253
|
+
surface: 'lista ^W ⏎',
|
|
254
|
+
kind: null,
|
|
255
|
+
title: '📏 {path}',
|
|
256
|
+
model: 'sonnet',
|
|
257
|
+
prompt: UNWRAP_PROMPT,
|
|
258
|
+
holes: ['path'],
|
|
259
|
+
fixed: {},
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
// T121 — il recap di progetto, headless (`claude -p`): nessuna tab, quindi
|
|
263
|
+
// nessun titolo da configurare.
|
|
264
|
+
id: 'project-status',
|
|
265
|
+
label: 'project status',
|
|
266
|
+
surface: 'tasto ^G',
|
|
267
|
+
kind: null,
|
|
268
|
+
title: null,
|
|
269
|
+
model: PROJECT_STATUS_MODEL,
|
|
270
|
+
prompt: '/loom-works:recap-status-project',
|
|
271
|
+
holes: [],
|
|
272
|
+
fixed: { title: 'headless: nessuna tab' },
|
|
273
|
+
},
|
|
274
|
+
];
|
|
275
|
+
/**
|
|
276
|
+
* Valori d'esempio dei buchi, per l'ANTEPRIMA dell'area di compilazione.
|
|
277
|
+
*
|
|
278
|
+
* La cella della tabella mostra il template grezzo (`🚀 {slug}`), l'area mostra
|
|
279
|
+
* come verrebbe reso: senza l'esempio un template si scrive alla cieca, e
|
|
280
|
+
* l'effetto si scopre alla prima conversazione aperta.
|
|
281
|
+
*
|
|
282
|
+
* Sono valori FINTI e dichiarati tali dalla pagina, non la task selezionata: la
|
|
283
|
+
* pagina non ha una selezione — si apre da qualunque punto del deck — e legare
|
|
284
|
+
* l'anteprima a ciò che capita di avere sotto il caret la renderebbe diversa a
|
|
285
|
+
* ogni apertura, per la stessa configurazione.
|
|
286
|
+
*/
|
|
287
|
+
export const EXAMPLE_HOLES = {
|
|
288
|
+
TASK: 'T42',
|
|
289
|
+
slug: 'esempio di task',
|
|
290
|
+
file: 'T42-nozioni',
|
|
291
|
+
path: 'runtime reference',
|
|
292
|
+
};
|
|
293
|
+
/**
|
|
294
|
+
* I valori sono quelli che i FORNITORI dei buchi producono davvero, non testo
|
|
295
|
+
* grezzo: lo slug arriva dal nome del task file coi trattini già sciolti in
|
|
296
|
+
* spazi, il basename dell'inbox senza `.md`, il path già reso in parole. Un
|
|
297
|
+
* esempio con dentro un `/` o un `.` mostrerebbe l'effetto della riduzione — le
|
|
298
|
+
* parole saldate — su un caso che allo spawn non si presenta mai, e chi lo
|
|
299
|
+
* legge correggerebbe un titolo che non è rotto.
|
|
300
|
+
*/
|
|
301
|
+
const BY_ID = new Map(SPAWN_ACTIONS.map((a) => [a.id, a]));
|
|
302
|
+
/** L'azione di un id, o `undefined` per un id che il catalogo non conosce — il
|
|
303
|
+
* caso di un override scritto a mano per un'azione che non esiste più. */
|
|
304
|
+
export function spawnAction(id) {
|
|
305
|
+
return BY_ID.get(id);
|
|
306
|
+
}
|
|
307
|
+
/** L'id è nel catalogo? Guardia del parser degli override. */
|
|
308
|
+
export function isSpawnActionId(id) {
|
|
309
|
+
return typeof id === 'string' && BY_ID.has(id);
|
|
310
|
+
}
|
|
311
|
+
/** Le celle editabili di una riga: il complemento di `fixed`, derivato e mai
|
|
312
|
+
* scritto a mano. */
|
|
313
|
+
export function editableFields(action) {
|
|
314
|
+
return SPAWN_FIELDS.filter((f) => !(f in action.fixed));
|
|
315
|
+
}
|
|
316
|
+
/** Il segnaposto di una cella editabile ma a vuoto. Il vuoto è un valore
|
|
317
|
+
* legittimo e distinto dall'assenza (P15): un titolo svuotato significa
|
|
318
|
+
* «nessuna nota», un prompt svuotato «nessun prompt». */
|
|
319
|
+
const EMPTY_LABEL = {
|
|
320
|
+
title: '(nessun titolo)',
|
|
321
|
+
model: '(nessun modello)',
|
|
322
|
+
prompt: '(nessun prompt)',
|
|
323
|
+
};
|
|
324
|
+
export function spawnCell(action, field,
|
|
325
|
+
/** Il valore risolto: `null` quando la cella non ne ha uno. */
|
|
326
|
+
value,
|
|
327
|
+
/** Il valore viene da un override di progetto. */
|
|
328
|
+
overridden) {
|
|
329
|
+
const reason = action.fixed[field];
|
|
330
|
+
if (reason !== undefined) {
|
|
331
|
+
return { text: `(${reason})`, editable: false, origin: 'fisso', placeholder: true };
|
|
332
|
+
}
|
|
333
|
+
const origin = overridden ? 'override' : 'default';
|
|
334
|
+
if (value === null || value === '') {
|
|
335
|
+
return { text: EMPTY_LABEL[field], editable: true, origin, placeholder: true };
|
|
336
|
+
}
|
|
337
|
+
return { text: value, editable: true, origin, placeholder: false };
|
|
338
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// T161 — gli OVERRIDE di progetto delle azioni di spawn: lettura, validazione,
|
|
2
|
+
// risoluzione della terna, scrittura.
|
|
3
|
+
//
|
|
4
|
+
// ── La sede (P19 preflight) ─────────────────────────────────────────────────
|
|
5
|
+
// Blocco `spawn` dentro `.claude/loom-works.json`, il file di progetto che il
|
|
6
|
+
// deck già apre in tre loader (`launch`, `archivableDays`, `identity`) e che
|
|
7
|
+
// `deck-run` legge con `jq`. Nessun file nuovo: sotto `.claude/` ci sono già tre
|
|
8
|
+
// file di loom, e le impostazioni di spawn sono decisioni di progetto che devono
|
|
9
|
+
// viaggiare col repo — cioè committate, esattamente come `permissionMode` e
|
|
10
|
+
// `order`, e a differenza della vista (`.claude/loom/deck-view.json`, che è
|
|
11
|
+
// gitignored perché è una preferenza di questa macchina).
|
|
12
|
+
//
|
|
13
|
+
// Il costo della scelta, dichiarato: da qui in poi il deck SCRIVE in
|
|
14
|
+
// `loom-works.json`, cosa che prima non faceva mai. Il saver è un
|
|
15
|
+
// read-modify-write che conserva ogni altra chiave nel suo ordine e tocca il
|
|
16
|
+
// solo blocco `spawn`, e RIFIUTA di scrivere se il file non si parsa — quel file
|
|
17
|
+
// regge identità, label e registry di compass, e rigenerarlo da zero al posto di
|
|
18
|
+
// uno rotto significherebbe perderli.
|
|
19
|
+
//
|
|
20
|
+
// Lo schema del plugin (`templates/loom-works.schema.json`) dichiara
|
|
21
|
+
// `additionalProperties: false` e il blocco `spawn` non vi compare ancora: non è
|
|
22
|
+
// un errore a runtime, perché `cfg_validate` in `lib-config.sh` è jq scritto a
|
|
23
|
+
// mano sui soli campi noti e un campo sconosciuto passa. Lo schema è reference
|
|
24
|
+
// per chi scrive il file a mano, e la voce `"spawn": { "type": "object" }` lo
|
|
25
|
+
// raggiunge al prossimo publish del plugin (Prod Validation della task).
|
|
26
|
+
//
|
|
27
|
+
// ── Il formato ──────────────────────────────────────────────────────────────
|
|
28
|
+
// "spawn": { "<id azione>": { "title"?: string, "model"?: alias, "prompt"?: string } }
|
|
29
|
+
//
|
|
30
|
+
// SOLO override: una chiave assente significa «il default del deck», e il blocco
|
|
31
|
+
// sparisce dal file quando non resta nessun override. Vuoto ≠ assente (P15): un
|
|
32
|
+
// `"title": ""` è un override legittimo che dice «nessuna nota».
|
|
33
|
+
//
|
|
34
|
+
// ── Chi legge cosa ──────────────────────────────────────────────────────────
|
|
35
|
+
// Il blocco lo legge il SOLO deck, che risolve la terna e la passa a `deck-run`
|
|
36
|
+
// sempre esplicita nell'argv (`--title-note`, `--model`, `--prompt`). `deck-run`
|
|
37
|
+
// non lo legge e non deve: `jq` è dipendenza OPZIONALE di quello script, e
|
|
38
|
+
// dargli un blocco JSON da consultare la renderebbe obbligatoria su un percorso
|
|
39
|
+
// che oggi degrada senza. La cascata env → catalogo → `fable` di `deck-run`
|
|
40
|
+
// resta intatta per chi lo invoca a mano.
|
|
41
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
42
|
+
import { configFilePath } from './config.js';
|
|
43
|
+
import { modelFor } from './prompt-catalog.js';
|
|
44
|
+
import { interpolate, saneNote, saneTemplate } from './sane-note.js';
|
|
45
|
+
import { MODEL_DEFAULT, SPAWN_ACTIONS, SPAWN_FIELDS, editableFields, isModelKind, spawnAction, } from './spawn-catalog.js';
|
|
46
|
+
/** La chiave del blocco nel file di progetto. Costante e non letterale sparso:
|
|
47
|
+
* la nominano il parser, il saver e il messaggio d'avviso. */
|
|
48
|
+
export const SPAWN_BLOCK = 'spawn';
|
|
49
|
+
/**
|
|
50
|
+
* Validazione per VOCE, non per file (P7).
|
|
51
|
+
*
|
|
52
|
+
* La voce sbagliata cade con una nota che la nomina, il resto vive. Rifiutare
|
|
53
|
+
* l'intero blocco alla prima chiave sporca significherebbe che un refuso su una
|
|
54
|
+
* riga spegne in silenzio gli override di tutte le altre; e conservare per
|
|
55
|
+
* sempre le voci ignote significherebbe riscriverle a ogni salvataggio, cioè
|
|
56
|
+
* tenere in vita nel file una configurazione che nessuno legge più. È lo stesso
|
|
57
|
+
* regime di `sanitizeSort` (`view-store.ts`) e di `parseLaunch`.
|
|
58
|
+
*
|
|
59
|
+
* Cosa cade e cosa no:
|
|
60
|
+
* - id fuori catalogo → l'intera voce, con nota (un'azione che non esiste più);
|
|
61
|
+
* - modello fuori dai quattro alias → il solo campo, con nota (arriverebbe al
|
|
62
|
+
* CLI e produrrebbe una tab con un comando che fallisce all'avvio);
|
|
63
|
+
* - campo su una cella FISSA → il solo campo, con nota (il valore non avrebbe
|
|
64
|
+
* nessuna strada per arrivare allo spawn);
|
|
65
|
+
* - titolo → ridotto, mai scartato: quello che si legge nella pagina è quello
|
|
66
|
+
* che comparirà nella tab, anche quando il file è stato scritto a mano;
|
|
67
|
+
* - prompt → qualunque testo, che viaggia come argv singolo.
|
|
68
|
+
*/
|
|
69
|
+
export function parseSpawnOverrides(raw) {
|
|
70
|
+
const entries = new Map();
|
|
71
|
+
const warnings = [];
|
|
72
|
+
if (!raw || typeof raw !== 'object')
|
|
73
|
+
return { entries, warnings };
|
|
74
|
+
const block = raw[SPAWN_BLOCK];
|
|
75
|
+
if (block === undefined)
|
|
76
|
+
return { entries, warnings };
|
|
77
|
+
if (!block || typeof block !== 'object' || Array.isArray(block)) {
|
|
78
|
+
warnings.push(`blocco ${SPAWN_BLOCK} ignorato: non è un oggetto`);
|
|
79
|
+
return { entries, warnings };
|
|
80
|
+
}
|
|
81
|
+
for (const [id, value] of Object.entries(block)) {
|
|
82
|
+
const action = spawnAction(id);
|
|
83
|
+
if (!action) {
|
|
84
|
+
warnings.push(`${SPAWN_BLOCK}.${id}: azione fuori catalogo, voce scartata`);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
88
|
+
warnings.push(`${SPAWN_BLOCK}.${id}: non è un oggetto, voce scartata`);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const src = value;
|
|
92
|
+
const editable = new Set(editableFields(action));
|
|
93
|
+
const out = {};
|
|
94
|
+
for (const field of SPAWN_FIELDS) {
|
|
95
|
+
const v = src[field];
|
|
96
|
+
if (v === undefined)
|
|
97
|
+
continue;
|
|
98
|
+
if (!editable.has(field)) {
|
|
99
|
+
warnings.push(`${SPAWN_BLOCK}.${id}.${field}: ${action.fixed[field]}, campo scartato`);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (field === 'model') {
|
|
103
|
+
if (!isModelKind(v)) {
|
|
104
|
+
warnings.push(`${SPAWN_BLOCK}.${id}.model: '${String(v)}' non è un modello, campo scartato`);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
out.model = v;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (typeof v !== 'string') {
|
|
111
|
+
warnings.push(`${SPAWN_BLOCK}.${id}.${field}: non è un testo, campo scartato`);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (field === 'title')
|
|
115
|
+
out.title = saneTemplate(v);
|
|
116
|
+
else
|
|
117
|
+
out.prompt = v;
|
|
118
|
+
}
|
|
119
|
+
// Una voce rimasta senza nessun campo valido non entra: nella pagina
|
|
120
|
+
// marcherebbe la riga come «toccata» senza che niente sia stato cambiato.
|
|
121
|
+
if (Object.keys(out).length > 0)
|
|
122
|
+
entries.set(action.id, out);
|
|
123
|
+
}
|
|
124
|
+
return { entries, warnings };
|
|
125
|
+
}
|
|
126
|
+
/** File assente → nessun override e NESSUN avviso: è lo stato normale di un
|
|
127
|
+
* progetto che non ha mai aperto la pagina, come per `loadLaunch` e
|
|
128
|
+
* `loadArchivableDays`. Illeggibile o non-JSON → default con avviso: lì c'è un
|
|
129
|
+
* file, e sta dicendo qualcosa che non si riesce a leggere. */
|
|
130
|
+
export function loadSpawnOverrides(projectRoot) {
|
|
131
|
+
let text;
|
|
132
|
+
try {
|
|
133
|
+
text = readFileSync(configFilePath(projectRoot), 'utf8');
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return { entries: new Map(), warnings: [] };
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
return parseSpawnOverrides(JSON.parse(text));
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return {
|
|
143
|
+
entries: new Map(),
|
|
144
|
+
warnings: [`${configFilePath(projectRoot)} non si parsa: override di spawn ignorati`],
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Default del deck ← override di progetto, più l'interpolazione dei buchi.
|
|
150
|
+
*
|
|
151
|
+
* La precedenza è a due soli livelli, per tutta la task: i valori cablati nel
|
|
152
|
+
* sorgente sono i default, l'unico livello di override è il progetto. Nessuna
|
|
153
|
+
* configurazione d'utente — né sotto `~/.claude/`, né sotto `~/.config/`.
|
|
154
|
+
*
|
|
155
|
+
* Dentro il livello «default» ci sono però due sorgenti, e l'ordine conta: il
|
|
156
|
+
* modello e il prompt cablati nella riga di catalogo vincono su quelli del
|
|
157
|
+
* catalogo dati, che si consulta solo per le azioni che hanno un `kind`. È
|
|
158
|
+
* l'ordine giusto perché un'azione senza `kind` non ha una riga in
|
|
159
|
+
* `prompt-catalog` per costruzione, e una che ce l'ha non porta valori propri.
|
|
160
|
+
*/
|
|
161
|
+
export function resolveSpawn(action, overrides, catalog, holes = {}) {
|
|
162
|
+
const raw = rawSpawn(action, overrides, catalog);
|
|
163
|
+
let prompt = raw.prompt;
|
|
164
|
+
if (prompt)
|
|
165
|
+
prompt = interpolate(prompt, holes) ?? '';
|
|
166
|
+
// La riduzione si applica DOPO l'interpolazione, perché i buchi portano dentro
|
|
167
|
+
// testo che l'alfabeto non ammette: uno slug col punto, un path con le barre.
|
|
168
|
+
// Quello che esce di qui è la stessa stringa che `deck-run` comporrà.
|
|
169
|
+
const filled = raw.title === null ? null : interpolate(raw.title, holes);
|
|
170
|
+
const title = filled === null ? null : saneNote(filled) || null;
|
|
171
|
+
return { title, model: raw.model, prompt, overridden: raw.overridden };
|
|
172
|
+
}
|
|
173
|
+
/** La terna di un'azione per ID di catalogo. Due chiamanti — l'attuatore, che la
|
|
174
|
+
* espone a tutte le sedi di spawn, e il valore iniziale del selettore della
|
|
175
|
+
* sessione nuda, che si risolve prima che l'attuatore esista — e una sola
|
|
176
|
+
* implementazione. */
|
|
177
|
+
export function resolveSpawnId(id, overrides, catalog, holes = {}) {
|
|
178
|
+
return resolveSpawn(spawnAction(id), overrides, catalog, holes);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Gli stessi tre valori PRIMA dell'interpolazione: i template coi buchi ancora
|
|
182
|
+
* dentro.
|
|
183
|
+
*
|
|
184
|
+
* È la forma che la pagina mostra e fa modificare (P9 preflight): nella cella si
|
|
185
|
+
* legge `🚀 {slug}`, non il titolo di una task particolare — un valore già reso
|
|
186
|
+
* si modificherebbe a partire da un esempio, e ogni modifica cancellerebbe il
|
|
187
|
+
* buco senza che nulla lo dica. L'esempio reso sta accanto, nell'area di
|
|
188
|
+
* compilazione, dove serve a vedere l'effetto senza essere il campo.
|
|
189
|
+
*
|
|
190
|
+
* Serve anche al confronto col DEFAULT: chi conferma una modifica scrive la
|
|
191
|
+
* cella solo se differisce dal grezzo che uscirebbe senza override, ed è così
|
|
192
|
+
* che un valore riportato a mano al default smette di essere un override invece
|
|
193
|
+
* di restare nel file come una riga che non cambia niente.
|
|
194
|
+
*/
|
|
195
|
+
export function rawSpawn(action, overrides, catalog) {
|
|
196
|
+
const ov = overrides.get(action.id) ?? {};
|
|
197
|
+
const overridden = new Set(Object.keys(ov));
|
|
198
|
+
const model = ov.model ??
|
|
199
|
+
action.model ??
|
|
200
|
+
(action.kind ? modelFor(catalog, action.kind) : undefined) ??
|
|
201
|
+
MODEL_DEFAULT;
|
|
202
|
+
let prompt;
|
|
203
|
+
if (ov.prompt !== undefined)
|
|
204
|
+
prompt = ov.prompt;
|
|
205
|
+
else if (action.prompt !== null)
|
|
206
|
+
prompt = action.prompt;
|
|
207
|
+
// Catalogo dati assente o voce mancante → nessun prompt, non un testo
|
|
208
|
+
// inventato qui: un default scritto nel deck sarebbe la copia del catalogo che
|
|
209
|
+
// il file dati esiste per non avere.
|
|
210
|
+
else if (action.kind)
|
|
211
|
+
prompt = catalog.get(action.kind)?.template ?? '';
|
|
212
|
+
else
|
|
213
|
+
prompt = null;
|
|
214
|
+
return { title: ov.title ?? action.title, model, prompt, overridden };
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Il blocco `spawn` come lo si scrive nel file: le sole voci non vuote, coi soli
|
|
218
|
+
* campi presenti, ordinate come il catalogo.
|
|
219
|
+
*
|
|
220
|
+
* L'ordine è quello di `SPAWN_ACTIONS` e non quello di inserimento nella mappa:
|
|
221
|
+
* il file è committato e lo si legge in diff, dove una riga che si sposta da
|
|
222
|
+
* sola a ogni salvataggio è rumore su cui poi si fa merge.
|
|
223
|
+
*
|
|
224
|
+
* `undefined` quando non resta nessun override: il chiamante toglie la chiave
|
|
225
|
+
* invece di scrivere `"spawn": {}`, che sarebbe una configurazione vuota da
|
|
226
|
+
* spiegare a chi la legge.
|
|
227
|
+
*/
|
|
228
|
+
export function serializeSpawnBlock(entries) {
|
|
229
|
+
const out = {};
|
|
230
|
+
// L'ordine viene da `SPAWN_ACTIONS`, non da una seconda lista di id scritta
|
|
231
|
+
// qui: una copia dell'elenco diverge alla prima riga aggiunta al catalogo, e
|
|
232
|
+
// la voce nuova finirebbe fuori dal file senza che niente lo segnali.
|
|
233
|
+
for (const action of SPAWN_ACTIONS) {
|
|
234
|
+
const ov = entries.get(action.id);
|
|
235
|
+
if (!ov)
|
|
236
|
+
continue;
|
|
237
|
+
const clean = {};
|
|
238
|
+
if (ov.title !== undefined)
|
|
239
|
+
clean.title = ov.title;
|
|
240
|
+
if (ov.model !== undefined)
|
|
241
|
+
clean.model = ov.model;
|
|
242
|
+
if (ov.prompt !== undefined)
|
|
243
|
+
clean.prompt = ov.prompt;
|
|
244
|
+
if (Object.keys(clean).length > 0)
|
|
245
|
+
out[action.id] = clean;
|
|
246
|
+
}
|
|
247
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Scrive il blocco `spawn` nel file di progetto, conservando tutto il resto.
|
|
251
|
+
*
|
|
252
|
+
* Read-modify-write su un file che NON è nostro: `loom-works.json` regge
|
|
253
|
+
* identità, label, surface, registry di compass e `permissionMode`, e il deck
|
|
254
|
+
* finora non ci aveva mai scritto. Da qui le due cautele:
|
|
255
|
+
* - se il file non si parsa la funzione LANCIA invece di rigenerarlo: un
|
|
256
|
+
* `loom-works.json` rotto va riparato a mano, non sostituito con uno che ha
|
|
257
|
+
* perso l'identità del progetto;
|
|
258
|
+
* - ogni altra chiave sopravvive nel suo ordine (`JSON.parse` conserva l'ordine
|
|
259
|
+
* di inserimento delle chiavi stringa, `JSON.stringify` lo riscrive uguale),
|
|
260
|
+
* così il diff del commit mostra il solo blocco toccato.
|
|
261
|
+
*
|
|
262
|
+
* File ASSENTE: anche qui si lancia. Un progetto senza `loom-works.json` non è
|
|
263
|
+
* registrato, e creargliene uno col solo blocco `spawn` produrrebbe un file che
|
|
264
|
+
* fallisce ogni validazione a valle.
|
|
265
|
+
*/
|
|
266
|
+
export function saveSpawnOverrides(projectRoot, entries) {
|
|
267
|
+
const path = configFilePath(projectRoot);
|
|
268
|
+
const raw = readFileSync(path, 'utf8');
|
|
269
|
+
const parsed = JSON.parse(raw);
|
|
270
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
271
|
+
throw new Error(`${path}: non è un oggetto JSON`);
|
|
272
|
+
}
|
|
273
|
+
const doc = parsed;
|
|
274
|
+
const block = serializeSpawnBlock(entries);
|
|
275
|
+
if (block)
|
|
276
|
+
doc[SPAWN_BLOCK] = block;
|
|
277
|
+
else
|
|
278
|
+
delete doc[SPAWN_BLOCK];
|
|
279
|
+
writeFileSync(path, JSON.stringify(doc, null, 2) + '\n', 'utf8');
|
|
280
|
+
}
|