@lamemind/loom-deck 0.36.0 → 0.38.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 +19 -0
- package/dist/archivable.js +8 -4
- package/dist/cli.js +164 -6
- package/dist/hooks.js +37 -0
- package/dist/input-modes.js +4 -0
- package/dist/purge.js +116 -0
- package/dist/spawn.js +64 -21
- package/dist/ui/modals.js +53 -2
- package/dist/ui/panes.js +2 -2
- package/dist/version.js +22 -0
- package/dist/viewport.js +7 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -66,6 +66,7 @@ Assegnazioni correnti:
|
|
|
66
66
|
| modale | `F` | filtri |
|
|
67
67
|
| modale | `^F` | **ricerca full-text** nelle conversazioni del progetto |
|
|
68
68
|
| modale | `⏎` | **detail** della task selezionata: task file scrollabile + barra azioni |
|
|
69
|
+
| modale | `CANC` | **elimina** — la task selezionata, oppure l'intera vista `archiviabili` se è quella attiva |
|
|
69
70
|
| immediata | `^K` | spawna la task selezionata col prompt di **recap** |
|
|
70
71
|
| immediata | `^P` | spawna la task selezionata sul **preflight** |
|
|
71
72
|
| immediata | `^R` | spawna la task selezionata in **esecuzione** |
|
|
@@ -93,6 +94,24 @@ Le emoji sono quelle del menu compass. Per il terminale compass usa 🖥️, che
|
|
|
93
94
|
|
|
94
95
|
`t` e `c` sono gemelle: entrambe aprono una surface del cappello nella stessa finestra Ptyxis, senza passare da un modale. `c` (minuscola, azione) e `C` (maiuscola, modale create-task) restano distinte per la regola sopra — così come `f` (fork) e `F` (filtri).
|
|
95
96
|
|
|
97
|
+
### `CANC` — eliminare task
|
|
98
|
+
|
|
99
|
+
Il deck **ordina** la potatura, non la esegue: la fa `loom-works:clean-tasks`, invocato da un processo Claude headless. Nessun `git rm` e nessuna riscrittura di `tasks.md` vivono qui — quella sequenza (task file + folder dot-prefixed + riga in `tasks.md`, un commit atomico per task, symlink `current-task.md` rimosso, righe orfane riconciliate) è implementata una volta sola, e averne una seconda darebbe due rimozioni capaci di divergere.
|
|
100
|
+
|
|
101
|
+
Lo **stesso tasto ha due bersagli di taglia diversa**, e ciò che li discrimina è la vista attiva — un'informazione che sta in header, non sotto le dita:
|
|
102
|
+
|
|
103
|
+
| Vista attiva | Bersaglio |
|
|
104
|
+
|---|---|
|
|
105
|
+
| `archiviabili` | **tutte** le task che la vista mostra |
|
|
106
|
+
| qualunque altra | la **task selezionata** |
|
|
107
|
+
|
|
108
|
+
Da qui la forma della conferma: nomina il bersaglio (quante e quali ID, troncando con `+N`) invece dell'azione, e dice l'**effetto sul disco** — mai «archiviare», che prometterebbe uno spostamento in un archivio che non esiste. I commit restano **locali**: `clean-tasks` non pusha, quindi finché nessuno lo fa un altro worktree continua a vedere le task potate.
|
|
109
|
+
|
|
110
|
+
- **`CANC` è anche Backspace.** Ink battezza `delete` sia `\x7f` sia `\x1b[3~` e azzera `input`: a valle non resta nulla da cui distinguerli. Il binding vive nel modo `normal`, che non ha campi di testo, e ogni modo che ne ha uno cattura l'input e non vede quel ramo — la conferma è la rete contro chi usa Backspace come «indietro».
|
|
111
|
+
- **Le folder con file non tracciati si scartano a monte.** `clean-tasks.sh` esce 2 appena trova una task folder con file che `git rm` non rimuove, e lo fa *prima* di potare qualsiasi cosa: in un bulk su N target, uno solo non conforme annullerebbe il lavoro sugli altri N-1. Il deck replica il predicato del gate (`git ls-files -o`), marca quelle task con `⚠` nella vista `archiviabili` e le esclude dal bulk — nominandole nella conferma, perché promettere 7 e farne 5 sarebbe una bugia invisibile.
|
|
112
|
+
- **Sulla singola task sporca la conferma ha tre uscite**: `⏎` su `keep` (snapshot in git prima del purge), `⏎` su `purge` (file persi), `esc`. La scelta viaggia come `--ignored-files` ed è rara: nasconderla dentro un binario `⏎`/`esc` la mascherebbe.
|
|
113
|
+
- **`CANC` col focus sul pane Sessions è inerte e lo dice**, come `F` fuori dalla vista principale.
|
|
114
|
+
|
|
96
115
|
### I cinque modi di entrare in una task
|
|
97
116
|
|
|
98
117
|
Sulla task selezionata (focus sul pane Tasks) si apre una sessione **bound**: `LOOM_TASK` iniettata, `sessionId` pinnato, binding scritto nel sidecar. Fra i modi cambia **solo il prompt iniziale**.
|
package/dist/archivable.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
// T61 — conteggio delle task Done potabili ("archiviabili").
|
|
2
2
|
//
|
|
3
|
-
// Il deck
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// Il deck ORDINA la potatura, non la esegue (T112): questo modulo dice quante
|
|
4
|
+
// e quali Done hanno superato la soglia d'età, `CANC` sul pane task apre la
|
|
5
|
+
// conferma, e la rimozione vera resta `loom-works:clean-tasks` — invocata da un
|
|
6
|
+
// umano allora come adesso, con la sola differenza che ora la superficie da cui
|
|
7
|
+
// la ordina è il deck. La sequenza distruttiva (task file + folder + riga in
|
|
8
|
+
// tasks.md, un commit per task) è implementata una volta sola, nello script:
|
|
9
|
+
// averne una seconda qui darebbe due `git rm` capaci di divergere.
|
|
10
|
+
// La policy di retention resta manuale per scelta, nessuna GC automatica.
|
|
7
11
|
//
|
|
8
12
|
// D1 (preflight) — la regola d'età è RICALCOLATA qui, non delegata a
|
|
9
13
|
// `cleanup-done-tasks.sh`: il deck è un binario npm globale spawnato da Ptyxis,
|
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { render, Box, Text, useApp, useInput } from 'ink';
|
|
4
4
|
import { useState, useEffect, useMemo } from 'react';
|
|
5
5
|
import { randomUUID } from 'node:crypto';
|
|
@@ -14,21 +14,23 @@ import { cut, sanitize, termWidth } from './width.js';
|
|
|
14
14
|
import { applyView, cycleSort, describeSort, priName, progName, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
|
|
15
15
|
import { initialDetail, writeTaskEdit, PRI_GLYPH, PRI_LABEL } from './task-edit.js';
|
|
16
16
|
import { ALL, EDIT_PRI, EDIT_PROG, MAX_SESSIONS, MAX_SESSIONS_ALL, META_ROWS, ROW_ALL, ROW_SPOT, SORT_TASTI, SPOT, } from './model.js';
|
|
17
|
+
import { purgeTargets, splitTargets } from './purge.js';
|
|
17
18
|
import { conversationLabel, cpLen, editField, insertAt, isDone, isTextRow, removeAt, EDIT_ROWS, } from './layout.js';
|
|
18
19
|
import { TASK_EMPTY, relTime, sanitizeTyped } from './glyphs.js';
|
|
19
|
-
import { commitTaskEdit, runLaunch, spawnClaudeEmpty, spawnCreateTask, spawnDeck, spawnDeckFork, spawnDeckResume, spawnTerminal, CLAUDE_CMD, DECK_RUN, MODEL_DEFAULT, } from './spawn.js';
|
|
20
|
-
import { EditModal, FilterModal, SortModal } from './ui/modals.js';
|
|
20
|
+
import { commitTaskEdit, runLaunch, spawnClaudeEmpty, spawnCleanTasks, spawnCreateTask, spawnDeck, spawnDeckFork, spawnDeckResume, spawnTerminal, CLAUDE_CMD, DECK_RUN, MODEL_DEFAULT, } from './spawn.js';
|
|
21
|
+
import { EditModal, FilterModal, PurgeModal, SortModal, idList } from './ui/modals.js';
|
|
21
22
|
import { ReaderScreen, SearchScreen } from './ui/search-screen.js';
|
|
22
23
|
import { DetailScreen } from './ui/detail-screen.js';
|
|
23
24
|
import { AssignScreen } from './ui/assign-screen.js';
|
|
24
25
|
import { SessionsPane, TasksPane } from './ui/panes.js';
|
|
25
26
|
import { PreviewPane, detailMetaOf } from './ui/preview.js';
|
|
26
27
|
import { loadView, saveView, viewFilePath } from './view-store.js';
|
|
27
|
-
import { useArchivable, useSessions, useTaskDetail, useTasks, useTerminalSize } from './hooks.js';
|
|
28
|
+
import { useArchivable, useDirtyFolders, useSessions, useTaskDetail, useTasks, useTerminalSize, } from './hooks.js';
|
|
28
29
|
import { useSearchOverlay } from './overlays/search.js';
|
|
29
30
|
import { useSheetOverlay } from './overlays/sheet.js';
|
|
30
31
|
import { useAssignOverlay } from './overlays/assign.js';
|
|
31
32
|
import { captures } from './input-modes.js';
|
|
33
|
+
import { VERSION } from './version.js';
|
|
32
34
|
function Deck({ cwd, tasksPath, tasksDir }) {
|
|
33
35
|
const { exit } = useApp();
|
|
34
36
|
const { tasks, loadError } = useTasks(tasksPath);
|
|
@@ -75,6 +77,8 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
75
77
|
// T41 — bozza dell'edit (null fuori dal modale) e riga attiva della griglia.
|
|
76
78
|
const [edit, setEdit] = useState(null);
|
|
77
79
|
const [editRow, setEditRow] = useState(0);
|
|
80
|
+
// T112 — bozza della conferma di eliminazione (null fuori dal modale).
|
|
81
|
+
const [purge, setPurge] = useState(null);
|
|
78
82
|
// Dimensioni vive del terminale: sono l'input del budget d'altezza sotto.
|
|
79
83
|
const { rows, columns } = useTerminalSize();
|
|
80
84
|
// Voci launch del progetto (T32): lette una volta, raggiunte per indice 1..9.
|
|
@@ -99,6 +103,12 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
99
103
|
const doneSig = useMemo(() => tasks.filter((t) => isDone(t.prog)).map((t) => t.id).join(','), [tasks]);
|
|
100
104
|
const archivableDays = useMemo(() => loadArchivableDays(cwd), [cwd]);
|
|
101
105
|
const archivable = useArchivable(doneSig, tasksDir, cwd, archivableDays);
|
|
106
|
+
// T112 — secondo asse di stato della vista `archiviabili`: quali fra le
|
|
107
|
+
// eliminabili hanno una folder che `git rm` non svuoterebbe. Si misura solo
|
|
108
|
+
// su quelle, non su tutta la lista: fuori da quella vista `CANC` agisce su
|
|
109
|
+
// una task sola e il dato lo ricalcola al momento.
|
|
110
|
+
const archivableSig = useMemo(() => [...archivable].sort().join(','), [archivable]);
|
|
111
|
+
const dirtyFolders = useDirtyFolders(archivableSig, tasksDir, cwd);
|
|
102
112
|
// T100 — le task effettivamente a schermo: la vista principale coincide con
|
|
103
113
|
// `viewTasks` (nessun ricalcolo sul cammino di default), le altre due passano
|
|
104
114
|
// dal predicato del catalogo. I CONTATORI restano misurati sulla vista di
|
|
@@ -416,6 +426,137 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
416
426
|
setNote(ok ? `✔ ${task.id} → ${summary} · committato` : `⚠ ${task.id} salvato, commit fallito: ${err}`);
|
|
417
427
|
});
|
|
418
428
|
}
|
|
429
|
+
// T112 — apertura della conferma di eliminazione. Il tasto è uno, il bersaglio
|
|
430
|
+
// dipende dalla VISTA attiva: `archiviabili` → l'insieme intero, ogni altra →
|
|
431
|
+
// la task selezionata. Ciò che discrimina sta in header e non sotto le dita,
|
|
432
|
+
// quindi il modale deve nominare il bersaglio (quante e quali), non l'azione.
|
|
433
|
+
//
|
|
434
|
+
// Il bersaglio del bulk si legge da `paneTasks`, cioè dalla stessa fonte che
|
|
435
|
+
// disegna le righe e alimenta il contatore in header (D6): mai il `Set` grezzo
|
|
436
|
+
// di `archivable.ts`, mai un secondo filtro sullo stato. T100 ha fissato che
|
|
437
|
+
// ciò che si conta e ciò che si mostra siano lo stesso insieme per
|
|
438
|
+
// costruzione; qui l'invariante si estende a ciò che si pota.
|
|
439
|
+
function openPurge() {
|
|
440
|
+
// La guardia di focus sta QUI e non solo dentro `selectedTaskOr`: il ramo
|
|
441
|
+
// bulk non passa da quella, perché il suo oggetto è la vista e non la
|
|
442
|
+
// selezione. Senza, `CANC` col focus sulle sessioni potrebbe potare in
|
|
443
|
+
// blocco senza che nessuna task fosse selezionata.
|
|
444
|
+
if (focus !== 'tasks') {
|
|
445
|
+
setNote('CANC → eliminare: seleziona una task (← per il pane)');
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
const bulk = taskViewId === 'archivable';
|
|
449
|
+
const ids = bulk ? paneTasks.map((t) => t.id) : [];
|
|
450
|
+
if (!bulk) {
|
|
451
|
+
const task = selectedTaskOr('CANC', 'eliminare');
|
|
452
|
+
if (!task)
|
|
453
|
+
return;
|
|
454
|
+
ids.push(task.id);
|
|
455
|
+
}
|
|
456
|
+
if (ids.length === 0) {
|
|
457
|
+
setNote('CANC → nessuna task in vista da eliminare');
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
// Ricalcolo al momento dell'AZIONE, non lettura del campionamento in lista:
|
|
461
|
+
// una folder sporcata dopo l'ultimo scan si presenterebbe come eliminabile.
|
|
462
|
+
const { clean, dirty } = splitTargets(purgeTargets(ids, tasksDir, cwd));
|
|
463
|
+
if (bulk) {
|
|
464
|
+
// Il gate del plugin esce 2 PRIMA di toccare qualsiasi cosa, quindi una
|
|
465
|
+
// sola folder sporca annullerebbe il purge di tutte le altre: si scartano
|
|
466
|
+
// a monte e il modale nomina sia le potate sia le scartate.
|
|
467
|
+
if (clean.length === 0) {
|
|
468
|
+
setNote(`CANC → ${dirty.length} task con file non tracciati in folder: eliminale una per una`);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
setPurge({
|
|
472
|
+
ids: clean.map((t) => t.id),
|
|
473
|
+
skipped: dirty.map((t) => t.id),
|
|
474
|
+
bulk: true,
|
|
475
|
+
ignored: null,
|
|
476
|
+
survivors: 0,
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
else {
|
|
480
|
+
// Singola con superstiti → conferma a TRE uscite (D3): la scelta
|
|
481
|
+
// keep/purge è rara e distruttiva in modo diverso dal purge normale, e
|
|
482
|
+
// qui è visibile invece di stare davanti a ogni potatura.
|
|
483
|
+
const one = clean[0] ?? dirty[0];
|
|
484
|
+
setPurge({
|
|
485
|
+
ids: [one.id],
|
|
486
|
+
skipped: [],
|
|
487
|
+
bulk: false,
|
|
488
|
+
ignored: one.survivors > 0 ? 'keep' : null,
|
|
489
|
+
survivors: one.survivors,
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
setNote('');
|
|
493
|
+
setMode('purge');
|
|
494
|
+
}
|
|
495
|
+
function onPurgeKey(_input, key) {
|
|
496
|
+
if (key.escape) {
|
|
497
|
+
setMode('normal');
|
|
498
|
+
setPurge(null);
|
|
499
|
+
setNote('CANC → eliminazione annullata');
|
|
500
|
+
}
|
|
501
|
+
else if (key.return) {
|
|
502
|
+
submitPurge();
|
|
503
|
+
}
|
|
504
|
+
else if ((key.leftArrow || key.rightArrow) && purge?.ignored) {
|
|
505
|
+
setPurge((p) => (p ? { ...p, ignored: p.ignored === 'keep' ? 'purge' : 'keep' } : p));
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
// ⏎ nella conferma: ordina la potatura a `loom-works:clean-tasks` e la
|
|
509
|
+
// osserva. Il deck non rimuove niente da sé — nessun `git rm`, nessuna
|
|
510
|
+
// riscrittura di tasks.md, nessuna `unlink`: quella sequenza è già
|
|
511
|
+
// implementata una volta, e averne due significherebbe due rimozioni capaci
|
|
512
|
+
// di divergere.
|
|
513
|
+
//
|
|
514
|
+
// La lista si riallinea al primo tick del poll e il set delle archiviabili
|
|
515
|
+
// allo scan che `doneSig` fa scattare quando la popolazione Done cambia; la
|
|
516
|
+
// selezione, keyed su id, cade sulla prima riga della vista (effect di
|
|
517
|
+
// validità) invece che su una posizione residua.
|
|
518
|
+
function submitPurge() {
|
|
519
|
+
const draft = purge;
|
|
520
|
+
setMode('normal');
|
|
521
|
+
setPurge(null);
|
|
522
|
+
if (!draft)
|
|
523
|
+
return;
|
|
524
|
+
const sid = randomUUID();
|
|
525
|
+
setNote(`⏳ eliminando ${draft.ids.length} task… ${idList(draft.ids, 6)}`);
|
|
526
|
+
const child = spawnCleanTasks(draft.ids, cwd, sid, draft.ignored, (ok, detail) => {
|
|
527
|
+
// L'esito si misura su `tasks.md`, NON sul solo `is_error`. Misurato: col
|
|
528
|
+
// gate `--ignored-files` che blocca, la skill spiega il blocco e chiude
|
|
529
|
+
// comunque `is_error: false` — un successo dichiarato su zero rimozioni.
|
|
530
|
+
// Il segnale robusto è quali degli ID bersaglio non hanno più una riga:
|
|
531
|
+
// deterministico, e indipendente da come la skill racconta sé stessa.
|
|
532
|
+
let survived = draft.ids;
|
|
533
|
+
try {
|
|
534
|
+
const now = new Set(loadTasks(tasksPath).map((t) => t.id));
|
|
535
|
+
survived = draft.ids.filter((id) => now.has(id));
|
|
536
|
+
}
|
|
537
|
+
catch {
|
|
538
|
+
// tasks.md illeggibile → nessuna verifica possibile, e allora si dice
|
|
539
|
+
// che non è stata fatta invece di dedurre un esito.
|
|
540
|
+
setNote(`⚠ ${draft.ids.length} task: esito non verificabile (tasks.md illeggibile)`);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
const removed = draft.ids.length - survived.length;
|
|
544
|
+
if (removed === draft.ids.length) {
|
|
545
|
+
// Il push non c'è, per scelta della skill: finché nessuno pusha, un
|
|
546
|
+
// altro worktree continua a vedere le task potate in tasks.md. Non
|
|
547
|
+
// dirlo lascerebbe leggere l'assenza di push come «fatto».
|
|
548
|
+
setNote(`✔ ${removed} task eliminate · commit locali, nessun push`);
|
|
549
|
+
}
|
|
550
|
+
else {
|
|
551
|
+
// Il gate `--ignored-files` (exit 2) può scattare lo stesso: la
|
|
552
|
+
// dirtiness è un dato campionato e una folder può sporcarsi fra il
|
|
553
|
+
// ricalcolo e l'apply. Il testo del result event è l'unica cosa che dice
|
|
554
|
+
// PERCHÉ, quindi entra nella riga invece di un ⚠ muto.
|
|
555
|
+
setNote(`⚠ ${removed}/${draft.ids.length} eliminate · restano ${idList(survived, 4)} · ${cut(detail || (ok ? '' : `${CLAUDE_CMD} -p`), 56)}`);
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
child?.on('error', () => setNote(`⚠ clean-tasks: '${CLAUDE_CMD}' non lanciabile`));
|
|
559
|
+
}
|
|
419
560
|
// Chiusura di un modale di vista: `restore` rimette la fotografia scattata
|
|
420
561
|
// all'apertura (esc = annulla), altrimenti tiene ciò che si è composto (⏎).
|
|
421
562
|
function closeViewModal(restore) {
|
|
@@ -702,6 +843,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
702
843
|
sort: onSortKey,
|
|
703
844
|
filter: onFilterKey,
|
|
704
845
|
edit: onEditKey,
|
|
846
|
+
purge: onPurgeKey,
|
|
705
847
|
};
|
|
706
848
|
useInput((input, key) => {
|
|
707
849
|
// Un modo capturing consuma TUTTO — acceleratori globali compresi — e le
|
|
@@ -799,6 +941,16 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
799
941
|
}
|
|
800
942
|
}
|
|
801
943
|
}
|
|
944
|
+
else if (key.delete) {
|
|
945
|
+
// T112/D1 — `CANC` non esiste come tasto distinto in Ink: `\x7f`
|
|
946
|
+
// (Backspace) e `\x1b[3~` (Canc) collassano entrambi su `key.delete` con
|
|
947
|
+
// `input` azzerato, quindi a valle non resta niente da cui distinguerli.
|
|
948
|
+
// Il binding vale per entrambi e si accetta come tale: in `normal` non
|
|
949
|
+
// c'è nessun campo di testo da cui Backspace possa rubare un significato,
|
|
950
|
+
// e ogni modo che ne ha uno è capturing e non vede questo ramo. Il modale
|
|
951
|
+
// di conferma è la rete contro chi usa Backspace come «indietro».
|
|
952
|
+
openPurge();
|
|
953
|
+
}
|
|
802
954
|
else if (input === 'C') {
|
|
803
955
|
setNote('');
|
|
804
956
|
setMode('create');
|
|
@@ -993,6 +1145,12 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
993
1145
|
// ridirlo a parole costava colonne su una riga che tronca in silenzio.
|
|
994
1146
|
const keyLegend = sanitize([
|
|
995
1147
|
...(canSpawn ? ['⏎ detail', '^K/^P/^R spawn'] : canResume ? ['⏎ resume'] : []),
|
|
1148
|
+
// T112 — la voce nomina il BERSAGLIO, che cambia con la vista attiva
|
|
1149
|
+
// senza che cambi il tasto: dire solo «CANC elimina» lascerebbe credere
|
|
1150
|
+
// che sulla vista `archiviabili` agisca sulla riga selezionata.
|
|
1151
|
+
...(focus === 'tasks'
|
|
1152
|
+
? [taskViewId === 'archivable' ? 'CANC elimina tutte' : 'CANC elimina']
|
|
1153
|
+
: []),
|
|
996
1154
|
...(canResume ? ['f fork'] : []),
|
|
997
1155
|
...(canPin ? ['p pin', 'N nota', 'A assegna'] : []),
|
|
998
1156
|
'^F cerca',
|
|
@@ -1123,9 +1281,9 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1123
1281
|
// a una riga sola. Perdere il deck per un terminale basso è meglio che
|
|
1124
1282
|
// sporcare la cronologia del terminale a ogni poll.
|
|
1125
1283
|
if (budget.compact) {
|
|
1126
|
-
return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, children: [' ', "\u00B7 ", viewTasks.length, " task \u00B7 sel ", selectedTaskId ?? parentLabel, " \u00B7 terminale ", rows, "\u00D7", columns, ": troppo basso, allarga"] })] }));
|
|
1284
|
+
return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, children: [' ', "v", VERSION, " \u00B7 ", viewTasks.length, " task \u00B7 sel ", selectedTaskId ?? parentLabel, " \u00B7 terminale ", rows, "\u00D7", columns, ": troppo basso, allarga"] })] }));
|
|
1127
1285
|
}
|
|
1128
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), mode === 'create' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nuova task \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " crea \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'sort' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["sort \u00B7 ", _jsx(Text, { color: "yellow", children: "p" }), " pri ", _jsx(Text, { color: "yellow", children: "s" }), " stato", ' ', _jsx(Text, { color: "yellow", children: "i" }), " id (asc\u2192desc\u2192off) \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'filter' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["filtri \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193\u2190\u2192" }), " naviga \u00B7 ", _jsx(Text, { color: "yellow", children: "spazio" }), ' ', "mostra/nascondi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'note' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nota conversazione \u00B7 ", _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva (vuoto = rimuove) \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'edit' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["edit \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " campo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " valore, o cursore sul testo \u00B7 ", _jsx(Text, { color: "yellow", children: "^A/^E" }), " inizio/fine \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^D" }), " canc \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva+commit \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: keyLegend })), mode === 'normal' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [surfaceLegend, legend.shown ? ` · ${legend.shown}` : '', legend.overflow > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 +", legend.overflow, " fuori riga"] })) : null, legend.unreachable > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 ", legend.unreachable, " oltre la 9\u00AA (non raggiungibili)"] })) : null] })) : null, mode === 'create' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "C \u203A " }), _jsx(Text, { children: draft }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'note' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "\u270E \u203A " }), _jsx(Text, { children: noteDraft }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'sort' ? _jsx(SortModal, { sort: view.sort }) : null, mode === 'filter' ? _jsx(FilterModal, { view: view, cursor: filterCursor }) : null, mode === 'edit' && edit && selTask ? (_jsx(EditModal, { id: selTask.id, draft: edit, row: editRow, columns: columns })) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: windowTasks, counts: taskCounts, activeView: taskViewId, paneCount: paneTasks.length, view: view, selected: selIndex, spotCount: spotCount, allCount: sessions.length, childCount: childCount, focused: focus === 'tasks', loadError: loadError, windowStart: taskWin.start, above: taskWin.start, below: paneTasks.length - taskWin.end, columns: columns }), _jsx(SessionsPane, { parentLabel: parentLabel, isSpot: isSpot, isAll: isAll, bindings: bindings, taskW: sessionCols.task, ageW: sessionCols.age, rows: windowRows, counts: sessionCounts, activeView: sessionViewId, paneCount: sessionRows.length, selectedId: selSessionId ?? undefined, focused: focus === 'sessions', above: sessionWin.start, below: sessionRows.length - sessionWin.end, columns: columns, forkOf: forkOf, sessionNotes: sessionNotes, projectCore: projectCore, live: live })] }), budget.preview && previewKind === 'task' && detail ? (_jsx(PreviewPane, { kind: "task", detail: detail, maxLines: budget.detailLines, columns: columns })) : budget.preview && previewKind === 'session' && selSessionObj ? (_jsx(PreviewPane, { kind: "session", s: selSessionObj, firstLines: budget.sessionFirstLines, lastLines: budget.sessionLastLines, columns: columns, origin: forkOf.get(selSessionObj.sessionId) ?? null, note: sessionNotes.get(selSessionObj.sessionId) ?? '', live: live.get(selSessionObj.sessionId) ?? null })) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
|
|
1286
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, children: ["v", VERSION] })] }), mode === 'create' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nuova task \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " crea \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'sort' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["sort \u00B7 ", _jsx(Text, { color: "yellow", children: "p" }), " pri ", _jsx(Text, { color: "yellow", children: "s" }), " stato", ' ', _jsx(Text, { color: "yellow", children: "i" }), " id (asc\u2192desc\u2192off) \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'filter' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["filtri \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193\u2190\u2192" }), " naviga \u00B7 ", _jsx(Text, { color: "yellow", children: "spazio" }), ' ', "mostra/nascondi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'note' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nota conversazione \u00B7 ", _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva (vuoto = rimuove) \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'purge' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["elimina task \u00B7", ' ', purge?.ignored ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " keep/purge dei file non tracciati \u00B7", ' '] })) : null, _jsx(Text, { color: "yellow", children: "\u23CE" }), " conferma \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'edit' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["edit \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " campo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " valore, o cursore sul testo \u00B7 ", _jsx(Text, { color: "yellow", children: "^A/^E" }), " inizio/fine \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^D" }), " canc \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva+commit \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: keyLegend })), mode === 'normal' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [surfaceLegend, legend.shown ? ` · ${legend.shown}` : '', legend.overflow > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 +", legend.overflow, " fuori riga"] })) : null, legend.unreachable > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 ", legend.unreachable, " oltre la 9\u00AA (non raggiungibili)"] })) : null] })) : null, mode === 'create' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "C \u203A " }), _jsx(Text, { children: draft }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'note' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "\u270E \u203A " }), _jsx(Text, { children: noteDraft }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'sort' ? _jsx(SortModal, { sort: view.sort }) : null, mode === 'filter' ? _jsx(FilterModal, { view: view, cursor: filterCursor }) : null, mode === 'edit' && edit && selTask ? (_jsx(EditModal, { id: selTask.id, draft: edit, row: editRow, columns: columns })) : null, mode === 'purge' && purge ? _jsx(PurgeModal, { draft: purge, columns: columns }) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: windowTasks, counts: taskCounts, activeView: taskViewId, paneCount: paneTasks.length, view: view, selected: selIndex, spotCount: spotCount, allCount: sessions.length, childCount: childCount, focused: focus === 'tasks', loadError: loadError, windowStart: taskWin.start, above: taskWin.start, below: paneTasks.length - taskWin.end, columns: columns, dirty: dirtyFolders }), _jsx(SessionsPane, { parentLabel: parentLabel, isSpot: isSpot, isAll: isAll, bindings: bindings, taskW: sessionCols.task, ageW: sessionCols.age, rows: windowRows, counts: sessionCounts, activeView: sessionViewId, paneCount: sessionRows.length, selectedId: selSessionId ?? undefined, focused: focus === 'sessions', above: sessionWin.start, below: sessionRows.length - sessionWin.end, columns: columns, forkOf: forkOf, sessionNotes: sessionNotes, projectCore: projectCore, live: live })] }), budget.preview && previewKind === 'task' && detail ? (_jsx(PreviewPane, { kind: "task", detail: detail, maxLines: budget.detailLines, columns: columns })) : budget.preview && previewKind === 'session' && selSessionObj ? (_jsx(PreviewPane, { kind: "session", s: selSessionObj, firstLines: budget.sessionFirstLines, lastLines: budget.sessionLastLines, columns: columns, origin: forkOf.get(selSessionObj.sessionId) ?? null, note: sessionNotes.get(selSessionObj.sessionId) ?? '', live: live.get(selSessionObj.sessionId) ?? null })) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
|
|
1129
1287
|
}
|
|
1130
1288
|
const cwd = process.cwd();
|
|
1131
1289
|
render(_jsx(Deck, { cwd: cwd, tasksPath: resolveTasksPath(cwd), tasksDir: resolveTasksDir(cwd) }));
|
package/dist/hooks.js
CHANGED
|
@@ -10,6 +10,7 @@ import { discoverProjectSessions } from './sessions.js';
|
|
|
10
10
|
import { discoverLiveSessions, liveSig } from './live-sessions.js';
|
|
11
11
|
import { loadSessionIndex } from './task-index.js';
|
|
12
12
|
import { archivableIds, SCAN_INTERVAL_MS } from './archivable.js';
|
|
13
|
+
import { purgeTargets } from './purge.js';
|
|
13
14
|
import { POLL_MS } from './model.js';
|
|
14
15
|
// Dimensioni del terminale, live sul resize.
|
|
15
16
|
//
|
|
@@ -179,6 +180,42 @@ export function useArchivable(doneSig, tasksDir, projectRoot, days) {
|
|
|
179
180
|
}, [doneSig, tasksDir, projectRoot, days]);
|
|
180
181
|
return ids;
|
|
181
182
|
}
|
|
183
|
+
// T112 — quali fra le task passate hanno una folder che `git rm` non
|
|
184
|
+
// svuoterebbe (file ignorati o mai tracciati). È il SECONDO asse di stato della
|
|
185
|
+
// vista `archiviabili`: eliminabile · dirty.
|
|
186
|
+
//
|
|
187
|
+
// Non viene dalla fonte della lista. Le righe arrivano da `tasks.md` (poll
|
|
188
|
+
// 1,5s), la dirtiness dal filesystem via git su questa cadenza — mount, cambio
|
|
189
|
+
// dell'insieme, poi ogni SCAN_INTERVAL_MS, gemella di `useArchivable` e per la
|
|
190
|
+
// stessa ragione: una folder non si sporca al ritmo di un poll.
|
|
191
|
+
//
|
|
192
|
+
// Ne discende che il dato è CAMPIONATO e può essere stale nell'istante in cui
|
|
193
|
+
// si preme il tasto — perciò il momento dell'azione lo ricalcola da sé
|
|
194
|
+
// (`purgeTargets`), e questo hook serve solo a mostrarlo in lista.
|
|
195
|
+
export function useDirtyFolders(idsSig, tasksDir, projectRoot) {
|
|
196
|
+
const [dirty, setDirty] = useState(() => new Set());
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
const ids = idsSig ? idsSig.split(',') : [];
|
|
199
|
+
const scan = () => {
|
|
200
|
+
try {
|
|
201
|
+
const found = purgeTargets(ids, tasksDir, projectRoot)
|
|
202
|
+
.filter((t) => t.survivors > 0)
|
|
203
|
+
.map((t) => t.id);
|
|
204
|
+
setDirty(new Set(found));
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
// git muto o task file illeggibili → nessun marker. Il gate del plugin
|
|
208
|
+
// resta la rete: un marker mancante non autorizza niente, fa solo
|
|
209
|
+
// arrivare il rifiuto più tardi.
|
|
210
|
+
setDirty(new Set());
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
scan();
|
|
214
|
+
const id = setInterval(scan, SCAN_INTERVAL_MS);
|
|
215
|
+
return () => clearInterval(id);
|
|
216
|
+
}, [idsSig, tasksDir, projectRoot]);
|
|
217
|
+
return dirty;
|
|
218
|
+
}
|
|
182
219
|
// Legge il task file della task selezionata (Q1+B T20). On-id-change: navigare
|
|
183
220
|
// con ↑↓ ricarica il dettaglio; leggere un singolo file 4-9KB è I/O triviale,
|
|
184
221
|
// niente debounce serve per la tastiera. Il refresh del contenuto a file fermo
|
package/dist/input-modes.js
CHANGED
|
@@ -29,6 +29,7 @@ export const CAPTURING_MODES = [
|
|
|
29
29
|
'sort',
|
|
30
30
|
'filter',
|
|
31
31
|
'edit',
|
|
32
|
+
'purge',
|
|
32
33
|
];
|
|
33
34
|
const _MODES_ARE_EXHAUSTIVE = true;
|
|
34
35
|
void _MODES_ARE_EXHAUSTIVE;
|
|
@@ -46,6 +47,9 @@ export function captures(mode) {
|
|
|
46
47
|
* sopravvive: è un tasto diverso che porta lo stesso nome, e il modo se lo
|
|
47
48
|
* gestisce da sé. Sta scritto qui perché è l'eccezione a una regola dichiarata,
|
|
48
49
|
* e un'eccezione che vive solo dentro un `if` annidato è come non averla.
|
|
50
|
+
*
|
|
51
|
+
* `purge` (T112) non ne ha e non può averne: è una domanda binaria, e dentro
|
|
52
|
+
* una domanda non c'è nessun acceleratore da salvare.
|
|
49
53
|
*/
|
|
50
54
|
export const CTRL_DEROGATIONS = {
|
|
51
55
|
detail: ['f'],
|
package/dist/purge.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// T112 — il BERSAGLIO di una potatura, e se il gate del plugin lo lascerebbe
|
|
2
|
+
// passare.
|
|
3
|
+
//
|
|
4
|
+
// Il deck non pota: ordina la potatura a `loom-works:clean-tasks`, che possiede
|
|
5
|
+
// l'intera sequenza distruttiva (task file + folder dot-prefixed + riga in
|
|
6
|
+
// tasks.md, un commit atomico per task, symlink `current-task.md` rimosso,
|
|
7
|
+
// righe orfane riconciliate). Qui non c'è nessun `git rm` e nessuna riscrittura
|
|
8
|
+
// di tasks.md — solo il calcolo di CHI si può potare.
|
|
9
|
+
//
|
|
10
|
+
// D3/D4 (preflight) — il gate `--ignored-files` di `clean-tasks.sh` esce 2
|
|
11
|
+
// PRIMA di toccare qualsiasi cosa: in un'invocazione su N target, una sola
|
|
12
|
+
// folder con file che `git rm` non rimuove annulla il lavoro sugli altri N-1.
|
|
13
|
+
// Un chiamante che scoprisse la condizione dall'exit code potrebbe solo
|
|
14
|
+
// riportare il fallimento intero, quindi il deck replica il predicato del gate
|
|
15
|
+
// e scarta i non conformi a monte. È la TERZA duplicazione di un criterio del
|
|
16
|
+
// plugin dentro il deck (dopo la regola d'età, D1 di T61): se lo script cambia
|
|
17
|
+
// il modo di contare i superstiti, le due letture divergono in silenzio. Costo
|
|
18
|
+
// accettato perché il canale alternativo — un dry-run via processo Claude
|
|
19
|
+
// headless — non è rifacibile on-demand, cioè al momento in cui il tasto viene
|
|
20
|
+
// premuto.
|
|
21
|
+
import { execFileSync } from 'node:child_process';
|
|
22
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
23
|
+
import { join, relative } from 'node:path';
|
|
24
|
+
import { findTaskFile } from './tasks.js';
|
|
25
|
+
/**
|
|
26
|
+
* Il campo `- **Folder**: <path>` del task file, primo token soltanto.
|
|
27
|
+
*
|
|
28
|
+
* Gemello di `read_field` in `clean-tasks.sh`, che chiude il valore con un
|
|
29
|
+
* `awk '{print $1; exit}'`: il campo ammette un'annotazione inline dopo il path
|
|
30
|
+
* (`.26-06-16-cat (condivisa con T19)`) e prenderla dentro produrrebbe una
|
|
31
|
+
* directory inesistente.
|
|
32
|
+
*
|
|
33
|
+
* `[ \t]*` e non `\s*`: con un campo vuoto (`- **Folder**:` seguito dall'a
|
|
34
|
+
* capo) uno `\s*` mangerebbe la newline e `(\S+)` catturerebbe il primo token
|
|
35
|
+
* della riga SUCCESSIVA — cioè un path preso da un altro campo.
|
|
36
|
+
*/
|
|
37
|
+
export function folderField(content) {
|
|
38
|
+
const m = content.match(/^-[ \t]*\*\*Folder\*\*:[ \t]*(\S+)/m);
|
|
39
|
+
return m ? m[1] : '';
|
|
40
|
+
}
|
|
41
|
+
/** Task folder della task, risolta e verificata su disco. */
|
|
42
|
+
export function taskFolder(projectRoot, taskFilePath) {
|
|
43
|
+
let content;
|
|
44
|
+
try {
|
|
45
|
+
content = readFileSync(taskFilePath, 'utf8');
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
const field = folderField(content);
|
|
51
|
+
if (!field)
|
|
52
|
+
return null;
|
|
53
|
+
const abs = join(projectRoot, field.replace(/^\.\//, ''));
|
|
54
|
+
try {
|
|
55
|
+
if (!statSync(abs).isDirectory())
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
return abs;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* I file di una folder che `git rm` non rimuove — ignorati da un `.gitignore` o
|
|
65
|
+
* mai tracciati.
|
|
66
|
+
*
|
|
67
|
+
* Stesso comando di `lw_folder_survivors` in `lib.sh` (`git ls-files -o`, senza
|
|
68
|
+
* `--exclude-standard`, quindi ignorati inclusi): replicare il predicato del
|
|
69
|
+
* gate ha senso solo se è LO STESSO predicato.
|
|
70
|
+
*
|
|
71
|
+
* Git muto (non installato, repo assente) → lista vuota, cioè "conforme". La
|
|
72
|
+
* direzione dell'errore è deliberata: il gate a valle resta comunque la rete,
|
|
73
|
+
* e il chiamante ne riporta il fallimento invece di dichiarare successo.
|
|
74
|
+
*/
|
|
75
|
+
export function folderSurvivors(projectRoot, folder) {
|
|
76
|
+
const rel = relative(projectRoot, folder);
|
|
77
|
+
if (!rel || rel.startsWith('..'))
|
|
78
|
+
return [];
|
|
79
|
+
try {
|
|
80
|
+
const out = execFileSync('git', ['ls-files', '-o', '--', rel], {
|
|
81
|
+
cwd: projectRoot,
|
|
82
|
+
encoding: 'utf8',
|
|
83
|
+
});
|
|
84
|
+
return out.split('\n').filter(Boolean);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Il bersaglio, misurato ADESSO.
|
|
92
|
+
*
|
|
93
|
+
* Sincrono apposta: la dirtiness è un dato campionato (la vista `archiviabili`
|
|
94
|
+
* lo mostra da uno scan periodico) e nell'istante in cui si preme il tasto può
|
|
95
|
+
* essere stale — una folder sporcata dopo l'ultimo scan si presenterebbe come
|
|
96
|
+
* eliminabile. Ricalcolarlo al momento dell'azione costa uno spawn di git per
|
|
97
|
+
* le sole task che dichiarano una folder, cioè quasi nulla.
|
|
98
|
+
*/
|
|
99
|
+
export function purgeTargets(ids, tasksDir, projectRoot) {
|
|
100
|
+
return ids.map((id) => {
|
|
101
|
+
const taskFile = findTaskFile(tasksDir, id);
|
|
102
|
+
const folder = taskFile ? taskFolder(projectRoot, taskFile) : null;
|
|
103
|
+
return {
|
|
104
|
+
id,
|
|
105
|
+
folder,
|
|
106
|
+
survivors: folder ? folderSurvivors(projectRoot, folder).length : 0,
|
|
107
|
+
};
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
/** Conformi al gate (potabili in blocco) e non conformi (da scartare a monte). */
|
|
111
|
+
export function splitTargets(targets) {
|
|
112
|
+
return {
|
|
113
|
+
clean: targets.filter((t) => t.survivors === 0),
|
|
114
|
+
dirty: targets.filter((t) => t.survivors > 0),
|
|
115
|
+
};
|
|
116
|
+
}
|
package/dist/spawn.js
CHANGED
|
@@ -204,28 +204,26 @@ export function spawnTerminal(cwd, title) {
|
|
|
204
204
|
}
|
|
205
205
|
// Comando claude (override per ambienti dove non è su PATH; loom-deck → NPM).
|
|
206
206
|
export const CLAUDE_CMD = process.env.LOOM_DECK_CLAUDE_CMD ?? 'claude';
|
|
207
|
-
// T30:
|
|
208
|
-
//
|
|
207
|
+
// T30: invocazione di una skill del plugin da CC HEADLESS (`-p`), con
|
|
208
|
+
// `--session-id` pinnato. È la TERZA strada per eseguire uno script del plugin
|
|
209
|
+
// da un consumer che non ha `CLAUDE_PLUGIN_ROOT`: il path version-pinned della
|
|
210
|
+
// cache lo risolve il processo Claude, e il deck non lo nomina mai.
|
|
209
211
|
// - headless (`-p`), non una tab Ptyxis interattiva → il deck osserva l'esito;
|
|
210
|
-
// - `yolo` FORZATO: create-task è interattiva di default (AskUserQuestion) e in
|
|
211
|
-
// `-p` non può ricevere risposte → si impianterebbe. yolo = zero domande.
|
|
212
212
|
// - `--output-format stream-json` (richiede `--verbose`): l'ultima riga è
|
|
213
|
-
// `{type:"result", is_error}`, segnale di completamento robusto (> exit code)
|
|
214
|
-
// - detached (own process-group) → il
|
|
215
|
-
// completa commit
|
|
216
|
-
// Il prompt viaggia come singolo argv (no shell) → nessuna injection dal testo
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
sessionId,
|
|
225
|
-
`/loom-works:create-task yolo ${text}`,
|
|
226
|
-
], { cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
213
|
+
// `{type:"result", is_error}`, segnale di completamento robusto (> exit code);
|
|
214
|
+
// - detached (own process-group) → il lavoro sopravvive alla chiusura del deck
|
|
215
|
+
// e completa i commit da sé; stdout in pipe SOLO per leggere il result event.
|
|
216
|
+
// Il prompt viaggia come singolo argv (no shell) → nessuna injection dal testo.
|
|
217
|
+
//
|
|
218
|
+
// `detail` è il testo finale del result event, troncato dal chiamante: senza,
|
|
219
|
+
// un fallimento arriva come booleano nudo e la riga di stato può solo dire "non
|
|
220
|
+
// ha funzionato" — che su un'operazione distruttiva non basta a sapere se
|
|
221
|
+
// qualcosa è stato rimosso.
|
|
222
|
+
function spawnSkill(prompt, cwd, sessionId, onResult) {
|
|
223
|
+
const child = spawnOut(CLAUDE_CMD, ['-p', '--output-format', 'stream-json', '--verbose', '--session-id', sessionId, prompt], { cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
227
224
|
let buf = '';
|
|
228
225
|
let isError = null;
|
|
226
|
+
let detail = '';
|
|
229
227
|
child.stdout?.on('data', (chunk) => {
|
|
230
228
|
buf += chunk.toString();
|
|
231
229
|
let nl;
|
|
@@ -236,8 +234,11 @@ export function spawnCreateTask(text, cwd, sessionId, onResult) {
|
|
|
236
234
|
continue;
|
|
237
235
|
try {
|
|
238
236
|
const obj = JSON.parse(line);
|
|
239
|
-
if (obj.type === 'result')
|
|
237
|
+
if (obj.type === 'result') {
|
|
240
238
|
isError = obj.is_error ?? false;
|
|
239
|
+
if (typeof obj.result === 'string')
|
|
240
|
+
detail = obj.result;
|
|
241
|
+
}
|
|
241
242
|
}
|
|
242
243
|
catch {
|
|
243
244
|
// riga parziale / non-json → skip
|
|
@@ -246,12 +247,54 @@ export function spawnCreateTask(text, cwd, sessionId, onResult) {
|
|
|
246
247
|
});
|
|
247
248
|
// Drena stderr per non riempire il buffer pipe (deadlock del figlio).
|
|
248
249
|
child.stderr?.on('data', () => { });
|
|
249
|
-
child.on('error', () => onResult(false));
|
|
250
|
+
child.on('error', () => onResult(false, `'${CLAUDE_CMD}' non lanciabile`));
|
|
250
251
|
child.on('close', (code) => {
|
|
251
|
-
onResult(isError === null ? code === 0 : !isError);
|
|
252
|
+
onResult(isError === null ? code === 0 : !isError, detail);
|
|
252
253
|
});
|
|
253
254
|
return child;
|
|
254
255
|
}
|
|
256
|
+
// T30: create-task inline. `yolo` FORZATO: create-task è interattiva di default
|
|
257
|
+
// (AskUserQuestion) e in `-p` non c'è nessuno che risponda → la sessione
|
|
258
|
+
// resterebbe appesa. yolo = zero domande.
|
|
259
|
+
export function spawnCreateTask(text, cwd, sessionId, onResult) {
|
|
260
|
+
return spawnSkill(`/loom-works:create-task yolo ${text}`, cwd, sessionId, (ok) => onResult(ok));
|
|
261
|
+
}
|
|
262
|
+
// T112 — potatura ordinata a `loom-works:clean-tasks`. Modo A della skill (ID
|
|
263
|
+
// espliciti), MAI il modo B (`--days`): passarle una soglia d'età le farebbe
|
|
264
|
+
// ricalcolare il bersaglio con la propria regola, che è già la copia divergibile
|
|
265
|
+
// di quella del deck (D6 preflight). Il bersaglio lo decide il deck e la skill
|
|
266
|
+
// lo esegue.
|
|
267
|
+
//
|
|
268
|
+
// `yolo` come in create-task, e per la stessa ragione con una posta più alta:
|
|
269
|
+
// `clean-tasks` chiede conferma quando fra i target ci sono task non-Done, e in
|
|
270
|
+
// headless quella domanda non ha nessuno che risponda. Il token dichiara che la
|
|
271
|
+
// conferma è già stata raccolta dal chiamante — qui, dal modale del deck.
|
|
272
|
+
//
|
|
273
|
+
// `--ignored-files` viaggia solo quando la scelta keep/purge è stata fatta
|
|
274
|
+
// davvero (una singola task con superstiti): passarlo sempre significherebbe
|
|
275
|
+
// deciderlo di default su un'operazione che perde file.
|
|
276
|
+
export function cleanTasksPrompt(ids, ignored) {
|
|
277
|
+
const flag = ignored ? ` --ignored-files ${ignored}` : '';
|
|
278
|
+
return `/loom-works:clean-tasks yolo${flag} ${ids.join(' ')}`;
|
|
279
|
+
}
|
|
280
|
+
export function cleanTasksArgs(ids, sessionId, ignored) {
|
|
281
|
+
return [
|
|
282
|
+
'-p',
|
|
283
|
+
'--output-format',
|
|
284
|
+
'stream-json',
|
|
285
|
+
'--verbose',
|
|
286
|
+
'--session-id',
|
|
287
|
+
sessionId,
|
|
288
|
+
cleanTasksPrompt(ids, ignored),
|
|
289
|
+
];
|
|
290
|
+
}
|
|
291
|
+
/** Bersaglio vuoto → nessuno spawn e `null`: un `clean-tasks` senza SPEC esce 1
|
|
292
|
+
* e il deck riporterebbe un fallimento per un'operazione mai chiesta. */
|
|
293
|
+
export function spawnCleanTasks(ids, cwd, sessionId, ignored, onResult) {
|
|
294
|
+
if (ids.length === 0)
|
|
295
|
+
return null;
|
|
296
|
+
return spawnSkill(cleanTasksPrompt(ids, ignored), cwd, sessionId, onResult);
|
|
297
|
+
}
|
|
255
298
|
// T41 — Commit dell'edit. `git commit -- <paths>` committa lo stato working-tree
|
|
256
299
|
// SOLO di quei path, ignorando l'index: se l'utente ha altro in stage (o altri
|
|
257
300
|
// file sporchi) non finisce dentro per errore. NON detached: è un'operazione
|
package/dist/ui/modals.js
CHANGED
|
@@ -3,9 +3,9 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
3
3
|
// schermate sostitutive costano righe al budget d'altezza, quindi ognuno ha un
|
|
4
4
|
// costo dichiarato in `viewport.ts`.
|
|
5
5
|
import { Box, Text } from 'ink';
|
|
6
|
-
import { caretWindow, sanitize } from '../width.js';
|
|
6
|
+
import { caretWindow, cut, cutParts, sanitize } from '../width.js';
|
|
7
7
|
import { cpLen } from '../layout.js';
|
|
8
|
-
import { CARET, CARET_OFF } from '../glyphs.js';
|
|
8
|
+
import { CARET, CARET_OFF, WARN } from '../glyphs.js';
|
|
9
9
|
import { EDIT_PRI, EDIT_PROG } from '../model.js';
|
|
10
10
|
import { PRI_ENTRIES, PROG_ENTRIES } from '../view.js';
|
|
11
11
|
import { progressText, PRI_GLYPH, PRI_LABEL, PROG_GLYPH } from '../task-edit.js';
|
|
@@ -29,6 +29,57 @@ export function FilterModal({ view, cursor }) {
|
|
|
29
29
|
return (_jsxs(Text, { inverse: here, color: on ? 'green' : 'gray', dimColor: !on, children: [' ', "[", on ? 'x' : ' ', "] ", sanitize(e.glyph)] }, e.name));
|
|
30
30
|
})] }, row.label)))] }));
|
|
31
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* T112 — conferma di ELIMINAZIONE, in flusso come gli altri modali.
|
|
34
|
+
*
|
|
35
|
+
* Quattro righe, e nessuna è decorativa:
|
|
36
|
+
*
|
|
37
|
+
* ① il bersaglio per TAGLIA — «7 task» o «T31». Lo stesso tasto pota una task
|
|
38
|
+
* sulla vista principale e N sulla vista `archiviabili`, e ciò che discrimina
|
|
39
|
+
* è quale vista è attiva, cioè un'informazione che sta in header e non sotto
|
|
40
|
+
* le dita di chi preme: la conferma è l'unico punto in cui quella differenza
|
|
41
|
+
* torna visibile.
|
|
42
|
+
* ② gli ID, troncati con `+N`: una conferma che non dice COSA sparisce non è
|
|
43
|
+
* informata.
|
|
44
|
+
* ③ l'EFFETTO sul disco, non il nome della vista. «Archiviare 7 task?»
|
|
45
|
+
* prometterebbe uno spostamento in un archivio che non esiste — `clean-tasks`
|
|
46
|
+
* rimuove task file, folder e riga, e il recupero da git history è il rimedio,
|
|
47
|
+
* non l'operazione.
|
|
48
|
+
* ④ ciò che il bersaglio dichiarato NON copre: le scartate del bulk (promettere
|
|
49
|
+
* 7 e farne 5 è una bugia che non compare da nessuna parte, perché le 2
|
|
50
|
+
* restano in lista come prima) oppure, sulla singola task sporca, la scelta
|
|
51
|
+
* keep/purge che accompagna l'ordine.
|
|
52
|
+
*/
|
|
53
|
+
export function PurgeModal({ draft, columns }) {
|
|
54
|
+
// Due cornici annidate da scalare — root (bordo 2 + paddingX 2) e modale
|
|
55
|
+
// (bordo 2 + paddingX 2): il taglio è del chiamante, o la riga esce dal box e
|
|
56
|
+
// il frame va a capo (invariante ③ di width.ts).
|
|
57
|
+
const width = Math.max(8, columns - 8);
|
|
58
|
+
const target = draft.bulk ? `${draft.ids.length} task` : (draft.ids[0] ?? '—');
|
|
59
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "red", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { color: "red", children: ["CANC \u203A eliminare ", target, "?"] }), _jsx(Text, { wrap: "truncate-end", children: cut(idList(draft.ids), width) }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: cut('via clean-tasks: task file + folder + riga in tasks.md · un commit per task · nessun push', width) }), draft.ignored ? (_jsx(IgnoredChoice, { survivors: draft.survivors, mode: draft.ignored, width: width })) : (_jsx(Text, { color: draft.skipped.length > 0 ? 'yellow' : undefined, dimColor: draft.skipped.length === 0, wrap: "truncate-end", children: cut(draft.skipped.length > 0
|
|
60
|
+
? `${WARN} ${draft.skipped.length} scartate (folder con file non tracciati): ${idList(draft.skipped)}`
|
|
61
|
+
: 'nessuna esclusa · ⏎ conferma · esc annulla', width) }))] }));
|
|
62
|
+
}
|
|
63
|
+
/** Gli ID del bersaglio, troncati con `+N` quando non ci stanno tutti. Il
|
|
64
|
+
* troncamento lo fa questa funzione e non `cut`, perché tagliare a metà un id
|
|
65
|
+
* (`T1…`) direbbe una cosa falsa invece di dire che manca. */
|
|
66
|
+
export function idList(ids, max = 12) {
|
|
67
|
+
if (ids.length <= max)
|
|
68
|
+
return ids.join(' ');
|
|
69
|
+
return `${ids.slice(0, max).join(' ')} +${ids.length - max}`;
|
|
70
|
+
}
|
|
71
|
+
/** D3 — la sola task con superstiti apre una conferma a TRE uscite: `⏎` su
|
|
72
|
+
* KEEP, `⏎` su PURGE, `esc`. La scelta viaggia come `--ignored-files` nel
|
|
73
|
+
* comando passato alla skill, ed è rara e distruttiva in modo diverso dal
|
|
74
|
+
* purge normale: nasconderla dentro un binario `⏎`/`esc` la mascherebbe. */
|
|
75
|
+
function IgnoredChoice({ survivors, mode, width, }) {
|
|
76
|
+
const segs = ['[ keep ]', '[ purge ]'];
|
|
77
|
+
const parts = [`${WARN} ${survivors} file non tracciati · `, segs[0], ' ', segs[1], ' · ←→'];
|
|
78
|
+
// `priority` sulla voce SCELTA: è l'unica informazione che la riga esiste per
|
|
79
|
+
// dare, e su un terminale stretto deve restare a schermo per prima.
|
|
80
|
+
const shown = cutParts(parts, width, mode === 'keep' ? 1 : 3);
|
|
81
|
+
return (_jsx(Text, { wrap: "truncate-end", children: shown.map((s, i) => s ? (_jsx(Text, { color: "yellow", inverse: (i === 1 && mode === 'keep') || (i === 3 && mode === 'purge'), children: s }, i)) : null) }));
|
|
82
|
+
}
|
|
32
83
|
/**
|
|
33
84
|
* Campo di testo del modale edit: finestra ancorata al caret + cursore inverso
|
|
34
85
|
* nella posizione REALE.
|
package/dist/ui/panes.js
CHANGED
|
@@ -49,7 +49,7 @@ export function TasksHeader({ counts, active, above, below, focused, columns, })
|
|
|
49
49
|
const shown = cutParts(segments.map((s) => s.text), paneTextWidth(columns), segments.findIndex((s) => s.active));
|
|
50
50
|
return (_jsx(Text, { bold: true, color: focused ? 'cyan' : undefined, wrap: "truncate-end", children: segments.map((seg, i) => shown[i] ? (_jsx(Text, { color: seg.color, dimColor: seg.dim, inverse: seg.active, children: shown[i] }, i)) : null) }));
|
|
51
51
|
}
|
|
52
|
-
export function TasksPane({ tasks, counts, activeView, paneCount, view, selected, spotCount, allCount, childCount, focused, loadError, windowStart, above, below, columns, }) {
|
|
52
|
+
export function TasksPane({ tasks, counts, activeView, paneCount, view, selected, spotCount, allCount, childCount, focused, loadError, windowStart, above, below, columns, dirty, }) {
|
|
53
53
|
const allSelected = selected === ROW_ALL;
|
|
54
54
|
const spotSelected = selected === ROW_SPOT;
|
|
55
55
|
return (_jsxs(Box, { flexDirection: "column", width: "50%", marginRight: 1, borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsx(TasksHeader, { counts: counts, active: activeView, above: above, below: below, focused: focused, columns: columns }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: cut(sanitize(`sort: ${describeSort(view.sort)}` +
|
|
@@ -79,7 +79,7 @@ export function TasksPane({ tasks, counts, activeView, paneCount, view, selected
|
|
|
79
79
|
// quindi scrive sopra il bordo. Le parti fisse si misurano con
|
|
80
80
|
// `termWidth`: `task.id` è `T9` o `T52`, i due glifi valgono 2 ciascuno.
|
|
81
81
|
const head = `${CARET_OFF}${task.id} ${sanitize(task.pri)} ${displayProg(task.prog)} `;
|
|
82
|
-
const tail = n > 0 ? ` (${n})` : ''
|
|
82
|
+
const tail = `${n > 0 ? ` (${n})` : ''}${dirty.has(task.id) ? ` ${WARN}` : ''}`;
|
|
83
83
|
const desc = cut(task.desc, Math.max(4, paneTextWidth(columns) - termWidth(head) - termWidth(tail)));
|
|
84
84
|
return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: !sel && isDone(task.prog), wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, task.id, " ", sanitize(task.pri), " ", displayProg(task.prog), " ", desc, tail] }, task.id));
|
|
85
85
|
}))] }));
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Versione del pacchetto, letta dal `package.json` vero invece che cablata in
|
|
2
|
+
// un `const`: una costante duplicata dal manifest è un calco — al primo bump
|
|
3
|
+
// dimenticato il deck mostrerebbe con sicurezza un numero falso, ed è
|
|
4
|
+
// esattamente il caso in cui serve (capire quale build sto guardando).
|
|
5
|
+
//
|
|
6
|
+
// Il path relativo `../package.json` vale per entrambe le forme in cui questo
|
|
7
|
+
// modulo gira: `src/version.ts` sotto `tsx` e `dist/version.js` dopo il build —
|
|
8
|
+
// il manifest sta un livello sopra in tutti e due i casi.
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
function readVersion() {
|
|
11
|
+
try {
|
|
12
|
+
const raw = readFileSync(new URL('../package.json', import.meta.url), 'utf8');
|
|
13
|
+
const parsed = JSON.parse(raw);
|
|
14
|
+
return typeof parsed.version === 'string' ? parsed.version : '?';
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
// Installazione monca o manifest illeggibile: la versione è cosmetica,
|
|
18
|
+
// non vale far cadere il deck per averla persa.
|
|
19
|
+
return '?';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export const VERSION = readVersion();
|
package/dist/viewport.js
CHANGED
|
@@ -45,6 +45,13 @@ export const MODAL_HEIGHT = {
|
|
|
45
45
|
sort: 5, // marginTop + 2 bordi + titolo + 1 riga catena
|
|
46
46
|
filter: 6, // marginTop + 2 bordi + titolo + 2 righe (pri, stato)
|
|
47
47
|
edit: 9, // marginTop + 2 bordi + titolo + 4 campi (pri, stato, prog, titolo) + riga anteprima
|
|
48
|
+
// T112 — conferma di eliminazione: marginTop + 2 bordi + 4 righe di contenuto
|
|
49
|
+
// (titolo, elenco ID, effetto sul disco, riga di stato del bersaglio).
|
|
50
|
+
// Le quattro righe sono FISSE, non condizionali: la quarta ospita tanto gli
|
|
51
|
+
// scarti del bulk quanto la scelta keep/purge della singola task sporca, e due
|
|
52
|
+
// condizionali mutuamente esclusivi obbligherebbero il budget a sapere QUALE
|
|
53
|
+
// dei due è a schermo (stessa ragione della riga nota in DETAIL_CHROME, T111).
|
|
54
|
+
purge: 7,
|
|
48
55
|
// T52 — search e reader sono gli unici modali NON in flusso: sostituiscono i
|
|
49
56
|
// due pane invece di spingerli giù (una lista di occorrenze non entra in un
|
|
50
57
|
// box sopra il deck). Costo 0 nel budget dei pane perché quel budget non
|
package/package.json
CHANGED