@lamemind/loom-deck 0.76.0 → 0.78.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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
+ }
@@ -0,0 +1,233 @@
1
+ // T176 — la COMPOSIZIONE di una tab di conversazione: dai quattro assi di
2
+ // `deck-run` (`tab-args.ts`) a titolo, flag di `claude`, env di sessione e
3
+ // comando in-tab.
4
+ //
5
+ // Un composer solo per Linux e Windows (D1 preflight). Cambia per piattaforma
6
+ // solo CHI riceve il risultato: su Linux una riga per `bash -lc` dentro una tab
7
+ // Ptyxis, su Windows l'argv di `claude` in una variabile d'ambiente letta dal
8
+ // wrapper della tab (`windows-terminal.ts`). Titolo, modello, permission mode e
9
+ // prompt si risolvono qui una volta, per entrambe.
10
+ //
11
+ // Modulo PURO: il file di progetto, il catalogo dei prompt e l'env arrivano già
12
+ // letti. Le regole e i messaggi sono quelli dello script bash che questo modulo
13
+ // ha sostituito, e il gate di parità su Linux (`test/deck-run.test.ts`) li
14
+ // misura sul comando in-tab.
15
+ import { saneNote } from './sane-note.js';
16
+ import { MODEL_DEFAULT, MODELS } from './spawn-catalog.js';
17
+ import { shellQuote } from './spawn.js';
18
+ export const PERMISSION_MODES = ['acceptEdits', 'auto', 'bypassPermissions', 'manual', 'dontAsk', 'plan'];
19
+ /** L'alfabeto della chiave di stato: il valore entra in `bash -lc "…"` e il
20
+ * file di progetto è editabile a mano, quindi si scarta invece di quotare. */
21
+ const STATE_KEY_RE = /^[A-Za-z0-9_-]+$/;
22
+ /**
23
+ * Un campo del file di progetto come lo leggeva `jq -r '.campo // empty'`:
24
+ * `null`, `false` o assente danno vuoto, una stringa passa com'è, ogni altro
25
+ * valore diventa il suo JSON (`jq -r` stampa così numeri e oggetti).
26
+ */
27
+ function configField(config, key) {
28
+ if (!config || typeof config !== 'object' || Array.isArray(config))
29
+ return '';
30
+ const v = config[key];
31
+ if (v === undefined || v === null || v === false)
32
+ return '';
33
+ return typeof v === 'string' ? v : JSON.stringify(v);
34
+ }
35
+ /**
36
+ * La label del progetto, `{emoji} {name}`: la chiave con cui compass riconosce
37
+ * la finestra cercando nel titolo della tab attiva. Vuota se il file manca o
38
+ * non porta entrambi i campi, e il titolo ricade su `cc`.
39
+ */
40
+ export function projectLabel(config) {
41
+ const emoji = configField(config, 'emoji');
42
+ const name = configField(config, 'name');
43
+ return emoji && name ? `${emoji} ${name}` : '';
44
+ }
45
+ /**
46
+ * Il titolo della tab: label + `· <task>` + nota + `· fork`.
47
+ *
48
+ * La label sta in TESTA perché il match di compass è un `.includes` sulla
49
+ * chiave, e tutto il resto va in coda per non spezzarla. Il suffisso task
50
+ * distingue le tab di più task, la nota (T64) quelle di più conversazioni sulla
51
+ * stessa task, `· fork` (T28) un ramo dalla sua origine. La nota è l'unico testo
52
+ * libero del titolo: non si quota, si RIDUCE (`saneNote`) — e ridotta a vuoto
53
+ * non lascia nessun suffisso.
54
+ */
55
+ export function tabTitle(req, label) {
56
+ let title;
57
+ if (req.noTask)
58
+ title = label || 'cc';
59
+ else
60
+ title = label ? `${label} · ${req.task}` : `cc ${req.task}`;
61
+ if (req.titleNote) {
62
+ const note = saneNote(req.titleNote);
63
+ if (note)
64
+ title = `${title} ${note}`;
65
+ }
66
+ if (req.fork)
67
+ title = `${title} · fork`;
68
+ return title;
69
+ }
70
+ /**
71
+ * Il permission mode: env `LOOM_DECK_PERMISSION_MODE` > campo del file > `manual`.
72
+ *
73
+ * Passato SEMPRE al CLI, anche `manual`: lo spawn resta deterministico invece
74
+ * di dipendere da un default che cambia fra versioni. Un valore fuori elenco
75
+ * non arriva al CLI — produrrebbe una tab con un comando rotto — e degrada a
76
+ * `manual` con un avviso.
77
+ */
78
+ function permissionMode(ctx, warnings) {
79
+ const raw = ctx.env.LOOM_DECK_PERMISSION_MODE || configField(ctx.config, 'permissionMode');
80
+ if (!raw)
81
+ return 'manual';
82
+ if (PERMISSION_MODES.includes(raw))
83
+ return raw;
84
+ warnings.push(`permissionMode ignoto: '${raw}' → fallback 'manual'`);
85
+ return 'manual';
86
+ }
87
+ /**
88
+ * Il kind di catalogo effettivo: solo sul ramo bound senza prompt letterale.
89
+ * `--no-task` e `--prompt` non hanno una riga di catalogo da consultare, né per
90
+ * il prompt né per il modello.
91
+ */
92
+ function catalogKind(req) {
93
+ return !req.promptGiven && !req.noTask ? req.promptKind || 'recap' : '';
94
+ }
95
+ /**
96
+ * Il modello: `--model` > env `LOOM_DECK_MODEL` > modello del kind nel catalogo
97
+ * > `MODEL_DEFAULT`.
98
+ *
99
+ * Il deck TUI passa SEMPRE `--model` già risolto (T161), quindi dal deck la
100
+ * cascata si ferma al primo gradino: i tre sotto servono a chi invoca a mano.
101
+ * Le voci restano ALIAS fino dentro `claude --model`: un id versionato cablato
102
+ * qui diventerebbe un modello inesistente al primo cambio di generazione. Un
103
+ * valore ignoto degrada al default con un avviso — arriverebbe al CLI, che lo
104
+ * rifiuterebbe all'avvio.
105
+ */
106
+ function modelOf(req, ctx, warnings) {
107
+ let model = req.model || ctx.env.LOOM_DECK_MODEL || '';
108
+ const kind = catalogKind(req);
109
+ if (!model && kind && kind !== 'none')
110
+ model = ctx.catalog.get(kind)?.model ?? '';
111
+ if (!model)
112
+ return MODEL_DEFAULT;
113
+ if (MODELS.includes(model))
114
+ return model;
115
+ warnings.push(`modello ignoto: '${model}' → fallback '${MODEL_DEFAULT}' (usa ${MODELS.join('|')})`);
116
+ return MODEL_DEFAULT;
117
+ }
118
+ /**
119
+ * Il prompt iniziale, vuoto = nessun prompt.
120
+ *
121
+ * `--prompt` vince su tutto ed è usato così com'è. Una sessione nuda senza
122
+ * `--prompt` non ha prompt: il default `recap` del catalogo non si applica,
123
+ * perché ogni template interpola `{TASK}` e con la task vuota sarebbe un
124
+ * comando monco. Sul ramo bound il kind sceglie il template; un catalogo che
125
+ * non ha la voce è un'installazione mutila, e la sessione parte senza prompt
126
+ * dicendolo. `LOOM_DECK_ENTER_PROMPT` vince sul kind, tranne su `none`.
127
+ */
128
+ function promptOf(req, ctx, warnings) {
129
+ if (req.promptGiven)
130
+ return req.promptText;
131
+ if (req.noTask)
132
+ return '';
133
+ const kind = catalogKind(req);
134
+ if (kind === 'none')
135
+ return '';
136
+ const enter = ctx.env.LOOM_DECK_ENTER_PROMPT;
137
+ const template = ctx.catalog.get(kind)?.template;
138
+ if (template === undefined) {
139
+ warnings.push(`catalogo prompt non leggibile ('${ctx.catalogPath}'): sessione senza prompt iniziale`);
140
+ }
141
+ const text = enter || template || '';
142
+ return text.replaceAll('{TASK}', req.task);
143
+ }
144
+ /**
145
+ * La chiave di stato per compass, solo su Linux: env `LOOM_DECK_STATE_PROFILE`
146
+ * (anche vuota = nessun annuncio) > `id` del file di progetto.
147
+ *
148
+ * Lo stato di una sessione viaggia verso compass via D-Bus keyed su
149
+ * `PTYXIS_PROFILE`, e compass lo associa a un progetto solo se la chiave è l'`id`
150
+ * di un progetto del registry. Senza la variabile forzata in-tab, Ptyxis ne
151
+ * esporterebbe una col valore del profilo di default, e il badge resterebbe
152
+ * fermo senza nessun errore. Il nome della variabile è storico: non contiene
153
+ * l'UUID di un profilo. Su Windows compass non esiste, e la chiave non serve.
154
+ */
155
+ function stateKey(ctx, warnings) {
156
+ if (ctx.platform === 'win32')
157
+ return '';
158
+ const raw = ctx.env.LOOM_DECK_STATE_PROFILE !== undefined
159
+ ? ctx.env.LOOM_DECK_STATE_PROFILE
160
+ : configField(ctx.config, 'id');
161
+ if (raw && !STATE_KEY_RE.test(raw)) {
162
+ warnings.push(`profilo di stato ignorato (caratteri non ammessi): '${raw}'`);
163
+ return '';
164
+ }
165
+ return raw;
166
+ }
167
+ /** Un testo come UN argomento di una shell POSIX: sempre fra apici singoli, con
168
+ * l'apice stesso chiuso, escapato e riaperto (`'\''`). Dentro gli apici nessun
169
+ * altro carattere resta speciale, newline compresi. */
170
+ export function posixQuote(text) {
171
+ return `'${text.replace(/'/g, `'\\''`)}'`;
172
+ }
173
+ /**
174
+ * La composizione intera.
175
+ *
176
+ * Ordine degli argomenti di `claude`: nome, permission mode e modello prima dei
177
+ * flag di continuità, il prompt sempre ultimo. Il modello sta accanto al
178
+ * permission mode perché sono la stessa cosa — la scelta di chi apre la
179
+ * sessione — e non una proprietà della task né della ripresa.
180
+ *
181
+ * Su una ripresa il prompt salta anche sul ramo bound: riprendere significa
182
+ * continuare la conversazione, non iniettarle un messaggio nuovo; il fork è una
183
+ * ripresa con id nuovo e ricade nello stesso ramo. Il `--session-id` vale invece
184
+ * anche sul ramo nudo: nel fork di una sessione spot rende noto in anticipo
185
+ * l'id del ramo, senza il quale il lineage non sarebbe registrabile.
186
+ */
187
+ export function composeTab(req, ctx) {
188
+ const warnings = [];
189
+ const title = tabTitle(req, projectLabel(ctx.config));
190
+ const mode = permissionMode(ctx, warnings);
191
+ const model = modelOf(req, ctx, warnings);
192
+ const key = stateKey(ctx, warnings);
193
+ const prompt = promptOf(req, ctx, warnings);
194
+ const withPrompt = prompt !== '' && !req.resumeId;
195
+ const flags = ['--permission-mode', mode, '--model', model];
196
+ if (req.sessionId)
197
+ flags.push('--session-id', req.sessionId);
198
+ if (req.resumeId)
199
+ flags.push('--resume', req.resumeId);
200
+ if (req.fork)
201
+ flags.push('--fork-session');
202
+ const sessionEnv = {};
203
+ if (key)
204
+ sessionEnv.PTYXIS_PROFILE = key;
205
+ if (!req.noTask)
206
+ sessionEnv.LOOM_TASK = req.task;
207
+ // La riga in-tab nella grafia storica dello script: ogni flag seguito da uno
208
+ // spazio, il prompt in coda fra apici. Il gate di parità su Linux confronta
209
+ // questa riga byte per byte, spazio finale compreso quando il prompt manca.
210
+ const envPrefix = Object.entries(sessionEnv)
211
+ .map(([k, v]) => `${k}=${shellQuote(v)} `)
212
+ .join('');
213
+ const flagText = flags.map((f) => `${f} `).join('');
214
+ const inTab = `${envPrefix}claude --name ${posixQuote(title)} ${flagText}` +
215
+ (withPrompt ? posixQuote(prompt) : '');
216
+ return {
217
+ title,
218
+ claudeArgs: ['--name', title, ...flags, ...(withPrompt ? [prompt] : [])],
219
+ sessionEnv,
220
+ inTab,
221
+ warnings,
222
+ };
223
+ }
224
+ /**
225
+ * L'argv di Ptyxis: una tab nella finestra attiva (quella col focus, cioè il
226
+ * deck), o una finestra nuova con `--new-window` (T162) per chi chiama da fuori
227
+ * dalla finestra del progetto. Il comando gira dentro `bash -lc`, e le env di
228
+ * sessione viaggiano DENTRO il comando: `ptyxis --tab` consegna la tab
229
+ * all'istanza già aperta, che non eredita l'env di chi la chiede.
230
+ */
231
+ export function ptyxisArgv(newWindow, workdir, title, inTab) {
232
+ return [newWindow ? '--new-window' : '--tab', '-d', workdir, '-T', title, '--', 'bash', '-lc', inTab];
233
+ }