@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/README.md +24 -2
- package/dist/actions.js +67 -5
- package/dist/archivable.js +1 -1
- package/dist/claude-cmd.js +101 -0
- package/dist/cli.js +18 -7
- package/dist/commit-times.js +5 -2
- package/dist/config.js +51 -6
- package/dist/deck-model.js +14 -9
- package/dist/deck-run.js +7 -0
- package/dist/doc-tree.js +2 -2
- package/dist/frame.js +7 -0
- package/dist/git-bash.js +78 -0
- package/dist/gitlink.js +1 -0
- package/dist/hooks.js +21 -7
- package/dist/inbox.js +2 -2
- package/dist/input.js +7 -0
- package/dist/live-sessions.js +4 -1
- package/dist/overlays/gitlink.js +12 -2
- package/dist/overlays/wrap.js +10 -1
- package/dist/pane-views.js +12 -2
- package/dist/platform.js +93 -0
- package/dist/project-path.js +102 -0
- package/dist/purge.js +1 -0
- package/dist/sane-note.js +18 -24
- package/dist/sessions.js +63 -29
- package/dist/spawn-catalog.js +4 -4
- package/dist/spawn-config.js +4 -4
- package/dist/spawn.js +77 -11
- package/dist/tab-args.js +141 -0
- package/dist/tab-compose.js +233 -0
- package/dist/tab-launch.js +169 -0
- package/dist/tab-wrapper.js +44 -0
- package/dist/tasks.js +9 -22
- package/dist/ui/doc-pane.js +6 -2
- package/dist/ui/inbox-pane.js +7 -2
- package/dist/windows-terminal.js +153 -0
- package/dist/wrap-scan.js +2 -2
- package/package.json +3 -2
- package/scripts/deck-run +11 -678
- package/scripts/prompt-catalog +6 -11
package/README.md
CHANGED
|
@@ -387,11 +387,33 @@ npx @lamemind/loom-deck
|
|
|
387
387
|
## Requisiti runtime
|
|
388
388
|
|
|
389
389
|
- **Node.js ≥ 18** (dichiarato in `engines`).
|
|
390
|
-
- **[Ptyxis](https://gitlab.gnome.org/chergert/ptyxis)** (terminale GNOME) — **dipendenza di runtime, non risolvibile da npm**. Lo spawn di una tab (`scripts/deck-run`) invoca il binario `ptyxis`: l'**install riesce anche senza**, ma al momento dello spawn il comando
|
|
390
|
+
- **[Ptyxis](https://gitlab.gnome.org/chergert/ptyxis)** (terminale GNOME), su Linux — **dipendenza di runtime, non risolvibile da npm**. Lo spawn di una tab (`scripts/deck-run`) invoca il binario `ptyxis`: l'**install riesce anche senza**, ma al momento dello spawn il comando
|
|
391
391
|
fallisce (gestito: handler `error` → la TUI resta viva, mostra la nota). Senza una GUI
|
|
392
392
|
GNOME con Ptyxis installato, il deck naviga i task ma **non apre sessioni**.
|
|
393
393
|
- **[Claude Code](https://claude.com/claude-code)** nel `PATH` — la tab spawnata avvia `claude`.
|
|
394
394
|
|
|
395
|
+
### Su Windows
|
|
396
|
+
|
|
397
|
+
Le tab si aprono in **Windows Terminal** (`wt.exe`, l'alias d'esecuzione che l'installazione dello Store mette nel `PATH`), nella finestra usata per ultima: `wt.exe -w 0 new-tab -p loom-deck -d <root> --title <titolo> --suppressApplicationTitle -- <node> <dist/tab-wrapper.js>`. L'argv di `claude` — nome, flag, prompt — non sta su quella riga: viaggia come JSON nella variabile `LOOM_DECK_TAB_ARGV`, che la tab eredita dal processo che lancia `wt.exe`, e il wrapper lo passa a `claude` senza shell. Il prompt non passa quindi né dal parser di `wt`, che legge `;` come separatore di sottocomandi in qualunque argomento, né da cmd o PowerShell.
|
|
398
|
+
|
|
399
|
+
Le tab usano il profilo di Windows Terminal **`loom-deck`**, uno solo per tutti i progetti. Il deck non lo crea e non scrive `settings.json`: lo si aggiunge a mano in `%LOCALAPPDATA%\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json`, sotto `profiles.list`, nascosto dal menu. Porta solo ciò che i flag di `wt` non sanno dire — titolo, cartella e comando li passa il deck, e battono quelli del profilo:
|
|
400
|
+
|
|
401
|
+
```jsonc
|
|
402
|
+
{
|
|
403
|
+
"name": "loom-deck",
|
|
404
|
+
"guid": "{<un GUID nuovo>}",
|
|
405
|
+
"hidden": true,
|
|
406
|
+
"commandline": "%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe -NoLogo",
|
|
407
|
+
"closeOnExit": "graceful"
|
|
408
|
+
}
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
`commandline` è la shell del tasto `t`; nelle conversazioni il comando del deck prende il suo posto. `closeOnExit` decide cosa resta della tab quando `claude` esce (il wrapper esce col suo codice). Senza il profilo la tab si apre comunque, col profilo di default. Il nome si cambia con `LOOM_DECK_WT_PROFILE` (vuota = nessun `-p`).
|
|
412
|
+
|
|
413
|
+
La tab eredita tutto l'env del deck, tranne i marker che una sessione Claude Code inietta nei figli (`CLAUDECODE`, `CLAUDE_CODE_CHILD_SESSION`, …): con quelli una conversazione aperta da un deck lanciato dentro Claude Code non salverebbe il transcript. Se manca, il deck aggiunge `CLAUDE_CODE_GIT_BASH_PATH`: col `PATH` di Git Bash `claude` eseguirebbe gli hook del plugin in PowerShell, e la task non arriverebbe in contesto.
|
|
414
|
+
|
|
415
|
+
Su Windows il resume di una conversazione e il restore (`^T`) restano spenti con un avviso finché il deck non sa quali conversazioni sono vive: senza quella informazione un resume può riaprire una conversazione già aperta in un'altra tab. Il fork resta acceso, perché apre un transcript nuovo.
|
|
416
|
+
|
|
395
417
|
## Uso (spike ①)
|
|
396
418
|
|
|
397
419
|
```bash
|
|
@@ -419,7 +441,7 @@ npm test # node:test sul core vista (src/view.ts), senza Ink né termina
|
|
|
419
441
|
|
|
420
442
|
Il core di filtri e ordinamenti (`src/view.ts`) è **puro**: nessun import da Ink o React, nessun I/O. È il motivo per cui è testabile con `node:test` su array fixture, mentre la TUI resta un guscio sottile che lo consuma.
|
|
421
443
|
|
|
422
|
-
Il deck cerca `tasks.md` in `$PWD
|
|
444
|
+
Il deck cerca `tasks.md` in `$PWD/<docs-root>/tasks.md`. La docs-root si risolve in quest'ordine: la variabile `LOOM_DECK_DOCS_ROOT`, se impostata; il campo `docsRoot` di `$PWD/.claude/loom-works.json`; `docs`. Un progetto con docs-root non-standard la dichiara nel file, e il deck la trova anche avviato a mano.
|
|
423
445
|
|
|
424
446
|
La lista si **auto-aggiorna** quando `tasks.md` cambia sotto (poll su `mtime`, ~1.5s):
|
|
425
447
|
crei/checkpoint una task da un'altra sessione → il deck la riflette senza riavvio.
|
package/dist/actions.js
CHANGED
|
@@ -17,13 +17,15 @@
|
|
|
17
17
|
// migliorano di passaggio.
|
|
18
18
|
import { useRef } from 'react';
|
|
19
19
|
import { randomUUID } from 'node:crypto';
|
|
20
|
+
import { existsSync } from 'node:fs';
|
|
21
|
+
import { conversationCwd } from './project-path.js';
|
|
20
22
|
import { taskSlug } from './tasks.js';
|
|
21
23
|
import { appendNote, appendPin, appendPriority, appendSessionRecord, appendTaskBinding, } from './task-index.js';
|
|
22
24
|
import { neighborId } from './session-list.js';
|
|
23
25
|
import { cut, cutMiddle } from './width.js';
|
|
24
26
|
import { dropSessionFiles, sessionArtifacts } from './session-drop.js';
|
|
25
27
|
import { saveView, viewFilePath } from './view-store.js';
|
|
26
|
-
import { onInTabCommand, runLaunch, spawnClaudeEmpty, spawnBare, spawnDeck, spawnDeckFork, spawnDeckResume, spawnTerminal, DECK_RUN, } from './spawn.js';
|
|
28
|
+
import { onInTabCommand, runLaunch, spawnClaudeEmpty, spawnBare, spawnDeck, spawnDeckFork, spawnDeckResume, spawnTerminal, terminalName, DECK_RUN, } from './spawn.js';
|
|
27
29
|
import { MODEL_DEFAULT } from './spawn-catalog.js';
|
|
28
30
|
import { modelAlias } from './glyphs.js';
|
|
29
31
|
import { resolveSpawnId } from './spawn-config.js';
|
|
@@ -32,6 +34,7 @@ import { inboxPrompt, inboxWords } from './inbox.js';
|
|
|
32
34
|
import { docTargetKind, docWords } from './doc-tree.js';
|
|
33
35
|
import { wrapWords } from './wrap-scan.js';
|
|
34
36
|
import { enterSession, focusSession } from './focus-session.js';
|
|
37
|
+
import { refuse } from './platform.js';
|
|
35
38
|
/**
|
|
36
39
|
* T181 — l'attesa massima di un passo del restore prima di passare al
|
|
37
40
|
* successivo. Un `deck-run` esce appena `ptyxis --tab` ha consegnato la tab
|
|
@@ -102,7 +105,7 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
|
|
|
102
105
|
}
|
|
103
106
|
/** I buchi di un'azione su task. Lo slug viene dal NOME del task file, non
|
|
104
107
|
* dalla descrizione di `tasks.md`: il nome è già dentro l'alfabeto di
|
|
105
|
-
* `
|
|
108
|
+
* `saneNote` per costruzione — minuscolo, separato da trattini, senza
|
|
106
109
|
* punteggiatura — mentre la descrizione porta apostrofi e `/` che la riduzione
|
|
107
110
|
* toglie senza sostituto, saldando le parole. Task file assente → slug vuoto,
|
|
108
111
|
* e il titolo si spegne invece di ridursi al solo prefisso. */
|
|
@@ -156,6 +159,10 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
|
|
|
156
159
|
// della lista (^K/^P/^R), che passano di qui con `spawnNote` sul proprio
|
|
157
160
|
// default '' (P1 preflight) — nascono quindi titolati anche loro.
|
|
158
161
|
function spawnForTask(id, modelKind, spawnNote = '', prompt = '', priority = false) {
|
|
162
|
+
// T175 — prima del sidecar: un binding scritto per una tab che non si apre
|
|
163
|
+
// sarebbe una conversazione figlia della task che non esiste.
|
|
164
|
+
if (refuse('spawn', '⏎', setNote))
|
|
165
|
+
return;
|
|
159
166
|
const sid = randomUUID();
|
|
160
167
|
const note = spawnNote;
|
|
161
168
|
appendTaskBinding(cwd, sid, id);
|
|
@@ -185,6 +192,8 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
|
|
|
185
192
|
// async: senza handler diventa uncaughtException e ucciderebbe il deck, che
|
|
186
193
|
// invece deve restare vivo.
|
|
187
194
|
function spawnTaskSession(kind, keyLabel) {
|
|
195
|
+
if (refuse('spawn', keyLabel, setNote))
|
|
196
|
+
return;
|
|
188
197
|
const task = selectedTaskOr(keyLabel, 'spawnare');
|
|
189
198
|
if (!task)
|
|
190
199
|
return;
|
|
@@ -213,9 +222,22 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
|
|
|
213
222
|
// ricerca e il restore il modello ereditato dalla sessione senza superficie
|
|
214
223
|
// di scelta (D1 preflight). I chiamanti restano la stessa azione perché è
|
|
215
224
|
// lo spawn a essere identico, non la sorgente del valore.
|
|
225
|
+
/**
|
|
226
|
+
* T176 — la cartella della tab di una ripresa o di un fork: la root su Linux,
|
|
227
|
+
* la cwd registrata dalla conversazione su Windows (`conversationCwd`, P7
|
|
228
|
+
* preflight). Una cartella che non esiste più ricade sulla root: lo spawn
|
|
229
|
+
* fallirebbe prima ancora di aprire la tab.
|
|
230
|
+
*/
|
|
231
|
+
function tabCwdOf(sessionId) {
|
|
232
|
+
const s = model.sessions.find((x) => x.sessionId === sessionId);
|
|
233
|
+
if (!s)
|
|
234
|
+
return cwd;
|
|
235
|
+
const dir = conversationCwd(s.cwd, s.path, cwd);
|
|
236
|
+
return dir === cwd || existsSync(dir) ? dir : cwd;
|
|
237
|
+
}
|
|
216
238
|
function resumeSession(sessionId, modelKind) {
|
|
217
239
|
const bound = model.bindings.get(sessionId) ?? null;
|
|
218
|
-
const spawned = spawnDeckResume(bound,
|
|
240
|
+
const spawned = spawnDeckResume(bound, tabCwdOf(sessionId), sessionId, modelKind, model.sessionNotes.get(sessionId));
|
|
219
241
|
spawned.child.on('error', () => setNote(`⚠ resume fallito (${DECK_RUN})`));
|
|
220
242
|
noteSpawn(spawned);
|
|
221
243
|
return spawned;
|
|
@@ -233,6 +255,14 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
|
|
|
233
255
|
* tab); senza nome, il prefisso del `sessionId` come nelle altre note.
|
|
234
256
|
*/
|
|
235
257
|
function openSession(sessionId, modelKind) {
|
|
258
|
+
if (refuse('spawn', '⏎', setNote, 'resume'))
|
|
259
|
+
return;
|
|
260
|
+
// T176 — il resume vuole sapere se la conversazione è viva, non solo poter
|
|
261
|
+
// aprire una tab (D4 preflight): senza liveness un `⏎` su una conversazione
|
|
262
|
+
// aperta in un'altra tab avvierebbe un secondo `claude` sullo stesso
|
|
263
|
+
// transcript. Spento finché la piattaforma non ha le sessioni vive.
|
|
264
|
+
if (refuse('liveSessions', '⏎', setNote, 'resume'))
|
|
265
|
+
return;
|
|
236
266
|
enterSession(sessionId, {
|
|
237
267
|
isLive: (id) => model.live.has(id),
|
|
238
268
|
nameOf: (id) => model.live.get(id)?.name || id.slice(0, 8),
|
|
@@ -266,6 +296,14 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
|
|
|
266
296
|
* chiudendo le tab.
|
|
267
297
|
*/
|
|
268
298
|
function restoreSessions() {
|
|
299
|
+
// T176 — la guardia delle sessioni vive PRIMA di quella dello spawn (P8
|
|
300
|
+
// preflight): l'insieme da riaprire è l'ultimo insieme vivo, e senza
|
|
301
|
+
// liveness sarebbe vuoto — «nessuna conversazione da riaprire» direbbe uno
|
|
302
|
+
// stato dove manca una funzione.
|
|
303
|
+
if (refuse('liveSessions', '^T', setNote, 'restore delle conversazioni'))
|
|
304
|
+
return;
|
|
305
|
+
if (refuse('spawn', '^T', setNote, 'restore delle conversazioni'))
|
|
306
|
+
return;
|
|
269
307
|
const list = model.restorable;
|
|
270
308
|
if (list.length === 0) {
|
|
271
309
|
setNote(model.restoring ? '^T → restore già in corso' : '^T → nessuna conversazione da riaprire');
|
|
@@ -368,6 +406,8 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
|
|
|
368
406
|
// ha per oggetto una conversazione, e senza focus lì non ce n'è una
|
|
369
407
|
// selezionata su cui agire.
|
|
370
408
|
function forkSession() {
|
|
409
|
+
if (refuse('spawn', 'f', setNote, 'fork'))
|
|
410
|
+
return;
|
|
371
411
|
if (model.focus !== 'sessions') {
|
|
372
412
|
setNote('f → fork: seleziona una sessione (→ per il pane)');
|
|
373
413
|
return;
|
|
@@ -393,7 +433,7 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
|
|
|
393
433
|
// T148 — il fork è l'altra continuità che condivide la stessa riga
|
|
394
434
|
// selezionata: porta via lo stesso `model.resumeModel` del resume, non un
|
|
395
435
|
// default fisso (P2 preflight).
|
|
396
|
-
const spawned = spawnDeckFork(bound,
|
|
436
|
+
const spawned = spawnDeckFork(bound, tabCwdOf(s.sessionId), s.sessionId, newId, model.resumeModel);
|
|
397
437
|
spawned.child.on('error', () => setNote(`⚠ fork fallito (${DECK_RUN})`));
|
|
398
438
|
noteSpawn(spawned);
|
|
399
439
|
}
|
|
@@ -463,6 +503,12 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
|
|
|
463
503
|
setNote('CANC → eliminare: seleziona una conversazione (→ per il pane)');
|
|
464
504
|
return null;
|
|
465
505
|
}
|
|
506
|
+
// T175 — il rifiuto della VIVA qui sotto legge `model.live`, e dove la
|
|
507
|
+
// liveness manca la mappa è sempre vuota: il rifiuto non scatterebbe mai, e
|
|
508
|
+
// `CANC` toglierebbe il transcript a una conversazione aperta. Spento finché
|
|
509
|
+
// la liveness non c'è, non degradato a «nessuna è viva».
|
|
510
|
+
if (refuse('liveSessions', 'CANC', setNote, 'eliminazione delle conversazioni'))
|
|
511
|
+
return null;
|
|
466
512
|
const sid = model.selSessionId;
|
|
467
513
|
if (!sid) {
|
|
468
514
|
setNote('CANC → nessuna conversazione da eliminare');
|
|
@@ -488,6 +534,10 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
|
|
|
488
534
|
* appena eliminata apposta.
|
|
489
535
|
*/
|
|
490
536
|
function dropSession(draft) {
|
|
537
|
+
// La stessa guardia della bozza: chi arrivasse qui senza passare da lei non
|
|
538
|
+
// deve trovare un `rmSync` sguarnito.
|
|
539
|
+
if (refuse('liveSessions', 'CANC', setNote, 'eliminazione delle conversazioni'))
|
|
540
|
+
return;
|
|
491
541
|
const short = draft.sessionId.slice(0, 8);
|
|
492
542
|
const landing = neighborId(model.sessionRows, draft.sessionId);
|
|
493
543
|
try {
|
|
@@ -506,9 +556,11 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
|
|
|
506
556
|
}
|
|
507
557
|
/** `t` — terminale a project root, con un titolo che il matcher di compass riconosce. */
|
|
508
558
|
function openTerminal() {
|
|
559
|
+
if (refuse('spawn', 't', setNote, 'terminale'))
|
|
560
|
+
return;
|
|
509
561
|
const title = model.identity ? `🖥️ ${model.identity.name} [term]` : null;
|
|
510
562
|
const child = spawnTerminal(cwd, title);
|
|
511
|
-
child.on('error', () => setNote(
|
|
563
|
+
child.on('error', () => setNote(`⚠ t → ${terminalName()} non lanciabile`));
|
|
512
564
|
setNote(`t → terminale su ${model.projectName}`);
|
|
513
565
|
}
|
|
514
566
|
/**
|
|
@@ -532,6 +584,8 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
|
|
|
532
584
|
* due superfici mostrano nasce da un valore solo.
|
|
533
585
|
*/
|
|
534
586
|
function drainInbox(file, prompt) {
|
|
587
|
+
if (refuse('spawn', '⏎', setNote))
|
|
588
|
+
return;
|
|
535
589
|
const sid = randomUUID();
|
|
536
590
|
const d = spawnDefaults('drain', { file: inboxWords(file) });
|
|
537
591
|
const title = d.title ?? '';
|
|
@@ -608,6 +662,8 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
|
|
|
608
662
|
function rebalanceDoc(row, prompt) {
|
|
609
663
|
if (!prompt)
|
|
610
664
|
return;
|
|
665
|
+
if (refuse('spawn', '⏎', setNote))
|
|
666
|
+
return;
|
|
611
667
|
// Il bersaglio inbox prende la terna della riga `drain`, non quella di
|
|
612
668
|
// `rebalance`: il prompt da solo non basterebbe — modello e titolo della tab
|
|
613
669
|
// verrebbero comunque dalla riga sbagliata, e la conversazione si
|
|
@@ -642,6 +698,8 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
|
|
|
642
698
|
* non progettare niente.
|
|
643
699
|
*/
|
|
644
700
|
function unwrapPath(path) {
|
|
701
|
+
if (refuse('spawn', '⏎', setNote))
|
|
702
|
+
return;
|
|
645
703
|
const sid = randomUUID();
|
|
646
704
|
const d = spawnDefaults('unwrap', { path: wrapWords(path) });
|
|
647
705
|
const title = d.title ?? '';
|
|
@@ -655,6 +713,8 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
|
|
|
655
713
|
* modello è quello del selettore proprio della nuda (`model.bareModel`),
|
|
656
714
|
* passato SEMPRE nell'argv anche sul default. */
|
|
657
715
|
function openClaude() {
|
|
716
|
+
if (refuse('spawn', 'c', setNote, 'sessione claude'))
|
|
717
|
+
return;
|
|
658
718
|
const spawned = spawnClaudeEmpty(cwd, model.bareModel);
|
|
659
719
|
spawned.child.on('error', () => setNote(`⚠ c → spawn claude fallito (${DECK_RUN})`));
|
|
660
720
|
noteSpawn(spawned);
|
|
@@ -672,6 +732,8 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
|
|
|
672
732
|
}
|
|
673
733
|
/** `1`-`9` — le voci launch del progetto, per indice base-1. */
|
|
674
734
|
function runLaunchAt(input) {
|
|
735
|
+
if (refuse('launch', input, setNote))
|
|
736
|
+
return;
|
|
675
737
|
const entry = model.launch[Number(input) - 1];
|
|
676
738
|
if (!entry) {
|
|
677
739
|
setNote(`${input} → nessuna voce launch (${model.launch.length} configurate)`);
|
package/dist/archivable.js
CHANGED
|
@@ -102,7 +102,7 @@ async function resolveDoneDate(id, content, taskFile, projectRoot) {
|
|
|
102
102
|
* un throw: un contatore informativo non può rompere il deck. */
|
|
103
103
|
async function gitOut(args, cwd) {
|
|
104
104
|
try {
|
|
105
|
-
const { stdout } = await execFileAsync('git', args, { cwd });
|
|
105
|
+
const { stdout } = await execFileAsync('git', args, { cwd, windowsHide: true });
|
|
106
106
|
return stdout.trim() || null;
|
|
107
107
|
}
|
|
108
108
|
catch {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// T175 — COME il deck lancia `claude`, senza shell, su ogni installazione.
|
|
2
|
+
//
|
|
3
|
+
// Su Linux `claude` è un eseguibile nel `PATH` e basta il nome. Su Windows le
|
|
4
|
+
// installazioni sono due e si comportano in modo opposto davanti a `spawn`:
|
|
5
|
+
//
|
|
6
|
+
// - l'installer nativo dà `claude.exe`, e `spawn('claude')` lo trova — libuv
|
|
7
|
+
// prova da sé `.com` ed `.exe`;
|
|
8
|
+
// - l'installazione npm dà uno shim `claude.cmd`, e `spawn` non lo trova
|
|
9
|
+
// (`ENOENT` col nome nudo) né lo lancia (`EINVAL` col nome intero: Node ≥
|
|
10
|
+
// 18.20.2 rifiuta un `.cmd` senza shell).
|
|
11
|
+
//
|
|
12
|
+
// La shell non è un rimedio: con `shell: true` il prompt passerebbe da cmd.exe,
|
|
13
|
+
// che interpreta `& | % ^`, e un prompt è testo arbitrario. Lo shim invece è un
|
|
14
|
+
// file generato da cmd-shim con una forma fissa, e dice in chiaro cosa lancia:
|
|
15
|
+
// il deck lo legge e lancia il bersaglio da sé (P5 preflight).
|
|
16
|
+
//
|
|
17
|
+
// Modulo PURO nel cuore (`resolveClaude` riceve il filesystem come funzioni),
|
|
18
|
+
// con un involucro che legge quello vero.
|
|
19
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
20
|
+
import { win32 } from 'node:path';
|
|
21
|
+
/**
|
|
22
|
+
* Il bersaglio di uno shim di cmd-shim, o `null` se il testo non ne ha uno che
|
|
23
|
+
* il deck sappia lanciare.
|
|
24
|
+
*
|
|
25
|
+
* Il bersaglio è l'ultimo `"%dp0%\<path>"` seguito da `%*` (le due forme dello
|
|
26
|
+
* shim: `%dp0%` dalla v4 di cmd-shim, `%~dp0` prima). Un `.js` parte col node
|
|
27
|
+
* del deck — è il node che lo shim userebbe in assenza di un `node.exe`
|
|
28
|
+
* accanto, cioè quello del `PATH` —, un `.exe` parte da sé. Ogni altro bersaglio
|
|
29
|
+
* (uno script sh, per esempio) vorrebbe il suo interprete, e il deck non lo
|
|
30
|
+
* indovina.
|
|
31
|
+
*/
|
|
32
|
+
export function parseCmdShim(text, shimPath, nodePath) {
|
|
33
|
+
const re = /"%~?dp0%?\\?([^"]+)"\s+%\*/g;
|
|
34
|
+
let rel = null;
|
|
35
|
+
for (let m = re.exec(text); m; m = re.exec(text))
|
|
36
|
+
rel = m[1];
|
|
37
|
+
if (!rel)
|
|
38
|
+
return null;
|
|
39
|
+
const target = win32.join(win32.dirname(shimPath), rel);
|
|
40
|
+
if (/\.(c|m)?js$/i.test(target))
|
|
41
|
+
return { cmd: nodePath, prefix: [target] };
|
|
42
|
+
if (/\.(exe|com)$/i.test(target))
|
|
43
|
+
return { cmd: target, prefix: [] };
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* La cascata (P5 preflight): `LOOM_DECK_CLAUDE_CMD` → `claude` così com'è →
|
|
48
|
+
* shim `claude.cmd` nel `PATH`.
|
|
49
|
+
*
|
|
50
|
+
* Su win32 «così com'è» vuol dire che un `claude.exe` o `claude.com` sta nel
|
|
51
|
+
* `PATH`: è lo stesso giro che farebbe libuv, fatto prima per sapere se serve
|
|
52
|
+
* il gradino dopo. Un override che nomina un `.cmd` passa dallo stesso parser,
|
|
53
|
+
* o cadrebbe nell'`EINVAL`. Niente trovato → `claude` nudo: lo spawn fallirà,
|
|
54
|
+
* e l'errore dirà «`claude` non lanciabile», che è il fatto.
|
|
55
|
+
*/
|
|
56
|
+
export function resolveClaude(env, platform, nodePath, fs) {
|
|
57
|
+
const fromShim = (shim) => {
|
|
58
|
+
try {
|
|
59
|
+
return parseCmdShim(fs.read(shim), shim, nodePath);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const override = env.LOOM_DECK_CLAUDE_CMD;
|
|
66
|
+
if (override) {
|
|
67
|
+
if (platform === 'win32' && /\.(cmd|bat)$/i.test(override)) {
|
|
68
|
+
return fromShim(override) ?? { cmd: override, prefix: [] };
|
|
69
|
+
}
|
|
70
|
+
return { cmd: override, prefix: [] };
|
|
71
|
+
}
|
|
72
|
+
const bare = { cmd: 'claude', prefix: [] };
|
|
73
|
+
if (platform !== 'win32')
|
|
74
|
+
return bare;
|
|
75
|
+
const dirs = (env.PATH ?? env.Path ?? '').split(win32.delimiter).filter(Boolean);
|
|
76
|
+
for (const dir of dirs) {
|
|
77
|
+
if (fs.exists(win32.join(dir, 'claude.com')) || fs.exists(win32.join(dir, 'claude.exe')))
|
|
78
|
+
return bare;
|
|
79
|
+
}
|
|
80
|
+
for (const dir of dirs) {
|
|
81
|
+
const shim = win32.join(dir, 'claude.cmd');
|
|
82
|
+
if (!fs.exists(shim))
|
|
83
|
+
continue;
|
|
84
|
+
const launcher = fromShim(shim);
|
|
85
|
+
if (launcher)
|
|
86
|
+
return launcher;
|
|
87
|
+
}
|
|
88
|
+
return bare;
|
|
89
|
+
}
|
|
90
|
+
let cached;
|
|
91
|
+
/** Il `claude` di questa macchina, risolto alla prima richiesta e tenuto per la
|
|
92
|
+
* vita del deck. */
|
|
93
|
+
export function claudeLauncher() {
|
|
94
|
+
if (!cached) {
|
|
95
|
+
cached = resolveClaude(process.env, process.platform, process.execPath, {
|
|
96
|
+
exists: existsSync,
|
|
97
|
+
read: (p) => readFileSync(p, 'utf8'),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return cached;
|
|
101
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -16,7 +16,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
16
16
|
import { render, Box, Text } from 'ink';
|
|
17
17
|
import { useMemo, useState } from 'react';
|
|
18
18
|
import { resolveTasksPath, resolveTasksDir } from './tasks.js';
|
|
19
|
-
import { LAUNCH_SEP } from './config.js';
|
|
19
|
+
import { LAUNCH_SEP, docsRootName } from './config.js';
|
|
20
20
|
import { anchorFrame, enableMouse } from './mouse.js';
|
|
21
21
|
import { sanitize } from './width.js';
|
|
22
22
|
import { MODEL_SHORT_LIST, modelAlias } from './glyphs.js';
|
|
@@ -48,7 +48,7 @@ import { DocPane } from './ui/doc-pane.js';
|
|
|
48
48
|
import { PreviewPane, detailMetaOf } from './ui/preview.js';
|
|
49
49
|
import { CompactNotice, HintBar, TextBox, screenFor } from './ui/screens.js';
|
|
50
50
|
import { VERSION } from './version.js';
|
|
51
|
-
function Deck({ cwd, tasksPath, tasksDir }) {
|
|
51
|
+
function Deck({ cwd, docsRoot, tasksPath, tasksDir, }) {
|
|
52
52
|
// La riga di STATO in fondo al frame: il feedback dell'ultima azione. Non è
|
|
53
53
|
// la nota di una conversazione, che nel modello si chiama `sessionNotes`.
|
|
54
54
|
const [note, setNote] = useState('');
|
|
@@ -68,6 +68,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
68
68
|
const bareDefault = resolveSpawnId('bare', spawnConfig.overrides, catalog).model;
|
|
69
69
|
const model = useDeckModel({
|
|
70
70
|
cwd,
|
|
71
|
+
docsRoot,
|
|
71
72
|
tasksPath,
|
|
72
73
|
tasksDir,
|
|
73
74
|
bareModel: bareDefault,
|
|
@@ -267,6 +268,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
267
268
|
scanned: gitlink.scanned,
|
|
268
269
|
ok: gitlink.ok,
|
|
269
270
|
failed: gitlink.failed,
|
|
271
|
+
available: gitlink.available,
|
|
270
272
|
}
|
|
271
273
|
: null,
|
|
272
274
|
inbox: {
|
|
@@ -274,8 +276,15 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
274
276
|
stale: model.inboxStale,
|
|
275
277
|
scanned: model.inboxScanned,
|
|
276
278
|
ok: model.inboxOk,
|
|
279
|
+
available: model.pluginScripts,
|
|
280
|
+
},
|
|
281
|
+
wrap: {
|
|
282
|
+
count: wrap.count,
|
|
283
|
+
mtime: wrap.mtime,
|
|
284
|
+
ok: wrap.ok,
|
|
285
|
+
scanning: wrap.scanning,
|
|
286
|
+
available: wrap.available,
|
|
277
287
|
},
|
|
278
|
-
wrap: { count: wrap.count, mtime: wrap.mtime, ok: wrap.ok, scanning: wrap.scanning },
|
|
279
288
|
}, columns);
|
|
280
289
|
const frame = frameGeometry({
|
|
281
290
|
rows,
|
|
@@ -357,9 +366,13 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
357
366
|
hasDoc: model.selDoc !== null,
|
|
358
367
|
gitlink: gitlink.enabled,
|
|
359
368
|
restore: frame.headerButton !== null,
|
|
360
|
-
}), indicators: indicators, columns: columns }), frame.launchLine ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [launch.segments.map((s) => s.text).join(LAUNCH_SEP), launch.overflow > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 +", launch.overflow, " fuori riga"] })) : null, launch.unreachable > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 ", launch.unreachable, " oltre la 9\u00AA (non raggiungibili)"] })) : null, launch.filler, BARE_LABEL.text, ' ', bareButtons.nodes] })) : null, mode === 'create' ? _jsx(TextBox, { glyph: "C", value: textModals.draft }) : null, mode === 'note' ? _jsx(TextBox, { glyph: "\u270E", value: textModals.noteDraft }) : null, mode === 'sort' ? _jsx(SortModal, { sort: model.view.sort }) : null, mode === 'filter' ? (_jsx(FilterModal, { view: model.view, cursor: viewModals.filterCursor })) : null, mode === 'edit' && textModals.edit && textModals.editTask ? (_jsx(EditModal, { id: textModals.editTask.id, draft: textModals.edit, row: textModals.editCursor.row, caret: textModals.editCursor.caret, columns: columns })) : null, mode === 'purge' && purge.draft ? (_jsx(PurgeModal, { draft: purge.draft, columns: columns })) : null, mode === 'drop' && drop.draft ? _jsx(DropModal, { draft: drop.draft, columns: columns }) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [model.deckMode === 'doc' ? (_jsx(DocPane, { rows: frame.windowDoc, counts: model.docCounts, activeView: model.docViewId, paneCount: model.docRows.length, selectedPath: model.selDocPath, focused: model.focus === 'doctree', above: frame.docWin.start, below: model.docRows.length - frame.docWin.end, columns: columns, ok: model.docOk, scanned: model.docScanned, scanning: model.docScanning, hasDirs: model.docHasDirs })) : (_jsx(TasksPane, { tasks: frame.windowTasks, counts: model.taskCounts, activeView: model.taskViewId, paneCount: model.paneTasks.length, view: model.view, selected: model.selIndex, spotCount: model.spotCount, allCount: model.sessions.length, idW: model.taskCols.id, tailW: model.taskCols.tail, focused: model.focus === 'tasks', loadError: model.loadError, windowStart: frame.taskWin.start, above: frame.taskWin.start, below: model.paneTasks.length - frame.taskWin.end, columns: columns, data: model.taskRowData, blockMark: model.blockMark })), model.deckMode === 'doc' ? (_jsx(InboxPane, { files: frame.windowInbox, counts: model.inboxCounts, activeView: model.inboxViewId, paneCount: model.inboxFiles.length, selectedPath: model.selInboxPath, focused: model.focus === 'inbox', above: frame.inboxWin.start, below: model.inboxFiles.length - frame.inboxWin.end, columns: columns, ok: model.inboxOk, scanned: model.inboxScanned })) : (_jsx(SessionsPane, { parentLabel: model.parentLabel, isSpot: model.isSpot, isAll: model.isAll, bindings: model.bindings, taskW: model.sessionCols.task, ageW: model.sessionCols.age, rows: frame.windowRows, counts: model.sessionCounts, activeView: model.sessionViewId, paneCount: model.sessionRows.length, selectedId: model.selSessionId ?? undefined, focused: model.focus === 'sessions', above: frame.sessionWin.start, below: model.sessionRows.length - frame.sessionWin.end, columns: columns, forkOf: model.forkOf, sessionNotes: model.sessionNotes, sessionPriority: model.sessionPriority, projectCore: model.projectCore, live: model.live }))] }), frame.budget.preview && previewKind === 'task' && model.detail ? (_jsx(PreviewPane, { kind: "task", detail: model.detail, maxLines: frame.budget.detailLines, columns: columns })) : frame.budget.preview && previewKind === 'inbox' && model.selInbox ? (_jsx(PreviewPane, { kind: "inbox", file: model.selInbox, columns: columns })) : frame.budget.preview && previewKind === 'doc' && model.selDoc ? (_jsx(PreviewPane, { kind: "doc", row: model.selDoc, columns: columns })) : frame.budget.preview && previewKind === 'session' && model.selSessionObj ? (_jsx(PreviewPane, { kind: "session", s: model.selSessionObj, firstLines: frame.budget.sessionFirstLines, lastLines: frame.budget.sessionLastLines, columns: columns, origin: model.forkOf.get(model.selSessionObj.sessionId) ?? null, note: model.sessionNotes.get(model.selSessionObj.sessionId) ?? '', live: model.live.get(model.selSessionObj.sessionId) ?? null, resumeModel: model.resumeModel })) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
|
|
369
|
+
}), indicators: indicators, columns: columns }), frame.launchLine ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [launch.segments.map((s) => s.text).join(LAUNCH_SEP), launch.overflow > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 +", launch.overflow, " fuori riga"] })) : null, launch.unreachable > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 ", launch.unreachable, " oltre la 9\u00AA (non raggiungibili)"] })) : null, launch.filler, BARE_LABEL.text, ' ', bareButtons.nodes] })) : null, mode === 'create' ? _jsx(TextBox, { glyph: "C", value: textModals.draft }) : null, mode === 'note' ? _jsx(TextBox, { glyph: "\u270E", value: textModals.noteDraft }) : null, mode === 'sort' ? _jsx(SortModal, { sort: model.view.sort }) : null, mode === 'filter' ? (_jsx(FilterModal, { view: model.view, cursor: viewModals.filterCursor })) : null, mode === 'edit' && textModals.edit && textModals.editTask ? (_jsx(EditModal, { id: textModals.editTask.id, draft: textModals.edit, row: textModals.editCursor.row, caret: textModals.editCursor.caret, columns: columns })) : null, mode === 'purge' && purge.draft ? (_jsx(PurgeModal, { draft: purge.draft, columns: columns })) : null, mode === 'drop' && drop.draft ? _jsx(DropModal, { draft: drop.draft, columns: columns }) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [model.deckMode === 'doc' ? (_jsx(DocPane, { rows: frame.windowDoc, counts: model.docCounts, activeView: model.docViewId, paneCount: model.docRows.length, selectedPath: model.selDocPath, focused: model.focus === 'doctree', above: frame.docWin.start, below: model.docRows.length - frame.docWin.end, columns: columns, ok: model.docOk, scanned: model.docScanned, scanning: model.docScanning, hasDirs: model.docHasDirs, available: model.pluginScripts })) : (_jsx(TasksPane, { tasks: frame.windowTasks, counts: model.taskCounts, activeView: model.taskViewId, paneCount: model.paneTasks.length, view: model.view, selected: model.selIndex, spotCount: model.spotCount, allCount: model.sessions.length, idW: model.taskCols.id, tailW: model.taskCols.tail, focused: model.focus === 'tasks', loadError: model.loadError, windowStart: frame.taskWin.start, above: frame.taskWin.start, below: model.paneTasks.length - frame.taskWin.end, columns: columns, data: model.taskRowData, blockMark: model.blockMark })), model.deckMode === 'doc' ? (_jsx(InboxPane, { files: frame.windowInbox, counts: model.inboxCounts, activeView: model.inboxViewId, paneCount: model.inboxFiles.length, selectedPath: model.selInboxPath, focused: model.focus === 'inbox', above: frame.inboxWin.start, below: model.inboxFiles.length - frame.inboxWin.end, columns: columns, ok: model.inboxOk, scanned: model.inboxScanned, available: model.pluginScripts })) : (_jsx(SessionsPane, { parentLabel: model.parentLabel, isSpot: model.isSpot, isAll: model.isAll, bindings: model.bindings, taskW: model.sessionCols.task, ageW: model.sessionCols.age, rows: frame.windowRows, counts: model.sessionCounts, activeView: model.sessionViewId, paneCount: model.sessionRows.length, selectedId: model.selSessionId ?? undefined, focused: model.focus === 'sessions', above: frame.sessionWin.start, below: model.sessionRows.length - frame.sessionWin.end, columns: columns, forkOf: model.forkOf, sessionNotes: model.sessionNotes, sessionPriority: model.sessionPriority, projectCore: model.projectCore, live: model.live }))] }), frame.budget.preview && previewKind === 'task' && model.detail ? (_jsx(PreviewPane, { kind: "task", detail: model.detail, maxLines: frame.budget.detailLines, columns: columns })) : frame.budget.preview && previewKind === 'inbox' && model.selInbox ? (_jsx(PreviewPane, { kind: "inbox", file: model.selInbox, columns: columns })) : frame.budget.preview && previewKind === 'doc' && model.selDoc ? (_jsx(PreviewPane, { kind: "doc", row: model.selDoc, columns: columns })) : frame.budget.preview && previewKind === 'session' && model.selSessionObj ? (_jsx(PreviewPane, { kind: "session", s: model.selSessionObj, firstLines: frame.budget.sessionFirstLines, lastLines: frame.budget.sessionLastLines, columns: columns, origin: model.forkOf.get(model.selSessionObj.sessionId) ?? null, note: model.sessionNotes.get(model.selSessionObj.sessionId) ?? '', live: model.live.get(model.selSessionObj.sessionId) ?? null, resumeModel: model.resumeModel })) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
|
|
361
370
|
}
|
|
362
371
|
const cwd = process.cwd();
|
|
372
|
+
// T175 — la docs-root si risolve QUI, una volta, e scende a tutti: i path di
|
|
373
|
+
// `tasks.md` e dei task file e i due scan del modo doc devono vedere la stessa
|
|
374
|
+
// cartella, e due risoluzioni potrebbero non vederla.
|
|
375
|
+
const docsRoot = docsRootName(cwd);
|
|
363
376
|
// T116 — `exitOnCtrlC: false` toglie a Ink l'uscita immediata su `^C`: senza,
|
|
364
377
|
// il tasto non arriverebbe mai a `useInput` e il doppio colpo non sarebbe
|
|
365
378
|
// osservabile. In raw mode `^C` non è un SIGINT — è il byte `\x03` nello
|
|
@@ -372,6 +385,4 @@ const cwd = process.cwd();
|
|
|
372
385
|
// esista qualcosa da cui uscire.
|
|
373
386
|
anchorFrame();
|
|
374
387
|
enableMouse();
|
|
375
|
-
render(_jsx(Deck, { cwd: cwd, tasksPath: resolveTasksPath(cwd), tasksDir: resolveTasksDir(cwd) }), {
|
|
376
|
-
exitOnCtrlC: false,
|
|
377
|
-
});
|
|
388
|
+
render(_jsx(Deck, { cwd: cwd, docsRoot: docsRoot, tasksPath: resolveTasksPath(cwd, docsRoot), tasksDir: resolveTasksDir(cwd, docsRoot) }), { exitOnCtrlC: false });
|
package/dist/commit-times.js
CHANGED
|
@@ -37,7 +37,10 @@ export function commitLogSpawnCount() {
|
|
|
37
37
|
}
|
|
38
38
|
async function headSha(projectRoot) {
|
|
39
39
|
try {
|
|
40
|
-
const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], {
|
|
40
|
+
const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], {
|
|
41
|
+
cwd: projectRoot,
|
|
42
|
+
windowsHide: true,
|
|
43
|
+
});
|
|
41
44
|
return stdout.trim();
|
|
42
45
|
}
|
|
43
46
|
catch {
|
|
@@ -98,7 +101,7 @@ export async function commitTimes(tasksDir, projectRoot) {
|
|
|
98
101
|
// Il contatore sale PRIMA dell'await: conta gli spawn partiti, e uno
|
|
99
102
|
// spawn fallito costa comunque la fork del processo.
|
|
100
103
|
logSpawns++;
|
|
101
|
-
const { stdout } = await execFileAsync('git', ['log', '--format=#%ct', '--name-only', '--', tasksDir], { cwd: projectRoot });
|
|
104
|
+
const { stdout } = await execFileAsync('git', ['log', '--format=#%ct', '--name-only', '--', tasksDir], { cwd: projectRoot, windowsHide: true });
|
|
102
105
|
map = parseCommitLog(stdout);
|
|
103
106
|
}
|
|
104
107
|
catch {
|
package/dist/config.js
CHANGED
|
@@ -112,12 +112,10 @@ export function launchLegend(entries, columns, reserved = 0) {
|
|
|
112
112
|
// T61 — soglia d'età del contatore archiviabili, campo `archivableDays`.
|
|
113
113
|
//
|
|
114
114
|
// È il PRIMO scalare che il lato TypeScript legge dal file config: `launch` e
|
|
115
|
-
// `identity` sopra sono strutture
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
// `permissionMode`, che vive nel file, lo legge `deck-run` via jq e non è
|
|
120
|
-
// propagato al registry.
|
|
115
|
+
// `identity` sopra sono strutture. La catena da percorrere si ferma qui: nessun
|
|
116
|
+
// passaggio da `lib-config.sh`/`reg_pull`/dconf, perché il solo consumer è il
|
|
117
|
+
// deck e il file ce l'ha sotto mano. Il precedente esatto è `permissionMode`,
|
|
118
|
+
// che vive nel file, lo legge anche `deck-run` e non è propagato al registry.
|
|
121
119
|
/** Interi positivi soltanto: uno 0 spegnerebbe la soglia (tutte le Done
|
|
122
120
|
* archiviabili), un negativo o un decimale sono un typo. Valore fuori dominio
|
|
123
121
|
* → default, mai passaggio cieco di un numero senza senso al conteggio. */
|
|
@@ -149,6 +147,53 @@ export function loadArchivableDays(projectRoot) {
|
|
|
149
147
|
}
|
|
150
148
|
return DEFAULT_ARCHIVABLE_DAYS;
|
|
151
149
|
}
|
|
150
|
+
/** La docs-root di un progetto che non la dichiara: la stessa di `lw_docs_root`
|
|
151
|
+
* nel plugin. */
|
|
152
|
+
export const DEFAULT_DOCS_ROOT = 'docs';
|
|
153
|
+
/** Stringa non vuota, presa così com'è: è il valore che il plugin legge con
|
|
154
|
+
* `jq -r '.docsRoot'`, e due letture che normalizzassero in modo diverso
|
|
155
|
+
* manderebbero deck e skill in due cartelle. */
|
|
156
|
+
export function parseDocsRoot(raw) {
|
|
157
|
+
if (!raw || typeof raw !== 'object')
|
|
158
|
+
return null;
|
|
159
|
+
const v = raw.docsRoot;
|
|
160
|
+
return typeof v === 'string' && v.trim() ? v : null;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Nome della cartella doc del progetto (`docs`, `runtime`, …): `tasks.md`,
|
|
164
|
+
* `tasks/`, `inbox/` stanno lì sotto.
|
|
165
|
+
*
|
|
166
|
+
* T175 — precedenza `env → file → default`. Fino a T175 il deck leggeva la sola
|
|
167
|
+
* env `LOOM_DECK_DOCS_ROOT`, contando su chi lo avvia: compass la antepone al
|
|
168
|
+
* comando, il profilo Ptyxis la porta cablata. Un deck avviato a mano da una
|
|
169
|
+
* shell — su Linux, e su Windows sempre, perché lì nessuno dei due canali
|
|
170
|
+
* esiste — cercava `docs/` e mostrava una lista vuota invece di un errore. Il
|
|
171
|
+
* campo `docsRoot` del file è la fonte unica della famiglia (lo legge anche il
|
|
172
|
+
* plugin): il deck lo legge da sé.
|
|
173
|
+
*
|
|
174
|
+
* L'env resta IN TESTA, e non solo per i test: è il canale con cui compass
|
|
175
|
+
* passa il valore del registry, e una env impostata è una scelta esplicita di
|
|
176
|
+
* chi avvia, che batte il file come `LOOM_DECK_ARCHIVABLE_DAYS` batte
|
|
177
|
+
* `archivableDays`.
|
|
178
|
+
*
|
|
179
|
+
* Si risolve UNA volta per vita del deck, in `cli.tsx`, e il valore scende a
|
|
180
|
+
* chi lo usa: i path di `tasks.md` e dei task file e i due scan del modo doc
|
|
181
|
+
* devono vedere la stessa cartella.
|
|
182
|
+
*/
|
|
183
|
+
export function docsRootName(projectRoot) {
|
|
184
|
+
const env = process.env.LOOM_DECK_DOCS_ROOT;
|
|
185
|
+
if (env)
|
|
186
|
+
return env;
|
|
187
|
+
try {
|
|
188
|
+
const fromFile = parseDocsRoot(JSON.parse(readFileSync(configFilePath(projectRoot), 'utf8')));
|
|
189
|
+
if (fromFile !== null)
|
|
190
|
+
return fromFile;
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
// file assente o malformato → default, come loadLaunch/loadIdentity
|
|
194
|
+
}
|
|
195
|
+
return DEFAULT_DOCS_ROOT;
|
|
196
|
+
}
|
|
152
197
|
export function parseIdentity(raw) {
|
|
153
198
|
if (!raw || typeof raw !== 'object')
|
|
154
199
|
return null;
|
package/dist/deck-model.js
CHANGED
|
@@ -19,7 +19,6 @@ import { DEFAULT_INBOX_STALE_HOURS, staleCount } from './inbox.js';
|
|
|
19
19
|
import { aliveIds, restoreSet } from './live-marks.js';
|
|
20
20
|
import { cycleDocView, docCounts as deriveDocCounts, docView, selectDocRows, } from './doc-views.js';
|
|
21
21
|
import { cycleInboxView, inboxCounts as deriveInboxCounts, inboxView, selectInboxRows, } from './inbox-views.js';
|
|
22
|
-
import { docsRootName } from './tasks.js';
|
|
23
22
|
import { isDone } from './layout.js';
|
|
24
23
|
import { TASK_EMPTY, modelAlias, relTime } from './glyphs.js';
|
|
25
24
|
import { assembleSessionList, firstSelectableId, rowIndexOf, selectedSession, } from './session-list.js';
|
|
@@ -29,6 +28,8 @@ import { applyView, epicRollup, taskColumns } from './view.js';
|
|
|
29
28
|
import { loadView } from './view-store.js';
|
|
30
29
|
import { termWidth } from './width.js';
|
|
31
30
|
import { MODELS, MODEL_DEFAULT } from './spawn-catalog.js';
|
|
31
|
+
import { isAvailable } from './platform.js';
|
|
32
|
+
import { projectBaseName } from './project-path.js';
|
|
32
33
|
/**
|
|
33
34
|
* Conteggio dei figli per task, più il rollup delle VIVE e le conversazioni
|
|
34
35
|
* senza binding.
|
|
@@ -107,7 +108,7 @@ export function childSessionsOf(sessions, bindings, selectedTaskId, isAll) {
|
|
|
107
108
|
return selectedTaskId ? bound === selectedTaskId : !bound;
|
|
108
109
|
});
|
|
109
110
|
}
|
|
110
|
-
export function useDeckModel({ cwd, tasksPath, tasksDir, bareModel: bareModelDefault, setNote, }) {
|
|
111
|
+
export function useDeckModel({ cwd, docsRoot, tasksPath, tasksDir, bareModel: bareModelDefault, setNote, }) {
|
|
111
112
|
const { tasks, loadError } = useTasks(tasksPath);
|
|
112
113
|
// Voci launch del progetto (T32): lette una volta, raggiunte per indice 1..9.
|
|
113
114
|
const launch = useMemo(() => loadLaunch(cwd), [cwd]);
|
|
@@ -125,7 +126,9 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, bareModel: bareModelDef
|
|
|
125
126
|
// scrive nel sidecar il residuo del titolo, e quel residuo si ottiene
|
|
126
127
|
// togliendo questo stesso core (§Riempimento dei buchi in `hooks.ts`).
|
|
127
128
|
const projectCore = identity ? identity.name : null;
|
|
128
|
-
|
|
129
|
+
// T175 — `projectBaseName` e non uno split su `/`: su Windows restituiva il
|
|
130
|
+
// path intero.
|
|
131
|
+
const projectName = projectBaseName(cwd);
|
|
129
132
|
// `notes` esce dall'indice come `sessionNotes`: nel deck `note` è già la riga
|
|
130
133
|
// di STATO in fondo al frame (il feedback di un'azione). Due concetti diversi
|
|
131
134
|
// a una lettera di distanza sarebbero una trappola di lettura — e di
|
|
@@ -214,11 +217,12 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, bareModel: bareModelDef
|
|
|
214
217
|
const archivableSig = useMemo(() => [...archivable].sort().join(','), [archivable]);
|
|
215
218
|
const dirtyFolders = useDirtyFolders(archivableSig, tasksDir, cwd);
|
|
216
219
|
// T134 — terzo scan della famiglia (D1): coda inbox per natura, età del più
|
|
217
|
-
// vecchio.
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
|
|
221
|
-
const
|
|
220
|
+
// vecchio. T175 — la docs-root è quella di `tasksPath`, risolta una volta in
|
|
221
|
+
// `cli.tsx`: lista task e coda inbox guardano la stessa cartella.
|
|
222
|
+
// T175 — coda inbox e albero doc li misura lo stesso script del plugin: dove
|
|
223
|
+
// non gira, nessuno dei due scan parte e i due pane dicono perché.
|
|
224
|
+
const pluginScripts = isAvailable('pluginScripts');
|
|
225
|
+
const inbox = useInboxScan(cwd, docsRoot, pluginScripts);
|
|
222
226
|
const inboxCounts = useMemo(() => deriveInboxCounts(inbox.files), [inbox.files]);
|
|
223
227
|
// Il numero accanto alla sirena: solo i file in coda oltre soglia. `Date.now()`
|
|
224
228
|
// letto a ogni render e non memoizzato — è una sottrazione, e congelarlo
|
|
@@ -232,7 +236,7 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, bareModel: bareModelDef
|
|
|
232
236
|
// vero, quindi il montaggio rimisura da sé — `toggleDeckMode` sotto non deve
|
|
233
237
|
// chiamare `doc.scan()` come fa il gemello inbox, e chiamarlo darebbe due
|
|
234
238
|
// spawn da quattro secondi per un gesto solo.
|
|
235
|
-
const doc = useDocScan(cwd, docsRoot, deckMode === 'doc');
|
|
239
|
+
const doc = useDocScan(cwd, docsRoot, deckMode === 'doc' && pluginScripts);
|
|
236
240
|
// La misura GREZZA, memoizzata a parte: la consumano tre derivazioni (i
|
|
237
241
|
// contatori, le righe della vista, l'elenco dei file di una cartella nello
|
|
238
242
|
// sheet) e ricomporre l'oggetto a ogni render darebbe una identità nuova a
|
|
@@ -599,6 +603,7 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, bareModel: bareModelDef
|
|
|
599
603
|
deckMode,
|
|
600
604
|
// derivazioni del pane inbox
|
|
601
605
|
docsRoot,
|
|
606
|
+
pluginScripts,
|
|
602
607
|
inboxScanned: inbox.scanned,
|
|
603
608
|
inboxOk: inbox.ok,
|
|
604
609
|
inboxViewId,
|
package/dist/deck-run.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// T176 — il punto d'ingresso di `deck-run`: `scripts/deck-run` (lo shim bash
|
|
2
|
+
// che compass e i test lanciano) e il deck su Windows eseguono questo file col
|
|
3
|
+
// proprio node. La logica sta in `tab-launch.ts`, che si testa senza aprire tab.
|
|
4
|
+
import { realIo, runDeckRun } from './tab-launch.js';
|
|
5
|
+
runDeckRun(process.argv.slice(2), realIo()).then((code) => {
|
|
6
|
+
process.exitCode = code;
|
|
7
|
+
});
|
package/dist/doc-tree.js
CHANGED
|
@@ -376,7 +376,7 @@ export function docTargetKind(row, docsRoot) {
|
|
|
376
376
|
* T160 — il buco `{words}` del titolo di una sessione di rebalance: il path reso
|
|
377
377
|
* a parole.
|
|
378
378
|
*
|
|
379
|
-
* Gemello di `wrapWords`, e per la stessa ragione: l'alfabeto di `
|
|
379
|
+
* Gemello di `wrapWords`, e per la stessa ragione: l'alfabeto di `saneNote`
|
|
380
380
|
* non ha né `/` né `.`, quindi un path lasciato cadere lì dentro esce con le
|
|
381
381
|
* parole saldate (`runtimereferencedoc-systemmd`) mentre la lista del deck, che
|
|
382
382
|
* legge la nota grezza dal sidecar, mostrerebbe il path intero — la stessa
|
|
@@ -468,7 +468,7 @@ export async function scanDocTree(projectRoot, docsRoot) {
|
|
|
468
468
|
if (!script)
|
|
469
469
|
return { files: [], dirs: [], hasDirs: false, ok: false };
|
|
470
470
|
try {
|
|
471
|
-
const { stdout } = await execFileAsync(script, ['--docs-root', docsRoot, '--format', 'tsv'], { cwd: projectRoot, maxBuffer: 8 * 1024 * 1024 });
|
|
471
|
+
const { stdout } = await execFileAsync(script, ['--docs-root', docsRoot, '--format', 'tsv'], { cwd: projectRoot, maxBuffer: 8 * 1024 * 1024, windowsHide: true });
|
|
472
472
|
return { ...parseDocTsv(stdout), ok: true };
|
|
473
473
|
}
|
|
474
474
|
catch {
|