@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,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
+ }
package/dist/spawn.js CHANGED
@@ -3,8 +3,11 @@
3
3
  import { spawn } from 'node:child_process';
4
4
  import { EventEmitter } from 'node:events';
5
5
  import { fileURLToPath } from 'node:url';
6
- import { basename, dirname, join } from 'node:path';
7
- import { findTaskFile } from './tasks.js';
6
+ import { dirname, join } from 'node:path';
7
+ // T161 — i cataloghi (kind, modelli, azioni) stanno in `spawn-catalog.ts`, che
8
+ // è DATO PURO e non importa questo file: la dipendenza va in un verso solo.
9
+ // Qui restano i soli effetti verso l'esterno — comporre un argv e lanciarlo.
10
+ import { MODEL_DEFAULT, PROJECT_STATUS_MODEL } from './spawn-catalog.js';
8
11
  // scripts/deck-run è un sibling della dir del bundle: src/ (dev, tsx) e dist/
9
12
  // (build, node) stanno entrambi sotto la package root → risalita di un livello.
10
13
  export const DECK_RUN = join(dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'deck-run');
@@ -118,171 +121,50 @@ export function onInTabCommand(child, cb) {
118
121
  }
119
122
  });
120
123
  }
121
- /**
122
- * `recap` → la sotto-skill giusta, quando chi spawna SA se la task è un cappello.
123
- *
124
- * `recap` resta il kind onesto per chi non lo sa: punta al dispatcher, che
125
- * risolve la task e classifica da sé. È il caso degli acceleratori della lista,
126
- * dove il deck ha in mano solo `tasks.md` — e lì il `Size` non c'è. Il DETAIL
127
- * invece il task file l'ha già letto, quindi può saltare il giro e pagare un
128
- * turno di modello in meno.
129
- *
130
- * Non è una classificazione duplicata: il criterio (`Size: Epic`) resta uno solo
131
- * e sta in `taskIsEpic`, che legge lo stesso campo che leggerebbe il dispatcher.
132
- * Quello che si evita è il RITARDO, non il giudizio.
133
- *
134
- * Ogni kind diverso da `recap` passa intatto: la specializzazione è un caso, non
135
- * una trasformazione da applicare a tutti.
136
- */
137
- export function specializeRecap(kind, epic) {
138
- if (kind !== 'recap')
139
- return kind;
140
- return epic ? 'recap-epic' : 'recap-task';
141
- }
142
- // L'ordine È il giro di `tab` nel detail, non una preferenza di lettura:
143
- // cambiarlo sposta le voci sotto le dita di chi le ha imparate. Fino a T111 era
144
- // anche il binding delle cifre `1`-`4`, passate poi al campo nota.
145
- export const MODELS = ['fable', 'opus', 'sonnet', 'haiku'];
146
- // Default del selettore e FALLBACK per ogni kind fuori catalogo (kind `none`,
147
- // o un catalogo mutilo). Duplicato del default di deck-run e non letto da lì:
148
- // il deck deve poter MOSTRARE la selezione iniziale prima di spawnare
149
- // alcunché, e un valore che si conosce solo a spawn avvenuto non è mostrabile.
150
- //
151
- // T152 — non è più IL default di `^K`/`^P`/`^R` dalla lista: quei percorsi
152
- // leggono il catalogo come il detail (`modelFor`), e cadono qui solo quando il
153
- // kind non ha una riga. Resta l'unico default che resume e fork usano per
154
- // intero, perché lì il modello non è mai una funzione del kind — è quello
155
- // della conversazione d'origine (T148).
156
- export const MODEL_DEFAULT = 'fable';
157
- // T152 — `recap-status-project` non è una chiave del catalogo (§Doc Impact,
158
- // "le chiavi del catalogo sono i valori di --prompt-kind"): lo spawn headless
159
- // non passa mai --prompt-kind, quindi una riga lì sarebbe irraggiungibile da
160
- // ogni chiamante. Il default vive qui, accanto a MODEL_DEFAULT, e non nel
161
- // catalogo. Resta `opus` mentre i tre recap del catalogo stanno su fable: è
162
- // l'unico recap che gira in headless (`-p`), senza nessuno che ne legga
163
- // l'esito mentre si forma e possa rilanciarlo — il testo prodotto è il
164
- // deliverable, e viene riletto da disco anche giorni dopo.
165
- export const PROJECT_STATUS_MODEL = 'opus';
166
- // T66 — le azioni del detail. Non sono un catalogo nuovo: ognuna è un
167
- // `--prompt-kind` già esistente più `checkpoint`, e tutte passano dallo stesso
168
- // `spawnForTask` dei CTRL della lista — una superficie in più, zero percorsi di
169
- // spawn in più.
170
- //
171
- // L'etichetta è distinta dal kind dove il kind è il nome del MECCANISMO e
172
- // l'etichetta quello dell'INTENZIONE: `none` è "aprire la task a mani nude",
173
- // `recap` è "vedere a che punto sta".
174
- export const DETAIL_ACTIONS = [
175
- { kind: 'none', label: 'open' },
176
- { kind: 'preflight', label: 'preflight' },
177
- { kind: 'run', label: 'run' },
178
- { kind: 'recap', label: 'status' },
179
- { kind: 'checkpoint', label: 'checkpoint' },
180
- ];
181
- // T117 — selezione DIRETTA di un'azione con la sua iniziale (`o p r s c`),
182
- // accanto allo scorrimento `←→`. Derivate dalle label e non cablate: una voce
183
- // aggiunta al catalogo porta con sé la propria lettera, invece di lasciare
184
- // indietro una seconda lista.
185
- // Il vincolo che la derivazione impone al catalogo: le iniziali devono restare
186
- // DISTINTE fra loro. Due label con la stessa lettera renderebbero la seconda
187
- // irraggiungibile, e in silenzio — chi aggiunge una voce lo controlla qui.
188
- export const ACTION_HOTKEYS = Object.fromEntries(DETAIL_ACTIONS.map((a, i) => [a.label[0], i]));
189
- // T150 — titolo di fallback quando il campo nota è vuoto, derivato dall'AZIONE
190
- // e dal task. Mappa sul KIND, non sulla label (P2 preflight): alla sede del
191
- // fallback (`spawnForTask` in actions.ts) arriva il kind — già specializzato
192
- // quando la chiamata viene dal detail (`recap-task`/`recap-epic`) — mai la
193
- // label del catalogo.
194
- // D2/P7 — `none` (label "open") non ha un prefisso: è "nessuna azione", e
195
- // inventargliene uno contraddirebbe l'intenzione dell'utente.
196
- // P3 — le tre varianti di recap condividono il prefisso: la specializzazione
197
- // sceglie quale skill parte, non cosa l'utente sta chiedendo.
198
- //
199
- // T156 — emoji invece della parola in maiuscolo (`PREFL`, `RUN`, …): il titolo
200
- // si legge in lista deck e nella tab Ptyxis, dove l'azione va riconosciuta a
201
- // colpo d'occhio e non letta.
202
- // Le quattro sono ASTRALI (code point ≥ U+10000) per necessità, non per gusto:
203
- // `sanitize` (`src/width.ts`) sostituisce con `·` ogni glifo su cui le due
204
- // contabilità di larghezza divergono, e le emoji del BMP a presentazione testo
205
- // (⚙️ ⏱️ 🛠️ …) divergono sempre — string-width le conta 2, VTE ne disegna 1, e
206
- // il VS16 non sposta nessuno dei due verdetti. Un'emoji aggiunta qui va prima
207
- // passata in `sanitize` per vedere se sopravvive.
208
- // Le stesse quattro stanno anche nella whitelist di `_sane_note` in `deck-run`,
209
- // che è una lista chiusa di code point (P2 preflight): cambiare una voce qui e
210
- // non là riapre la divergenza fra titolo in lista e titolo in tab. La whitelist
211
- // è più larga di questa tabella — porta anche le emoji dei titoli che non
212
- // nascono da un'azione su task (🧹 drain di un file inbox, `src/inbox.ts`; 📏
213
- // srotolamento di un path, `src/wrap-scan.ts`).
214
- // Le quadre che delimitavano il prefisso sono cadute: l'emoji da sola si stacca
215
- // già dallo slug, e con lei sono uscite dalla whitelist di `_sane_note`.
216
- const ACTION_EMOJI = {
217
- preflight: '📐',
218
- run: '🚀',
219
- recap: '📊',
220
- 'recap-task': '📊',
221
- 'recap-epic': '📊',
222
- checkpoint: '🏁',
223
- };
224
- /**
225
- * Titolo di fallback per una conversazione con nota vuota: `{emoji} {slug}`,
226
- * o il solo slug per `none` (D1/D2 preflight). Lo slug viene dal NOME del
227
- * task file (`findTaskFile`), non dalla descrizione di `tasks.md`: il nome è
228
- * già dentro l'alfabeto di `_sane_note` per costruzione — minuscolo, separato
229
- * da trattini, senza punteggiatura — mentre la descrizione porta apostrofi e
230
- * `/` che la riduzione toglie senza sostituto, saldando le parole (D1
231
- * razionale).
232
- *
233
- * Il PREFISSO invece nell'alfabeto non ci sta per costruzione: l'emoji ci entra
234
- * solo perché la whitelist di `_sane_note` le nomina una per una (T156). È
235
- * l'unica parte del titolo di fallback che dipende da una modifica fatta
236
- * dall'altro lato.
237
- *
238
- * `null` quando il task file non si trova: il chiamante decide se ripiegare
239
- * su nota vuota o su un altro fallback.
240
- */
241
- export function fallbackTitle(tasksDir, id, kind) {
242
- const path = findTaskFile(tasksDir, id);
243
- if (!path)
244
- return null;
245
- const slug = basename(path, '.md').slice(id.length + 1).replace(/-/g, ' ');
246
- const emoji = ACTION_EMOJI[kind];
247
- return emoji ? `${emoji} ${slug}` : slug;
248
- }
249
124
  // Spawn detached: il deck spawna ma NON contiene la sessione (la possiede
