@lamemind/loom-deck 0.75.0 → 0.76.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 CHANGED
@@ -419,7 +419,7 @@ npm test # node:test sul core vista (src/view.ts), senza Ink né termina
419
419
 
420
420
  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
421
 
422
- Il deck cerca `tasks.md` in `$PWD/${LOOM_DECK_DOCS_ROOT:-docs}/tasks.md`. Progetti con docs-root non-standard esportano la variabile, es. `LOOM_DECK_DOCS_ROOT=runtime`.
422
+ 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
423
 
424
424
  La lista si **auto-aggiorna** quando `tasks.md` cambia sotto (poll su `mtime`, ~1.5s):
425
425
  crei/checkpoint una task da un'altra sessione → il deck la riflette senza riavvio.
package/dist/actions.js CHANGED
@@ -32,6 +32,7 @@ import { inboxPrompt, inboxWords } from './inbox.js';
32
32
  import { docTargetKind, docWords } from './doc-tree.js';
33
33
  import { wrapWords } from './wrap-scan.js';
34
34
  import { enterSession, focusSession } from './focus-session.js';
35
+ import { refuse } from './platform.js';
35
36
  /**
36
37
  * T181 — l'attesa massima di un passo del restore prima di passare al
37
38
  * successivo. Un `deck-run` esce appena `ptyxis --tab` ha consegnato la tab
@@ -156,6 +157,10 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
156
157
  // della lista (^K/^P/^R), che passano di qui con `spawnNote` sul proprio
157
158
  // default '' (P1 preflight) — nascono quindi titolati anche loro.
158
159
  function spawnForTask(id, modelKind, spawnNote = '', prompt = '', priority = false) {
160
+ // T175 — prima del sidecar: un binding scritto per una tab che non si apre
161
+ // sarebbe una conversazione figlia della task che non esiste.
162
+ if (refuse('spawn', '⏎', setNote))
163
+ return;
159
164
  const sid = randomUUID();
160
165
  const note = spawnNote;
161
166
  appendTaskBinding(cwd, sid, id);
@@ -185,6 +190,8 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
185
190
  // async: senza handler diventa uncaughtException e ucciderebbe il deck, che
186
191
  // invece deve restare vivo.
187
192
  function spawnTaskSession(kind, keyLabel) {
193
+ if (refuse('spawn', keyLabel, setNote))
194
+ return;
188
195
  const task = selectedTaskOr(keyLabel, 'spawnare');
189
196
  if (!task)
190
197
  return;
@@ -233,6 +240,8 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
233
240
  * tab); senza nome, il prefisso del `sessionId` come nelle altre note.
234
241
  */
