@lamemind/loom-deck 0.61.0 → 0.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/actions.js CHANGED
@@ -15,8 +15,9 @@
15
15
  // `test/modes-smoke.test.ts` (`seleziona una task`, `terminale su`, `deck-run`,
16
16
  // `nessun push`, `eliminare N task?`, `scartate`): si copiano verbatim, non si
17
17
  // migliorano di passaggio.
18
- import { useRef } from 'react';
18
+ import { useMemo, useRef } from 'react';
19
19
  import { randomUUID } from 'node:crypto';
20
+ import { loadPromptCatalog, modelFor } from './prompt-catalog.js';
20
21
  import { appendNote, appendPin, appendSessionRecord, appendTaskBinding, } from './task-index.js';
21
22
  import { neighborId } from './session-list.js';
22
23
  import { cut, cutMiddle } from './width.js';
@@ -57,6 +58,9 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, setNo
57
58
  // prima dell'exec), ed è anche il comando da ripetere a mano per vedere
58
59
  // l'errore. Il salto fra le due dura i millisecondi che `deck-run` impiega a
59
60
  // comporre.
61
+ // T152 — stessa fonte del detail (sheet.ts): un file di sei righe accanto al
62
+ // codice, letto una volta per vita del deck.
63
+ const catalog = useMemo(() => loadPromptCatalog(), []);
60
64
  const spawnSeq = useRef(0);
