@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 +21 -0
- package/README.md +85 -0
- package/dist/cli.js +411 -0
- package/dist/sessions.js +158 -0
- package/dist/task-index.js +46 -0
- package/dist/tasks.js +100 -0
- package/package.json +52 -0
- package/scripts/deck-run +125 -0
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) }));
|
package/dist/sessions.js
ADDED
|
@@ -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
|
+
}
|
package/scripts/deck-run
ADDED
|
@@ -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
|