@lamemind/loom-deck 0.75.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/dist/sessions.js CHANGED
@@ -2,6 +2,7 @@ import { readFileSync, readdirSync, statSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { basename, join } from 'node:path';
4
4
  import { sanitize } from './width.js';
5
+ import { isWithin, matchingDirNames } from './project-path.js';
5
6
  // CC codifica la project dir sostituendo ogni char non-alfanumerico con '-'
6
7
  // (verificato: `/home/lamemind/cc-host` → `-home-lamemind-cc-host`). È LOSSY
7
8
  // (un '-' nel path e un '/' collassano nello stesso char) → il forward è
@@ -308,51 +309,84 @@ export function compareSessions(a, b) {
308
309
  return b.lastReplyTs - a.lastReplyTs;
309
310
  return b.ts - a.ts;
310
311
  }
311
- // Discovery read-only delle sessioni del SOLO progetto corrente (D2 preflight
312
- // T27): legge la project dir calcolata dal forward-transform, filtra per cwd
313
- // (difesa contro le collisioni lossy del naming), ordina con `compareSessions`.
314
- export function discoverProjectSessions(projectRoot) {
315
- const dir = join(claudeProjectsRoot(), projectDirName(projectRoot));
316
- let files;
312
+ /**
313
+ * T175 — le cartelle di transcript del progetto.
314
+ *
315
+ * Su Linux una sola, il nome esatto, e senza leggere la cartella madre: il poll
316
+ * gira ogni 1,5 s, e un `readdir` di `~/.claude/projects/` costerebbe a ogni
317
+ * tick per trovare un nome già noto. Su win32 tutte quelle che coincidono a
318
+ * meno delle maiuscole (`matchingDirNames`): Claude Code non normalizza la
319
+ * lettera del drive, e lo stesso progetto ne ha due.
320
+ */
321
+ function transcriptDirs(projectRoot) {
322
+ const root = claudeProjectsRoot();
323
+ const name = projectDirName(projectRoot);
324
+ if (process.platform !== 'win32')
325
+ return [join(root, name)];
326
+ let entries;
317
327
  try {
318
- files = readdirSync(dir).filter((f) => f.endsWith('.jsonl'));
328
+ entries = readdirSync(root);
319
329
  }
320
330
  catch {
321
331
  return [];
322
332
  }
323
- const out = [];
333
+ return matchingDirNames(entries, name).map((e) => join(root, e));
334
+ }
335
+ // Discovery read-only delle sessioni del SOLO progetto corrente (D2 preflight
336
+ // T27): legge le project dir calcolate dal forward-transform, filtra per cwd
337
+ // (difesa contro le collisioni lossy del naming), ordina con `compareSessions`.
338
+ //
339
+ // D1 preflight T175 — restano fuori le conversazioni AVVIATE in una
340
+ // sottocartella, su ogni piattaforma: Claude Code ne scrive il transcript nella
341
+ // cartella che codifica la cwd di avvio, cioè un'altra. Il filtro accetta una
342
+ // cwd sotto la root perché non scarta niente di legittimo, non per raccoglierle.
343
+ export function discoverProjectSessions(projectRoot) {
344
+ // Keyed sul sessionId: su win32 due cartelle dello stesso progetto potrebbero
345
+ // portare lo stesso transcript, e due righe con un id solo romperebbero la
346
+ // selezione. Vince il più recente.
347
+ const out = new Map();
324
348
  const seen = new Set();
325
- for (const f of files) {
326
- const path = join(dir, f);
327
- seen.add(path);
328
- let mtime;
329
- let sizeBytes;
349
+ for (const dir of transcriptDirs(projectRoot)) {
350
+ let files;
330
351
  try {
331
- const st = statSync(path);
332
- mtime = st.mtimeMs;
333
- sizeBytes = st.size;
352
+ files = readdirSync(dir).filter((f) => f.endsWith('.jsonl'));
334
353
  }
335
354
  catch {
336
355
  continue;
337
356
  }
338
- const cached = cache.get(path);
339
- let session;
340
- if (cached && cached.mtime === mtime) {
341
- session = cached.session;
342
- }
343
- else {
344
- session = parseSessionFile(path, mtime, sizeBytes);
345
- cache.set(path, { mtime, session });
346
- }
347
- if (session && (session.cwd === projectRoot || session.cwd.startsWith(projectRoot + '/'))) {
348
- out.push(session);
357
+ for (const f of files) {
358
+ const path = join(dir, f);
359
+ seen.add(path);
360
+ let mtime;
361
+ let sizeBytes;
362
+ try {
363
+ const st = statSync(path);
364
+ mtime = st.mtimeMs;
365
+ sizeBytes = st.size;
366
+ }
367
+ catch {
368
+ continue;
369
+ }
370
+ const cached = cache.get(path);
371
+ let session;
372
+ if (cached && cached.mtime === mtime) {
373
+ session = cached.session;
374
+ }
375
+ else {
376
+ session = parseSessionFile(path, mtime, sizeBytes);
377
+ cache.set(path, { mtime, session });
378
+ }
379
+ if (!session || !isWithin(session.cwd, projectRoot))
380
+ continue;
381
+ const prev = out.get(session.sessionId);
382
+ if (!prev || session.ts > prev.ts)
383
+ out.set(session.sessionId, session);
349
384
  }
350
385
  }
351
386
  for (const key of cache.keys())
352
387
  if (!seen.has(key))
353
388
  cache.delete(key);
354
- out.sort(compareSessions);
355
- return out;
389
+ return [...out.values()].sort(compareSessions);
356
390
  }
357
391
  // Raggruppa per gitBranch (D2: group-by-branch nel progetto corrente). Ordine
358
392
  // dei gruppi = la prima sessione di ciascuno, con `compareSessions`; dentro il
@@ -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
@@ -8,9 +8,26 @@ import { dirname, join } from 'node:path';
8
8
  // è DATO PURO e non importa questo file: la dipendenza va in un verso solo.
9
9
  // Qui restano i soli effetti verso l'esterno — comporre un argv e lanciarlo.
10
10
  import { MODEL_DEFAULT, PROJECT_STATUS_MODEL } from './spawn-catalog.js';
11
+ import { isAvailable } from './platform.js';
12
+ import { claudeLauncher } from './claude-cmd.js';
13
+ import { claudeEnv, gitBash } from './git-bash.js';
14
+ import { windowsTabEnv, WT_CMD, wtNewTabArgs, wtProfile } from './windows-terminal.js';
11
15
  // scripts/deck-run è un sibling della dir del bundle: src/ (dev, tsx) e dist/
12
16
  // (build, node) stanno entrambi sotto la package root → risalita di un livello.
13
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
+ }
14
31
  /**
15
32
  * Freno agli effetti VERSO L'ESTERNO (tab Ptyxis, sessioni Claude, git commit).
16
33
  *
@@ -27,9 +44,14 @@ export const DECK_RUN = join(dirname(fileURLToPath(import.meta.url)), '..', 'scr
27
44
  * test misura resta identico.
28
45
  */
29
46
  export const NO_SPAWN = process.env.LOOM_DECK_NO_SPAWN === '1';
47
+ // T175 — `windowsHide` su OGNI figlio, qui e non a ogni chiamante: su Windows
48
+ // un processo console lanciato senza apre una finestra (un `claude -p`, un
49
+ // `git`), e ogni sede di spawn che la dimenticasse ne farebbe lampeggiare una a
50
+ // ogni giro. Su Linux l'opzione non ha effetto. Un chiamante può ancora
51
+ // sovrascriverla, perché `opts` viene dopo.
30
52
  export function spawnOut(cmd, args, opts) {
31
53
  if (!NO_SPAWN)
32
- return spawn(cmd, args, opts);
54
+ return spawn(cmd, args, { windowsHide: true, ...opts });
33
55
  // Figlio inerte: emette nulla, quindi i `.on('error'|'close')` dei chiamanti
34
56
  // restano appesi senza mai scattare — che è esattamente "non è successo niente".
35
57
  const fake = new EventEmitter();
@@ -70,13 +92,15 @@ export function shellCommand(cmd, args) {
70
92
  // comando in-tab (vedi `onInTabCommand`). Il figlio resta detached — la pipe
71
93
  // serve a leggere una riga, non a possedere il processo.
72
94
  function launchDeckRun(args, cwd) {
73
- const child = spawnOut(DECK_RUN, args, {
95
+ const run = deckRunLauncher();
96
+ const argv = [...run.prefix, ...args];
97
+ const child = spawnOut(run.cmd, argv, {
74
98
  cwd,
75
99
  detached: true,
76
100
  stdio: ['ignore', 'pipe', 'ignore'],
77
101
  });
78
102
  child.unref();
79
- return { child, cmd: shellCommand(DECK_RUN, args) };
103
+ return { child, cmd: shellCommand(run.cmd, argv) };
80
104
  }
81
105
  /** Prefisso della riga con cui `deck-run` annuncia il comando in-tab. */
82
106
  export const INTAB_MARKER = 'LOOM_DECK_INTAB ';
@@ -144,10 +168,9 @@ export function onInTabCommand(child, cb) {
144
168
  // acceleratori della lista passavano `--prompt-kind` e lasciavano risolvere il
145
169
  // testo a `deck-run`, che consultava il proprio catalogo; il detail passava già
146
170
  // il letterale. Con un override di progetto sui prompt la risoluzione deve
147
- // stare da una parte sola, e quella parte è il deck — che è l'unico dei due a
148
- // leggere `.claude/loom-works.json` per questo (`jq` è dipendenza opzionale di
149
- // `deck-run`). Un percorso solo, quindi, e ciò che parte è verificabile
150
- // 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.
151
174
  //
152
175
  // Due conseguenze dichiarate: `--prompt-kind` resta in `deck-run` per chi lo
153
176
  // invoca a mano, e `LOOM_DECK_ENTER_PROMPT` non ha più effetto sugli spawn dal
@@ -338,7 +361,36 @@ export function runLaunch(entry, cwd) {
338
361
  export function terminalArgs(cwd, title) {
339
362
  return title ? ['--tab', '-T', title, '-d', cwd] : ['--tab', '-d', cwd];
340
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
+ }
341
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
+ }
342
394
  const child = spawnOut('ptyxis', terminalArgs(cwd, title), {
343
395
  cwd,
344
396
  detached: true,
@@ -347,8 +399,11 @@ export function spawnTerminal(cwd, title) {
347
399
  child.unref();
348
400
  return child;
349
401
  }
350
- // Comando claude (override per ambienti dove non è su PATH; loom-deck → NPM).
351
- export const CLAUDE_CMD = process.env.LOOM_DECK_CLAUDE_CMD ?? 'claude';
402
+ // Il NOME di claude nelle note della riga di stato: l'override se c'è,
403
+ // `claude` altrimenti. Non è ciò che si esegue — quello lo risolve
404
+ // `claudeLauncher` (`claude-cmd.ts`), che su Windows può arrivare a
405
+ // `node …cli.js` da uno shim npm: nelle note resta il nome che l'utente conosce.
406
+ export const CLAUDE_CMD = process.env.LOOM_DECK_CLAUDE_CMD || 'claude';
352
407
  // T30: invocazione di una skill del plugin da CC HEADLESS (`-p`), con
353
408
  // `--session-id` pinnato. È la TERZA strada per eseguire uno script del plugin
354
409
  // da un consumer che non ha `CLAUDE_PLUGIN_ROOT`: il path version-pinned della
@@ -365,7 +420,12 @@ export const CLAUDE_CMD = process.env.LOOM_DECK_CLAUDE_CMD ?? 'claude';
365
420
  // ha funzionato" — che su un'operazione distruttiva non basta a sapere se
366
421
  // qualcosa è stato rimosso.
367
422
  function spawnSkill(prompt, cwd, sessionId, onResult, extraArgs = []) {
368
- const child = spawnOut(CLAUDE_CMD, [
423
+ // T175 — il lanciatore risolto (shim npm compreso, mai una shell) e, su
424
+ // Windows, l'env col bash di Git: senza, un deck avviato da Git Bash passa al
425
+ // figlio il `PATH` che manda gli hook del plugin in PowerShell.
426
+ const claude = claudeLauncher();
427
+ const child = spawnOut(claude.cmd, [
428
+ ...claude.prefix,
369
429
  '-p',
370
430
  '--output-format',
371
431
  'stream-json',
@@ -374,7 +434,7 @@ function spawnSkill(prompt, cwd, sessionId, onResult, extraArgs = []) {
374
434
  sessionId,
375
435
  ...extraArgs,
376
436
  prompt,
377
- ], { cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
437
+ ], { cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe'], env: claudeEnv() });
378
438
  let buf = '';
379
439
  let isError = null;
380
440
  let detail = '';
@@ -491,7 +551,13 @@ export const NOTIFY_CMD = process.env.LOOM_DECK_NOTIFY_CMD ?? 'notify-send';
491
551
  // Agganciato, la degradazione è MUTA (D6): un binario assente è una proprietà
492
552
  // stabile della macchina, e dirlo a ogni generazione sarebbe lo stesso messaggio
493
553
  // a ogni giro su una funzione che non è il deliverable.
554
+ //
555
+ // T175 — dove le notifiche non ci sono, nessuno spawn e nessun avviso: non
556
+ // hanno un tasto né un pane, e l'esito del project status è già nella riga di
557
+ // stato. Il `null` dice al chiamante che niente è partito.
494
558
  export function notifyDone(title, body) {
559
+ if (!isAvailable('notify'))
560
+ return null;
495
561
  const child = spawnOut(NOTIFY_CMD, ['--app-name=loom-deck', title, body], {
496
562
  detached: true,
497
563
  stdio: 'ignore',
@@ -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
+ }