61
65
  function noteSpawn(spawned) {
62
66
  noteCommand(spawned.cmd);
@@ -132,8 +136,11 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, setNo
132
136
  // invece deve restare vivo.
133
137
  function spawnTaskSession(kind, keyLabel) {
134
138
  const task = selectedTaskOr(keyLabel, 'spawnare');
139
+ // T152 — stesso catalogo del detail: senza, `^R` partirebbe sul default
140
+ // fisso mentre l'azione `run` del detail parte sul modello del kind, e le
141
+ // due superfici divergerebbero sullo stesso comando.
135
142
  if (task)
136
- spawnForTask(task.id, kind, MODEL_DEFAULT);
143
+ spawnForTask(task.id, kind, modelFor(catalog, kind) ?? MODEL_DEFAULT);
137
144
  }
138
145
  // T49 — resume di una conversazione in una nuova tab. Unico punto: lo chiamano
139
146
  // il `⏎` della lista sessioni e quello sulla riga-sessione della ricerca, che
@@ -18,6 +18,32 @@ const FORMAT_RE = /^#(\d+)$/;
18
18
  // Il basename del task file, non il path intero (che cambia con la docs-root)
19
19
  // e non `TASK_ID_RE` di tasks.ts (ancorata all'id nudo, non matcha `T136-x.md`).
20
20
  const TASK_FILE_RE = /^(T\d+)-.*\.md$/;
21
+ const EMPTY_MAP = new Map();
22
+ /**
23
+ * T153 — gate module-level: `git log` gira solo se lo sha di HEAD è cambiato
24
+ * dall'ultima chiamata su questa stessa coppia (tasksDir, projectRoot). Le
25
+ * date di ultimo commit cambiano SOLO se HEAD si muove (§Implementation
26
+ * Notes T153): un rev-parse fallito degrada come degradava il log, mai un
27
+ * throw. `key` tiene le due coppie separate — un test che passa tmpdir
28
+ * diversi a chiamate successive non deve leggere la cache dell'altro.
29
+ */
30
+ let cache = null;
31
+ let logSpawns = 0;
32
+ /** T153/DLV6 — quante volte è partito lo spawn COSTOSO (`git log`), non il
33
+ * gate. Il gate (`rev-parse`) gira a ogni chiamata per costruzione: contarlo
34
+ * renderebbe il numero inutile a dimostrare il no-op. */
35
+ export function commitLogSpawnCount() {
36
+ return logSpawns;
37
+ }
38
+ async function headSha(projectRoot) {
39
+ try {
40
+ const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: projectRoot });
41
+ return stdout.trim();
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
21
47
  /**
22
48
  * Parsing puro dell'output `git log --format=#%ct --name-only`. Isolato da
23
49
  * `commitTimes` perché è l'unica parte che vale la pena collaudare: invocare
@@ -54,13 +80,33 @@ export function parseCommitLog(output) {
54
80
  * incompleta proprio per le task vecchie mai più toccate, quelle che sotto
55
81
  * `desc` finiscono in coda — dove un timestamp assente e uno vecchio si
56
82
  * confonderebbero.
83
+ *
84
+ * T153 — gate su `rev-parse HEAD` (un ordine di grandezza più economico
85
+ * dello spawn del log che sostituisce, cifre misurate nel Progress Log): a
86
+ * sha invariato torna la STESSA istanza di mappa, non una ricostruita — è
87
+ * ciò che rende inutile il confronto per firma in `useCommitTimes` (P1
88
+ * preflight).
57
89
  */
58
90
  export async function commitTimes(tasksDir, projectRoot) {
91
+ const key = `${projectRoot}\u0000${tasksDir}`;
92
+ const sha = await headSha(projectRoot);
93
+ if (sha !== null && cache && cache.key === key && cache.sha === sha) {
94
+ return cache.map;
95
+ }
96
+ let map;
59
97
  try {
98
+ // Il contatore sale PRIMA dell'await: conta gli spawn partiti, e uno
99
+ // spawn fallito costa comunque la fork del processo.
100
+ logSpawns++;
60
101
  const { stdout } = await execFileAsync('git', ['log', '--format=#%ct', '--name-only', '--', tasksDir], { cwd: projectRoot });
61
- return parseCommitLog(stdout);
102
+ map = parseCommitLog(stdout);
62
103
  }
63
104
  catch {
64
- return new Map();
105
+ map = EMPTY_MAP;
65
106
  }
107
+ // sha === null (git muto): non si cachea uno stato che il prossimo giro
108
+ // potrebbe smentire in silenzio (repo che ricompare) senza mai vedersi
109
+ // ricontrollato.
110
+ cache = sha !== null ? { key, sha, map } : null;
111
+ return map;
66
112
  }
@@ -8,7 +8,7 @@
8
8
  // parentela si edita anche a mano dentro un task file già in tabella, non solo
9
9
  // alla nascita di una riga: un trigger sulla sola firma degli id (come
10
10
  // `useArchivable`) lascerebbe quell'edit stale fino al prossimo giro largo.
11
- import { readdirSync, readFileSync } from 'node:fs';
11
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
12
12
  import { join } from 'node:path';
13
13
  import { taskEpicOf, taskIsEpic } from './tasks.js';
14
14
  // Stesso pattern di `TASK_FILE_RE` in commit-times.ts: il basename del task
@@ -17,10 +17,23 @@ import { taskEpicOf, taskIsEpic } from './tasks.js';
17
17
  const TASK_FILE_RE = /^(T\d+)-.*\.md$/;
18
18
  export const EMPTY_EPIC_HIERARCHY = { epicOf: new Map(), epics: new Set() };
19
19
  /**
20
- * Lettura SINCRONA di tutti i task file sotto `tasksDir`: costo misurato in P2,
21
- * accettabile sul poll da 1,5s. Cartella illeggibile o singolo file non
22
- * apribile entry saltata, mai un throw un dato di rendering non può
23
- * rompere il deck, stessa regola di `archivableIds`/`commitTimes`.
20
+ * T153 gate module-level: il read+parse dei task file gira solo se la mtime
21
+ * MASSIMA fra i file `T<N>-*.md` di `tasksDir` è cambiata da ultima chiamata
22
+ * (un ordine di grandezza più economico del parse pieno che sostituisce,
23
+ * cifre misurate nel Progress Log). Sulla SOLA mtime e non su una firma
24
+ * degli id: deve intercettare un edit a mano di
25
+ * `**Parent Task**` DENTRO un file già in tabella, che non aggiunge né toglie
26
+ * nessun id (Testing Notes T153). A gate scattato torna la STESSA istanza —
27
+ * `useEpicHierarchy` non ha più bisogno di una firma propria per lo stesso
28
+ * motivo di `useCommitTimes` (P1 preflight).
29
+ */
30
+ let cache = null;
31
+ /**
32
+ * Lettura SINCRONA di tutti i task file sotto `tasksDir`: costo del gate
33
+ * accettabile sul poll da 1,5s anche a scan pieno (mai il caso in regime).
34
+ * Cartella illeggibile o singolo file non apribile → entry saltata, mai un
35
+ * throw — un dato di rendering non può rompere il deck, stessa regola di
36
+ * `archivableIds`/`commitTimes`.
24
37
  */
25
38
  export function scanEpicHierarchy(tasksDir) {
26
39
  let entries;
@@ -28,15 +41,32 @@ export function scanEpicHierarchy(tasksDir) {
28
41
  entries = readdirSync(tasksDir);
29
42
  }
30
43
  catch {
44
+ cache = null;
31
45
  return EMPTY_EPIC_HIERARCHY;
32
46
  }
33
- const epicOf = new Map();
34
- const epics = new Set();
47
+ let maxMtime = 0;
48
+ const files = [];
35
49
  for (const file of entries) {
36
- const m = TASK_FILE_RE.exec(file);
37
- if (!m)
50
+ if (!TASK_FILE_RE.test(file))
38
51
  continue;
39
- const id = m[1];
52
+ let mtime;
53
+ try {
54
+ mtime = statSync(join(tasksDir, file)).mtimeMs;
55
+ }
56
+ catch {
57
+ continue;
58
+ }
59
+ files.push(file);
60
+ if (mtime > maxMtime)
61
+ maxMtime = mtime;
62
+ }
63
+ if (cache && cache.key === tasksDir && cache.maxMtime === maxMtime) {
64
+ return cache.hierarchy;
65
+ }
66
+ const epicOf = new Map();
67
+ const epics = new Set();
68
+ for (const file of files) {
69
+ const id = TASK_FILE_RE.exec(file)[1];
40
70
  let content;
41
71
  try {
42
72
  content = readFileSync(join(tasksDir, file), 'utf8');
@@ -50,5 +80,7 @@ export function scanEpicHierarchy(tasksDir) {
50
80
  if (parent)
51
81
  epicOf.set(id, parent);
52
82
  }
53
- return { epicOf, epics };
83
+ const hierarchy = { epicOf, epics };
84
+ cache = { key: tasksDir, maxMtime, hierarchy };
85
+ return hierarchy;
54
86
  }
package/dist/hooks.js CHANGED
@@ -72,28 +72,26 @@ export function useTasks(tasksPath) {
72
72
  /**
73
73
  * T136 — data dell'ultimo commit di ogni task file, per la chiave di sort
74
74
  * `commit`. D1 (preflight): sullo STESSO poll di `tasks.md` (`POLL_MS`), non
75
- * su una scala propria come `useArchivable` — una passata di `git log` costa
76
- * ~33ms ed è UNA invocazione per tick, indipendentemente dal numero di task.
75
+ * su una scala propria come `useArchivable`.
77
76
  *
78
- * P5 (preflight): `commitTimes` ritorna una `Map` nuova a ogni tick anche
79
- * quando il contenuto non cambia; passata nuda a una `useMemo` a valle ne
80
- * romperebbe la memoizzazione ogni 1,5s. Si confronta per FIRMA prima di
81
- * aggiornare lo stato, come `lastMtime` in `useTasks` e `lastSig` in
82
- * `useSessions`: l'identità della mappa cambia solo dopo un commit vero.
77
+ * T153 `commitTimes` gatea da sé su `rev-parse HEAD` (davanti allo spawn
78
+ * caro del log, vedi `commit-times.ts`) e a sha invariato torna la STESSA
79
+ * istanza di mappa. L'hook confronta quindi per IDENTITÀ, non più per firma
80
+ * ricostruita: la firma evitava solo il re-render, il gate a monte evita il
81
+ * lavoro. `lastMtime` in `useTasks` resta il gemello sullo stesso schema.
83
82
  */
84
83
  export function useCommitTimes(tasksDir, projectRoot) {
85
84
  const [commitAt, setCommitAt] = useState(() => new Map());
86
85
  useEffect(() => {
87
- let lastSig = '';
86
+ let last = null;
88
87
  let alive = true;
89
88
  const reload = () => {
90
89
  commitTimes(tasksDir, projectRoot).then((next) => {
91
90
  if (!alive)
92
91
  return;
93
- const sig = [...next.entries()].map(([id, ts]) => `${id}:${ts}`).sort().join(',');
94
- if (sig === lastSig)
95
- return;
96
- lastSig = sig;
92
+ if (next === last)
93
+ return; // stessa istanza → il gate non è scattato
94
+ last = next;
97
95
  setCommitAt(next);
98
96
  });
99
97
  };
@@ -108,29 +106,24 @@ export function useCommitTimes(tasksDir, projectRoot) {
108
106
  }
109
107
  /**
110
108
  * T67 — mappa cappello/figlie (`**Parent Task**`, `Size: Epic`), sullo STESSO
111
- * poll di `tasks.md` e non su una scala propria: P2 (preflight) misura 7,86 ms
112
- * per il read+parse di 106 task file, meno dei ~33 ms di `git log` che
113
- * `useCommitTimes` paga già ogni tick. La parentela si edita anche a mano su
114
- * un task file già in tabella — un trigger sulla sola firma degli id, come
115
- * `useArchivable`, non vedrebbe quell'edit e lo lascerebbe stale fino al
116
- * prossimo giro largo.
109
+ * poll di `tasks.md` e non su una scala propria.
117
110
  *
118
- * Firma prima di `setState`, gemella di `lastSig`/`lastMtime` degli altri
119
- * poll: senza, la mappa cambia identità a ogni tick e rompe le `useMemo` a
120
- * valle che la consumano (`applyView`, `selectTasks`).
111
+ * T153 `scanEpicHierarchy` gatea da sulla mtime massima dei task file
112
+ * (davanti al read+parse caro, vedi `epic-hierarchy.ts`) e a mtime invariata
113
+ * torna la STESSA istanza: la parentela editata a mano dentro un file già in
114
+ * tabella resta vista, perché il gate è sulla mtime e non sugli id. L'hook
115
+ * confronta quindi per IDENTITÀ, non più per firma ricostruita, gemello di
116
+ * `useCommitTimes` per lo stesso motivo (P1 preflight).
121
117
  */
122
118
  export function useEpicHierarchy(tasksDir) {
123
119
  const [hierarchy, setHierarchy] = useState(EMPTY_EPIC_HIERARCHY);
124
120
  useEffect(() => {
125
- let lastSig = '';
121
+ let last = null;
126
122
  const reload = () => {
127
123
  const next = scanEpicHierarchy(tasksDir);
128
- const sig = [...next.epicOf.entries()].map(([c, p]) => `${c}<${p}`).sort().join(',') +
129
- '#' +
130
- [...next.epics].sort().join(',');
131
- if (sig === lastSig)
132
- return;
133
- lastSig = sig;
124
+ if (next === last)
125
+ return; // stessa istanza → il gate non è scattato
126
+ last = next;
134
127
  setHierarchy(next);
135
128
  };
136
129
  reload();
@@ -21,7 +21,7 @@ import { sanitizeTyped } from '../glyphs.js';
21
21
  import { ACTION_HOTKEYS, DETAIL_ACTIONS, MODELS, MODEL_DEFAULT, specializeRecap, } from '../spawn.js';
22
22
  import { taskIsEpic } from '../tasks.js';
23
23
  import { fieldsKey } from '../fields.js';
24
- import { loadPromptCatalog, promptFor } from '../prompt-catalog.js';
24
+ import { loadPromptCatalog, modelFor, promptFor } from '../prompt-catalog.js';
25
25
  // T117 — le quattro righe dell'area di compilazione del detail, nell'ordine in
26
26
  // cui si leggono dall'alto. Gli indici sono nominati perché li usano insieme
27
27
  // l'handler dei tasti e la resa, e un `2` nudo in due file diversi è la
@@ -113,32 +113,39 @@ export function useSheetOverlay(deps) {
113
113
  // Calcolato qui e usato subito: `setEpic` non ha ancora aggiornato lo stato
114
114
  // quando `setPrompt` gira, quindi il valore locale è l'unico leggibile ora.
115
115
  const isEpic = taskIsEpic(next.id, next.text);
116
+ const kind = specializeRecap(DETAIL_ACTIONS[0].kind, isEpic);
116
117
  setSheet(next);
117
118
  setTop(0);
118
119
  setAction(0);
119
120
  setEpic(isEpic);
120
- setModel(MODEL_DEFAULT);
121
+ // T152 — il modello segue l'azione iniziale, come già il prompt: su una
122
+ // task Epic la prima azione specializza a `recap-epic`, che nel catalogo
123
+ // può portare un modello diverso da `recap`. Nessun kind fuori catalogo →
124
+ // MODEL_DEFAULT, lo stesso fallback che vale ovunque nel deck.
125
+ setModel(modelFor(catalog, kind) ?? MODEL_DEFAULT);
121
126
  setSpawnNote('');
122
- setPrompt(promptFor(catalog, specializeRecap(DETAIL_ACTIONS[0].kind, isEpic), next.id));
127
+ setPrompt(promptFor(catalog, kind, next.id));
123
128
  setCursor({ row: DROW.action, caret: 0 });
124
129
  setFind(null);
125
130
  setOccIdx(0);
126
131
  setNote('');
127
132
  setMode('detail');
128
133
  }
129
- /** Cambia l'azione e RISCRIVE il prompt col default del nuovo kind (D2).
134
+ /** Cambia l'azione e RISCRIVE prompt e modello col default del nuovo kind (D2).
130
135
  *
131
- * Nessuna preservazione del testo modificato a mano, e non è una svista: la
132
- * regola `initialDetail` del modale edit protegge da un cambio di sorgente
133
- * ACCIDENTALE, e col fuoco per riga quel caso non esiste `←→` cambiano
134
- * azione solo dalla riga azione, mentre sul campo prompt muovono il caret.
135
- * Senza il cambio accidentale la preservazione difenderebbe da nulla, e
136
- * costerebbe un campo che non torna più al default. */
136
+ * Nessuna preservazione della scelta manuale di modello, per la stessa
137
+ * ragione già scritta sul prompt: col fuoco per riga un cambio di azione
138
+ * ACCIDENTALE non esiste `←→` cambiano azione solo dalla riga azione
139
+ * quindi la preservazione difenderebbe da nulla e costerebbe un campo che
140
+ * non torna più al default della nuova azione (T152). */
137
141
  function selectAction(index) {
138
142
  setAction(index);
139
143
  const id = sheet?.id;
140
- if (id)
141
- setPrompt(promptFor(catalog, specializeRecap(DETAIL_ACTIONS[index].kind, epic), id));
144
+ if (!id)
145
+ return;
146
+ const kind = specializeRecap(DETAIL_ACTIONS[index].kind, epic);
147
+ setPrompt(promptFor(catalog, kind, id));
148
+ setModel(modelFor(catalog, kind) ?? MODEL_DEFAULT);
142
149
  }
143
150
  // Il ponte fra le quattro righe e i quattro stati. Le righe restano
144
151
  // TIPIZZATE dove vivono (`ModelKind`, indice dell'azione) invece di finire in
@@ -16,17 +16,25 @@
16
16
  import { readFileSync } from 'node:fs';
17
17
  import { fileURLToPath } from 'node:url';
18
18
  import { dirname, join } from 'node:path';
19
+ import { MODELS } from './spawn.js';
19
20
  /** Stessa risalita di `DECK_RUN`: src/ (dev) e dist/ (build) sono entrambi
20
21
  * figli della package root, quindi il sibling è un livello sopra. */
21
22
  export const PROMPT_CATALOG = join(dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'prompt-catalog');
22
23
  /**
23
- * Il catalogo come mappa kind → template, col placeholder `{TASK}` ancora
24
- * dentro.
24
+ * Il catalogo come mappa kind → entry, col placeholder `{TASK}` del template
25
+ * ancora dentro.
25
26
  *
26
27
  * File illeggibile → mappa VUOTA, non un fallback cablato: un default scritto
27
28
  * qui sarebbe esattamente la copia del catalogo che questo modulo esiste per non
28
29
  * avere, e divergerebbe in silenzio. L'assenza si vede — il campo prompt resta
29
30
  * vuoto — e uno spawn senza prompt è un esito benigno.
31
+ *
32
+ * Il taglio è su DUE tab, non uno: il primo separa il kind, l'ultimo separa un
33
+ * modello opzionale in coda — così una riga a due colonne (nessun secondo tab)
34
+ * lascia il template intatto. Un valore in terza colonna che non è un
35
+ * `ModelKind` viene scartato come se la colonna non ci fosse (P2 preflight
36
+ * T152): il chiamante cade sul proprio default, stesso esito bash del `case`
37
+ * di `deck-run` che degrada un modello ignoto.
30
38
  */
31
39
  export function loadPromptCatalog(path = PROMPT_CATALOG) {
32
40
  const out = new Map();
@@ -43,13 +51,27 @@ export function loadPromptCatalog(path = PROMPT_CATALOG) {
43
51
  const tab = line.indexOf('\t');
44
52
  if (tab < 0)
45
53
  continue;
46
- out.set(line.slice(0, tab).trim(), line.slice(tab + 1).trim());
54
+ const kind = line.slice(0, tab).trim();
55
+ const rest = line.slice(tab + 1);
56
+ const lastTab = rest.lastIndexOf('\t');
57
+ const template = (lastTab >= 0 ? rest.slice(0, lastTab) : rest).trim();
58
+ const rawModel = lastTab >= 0 ? rest.slice(lastTab + 1).trim() : '';
59
+ const model = MODELS.includes(rawModel)
60
+ ? rawModel
61
+ : undefined;
62
+ out.set(kind, model ? { template, model } : { template });
47
63
  }
48
64
  return out;
49
65
  }
50
66
  /** Il prompt di un kind su una task. Kind fuori catalogo (`none`, o un file
51
67
  * mutilo) → stringa vuota, che è la stessa cosa che `deck-run` fa con `none`. */
52
68
  export function promptFor(catalog, kind, taskId) {
53
- const template = catalog.get(kind);
69
+ const template = catalog.get(kind)?.template;
54
70
  return template ? template.replaceAll('{TASK}', taskId) : '';
55
71
  }
72
+ /** Il modello di un kind, gemello di `promptFor`. Kind fuori catalogo o riga
73
+ * priva del campo → `undefined`: il chiamante cade sul proprio default
74
+ * (`MODEL_DEFAULT`), mai su un valore inventato qui. */
75
+ export function modelFor(catalog, kind) {
76
+ return catalog.get(kind)?.model;
77
+ }
package/dist/spawn.js CHANGED
@@ -143,12 +143,24 @@ export function specializeRecap(kind, epic) {
143
143
  // cambiarlo sposta le voci sotto le dita di chi le ha imparate. Fino a T111 era
144
144
  // anche il binding delle cifre `1`-`4`, passate poi al campo nota.
145
145
  export const MODELS = ['fable', 'opus', 'sonnet', 'haiku'];
146
- // Default del selettore e di ogni percorso di spawn che non passa da lui
147
- // (`^K`/`^P`/`^R` dalla lista, resume, fork). Duplicato del default di deck-run
148
- // e non letto da lì: il deck deve poter MOSTRARE la selezione iniziale prima di
149
- // spawnare alcunché, e un valore che si conosce solo a spawn avvenuto non è
150
- // mostrabile.
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).
151
156
  export const MODEL_DEFAULT = 'opus';
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. Valore = copia del frontmatter `model: opus` che la skill portava
162
+ // prima di T152 (P4 preflight), nessuna ri-taratura.
163
+ export const PROJECT_STATUS_MODEL = 'opus';
152
164
  // T66 — le azioni del detail. Non sono un catalogo nuovo: ognuna è un
153
165
  // `--prompt-kind` già esistente più `checkpoint`, e tutte passano dallo stesso
154
166
  // `spawnForTask` dei CTRL della lista — una superficie in più, zero percorsi di
@@ -502,6 +514,8 @@ export function spawnProjectStatus(cwd, sessionId, onResult) {
502
514
  return spawnSkill('/loom-works:recap-status-project', cwd, sessionId, onResult, [
503
515
  '--permission-mode',
504
516
  'auto',
517
+ '--model',
518
+ PROJECT_STATUS_MODEL,
505
519
  ]);
506
520
  }
507
521
  // Comando della notifica desktop. L'override esiste per il collaudo del ramo
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.61.0",
3
+ "version": "0.62.0",
4
4
  "description": "Deck TUI Ink per-progetto della famiglia loom: legge tasks.md e spawna sessioni Claude Code bound via LOOM_TASK",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/deck-run CHANGED
@@ -60,7 +60,7 @@
60
60
  # LOOM_DECK_STATE_PROFILE PTYXIS_PROFILE annunciata a compass (default: bindings/claude
61
61
  # del progetto, letto da dconf; settata a vuoto = nessun annuncio)
62
62
  # LOOM_DECK_PERMISSION_MODE override del permissionMode del file config (default: campo file, poi 'manual')
63
- # LOOM_DECK_MODEL modello quando --model non è passato (default: opus)
63
+ # LOOM_DECK_MODEL modello quando --model non è passato (batte il catalogo, default: opus)
64
64
  #
65
65
  set -euo pipefail
66
66
 
@@ -180,7 +180,8 @@ USAGE="uso: deck-run <TaskID> [--model <alias>] [--prompt-kind <kind>] [--sessio
180
180
  prompt. Ammesso anche con --no-task: un testo già scritto non
181
181
  ha una task da nominare, a differenza dei kind del catalogo
182
182
 
183
- --model modello della sessione: fable|opus|sonnet|haiku (default: opus)
183
+ --model modello della sessione: fable|opus|sonnet|haiku
184
+ (default: il modello del kind in catalogo, o 'opus' fuori catalogo)
184
185
  valore ignoto → fallback sul default, con avviso su stderr
185
186
 
186
187
  --title-note nota della conversazione, appesa al titolo tab
@@ -353,11 +354,16 @@ case "${PERM_MODE:-}" in
353
354
  esac
354
355
  MODE_FLAG="--permission-mode ${PERM_MODE} "
355
356
 
356
- # ── modello (T108) ───────────────────────────────────────────────────────────
357
- # Precedenza: --model > env LOOM_DECK_MODEL > 'opus'. Nessun campo nel file
358
- # config: il default è unico per tutta la famiglia, e uno scalare per-progetto
359
- # aggiungerebbe una chiave allo schema del plugin per un valore che il selettore
360
- # del deck sovrascrive a ogni spawn.
357
+ # ── modello (T108 + T152) ────────────────────────────────────────────────────
358
+ # Precedenza: --model > env LOOM_DECK_MODEL > modello del kind nel catalogo >
359
+ # 'opus'. Fino a T152 il default era un valore unico per tutta la famiglia,
360
+ # perché il frontmatter `model:` delle skill riscriveva comunque il modello di
361
+ # sessione un default configurabile qui non sarebbe servito a nulla. Da
362
+ # quando le skill non dichiarano più `model:`, il default DEVE viaggiare col
363
+ # kind: `run` costa quanto `preflight` solo se «vuole sonnet» è scritto da
364
+ # qualche parte che deck-run consulta, e quella parte è il catalogo che il
365
+ # deck TUI legge già per il testo del prompt (T117) — stessa fonte, stesso
366
+ # `kind`, niente seconda copia.
361
367
  # Il flag è passato SEMPRE, anche sul default, per la stessa ragione di
362
368
  # permissionMode: lo spawn resta leggibile nel process tree invece di dipendere
363
369
  # dal default del CLI, che cambia fra versioni.
@@ -366,7 +372,27 @@ MODE_FLAG="--permission-mode ${PERM_MODE} "
366
372
  # ferma DENTRO lo script (nessun template da scegliere, l'errore è del deck),
367
373
  # un modello ignoto arriverebbe al CLI e produrrebbe una tab con un comando che
368
374
  # fallisce all'avvio — cioè il guasto che permissionMode evita degradando.
375
+ _model_for_kind() { # <kind> → modello dichiarato in catalogo (3ª colonna), o niente
376
+ local want="$1" k t m
377
+ [[ -n "$want" && -f "$PROMPT_CATALOG" ]] || return 1
378
+ while IFS=$'\t' read -r k t m || [[ -n "$k" ]]; do
379
+ [[ -z "$k" || "$k" == \#* ]] && continue
380
+ [[ "$k" == "$want" && -n "$m" ]] && { printf '%s' "$m"; return 0; }
381
+ done < "$PROMPT_CATALOG"
382
+ return 1
383
+ }
384
+ # Kind di catalogo effettivo: SOLO quando la sessione è bound e senza prompt
385
+ # letterale — le due strade (--no-task, --prompt) non hanno una riga di
386
+ # catalogo da consultare, né per il prompt (sotto) né per il modello (qui).
387
+ # Riusato tal quale nella composizione del prompt: stesso guard, stesso valore.
388
+ CATALOG_KIND=""
389
+ if [[ $PROMPT_GIVEN -eq 0 && $NO_TASK -eq 0 ]]; then
390
+ CATALOG_KIND="${PROMPT_KIND:-recap}"
391
+ fi
369
392
  MODEL="${MODEL:-${LOOM_DECK_MODEL:-}}"
393
+ if [[ -z "$MODEL" && -n "$CATALOG_KIND" && "$CATALOG_KIND" != "none" ]]; then
394
+ MODEL="$(_model_for_kind "$CATALOG_KIND" || true)"
395
+ fi
370
396
  case "${MODEL:-}" in
371
397
  fable|opus|sonnet|haiku) ;;
372
398
  '') MODEL="opus" ;;
@@ -476,9 +502,12 @@ FORK_FLAG=""
476
502
  # (`T`, unico prefisso del contratto loom), quindi nessun prompt discrimina sulla
477
503
  # forma dell'id.
478
504
  _prompt_template() { # <kind> → template col placeholder {TASK}, o niente
479
- local want="$1" k t
505
+ local want="$1" k t m
480
506
  [[ -f "$PROMPT_CATALOG" ]] || return 1
481
- while IFS=$'\t' read -r k t || [[ -n "$k" ]]; do
507
+ # 3 variabili, non 2 (T152): da quando il catalogo porta una 3ª colonna
508
+ # modello, un `read -r k t` a due si mangerebbe il tab separatore e il
509
+ # modello dentro `t`, che è esattamente il template passato al CLI.
510
+ while IFS=$'\t' read -r k t m || [[ -n "$k" ]]; do
482
511
  [[ -z "$k" || "$k" == \#* ]] && continue
483
512
  [[ "$k" == "$want" ]] && { printf '%s' "$t"; return 0; }
484
513
  done < "$PROMPT_CATALOG"
@@ -503,7 +532,7 @@ elif [[ $NO_TASK -eq 1 ]]; then
503
532
  # volta e qui resta quindi inerte: senza task non ha nulla da nominare.
504
533
  PROMPT=""
505
534
  else
506
- _kind="${PROMPT_KIND:-recap}"
535
+ _kind="$CATALOG_KIND"
507
536
  if [[ "$_kind" == "none" ]]; then
508
537
  PROMPT=""
509
538
  else
@@ -1,28 +1,37 @@
1
1
  # Catalogo dei prompt iniziali di una sessione Claude aperta dal deck.
2
2
  #
3
- # Formato a righe: <kind><TAB><template>, con `{TASK}` interpolato col TaskID.
4
- # Righe vuote e righe che iniziano con `#` sono commenti.
3
+ # Formato a righe: <kind><TAB><template><TAB><modello>, con `{TASK}`
4
+ # interpolato col TaskID. Righe vuote e righe che iniziano con `#` sono
5
+ # commenti. Il modello è OPZIONALE: una riga a due colonne resta leggibile e
6
+ # chi la consulta cade sul proprio default (`MODEL_DEFAULT`/`opus`) — il campo
7
+ # esiste per i kind bound a una skill (T152), non per ogni riga del file.
5
8
  #
6
9
  # Perché un file dati e non un `case` dentro deck-run: due lati hanno bisogno
7
10
  # dello stesso testo — deck-run per comporre il comando in-tab, il deck TUI per
8
- # pre-riempire il campo prompt del detail con ciò che sta per essere eseguito.
9
- # Come codice, il secondo lato dovrebbe riscriverlo, e le due copie divergono
10
- # alla prima voce aggiunta da una parte sola.
11
+ # pre-riempire il campo prompt (e ora anche il modello) del detail con ciò che
12
+ # sta per essere eseguito. Come codice, il secondo lato dovrebbe riscriverlo, e
13
+ # le due copie divergono alla prima voce aggiunta da una parte sola.
11
14
  #
12
15
  # `none` non compare: nessun prompt non è un template vuoto, è l'assenza della
13
- # voce. Chi chiede un kind fuori da questo file riceve un prompt vuoto.
16
+ # voce. Chi chiede un kind fuori da questo file riceve un prompt vuoto e nessun
17
+ # modello (fallback sul default del chiamante).
14
18
  #
15
19
  # Vincolo sui template: nessun apice singolo. Il testo finisce dentro `'...'`
16
20
  # nel comando passato a `bash -lc`, e questo file è committato — un apice qui
17
21
  # sarebbe un errore di scrittura, non un input da quotare.
22
+ #
23
+ # Il modello è la copia esatta del `model:` che il frontmatter delle sei skill
24
+ # portava prima di T152 — non una ri-taratura: nasce da un difetto di canale
25
+ # (il frontmatter vinceva sempre su `--model`, rendendo il selettore del detail
26
+ # inerte), non da un giudizio su quale modello convenga a quale skill.
18
27
  # `recap` è il DISPATCHER: risolve la task, ne legge il Size e passa da sé alla
19
28
  # sotto-skill. Lo usa chi non sa se la task è un cappello — gli acceleratori
20
29
  # della lista, dove il deck ha in mano solo tasks.md, che il Size non ce l'ha.
21
30
  # Le due voci `recap-task`/`recap-epic` saltano quel giro: le sceglie il DETAIL,
22
31
  # che il task file l'ha già aperto e quindi il Size lo conosce.
23
- recap /loom-works:recap-status {TASK}
24
- recap-task /loom-works:recap-status-task {TASK}
25
- recap-epic /loom-works:recap-status-epic {TASK}
26
- preflight /loom-works:preflight-task {TASK}
27
- run /loom-works:run-task {TASK}
28
- checkpoint /loom-works:checkpoint-task {TASK}
32
+ recap /loom-works:recap-status {TASK} opus
33
+ recap-task /loom-works:recap-status-task {TASK} opus
34
+ recap-epic /loom-works:recap-status-epic {TASK} opus
35
+ preflight /loom-works:preflight-task {TASK} opus
36
+ run /loom-works:run-task {TASK} sonnet
37
+ checkpoint /loom-works:checkpoint-task {TASK} sonnet