@lamemind/loom-deck 0.5.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 lamemind
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,85 @@
1
+ # loom-deck
2
+
3
+ Deck TUI (Ink) **per-progetto** della famiglia [loom](https://github.com/lamemind/loom-works).
4
+
5
+ Legge il `tasks.md` del progetto e, con un tasto (poi un click), **spawna** una tab
6
+ [Ptyxis](https://gitlab.gnome.org/chergert/ptyxis) che avvia una sessione Claude Code
7
+ già bound alla task via `LOOM_TASK`, dritta su `/loom-works:run-task <Txx>`.
8
+
9
+ ```
10
+ ↑↓ scegli la task → ⏎ → tab CC di fianco → parte già su /loom-works:run-task <Txx>
11
+ ```
12
+
13
+ ## Ruolo nella famiglia loom
14
+
15
+ `loom-deck` è un **client** con runtime proprio (TUI Ink) che **consuma** il contratto
16
+ definito da `loom-works-plugin` (formato `tasks.md`, variabile `LOOM_TASK`) — non lo
17
+ ridefinisce. Divisione dei ruoli con Compass:
18
+
19
+ | | scope | ruolo | domanda |
20
+ |---|---|---|---|
21
+ | **Compass** (GNOME) | globale, cross-desktop | radar, stato live, focus/jump | "dove sono?" |
22
+ | **loom-deck** (TUI) | per-progetto | attuatore locale, spawna task | "cosa faccio qui?" |
23
+
24
+ ## Architettura di processo
25
+
26
+ Il deck è **UN processo Node**: spawna ma **non contiene** le sessioni CC — le possiede
27
+ Ptyxis. Chiudere il deck non uccide le sessioni. La tab nasce nella window *attiva*
28
+ (quella col focus = il deck) → desktop isolation "gratis".
29
+
30
+ ## Stato
31
+
32
+ Bootstrap + spike ① + **TUI ③** funzionante (legge `tasks.md`, `⏎` spawna). Roadmap:
33
+
34
+ ```
35
+ ① spike spawn-tab + LOOM_TASK ✅ scripts/deck-run
36
+ ② gradino $LOOM_TASK nelle skill loom-works
37
+ ③ TUI Ink sopra (lista tasks.md, ↑↓/⏎ → chiama ①) ✅ src/
38
+ ④ mouse opzionale (SGR enable+parse+hit-test)
39
+ ⑤ azioni extra (start/preflight/checkpoint/merge dal deck)
40
+ ```
41
+
42
+ ## Installazione
43
+
44
+ ```bash
45
+ npm install -g @lamemind/loom-deck # comando globale `loom-deck`
46
+ # oppure senza install permanente:
47
+ npx @lamemind/loom-deck
48
+ ```
49
+
50
+ ## Requisiti runtime
51
+
52
+ - **Node.js ≥ 18** (dichiarato in `engines`).
53
+ - **[Ptyxis](https://gitlab.gnome.org/chergert/ptyxis)** (terminale GNOME) — **dipendenza
54
+ di runtime, non risolvibile da npm**. Lo spawn di una tab (`scripts/deck-run`) invoca il
55
+ binario `ptyxis`: l'**install riesce anche senza**, ma al momento dello spawn il comando
56
+ fallisce (gestito: handler `error` → la TUI resta viva, mostra la nota). Senza una GUI
57
+ GNOME con Ptyxis installato, il deck naviga i task ma **non apre sessioni**.
58
+ - **[Claude Code](https://claude.com/claude-code)** nel `PATH` — la tab spawnata avvia `claude`.
59
+
60
+ ## Uso (spike ①)
61
+
62
+ ```bash
63
+ # dalla project dir con un tasks.md
64
+ scripts/deck-run T18
65
+ ```
66
+
67
+ Apre una tab Ptyxis nella window attiva con `LOOM_TASK=T18 claude '/loom-works:run-task T18'`.
68
+
69
+ ## Sviluppo (TUI Ink)
70
+
71
+ ```bash
72
+ npm install
73
+ npm run dev # tsx src/cli.tsx — lista tasks.md reale, ↑↓ naviga · ⏎ spawn · q esci
74
+ npm run build # tsc → dist/
75
+ ```
76
+
77
+ Il deck cerca `tasks.md` in `$PWD/${LOOM_DECK_DOCS_ROOT:-docs}/tasks.md`. Progetti
78
+ con docs-root non-standard esportano la variabile, es. `LOOM_DECK_DOCS_ROOT=runtime`.
79
+
80
+ La lista si **auto-aggiorna** quando `tasks.md` cambia sotto (poll su `mtime`, ~1.5s):
81
+ crei/checkpoint una task da un'altra sessione → il deck la riflette senza riavvio.
82
+
83
+ ## Licenza
84
+
85
+ MIT © 2026 lamemind
package/dist/cli.js ADDED
@@ -0,0 +1,411 @@
1
+ #!/usr/bin/env node
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { render, Box, Text, useApp, useInput } from 'ink';
4
+ import { useState, useEffect } from 'react';
5
+ import { spawn } from 'node:child_process';
6
+ import { statSync } from 'node:fs';
7
+ import { randomUUID } from 'node:crypto';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { dirname, join } from 'node:path';
10
+ import { resolveTasksPath, resolveTasksDir, loadTasks, loadTaskDetail, } from './tasks.js';
11
+ import { discoverProjectSessions } from './sessions.js';
12
+ import { appendTaskBinding, loadTaskBindings } from './task-index.js';
13
+ // scripts/deck-run è un sibling della dir del bundle: src/ (dev, tsx) e dist/
14
+ // (build, node) stanno entrambi sotto la package root → risalita di un livello.
15
+ const DECK_RUN = join(dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'deck-run');
16
+ const POLL_MS = 1500;
17
+ // Cap del pane sessioni: le più recenti (ts desc), le altre restano nell'indice
18
+ // ma fuori vista. Non-silenzioso → l'header mostra quante sono nascoste.
19
+ const MAX_SESSIONS = 30;
20
+ // Modello task-centrico: il Tasks pane ha, oltre alle task reali, una riga
21
+ // meta "spot" (sentinella) che raccoglie le sessioni NON legate ad alcuna task.
22
+ // La selezione nel Tasks pane è il "padre"; il Sessions pane mostra i suoi figli.
23
+ const SPOT = Symbol('spot');
24
+ function isDone(prog) {
25
+ return prog.includes('✔');
26
+ }
27
+ // Spawn detached: il deck spawna ma NON contiene la sessione (la possiede
28
+ // ptyxis-agent). unref + stdio ignore → ritorna subito, la TUI resta viva.
29
+ // sessionId pinnato (T27) → il binding sidecar è deterministico allo spawn.
30
+ function spawnDeck(id, cwd, sessionId) {
31
+ const child = spawn(DECK_RUN, [id, '--session-id', sessionId], {
32
+ cwd,
33
+ detached: true,
34
+ stdio: 'ignore',
35
+ });
36
+ child.unref();
37
+ return child;
38
+ }
39
+ // Editor esterni: aprono l'IDE sulla project root. Il comando risolve attraverso
40
+ // la shell login+interattiva dell'utente (bash -lic) → alias/funzioni in ~/.bashrc
41
+ // (es. `codium`=alias flatpak, `idea`=funzione) risolvono come nel terminale.
42
+ // Override via env per ambienti senza quegli alias (loom-deck è destinato a NPM).
43
+ const EDITOR_CMD = {
44
+ codium: process.env.LOOM_DECK_EDITOR_CODIUM ?? 'codium',
45
+ idea: process.env.LOOM_DECK_EDITOR_IDEA ?? 'idea',
46
+ };
47
+ // Spawn detached come spawnDeck: il deck lancia ma non possiede l'IDE. La project
48
+ // root arriva via $PWD (spawn cwd) → niente interpolazione di path, zero injection.
49
+ function openEditor(cmd, cwd) {
50
+ const child = spawn('bash', ['-lic', `${cmd} "$PWD"`], {
51
+ cwd,
52
+ detached: true,
53
+ stdio: 'ignore',
54
+ });
55
+ child.unref();
56
+ return child;
57
+ }
58
+ // Comando claude (override per ambienti dove non è su PATH; loom-deck → NPM).
59
+ const CLAUDE_CMD = process.env.LOOM_DECK_CLAUDE_CMD ?? 'claude';
60
+ // T30: create-task inline. Spawna CC HEADLESS (`-p`) con `--session-id` pinnato
61
+ // che invoca la skill create-task. Differenze da spawnDeck:
62
+ // - headless (`-p`), non una tab Ptyxis interattiva → il deck osserva l'esito;
63
+ // - `yolo` FORZATO: create-task è interattiva di default (AskUserQuestion) e in
64
+ // `-p` non può ricevere risposte → si impianterebbe. yolo = zero domande.
65
+ // - `--output-format stream-json` (richiede `--verbose`): l'ultima riga è
66
+ // `{type:"result", is_error}`, segnale di completamento robusto (> exit code).
67
+ // - detached (own process-group) → il create sopravvive alla chiusura del deck e
68
+ // completa commit+push da sé; stdout in pipe SOLO per leggere il result event.
69
+ // Il prompt viaggia come singolo argv (no shell) → nessuna injection dal testo utente.
70
+ function spawnCreateTask(text, cwd, sessionId, onResult) {
71
+ const child = spawn(CLAUDE_CMD, [
72
+ '-p',
73
+ '--output-format',
74
+ 'stream-json',
75
+ '--verbose',
76
+ '--session-id',
77
+ sessionId,
78
+ `/loom-works:create-task yolo ${text}`,
79
+ ], { cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
80
+ let buf = '';
81
+ let isError = null;
82
+ child.stdout?.on('data', (chunk) => {
83
+ buf += chunk.toString();
84
+ let nl;
85
+ while ((nl = buf.indexOf('\n')) >= 0) {
86
+ const line = buf.slice(0, nl).trim();
87
+ buf = buf.slice(nl + 1);
88
+ if (!line)
89
+ continue;
90
+ try {
91
+ const obj = JSON.parse(line);
92
+ if (obj.type === 'result')
93
+ isError = obj.is_error ?? false;
94
+ }
95
+ catch {
96
+ // riga parziale / non-json → skip
97
+ }
98
+ }
99
+ });
100
+ // Drena stderr per non riempire il buffer pipe (deadlock del figlio).
101
+ child.stderr?.on('data', () => { });
102
+ child.on('error', () => onResult(false));
103
+ child.on('close', (code) => {
104
+ onResult(isError === null ? code === 0 : !isError);
105
+ });
106
+ return child;
107
+ }
108
+ // Carica tasks.md e lo ri-legge quando cambia sotto (poll su mtime). Poll
109
+ // (non fs.watch) perché i writer di tasks.md — checkpoint-task/create-task —
110
+ // riscrivono il file (probabile replace atomico), che rompe il watch sull'inode
111
+ // originale; statSync(path) segue sempre il file corrente al path.
112
+ function useTasks(tasksPath) {
113
+ const [tasks, setTasks] = useState([]);
114
+ const [loadError, setLoadError] = useState('');
115
+ useEffect(() => {
116
+ let lastMtime = -1;
117
+ const reload = () => {
118
+ try {
119
+ const mtime = statSync(tasksPath).mtimeMs;
120
+ if (mtime === lastMtime)
121
+ return; // invariato → niente re-read
122
+ lastMtime = mtime;
123
+ setTasks(loadTasks(tasksPath));
124
+ setLoadError('');
125
+ }
126
+ catch {
127
+ lastMtime = -1; // così quando il file riappare viene ri-letto
128
+ setTasks([]);
129
+ setLoadError(`tasks.md non leggibile: ${tasksPath}`);
130
+ }
131
+ };
132
+ reload();
133
+ const id = setInterval(reload, POLL_MS);
134
+ return () => clearInterval(id);
135
+ }, [tasksPath]);
136
+ return { tasks, loadError };
137
+ }
138
+ // Poll delle sessioni del progetto + binding sidecar. discoverProjectSessions
139
+ // ha cache mtime-keyed interna → il poll è economico; qui si evita comunque il
140
+ // re-render inutile con una signature (sessionId:ts + binding entries): setState
141
+ // solo quando cambia davvero qualcosa.
142
+ function useSessions(projectRoot) {
143
+ const [state, setState] = useState({
144
+ sessions: [],
145
+ bindings: new Map(),
146
+ });
147
+ useEffect(() => {
148
+ let lastSig = '';
149
+ const reload = () => {
150
+ let sessions;
151
+ let bindings;
152
+ try {
153
+ sessions = discoverProjectSessions(projectRoot);
154
+ bindings = loadTaskBindings(projectRoot);
155
+ }
156
+ catch {
157
+ sessions = [];
158
+ bindings = new Map();
159
+ }
160
+ const sig = sessions.map((s) => `${s.sessionId}:${s.ts}`).join('|') +
161
+ '#' +
162
+ [...bindings.entries()].map(([k, v]) => `${k}=${v}`).sort().join(',');
163
+ if (sig === lastSig)
164
+ return;
165
+ lastSig = sig;
166
+ setState({ sessions, bindings });
167
+ };
168
+ reload();
169
+ const id = setInterval(reload, POLL_MS);
170
+ return () => clearInterval(id);
171
+ }, [projectRoot]);
172
+ return state;
173
+ }
174
+ // Legge il task file della task selezionata (Q1+B T20). On-id-change: navigare
175
+ // con ↑↓ ricarica il dettaglio; leggere un singolo file 4-9KB è I/O triviale,
176
+ // niente debounce serve per la tastiera. Il refresh del contenuto a file fermo
177
+ // (es. checkpoint aggiorna Progress) è demandato al prossimo cambio selezione.
178
+ function useTaskDetail(tasksDir, id) {
179
+ const [detail, setDetail] = useState(null);
180
+ useEffect(() => {
181
+ setDetail(id ? loadTaskDetail(tasksDir, id) : null);
182
+ }, [tasksDir, id]);
183
+ return detail;
184
+ }
185
+ // Collassa gli spazi (description multi-paragrafo → blocco unico wrappabile) e
186
+ // tronca: il pane resta compatto e d'altezza prevedibile sotto la lista.
187
+ function truncate(s, n) {
188
+ const flat = s.replace(/\s+/g, ' ').trim();
189
+ return flat.length > n ? flat.slice(0, n - 1).trimEnd() + '…' : flat;
190
+ }
191
+ // Forza la presentazione emoji (larghezza 2 non-ambigua) su un glifo BMP
192
+ // text-default come ⚡ (U+26A1, range simboli U+2190–U+2BFF). Senza il variation
193
+ // selector VS16 questi glifi sono larghi-ambigui: Ink li misura 1, ma string-width
194
+ // e il terminale li disegnano 2 → la riga esce 1 colonna troppo larga, sfonda il
195
+ // pane e va a capo (righe vuote spurie). Gli emoji astrali (🔥🔵🟡…, U+1F000+) sono
196
+ // già width-2 non-ambigui e le sequenze con VS16 (✔️) sono >1 code point → intatti.
197
+ const VS16 = '️';
198
+ function forceEmojiWidth(s) {
199
+ const cps = [...s];
200
+ if (cps.length !== 1)
201
+ return s;
202
+ const cp = cps[0].codePointAt(0);
203
+ if (cp >= 0x2190 && cp <= 0x2bff)
204
+ return s + VS16;
205
+ return s;
206
+ }
207
+ // Età relativa compatta (ms epoch → "2m"/"3h"/"5d") per il preview sessioni.
208
+ function relTime(ts) {
209
+ const sec = Math.max(0, Math.floor((Date.now() - ts) / 1000));
210
+ if (sec < 60)
211
+ return `${sec}s`;
212
+ const min = Math.floor(sec / 60);
213
+ if (min < 60)
214
+ return `${min}m`;
215
+ const hr = Math.floor(min / 60);
216
+ if (hr < 24)
217
+ return `${hr}h`;
218
+ return `${Math.floor(hr / 24)}d`;
219
+ }
220
+ const META_KEYS = ['Priority', 'Size', 'Estimated Time', 'Progress'];
221
+ function Deck({ cwd, tasksPath, tasksDir }) {
222
+ const { exit } = useApp();
223
+ const { tasks, loadError } = useTasks(tasksPath);
224
+ const { sessions, bindings } = useSessions(cwd);
225
+ const [focus, setFocus] = useState('tasks');
226
+ // selParent ∈ [0, tasks.length]: l'indice 0 è la riga meta "spot" (prima voce),
227
+ // gli indici 1..tasks.length sono le task reali (task = selParent-1).
228
+ const [selParent, setSelParent] = useState(0);
229
+ const [selSession, setSelSession] = useState(0);
230
+ const [note, setNote] = useState('');
231
+ // T30: modale di creazione task inline. mode='create' → useInput cattura il
232
+ // testo (gating della navigazione normale) e mostra l'input box.
233
+ const [mode, setMode] = useState('normal');
234
+ const [draft, setDraft] = useState('');
235
+ const isSpot = selParent === 0;
236
+ const projectName = cwd.split('/').pop() || cwd;
237
+ const selectedTaskId = isSpot ? null : tasks[selParent - 1]?.id ?? null;
238
+ const detail = useTaskDetail(tasksDir, selectedTaskId ?? undefined);
239
+ // Conteggio figli per task + spot (badge nel Tasks pane).
240
+ const childCount = new Map();
241
+ let spotCount = 0;
242
+ for (const s of sessions) {
243
+ const bound = bindings.get(s.sessionId);
244
+ if (bound)
245
+ childCount.set(bound, (childCount.get(bound) ?? 0) + 1);
246
+ else
247
+ spotCount++;
248
+ }
249
+ // Figli della selezione: sessioni bound alla task selezionata, oppure (spot)
250
+ // le sessioni senza binding. sessions è già ts desc → l'ordine si eredita.
251
+ const childSessions = sessions.filter((s) => {
252
+ const bound = bindings.get(s.sessionId);
253
+ return selectedTaskId ? bound === selectedTaskId : !bound;
254
+ });
255
+ const visibleSessions = childSessions.slice(0, MAX_SESSIONS);
256
+ const hiddenSessions = childSessions.length - visibleSessions.length;
257
+ // Dopo un refresh le liste possono accorciarsi: tieni le selezioni in range
258
+ // (max index = tasks.length; spot è sempre l'indice 0).
259
+ useEffect(() => {
260
+ setSelParent((s) => Math.min(s, tasks.length));
261
+ }, [tasks.length]);
262
+ // Cambio padre → riparti dalla prima sessione figlia.
263
+ useEffect(() => {
264
+ setSelSession(0);
265
+ }, [selParent]);
266
+ useEffect(() => {
267
+ setSelSession((s) => Math.min(s, Math.max(0, visibleSessions.length - 1)));
268
+ }, [visibleSessions.length]);
269
+ // T30: submit dell'input box. Il taskId nasce DOPO create-task (lo assegna la
270
+ // skill scrivendo tasks.md) → non è noto allo spawn. Il sessionId invece è
271
+ // pinnato qui: snapshot degli id PRIMA, poi al completamento re-leggo tasks.md
272
+ // e il diff dà il nuovo id → appendTaskBinding lega la sessione (scoped).
273
+ function submitCreate() {
274
+ const text = draft.trim();
275
+ setMode('normal');
276
+ setDraft('');
277
+ if (!text) {
278
+ setNote('c → create annullato (vuoto)');
279
+ return;
280
+ }
281
+ const sid = randomUUID();
282
+ const beforeIds = new Set(tasks.map((t) => t.id));
283
+ setNote(`⏳ creando task… "${truncate(text, 40)}" (sid ${sid.slice(0, 8)})`);
284
+ const child = spawnCreateTask(text, cwd, sid, (ok) => {
285
+ if (!ok) {
286
+ setNote(`⚠ create-task fallito (${CLAUDE_CMD} -p)`);
287
+ return;
288
+ }
289
+ let newId;
290
+ try {
291
+ newId = loadTasks(tasksPath).find((t) => !beforeIds.has(t.id))?.id;
292
+ }
293
+ catch {
294
+ // tasks.md illeggibile → id non rilevato, sotto
295
+ }
296
+ if (newId) {
297
+ appendTaskBinding(cwd, sid, newId);
298
+ setNote(`✔ ${newId} creata · sessione scoped (sid ${sid.slice(0, 8)})`);
299
+ }
300
+ else {
301
+ setNote(`✔ task creata (id non rilevato) · sid ${sid.slice(0, 8)}`);
302
+ }
303
+ });
304
+ child.on('error', () => setNote(`⚠ create-task: '${CLAUDE_CMD}' non lanciabile`));
305
+ }
306
+ useInput((input, key) => {
307
+ // T30: in modalità create l'handler cattura il testo e corto-circuita la
308
+ // navigazione normale (incl. q/esc → quit: qui esc annulla, non esce).
309
+ if (mode === 'create') {
310
+ if (key.escape) {
311
+ setMode('normal');
312
+ setDraft('');
313
+ setNote('c → create annullato');
314
+ }
315
+ else if (key.return) {
316
+ submitCreate();
317
+ }
318
+ else if (key.backspace || key.delete) {
319
+ setDraft((d) => d.slice(0, -1));
320
+ }
321
+ else if (input && !key.ctrl && !key.meta) {
322
+ setDraft((d) => d + input);
323
+ }
324
+ return;
325
+ }
326
+ if (key.leftArrow || key.rightArrow || key.tab) {
327
+ setFocus((f) => (f === 'tasks' ? 'sessions' : 'tasks'));
328
+ }
329
+ else if (key.upArrow) {
330
+ if (focus === 'tasks')
331
+ setSelParent((i) => Math.max(0, i - 1));
332
+ else
333
+ setSelSession((i) => Math.max(0, i - 1));
334
+ }
335
+ else if (key.downArrow) {
336
+ if (focus === 'tasks')
337
+ setSelParent((i) => Math.min(tasks.length, i + 1));
338
+ else
339
+ setSelSession((i) => Math.min(visibleSessions.length - 1, i + 1));
340
+ }
341
+ else if (key.return) {
342
+ if (focus === 'tasks') {
343
+ if (isSpot) {
344
+ setNote('spot: sessioni libere, nessuna task da spawnare');
345
+ }
346
+ else {
347
+ const task = tasks[selParent - 1];
348
+ if (task) {
349
+ // sessionId pinnato lato deck → binding sidecar scritto PRIMA dello
350
+ // spawn: la sessione risulta figlia della task appena il JSONL compare.
351
+ const sid = randomUUID();
352
+ appendTaskBinding(cwd, sid, task.id);
353
+ const child = spawnDeck(task.id, cwd, sid);
354
+ // Un errore di spawn è async: senza handler diventa uncaughtException
355
+ // e ucciderebbe il deck. Lo intercetto per preservare l'invariante
356
+ // "il deck resta vivo" e mostro la nota d'errore.
357
+ child.on('error', () => setNote(`⚠ spawn ${task.id} fallito (${DECK_RUN})`));
358
+ setNote(`⏎ spawn ${task.id} → tab CC (sid ${sid.slice(0, 8)})`);
359
+ }
360
+ }
361
+ }
362
+ else {
363
+ setNote('sessioni read-only in T27 · fork/resume → T28');
364
+ }
365
+ }
366
+ else if (input === 'c') {
367
+ setNote('');
368
+ setMode('create');
369
+ }
370
+ else if (input === 'C') {
371
+ const child = openEditor(EDITOR_CMD.codium, cwd);
372
+ child.on('error', () => setNote(`⚠ codium: '${EDITOR_CMD.codium}' non lanciabile`));
373
+ setNote(`C → codium su ${projectName}`);
374
+ }
375
+ else if (input === 'I') {
376
+ const child = openEditor(EDITOR_CMD.idea, cwd);
377
+ child.on('error', () => setNote(`⚠ idea: '${EDITOR_CMD.idea}' non lanciabile`));
378
+ setNote(`I → idea su ${projectName}`);
379
+ }
380
+ else if (input === 'q' || key.escape) {
381
+ exit();
382
+ }
383
+ });
384
+ const selSessionId = visibleSessions[selSession]?.sessionId;
385
+ const parentLabel = isSpot ? 'spot' : selectedTaskId ?? '—';
386
+ const canSpawn = focus === 'tasks' && !isSpot;
387
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), mode === 'create' ? (_jsxs(Text, { dimColor: true, children: ["nuova task \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " crea \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsxs(Text, { dimColor: true, children: ["\u2191\u2193 naviga \u00B7 \u2190\u2192 pane \u00B7 \u23CE ", canSpawn ? 'spawn' : '—', " \u00B7 c nuova \u00B7 C codium \u00B7 I idea \u00B7 q esci \u00B7 focus: ", _jsx(Text, { color: "cyan", children: focus })] })), mode === 'create' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "c \u203A " }), _jsx(Text, { children: draft }), _jsx(Text, { inverse: true, children: " " })] })) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: tasks, selected: selParent, spotCount: spotCount, childCount: childCount, focused: focus === 'tasks', loadError: loadError, detail: detail }), _jsx(SessionsPane, { parentLabel: parentLabel, isSpot: isSpot, sessions: visibleSessions, total: childSessions.length, hidden: hiddenSessions, selectedId: selSessionId, focused: focus === 'sessions' })] }), note ? _jsx(Text, { color: "green", children: note }) : null] }));
388
+ }
389
+ function TasksPane({ tasks, selected, spotCount, childCount, focused, loadError, detail, }) {
390
+ const spotSelected = selected === 0;
391
+ return (_jsxs(Box, { flexDirection: "column", width: "50%", marginRight: 1, borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsxs(Text, { bold: true, color: focused ? 'cyan' : undefined, children: ["Tasks (", tasks.length, ")"] }), _jsxs(Text, { inverse: spotSelected && focused, bold: spotSelected && !focused, wrap: "truncate-end", children: [spotSelected ? '▶ ' : ' ', "\u25CB spot sessioni libere", spotCount > 0 ? ` (${spotCount})` : ''] }), loadError ? (_jsx(Text, { color: "red", wrap: "truncate-end", children: loadError })) : (tasks.map((task, i) => {
392
+ const sel = i + 1 === selected; // +1: lo 0 è spot
393
+ const n = childCount.get(task.id) ?? 0;
394
+ return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: !sel && isDone(task.prog), wrap: "truncate-end", children: [sel ? '▶ ' : ' ', task.id, " ", forceEmojiWidth(task.pri), " ", forceEmojiWidth(task.prog), " ", task.desc, n > 0 ? ` (${n})` : ''] }, task.id));
395
+ })), detail ? _jsx(DetailPane, { detail: detail }) : null] }));
396
+ }
397
+ function SessionsPane({ parentLabel, isSpot, sessions, total, hidden, selectedId, focused, }) {
398
+ return (_jsxs(Box, { flexDirection: "column", width: "50%", borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsxs(Text, { bold: true, color: focused ? 'cyan' : undefined, children: ["Sessions \u00B7 ", parentLabel, " (", total, ")", hidden > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 +", hidden, " pi\u00F9 vecchie"] }) : null] }), total === 0 ? (_jsx(Text, { color: "yellow", wrap: "truncate-end", children: isSpot ? 'nessuna sessione libera' : 'nessuna sessione legata a questa task' })) : (sessions.map((s) => {
399
+ const sel = s.sessionId === selectedId;
400
+ return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, wrap: "truncate-end", children: [sel ? '▶ ' : ' ', isSpot ? _jsx(Text, { dimColor: true, children: "\u25CB" }) : _jsx(Text, { color: "green", children: "\uD83D\uDD17" }), ' ', truncate(s.title, 44), ' ', _jsxs(Text, { dimColor: true, children: ["\u00B7 ", s.gitBranch || '-', " \u00B7 ", relTime(s.ts)] })] }, s.sessionId));
401
+ }))] }));
402
+ }
403
+ function DetailPane({ detail }) {
404
+ const meta = META_KEYS.map((k) => detail.fields[k])
405
+ .filter(Boolean)
406
+ .join(' · ');
407
+ const commit = detail.fields['Last tracked commit'];
408
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, children: detail.title || detail.id }), meta ? _jsx(Text, { dimColor: true, children: meta }) : null, detail.description ? _jsx(Text, { wrap: "wrap", children: truncate(detail.description, 300) }) : null, commit ? _jsxs(Text, { dimColor: true, children: ["\u21B3 ", commit] }) : null] }));
409
+ }
410
+ const cwd = process.cwd();
411
+ render(_jsx(Deck, { cwd: cwd, tasksPath: resolveTasksPath(cwd), tasksDir: resolveTasksDir(cwd) }));
@@ -0,0 +1,158 @@
1
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { basename, join } from 'node:path';
4
+ // CC codifica la project dir sostituendo ogni char non-alfanumerico con '-'
5
+ // (verificato: `/home/lamemind/cc-host` → `-home-lamemind-cc-host`). È LOSSY
6
+ // (un '-' nel path e un '/' collassano nello stesso char) → il forward è
7
+ // deterministico ma non reversibile; per questo filtriamo comunque per cwd.
8
+ export function projectDirName(projectRoot) {
9
+ return projectRoot.replace(/[^a-zA-Z0-9]/g, '-');
10
+ }
11
+ function claudeProjectsRoot() {
12
+ return join(homedir(), '.claude', 'projects');
13
+ }
14
+ // Collassa whitespace/newline e strippa i tag XML-ish dei wrapper comando CC
15
+ // (`<command-name>`, `<local-command-*>`, …) per una preview leggibile.
16
+ function cleanPreview(s) {
17
+ return s
18
+ .replace(/<[^>]+>/g, ' ')
19
+ .replace(/\s+/g, ' ')
20
+ .trim();
21
+ }
22
+ function extractUserText(message) {
23
+ if (!message || typeof message !== 'object')
24
+ return '';
25
+ const content = message.content;
26
+ if (typeof content === 'string')
27
+ return cleanPreview(content);
28
+ if (Array.isArray(content)) {
29
+ for (const block of content) {
30
+ if (block &&
31
+ typeof block === 'object' &&
32
+ block.type === 'text' &&
33
+ typeof block.text === 'string') {
34
+ const t = cleanPreview(block.text);
35
+ if (t)
36
+ return t;
37
+ }
38
+ }
39
+ }
40
+ return '';
41
+ }
42
+ // Cache mtime-keyed: re-parse solo i file cambiati. Steady-state di una TUI che
43
+ // poll-a resta economico anche con decine di sessioni multi-MB. Lo stato vive
44
+ // dentro l'adapter così l'invariante "unico modulo che tocca lo store" regge.
45
+ const cache = new Map();
46
+ function parseSessionFile(path, mtime) {
47
+ let content;
48
+ try {
49
+ content = readFileSync(path, 'utf8');
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ let sessionId = basename(path).replace(/\.jsonl$/, '');
55
+ let cwd = '';
56
+ let gitBranch = '';
57
+ let parentUuid = null;
58
+ let customTitle = '';
59
+ let firstUserText = '';
60
+ for (const line of content.split('\n')) {
61
+ if (!line.trim())
62
+ continue;
63
+ let d;
64
+ try {
65
+ d = JSON.parse(line);
66
+ }
67
+ catch {
68
+ continue;
69
+ }
70
+ if (typeof d.sessionId === 'string' && d.sessionId)
71
+ sessionId = d.sessionId;
72
+ if (!cwd && typeof d.cwd === 'string' && d.cwd)
73
+ cwd = d.cwd;
74
+ if (!gitBranch && typeof d.gitBranch === 'string' && d.gitBranch)
75
+ gitBranch = d.gitBranch;
76
+ if (parentUuid === null && typeof d.parentUuid === 'string' && d.parentUuid) {
77
+ parentUuid = d.parentUuid;
78
+ }
79
+ if (typeof d.customTitle === 'string' && d.customTitle)
80
+ customTitle = d.customTitle; // last-wins
81
+ if (!firstUserText && d.type === 'user') {
82
+ const t = extractUserText(d.message);
83
+ if (t)
84
+ firstUserText = t;
85
+ }
86
+ }
87
+ if (!cwd)
88
+ return null; // nessuna riga con cwd → non è una sessione di progetto valida
89
+ return {
90
+ sessionId,
91
+ cwd,
92
+ gitBranch,
93
+ parentUuid,
94
+ title: customTitle || firstUserText || '(senza titolo)',
95
+ ts: mtime,
96
+ path,
97
+ };
98
+ }
99
+ // Discovery read-only delle sessioni del SOLO progetto corrente (D2 preflight
100
+ // T27): legge la project dir calcolata dal forward-transform, filtra per cwd
101
+ // (difesa contro le collisioni lossy del naming), ordina per ts desc.
102
+ export function discoverProjectSessions(projectRoot) {
103
+ const dir = join(claudeProjectsRoot(), projectDirName(projectRoot));
104
+ let files;
105
+ try {
106
+ files = readdirSync(dir).filter((f) => f.endsWith('.jsonl'));
107
+ }
108
+ catch {
109
+ return [];
110
+ }
111
+ const out = [];
112
+ const seen = new Set();
113
+ for (const f of files) {
114
+ const path = join(dir, f);
115
+ seen.add(path);
116
+ let mtime;
117
+ try {
118
+ mtime = statSync(path).mtimeMs;
119
+ }
120
+ catch {
121
+ continue;
122
+ }
123
+ const cached = cache.get(path);
124
+ let session;
125
+ if (cached && cached.mtime === mtime) {
126
+ session = cached.session;
127
+ }
128
+ else {
129
+ session = parseSessionFile(path, mtime);
130
+ cache.set(path, { mtime, session });
131
+ }
132
+ if (session && (session.cwd === projectRoot || session.cwd.startsWith(projectRoot + '/'))) {
133
+ out.push(session);
134
+ }
135
+ }
136
+ for (const key of cache.keys())
137
+ if (!seen.has(key))
138
+ cache.delete(key);
139
+ out.sort((a, b) => b.ts - a.ts);
140
+ return out;
141
+ }
142
+ // Raggruppa per gitBranch (D2: group-by-branch nel progetto corrente). Ordine
143
+ // dei gruppi = sessione più recente nel gruppo (desc); dentro il gruppo resta
144
+ // l'ordine ts desc ereditato dall'input.
145
+ export function groupByBranch(sessions) {
146
+ const map = new Map();
147
+ for (const s of sessions) {
148
+ const branch = s.gitBranch || '(no branch)';
149
+ const arr = map.get(branch);
150
+ if (arr)
151
+ arr.push(s);
152
+ else
153
+ map.set(branch, [s]);
154
+ }
155
+ return [...map.entries()]
156
+ .map(([branch, arr]) => ({ branch, sessions: arr }))
157
+ .sort((a, b) => b.sessions[0].ts - a.sessions[0].ts);
158
+ }
@@ -0,0 +1,46 @@
1
+ import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ // SIDECAR sessionId ↔ taskId (T27).
4
+ //
5
+ // Lo store JSONL di CC NON registra LOOM_TASK/taskId: la classificazione
6
+ // spot vs scoped non può derivare dal transcript. La verità è QUESTO indice,
7
+ // che il deck popola allo spawn — quando pinna `--session-id <uuid>` (D1
8
+ // preflight) il sessionId è già noto, quindi il binding è deterministico.
9
+ //
10
+ // Store (D3 preflight): project-local `<root>/.claude/loom/session-tasks.jsonl`,
11
+ // JSONL append-only. Append (non read-modify-write) = concurrency-safe fra
12
+ // spawn concorrenti; last-wins in lettura copre eventuali re-pin.
13
+ export function taskIndexPath(projectRoot) {
14
+ return join(projectRoot, '.claude', 'loom', 'session-tasks.jsonl');
15
+ }
16
+ export function appendTaskBinding(projectRoot, sessionId, taskId) {
17
+ const path = taskIndexPath(projectRoot);
18
+ mkdirSync(dirname(path), { recursive: true });
19
+ const record = { sessionId, taskId, ts: new Date().toISOString() };
20
+ appendFileSync(path, JSON.stringify(record) + '\n');
21
+ }
22
+ // sessionId → taskId, last-wins (un re-pin dello stesso sessionId sovrascrive).
23
+ export function loadTaskBindings(projectRoot) {
24
+ const bindings = new Map();
25
+ let content;
26
+ try {
27
+ content = readFileSync(taskIndexPath(projectRoot), 'utf8');
28
+ }
29
+ catch {
30
+ return bindings;
31
+ }
32
+ for (const line of content.split('\n')) {
33
+ if (!line.trim())
34
+ continue;
35
+ try {
36
+ const d = JSON.parse(line);
37
+ if (typeof d.sessionId === 'string' && typeof d.taskId === 'string') {
38
+ bindings.set(d.sessionId, d.taskId);
39
+ }
40
+ }
41
+ catch {
42
+ // riga corrotta → skip
43
+ }
44
+ }
45
+ return bindings;
46
+ }
package/dist/tasks.js ADDED
@@ -0,0 +1,100 @@
1
+ import { readFileSync, readdirSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ // D1 (preflight T20): default docs/tasks.md, override della docs-root via env
4
+ // LOOM_DECK_DOCS_ROOT (es. questo progetto usa `runtime`). No auto-detect.
5
+ export function resolveTasksPath(cwd = process.cwd()) {
6
+ const docsRoot = process.env.LOOM_DECK_DOCS_ROOT || 'docs';
7
+ return join(cwd, docsRoot, 'tasks.md');
8
+ }
9
+ // I task file vivono in `<docsRoot>/tasks/` — sibling di tasks.md. Derivo la
10
+ // dir dallo stesso path per rispettare l'override LOOM_DECK_DOCS_ROOT.
11
+ export function resolveTasksDir(cwd = process.cwd()) {
12
+ return join(dirname(resolveTasksPath(cwd)), 'tasks');
13
+ }
14
+ // Estrae le righe `| Tnn | Pri | K | Prog | Task |` della Tasks Overview.
15
+ // Il prefisso task è `T` (contratto plugin); le doc-task `D{N}` e le righe
16
+ // header/separatore non matchano `^T\d+$` → scartate. deck-run agisce solo
17
+ // su task T (run-task), quindi le D non hanno azione qui.
18
+ export function parseTasks(content) {
19
+ const tasks = [];
20
+ for (const line of content.split('\n')) {
21
+ const t = line.trim();
22
+ if (!t.startsWith('|'))
23
+ continue;
24
+ const cells = t.split('|').map((c) => c.trim());
25
+ // cells[0] = '' (prima del primo |), cells[last] = '' (dopo l'ultimo |)
26
+ const id = cells[1];
27
+ if (!/^T\d+$/.test(id))
28
+ continue;
29
+ // Colonne overview: | ID | Pri | K | Prog | Task | → cells[2]=Pri (icona
30
+ // priorità), cells[4]=Prog. K (Kind, cells[3]) non serve al deck.
31
+ const pri = cells[2] ?? '';
32
+ const prog = cells[4] ?? '';
33
+ // desc = colonna finale; join per resistere a eventuali `|` nella descrizione.
34
+ const desc = cells.slice(5, -1).join('|').trim();
35
+ tasks.push({ id, pri, prog, desc });
36
+ }
37
+ return tasks;
38
+ }
39
+ export function loadTasks(path) {
40
+ return parseTasks(readFileSync(path, 'utf8'));
41
+ }
42
+ // ID → path del task file: `<id>-<slug>.md` nella tasks dir. Il dash dopo l'ID
43
+ // disambigua i prefissi (`T20-` non matcha `T2-…`). Se più file matchano, primo
44
+ // in ordine. `null` se la dir non è leggibile o nessun file matcha.
45
+ export function findTaskFile(dir, id) {
46
+ let entries;
47
+ try {
48
+ entries = readdirSync(dir);
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ const prefix = `${id}-`;
54
+ const match = entries
55
+ .filter((f) => f.startsWith(prefix) && f.endsWith('.md'))
56
+ .sort()[0];
57
+ return match ? join(dir, match) : null;
58
+ }
59
+ // Estrae title (H1, `Task:` prefix strippato), bullet header e blocco
60
+ // `## Description`. First-match-wins per chiave: se un campo compare due volte
61
+ // (es. T15 ha una riga Progress `yyyy-MM-dd` residuo template nel body) vince la
62
+ // prima — quella dell'header. La description si ferma al successivo `## `.
63
+ export function parseTaskDetail(id, content) {
64
+ let title = '';
65
+ const fields = {};
66
+ const descLines = [];
67
+ let inDesc = false;
68
+ for (const line of content.split('\n')) {
69
+ if (!title && line.startsWith('# ')) {
70
+ title = line.replace(/^#\s+/, '').replace(/^Task:\s*/, '').trim();
71
+ continue;
72
+ }
73
+ if (line.startsWith('## ')) {
74
+ inDesc = /^##\s+Description\b/.test(line);
75
+ continue;
76
+ }
77
+ if (inDesc) {
78
+ descLines.push(line);
79
+ continue;
80
+ }
81
+ const f = line.match(/^-\s*\*\*(.+?)\*\*:\s*(.*)$/);
82
+ if (f) {
83
+ const key = f[1].trim();
84
+ if (!(key in fields))
85
+ fields[key] = f[2].trim();
86
+ }
87
+ }
88
+ return { id, title, fields, description: descLines.join('\n').trim() };
89
+ }
90
+ export function loadTaskDetail(dir, id) {
91
+ const path = findTaskFile(dir, id);
92
+ if (!path)
93
+ return null;
94
+ try {
95
+ return parseTaskDetail(id, readFileSync(path, 'utf8'));
96
+ }
97
+ catch {
98
+ return null;
99
+ }
100
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@lamemind/loom-deck",
3
+ "version": "0.5.0",
4
+ "description": "Deck TUI Ink per-progetto della famiglia loom: legge tasks.md e spawna sessioni Claude Code bound via LOOM_TASK",
5
+ "type": "module",
6
+ "bin": {
7
+ "loom-deck": "./dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "scripts"
12
+ ],
13
+ "scripts": {
14
+ "dev": "tsx src/cli.tsx",
15
+ "build": "tsc",
16
+ "start": "node dist/cli.js",
17
+ "prepare": "tsc"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/lamemind/loom-deck.git"
22
+ },
23
+ "homepage": "https://github.com/lamemind/loom-deck#readme",
24
+ "bugs": {
25
+ "url": "https://github.com/lamemind/loom-deck/issues"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "engines": {
31
+ "node": ">=18"
32
+ },
33
+ "keywords": [
34
+ "loom",
35
+ "ink",
36
+ "tui",
37
+ "deck",
38
+ "claude-code"
39
+ ],
40
+ "author": "lamemind",
41
+ "license": "MIT",
42
+ "dependencies": {
43
+ "ink": "^5.1.0",
44
+ "react": "^18.3.1"
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^22.20.0",
48
+ "@types/react": "^18.3.12",
49
+ "tsx": "^4.19.2",
50
+ "typescript": "^5.6.3"
51
+ }
52
+ }
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # deck-run <TaskID> — spike ① spawn primitive (UI-agnostico) di loom-deck.
4
+ #
5
+ # Apre una tab Ptyxis nella window ATTIVA (quella col focus = il deck) e vi
6
+ # avvia una sessione Claude Code già bound alla task via LOOM_TASK, dritta sul
7
+ # prompt iniziale (default: prompt diretto "recap stato task <TaskID>" — no skill,
8
+ # solo recap → più controllo). Riusabile identico da TUI (Ink) e da web.
9
+ #
10
+ # Modalità spawn (LOOM_DECK_SPAWN_MODE):
11
+ # inline → ptyxis --tab -- <cmd> (default) comando inline, LOOM_TASK diretta, zero dconf
12
+ # profile → ptyxis --tab-with-profile=<UUID> riusa il profilo, riscrive il custom-command via dconf
13
+ #
14
+ # La scelta fra le due è la Decisione aperta §9 della proposta: la modalità
15
+ # "inline" è il default perché non muta stato dconf condiviso. Vedi la nota
16
+ # di decisione nella task folder di T18.
17
+ #
18
+ # Env:
19
+ # LOOM_DECK_SPAWN_MODE inline|profile (default: inline)
20
+ # LOOM_DECK_PROFILE_UUID UUID profilo Ptyxis (default: cc-host)
21
+ # LOOM_DECK_WORKDIR dir di lavoro della tab (default: $PWD)
22
+ # LOOM_DECK_ENTER_PROMPT prompt iniziale sessione, {TASK}=TaskID (default: "recap stato task <TaskID>")
23
+ # LOOM_DECK_INTAB_CMD override comando in-tab (default: la sessione CC; usato per i test)
24
+ #
25
+ set -euo pipefail
26
+
27
+ TASK=""
28
+ SESSION_ID=""
29
+ # Positional <TaskID> + flag opzionale --session-id <uuid> (T27): il deck genera
30
+ # l'UUID e lo pinna così il binding sidecar sessionId↔taskId è deterministico.
31
+ while [[ $# -gt 0 ]]; do
32
+ case "$1" in
33
+ --session-id)
34
+ SESSION_ID="${2:-}"; shift 2 ;;
35
+ --session-id=*)
36
+ SESSION_ID="${1#*=}"; shift ;;
37
+ *)
38
+ if [[ -z "$TASK" ]]; then TASK="$1"; shift
39
+ else echo "argomento inatteso: '$1'" >&2; exit 2; fi ;;
40
+ esac
41
+ done
42
+
43
+ if [[ -z "$TASK" ]]; then
44
+ echo "uso: deck-run <TaskID> [--session-id <uuid>] (es. deck-run T18)" >&2
45
+ exit 2
46
+ fi
47
+
48
+ MODE="${LOOM_DECK_SPAWN_MODE:-inline}"
49
+ PROFILE_UUID="${LOOM_DECK_PROFILE_UUID:-5a36ae48df1c4d4882f43060e3e59656}"
50
+ WORKDIR="${LOOM_DECK_WORKDIR:-$PWD}"
51
+
52
+ # ── Titolo tab = label standard loom (matchabile da compass) ──────────────────
53
+ # compass matcha le finestre con `window.title.includes(project.label)`, dove la
54
+ # label è DERIVATA dal contratto committato .claude/loom-works.json:
55
+ # `{emoji} {owner} {name}` → es. "🧵 LOCAL loom-works". Un titolo "cc <task>"
56
+ # non contiene la label → finestra ORFANA (progetto appare spento anche mentre ci
57
+ # lavori dentro). Deriviamo la label dal file e la usiamo come titolo, con
58
+ # suffisso "· <task>" per restare distinguibili fra più tab claude (il suffisso
59
+ # non rompe il match: è .includes, non equals — stesso pattern del "· deck").
60
+ # Fallback su "cc <task>" se manca il file o jq (progetto non loom-registered).
61
+ _find_project_root() { # <start-dir> → dir con .claude/loom-works.json, o vuoto
62
+ local d="${1%/}"
63
+ while [[ -n "$d" && "$d" != "/" ]]; do
64
+ [[ -f "$d/.claude/loom-works.json" ]] && { printf '%s\n' "$d"; return 0; }
65
+ d="$(dirname "$d")"
66
+ done
67
+ [[ -f "/.claude/loom-works.json" ]] && { printf '/\n'; return 0; }
68
+ return 1
69
+ }
70
+
71
+ TITLE="cc ${TASK}"
72
+ if command -v jq >/dev/null 2>&1; then
73
+ _proot="$(_find_project_root "$WORKDIR" || true)"
74
+ if [[ -n "${_proot:-}" ]]; then
75
+ _label="$(jq -r 'if (.emoji and .owner and .name)
76
+ then "\(.emoji) \(.owner) \(.name)" else empty end' \
77
+ "${_proot}/.claude/loom-works.json" 2>/dev/null || true)"
78
+ [[ -n "${_label:-}" ]] && TITLE="${_label} · ${TASK}"
79
+ fi
80
+ fi
81
+
82
+ # --session-id per la tab: pinna il sessionId della sessione CC (vuoto → CC ne
83
+ # genera uno). Lo spazio finale tiene la concatenazione pulita quando è assente.
84
+ SID_FLAG=""
85
+ [[ -n "$SESSION_ID" ]] && SID_FLAG="--session-id ${SESSION_ID} "
86
+
87
+ # Prompt iniziale della sessione. Default: prompt DIRETTO (no skill) che chiede
88
+ # a Claude un recap dello stato della task — l'utente rivede/steera prima di
89
+ # lanciare (= più controllo), nessuna esecuzione automatica. Override completo via
90
+ # LOOM_DECK_ENTER_PROMPT, con placeholder {TASK} rimpiazzato dal TaskID. Vincolo: il
91
+ # template NON deve contenere apici singoli (viaggia dentro '...' nel comando in-tab).
92
+ # NB: il default NON può stare nel `${VAR:-...}` perché conterrebbe `{TASK}` e la sua
93
+ # `}` chiuderebbe in anticipo l'espansione → if/else esplicito (default interpola
94
+ # ${TASK} diretto; il placeholder {TASK} vale solo per il valore dell'override).
95
+ if [[ -n "${LOOM_DECK_ENTER_PROMPT:-}" ]]; then
96
+ PROMPT="${LOOM_DECK_ENTER_PROMPT//\{TASK\}/${TASK}}"
97
+ else
98
+ PROMPT="recap stato task ${TASK}"
99
+ fi
100
+
101
+ # Comando eseguito dentro la tab: bind LOOM_TASK per la sessione, avvia CC sul PROMPT.
102
+ # `--name '<TITLE>'` = canale AUTORITATIVO del terminal-title (Ptyxis -T è solo il
103
+ # titolo iniziale, prima che CC parta): CC lo setta e nomina anche la sessione nel
104
+ # picker. Override-abile via LOOM_DECK_INTAB_CMD per i test empirici (probe non-CC).
105
+ IN_TAB_CMD="${LOOM_DECK_INTAB_CMD:-LOOM_TASK=${TASK} claude --name '${TITLE}' ${SID_FLAG}'${PROMPT}'}"
106
+
107
+ case "$MODE" in
108
+ inline)
109
+ # --tab: tab nella window attiva. -- <cmd>: comando inline nella tab.
110
+ # LOOM_TASK viaggia dentro il comando → nessuna scrittura dconf.
111
+ exec ptyxis --tab -d "$WORKDIR" -T "${TITLE}" -- bash -lc "$IN_TAB_CMD"
112
+ ;;
113
+ profile)
114
+ # --tab-with-profile: riusa il profilo (custom-command FISSO) → per iniettare
115
+ # LOOM_TASK+task va riscritto il custom-command al volo via dconf (stato condiviso).
116
+ dpath="/org/gnome/Ptyxis/Profiles/${PROFILE_UUID}/"
117
+ dconf write "${dpath}use-custom-command" true
118
+ dconf write "${dpath}custom-command" "'bash -lc \"${IN_TAB_CMD//\"/\\\"}\"'"
119
+ exec ptyxis --tab-with-profile="$PROFILE_UUID" -d "$WORKDIR" -T "${TITLE}"
120
+ ;;
121
+ *)
122
+ echo "LOOM_DECK_SPAWN_MODE ignoto: '$MODE' (usa 'inline' o 'profile')" >&2
123
+ exit 2
124
+ ;;
125
+ esac