@lamemind/loom-deck 0.67.0 → 0.69.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.
@@ -0,0 +1,115 @@
1
+ // T161 — il gemello TypeScript di `_sane_note`, la riduzione che `deck-run`
2
+ // applica a ogni nota del titolo.
3
+ //
4
+ // Perché serve un gemello. La nota del titolo è l'UNICO ingresso di testo libero
5
+ // nel titolo di una tab, quindi `deck-run` non la quota: la RIDUCE a una
6
+ // whitelist chiusa di code point, e tutto ciò che non ci rientra sparisce senza
7
+ // errore. Finché la nota nasceva da un campo scritto al volo, la divergenza fra
8
+ // quello che si legge nel deck e quello che compare nella tab durava il tempo di
9
+ // uno spawn. Da quando un TEMPLATE del titolo si scrive in una pagina e si salva
10
+ // su disco, quella divergenza diventa permanente: il file direbbe una cosa e la
11
+ // tab ne mostrerebbe un'altra, per sempre e senza un errore da nessuna parte.
12
+ //
13
+ // La riduzione si applica quindi QUI, al confine — al salvataggio e alla
14
+ // lettura del blocco `spawn` — così quello che sta nel file è quello che la tab
15
+ // mostrerà.
16
+ //
17
+ // L'ALLINEAMENTO fra i due lati non è assunto: `test/sane-note.test.ts` passa la
18
+ // stessa batteria di input a questa funzione e a `deck-run` vero (via lo shim
19
+ // `ptyxis` di `deck-run.test.ts`) e pretende l'uguaglianza. La whitelist non si
20
+ // allarga — e se un giorno lo facesse, sono tre le sedi da toccare insieme
21
+ // (`deck-run`, questo file, le emoji dei titoli in `spawn-catalog.ts`), col test
22
+ // che fallisce se una resta indietro.
23
+ /**
24
+ * L'alfabeto ammesso, negato: lettere, cifre, spazio, `_`, `-`, le accentate
25
+ * italiane e sei emoji.
26
+ *
27
+ * Whitelist e non blacklist, come in bash: il titolo finisce dentro apici
28
+ * singoli in `bash -lc "claude --name '…'"`, ed enumerare i caratteri pericolosi
29
+ * significa sbagliarne uno.
30
+ *
31
+ * Le sei emoji sono nominate una per una e NON come intervallo di code point,
32
+ * per due ragioni che valgono su entrambi i lati: un'emoji arriva nella tab solo
33
+ * se il deck la mostra identica in lista, cioè se `sanitize` (`src/width.ts`) la
34
+ * lascia passare invece di renderla `·`, e quel verdetto si verifica un glifo
35
+ * alla volta; e l'ordine di un intervallo dentro un bracket expression segue la
36
+ * collazione della locale, non i code point.
37
+ *
38
+ * Il flag `u` non è cosmetico: senza, le quattro emoji astrali entrerebbero
39
+ * nella classe come coppie di surrogati sciolti, e la riduzione lascerebbe
40
+ * passare mezzo carattere di qualunque altra emoji che ne condivida il primo.
41
+ */
42
+ const OUT_OF_ALPHABET = /[^A-Za-z0-9 _àèéìòùÀÈÉÌÒÙ📐🚀📊🏁🧹📏-]/gu;
43
+ /** Cap in CARATTERI (code point), non in byte: a byte un taglio a metà di una
44
+ * `à` lascerebbe UTF-8 rotto nel titolo. Largo di proposito — una tab che sfora
45
+ * tronca a destra da sola, e il limite serve solo a non spingere fuori vista la
46
+ * parte del titolo che compass matcha. */
47
+ export const SANE_CAP = 60;
48
+ /** Un buco di template: `{TASK}`, `{slug}`, `{file}`, `{path}`. La cattura nel
49
+ * gruppo è ciò che fa comparire i buchi nei dispari di `split`. */
50
+ const HOLE = /(\{[A-Za-z]+\})/;
51
+ /** Spazi collassati, estremi potati, cap: i tre passi che in bash sono `tr -s`,
52
+ * i due `${s#/%}` e `${s:0:60}`. */
53
+ function tidy(s) {
54
+ const squeezed = s.replace(/ +/g, ' ');
55
+ const trimmed = squeezed.replace(/^ /, '').replace(/ $/, '');
56
+ return [...trimmed].slice(0, SANE_CAP).join('');
57
+ }
58
+ /**
59
+ * La nota ridotta, esattamente come la ridurrebbe `deck-run`.
60
+ *
61
+ * Si applica al titolo GIÀ interpolato, cioè al testo che finisce nel sidecar e
62
+ * in `--title-note`: le due superfici che mostrano quel nome — la riga di lista
63
+ * del deck, che legge la nota grezza dal sidecar, e la tab Ptyxis — devono
64
+ * vedere la stessa stringa.
65
+ */
66
+ export function saneNote(raw) {
67
+ return tidy(raw.replace(OUT_OF_ALPHABET, ''));
68
+ }
69
+ /**
70
+ * La stessa riduzione, coi BUCHI preservati.
71
+ *
72
+ * Le graffe non sono nell'alfabeto: passare un template da `saneNote` lo
73
+ * ridurrebbe a `📐 slug`, cioè a un titolo che nomina la parola «slug» invece di
74
+ * interpolare il nome della task. Il template si riduce comunque — è testo
75
+ * scritto a mano, e il resto della riga deve sopravvivere alla tab — ma i buchi
76
+ * attraversano intatti e vengono riempiti dopo.
77
+ *
78
+ * Fuori dai buchi il comportamento è identico a `saneNote`, compresi collasso
79
+ * degli spazi, potatura e cap: un template senza buchi passa dalle stesse regole
80
+ * di una nota qualunque.
81
+ */
82
+ export function saneTemplate(raw) {
83
+ return tidy(raw
84
+ .split(HOLE)
85
+ .map((part, i) => (i % 2 === 1 ? part : part.replace(OUT_OF_ALPHABET, '')))
86
+ .join(''));
87
+ }
88
+ /**
89
+ * Riempie i buchi di un template. `null` quando un buco nominato non ha un
90
+ * valore da mettere.
91
+ *
92
+ * Il `null` non è difensivo, è il comportamento: un titolo `📐 {slug}` su una
93
+ * task il cui file non si trova diventerebbe `📐` — un prefisso solo, uguale per
94
+ * ogni task, che è peggio di nessun titolo. Chi chiama riceve `null` e apre la
95
+ * conversazione senza nota, che è ciò che faceva il vecchio `fallbackTitle`
96
+ * quando il task file mancava.
97
+ *
98
+ * I buchi non dichiarati dall'azione restano LETTERALI e non fanno fallire
99
+ * l'interpolazione: non sono un valore mancante, sono testo che l'utente ha
100
+ * scritto — e il giro di riduzione dopo l'interpolazione toglierà le graffe.
101
+ */
102
+ export function interpolate(template, values) {
103
+ let missing = false;
104
+ const filled = template.replace(/\{([A-Za-z]+)\}/g, (whole, name) => {
105
+ if (!(name in values))
106
+ return whole;
107
+ const v = values[name];
108
+ if (!v) {
109
+ missing = true;
110
+ return '';
111
+ }
112
+ return v;
113
+ });
114
+ return missing ? null : filled;
115
+ }
@@ -0,0 +1,108 @@
1
+ // T162 — i campi DERIVATI del sidecar: cosa scrivere in `title`/`model` e
2
+ // quando scriverlo.
3
+ //
4
+ // Esiste perché compass ha bisogno del titolo di una conversazione e non può
5
+ // leggerne il transcript: l'estensione gira dentro il processo di gnome-shell,
6
+ // e una lettura sincrona di qualche megabyte blocca il compositore. Il dato
7
+ // deve quindi arrivargli da un file piccolo che già apre — il sidecar — messo
8
+ // lì da chi ce l'ha in mano, cioè il deck, che a ogni tick materializza le
9
+ // `Session` complete di titolo e modello.
10
+ //
11
+ // Modulo PURO come `session-list.ts`: nessun I/O, nessun import da ink/react.
12
+ // Decide COSA appendere e ritorna i record; il chiamante li scrive. La
13
+ // separazione è ciò che rende testabile la parte non ovvia — la convergenza:
14
+ // un derivato che non combacia mai col valore su disco produrrebbe un append a
15
+ // ogni tick del poll, per sempre, senza nessun errore a dirlo.
16
+ import { modelAlias } from './glyphs.js';
17
+ import { stripProjectCore, stripTaskId } from './session-list.js';
18
+ import { NO_TITLE } from './sessions.js';
19
+ /**
20
+ * Cap del titolo scritto nel sidecar, in CODE POINT.
21
+ *
22
+ * È il `LABEL_MAX` del lettore (compass): oltre, Pango ellissizza comunque, e
23
+ * il file non deve portare una stringa che nessuno mostrerà per intero. Misura
24
+ * del preflight: col primo prompt come titolo il massimo osservato era 1.367
25
+ * caratteri, 36 conversazioni su 604 sopra 120, p90 a 61 — il cap morde su una
26
+ * minoranza e non tocca il caso normale.
27
+ *
28
+ * In code point e non in code unit perché il taglio va fatto senza spezzare una
29
+ * coppia surrogata: un'emoji tagliata a metà lascia nel file un surrogato
30
+ * spaiato, cioè un glifo rotto nel menu.
31
+ */
32
+ export const SIDECAR_TITLE_MAX = 120;
33
+ function capTitle(s) {
34
+ const cps = [...s];
35
+ return cps.length <= SIDECAR_TITLE_MAX ? s : cps.slice(0, SIDECAR_TITLE_MAX).join('');
36
+ }
37
+ /**
38
+ * Il valore da scrivere in `title`: il RESIDUO del titolo, non il titolo
39
+ * grezzo. `''` = non c'è niente da scrivere.
40
+ *
41
+ * I due casi non si trattano allo stesso modo, ed è il punto di questa
42
+ * funzione:
43
+ *
44
+ * - **con titolo custom** → il titolo di una conversazione nata dal deck È la
45
+ * label della tab (`🧵 loom-works · T32 revisione`), composta di tre pezzi
46
+ * che il lettore ha già altrove: il progetto (la riga sotto cui la
47
+ * conversazione compare), il task id (il campo `taskId` dello stesso
48
+ * record) e la nota (il campo `note`). Copiare il grezzo obbligherebbe
49
+ * compass a una seconda copia di `stripTaskId`, e a schermo ripeterebbe tre
50
+ * volte quello che già si legge.
51
+ * - **senza titolo custom** → `title` È il primo prompt, e vale intatto:
52
+ * quella è l'unica cosa che dice di cosa si parlava.
53
+ *
54
+ * Non è `sessionTitle`: quella, con residuo vuoto, cade sul primo prompt —
55
+ * dentro il deck è giusto, perché una riga muta non serve a nessuno, ma qui il
56
+ * primo prompt di una sessione nata dal deck è l'invocazione di una skill
57
+ * (`/loom-works:recap-status-task T32`), che nel menu non aggiunge nulla. Qui
58
+ * il residuo vuoto è ASSENZA: non si scrive, e chi legge cade sulla nota.
59
+ */
60
+ export function sidecarTitle(s, core, taskId) {
61
+ if (!s.customTitle)
62
+ return s.title === NO_TITLE ? '' : capTitle(s.title);
63
+ return capTitle(stripTaskId(stripProjectCore(s.title, core), taskId));
64
+ }
65
+ /**
66
+ * I buchi da riempire in questo giro: per ogni conversazione PINNATA, i campi
67
+ * derivati che nel sidecar mancano o non corrispondono più al valore corrente.
68
+ *
69
+ * Solo le pinnate, e non tutte le conversazioni del progetto, perché sono le
70
+ * sole che un lettore esterno mostra: il resto sarebbe churn su un file
71
+ * append-only, un record per conversazione per ogni deck che si apre.
72
+ *
73
+ * Tre cose che non producono un append, e nessuna delle tre è un errore:
74
+ * - una pinnata STALE (il transcript non c'è più) — nessuna `Session`, quindi
75
+ * nessun valore da cui derivare;
76
+ * - un titolo derivato VUOTO — è l'assenza di P1, e appenderlo scriverebbe la
77
+ * cancellazione di un campo che nessuno ha chiesto di cancellare;
78
+ * - un modello non riconducibile a un alias (`''` o fuori famiglia) — chi
79
+ * riprende cadrà sulla propria cascata, che è meglio di un alias inventato.
80
+ *
81
+ * La CONVERGENZA è l'invariante da tenere: il confronto avviene fra il valore
82
+ * derivato e il valore letto dal sidecar senza riscritture in mezzo (nessuna
83
+ * sanificazione in lettura, cap applicato da questo lato una volta sola),
84
+ * quindi un buco riempito resta riempito e il giro successivo non trova niente
85
+ * da fare.
86
+ */
87
+ export function sidecarGaps(input) {
88
+ const { pinned, sessions, bindings, titles, models, core } = input;
89
+ if (pinned.size === 0)
90
+ return [];
91
+ const byId = new Map(sessions.map((s) => [s.sessionId, s]));
92
+ const out = [];
93
+ for (const sessionId of pinned.keys()) {
94
+ const session = byId.get(sessionId);
95
+ if (!session)
96
+ continue; // pinnata stale: niente da cui derivare
97
+ const gap = { sessionId };
98
+ const title = sidecarTitle(session, core, bindings.get(sessionId) ?? null);
99
+ if (title && titles.get(sessionId) !== title)
100
+ gap.title = title;
101
+ const model = modelAlias(session.model);
102
+ if (model && models.get(sessionId) !== model)
103
+ gap.model = model;
104
+ if (gap.title !== undefined || gap.model !== undefined)
105
+ out.push(gap);
106
+ }
107
+ return out;
108
+ }
package/dist/sessions.js CHANGED
@@ -54,6 +54,12 @@ const INTERRUPT_MARKER = '[Request interrupted by user';
54
54
  function isInterrupt(text) {
55
55
  return text.trimStart().startsWith(INTERRUPT_MARKER);
56
56
  }
57
+ /** Titolo di una conversazione che non ne ha nessuno — né custom né un primo
58
+ * prompt da cui derivarlo. È un SEGNAPOSTO di resa, non un titolo: chi copia
59
+ * il titolo altrove (T162, il sidecar per compass) deve poterlo riconoscere e
60
+ * trattarlo come assenza, invece di propagare la stringa. Esportato per quello
61
+ * — una seconda copia del letterale divergerebbe al primo ritocco del testo. */
62
+ export const NO_TITLE = '(senza titolo)';
57
63
  // T110 — valore che il CLI scrive nel campo modello dei record che fabbrica da
58
64
  // sé (osservato su `{"type":"text","text":"No response requested."}`). Restano
59
65
  // `type:assistant` a tutti gli effetti: chi si fida del campo etichetta la
@@ -248,7 +254,7 @@ export function parseTranscript(content, path, mtime, sizeBytes) {
248
254
  cwd,
249
255
  gitBranch,
250
256
  parentUuid,
251
- title: sanitize(customTitle || firstUserText || '(senza titolo)'),
257
+ title: sanitize(customTitle || firstUserText || NO_TITLE),
252
258
  ts: mtime,
253
259
  path,
254
260
  sizeBytes,
@@ -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
+ }