250
125
  // ptyxis-agent). unref + stdio ignore → ritorna subito, la TUI resta viva.
251
126
  // sessionId pinnato (T27) → il binding sidecar è deterministico allo spawn.
252
- // Il kind è OBBLIGATORIO e non ha default qui: il default vive in deck-run (per
253
- // le invocazioni a mano), mentre dal deck ogni tasto dichiara il proprio intento
254
- // — un default silenzioso renderebbe indistinguibili `⏎` e `^K`.
255
- // Il modello, al contrario del kind, ha un default (T108): i percorsi che non
256
- // passano dal selettore del detail non devono nominarlo per forza, e l'unico
257
- // valore sensato per loro è quello che il selettore stesso mostra all'apertura.
127
+ // Il modello ha un default (T108): i percorsi che non passano dal selettore del
128
+ // detail non devono nominarlo per forza, e l'unico valore sensato per loro è
129
+ // quello che il selettore stesso mostra all'apertura.
258
130
  // T111 — `spawnNote` è la nota data alla NASCITA della conversazione, dal campo
259
- // sempre attivo del detail. Stesso flag `--title-note` del resume (T64): il
131
+ // sempre attivo del detail — che da T161 nasce PRE-RIEMPITO col template di
132
+ // titolo dell'azione, reso sulla task aperta. Ne discende che un campo svuotato
133
+ // a mano significa «nessuna nota» e non più «usa il titolo di fallback»: il
134
+ // fallback non esiste più, perché il default è già a schermo.
135
+ // Stesso flag `--title-note` del resume (T64): il
260
136
  // suffisso `«nota»` viene appeso a `TITLE` dentro deck-run PRIMA che i rami si