235
242
  function openSession(sessionId, modelKind) {
243
+ if (refuse('spawn', '⏎', setNote, 'resume'))
244
+ return;
236
245
  enterSession(sessionId, {
237
246
  isLive: (id) => model.live.has(id),
238
247
  nameOf: (id) => model.live.get(id)?.name || id.slice(0, 8),
@@ -266,6 +275,8 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
266
275
  * chiudendo le tab.
267
276
  */
268
277
  function restoreSessions() {
278
+ if (refuse('spawn', '^T', setNote, 'restore delle conversazioni'))
279
+ return;
269
280
  const list = model.restorable;
270
281
  if (list.length === 0) {
271
282
  setNote(model.restoring ? '^T → restore già in corso' : '^T → nessuna conversazione da riaprire');
@@ -368,6 +379,8 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
368
379
  // ha per oggetto una conversazione, e senza focus lì non ce n'è una
369
380
  // selezionata su cui agire.
370
381
  function forkSession() {
382
+ if (refuse('spawn', 'f', setNote, 'fork'))
383
+ return;
371
384
  if (model.focus !== 'sessions') {
372
385
  setNote('f → fork: seleziona una sessione (→ per il pane)');
373
386
  return;
@@ -463,6 +476,12 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
463
476
  setNote('CANC → eliminare: seleziona una conversazione (→ per il pane)');
464
477
  return null;
465
478
  }
479
+ // T175 — il rifiuto della VIVA qui sotto legge `model.live`, e dove la
480
+ // liveness manca la mappa è sempre vuota: il rifiuto non scatterebbe mai, e
481
+ // `CANC` toglierebbe il transcript a una conversazione aperta. Spento finché
482
+ // la liveness non c'è, non degradato a «nessuna è viva».
483
+ if (refuse('liveSessions', 'CANC', setNote, 'eliminazione delle conversazioni'))
484
+ return null;
466
485
  const sid = model.selSessionId;
467
486
  if (!sid) {
468
487
  setNote('CANC → nessuna conversazione da eliminare');
@@ -488,6 +507,10 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
488
507
  * appena eliminata apposta.
489
508
  */
490
509
  function dropSession(draft) {
510
+ // La stessa guardia della bozza: chi arrivasse qui senza passare da lei non
511
+ // deve trovare un `rmSync` sguarnito.
512
+ if (refuse('liveSessions', 'CANC', setNote, 'eliminazione delle conversazioni'))
513
+ return;
491
514
  const short = draft.sessionId.slice(0, 8);
492
515
  const landing = neighborId(model.sessionRows, draft.sessionId);
493
516
  try {
@@ -506,6 +529,8 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
506
529
  }
507
530
  /** `t` — terminale a project root, con un titolo che il matcher di compass riconosce. */
508
531
  function openTerminal() {
532
+ if (refuse('spawn', 't', setNote, 'terminale'))
533
+ return;
509
534
  const title = model.identity ? `🖥️ ${model.identity.name} [term]` : null;
510
535
  const child = spawnTerminal(cwd, title);
511
536
  child.on('error', () => setNote('⚠ t → ptyxis non lanciabile'));
@@ -532,6 +557,8 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
532
557
  * due superfici mostrano nasce da un valore solo.
533
558
  */
534
559
  function drainInbox(file, prompt) {
560
+ if (refuse('spawn', '⏎', setNote))
561
+ return;
535
562
  const sid = randomUUID();
536
563
  const d = spawnDefaults('drain', { file: inboxWords(file) });
537
564
  const title = d.title ?? '';
@@ -608,6 +635,8 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
608
635
  function rebalanceDoc(row, prompt) {
609
636
  if (!prompt)
610
637
  return;
638
+ if (refuse('spawn', '⏎', setNote))
639
+ return;
611
640
  // Il bersaglio inbox prende la terna della riga `drain`, non quella di
612
641
  // `rebalance`: il prompt da solo non basterebbe — modello e titolo della tab
613
642
  // verrebbero comunque dalla riga sbagliata, e la conversazione si
@@ -642,6 +671,8 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
642
671
  * non progettare niente.
643
672
  */
644
673
  function unwrapPath(path) {
674
+ if (refuse('spawn', '⏎', setNote))
675
+ return;
645
676
  const sid = randomUUID();
646
677
  const d = spawnDefaults('unwrap', { path: wrapWords(path) });
647
678
  const title = d.title ?? '';
@@ -655,6 +686,8 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
655
686
  * modello è quello del selettore proprio della nuda (`model.bareModel`),
656
687
  * passato SEMPRE nell'argv anche sul default. */
657
688
  function openClaude() {
689
+ if (refuse('spawn', 'c', setNote, 'sessione claude'))
690
+ return;
658
691
  const spawned = spawnClaudeEmpty(cwd, model.bareModel);
659
692
  spawned.child.on('error', () => setNote(`⚠ c → spawn claude fallito (${DECK_RUN})`));
660
693
  noteSpawn(spawned);
@@ -672,6 +705,8 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
672
705
  }
673
706
  /** `1`-`9` — le voci launch del progetto, per indice base-1. */
674
707
  function runLaunchAt(input) {
708
+ if (refuse('launch', input, setNote))
709
+ return;
675
710
  const entry = model.launch[Number(input) - 1];
676
711
  if (!entry) {
677
712
  setNote(`${input} → nessuna voce launch (${model.launch.length} configurate)`);
@@ -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 });
@@ -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'], { cwd: projectRoot });
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, e `docsRoot` arriva ancora dalla sola env
116
- // `LOOM_DECK_DOCS_ROOT` (vedi `tasks.ts`). La catena da percorrere si ferma
117
- // qui: nessun passaggio da `lib-config.sh`/`reg_pull`/dconf, perché il solo
118
- // consumer è il deck e il file ce l'ha sotto mano. Il precedente esatto è
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 `deck-run` via jq 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;
@@ -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
- const projectName = cwd.split('/').pop() || cwd;
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. La docs-root arriva dalla sola env, come `resolveTasksPath` — il
218
- // limite si eredita invece di aprire qui una cascata che nessun percorso di
219
- // avvio reale userebbe (D1 preflight).
220
- const docsRoot = useMemo(() => docsRootName(), []);
221
- const inbox = useInboxScan(cwd, docsRoot);
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/doc-tree.js CHANGED
@@ -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 {
package/dist/frame.js CHANGED
@@ -26,6 +26,7 @@ import { META_ROWS } from './model.js';
26
26
  import { TASK_MODE_LEGEND } from './input-modes.js';
27
27
  import { rowIndexOf } from './session-list.js';
28
28
  import { VERSION } from './version.js';
29
+ import { OFF_MARK } from './platform.js';
29
30
  // La surface built-in del cappello, in testa alla riga launch. Non sta fra i
30
31
  // tasti perché ha la stessa natura delle voci `launch` — fire-once, cwd =
31
32
  // project root, nessuno stato. Per il terminale compass usa 🖥️, che nel frame
@@ -230,6 +231,8 @@ export const INDICATOR_SEP = ' ';
230
231
  * dell'ultimo riuscito, che resta vero e ancora apribile.
231
232
  */
232
233
  export function inboxButton(state) {
234
+ if (state.available === false)
235
+ return sanitize(`[ Doc 📄 ${OFF_MARK} ]`);
233
236
  const numbers = state.scanned
234
237
  ? `${state.counts.nozioni}/${state.counts.derivazione}/${state.counts.sweep}`
235
238
  : STATUS_MISSING;
@@ -261,6 +264,8 @@ export function inboxButton(state) {
261
264
  * l'esito dell'ultimo riuscito resta vero e ancora apribile.
262
265
  */
263
266
  export function wrapIndicator(state) {
267
+ if (state.available === false)
268
+ return `wrap ${OFF_MARK}`;
264
269
  const body = state.scanning
265
270
  ? 'scan…'
266
271
  : state.mtime === null
@@ -289,6 +294,8 @@ export function wrapIndicator(state) {
289
294
  * nomina i membri.
290
295
  */
291
296
  export function gitlinkIndicator(state) {
297
+ if (state.available === false)
298
+ return `gitlink ${OFF_MARK}`;
292
299
  const body = !state.scanned
293
300
  ? STATUS_MISSING
294
301
  : !state.ok
@@ -0,0 +1,78 @@
1
+ // T175 — il bash di Git for Windows: dove sta, e come lo riceve un `claude`
2
+ // lanciato dal deck.
3
+ //
4
+ // Serve già qui e non solo a T178 (gli script `.sh` del plugin), per il verdetto
5
+ // 9 di T174: un `claude` che eredita il `PATH` di Git Bash esegue gli hook del
6
+ // plugin in PowerShell — `cat` diventa `Get-Content`, lo script `.sh` una
7
+ // stringa stampata — e la task non arriva in contesto. Con
8
+ // `CLAUDE_CODE_GIT_BASH_PATH` nell'env, la variabile con cui Claude Code sceglie
9
+ // il proprio bash, gli hook tornano in bash. Un deck avviato da Git Bash non la
10
+ // porta: la risolve lui.
11
+ //
12
+ // Modulo PURO nel cuore (`resolveGitBash` riceve il filesystem come funzione),
13
+ // con un involucro che legge quello vero. Su Linux non fa niente.
14
+ import { existsSync } from 'node:fs';
15
+ import { win32 } from 'node:path';
16
+ export const GIT_BASH_ENV = 'CLAUDE_CODE_GIT_BASH_PATH';
17
+ /**
18
+ * Quanti livelli sopra la cartella di `git.exe` si cerca `bin\bash.exe`. Tre
19
+ * coprono le tre posizioni di `git.exe` in Git for Windows: `Git\cmd\` (quella
20
+ * del `PATH` di Windows), `Git\bin\` e `Git\mingw64\bin\` (la prima del `PATH`
21
+ * di Git Bash, dove accanto non c'è nessun bash).
22
+ */
23
+ const MAX_UP = 3;
24
+ /**
25
+ * Il bash di Git, o `null`.
26
+ *
27
+ * Cascata (P6 preflight): la variabile, se impostata — è la scelta esplicita di
28
+ * chi ha configurato Claude Code; poi ogni `git.exe` del `PATH`, nell'ordine,
29
+ * risalendo da lì fino a trovare `bin\bash.exe`. Mai `bash` dal `PATH`: Git
30
+ * aggiunge solo `Git\cmd`, e `C:\Windows\System32\bash.exe` è il lanciatore di
31
+ * WSL. Mai un path cablato: Git può stare fuori da `C:\Program Files\Git`
32
+ * (sulla macchina di T174 sta in `C:\Dev\Tools\Git`).
33
+ */
34
+ export function resolveGitBash(env, exists) {
35
+ const explicit = env[GIT_BASH_ENV];
36
+ if (explicit)
37
+ return explicit;
38
+ const path = env.PATH ?? env.Path ?? '';
39
+ for (const dir of path.split(win32.delimiter)) {
40
+ if (!dir)
41
+ continue;
42
+ const git = win32.join(dir, 'git.exe');
43
+ if (!exists(git))
44
+ continue;
45
+ let at = win32.dirname(git);
46
+ for (let i = 0; i < MAX_UP; i++) {
47
+ const bash = win32.join(at, 'bin', 'bash.exe');
48
+ if (exists(bash))
49
+ return bash;
50
+ const up = win32.dirname(at);
51
+ if (up === at)
52
+ break;
53
+ at = up;
54
+ }
55
+ }
56
+ return null;
57
+ }
58
+ let cached;
59
+ /** Il bash di Git di questa macchina, risolto una volta per vita del deck:
60
+ * un'installazione di Git non si sposta mentre il deck è aperto. */
61
+ export function gitBash() {
62
+ if (cached === undefined)
63
+ cached = resolveGitBash(process.env, existsSync);
64
+ return cached;
65
+ }
66
+ /**
67
+ * L'env di un `claude` lanciato dal deck.
68
+ *
69
+ * Su win32, quello del deck più `CLAUDE_CODE_GIT_BASH_PATH` quando non c'è già e
70
+ * il bash si trova; senza bash il figlio parte con l'env del deck, come prima.
71
+ * Altrove `undefined`, cioè l'env ereditato di `spawn`: nessun cambio su Linux.
72
+ */
73
+ export function claudeEnv(platform = process.platform, env = process.env, bash = gitBash) {
74
+ if (platform !== 'win32' || env[GIT_BASH_ENV])
75
+ return undefined;
76
+ const found = bash();
77
+ return found ? { ...env, [GIT_BASH_ENV]: found } : undefined;
78
+ }
package/dist/gitlink.js CHANGED
@@ -187,6 +187,7 @@ async function run(projectRoot, args) {
187
187
  const { stdout } = await execFileAsync(script, args, {
188
188
  cwd: projectRoot,
189
189
  maxBuffer: 1024 * 1024,
190
+ windowsHide: true,
190
191
  });
191
192
  return { members: parseGitlinkTsv(stdout), ok: true };
192
193
  }