@lamemind/loom-deck 0.30.1 → 0.31.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/task-edit.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // isolata in `writeTaskEdit`, così la grammatica delle sostituzioni è testabile
4
4
  // senza toccare il filesystem.
5
5
  import { readFileSync, writeFileSync } from 'node:fs';
6
- import { TASK_ID_RE, findTaskFile } from './tasks.js';
6
+ import { TASK_ID_RE, findTaskFile, headerColumns } from './tasks.js';
7
7
  // Il task file scrive la priorità per NOME (`- **Priority**: Med`), tasks.md la
8
8
  // scrive come GLIFO (colonna Pri). Due rappresentazioni dello stesso valore →
9
9
  // due mappe, entrambe keyed sul nome canonico di view.ts (unica fonte del rango).
@@ -75,31 +75,38 @@ export function initialDetail(current, prog, date = today()) {
75
75
  return stripProgGlyph(s);
76
76
  }
77
77
  /**
78
- * Riscrive le celle Pri (col 2), Prog (col 4) e — se `desc` è passata — la
79
- * descrizione (col 5) della riga `| Tnn | … |` in tasks.md. Solo la PRIMA riga
80
- * con quell'id — l'overview è unica, un secondo match sarebbe un duplicato da
81
- * non propagare. Le celle non toccate restano i token grezzi originali: nessun
82
- * re-flow della tabella, il diff resta di una riga. `ok:false` = id assente →
83
- * il chiamante non scrive nulla.
78
+ * Riscrive le celle Pri, Prog e — se `desc` è passata — la descrizione della
79
+ * riga `| Tnn | … |` in tasks.md. Solo la PRIMA riga con quell'id — l'overview
80
+ * è unica, un secondo match sarebbe un duplicato da non propagare. Le celle non
81
+ * toccate restano i token grezzi originali: nessun re-flow della tabella, il
82
+ * diff resta di una riga. `ok:false` = id assente → il chiamante non scrive nulla.
83
+ *
84
+ * Le posizioni vengono dall'HEADER (`headerColumns`, stessa fonte di
85
+ * `parseTasks`), mai da un indice cablato: qui si SCRIVE su disco, quindi una
86
+ * colonna in più o in meno non produrrebbe una lettura sbagliata ma un titolo
87
+ * sovrascritto col glifo. Header assente → `ok:false`, non si tira a indovinare.
84
88
  *
85
89
  * `desc` undefined ≠ `desc` vuota: la prima non tocca la cella (chiamata
86
90
  * legacy, solo pri/prog), la seconda la svuota davvero.
87
91
  *
88
92
  * La descrizione è l'ULTIMA colonna e può contenere `|` — che in una tabella
89
93
  * markdown spezza la cella. Riscriverla significa quindi collassare tutte le
90
- * celle da 5 fino alla penultima in una sola (`splice`), altrimenti i pezzi
91
- * della vecchia descrizione resterebbero appesi in coda alla nuova.
94
+ * celle da `cols.desc` fino alla penultima in una sola (`splice`), altrimenti i
95
+ * pezzi della vecchia descrizione resterebbero appesi in coda alla nuova.
92
96
  *
93
97
  * L'id deve rispettare `TASK_ID_RE` (importato da tasks.ts — stesso gate di
94
98
  * `parseTasks`, non una copia): senza, un id arbitrario matcherebbe la riga di
95
- * HEADER (`| ID | Pri | K | Prog |`) o quella di separatore, riscrivendone le
96
- * celle e sfondando la tabella.
99
+ * separatore, riscrivendone le celle e sfondando la tabella.
97
100
  */