261
137
  // separino, quindi il flag non appartiene alla ripresa — vale su ogni spawn.
262
138
  // Assente quando la nota è vuota, e non passato vuoto: `--title-note ''`
263
139
  // produrrebbe un `«»` a vuoto nel titolo.
264
- // T117 — `prompt` è il TESTO letterale, e quando c'è sostituisce il kind:
265
- // `--prompt` e `--prompt-kind` sono mutuamente esclusivi in deck-run. Serve al
266
- // detail, dove il prompt è un campo editabile e quello che l'utente legge è
267
- // quello che parte — dopo una modifica nessun kind lo descrive più.
268
- // `undefined` = questo percorso non ha un campo prompt (gli acceleratori della
269
- // lista) e viaggia col simbolo, come prima. Stringa VUOTA ≠ undefined: è la
270
- // richiesta esplicita di nessun prompt (azione `open`, o un campo svuotato a
271
- // mano) e si esprime col kind `none`, perché senza flag deck-run cadrebbe sul
272
- // proprio default `recap`.
273
- export function deckArgs(id, sessionId, kind, model = MODEL_DEFAULT, spawnNote, prompt) {
274
- const promptArgs = prompt === undefined
275
- ? ['--prompt-kind', kind]
276
- : prompt
277
- ? ['--prompt', prompt]
278
- : ['--prompt-kind', 'none'];
140
+ // T117 — `prompt` è il TESTO letterale, e sostituisce il kind: `--prompt` e
141
+ // `--prompt-kind` sono mutuamente esclusivi in deck-run.
142
+ //
143
+ // T161 — dal deck viaggia SEMPRE il testo, mai più il simbolo. Prima gli
144
+ // acceleratori della lista passavano `--prompt-kind` e lasciavano risolvere il
145
+ // testo a `deck-run`, che consultava il proprio catalogo; il detail passava già
146
+ // il letterale. Con un override di progetto sui prompt la risoluzione deve
147
+ // stare da una parte sola, e quella parte è il deck — che è l'unico dei due a
148
+ // leggere `.claude/loom-works.json` per questo (`jq` è dipendenza opzionale di
149
+ // `deck-run`). Un percorso solo, quindi, e ciò che parte è verificabile
150
+ // sull'argv.
151
+ //
152
+ // Due conseguenze dichiarate: `--prompt-kind` resta in `deck-run` per chi lo
153
+ // invoca a mano, e `LOOM_DECK_ENTER_PROMPT` non ha più effetto sugli spawn dal
154
+ // deck (non ha mai vinto su `--prompt`).
155
+ //
156
+ // Stringa VUOTA = richiesta esplicita di nessun prompt (azione `open`, o un
157
+ // campo svuotato a mano): si esprime col kind `none`, perché senza nessun flag
158
+ // `deck-run` cadrebbe sul proprio default `recap`.
159
+ export function deckArgs(id, sessionId, model = MODEL_DEFAULT, spawnNote, prompt = '') {
160
+ const promptArgs = prompt ? ['--prompt', prompt] : ['--prompt-kind', 'none'];
279
161
  const args = [id, '--session-id', sessionId, ...promptArgs, '--model', model];
280
162
  if (spawnNote)
281
163
  args.push('--title-note', spawnNote);
282
164
  return args;
283
165
  }
284
- export function spawnDeck(id, cwd, sessionId, kind, model = MODEL_DEFAULT, spawnNote, prompt) {
285
- return launchDeckRun(deckArgs(id, sessionId, kind, model, spawnNote, prompt), cwd);
166
+ export function spawnDeck(id, cwd, sessionId, model = MODEL_DEFAULT, spawnNote, prompt = '') {
167
+ return launchDeckRun(deckArgs(id, sessionId, model, spawnNote, prompt), cwd);
286
168
  }
287
169
  // T49 — resume di una sessione esistente come nuova tab Ptyxis. Scoped (taskId
288
170
  // presente) → `deck-run <task> --resume <sid>`: la ripresa eredita LOOM_TASK +
@@ -578,12 +460,19 @@ export function spawnCleanTasks(ids, cwd, sessionId, ignored, onResult) {
578
460
  // permissivo toglie le richieste di permesso che il CLI pone su un tool, e
579
461
  // quelle in `-p` non hanno nessuno che risponda — il processo resterebbe appeso
580
462
  // senza emettere niente, cioè con l'indicatore che conta all'infinito.
581
- export function spawnProjectStatus(cwd, sessionId, onResult) {
582
- return spawnSkill('/loom-works:recap-status-project', cwd, sessionId, onResult, [
463
+ //
464
+ // T161 — modello e prompt arrivano dal CHIAMANTE, risolti sul catalogo delle
465
+ // azioni: erano gli ultimi due valori che questa funzione sceglieva da sé
466
+ // (`PROJECT_STATUS_MODEL` e la stringa della skill), e con l'override di
467
+ // progetto il default deve stare in un posto solo insieme a quello delle altre
468
+ // dieci azioni. Il `--permission-mode auto` invece resta qui: non è una
469
+ // preferenza ma la condizione perché un `-p` non resti appeso.
470
+ export function spawnProjectStatus(cwd, sessionId, onResult, model = PROJECT_STATUS_MODEL, prompt = '/loom-works:recap-status-project') {
471
+ return spawnSkill(prompt, cwd, sessionId, onResult, [
583
472
  '--permission-mode',
584
473
  'auto',
585
474
  '--model',
586
- PROJECT_STATUS_MODEL,
475
+ model,
587
476
  ]);
588
477
  }
589
478
  // Comando della notifica desktop. L'override esiste per il collaudo del ramo
@@ -37,6 +37,31 @@ export function appendNote(projectRoot, sessionId, note) {
37
37
  export function appendPriority(projectRoot, sessionId, priority) {
38
38
  appendSessionRecord(projectRoot, { sessionId, priority });
39
39
  }
40
+ /**
41
+ * T162 — scrive i campi DERIVATI (`title`, `model`) di una conversazione.
42
+ *
43
+ * I due vanno in UN record e non in due, anche se il lettore risolve last-wins
44
+ * per campo e due append darebbero lo stesso esito finale: due record lasciano
45
+ * un istante in cui su disco lo stato è a metà, e in quel mezzo ci sta il
46
+ * lettore di compass — che mostrerebbe il titolo nuovo e riprenderebbe col
47
+ * modello vecchio. È la stessa regola di `writeSessionMarks` lato compass.
48
+ *
49
+ * Il record porta SOLO i campi passati: uno che portasse anche l'altro col
50
+ * valore che aveva prima lo riscriverebbe, e il last-wins trasformerebbe quella
51
+ * riscrittura in una sovrascrittura di ciò che un altro scrittore ha messo nel
52
+ * frattempo. Un insieme vuoto non scrive niente, invece di appendere un record
53
+ * col solo `sessionId` che nessun lettore userebbe.
54
+ */
55
+ export function appendSessionMeta(projectRoot, sessionId, fields) {
56
+ const rec = { sessionId };
57
+ if (fields.title !== undefined)
58
+ rec.title = fields.title;
59
+ if (fields.model !== undefined)
60
+ rec.model = fields.model;
61
+ if (rec.title === undefined && rec.model === undefined)
62
+ return;
63
+ appendSessionRecord(projectRoot, rec);
64
+ }
40
65
  // Una sola lettura del JSONL per entrambe le mappe: il deck poll-a l'indice a
41
66
  // ogni tick, leggere il file due volte raddoppierebbe l'I/O per nulla.
42
67
  // Last-wins per campo (un re-pin dello stesso sessionId sovrascrive), e i due
@@ -48,12 +73,14 @@ export function loadSessionIndex(projectRoot) {
48
73
  const pinned = new Map();
49
74
  const notes = new Map();
50
75
  const priority = new Set();
76
+ const titles = new Map();
77
+ const models = new Map();
51
78
  let content;
52
79
  try {
53
80
  content = readFileSync(taskIndexPath(projectRoot), 'utf8');
54
81
  }
55
82
  catch {
56
- return { bindings, forkOf, pinned, notes, priority };
83
+ return { bindings, forkOf, pinned, notes, priority, titles, models };
57
84
  }
58
85
  let order = 0; // posizione crescente dei record pinned → rango di pin (D2)
59
86
  for (const line of content.split('\n')) {
@@ -108,10 +135,32 @@ export function loadSessionIndex(projectRoot) {
108
135
  else
109
136
  priority.delete(d.sessionId);
110
137
  }
138
+ // T162 — stesso last-wins per campo di `note`, con una differenza che
139
+ // conta: NESSUNA sanificazione in lettura. `note` la paga perché il deck
140
+ // la mette nel frame e il file è editabile a mano; qui il valore non
141
+ // entra nel frame (il deck il titolo lo ha dalla `Session`) e chi lo
142
+ // rende — compass, sotto Pango — non fa contabilità di colonne.
143
+ //
144
+ // Sanificare qui avrebbe un costo suo: il deck decide se riempire il
145
+ // buco CONFRONTANDO il valore letto con quello derivato, e un valore
146
+ // riscritto in lettura non combacerebbe mai col derivato — un append a
147
+ // ogni tick del poll, per sempre, senza nessun errore a dirlo.
148
+ if (typeof d.title === 'string') {
149
+ if (d.title)
150
+ titles.set(d.sessionId, d.title);
151
+ else
152
+ titles.delete(d.sessionId);
153
+ }
154
+ if (typeof d.model === 'string') {
155
+ if (d.model)
156
+ models.set(d.sessionId, d.model);
157
+ else
158
+ models.delete(d.sessionId);
159
+ }
111
160
  }
112
161
  catch {
113
162
  // riga corrotta → skip
114
163
  }
115
164
  }
116
- return { bindings, forkOf, pinned, notes, priority };
165
+ return { bindings, forkOf, pinned, notes, priority, titles, models };
117
166
  }
package/dist/tasks.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { readFileSync, readdirSync } from 'node:fs';
2
- import { dirname, join } from 'node:path';
2
+ import { basename, dirname, join } from 'node:path';
3
3
  import { sanitize } from './width.js';
4
4
  /**
5
5
  * Forma di un ID task: `T` + numero. FONTE UNICA del gate — `parseTasks` lo usa
@@ -108,6 +108,26 @@ export function loadTasks(path) {
108
108
  // ID → path del task file: `<id>-<slug>.md` nella tasks dir. Il dash dopo l'ID
109
109
  // disambigua i prefissi (`T20-` non matcha `T2-…`). Se più file matchano, primo
110
110
  // in ordine. `null` se la dir non è leggibile o nessun file matcha.
111
+ /**
112
+ * T161 — lo SLUG di una task: il nome del suo file senza l'id e coi trattini
113
+ * sciolti in spazi, cioè il buco `{slug}` dei template di titolo.
114
+ *
115
+ * Viene dal NOME del file e non dalla descrizione in `tasks.md` per una ragione
116
+ * di alfabeto: il nome è già dentro quello di `_sane_note` per costruzione —
117
+ * minuscolo, separato da trattini, senza punteggiatura — mentre la descrizione
118
+ * porta apostrofi e `/`, che la riduzione toglie senza sostituto saldando le
119
+ * parole (`l'epica/doc` → `lepicadoc`).
120
+ *
121
+ * Stringa VUOTA quando il task file non si trova: chi interpola tratta un buco
122
+ * senza valore come «nessun titolo», invece di comporre un prefisso uguale per
123
+ * ogni task.
124
+ */
125
+ export function taskSlug(tasksDir, id) {
126
+ const path = findTaskFile(tasksDir, id);
127
+ if (!path)
128
+ return '';
129
+ return basename(path, '.md').slice(id.length + 1).replace(/-/g, ' ');
130
+ }
111
131
  export function findTaskFile(dir, id) {
112
132
  let entries;
113
133
  try {
@@ -6,7 +6,7 @@ import { cut } from '../width.js';
6
6
  import { sliceLine } from '../text-search.js';
7
7
  import { sliceSpans } from '../markdown.js';
8
8
  import { ChoiceRow, FieldText, LABEL_W } from './fields.js';
9
- import { DETAIL_ACTIONS, MODELS } from '../spawn.js';
9
+ import { DETAIL_ACTIONS, MODELS } from '../spawn-catalog.js';
10
10
  import { DROW, PRIORITY_CHOICES } from '../overlays/sheet.js';
11
11
  import { CARET, CARET_OFF, WARN } from '../glyphs.js';
12
12
  /** Resa di ogni costrutto markdown (T75 · D4): un solo livello di enfasi per
@@ -7,7 +7,7 @@ import { previewTextWidth, previewTitleParts } from '../layout.js';
7
7
  import { LIVE_BUSY, LIVE_IDLE, META_KEYS, fmtDateTime, fmtSize, modelAlias, } from '../glyphs.js';
8
8
  import { NATURA_SHORT, inboxMark } from '../inbox.js';
9
9
  import { ChoiceRow } from './fields.js';
10
- import { MODELS } from '../spawn.js';
10
+ import { MODELS } from '../spawn-catalog.js';
11
11
  /** Colonne del prefisso delle due anteprime della preview sessione (`» `, `« `,
12
12
  * e i due spazi delle righe di continuazione). Costante perché entra nel
13
13
  * budget di wrap, che è il posto in cui dimenticarla non produce un errore ma