@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/scripts/deck-run CHANGED
@@ -2,681 +2,14 @@
2
2
  #
3
3
  # deck-run <TaskID> | deck-run --no-task — spawn primitive (UI-agnostico) di loom-deck.
4
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 scelto con --prompt-kind (default: `recap`, cioè la skill
8
- # `/loom-works:recap-status` sulla task).
9
- # Riusabile identico da TUI (Ink) e da web.
10
- #
11
- # Quattro assi ORTOGONALI, da non confondere fra loro:
12
- # binding task <TaskID> ..... --no-task
13
- # continuità nuova ........ --resume [--fork]
14
- # prompt --prompt-kind none|recap|preflight|run|checkpoint | --prompt <testo>
15
- # modello --model fable|opus|sonnet|haiku
16
- # Fuori dai quattro, e volutamente non un asse: --new-window, che cambia SOLO il
17
- # verbo dell'exec finale (tab nella finestra attiva → finestra nuova).
18
- # Il terzo asse (T56) prima non esisteva: il prompt era una CONSEGUENZA degli
19
- # altri due (bound ⇒ recap, --no-task ⇒ niente, --resume ⇒ niente), quindi
20
- # "task-bound SENZA prompt" era inesprimibile e l'unico modo di non avere un
21
- # prompt era perdere la task. Il kind è un SIMBOLO e il suo testo sta nel
22
- # catalogo condiviso `prompt-catalog` (sibling di questo script).
23
- # T117 — accanto al kind c'è ora `--prompt <testo>`, che passa il prompt
24
- # LETTERALE. I due sono mutuamente esclusivi: sono due modi di dire la stessa
25
- # cosa, e accettarli insieme costringerebbe a stabilire quale vince. Serve a chi
26
- # mostra il prompt all'utente prima dello spawn e glielo lascia modificare —
27
- # dopo una modifica nessun kind descrive più quel testo.
28
- # Il quarto asse (T108) è indipendente dagli altri tre: vale sul ramo bound come
29
- # su --no-task, su una sessione nuova come su una ripresa.
30
- #
31
- # Con --no-task la sessione è NUDA: niente LOOM_TASK, niente --session-id.
32
- # Serve al lavoro spot che non appartiene a nessuna task.
33
- # T134 — un prompt iniziale ce l'ha se glielo si passa LETTERALE (`--prompt`):
34
- # il lavoro sulla doc — drenare un file inbox, srotolare l'hard-wrap di un path
35
- # — non appartiene a nessuna task e non deve ereditarne una. I `--prompt-kind`
36
- # del catalogo restano invece rifiutati con --no-task: nominano tutti un TaskID.
37
- #
38
- # Con --resume <sid> riprende una conversazione esistente; aggiungendo --fork la
39
- # riprende RAMANDOLA (`--fork-session`): id nuovo, transcript copiato, l'origine
40
- # resta intatta e scrivibile.
41
- #
42
- # Su stdout, prima di aprire la tab, stampa una riga
43
- # LOOM_DECK_INTAB <comando>
44
- # col comando che girerà DENTRO la tab (la sessione `claude` con le sue env).
45
- # Serve a chi spawna deck-run e vuole mostrare l'invocazione vera senza
46
- # ricomporla per conto proprio; le diagnostiche restano tutte su stderr.
47
- #
48
- # Spawn: `ptyxis --tab -- bash -lc <cmd>`, comando inline sul profilo di default
49
- # di Ptyxis — LOOM_TASK e chiave di stato viaggiano dentro il comando, nessuna
50
- # scrittura dconf e nessun profilo dedicato.
51
- #
52
- # Env:
53
- # LOOM_DECK_WORKDIR dir di lavoro della tab (default: $PWD)
54
- # LOOM_DECK_ENTER_PROMPT override del prompt iniziale, {TASK}=TaskID (vince sul kind, tranne `none`)
55
- # T161: inerte sugli spawn che arrivano dal deck TUI —
56
- # quelli passano SEMPRE `--prompt`, su cui non ha mai
57
- # vinto. Resta per le invocazioni a mano e per i test.
58
- # LOOM_DECK_INTAB_CMD override comando in-tab (default: la sessione CC; usato per i test)
59
- # LOOM_DECK_STATE_PROFILE PTYXIS_PROFILE annunciata a compass (default: `id` del file
60
- # config del progetto; settata a vuoto = nessun annuncio)
61
- # LOOM_DECK_PERMISSION_MODE override del permissionMode del file config (default: campo file, poi 'manual')
62
- # LOOM_DECK_MODEL modello quando --model non è passato (batte il catalogo, default: fable)
63
- #
64
- set -euo pipefail
65
-
66
- # Il catalogo dei prompt è un file DATI sibling di questo script, non un `case`
67
- # qui dentro: lo legge anche il deck TUI, che con la stessa risoluzione sibling
68
- # arriva allo stesso file della stessa installazione.
69
- _SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
70
- PROMPT_CATALOG="${LOOM_DECK_PROMPT_CATALOG:-${_SCRIPT_DIR}/prompt-catalog}"
71
-
72
- TASK=""
73
- SESSION_ID=""
74
- RESUME_ID=""
75
- NO_TASK=0
76
- FORK=0
77
- # T162 — finestra NUOVA invece di una tab nella finestra attiva.
78
- #
79
- # `ptyxis --tab` va sempre nella finestra col focus, e va bene per il deck, che
80
- # è già una tab della finestra del progetto. Un chiamante che vive FUORI da
81
- # quella finestra — compass, che riapre una conversazione pinnata di un
82
- # progetto chiuso — non ha una finestra attiva giusta: senza un verbo diverso
83
- # la tab finisce nella finestra di un altro progetto, o in una a caso.
84
- #
85
- # Non è un quinto asse: non cambia cosa gira nella tab, solo dove la tab nasce.
86
- NEW_WINDOW=0
87
- TITLE_NOTE=""
88
- # Vuoto = flag non passato (≠ un default già risolto): serve a distinguere
89
- # "kind implicito" da "kind chiesto", perché con --no-task il primo è legittimo
90
- # (nessun prompt, come sempre) e il secondo è un errore d'uso.
91
- PROMPT_KIND=""
92
- # T117 — prompt letterale. Vuoto NON basta a dire "non passato": `--prompt ''`
93
- # è una richiesta legittima (nessun prompt, deciso da chi ha svuotato il campo),
94
- # quindi la presenza del flag si tiene in un booleano a parte.
95
- PROMPT_TEXT=""
96
- PROMPT_GIVEN=0
97
- # Vuoto = flag non passato: la cascata (env, poi default) si risolve più sotto,
98
- # insieme alla validazione, così l'ingresso da argomento e quello da env passano
99
- # per lo stesso enum.
100
- MODEL=""
101
- # Positional <TaskID> + flag opzionale --session-id <uuid> (T27): il deck genera
102
- # l'UUID e lo pinna così il binding sidecar sessionId↔taskId è deterministico.
103
- # --no-task (T42): modalità NUDA, senza TaskID — nessuna LOOM_TASK, nessun prompt
104
- # iniziale, nessun sessionId pinnato. Flag esplicito e non "positional opzionale"
105
- # perché un `deck-run` a mani vuote resta un errore d'uso, non una sessione spot.
106
- # --resume <sid> (T49): riapre una sessione esistente con `claude --resume`.
107
- # Componibile con entrambe le forme: `deck-run <TaskID> --resume <sid>` (scoped:
108
- # la ripresa eredita LOOM_TASK + titolo · task) e `deck-run --no-task --resume
109
- # <sid>` (spot: resume nudo). Su resume niente prompt iniziale (si continua la
110
- # conversazione, non se ne inietta una nuova) e niente --session-id (l'id ce
111
- # l'ha già la sessione ripresa).
112
- # --fork (T28): MODIFICATORE di --resume, speculare al `--fork-session` del CLI
113
- # (che è definito allo stesso modo: "when resuming, create a new session ID").
114
- # Non è una terza forma di spawn ma una variante della ripresa → richiede
115
- # --resume, e senza di esso è un errore d'uso, non un fork "dell'ultima".
116
- # Sotto --fork il --session-id torna AMMESSO (anzi: è il punto) — la mutua
117
- # esclusione con --resume esiste perché una ripresa nuda riscrive il transcript
118
- # dell'id ripreso, mentre il fork ne apre uno NUOVO, che possiamo quindi pinnare.
119
- # --prompt-kind <none|recap|recap-task|recap-epic|preflight|run|checkpoint>: sceglie il prompt iniziale fra
120
- # quelli del catalogo qui sotto. Enum e non stringa libera: il prompt viaggia
121
- # dentro apici singoli in `bash -lc`, quindi il testo va tenuto in un posto solo
122
- # e verificato una volta, invece di spostare il rischio di quoting su ogni
123
- # chiamante. Assente → `recap`, così ogni invocazione preesistente è invariata.
124
- # --title-note <testo> (T64): appende la nota della conversazione al titolo della
125
- # tab. È l'UNICO ingresso di testo libero nel titolo — gli altri componenti
126
- # (emoji/name dal file committato, TaskID) sono controllati — quindi il testo non
127
- # si quota, si RIDUCE a un alfabeto sicuro (vedi _sane_note): tutto ciò che non
128
- # ci rientra sparisce, apici inclusi. L'alfabeto ammette quattro emoji (T156)
129
- # perché il deck ci fa passare il prefisso del titolo di fallback (`🚀 slug`);
130
- # ogni altra emoji cade ancora.
131
- # --model <fable|opus|sonnet|haiku> (T108): modello della sessione. Enum e non
132
- # stringa libera perché il valore finisce dentro `bash -lc` e un refuso
133
- # produrrebbe una tab con un comando che il CLI rifiuta. Le voci sono ALIAS e
134
- # restano tali: espanderle a id versionati (`claude-opus-5`) cablerebbe qui una
135
- # generazione, che al primo bump diventa un modello inesistente.
136
- while [[ $# -gt 0 ]]; do
137
- case "$1" in
138
- --model)
139
- MODEL="${2:-}"; shift 2 ;;
140
- --model=*)
141
- MODEL="${1#*=}"; shift ;;
142
- --title-note)
143
- TITLE_NOTE="${2:-}"; shift 2 ;;
144
- --title-note=*)
145
- TITLE_NOTE="${1#*=}"; shift ;;
146
- --prompt-kind)
147
- PROMPT_KIND="${2:-}"; shift 2 ;;
148
- --prompt-kind=*)
149
- PROMPT_KIND="${1#*=}"; shift ;;
150
- --prompt)
151
- PROMPT_TEXT="${2:-}"; PROMPT_GIVEN=1; shift 2 ;;
152
- --prompt=*)
153
- PROMPT_TEXT="${1#*=}"; PROMPT_GIVEN=1; shift ;;
154
- --session-id)
155
- SESSION_ID="${2:-}"; shift 2 ;;
156
- --session-id=*)
157
- SESSION_ID="${1#*=}"; shift ;;
158
- --resume)
159
- RESUME_ID="${2:-}"; shift 2 ;;
160
- --resume=*)
161
- RESUME_ID="${1#*=}"; shift ;;
162
- --fork)
163
- FORK=1; shift ;;
164
- --no-task)
165
- NO_TASK=1; shift ;;
166
- --new-window)
167
- NEW_WINDOW=1; shift ;;
168
- *)
169
- if [[ -z "$TASK" ]]; then TASK="$1"; shift
170
- else echo "argomento inatteso: '$1'" >&2; exit 2; fi ;;
171
- esac
172
- done
173
-
174
- USAGE="uso: deck-run <TaskID> [--model <alias>] [--prompt-kind <kind>] [--session-id <uuid>]
175
- (es. deck-run T18)
176
- | deck-run --no-task (sessione nuda, senza task)
177
- | deck-run <TaskID> --resume <uuid> (riprende sessione scoped)
178
- | deck-run --no-task --resume <uuid> (riprende sessione spot)
179
- | deck-run <TaskID> --resume <uuid> --fork [--session-id <nuovo-uuid>]
180
- (forka: ramo con id NUOVO)
181
-
182
- --prompt-kind prompt iniziale della sessione bound (default: recap)
183
- none nessun prompt — sessione aperta sulla task, a mani nude
184
- recap /loom-works:recap-status <TaskID> (dispatcher)
185
- recap-task /loom-works:recap-status-task <TaskID> (task non cappello)
186
- recap-epic /loom-works:recap-status-epic <TaskID> (Size: Epic)
187
- preflight /loom-works:preflight-task <TaskID>
188
- run /loom-works:run-task <TaskID>
189
- checkpoint /loom-works:checkpoint-task <TaskID>
190
-
191
- --prompt prompt LETTERALE, per chi lo ha già composto (o fatto
192
- modificare). Esclusivo con --prompt-kind; vuoto = nessun
193
- prompt. Ammesso anche con --no-task: un testo già scritto non
194
- ha una task da nominare, a differenza dei kind del catalogo
195
-
196
- --model modello della sessione: fable|opus|sonnet|haiku
197
- (default: il modello del kind in catalogo, o 'fable' fuori catalogo)
198
- valore ignoto → fallback sul default, con avviso su stderr
199
-
200
- --title-note nota della conversazione, appesa al titolo tab
201
- (ridotta a lettere/cifre/spazi/-/_, cap 60 char)
202
-
203
- --new-window apre una FINESTRA nuova invece di una tab nella finestra
204
- attiva. Per chi chiama da fuori dalla finestra del progetto"
205
-
206
- if [[ $NO_TASK -eq 1 && -n "$TASK" ]]; then
207
- echo "--no-task e <TaskID> sono mutuamente esclusivi" >&2
208
- echo "$USAGE" >&2
209
- exit 2
210
- fi
211
- if [[ $NO_TASK -eq 0 && -z "$TASK" ]]; then
212
- echo "$USAGE" >&2
213
- exit 2
214
- fi
215
- if [[ $FORK -eq 1 && -z "$RESUME_ID" ]]; then
216
- echo "--fork richiede --resume <uuid>: si forka una conversazione esistente, non il nulla" >&2
217
- echo "$USAGE" >&2
218
- exit 2
219
- fi
220
- if [[ -n "$RESUME_ID" && -n "$SESSION_ID" && $FORK -eq 0 ]]; then
221
- echo "--resume e --session-id sono mutuamente esclusivi (la sessione ripresa ha già il suo id)" >&2
222
- echo "usa --fork se vuoi un ramo con id nuovo" >&2
223
- echo "$USAGE" >&2
224
- exit 2
225
- fi
226
- # Un kind fuori catalogo è un errore d'uso del CHIAMANTE (simbolo interno, non
227
- # configurazione utente) → exit, non fallback silenzioso come permissionMode:
228
- # lì il valore arriva da un file editabile a mano, qui da un argomento nostro.
229
- if [[ -n "$PROMPT_KIND" ]]; then
230
- case "$PROMPT_KIND" in
231
- none|recap|recap-task|recap-epic|preflight|run|checkpoint) ;;
232
- *) echo "--prompt-kind ignoto: '${PROMPT_KIND}' (usa none|recap|recap-task|recap-epic|preflight|run|checkpoint)" >&2
233
- echo "$USAGE" >&2
234
- exit 2 ;;
235
- esac
236
- fi
237
- # Ogni prompt del catalogo nomina il TaskID: senza task non c'è nulla da
238
- # chiedere, e un `--no-task --prompt-kind run` è una richiesta contraddittoria,
239
- # non una da degradare in silenzio.
240
- if [[ $NO_TASK -eq 1 && -n "$PROMPT_KIND" ]]; then
241
- echo "--prompt-kind richiede una task: una sessione --no-task non ha prompt iniziale" >&2
242
- echo "$USAGE" >&2
243
- exit 2
244
- fi
245
- # T134/D9 — `--prompt` con `--no-task` è AMMESSO, e il divieto che c'era qui è
246
- # caduto. Il razionale del divieto valeva per il catalogo, non per il testo
247
- # letterale: i sette kind nominano tutti il TaskID, quindi senza task non c'è
248
- # nulla da chiedere loro; un prompt già composto dal chiamante non ha invece
249
- # nessuna task da nominare — e ce ne sono di legittimi che non ne hanno una.
250
- # Il caso che ha fatto cadere il divieto: il deck offre il drain di un file
251
- # inbox e lo srotolamento dei `.md` di un path, due lavori che stanno sulla doc
252
- # e non su una task; legarli a un cappello esporterebbe `LOOM_TASK`, farebbe
253
- # iniettare in contesto il task file di un lavoro chiuso e metterebbe `· T<n>`
254
- # nel titolo della tab (D10 preflight).
255
- #
256
- # Il divieto gemello su `--prompt-kind` RESTA. Toglierlo non è simmetrico:
257
- # ogni template del catalogo interpola `{TASK}`, e senza task produrrebbe un
258
- # prompt come `/loom-works:run-task ` con l'id vuoto — cioè una sessione che
259
- # parte su un comando monco, che è il guasto che il divieto esiste per evitare.
260
- # Due modi di dire la stessa cosa: accettarli insieme obbligherebbe a stabilire
261
- # quale vince, cioè a scrivere una precedenza che nessun chiamante ha chiesto.
262
- if [[ $PROMPT_GIVEN -eq 1 && -n "$PROMPT_KIND" ]]; then
263
- echo "--prompt e --prompt-kind sono mutuamente esclusivi (il testo o il simbolo, non entrambi)" >&2
264
- echo "$USAGE" >&2
265
- exit 2
266
- fi
267
-
268
- WORKDIR="${LOOM_DECK_WORKDIR:-$PWD}"
269
-
270
- # ── Titolo tab = label standard loom (matchabile da compass) ──────────────────
271
- # compass matcha le finestre cercando nel titolo un prefisso `{emoji} {name}`,
272
- # dove i due token sono DERIVATI dal contratto committato .claude/loom-works.json
273
- # → es. "🧵 loom-works". Un titolo "cc <task>" non contiene la label → finestra
274
- # ORFANA (progetto appare spento anche mentre ci lavori dentro). Deriviamo la
275
- # label dal file e la usiamo come titolo, con
276
- # suffisso "· <task>" per restare distinguibili fra più tab claude (il suffisso
277
- # non rompe il match: è .includes, non equals — stesso pattern del "· deck").
278
- # Fallback su "cc <task>" se manca il file o jq (progetto non loom-registered).
279
-
280
- # Riduzione della nota a un alfabeto sicuro (T64). Whitelist, non blacklist: il
281
- # titolo finisce dentro apici singoli in `bash -lc "claude --name '…'"`, e la
282
- # nota è testo digitato a mano — enumerare i caratteri pericolosi significa
283
- # sbagliarne uno, enumerare quelli ammessi no. Restano lettere, cifre, accentate
284
- # italiane, spazio, trattino, underscore e sei emoji (sotto); tutto il resto
285
- # (apici, backslash, `$`, backtick, `;`, quadre, ogni altra emoji…) cade.
286
- #
287
- # T156 — le emoji entrano per il PREFISSO del titolo che il deck compone
288
- # (`🚀 slug`). Senza di loro il prefisso spariva qui e restava nella lista del
289
- # deck, che riceve la nota grezza dal sidecar `session-tasks.jsonl`: la stessa
290
- # conversazione portava due titoli diversi, senza errore. Le quadre che
291
- # delimitavano il prefisso sono cadute col prefisso stesso: l'emoji da sola si
292
- # stacca già dallo slug.
293
- # T161/T160 — le sette stanno tutte nei TEMPLATE di titolo del catalogo delle
294
- # azioni (`SPAWN_ACTIONS` in `src/spawn-catalog.ts`), che il progetto può
295
- # riscrivere: quattro nominano un'azione su una task (📐🚀📊🏁), 🧹 il file inbox
296
- # che si drena, 📏 il path che si srotola, 🧭 il bersaglio doc che si
297
- # riorganizza.
298
- # Questa whitelist ha un GEMELLO TypeScript (`saneNote` in `src/sane-note.ts`),
299
- # che riduce il template al salvataggio perché quello che sta nel file di
300
- # progetto sia quello che la tab mostrerà. I due lati non si leggono a vicenda e
301
- # vanno cambiati insieme: il custode è il gate di `test/deck-run.test.ts`, che
302
- # passa la stessa batteria di input a questa funzione e al gemello e pretende
303
- # l'uguaglianza.
304
- # La whitelist le nomina una per una e NON apre un intervallo di code point. Due
305
- # ragioni: un'emoji arriva nella tab solo se il deck la mostra identica in lista,
306
- # cioè se `sanitize` (`src/width.ts`) la lascia passare invece di renderla `·`, e
307
- # quel verdetto si verifica un glifo alla volta (🗺️ è astrale e finisce comunque
308
- # in `·`); e l'ordine di un intervallo dentro un bracket expression segue la
309
- # collazione della locale, non i code point.
310
- #
311
- # Il cap a 60 CHARACTER è largo di proposito: una tab che sfora tronca a destra
312
- # da sola, quindi il limite serve solo a non spingere fuori vista la parte del
313
- # titolo che compass matcha, non a far stare la nota nella tab.
314
- _sane_note() { # <raw> → nota ridotta, spazi collassati, cap 60 char
315
- # LC_ALL locale alla funzione: serve a bash per tagliare a 60 CARATTERI e non
316
- # a 60 byte (a byte, un cap che cade a metà di una `à` lascia UTF-8 rotto nel
317
- # titolo). L'assegnazione rifà setlocale, l'uscita dalla funzione lo ripristina.
318
- local LC_ALL=C.UTF-8
319
- local s
320
- # La locale va rimessa SUL `sed`, non ereditata da qui: `local LC_ALL` non
321
- # porta l'attributo export quando `LC_ALL` non era già esportata nell'ambiente
322
- # (misurato: il figlio la vede vuota), quindi `sed` ricade su `LANG` — sotto
323
- # una locale `C` lavora a byte e un carattere multibyte fuori whitelist lascia
324
- # nel titolo i byte che coincidono con quelli ammessi. Le emoji astrali, che
325
- # condividono i primi due byte `f0 9f`, ne lasciano sempre due: `🔥 fuoco`
326
- # diventa `\xf0\x9f fuoco`, cioè UTF-8 rotto dentro il titolo della tab.
327
- # `tr` non ne ha bisogno: squeeza uno spazio ASCII, e 0x20 non compare mai
328
- # come byte di continuazione di una sequenza UTF-8.
329
- s="$(printf '%s' "$1" \
330
- | LC_ALL=C.UTF-8 sed 's/[^A-Za-z0-9 _àèéìòùÀÈÉÌÒÙ📐🚀📊🏁🧹📏🧭-]//g' \
331
- | tr -s ' ')"
332
- s="${s# }"; s="${s% }"
333
- printf '%s' "${s:0:60}"
334
- }
335
-
336
- _find_project_root() { # <start-dir> → dir con .claude/loom-works.json, o vuoto
337
- local d="${1%/}"
338
- while [[ -n "$d" && "$d" != "/" ]]; do
339
- [[ -f "$d/.claude/loom-works.json" ]] && { printf '%s\n' "$d"; return 0; }
340
- d="$(dirname "$d")"
341
- done
342
- [[ -f "/.claude/loom-works.json" ]] && { printf '/\n'; return 0; }
343
- return 1
344
- }
345
-
346
- # Project root risolta UNA volta: la usano sia la label sia permissionMode (T45).
347
- _proot=""
348
- if command -v jq >/dev/null 2>&1; then
349
- _proot="$(_find_project_root "$WORKDIR" || true)"
350
- fi
351
- _cfg=""
352
- [[ -n "$_proot" ]] && _cfg="${_proot}/.claude/loom-works.json"
353
-
354
- # La label vale per OGNI surface claude, task o no (T42): il match compass è
355
- # window-level e non sa nulla di task → legare la titolazione alla presenza di
356
- # una task sarebbe un errore di layer. Cambia solo il suffisso: `· <task>` quando
357
- # la sessione è bound, nessun suffisso quando è nuda.
358
- if [[ $NO_TASK -eq 1 ]]; then TITLE="cc"; else TITLE="cc ${TASK}"; fi
359
- if [[ -n "$_cfg" ]]; then
360
- _label="$(jq -r 'if (.emoji and .name)
361
- then "\(.emoji) \(.name)" else empty end' \
362
- "$_cfg" 2>/dev/null || true)"
363
- if [[ -n "${_label:-}" ]]; then
364
- if [[ $NO_TASK -eq 1 ]]; then TITLE="${_label}"; else TITLE="${_label} · ${TASK}"; fi
365
- fi
366
- fi
367
- # Suffisso nota (T64): distingue fra loro le tab di più conversazioni sulla
368
- # STESSA task, che altrimenti condividono un titolo identico (`label · T81`) —
369
- # la nota è già la maniglia con cui l'utente le distingue in lista. In coda per
370
- # la solita ragione degli altri suffissi: il match compass è `.includes(label)`,
371
- # la label deve restare intatta in TESTA.
372
- # T150 — nudo, senza i caporali `«…»` che lo decoravano: la nota di fallback
373
- # generata dal deck quando il campo è vuoto passa dallo stesso alfabeto ridotto
374
- # e non ha bisogno di una cornice per distinguersi da un titolo scritto a mano.
375
- # Nota ridotta a vuoto (era tutta emoji/punteggiatura) → nessun suffisso.
376
- if [[ -n "$TITLE_NOTE" ]]; then
377
- _note="$(_sane_note "$TITLE_NOTE")"
378
- [[ -n "$_note" ]] && TITLE="${TITLE} ${_note}"
379
- fi
380
- # Suffisso fork (T28): un ramo eredita task e label dell'origine, quindi senza
381
- # marcatore le due tab risulterebbero omonime nella stessa window. Suffisso e
382
- # non prefisso perché il match compass è `.includes(label)` e la label deve
383
- # restare intatta in testa — stesso schema di `· <task>` e `· deck`.
384
- [[ $FORK -eq 1 ]] && TITLE="${TITLE} · fork"
385
-
386
- # ── permissionMode (T45) ─────────────────────────────────────────────────────
387
- # Precedenza: env LOOM_DECK_PERMISSION_MODE > campo del file config > 'manual'.
388
- # Il flag è passato SEMPRE, anche per 'manual': lo spawn resta deterministico e
389
- # leggibile nel process tree, invece di dipendere dal default del CLI (che può
390
- # cambiare fra versioni). Un valore fuori enum NON viene passato al CLI (che
391
- # rifiuterebbe, lasciando una tab con un comando rotto): fallback su 'manual'
392
- # con segnalazione su stderr.
393
- PERM_MODE="${LOOM_DECK_PERMISSION_MODE:-}"
394
- if [[ -z "$PERM_MODE" && -n "$_cfg" ]]; then
395
- PERM_MODE="$(jq -r '.permissionMode // empty' "$_cfg" 2>/dev/null || true)"
396
- fi
397
- case "${PERM_MODE:-}" in
398
- acceptEdits|auto|bypassPermissions|manual|dontAsk|plan) ;;
399
- '') PERM_MODE="manual" ;;
400
- *) echo "permissionMode ignoto: '${PERM_MODE}' → fallback 'manual'" >&2
401
- PERM_MODE="manual" ;;
402
- esac
403
- MODE_FLAG="--permission-mode ${PERM_MODE} "
404
-
405
- # ── modello (T108 + T152) ────────────────────────────────────────────────────
406
- # Precedenza: --model > env LOOM_DECK_MODEL > modello del kind nel catalogo >
407
- # 'fable'.
408
- #
409
- # T161 — questa cascata vale per chi invoca deck-run A MANO. Il deck TUI passa
410
- # SEMPRE `--model` già risolto (default del deck ← override di progetto nel
411
- # blocco `spawn` di `.claude/loom-works.json`), quindi dal deck si ferma al primo
412
- # gradino e i tre sotto non intervengono mai. Il blocco di progetto qui non si
413
- # legge di proposito: `jq` è dipendenza opzionale di questo script, e dargli un
414
- # JSON da consultare la renderebbe obbligatoria su un percorso che oggi degrada
415
- # senza. L'ultimo gradino è il gemello bash di `MODEL_DEFAULT` in spawn.ts e
416
- # va tenuto allineato a mano: i due lati non si leggono a vicenda (il deck deve
417
- # poter MOSTRARE la selezione iniziale prima che deck-run esista come processo),
418
- # quindi un cambio qui senza l'altro fa partire dalla lista un modello diverso
419
- # da quello che il detail aveva scritto a schermo.
420
- # Fino a T152 il default era un valore unico per tutta la famiglia,
421
- # perché il frontmatter `model:` delle skill riscriveva comunque il modello di
422
- # sessione — un default configurabile qui non sarebbe servito a nulla. Da
423
- # quando le skill non dichiarano più `model:`, il default DEVE viaggiare col
424
- # kind: `run` costa quanto `preflight` solo se «vuole sonnet» è scritto da
425
- # qualche parte che deck-run consulta, e quella parte è il catalogo che il
426
- # deck TUI legge già per il testo del prompt (T117) — stessa fonte, stesso
427
- # `kind`, niente seconda copia.
428
- # Il flag è passato SEMPRE, anche sul default, per la stessa ragione di
429
- # permissionMode: lo spawn resta leggibile nel process tree invece di dipendere
430
- # dal default del CLI, che cambia fra versioni.
431
- # Valore fuori enum → fallback con avviso, non exit. Regime opposto a
432
- # --prompt-kind, e il discriminante è dove finisce il valore: un kind ignoto si
433
- # ferma DENTRO lo script (nessun template da scegliere, l'errore è del deck),
434
- # un modello ignoto arriverebbe al CLI e produrrebbe una tab con un comando che
435
- # fallisce all'avvio — cioè il guasto che permissionMode evita degradando.
436
- _model_for_kind() { # <kind> → modello dichiarato in catalogo (3ª colonna), o niente
437
- local want="$1" k t m
438
- [[ -n "$want" && -f "$PROMPT_CATALOG" ]] || return 1
439
- while IFS=$'\t' read -r k t m || [[ -n "$k" ]]; do
440
- [[ -z "$k" || "$k" == \#* ]] && continue
441
- [[ "$k" == "$want" && -n "$m" ]] && { printf '%s' "$m"; return 0; }
442
- done < "$PROMPT_CATALOG"
443
- return 1
444
- }
445
- # Kind di catalogo effettivo: SOLO quando la sessione è bound e senza prompt
446
- # letterale — le due strade (--no-task, --prompt) non hanno una riga di
447
- # catalogo da consultare, né per il prompt (sotto) né per il modello (qui).
448
- # Riusato tal quale nella composizione del prompt: stesso guard, stesso valore.
449
- CATALOG_KIND=""
450
- if [[ $PROMPT_GIVEN -eq 0 && $NO_TASK -eq 0 ]]; then
451
- CATALOG_KIND="${PROMPT_KIND:-recap}"
452
- fi
453
- MODEL="${MODEL:-${LOOM_DECK_MODEL:-}}"
454
- if [[ -z "$MODEL" && -n "$CATALOG_KIND" && "$CATALOG_KIND" != "none" ]]; then
455
- MODEL="$(_model_for_kind "$CATALOG_KIND" || true)"
456
- fi
457
- case "${MODEL:-}" in
458
- fable|opus|sonnet|haiku) ;;
459
- '') MODEL="fable" ;;
460
- *) echo "modello ignoto: '${MODEL}' → fallback 'fable' (usa fable|opus|sonnet|haiku)" >&2
461
- MODEL="fable" ;;
462
- esac
463
- MODEL_FLAG="--model ${MODEL} "
464
-
465
- # ── Chiave di stato per compass ──────────────────────────────────────────────
466
- # Lo stato di una sessione (running/ask/done) viaggia verso compass via D-Bus
467
- # keyed su $PTYXIS_PROFILE (hook Claude → `compass <stato>`), e compass lo mappa
468
- # a un progetto SOLO se quella chiave è l'`id` di un progetto del registry. La
469
- # tab nasce da `ptyxis --tab` nudo, e Ptyxis esporta da sé PTYXIS_PROFILE col
470
- # valore del profilo DEFAULT: senza un prefisso l'annuncio arriverebbe keyed su
471
- # un UUID che nessun progetto dichiara → stato scartato, badge fermo per sempre.
472
- # Il match della FINESTRA non se ne accorge (passa dal titolo, non dalla
473
- # chiave), quindi il progetto risulta presente ma senza stato — un guasto che
474
- # non si manifesta come errore, solo come badge che non cambia mai.
475
- # Rimedio: forzare in-tab PTYXIS_PROFILE=<id del progetto>. Il nome della
476
- # variabile è storico: non contiene l'UUID di un profilo Ptyxis, e nessuna
477
- # chiamata a Ptyxis la usa. È la stessa chiave che mette il bottone 🤖 di compass.
478
- #
479
- # `${VAR+set}`, non `${VAR:-}`: env settata anche a VUOTO = "nessun annuncio"
480
- # (deterministico per i test). Non settata = `id` del file config del progetto.
481
- # Assente/non risolvibile → nessun prefisso: si degrada a stato orfano invece di
482
- # rompere lo spawn.
483
- if [[ -n "${LOOM_DECK_STATE_PROFILE+set}" ]]; then
484
- STATE_PROFILE="${LOOM_DECK_STATE_PROFILE}"
485
- else
486
- STATE_PROFILE=""
487
- if [[ -n "$_cfg" ]]; then
488
- STATE_PROFILE="$(jq -r '.id // empty' "$_cfg" 2>/dev/null || true)"
489
- fi
490
- fi
491
- # Whitelist come per la nota del titolo: il valore entra in `bash -lc "…"`, e il
492
- # file config è editabile a mano. Lo schema non vincola la forma dell'`id`:
493
- # accetto l'alfabeto id-safe e scarto il resto invece di quotare.
494
- if [[ -n "$STATE_PROFILE" && ! "$STATE_PROFILE" =~ ^[A-Za-z0-9_-]+$ ]]; then
495
- echo "profilo di stato ignorato (caratteri non ammessi): '${STATE_PROFILE}'" >&2
496
- STATE_PROFILE=""
497
- fi
498
- PROFILE_ENV=""
499
- [[ -n "$STATE_PROFILE" ]] && PROFILE_ENV="PTYXIS_PROFILE=${STATE_PROFILE} "
500
-
501
- # --session-id per la tab: pinna il sessionId della sessione CC (vuoto → CC ne
502
- # genera uno). Lo spazio finale tiene la concatenazione pulita quando è assente.
503
- SID_FLAG=""
504
- [[ -n "$SESSION_ID" ]] && SID_FLAG="--session-id ${SESSION_ID} "
505
-
506
- # --resume per la tab (T49): riprende la sessione esistente. Stesso pattern
507
- # flag-opzionale di SID_FLAG — la variante resta dentro l'unico IN_TAB_CMD.
508
- RES_FLAG=""
509
- [[ -n "$RESUME_ID" ]] && RES_FLAG="--resume ${RESUME_ID} "
510
-
511
- # --fork-session per la tab (T28): CC apre un id NUOVO invece di riscrivere
512
- # quello ripreso → mai due writer sullo stesso JSONL. Terzo gemello del pattern
513
- # flag-opzionale, per la stessa ragione degli altri due: la variante non deve
514
- # duplicare IN_TAB_CMD.
515
- FORK_FLAG=""
516
- [[ $FORK -eq 1 ]] && FORK_FLAG="--fork-session "
517
-
518
- # ── Catalogo dei prompt iniziali (T56 · T117) ────────────────────────────────
519
- # Il prompt iniziale è la scelta di CHI APRE la sessione, non una proprietà della
520
- # task: lo stesso task file si apre per leggerne lo stato, per un preflight, per
521
- # eseguirlo o a mani nude. Il kind è quell'intento.
522
- # none → nessun prompt (≠ stringa vuota: PROMPT_ARG sparisce del tutto,
523
- # come sul ramo --resume, altrimenti CC riceve un posizionale vuoto)
524
- # recap → skill di recap sulla task. È un DISPATCHER: `recap-status`
525
- # risolve la task, classifica (progetto / task / epica) e passa a
526
- # una sotto-skill. Fino a v0.45 era invece un prompt diretto
527
- # ("recap stato task <id>"), scelto perché non esisteva una skill
528
- # di recap tarata sulla singola task e quella di progetto avrebbe
529
- # risposto largo — vincolo caduto col dispatcher.
530
- # recap-task → la sotto-skill diretta, senza il giro del dispatcher
531
- # recap-epic → idem per un cappello (`Size: Epic`)
532
- # I due specializzati li sceglie chi SA già come è fatta la task:
533
- # il detail del deck, che il task file l'ha aperto. Chi non lo sa
534
- # — gli acceleratori della lista, che leggono solo `tasks.md`, e
535
- # quindi non vedono il `Size` — resta su `recap` e lascia
536
- # classificare al dispatcher. Il criterio non è duplicato: sta in
537
- # `taskIsEpic` (src/tasks.ts) e legge lo stesso campo che leggerebbe
538
- # il dispatcher; quello che cambia è solo QUANDO viene letto.
539
- # preflight → skill di preflight sulla task
540
- # run → skill di esecuzione sulla task
541
- # checkpoint → skill di checkpoint sulla task
542
- #
543
- # T117 — i testi non stanno più qui ma nel file dati `prompt-catalog`, sibling di
544
- # questo script. Il deck TUI li mostra nel campo prompt del detail prima dello
545
- # spawn, e per farlo deve leggerli: come `case` bash sarebbe una seconda scrittura
546
- # delle stesse regole. Il file resta l'unica copia, letta da entrambi i lati.
547
- # Formato a righe e non JSON perché `jq` è dipendenza OPZIONALE di questo script
548
- # (con fallback) e leggere il catalogo lo renderebbe dura.
549
- #
550
- # Precedenza con l'env: LOOM_DECK_ENTER_PROMPT VINCE sul kind (retro-compat: chi
551
- # lo usa oggi non si accorge del cambio), tranne su `none` — chiedere
552
- # esplicitamente nessun prompt non è una richiesta che un default d'ambiente
553
- # possa scavalcare. Non vince invece su `--prompt`, che è una richiesta esplicita
554
- # e già risolta: un override d'ambiente che scavalca un testo composto a mano
555
- # butterebbe via proprio la modifica che il flag esiste per portare.
556
- # Ogni template nomina il TaskID via `{TASK}`: la lista del deck è monofamiglia
557
- # (`T`, unico prefisso del contratto loom), quindi nessun prompt discrimina sulla
558
- # forma dell'id.
559
- _prompt_template() { # <kind> → template col placeholder {TASK}, o niente
560
- local want="$1" k t m
561
- [[ -f "$PROMPT_CATALOG" ]] || return 1
562
- # 3 variabili, non 2 (T152): da quando il catalogo porta una 3ª colonna
563
- # modello, un `read -r k t` a due si mangerebbe il tab separatore e il
564
- # modello dentro `t`, che è esattamente il template passato al CLI.
565
- while IFS=$'\t' read -r k t m || [[ -n "$k" ]]; do
566
- [[ -z "$k" || "$k" == \#* ]] && continue
567
- [[ "$k" == "$want" ]] && { printf '%s' "$t"; return 0; }
568
- done < "$PROMPT_CATALOG"
569
- return 1
570
- }
571
-
572
- if [[ $PROMPT_GIVEN -eq 1 ]]; then
573
- PROMPT="$PROMPT_TEXT"
574
- elif [[ $NO_TASK -eq 1 ]]; then
575
- # Sessione nuda senza `--prompt`: nessun prompt iniziale, punto.
576
- #
577
- # Il default `recap` del catalogo NON si applica qui, e la guardia deve stare
578
- # a monte invece che a valle. Finché il ramo `--no-task` scartava il prompt
579
- # al momento di comporre il comando in-tab, un `PROMPT` risolto e mai usato
580
- # era innocuo; da quando il PROMPT_ARG entra anche in quel ramo (T134/D9,
581
- # per il drain e lo srotolamento) il testo del catalogo ci arriva davvero —
582
- # e siccome ogni template interpola `{TASK}`, con la task vuota diventa
583
- # `/loom-works:recap-status ` con l'id monco: la sessione vuota del deck (`c`)
584
- # nasceva su un recap che nessuno ha chiesto.
585
- #
586
- # Stessa ragione per `LOOM_DECK_ENTER_PROMPT`, che interpola `{TASK}` a sua
587
- # volta e qui resta quindi inerte: senza task non ha nulla da nominare.
588
- PROMPT=""
589
- else
590
- _kind="$CATALOG_KIND"
591
- if [[ "$_kind" == "none" ]]; then
592
- PROMPT=""
593
- else
594
- # Catalogo assente o voce mancante: il kind è già passato dall'enum sopra,
595
- # quindi qui manca il FILE — un'installazione mutila, non un errore d'uso.
596
- # Si degrada a nessun prompt dicendolo, invece di aprire una sessione con un
597
- # prompt inventato al volo (che sarebbe la copia del catalogo).
598
- if ! PROMPT="$(_prompt_template "$_kind")"; then
599
- echo "catalogo prompt non leggibile ('${PROMPT_CATALOG}'): sessione senza prompt iniziale" >&2
600
- PROMPT=""
601
- fi
602
- PROMPT="${PROMPT//\{TASK\}/${TASK}}"
603
- fi
604
- if [[ "$_kind" != "none" && -n "${LOOM_DECK_ENTER_PROMPT:-}" ]]; then
605
- PROMPT="${LOOM_DECK_ENTER_PROMPT//\{TASK\}/${TASK}}"
606
- fi
607
- fi
608
-
609
- # Comando eseguito dentro la tab: bind LOOM_TASK per la sessione, avvia CC sul PROMPT.
610
- # `--name '<TITLE>'` = canale AUTORITATIVO del terminal-title (Ptyxis -T è solo il
611
- # titolo iniziale, prima che CC parta): CC lo setta e nomina anche la sessione nel
612
- # picker. Override-abile via LOOM_DECK_INTAB_CMD per i test empirici (probe non-CC).
613
- # Ramo --no-task (T42): saltano i TRE elementi che legano la sessione alla task —
614
- # iniezione LOOM_TASK, prompt iniziale, --session-id pinnato. Resta claude nudo,
615
- # titolato e con il permission mode del progetto.
616
- # Su resume (T49) il prompt iniziale salta anche nel ramo task: riprendere una
617
- # conversazione significa continuarla, non iniettarle un nuovo messaggio. Il
618
- # fork (T28) ricade nello stesso ramo — è una ripresa, con id nuovo.
619
- # Il kind `none` (T56) atterra sulla stessa assenza per una ragione diversa: lì
620
- # la sessione è nuova e bound, ma l'utente ha chiesto di entrarci a mani nude.
621
- #
622
- # QUOTING DEL PROMPT — il testo entra in `bash -lc "$IN_TAB_CMD"`, cioè in una
623
- # riga che una shell PARSA. Finché i template erano scritti qui dentro bastava il
624
- # vincolo "nessun apice singolo nel catalogo"; da T117 il testo può arrivare da
625
- # `--prompt`, cioè digitato a mano dall'utente nel campo del detail — e lì un
626
- # apice singolo chiuderebbe la stringa, consegnando alla shell tutto ciò che
627
- # segue come comando. Si passa quindi per la stessa forma canonica di
628
- # `shellQuote` in spawn.ts: apice chiuso, apice letterale escapato, apice
629
- # riaperto (`'\''`). Dentro apici singoli nessun altro carattere resta speciale,
630
- # quindi non c'è nient'altro da enumerare — newline compresi.
631
- _shell_quote() { # <testo> → il testo come argomento singolo per una shell POSIX
632
- printf "'%s'" "${1//\'/\'\\\'\'}"
633
- }
634
- PROMPT_ARG=""
635
- [[ -n "$PROMPT" && -z "$RESUME_ID" ]] && PROMPT_ARG="$(_shell_quote "$PROMPT")"
636
- # Il SID_FLAG entra anche nel ramo --no-task, che altrimenti lo scarta insieme
637
- # agli altri elementi task-bound: nel fork di una sessione SPOT il sessionId
638
- # pinnato non serve a legare una task (non c'è) ma a rendere noto in anticipo
639
- # l'id del ramo, senza il quale il lineage non sarebbe registrabile.
640
- # PROFILE_ENV in testa a entrambi i rami: lo stato è una proprietà della SESSIONE
641
- # (esiste anche senza task), esattamente come la label del titolo — legarlo al
642
- # ramo task-bound ripeterebbe l'errore di layer già evitato per la titolazione.
643
- # MODEL_FLAG in entrambi i rami e prima dei flag di continuità: il modello è la
644
- # scelta di chi apre la sessione, non una proprietà della task né della ripresa
645
- # (T108) — stesso layer di MODE_FLAG, che infatti gli sta accanto.
646
- if [[ $NO_TASK -eq 1 ]]; then
647
- # T134/D9 — il PROMPT_ARG entra anche qui: dal 134 una sessione nuda può
648
- # nascere su un prompt letterale (drain di un file inbox, srotolamento di un
649
- # path). Resta vuoto su ogni altro percorso `--no-task`, perché senza
650
- # `--prompt` il ramo del catalogo non gira — `PROMPT_KIND` con `--no-task`
651
- # è ancora rifiutato a monte.
652
- _default_intab="${PROFILE_ENV}claude --name '${TITLE}' ${MODE_FLAG}${MODEL_FLAG}${SID_FLAG}${RES_FLAG}${FORK_FLAG}${PROMPT_ARG}"
653
- else
654
- _default_intab="${PROFILE_ENV}LOOM_TASK=${TASK} claude --name '${TITLE}' ${MODE_FLAG}${MODEL_FLAG}${SID_FLAG}${RES_FLAG}${FORK_FLAG}${PROMPT_ARG}"
655
- fi
656
- IN_TAB_CMD="${LOOM_DECK_INTAB_CMD:-$_default_intab}"
657
-
658
- # ANNUNCIO del comando in-tab, su stdout, prima dell'exec.
659
- #
660
- # È l'unico modo che ha un chiamante di sapere cosa gira davvero dentro la tab:
661
- # il comando si compone QUI — catalogo dei prompt, quoting, permission mode,
662
- # profilo di stato, titolo — e ricomporlo dall'altro lato sarebbe una seconda
663
- # scrittura delle stesse regole, divergente al primo flag aggiunto. Il deck lo
664
- # legge da questa riga e la mostra nella propria riga di stato.
665
- #
666
- # Deve stare DOPO l'ultima assegnazione di IN_TAB_CMD e PRIMA dell'exec (che
667
- # sostituisce il processo): annunciare un valore ancora modificabile
668
- # annuncerebbe qualcosa di diverso da quello eseguito.
669
- #
670
- # stdout e non stderr: qui non c'è nessuna anomalia da segnalare, è l'esito
671
- # normale del lavoro dello script. Le diagnostiche restano su stderr, e i
672
- # chiamanti che leggono l'ULTIMA riga di stdout (lo shim `ptyxis` dei test)
673
- # vedono comunque quello che vedevano prima.
674
- printf 'LOOM_DECK_INTAB %s\n' "$IN_TAB_CMD"
675
-
676
- # Verbo di apertura (T162): tab nella finestra attiva, o finestra nuova —
677
- # `--new-window` è l'unica differenza fra i due casi.
678
- if [[ $NEW_WINDOW -eq 1 ]]; then WIN_FLAG="--new-window"; else WIN_FLAG="--tab"; fi
679
-
680
- # -- <cmd>: comando inline nella tab. LOOM_TASK e chiave di stato viaggiano
681
- # dentro il comando → nessuna scrittura dconf, nessun profilo dedicato.
682
- exec ptyxis "$WIN_FLAG" -d "$WORKDIR" -T "${TITLE}" -- bash -lc "$IN_TAB_CMD"
5
+ # Apre una tab di terminale nella finestra attiva e vi avvia una sessione Claude
6
+ # Code: Ptyxis su Linux, Windows Terminal su Windows. Argomenti, regole e
7
+ # messaggi: `deck-run` senza argomenti stampa l'uso.
8
+ #
9
+ # T176 — la composizione è TypeScript (`src/tab-args.ts`, `src/tab-compose.ts`,
10
+ # `src/tab-launch.ts`), una sola per le due piattaforme. Questo file resta allo
11
+ # stesso path, eseguibile, come shim: compass lo risolve come
12
+ # `<cli>/../scripts/deck-run` e ne controlla l'eseguibilità prima di lanciarlo, e
13
+ # `test/deck-run.test.ts` lo esegue a scatola chiusa come gate di parità.
14
+ # Esegue il BUILD (`dist/`), come il bin del pacchetto.
15
+ exec node "$(dirname "${BASH_SOURCE[0]}")/../dist/deck-run.js" "$@"