@lamemind/loom-deck 0.76.0 → 0.77.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/README.md CHANGED
@@ -387,11 +387,33 @@ npx @lamemind/loom-deck
387
387
  ## Requisiti runtime
388
388
 
389
389
  - **Node.js ≥ 18** (dichiarato in `engines`).
390
- - **[Ptyxis](https://gitlab.gnome.org/chergert/ptyxis)** (terminale GNOME) — **dipendenza di runtime, non risolvibile da npm**. Lo spawn di una tab (`scripts/deck-run`) invoca il binario `ptyxis`: l'**install riesce anche senza**, ma al momento dello spawn il comando
390
+ - **[Ptyxis](https://gitlab.gnome.org/chergert/ptyxis)** (terminale GNOME), su Linux — **dipendenza di runtime, non risolvibile da npm**. Lo spawn di una tab (`scripts/deck-run`) invoca il binario `ptyxis`: l'**install riesce anche senza**, ma al momento dello spawn il comando
391
391
  fallisce (gestito: handler `error` → la TUI resta viva, mostra la nota). Senza una GUI
392
392
  GNOME con Ptyxis installato, il deck naviga i task ma **non apre sessioni**.
393
393
  - **[Claude Code](https://claude.com/claude-code)** nel `PATH` — la tab spawnata avvia `claude`.
394
394
 
395
+ ### Su Windows
396
+
397
+ Le tab si aprono in **Windows Terminal** (`wt.exe`, l'alias d'esecuzione che l'installazione dello Store mette nel `PATH`), nella finestra usata per ultima: `wt.exe -w 0 new-tab -p loom-deck -d <root> --title <titolo> --suppressApplicationTitle -- <node> <dist/tab-wrapper.js>`. L'argv di `claude` — nome, flag, prompt — non sta su quella riga: viaggia come JSON nella variabile `LOOM_DECK_TAB_ARGV`, che la tab eredita dal processo che lancia `wt.exe`, e il wrapper lo passa a `claude` senza shell. Il prompt non passa quindi né dal parser di `wt`, che legge `;` come separatore di sottocomandi in qualunque argomento, né da cmd o PowerShell.
398
+
399
+ Le tab usano il profilo di Windows Terminal **`loom-deck`**, uno solo per tutti i progetti. Il deck non lo crea e non scrive `settings.json`: lo si aggiunge a mano in `%LOCALAPPDATA%\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json`, sotto `profiles.list`, nascosto dal menu. Porta solo ciò che i flag di `wt` non sanno dire — titolo, cartella e comando li passa il deck, e battono quelli del profilo:
400
+
401
+ ```jsonc
402
+ {
403
+ "name": "loom-deck",
404
+ "guid": "{<un GUID nuovo>}",
405
+ "hidden": true,
406
+ "commandline": "%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe -NoLogo",
407
+ "closeOnExit": "graceful"
408
+ }
409
+ ```
410
+
411
+ `commandline` è la shell del tasto `t`; nelle conversazioni il comando del deck prende il suo posto. `closeOnExit` decide cosa resta della tab quando `claude` esce (il wrapper esce col suo codice). Senza il profilo la tab si apre comunque, col profilo di default. Il nome si cambia con `LOOM_DECK_WT_PROFILE` (vuota = nessun `-p`).
412
+
413
+ La tab eredita tutto l'env del deck, tranne i marker che una sessione Claude Code inietta nei figli (`CLAUDECODE`, `CLAUDE_CODE_CHILD_SESSION`, …): con quelli una conversazione aperta da un deck lanciato dentro Claude Code non salverebbe il transcript. Se manca, il deck aggiunge `CLAUDE_CODE_GIT_BASH_PATH`: col `PATH` di Git Bash `claude` eseguirebbe gli hook del plugin in PowerShell, e la task non arriverebbe in contesto.
414
+
415
+ Su Windows il resume di una conversazione e il restore (`^T`) restano spenti con un avviso finché il deck non sa quali conversazioni sono vive: senza quella informazione un resume può riaprire una conversazione già aperta in un'altra tab. Il fork resta acceso, perché apre un transcript nuovo.
416
+
395
417
  ## Uso (spike ①)
396
418
 
397
419
  ```bash
package/dist/actions.js CHANGED
@@ -17,13 +17,15 @@
17
17
  // migliorano di passaggio.
18
18
  import { useRef } from 'react';
19
19
  import { randomUUID } from 'node:crypto';
20
+ import { existsSync } from 'node:fs';
21
+ import { conversationCwd } from './project-path.js';
20
22
  import { taskSlug } from './tasks.js';
21
23
  import { appendNote, appendPin, appendPriority, appendSessionRecord, appendTaskBinding, } from './task-index.js';
22
24
  import { neighborId } from './session-list.js';
23
25
  import { cut, cutMiddle } from './width.js';
24
26
  import { dropSessionFiles, sessionArtifacts } from './session-drop.js';
25
27
  import { saveView, viewFilePath } from './view-store.js';
26
- import { onInTabCommand, runLaunch, spawnClaudeEmpty, spawnBare, spawnDeck, spawnDeckFork, spawnDeckResume, spawnTerminal, DECK_RUN, } from './spawn.js';
28
+ import { onInTabCommand, runLaunch, spawnClaudeEmpty, spawnBare, spawnDeck, spawnDeckFork, spawnDeckResume, spawnTerminal, terminalName, DECK_RUN, } from './spawn.js';
27
29
  import { MODEL_DEFAULT } from './spawn-catalog.js';
28
30
  import { modelAlias } from './glyphs.js';
29
31
  import { resolveSpawnId } from './spawn-config.js';
@@ -103,7 +105,7 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
103
105
  }
104
106
  /** I buchi di un'azione su task. Lo slug viene dal NOME del task file, non
105
107
  * dalla descrizione di `tasks.md`: il nome è già dentro l'alfabeto di
106
- * `_sane_note` per costruzione — minuscolo, separato da trattini, senza
108
+ * `saneNote` per costruzione — minuscolo, separato da trattini, senza
107
109
  * punteggiatura — mentre la descrizione porta apostrofi e `/` che la riduzione
108
110
  * toglie senza sostituto, saldando le parole. Task file assente → slug vuoto,
109
111
  * e il titolo si spegne invece di ridursi al solo prefisso. */
@@ -220,9 +222,22 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
220
222
  // ricerca e il restore il modello ereditato dalla sessione senza superficie
221
223
  // di scelta (D1 preflight). I chiamanti restano la stessa azione perché è
222
224
  // lo spawn a essere identico, non la sorgente del valore.
225
+ /**
226
+ * T176 — la cartella della tab di una ripresa o di un fork: la root su Linux,
227
+ * la cwd registrata dalla conversazione su Windows (`conversationCwd`, P7
228
+ * preflight). Una cartella che non esiste più ricade sulla root: lo spawn
229
+ * fallirebbe prima ancora di aprire la tab.
230
+ */
231
+ function tabCwdOf(sessionId) {
232
+ const s = model.sessions.find((x) => x.sessionId === sessionId);
233
+ if (!s)
234
+ return cwd;
235
+ const dir = conversationCwd(s.cwd, s.path, cwd);
236
+ return dir === cwd || existsSync(dir) ? dir : cwd;
237
+ }
223
238
  function resumeSession(sessionId, modelKind) {
224
239
  const bound = model.bindings.get(sessionId) ?? null;
225
- const spawned = spawnDeckResume(bound, cwd, sessionId, modelKind, model.sessionNotes.get(sessionId));
240
+ const spawned = spawnDeckResume(bound, tabCwdOf(sessionId), sessionId, modelKind, model.sessionNotes.get(sessionId));
226
241
  spawned.child.on('error', () => setNote(`⚠ resume fallito (${DECK_RUN})`));
227
242
  noteSpawn(spawned);
228
243
  return spawned;
@@ -242,6 +257,12 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
242
257
  function openSession(sessionId, modelKind) {
243
258
  if (refuse('spawn', '⏎', setNote, 'resume'))
244
259
  return;
260
+ // T176 — il resume vuole sapere se la conversazione è viva, non solo poter
261
+ // aprire una tab (D4 preflight): senza liveness un `⏎` su una conversazione
262
+ // aperta in un'altra tab avvierebbe un secondo `claude` sullo stesso
263
+ // transcript. Spento finché la piattaforma non ha le sessioni vive.
264
+ if (refuse('liveSessions', '⏎', setNote, 'resume'))
265
+ return;
245
266
  enterSession(sessionId, {
246
267
  isLive: (id) => model.live.has(id),
247
268
  nameOf: (id) => model.live.get(id)?.name || id.slice(0, 8),
@@ -275,6 +296,12 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
275
296
  * chiudendo le tab.
276
297
  */
277
298
  function restoreSessions() {
299
+ // T176 — la guardia delle sessioni vive PRIMA di quella dello spawn (P8
300
+ // preflight): l'insieme da riaprire è l'ultimo insieme vivo, e senza
301
+ // liveness sarebbe vuoto — «nessuna conversazione da riaprire» direbbe uno
302
+ // stato dove manca una funzione.
303
+ if (refuse('liveSessions', '^T', setNote, 'restore delle conversazioni'))
304
+ return;
278
305
  if (refuse('spawn', '^T', setNote, 'restore delle conversazioni'))
279
306
  return;
280
307
  const list = model.restorable;
@@ -406,7 +433,7 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
406
433
  // T148 — il fork è l'altra continuità che condivide la stessa riga
407
434
  // selezionata: porta via lo stesso `model.resumeModel` del resume, non un
408
435
  // default fisso (P2 preflight).
409
- const spawned = spawnDeckFork(bound, cwd, s.sessionId, newId, model.resumeModel);
436
+ const spawned = spawnDeckFork(bound, tabCwdOf(s.sessionId), s.sessionId, newId, model.resumeModel);
410
437
  spawned.child.on('error', () => setNote(`⚠ fork fallito (${DECK_RUN})`));
411
438
  noteSpawn(spawned);
412
439
  }
@@ -533,7 +560,7 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
533
560
  return;
534
561
  const title = model.identity ? `🖥️ ${model.identity.name} [term]` : null;
535
562
  const child = spawnTerminal(cwd, title);
536
- child.on('error', () => setNote('⚠ t → ptyxis non lanciabile'));
563
+ child.on('error', () => setNote(`⚠ t → ${terminalName()} non lanciabile`));
537
564
  setNote(`t → terminale su ${model.projectName}`);
538
565
  }
539
566
  /**
package/dist/config.js CHANGED
@@ -115,7 +115,7 @@ export function launchLegend(entries, columns, reserved = 0) {
115
115
  // `identity` sopra sono strutture. La catena da percorrere si ferma qui: nessun
116
116
  // passaggio da `lib-config.sh`/`reg_pull`/dconf, perché il solo consumer è il
117
117
  // deck e il file ce l'ha sotto mano. Il precedente esatto è `permissionMode`,
118
- // che vive nel file, lo legge `deck-run` via jq e non è propagato al registry.
118
+ // che vive nel file, lo legge anche `deck-run` e non è propagato al registry.
119
119
  /** Interi positivi soltanto: uno 0 spegnerebbe la soglia (tutte le Done
120
120
  * archiviabili), un negativo o un decimale sono un typo. Valore fuori dominio
121
121
  * → default, mai passaggio cieco di un numero senza senso al conteggio. */
@@ -0,0 +1,7 @@
1
+ // T176 — il punto d'ingresso di `deck-run`: `scripts/deck-run` (lo shim bash
2
+ // che compass e i test lanciano) e il deck su Windows eseguono questo file col
3
+ // proprio node. La logica sta in `tab-launch.ts`, che si testa senza aprire tab.
4
+ import { realIo, runDeckRun } from './tab-launch.js';
5
+ runDeckRun(process.argv.slice(2), realIo()).then((code) => {
6
+ process.exitCode = code;
7
+ });
package/dist/doc-tree.js CHANGED
@@ -376,7 +376,7 @@ export function docTargetKind(row, docsRoot) {
376
376
  * T160 — il buco `{words}` del titolo di una sessione di rebalance: il path reso
377
377
  * a parole.
378
378
  *
379
- * Gemello di `wrapWords`, e per la stessa ragione: l'alfabeto di `_sane_note`
379
+ * Gemello di `wrapWords`, e per la stessa ragione: l'alfabeto di `saneNote`
380
380
  * non ha né `/` né `.`, quindi un path lasciato cadere lì dentro esce con le
381
381
  * parole saldate (`runtimereferencedoc-systemmd`) mentre la lista del deck, che
382
382
  * legge la nota grezza dal sidecar, mostrerebbe il path intero — la stessa
package/dist/inbox.js CHANGED
@@ -200,7 +200,7 @@ export function inboxPrompt(f) {
200
200
  * riscriverla.
201
201
  *
202
202
  * Il `.md` si toglie QUI e non lo si lascia cadere a valle: l'alfabeto di
203
- * `_sane_note` non ha il punto, quindi il titolo della tab uscirebbe con le due
203
+ * `saneNote` non ha il punto, quindi il titolo della tab uscirebbe con le due
204
204
  * lettere saldate al nome (`…-prioritariamd`) mentre la lista del deck, che
205
205
  * legge la nota grezza dal sidecar, mostrerebbe il nome intero — la stessa
206
206
  * conversazione con due nomi, senza errore.
package/dist/platform.js CHANGED
@@ -17,8 +17,7 @@
17
17
  */
18
18
  export const UNAVAILABLE = {
19
19
  win32: [
20
- 'spawn', // T176 — tab di Windows Terminal al posto di ptyxis
21
- 'liveSessions', // T177 — liveness senza /proc
20
+ 'liveSessions', // T177 — liveness senza /proc; spegne anche resume e restore
22
21
  'pluginScripts', // T178 — .sh del plugin via Git Bash
23
22
  'hardWrap', // T178 — python del wrap-scan
24
23
  'launch', // T179 — comando per piattaforma nelle voci launch[]
@@ -69,3 +69,34 @@ export function matchingDirNames(entries, name, platform = process.platform) {
69
69
  const want = name.toLowerCase();
70
70
  return entries.filter((e) => e.toLowerCase() === want);
71
71
  }
72
+ /**
73
+ * T176 — la cartella in cui nasce la tab che riprende o forka una conversazione.
74
+ *
75
+ * Su Linux la root del progetto, come prima. Su win32 la cwd che la
76
+ * conversazione ha registrato, in forma Windows (P7 preflight): `claude
77
+ * --resume` cerca il transcript nella cartella che ricava dalla PROPRIA cwd, e
78
+ * Claude Code non normalizza la lettera del drive (T174 verdetto 1) — una
79
+ * conversazione nata da VS Code (`c:\…`) sta in `c--…`, e un `claude` partito in
80
+ * `C:\…` la cercherebbe in `C--…`, senza trovarla.
81
+ *
82
+ * Una cwd in forma MSYS (`/c/…`) non dice la grafia del drive: la dice il nome
83
+ * della cartella del transcript, che Claude Code ha derivato dalla cwd vera
84
+ * (`c--…` o `C--…`). Una cwd che non è un path assoluto Windows ricade sulla
85
+ * root.
86
+ */
87
+ export function conversationCwd(sessionCwd, transcriptPath, root, platform = process.platform) {
88
+ if (platform !== 'win32')
89
+ return root;
90
+ if (/^[a-zA-Z]:[\\/]/.test(sessionCwd))
91
+ return sessionCwd.replace(/\//g, '\\');
92
+ if (sessionCwd.startsWith('\\\\'))
93
+ return sessionCwd;
94
+ const msys = /^\/([a-zA-Z])(?:\/(.*))?$/.exec(sessionCwd);
95
+ if (!msys)
96
+ return root;
97
+ const folder = win32.basename(win32.dirname(transcriptPath));
98
+ const letter = folder.length > 2 && folder.slice(1, 3) === '--' && folder[0].toLowerCase() === msys[1].toLowerCase()
99
+ ? folder[0]
100
+ : msys[1].toUpperCase();
101
+ return `${letter}:\\${(msys[2] ?? '').replace(/\//g, '\\')}`;
102
+ }
package/dist/sane-note.js CHANGED
@@ -1,35 +1,29 @@
1
- // T161 — il gemello TypeScript di `_sane_note`, la riduzione che `deck-run`
2
- // applica a ogni nota del titolo.
1
+ // La riduzione della nota del titolo di una tab: l'unica, per il deck e per
2
+ // `deck-run`.
3
3
  //
4
- // Perché serve un gemello. La nota del titolo è l'UNICO ingresso di testo libero
5
- // nel titolo di una tab, quindi `deck-run` non la quota: la RIDUCE a una
6
- // whitelist chiusa di code point, e tutto ciò che non ci rientra sparisce senza
7
- // errore. Finché la nota nasceva da un campo scritto al volo, la divergenza fra
8
- // quello che si legge nel deck e quello che compare nella tab durava il tempo di
9
- // uno spawn. Da quando un TEMPLATE del titolo si scrive in una pagina e si salva
10
- // su disco, quella divergenza diventa permanente: il file direbbe una cosa e la
11
- // tab ne mostrerebbe un'altra, per sempre e senza un errore da nessuna parte.
4
+ // La nota del titolo è l'UNICO ingresso di testo libero nel titolo di una tab,
5
+ // quindi non si quota: si RIDUCE a una whitelist chiusa di code point, e tutto
6
+ // ciò che non ci rientra sparisce senza errore. Da quando un TEMPLATE del titolo
7
+ // si scrive in una pagina e si salva su disco (T161), la riduzione si applica
8
+ // anche QUI, al confine — al salvataggio e alla lettura del blocco `spawn` —
9
+ // così quello che sta nel file è quello che la tab mostrerà.
12
10
  //
13
- // La riduzione si applica quindi QUI, al confine — al salvataggio e alla
14
- // lettura del blocco `spawn` — così quello che sta nel file è quello che la tab
15
- // mostrerà.
16
- //
17
- // L'ALLINEAMENTO fra i due lati non è assunto: `test/sane-note.test.ts` passa la
18
- // stessa batteria di input a questa funzione e a `deck-run` vero (via lo shim
19
- // `ptyxis` di `deck-run.test.ts`) e pretende l'uguaglianza. La whitelist non si
20
- // allarga — e se un giorno lo facesse, sono tre le sedi da toccare insieme
21
- // (`deck-run`, questo file, le emoji dei titoli in `spawn-catalog.ts`), col test
22
- // che fallisce se una resta indietro.
11
+ // T176 — fino a T176 questo modulo era il gemello di `_sane_note`, la stessa
12
+ // riduzione scritta in bash dentro `deck-run`, e un test teneva uguali i due
13
+ // lati. Il composer della tab ora è TypeScript (`src/tab-compose.ts`) e chiama
14
+ // questa funzione: la whitelist ha due sedi da toccare insieme, questo file e le
15
+ // emoji dei titoli in `spawn-catalog.ts`, e `test/deck-run.test.ts` fallisce se
16
+ // un'emoji di un titolo del catalogo non passa.
23
17
  /**
24
18
  * L'alfabeto ammesso, negato: lettere, cifre, spazio, `_`, `-`, le accentate
25
19
  * italiane e sette emoji.
26
20
  *
27
- * Whitelist e non blacklist, come in bash: il titolo finisce dentro apici
28
- * singoli in `bash -lc "claude --name '…'"`, ed enumerare i caratteri pericolosi
29
- * significa sbagliarne uno.
21
+ * Whitelist e non blacklist: il titolo finisce in una riga di shell su Linux
22
+ * (`bash -lc "claude --name '…'"`) e nella riga di `wt.exe` su Windows, ed
23
+ * enumerare i caratteri pericolosi significa sbagliarne uno.
30
24
  *
31
25
  * Le sette emoji sono nominate una per una e NON come intervallo di code point,
32
- * per due ragioni che valgono su entrambi i lati: un'emoji arriva nella tab solo
26
+ * per due ragioni: un'emoji arriva nella tab solo
33
27
  * se il deck la mostra identica in lista, cioè se `sanitize` (`src/width.ts`) la
34
28
  * lascia passare invece di renderla `·`, e quel verdetto si verifica un glifo
35
29
  * alla volta; e l'ordine di un intervallo dentro un bracket expression segue la
@@ -123,9 +123,9 @@ const UNWRAP_PROMPT = 'lancia md-wrap modo apply su {path} e backup in cartella
123
123
  * non per gusto: `sanitize` (`src/width.ts`) sostituisce con `·` ogni glifo su
124
124
  * cui le due contabilità di larghezza divergono, e le emoji del BMP a
125
125
  * presentazione testo divergono sempre. Le stesse SETTE (T160 aggiunge `🧭`)
126
- * stanno nella whitelist di `_sane_note` in `deck-run` e nel suo gemello
127
- * `src/sane-note.ts`: un'emoji cambiata qui e non là sparisce fra la tabella e
128
- * la tab, senza errore.
126
+ * stanno nella whitelist di `saneNote` (`src/sane-note.ts`), la riduzione del
127
+ * titolo: un'emoji cambiata qui e non là sparisce fra la tabella e la tab,
128
+ * senza errore.
129
129
  */
130
130
  export const SPAWN_ACTIONS = [
131
131
  {
@@ -276,7 +276,7 @@ export const SPAWN_ACTIONS = [
276
276
  // DUE buchi per lo stesso dato, e servono entrambi: `{target}` è il path
277
277
  // VERO, che la skill risolve contro la colonna `PATH` della misura, e
278
278
  // `{words}` lo stesso path reso a parole per il titolo. L'alfabeto di
279
- // `_sane_note` non ha né `/` né `.`, quindi un `{target}` in un template di
279
+ // `saneNote` non ha né `/` né `.`, quindi un `{target}` in un template di
280
280
  // titolo uscirebbe con le parole saldate (`runtimereferencedoc-systemmd`) e
281
281
  // la tab mostrerebbe un nome diverso da quello che il deck legge dal
282
282
  // sidecar.
@@ -4,7 +4,7 @@
4
4
  // ── La sede (P19 preflight) ─────────────────────────────────────────────────
5
5
  // Blocco `spawn` dentro `.claude/loom-works.json`, il file di progetto che il
6
6
  // deck già apre in tre loader (`launch`, `archivableDays`, `identity`) e che
7
- // `deck-run` legge con `jq`. Nessun file nuovo: sotto `.claude/` ci sono già tre
7
+ // `deck-run` legge per label e permission mode. Nessun file nuovo: sotto `.claude/` ci sono già tre
8
8
  // file di loom, e le impostazioni di spawn sono decisioni di progetto che devono
9
9
  // viaggiare col repo — cioè committate, esattamente come `permissionMode` e
10
10
  // `order`, e a differenza della vista (`.claude/loom/deck-view.json`, che è
@@ -34,9 +34,9 @@
34
34
  // ── Chi legge cosa ──────────────────────────────────────────────────────────
35
35
  // Il blocco lo legge il SOLO deck, che risolve la terna e la passa a `deck-run`
36
36
  // sempre esplicita nell'argv (`--title-note`, `--model`, `--prompt`). `deck-run`
37
- // non lo legge e non deve: `jq` è dipendenza OPZIONALE di quello script, e
38
- // dargli un blocco JSON da consultare la renderebbe obbligatoria su un percorso
39
- // che oggi degrada senza. La cascata env → catalogo → `fable` di `deck-run`
37
+ // non lo legge e non deve: la terna che parte è quella che il deck ha mostrato,
38
+ // e una seconda risoluzione dall'altra parte del processo sarebbe una seconda
39
+ // copia delle stesse regole. La cascata env → catalogo → `fable` di `deck-run`
40
40
  // resta intatta per chi lo invoca a mano.
41
41
  import { readFileSync, writeFileSync } from 'node:fs';
42
42
  import { configFilePath } from './config.js';
package/dist/spawn.js CHANGED
@@ -10,10 +10,24 @@ import { dirname, join } from 'node:path';
10
10
  import { MODEL_DEFAULT, PROJECT_STATUS_MODEL } from './spawn-catalog.js';
11
11
  import { isAvailable } from './platform.js';
12
12
  import { claudeLauncher } from './claude-cmd.js';
13
- import { claudeEnv } from './git-bash.js';
13
+ import { claudeEnv, gitBash } from './git-bash.js';
14
+ import { windowsTabEnv, WT_CMD, wtNewTabArgs, wtProfile } from './windows-terminal.js';
14
15
  // scripts/deck-run è un sibling della dir del bundle: src/ (dev, tsx) e dist/
15
16
  // (build, node) stanno entrambi sotto la package root → risalita di un livello.
16
17
  export const DECK_RUN = join(dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'deck-run');
18
+ // T176 — il punto d'ingresso che lo shim `scripts/deck-run` esegue, nel build.
19
+ // Su Windows il deck lo lancia da sé: CreateProcess non esegue uno script bash
20
+ // senza estensione.
21
+ export const DECK_RUN_JS = join(dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'deck-run.js');
22
+ /**
23
+ * Come il deck lancia `deck-run`: lo shim su Linux, il node del deck sul punto
24
+ * d'ingresso su Windows. Le due forme eseguono lo stesso programma — lo shim è
25
+ * una riga che fa `exec node dist/deck-run.js` —, e su Linux resta lo shim
26
+ * perché è il comando che la riga di stato mostra e che si ripete a mano.
27
+ */
28
+ export function deckRunLauncher(platform = process.platform, nodePath = process.execPath) {
29
+ return platform === 'win32' ? { cmd: nodePath, prefix: [DECK_RUN_JS] } : { cmd: DECK_RUN, prefix: [] };
30
+ }
17
31
  /**
18
32
  * Freno agli effetti VERSO L'ESTERNO (tab Ptyxis, sessioni Claude, git commit).
19
33
  *
@@ -78,13 +92,15 @@ export function shellCommand(cmd, args) {
78
92
  // comando in-tab (vedi `onInTabCommand`). Il figlio resta detached — la pipe
79
93
  // serve a leggere una riga, non a possedere il processo.
80
94
  function launchDeckRun(args, cwd) {
81
- const child = spawnOut(DECK_RUN, args, {
95
+ const run = deckRunLauncher();
96
+ const argv = [...run.prefix, ...args];
97
+ const child = spawnOut(run.cmd, argv, {
82
98
  cwd,
83
99
  detached: true,
84
100
  stdio: ['ignore', 'pipe', 'ignore'],
85
101
  });
86
102
  child.unref();
87
- return { child, cmd: shellCommand(DECK_RUN, args) };
103
+ return { child, cmd: shellCommand(run.cmd, argv) };
88
104
  }
89
105
  /** Prefisso della riga con cui `deck-run` annuncia il comando in-tab. */
90
106
  export const INTAB_MARKER = 'LOOM_DECK_INTAB ';
@@ -152,10 +168,9 @@ export function onInTabCommand(child, cb) {
152
168
  // acceleratori della lista passavano `--prompt-kind` e lasciavano risolvere il
153
169
  // testo a `deck-run`, che consultava il proprio catalogo; il detail passava già
154
170
  // il letterale. Con un override di progetto sui prompt la risoluzione deve
155
- // stare da una parte sola, e quella parte è il deck — che è l'unico dei due a
156
- // leggere `.claude/loom-works.json` per questo (`jq` è dipendenza opzionale di
157
- // `deck-run`). Un percorso solo, quindi, e ciò che parte è verificabile
158
- // sull'argv.
171
+ // stare da una parte sola, e quella parte è il deck — l'unico dei due che legge
172
+ // il blocco `spawn` di `.claude/loom-works.json`. Un percorso solo, quindi, e
173
+ // ciò che parte è verificabile sull'argv.
159
174
  //
160
175
  // Due conseguenze dichiarate: `--prompt-kind` resta in `deck-run` per chi lo
161
176
  // invoca a mano, e `LOOM_DECK_ENTER_PROMPT` non ha più effetto sugli spawn dal
@@ -346,7 +361,36 @@ export function runLaunch(entry, cwd) {
346
361
  export function terminalArgs(cwd, title) {
347
362
  return title ? ['--tab', '-T', title, '-d', cwd] : ['--tab', '-d', cwd];
348
363
  }
364
+ /**
365
+ * T176 — il tasto `t` su Windows (P10 preflight): una tab di Windows Terminal
366
+ * nella finestra attiva, col profilo del deck e il titolo bloccato, SENZA
367
+ * comando — la shell è il `commandline` del profilo, come `ptyxis --tab` apre
368
+ * quella del profilo di Ptyxis.
369
+ */
370
+ export function windowsTerminalArgs(cwd, title, env = process.env) {
371
+ return wtNewTabArgs({ newWindow: false, profile: wtProfile(env), cwd, title });
372
+ }
373
+ /** Il terminale del tasto `t`, per nome: è ciò che la nota dice quando non parte. */
374
+ export function terminalName(platform = process.platform) {
375
+ return platform === 'win32' ? WT_CMD : 'ptyxis';
376
+ }
349
377
  export function spawnTerminal(cwd, title) {
378
+ if (process.platform === 'win32') {
379
+ // La tab eredita l'env del deck (T174 verdetto 5): senza i marker di
380
+ // sessione e senza `LOOM_TASK`, col bash di Git — come una conversazione
381
+ // `--no-task`, perché un `claude` lanciato da questa shell nascerebbe con
382
+ // l'env che riceve. `windowsHide: false`: `wt.exe` è un'app GUI, e con
383
+ // `true` una finestra nuova partirebbe nascosta.
384
+ const child = spawnOut(WT_CMD, windowsTerminalArgs(cwd, title), {
385
+ cwd,
386
+ detached: true,
387
+ stdio: 'ignore',
388
+ env: windowsTabEnv(process.env, null, gitBash()),
389
+ windowsHide: false,
390
+ });
391
+ child.unref();
392
+ return child;
393
+ }
350
394
  const child = spawnOut('ptyxis', terminalArgs(cwd, title), {
351
395
  cwd,
352
396
  detached: true,
@@ -0,0 +1,141 @@
1
+ /** I kind accettati da `--prompt-kind`. Enum e non stringa libera: un kind è un
2
+ * simbolo interno del deck, e uno fuori elenco è un errore del chiamante. */
3
+ export const PROMPT_KINDS = [
4
+ 'none',
5
+ 'recap',
6
+ 'recap-task',
7
+ 'recap-epic',
8
+ 'preflight',
9
+ 'run',
10
+ 'checkpoint',
11
+ ];
12
+ export const USAGE = `uso: deck-run <TaskID> [--model <alias>] [--prompt-kind <kind>] [--session-id <uuid>]
13
+ (es. deck-run T18)
14
+ | deck-run --no-task (sessione nuda, senza task)
15
+ | deck-run <TaskID> --resume <uuid> (riprende sessione scoped)
16
+ | deck-run --no-task --resume <uuid> (riprende sessione spot)
17
+ | deck-run <TaskID> --resume <uuid> --fork [--session-id <nuovo-uuid>]
18
+ (forka: ramo con id NUOVO)
19
+
20
+ --prompt-kind prompt iniziale della sessione bound (default: recap)
21
+ none nessun prompt — sessione aperta sulla task, a mani nude
22
+ recap /loom-works:recap-status <TaskID> (dispatcher)
23
+ recap-task /loom-works:recap-status-task <TaskID> (task non cappello)
24
+ recap-epic /loom-works:recap-status-epic <TaskID> (Size: Epic)
25
+ preflight /loom-works:preflight-task <TaskID>
26
+ run /loom-works:run-task <TaskID>
27
+ checkpoint /loom-works:checkpoint-task <TaskID>
28
+
29
+ --prompt prompt LETTERALE, per chi lo ha già composto (o fatto
30
+ modificare). Esclusivo con --prompt-kind; vuoto = nessun
31
+ prompt. Ammesso anche con --no-task: un testo già scritto non
32
+ ha una task da nominare, a differenza dei kind del catalogo
33
+
34
+ --model modello della sessione: fable|opus|sonnet|haiku
35
+ (default: il modello del kind in catalogo, o 'fable' fuori catalogo)
36
+ valore ignoto → fallback sul default, con avviso su stderr
37
+
38
+ --title-note nota della conversazione, appesa al titolo tab
39
+ (ridotta a lettere/cifre/spazi/-/_, cap 60 char)
40
+
41
+ --new-window apre una FINESTRA nuova invece di una tab nella finestra
42
+ attiva. Per chi chiama da fuori dalla finestra del progetto`;
43
+ /** I flag che portano un valore, nel campo della richiesta che lo riceve. */
44
+ const VALUED = {
45
+ '--model': 'model',
46
+ '--title-note': 'titleNote',
47
+ '--prompt-kind': 'promptKind',
48
+ '--prompt': 'promptText',
49
+ '--session-id': 'sessionId',
50
+ '--resume': 'resumeId',
51
+ };
52
+ function usageError(...lines) {
53
+ return { lines: [...lines, USAGE] };
54
+ }
55
+ /**
56
+ * L'argv di `deck-run` come richiesta validata, o l'errore d'uso.
57
+ *
58
+ * Le due forme di un flag con valore (`--model x` e `--model=x`) valgono uguale;
59
+ * un argomento che non è un flag noto è il TaskID se il TaskID non c'è ancora,
60
+ * altrimenti un errore — anche `--qualcosa`, come nello script.
61
+ *
62
+ * L'ordine delle validazioni è quello dello script, perché è l'ordine in cui un
63
+ * chiamante riceve i messaggi quando più regole sono violate insieme.
64
+ */
65
+ export function parseTabArgs(argv) {
66
+ const req = {
67
+ task: '',
68
+ noTask: false,
69
+ sessionId: '',
70
+ resumeId: '',
71
+ fork: false,
72
+ newWindow: false,
73
+ titleNote: '',
74
+ promptKind: '',
75
+ promptText: '',
76
+ promptGiven: false,
77
+ model: '',
78
+ };
79
+ for (let i = 0; i < argv.length; i++) {
80
+ const arg = argv[i];
81
+ const eq = arg.indexOf('=');
82
+ const name = eq > 0 ? arg.slice(0, eq) : arg;
83
+ const field = VALUED[name];
84
+ if (field) {
85
+ let value;
86
+ if (eq > 0)
87
+ value = arg.slice(eq + 1);
88
+ else if (i + 1 < argv.length)
89
+ value = argv[++i];
90
+ else
91
+ return usageError(`${name} richiede un valore`);
92
+ req[field] = value;
93
+ if (field === 'promptText')
94
+ req.promptGiven = true;
95
+ continue;
96
+ }
97
+ if (arg === '--fork')
98
+ req.fork = true;
99
+ else if (arg === '--no-task')
100
+ req.noTask = true;
101
+ else if (arg === '--new-window')
102
+ req.newWindow = true;
103
+ else if (!req.task)
104
+ req.task = arg;
105
+ else
106
+ return { lines: [`argomento inatteso: '${arg}'`] };
107
+ }
108
+ if (req.noTask && req.task) {
109
+ return usageError('--no-task e <TaskID> sono mutuamente esclusivi');
110
+ }
111
+ if (!req.noTask && !req.task)
112
+ return usageError();
113
+ if (req.fork && !req.resumeId) {
114
+ return usageError('--fork richiede --resume <uuid>: si forka una conversazione esistente, non il nulla');
115
+ }
116
+ // Una ripresa nuda riscrive il transcript dell'id ripreso, quindi un id
117
+ // pinnato non ha senso; il fork apre un transcript NUOVO, che si può pinnare.
118
+ if (req.resumeId && req.sessionId && !req.fork) {
119
+ return usageError('--resume e --session-id sono mutuamente esclusivi (la sessione ripresa ha già il suo id)', 'usa --fork se vuoi un ramo con id nuovo');
120
+ }
121
+ // Un kind fuori catalogo è un errore del CHIAMANTE (simbolo interno, non
122
+ // configurazione utente): uscita, non fallback silenzioso come il modello.
123
+ if (req.promptKind && !PROMPT_KINDS.includes(req.promptKind)) {
124
+ return usageError(`--prompt-kind ignoto: '${req.promptKind}' (usa ${PROMPT_KINDS.join('|')})`);
125
+ }
126
+ // Ogni prompt del catalogo nomina il TaskID: senza task produrrebbe un
127
+ // comando monco (`/loom-works:run-task ` con l'id vuoto). Il testo letterale
128
+ // invece è ammesso con `--no-task` (T134/D9): non ha una task da nominare.
129
+ if (req.noTask && req.promptKind) {
130
+ return usageError('--prompt-kind richiede una task: una sessione --no-task non ha prompt iniziale');
131
+ }
132
+ // Due modi di dire la stessa cosa: accettarli insieme obbligherebbe a
133
+ // stabilire quale vince.
134
+ if (req.promptGiven && req.promptKind) {
135
+ return usageError('--prompt e --prompt-kind sono mutuamente esclusivi (il testo o il simbolo, non entrambi)');
136
+ }
137
+ return req;
138
+ }
139
+ export function isUsageError(r) {
140
+ return 'lines' in r;
141
+ }