98
101
  export function updateTasksMdRow(content, id, priGlyph, progGlyph, desc) {
99
102
  if (!TASK_ID_RE.test(id))
100
103
  return { content, ok: false };
104
+ const lines = content.split('\n');
105
+ const cols = lines.reduce((found, l) => found ?? headerColumns(l), null);
106
+ if (!cols)
107
+ return { content, ok: false };
101
108
  let ok = false;
102
- const lines = content.split('\n').map((line) => {
109
+ const out = lines.map((line) => {
103
110
  if (ok)
104
111
  return line;
105
112
  if (!line.trim().startsWith('|'))
@@ -107,20 +114,21 @@ export function updateTasksMdRow(content, id, priGlyph, progGlyph, desc) {
107
114
  const cells = line.split('|');
108
115
  if ((cells[1] ?? '').trim() !== id)
109
116
  return line;
110
- if (cells.length < 6)
117
+ // Serve la cella desc PIÙ la coda dopo il `|` finale: senza la coda lo
118
+ // splice inserirebbe invece di sostituire.
119
+ if (cells.length < cols.desc + 2)
111
120
  return line; // riga malformata → non toccarla
112
121
  ok = true;
113
- cells[2] = ` ${priGlyph} `;
114
- cells[4] = ` ${progGlyph} `;
122
+ cells[cols.pri] = ` ${priGlyph} `;
123
+ cells[cols.prog] = ` ${progGlyph} `;
115
124
  if (desc !== undefined) {
116
- // Da cells[5] fino alla penultima: l'ultima è la coda dopo il `|` finale
117
- // (stringa vuota su una riga ben formata) e va conservata, o la riga
118
- // perderebbe il proprio bordo destro.
119
- cells.splice(5, cells.length - 6, ` ${sanitizeCell(desc)} `);
125
+ // L'ultima cella è la coda dopo il `|` finale (stringa vuota su una riga
126
+ // ben formata) e va conservata, o la riga perderebbe il bordo destro.
127
+ cells.splice(cols.desc, cells.length - cols.desc - 1, ` ${sanitizeCell(desc)} `);
120
128
  }
121
129
  return cells.join('|');
122
130
  });
123
- return { content: ok ? lines.join('\n') : content, ok };
131
+ return { content: ok ? out.join('\n') : content, ok };
124
132
  }
125
133
  /**
126
134
  * Rende un testo scrivibile dentro una CELLA di tabella markdown. Due caratteri
@@ -139,11 +147,10 @@ function sanitizeCell(s) {
139
147
  * ricompare nel body (residuo template) vince quello dell'header — così ciò che
140
148
  * il deck mostra e ciò che scrive restano la stessa riga.
141
149
  *
142
- * L'H1 conserva il proprio CAPPELLO (`# Task: …` code, `# Doc Task: …` doc):
143
- * `parseTaskDetail` lo strippa in lettura, quindi il titolo che arriva dal
144
- * modale non ce l'ha e riscrivere la riga nuda perderebbe la categoria. Il
145
- * prefisso si rilegge dalla riga esistente e si ri-antepone tale e quale — così
146
- * una T resta `# Task:` e una D resta `# Doc Task:` senza doverlo sapere qui.
150
+ * L'H1 conserva il proprio CAPPELLO (`# Task: …`): `parseTaskDetail` lo strippa
151
+ * in lettura, quindi il titolo che arriva dal modale non ce l'ha e riscrivere la
152
+ * riga nuda lo perderebbe. Si rilegge dalla riga esistente e si ri-antepone tale
153
+ * e quale, così un file senza cappello resta senza.
147
154
  */
148
155
  export function updateTaskFileFields(content, priLabel, progress, title) {
149
156
  let priDone = false;
@@ -152,7 +159,7 @@ export function updateTaskFileFields(content, priLabel, progress, title) {
152
159
  const lines = content.split('\n').map((line) => {
153
160
  if (title !== undefined && !titleDone && /^#\s+/.test(line)) {
154
161
  titleDone = true;
155
- const prefix = line.match(/^#\s+((?:Doc\s+)?Task:\s*)/)?.[1] ?? '';
162
+ const prefix = line.match(/^#\s+(Task:\s*)/)?.[1] ?? '';
156
163
  return `# ${prefix}${title.replace(/\s+/g, ' ').trim()}`;
157
164
  }
158
165
  if (!priDone && /^-\s*\*\*Priority\*\*:/.test(line)) {
package/dist/tasks.js CHANGED
@@ -2,12 +2,12 @@ import { readFileSync, readdirSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { sanitize } from './width.js';
4
4
  /**
5
- * Forma di un ID task: `T` (code) o `D` (doc) + numero. FONTE UNICA del gate —
6
- * `parseTasks` lo usa per scartare header/separatore della tabella, `task-edit`
7
- * per non riscrivere per sbaglio quelle stesse righe. Tenerlo in un posto solo
8
- * evita che i due gate divergano (uno accetterebbe righe che l'altro rifiuta).
5
+ * Forma di un ID task: `T` + numero. FONTE UNICA del gate — `parseTasks` lo usa
6
+ * per scartare header/separatore della tabella, `task-edit` per non riscrivere
7
+ * per sbaglio quelle stesse righe. Tenerlo in un posto solo evita che i due gate
8
+ * divergano (uno accetterebbe righe che l'altro rifiuta).
9
9
  */
10
- export const TASK_ID_RE = /^[TD]\d+$/;
10
+ export const TASK_ID_RE = /^T\d+$/;
11
11
  // D1 (preflight T20): default docs/tasks.md, override della docs-root via env
12
12
  // LOOM_DECK_DOCS_ROOT (es. questo progetto usa `runtime`). No auto-detect.
13
13
  export function resolveTasksPath(cwd = process.cwd()) {
@@ -19,15 +19,50 @@ export function resolveTasksPath(cwd = process.cwd()) {
19
19
  export function resolveTasksDir(cwd = process.cwd()) {
20
20
  return join(dirname(resolveTasksPath(cwd)), 'tasks');
21
21
  }
22
- // Estrae le righe `| Tnn | Pri | K | Prog | Task |` della Tasks Overview.
23
- // Due prefissi ammessi (contratto plugin, counter separati): `T` = code task,
24
- // `D` = doc task. Le D entrano in lista come le T perché `deck-run` NON inietta
25
- // `run-task`: il prompt iniziale è un recap diretto ("recap stato task <id>"),
26
- // valido identico su una doc task. Righe header/separatore non matchano `^[TD]\d+$`
27
- // scartate.
22
+ /**
23
+ * Indici di colonna della Tasks Overview letti dall'HEADER, non cablati.
24
+ *
25
+ * Cablarli lega il parser al NUMERO di colonne invece che al loro nome: toglierne
26
+ * una fa scivolare tutto di uno e il deck legge la cella sbagliata **senza
27
+ * sollevare un errore** — la descrizione finisce dove si cerca il glifo di
28
+ * progresso, e `updateTasksMdRow` riscrive quella posizione su disco. È la stessa
29
+ * trappola già nota sull'indice di RIGA (selezione keyed su id, mai su posizione),
30
+ * applicata all'indice di colonna.
31
+ *
32
+ * Solo `pri` e `prog` si cercano per nome; la descrizione è per costruzione la
33
+ * colonna successiva a Prog, quindi rinominarla non rompe il parse. `null` = la
34
+ * riga non è l'header — l'header della tabella Lane (`| Lane | …`) cade qui.
35
+ */
36
+ export function headerColumns(line) {
37
+ const t = line.trim();
38
+ if (!t.startsWith('|'))
39
+ return null;
40
+ const cells = t.split('|').map((c) => c.trim().toLowerCase());
41
+ if (cells[1] !== 'id')
42
+ return null;
43
+ const pri = cells.indexOf('pri');
44
+ const prog = cells.indexOf('prog');
45
+ if (pri < 0 || prog < 0)
46
+ return null;
47
+ return { pri, prog, desc: prog + 1 };
48
+ }
49
+ // Estrae le righe `| Tnn | Pri | Prog | Task |` della Tasks Overview. Righe
50
+ // header/separatore non matchano `^T\d+$` → scartate.
51
+ //
52
+ // Le righe si parsano solo DOPO aver incontrato l'header: senza indici derivati
53
+ // non si tira a indovinare, la lista resta vuota. Un fallimento visibile invece
54
+ // di una cella letta al posto sbagliato.
28
55
  export function parseTasks(content) {
29
56
  const tasks = [];
57
+ let cols = null;
30
58
  for (const line of content.split('\n')) {
59
+ const header = headerColumns(line);
60
+ if (header) {
61
+ cols = header;
62
+ continue;
63
+ }
64
+ if (!cols)
65
+ continue;
31
66
  const t = line.trim();
32
67
  if (!t.startsWith('|'))
33
68
  continue;
@@ -36,12 +71,10 @@ export function parseTasks(content) {
36
71
  const id = cells[1];
37
72
  if (!TASK_ID_RE.test(id))
38
73
  continue;
39
- // Colonne overview: | ID | Pri | K | Prog | Task | → cells[2]=Pri (icona
40
- // priorità), cells[4]=Prog. K (Kind, cells[3]) non serve al deck.
41
- const pri = cells[2] ?? '';
42
- const prog = cells[4] ?? '';
74
+ const pri = cells[cols.pri] ?? '';
75
+ const prog = cells[cols.prog] ?? '';
43
76
  // desc = colonna finale; join per resistere a eventuali `|` nella descrizione.
44
- const desc = cells.slice(5, -1).join('|').trim();
77
+ const desc = cells.slice(cols.desc, -1).join('|').trim();
45
78
  // Sanificazione AL CONFINE: tutto ciò che entra da tasks.md è testo
46
79
  // arbitrario e può contenere glifi che Ink e il terminale misurano in modo
47
80
  // diverso (vedi `width.ts`). Farlo qui invece che a ogni sito di render è
@@ -87,10 +120,9 @@ export function parseTaskDetail(id, content) {
87
120
  let inDesc = false;
88
121
  for (const line of content.split('\n')) {
89
122
  if (!title && line.startsWith('# ')) {
90
- // I due template scrivono H1 diversi `# Task: …` (code) e
91
- // `# Doc Task: …` (doc): entrambi i cappelli vanno via, il titolo mostrato
92
- // è la descrizione, non la sua categoria (già data dall'id).
93
- title = line.replace(/^#\s+/, '').replace(/^(?:Doc\s+)?Task:\s*/, '').trim();
123
+ // Il cappello `Task:` del template va via: il titolo mostrato è la
124
+ // descrizione, non la categoria (già data dall'id).
125
+ title = line.replace(/^#\s+/, '').replace(/^Task:\s*/, '').trim();
94
126
  continue;
95
127
  }
96
128
  if (line.startsWith('## ')) {
package/dist/view.js CHANGED
@@ -53,16 +53,11 @@ export function progRank(glyph) {
53
53
  return PROG_TABLE.find((e) => e.glyph === g)?.rank ?? UNKNOWN_RANK;
54
54
  }
55
55
  // `T10`.localeCompare(`T9`) mette T10 prima: l'ID va confrontato NUMERICO.
56
- // parseTasks ammette due prefissi con counter SEPARATI (`T` code, `D` doc)
57
- // il solo numero non basta, T01 e D01 collidono. Il prefisso pesa quindi come
58
- // cifra più significativa (T prima di D, blocco doc in coda), il numero come
59
- // meno significativa: il rango resta un intero singolo, quindi il comparator
60
- // della chain non cambia forma. Il gap 1e6 è oltre qualunque counter reale.
56
+ // Prefisso unico il numero è già una chiave totale, e il rango è il numero.
61
57
  // Un id fuori forma non deve far esplodere il comparator → coda con MAX_SAFE.
62
- const ID_PREFIX_RANK = { T: 0, D: 1 };
63
58
  export function idNum(id) {
64
- const m = /^([TD])(\d+)$/.exec(id);
65
- return m ? ID_PREFIX_RANK[m[1]] * 1_000_000 + Number(m[2]) : Number.MAX_SAFE_INTEGER;
59
+ const m = /^T(\d+)$/.exec(id);
60
+ return m ? Number(m[1]) : Number.MAX_SAFE_INTEGER;
66
61
  }
67
62
  function rankOf(task, key) {
68
63
  if (key === 'pri')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.30.1",
3
+ "version": "0.31.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
@@ -124,7 +124,7 @@ USAGE="uso: deck-run <TaskID> [--prompt-kind <kind>] [--session-id <uuid>]
124
124
  none nessun prompt — sessione aperta sulla task, a mani nude
125
125
  recap recap stato task <TaskID>
126
126
  preflight /loom-works:preflight-task <TaskID>
127
- run /loom-works:run-task <TaskID> (run-doc sulle task D)
127
+ run /loom-works:run-task <TaskID>
128
128
  checkpoint /loom-works:checkpoint-task <TaskID>
129
129
 
130
130
  --title-note nota della conversazione, appesa al titolo tab come «nota»
@@ -353,23 +353,15 @@ FORK_FLAG=""
353
353
  # possa scavalcare. Il placeholder {TASK} vale solo per il valore dell'override;
354
354
  # i template del catalogo interpolano ${TASK} diretto (nel `${VAR:-...}` la `}`
355
355
  # di `{TASK}` chiuderebbe in anticipo l'espansione).
356
- # `run` DISPATCHA sul prefisso: la lista del deck contiene entrambe le famiglie
357
- # del contratto loom (`T` code, `D` doc) e le due hanno skill di esecuzione
358
- # diverse — `run-task` è dichiarata code-only, `run-doc` è la sua complementare.
359
- # Su una D il prompt `run-task` invocherebbe la skill sbagliata. Gli altri kind
360
- # non discriminano, ma è una verifica da rifare per OGNI kind nuovo, non una
361
- # proprietà del catalogo: `recap` è un prompt diretto (nessuna skill),
362
- # `preflight-task` risolve il task file per id, e `checkpoint-task` estrae il
363
- # taskId con `T\d+|D\d+` — copre quindi entrambi i prefissi da sé.
356
+ # Ogni kind è un template diretto sull'id: la lista del deck è monofamiglia
357
+ # (`T`, unico prefisso del contratto loom), quindi nessun prompt discrimina sulla
358
+ # forma dell'id.
364
359
  case "${PROMPT_KIND:-recap}" in
365
360
  none) PROMPT="" ;;
366
361
  recap) PROMPT="recap stato task ${TASK}" ;;
367
362
  preflight) PROMPT="/loom-works:preflight-task ${TASK}" ;;
368
363
  checkpoint) PROMPT="/loom-works:checkpoint-task ${TASK}" ;;
369
- run) case "$TASK" in
370
- D*) PROMPT="/loom-works:run-doc ${TASK}" ;;
371
- *) PROMPT="/loom-works:run-task ${TASK}" ;;
372
- esac ;;
364
+ run) PROMPT="/loom-works:run-task ${TASK}" ;;
373
365
  esac
374
366
  if [[ "${PROMPT_KIND:-}" != "none" && -n "${LOOM_DECK_ENTER_PROMPT:-}" ]]; then
375
367
  PROMPT="${LOOM_DECK_ENTER_PROMPT//\{TASK\}/${TASK}}"