Warsztat modów Claude Code: narzędzie modelu `szukaj` (JSDoc i sygnatura zdarzenia, metody `$.…` albo typu z lokalnego claude-code/index.d.ts po nazwie, indeks…

Warsztat modów: dwie rzeczy dla sesji, która pisze mod.
(a) Narzędzie mcp__kanon-warsztat__szukaj { nazwa } — JSDoc i sygnatura z lokalnego claude-code/index.d.ts po nazwie zdarzenia (tool.call), metody ($.fs.list albo fs.list), rzeczownika ($.fs → lista składników) albo typu (SessionUsage). Zamiast grepa po 16 tys. linii d.ts: wynik ≤ 2048 znaków, wiele trafień → lista nazw z numerami linii.
(b) /warsztat [katalog modu] — dla katalogu modu kolejno claude plugin validate . i claude plugin test . na CLI ~/.local/bin/claude i na najnowszej binarce desktopu oraz tsc --noEmit -p .; panel z tabelą binarka × krok → OK / FAIL / TIMEOUT / brak, czas i liczba testów; przyciski Powtórz i Kopiuj błędy.
Karta: ~/Developer/claude-kanon/claude-code/mods/PLAN-mody-2026-10-16-z-logow.md §6.2. Zero modelu, zero sieci, zero zapisu plików, zero tool.check; jedyny stan — $.state (indeks i wynik ostatniego biegu). $.process.run tylko z zamkniętej listy argv (niżej), bez shella, zawsze z timeoutMs, cwd wyłącznie w ~/Developer/claude-mods, ~/Developer/claude-mods-stabilne albo pod $.session.root() (reguła D5 z README repo, rozszerzona o -stabilne 2026-10-17 — commit repo 5ca8558; patrz Odstępstwa).
<$.plugin.root>/.claude-plugin/types/claude-code/index.d.ts — silnik kładzie go przy pierwszym załadowaniu modu (plugin-authoring: „holds the same for the editor”). To jest źródło w normalnej pracy.--plugin-dir przed położeniem typów): najnowszy wg mtime spośród ~/.claude/dev-mods/<sid>/<mod>/.claude-plugin/types/claude-code/index.d.ts i ~/Developer/claude-mods/<mod>/.claude-plugin/types/claude-code/index.d.ts.isError i opisem (bez wyjątku); następne wywołanie szuka od nowa.Indeks (zbudujIndeks) powstaje przy pierwszym wywołaniu szukaj z jednego $.fs.read (cały plik w pamięci modułu), raz na życie modułu; równoległe wywołania dołączają do trwającego ładowania. Wpisy idą do $.state kanon-warsztat.indeks razem z wersją parsera (WERSJA_INDEKSU), ścieżką, mtime i liczbą linii — po reloadzie modułu (ta sama wersja, ten sam plik, ten sam mtime) tekst jest czytany ponownie, ale nie parsowany; inna wersja parsera (po zmianie regexów) = budowa od nowa. Nagłówek odpowiedzi podaje źródło i linie (tool.call — zdarzenie (EngineEventOf — wejście hooka e; linie 4030–4030 w …/index.d.ts)).
Co indeks obejmuje (regexy na wcięciu, bez parsera TS): deklaracje top-level export type|interface|const|function|class obu modułów (claude-code, claude-code/testing), także „płaskie” (kontynuacja na tym samym wcięciu, np. NextResultFor — domykane po ;/}, kolejna deklaracja albo komentarz kończy wpis); zdarzenia 'a.b': … w blokach EngineEventOf (wejście hooka e), EngineResultOf (wynik hooka), OpEventOf (argument wywołania $), OpValueOf (wartość wywołania $), ChunkOf (porcja strumienia); rzeczowniki $.x i metody $.x.y z CoreEngineInterface. Poza indeksem: blok global {} (h, crypto, performance — nie API $) i pola zwykłych typów (np. rows w UiOpenArgs) — do nich prowadzi wynik dla typu. JSDoc to blok komentarza bezpośrednio nad deklaracją; sygnatura bez komentarzy wewnętrznych, z usuniętym wspólnym wcięciem.
Na prawdziwym d.ts tej sesji (2.1.299, 16 467 linii) skrypt skrypty/sanity-dts.ts (Node 22 --experimental-strip-types, czysta część modułu bez silnika; nie jest testem kitu, bo kit nie ma dysku) daje 2026-10-17: 956 wpisów — 220 zdarzeń, 84 metody, 21 rzeczowników, 631 typów; budowa 8 ms; wpisy w $.state ≈ 99 KB JSON — i sprawdza kotwice: tool.call jako zdarzenie z JSDoc, fs.list jako metodę, $.process ze składnikiem $.process.run, $.fs.stat, SessionUsage ze startedAt, NextResultFor w całości, ToolCall jako listę, każdy wpis do ≥ od i < 1500 linii.
node --experimental-strip-types --no-warnings skrypty/sanity-dts.ts [ścieżka d.ts] # --pokaz drukuje 4 odpowiedzi
szukaj { nazwa } (narzędzie modelu; zarejestrowane w session.start, dostępne przez ToolSearch jak każde narzędzie modu):tool.call, fs.list = $.fs.list, $.process, SessionUsage) → nagłówek nazwa — rodzaj (blok; linie a–b w plik), JSDoc, sygnatura (dla rzeczownika zamiast sygnatury Składniki: $.process.run (linia 3574), …), potem Inne wystąpienia: — ten sam klucz w innych blokach. Kolejność przy kilku wystąpieniach: zdarzenie silnika (EngineEventOf) → metoda $ → rzeczownik → wynik hooka → zdarzenia operacji $ (OpEventOf, OpValueOf, ChunkOf) → typ; dlatego fs.list pokazuje metodę $.fs.list, a zdarzenie 'fs.list' z OpEventOf ląduje w „Inne wystąpienia”. Zapytanie z $. i bez daje to samo;sessionusage);ToolCall) → szukaj: N trafień dla „ToolCall” — podaj pełną nazwę: i lista - nazwa (rodzaj, linia N), najwyżej 40 pozycji i … +N więcej;szukaj: brak „x” wśród 956 wpisów … z przykładami nazw (echo zapytania przycięte do 80 znaków); pusta nazwa → podpowiedź (bez isError);… [przycięte do 2048 znaków; …];{ result: tekst } (string). Silnik waliduje result wg schematu narzędzia — dla narzędzia z $.tool.register to string | bloki treści | undefined; obiekt MCP { content, isError } odrzuca (does not match its output shape, próba na żywo 2026-10-17 — v0.1 z commita 747446e tak odpowiadał i narzędzie nie działało). Błąd (brak d.ts, plik nieczytelny, wyjątek) → { deny: tekst }: d.ts ToolCallResult — „the model receives the text as an error result”./warsztat — katalog modu z argumentu (~, ścieżka względna od cwd sesji, bezwzględna) albo cwd sesji. Brama (odmowa = tekst komendy, bez procesu i bez panelu): ścieżka bez ..; leksykalnie w ~/Developer/claude-mods, ~/Developer/claude-mods-stabilne albo pod $.session.root() (root pomijany, gdy jest HOME, przodkiem HOME albo / — sesja zaczęta w ~ nie otwiera całego dysku); jest .claude-plugin/plugin.json; realPath katalogu ($.fs.stat(…, { resolve: true })) też leży w bazach rozwiązanych tak samo — dowiązanie claude-mods/x → /gdzie-indziej dostaje odmowę, a kroki biegną z cwd = ścieżka rozwiązana. Komendę przyjmuje tylko od właściciela: origin composer (prompt) albo bridge (Remote Control); z sdk, harmonogramu, powiadomienia zadania, peera itd. odpowiada odmową, bo claude plugin test wykonuje kod z katalogu. Bieg jest jeden naraz: strażnik ustawiany synchronicznie przed pierwszym await, więc dwa /warsztat albo podwójne Powtórz w tej samej chwili dają jeden bieg i poprzedni bieg jeszcze trwa.$.process.run, cwd = katalog modu, timeoutMs 120 000; po timeoucie bieg idzie dalej):CLI validate, 2. CLI test, 3. desktop <wersja> validate, 4. desktop <wersja> test, 5. tsc. Binarka desktopu = najwyższy semver (liczbowo) spośród ~/Library/Application Support/Claude/claude-code/<wersja>/<hash>/claude.app/Contents/MacOS/claude, które istnieją na dysku — katalog wersji bez binarki jest pomijany. Brak binarki / node / tsc.js → wiersz brak bez uruchamiania.Warsztat — <nazwa modu> (tytuł z (w toku…) w trakcie): wiersz na krok OK|FAIL|TIMEOUT|brak <binarka> <krok> <czas> <szczegóły> (test: 18 pass, 0 fail; validate: Validation passed / Validation failed (kod 1); tsc: bez błędów / N błędów TS (kod 2)), stopka N OK · N FAIL · N timeout · N brak · X s łącznie. Powtórz (hotkey p) uruchamia ten sam katalog od nowa; Kopiuj błędy (k) kopiuje przez $.ui.copy na powierzchni przycisku argv + stdout/stderr kroków FAIL i TIMEOUT; Zamknij (Esc). Schowek odmawia → toast z powodem. Wyjście padniętych kroków żyje w pamięci modułu (nie w $.state, który czyta każdy plugin sesji): do 4000 znaków — 2400 początku, znacznik … [przycięte N znaków] …, 1500 końca; w $.state zostaje jednolinijkowe szczegoly. Po hot-reloadzie modułu Kopiuj błędy mówi wyjście niedostępne — moduł przeładowany po biegu; naciśnij Powtórz.bieg modułu startuje od nowa, a $.state zostaje z wToku: true — session.start (i następna komenda) oznacza taki stan jako przerwany: kroki czeka/biegnie → FAIL przerwano — moduł przeładowany w trakcie biegu, stopka bez „w toku”./warsztat wynik — ta sama tabela tekstem (gdy panel nie ma miejsca: odpowiedź komendy mówi panel czeka (<powód>) — wynik: /warsztat wynik); /warsztat zamknij zamyka panel.tsc --noEmit -p .. Bez shella nie ma PATH, a node_modules/.bin/tsc ma shebang /usr/bin/env node, więc mod woła <node> ~/Developer/stelmach/wishlist/node_modules/typescript/lib/tsc.js --noEmit -p .; node = pierwszy istniejący z ~/.local/bin/node, /opt/homebrew/bin/node, /usr/local/bin/node. Brak node albo tsc.js → tsc: brak — … z powodem.tool.call to RegExp /^mcp__kanon-warsztat__szukaj$/, nie string: unia nazw narzędzi w claude-code-mcp/index.d.ts jest zamknięta i nie zna narzędzia, które mod dopiero rejestruje (string nie przechodzi tsc). W teście $.tool.call z tego samego powodu idzie przez rzutowanie.claude dostają CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 (jak kanon-flota) — validate i test nie potrzebują sieci.plugin.json (karta mówi tylko o cwd) — chroni przed uruchomieniem claude plugin test na dowolnym katalogu.composer/bridge (karta o tym milczy) — jak D6 w kanon-skrzynka.~/Developer/claude-mods-stabilne: zlecenie mówiło ~/Developer/claude-mods*, a D5 w README repo wymieniało tylko claude-mods i $.session.root(). Zostawione jako jawna nazwa (bez globa); właściciel dopisał -stabilne do D5 2026-10-17 (README repo, commit 5ca8558: „testy po awansie biegną w stabilne”) — od tego commita to nie jest odstępstwo.Z claude plugin validate . (2.1.302 i 2.1.299, identycznie):
./register.tsx hooks: session.start, tool.call{tool=/"^mcp__kanon-warsztat__szukaj$"/}, command.run{command=warsztat}, ui.render{component=Pane, requestId=warsztat}
./register.tsx answers its own command: command.run{command=warsztat}
./register.tsx gating hook with .catch: tool.call{tool=/"^mcp__kanon-warsztat__szukaj$"/}
./register.tsx calls: $.clock.now (via biegnij, uruchom), $.command.register, $.env.get (via plikTypow, uruchom), $.fs.exists (via istnieje), $.fs.list (via wpisyKatalogu), $.fs.read (via zaladujIndeks), $.fs.stat (via plik, realna), $.process.run (via biegnij), $.session.cwd (via uruchom), $.session.root (via uruchom), $.state.get, $.state.set, $.tool.register, $.ui.close, $.ui.copy (via kopiujBledy), $.ui.open (via uruchom), $.ui.resolve, $.ui.toast
./register.tsx env writes: nothing
./register.tsx env reads: HOME
./register.tsx state writes: kanon-warsztat.indeks, kanon-warsztat.warsztat
./register.tsx state reads: kanon-warsztat.indeks, kanon-warsztat.warsztat
$.process.run — zamknięta lista argv (strażnik argvDozwolone sprawdza każde argv tuż przed uruchomieniem; argv spoza listy = wiersz FAIL argv poza zamkniętą listą modu — nie uruchomiono bez procesu; test „strażnik argv” sprawdza oba kierunki):
~/.local/bin/claude plugin validate .
~/.local/bin/claude plugin test .
~/Library/Application Support/Claude/claude-code/<semver>/<hash>/claude.app/Contents/MacOS/claude plugin validate .
~/Library/Application Support/Claude/claude-code/<semver>/<hash>/claude.app/Contents/MacOS/claude plugin test .
<node> ~/Developer/stelmach/wishlist/node_modules/typescript/lib/tsc.js --noEmit -p .
node ∈ { ~/.local/bin/node, /opt/homebrew/bin/node, /usr/local/bin/node }
~ = HOME z $.env.get; <semver> = \d+.\d+.\d+, <hash> = [0-9a-f]+ (dokładnie jeden segment każdy, więc .. nie przejdzie). Zawsze { cwd: <katalog modu rozwiązany>, timeoutMs: 120000 }, dla claude dodatkowo env: { CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' } (nakłada się na env hosta). cwd przechodzi bramę katalogu (leksykalną i po realPath) przed pierwszym krokiem.
$.model, $.http, $.fs.write, $.store, tool.check; zapisy tylko do $.state (kanon-warsztat.warsztat, kanon-warsztat.indeks — kontrakt w types/index.d.ts); wyjścia procesów nie trafiają do $.state (pamięć modułu)..catch (fail-open): session.start przepuszcza next(e) (rejestracje komendy i narzędzia osobno — padnięta jedna nie wyłącza drugiej, toast mówi, czego brakuje), tool.call odpowiada { deny: opis } (błąd narzędzia dla modelu, bez wyjątku), command.run tekstem błędu, ui.render przepuszcza next(e). Odczyty katalogów i plików w try/catch (brak = brak kandydata, bez toasta).$.process.run klasyfikowane po czasie i komunikacie (d.ts nie podaje brzmienia): ENOENT|not found|no such file → brak binarki: …; zmierzony czas ≥ 119 s albo timed out|timeout|SIGKILL|SIGTERM|killed → TIMEOUT przekroczone 120 s; inne → FAIL z komunikatem. Realne brzmienie komunikatów silnika — Niesprawdzone.{ deny } (jak kanon-skrzynka), proces przez scenariusz (argv, numer) → ok|fail|timeout|brak|obiekt.tests/warsztat.test.ts — 18 testów (17 wpisów test(, jeden z nich w pętli po powierzchniach terminal/desktop), dysk w pamięci pod zmyślonym HOME z mini-d.ts (34 wpisy, dwa moduły, zdarzenie 'fs.list' w OpEventOf obok metody, długi JSDoc do przycięcia), zmyślonymi binarkami (CLI, desktop 2.1.295/2.1.299, katalog 2.1.300 bez binarki, 2.1.30 do semver) i dowiązaniami (link-ok → katalog w bazie, link-poza → /tmp/poza/mod): indekser (4 bloki zdarzeń, rzeczowniki, metody, typy jedno- i wieloliniowe, JSDoc, oba moduły); szukaj (dokładne, symetria $., metoda przed zdarzeniem Op*, rzeczownik → składniki, wielkość liter, podciąg → lista, brak z echem ≤ 80 znaków, przycięcie do 2048); narzędzie przez silnik (session.start rejestruje komendę i narzędzie, tool.call odpowiada tekstem MCP, indeks budowany raz — jeden odczyt, jeden zapis $.state, równoległe tool.call dołączają do jednego ładowania); plik typów spoza katalogu (najnowszy mtime), nieczytelny → isError; indeks zastany w $.state (ta sama wersja/ścieżka/mtime/linie → bez parsowania; inny mtime albo starsza wersja parsera → od nowa i zapis); /warsztat bez argumentu ×2 powierzchnie (5 argv w kolejności, cwd, timeout, env, panel, wynik, zamknij); FAIL (10 pass, 2 fail, 2 błędy TS, validate failed, stdout 10 000+ znaków → 2400/1500 ze znacznikiem) + Kopiuj błędy tylko z padniętych kroków na powierzchni przycisku, żaden zapis $.state nie niesie wyjścia; schowek odmawia → toast; timeout + brak desktopu i node; ENOENT, brak tsc.js, semver 2.1.30 < 2.1.299; strażnik argv (18 odrzuconych: shell, inne podkomendy, inne ścieżki, brak --noEmit, env node, desktop z ..); brama katalogu (poza bazami, poza rootem sesji, root = HOME//, .., bez plugin.json, dowiązanie poza bazy → odmowa bez procesu i panelu; ~, względna, końcowy /, repo stabilne, root sesji, dowiązanie do bazy przechodzą — to ostatnie z cwd rozwiązanym; origin sdk/scheduled-trigger/task-notification/peer → odmowa, bridge przechodzi); Powtórz bez biegu; stan „w toku” bez żywego biegu → „przerwano”; jeden bieg naraz (dwa /warsztat w Promise.all → jeden bieg)
| Binarka | validate | test | tsc strict |
|---|---|---|---|
CLI ~/.local/bin/claude 2.1.302 | ✔ | 18/18 (1,2 s) | ✔ (node …/typescript/lib/tsc.js --noEmit -p ., TS 6.0.3) |
desktop 2.1.299 (…/claude.app/Contents/MacOS/claude) | ✔ | 18/18 (1,2 s) | — |
Czego kit nie pokaże (zegar mock.clock stoi, dysku brak): czas kroków (wszędzie 0 s), prawdziwy d.ts (→ skrypty/sanity-dts.ts), ważność $.process.run/$.state po powrocie hooka command.run (→ próba na żywo), kształt wyniku tool.call — kit oddaje odpowiedź hooka bez walidacji wg schematu narzędzia (18/18 przechodziło z obiektem MCP, który silnik odrzuca; wykryte dopiero na żywo). Zapis $.state przerysowuje zamontowany panel „at the redraw rate” (d.ts ui.render @remarks), więc test montuje panel po zegar.settle(), nie przed.
Jedna runda recenzent [S5.5HI] (2026-10-17): 1 🔴, 11 🟡, 7 🔵, 2 ❓. Naprawione w kodzie/testach:
if (bieg) sprawdzane przed pięcioma await, bieg = dopiero po nich → strażnik synchroniczny (bieg = moj przed pierwszym await, finally zeruje tylko własny); test z Promise.all dwóch /warsztat.composer/bridge; 🟡 brama leksykalna (dowiązanie wyprowadza cwd poza bazy) → realPath katalogu i baz; 🟡 $.session.root() = HOME otwierał cały dysk → root pomijany, gdy HOME, przodek HOME albo /.$.state, który czyta każdy plugin → pamięć modułu, w stanie tylko szczegoly; 🟡 indeks z $.state bez wersji parsera → WERSJA_INDEKSU; 🟡 timeout po regexie /time|…/ (łapał „runtime”) → czas ≥ limit − 1 s albo węższy regex.wToku: true na zawsze → naprawPrzerwany w session.start i przed komendą; 🟡 fs.list trafiało w zdarzenie OpEventOf zamiast metody → ranking: metoda przed zdarzeniami operacji; 🟡 gałąź „brak” bez przytnij i z pełnym echem → echo ≤ 80 znaków, przytnij wszędzie.tool.call, (c) reużycie indeksu po reloadzie, (d) wyjście > 4000, (e) Powtórz bez stanu, (g) reload w trakcie biegu; (f) czas kroków — nie, patrz niżej.koniecWpisu domyka po ;/} (kotwica NextResultFor w sanity); 🔵 przykład podciągu „Session” trafiał dokładnie w $.session → „ToolCall”; 🔵 przytnijWyjscie 2500+1500 > 4000 → 2400+1500 ≤ 4000; 🔵 onPress Zamknij bez .catch → void …catch; 🔵 obie rejestracje w jednym try → osobno z toastem; 🔵 asercja SYMBOL.length → usunięta; 🔵 README (liczba testów, puste sekcje, claude-mods* jako glob) → ten plik.Nienaprawione, z uzasadnieniem:
global {} poza indeksem — trzy deklaracje globalne (h, crypto, performance), nie API $; szukaj ma odpowiadać o hooki, nie o globalia. Do rozważenia przy v0.2, jeśli ktoś o nie zapyta.$.fs tylko z hooków dna testu, 16 tys. linii nie wchodzi do fikstury). Zastąpiony skryptem skrypty/sanity-dts.ts (kotwice z recenzji: liczności, tool.call 4030, $.process, NextResultFor), uruchamianym ręcznie — nie przez claude plugin test.process.run (advance podczas dispatchu) nie jest udokumentowane; czas pokazuje próba na żywo.$.process.run i $.state z biegnij po powrocie hooka command.run (bez await, do 10 min) — d.ts tego nie opisuje; rozstrzyga próba na żywo (sekcja niżej). Gdyby $ gasło, pierwszy krok padłby z komunikatem silnika w wierszu FAIL.claude-mods-stabilne w D5 — rozstrzygnięte 2026-10-17: właściciel rozszerzył D5 (README repo, commit 5ca8558; Odstępstwa).~/.claude/dev-mods/<sid>/kanon-warsztat/ (bez .claude-plugin/types), właściciel włączył hot-reload ok. 12:08 — mod załadował się bez linii o błędzie w transkrypcie, silnik położył .claude-plugin/types/ (claude-code, claude-code-mcp, claude-code-tools, tsconfig), narzędzie mcp__kanon-warsztat__szukaj pojawiło się w ToolSearch z opisem i schematem z $.tool.register.szukaj { nazwa: "tool.call" } z modelu → silnik odrzucił odpowiedź v0.1 (tool.call step resolved mcp__kanon-warsztat__szukaj with a result that does not match its output shape: expected string | array | undefined, received object) — hook zwracał { result: { content, isError } }. Poprawione na { result: tekst } / { deny: tekst } (commit 2341e22). Po przeładowaniu (12:50) dwa wywołania z modelu przeszły: tool.call → nagłówek tool.call — zdarzenie (EngineEventOf — wejście hooka e; linie 4030–4030 w …/dev-mods/<sid>/kanon-warsztat/.claude-plugin/ types/claude-code/index.d.ts), JSDoc, 'tool.call': ToolCallInput;, „Inne wystąpienia” = metoda $.tool.call (3082) i EngineResultOf (4589); fs.list → metoda $.fs.list (3322) z JSDoc i list: (path?: string) => Promise<FsEntry[]>;, zdarzenia OpEventOf (7155) i OpValueOf (7370) w „Inne wystąpienia” — ranking z recenzji potwierdzony na żywo. Model dostaje wynik jako zwykły tekst; źródło = d.ts położony przez silnik przy ładowaniu modu (ścieżka 1 z „Skąd d.ts”)./warsztat ~/Developer/claude-mods/kanon-skrzynka uruchomił właściciel z promptu (origin composer przeszedł — komenda nie odmówiła). Panel i wynik widzi tylko właściciel: sesja nie czyta własnego terminala ani $.state modu, odpowiedź komendy nie trafia do transkryptu, procesy plugin validate/test nie zostawiają plików. Relacja właściciela (wiersze OK/FAIL, czasy) — do dopisania.warning/error, hotkeye p/k, Esc), $.ui.copy na żywo, ważność $ po powrocie hooka (❓ wyżej), realne komunikaty odrzucenia $.process.run (ENOENT/timeout), czas kroków, realPath na prawdziwym dysku (macOS: /tmp → /private/tmp — bazy też są rozwiązywane, więc różnica prefiksu nie powinna odrzucać), zachowanie $.state i panelu po prawdziwym hot-reloadzie w trakcie biegu.~/.claude/dev-mods/<session_id>/kanon-warsztat/ (hot-reload za zgodą właściciela) albo claude --plugin-dir ~/Developer/claude-mods/kanon-warsztat. .claude-plugin/types/ jest w .gitignore — silnik kładzie je sam.CLAUDE_CODE_PLUGIN_DIRS od 2026-10-17; w claude-mods-stabilne katalog jest (HEAD 5ca8558 w chwili tego commita). Awans ff-only i env prowadzi właściciel.CLAUDE_CODE_PLUGIN_DIRS (albo folder z dev-mods). Mod nie zostawia nic poza $.state sesji — po restarcie nie ma śladu.hooks/register.tsx 690 lines1// kanon-warsztat — warsztat modów (mod Claude Code, API function hooks 2.1.299; early access). v0.1.
2// Plan: ~/Developer/claude-kanon/claude-code/mods/PLAN-mody-2026-10-16-z-logow.md §6.2. Dowód z logów jednej sesji: 54× `claude plugin
3// test/validate`, 53× tsc, 26 grepów po d.ts — każdy grep to polecenie i kilkaset linii wyniku w kontekście modelu.
4// (a) Narzędzie modelu `mcp__kanon-warsztat__szukaj { nazwa }`: JSDoc i sygnatura zdarzenia (`tool.call`), metody (`$.fs.list`, też bez
5// `$.`), rzeczownika (`$.fs` → lista składników) albo typu (`SessionUsage`) z lokalnego pliku typów. Indeks budowany leniwie przy
6// pierwszym wywołaniu: `$.fs.read` całego pliku (~640 KB; `$.fs.read` nie czyta fragmentu), parsowanie po wcięciach bez parsera TS;
7// wpisy (nazwa, rodzaj, blok, linie) w `$.state` `indeks`, tekst w pamięci modułu (po reloadzie czytany ponownie, indeks z `$.state`
8// zostaje, gdy ścieżka, mtime i liczba linii się zgadzają). Plik: `<$.plugin.root>/.claude-plugin/types/claude-code/index.d.ts`
9// (silnik kładzie go przy załadowaniu modu); gdy go tam nie ma — najnowszy (mtime) spośród `~/.claude/dev-mods/*/*/…` i
10// `~/Developer/claude-mods/*/…`. Wynik ≤ 2048 znaków: nagłówek (nazwa, rodzaj, blok, linie, plik), JSDoc, sygnatura (bez
11// zagnieżdżonych komentarzy), inne wystąpienia nazwy; podciąg → lista nazw z numerami linii; brak → komunikat z przykładami.
12// (b) `/warsztat [ścieżka|wynik|zamknij]`: katalog z argumentu (`~` i ścieżka względna od cwd; `..` odrzucone) albo cwd sesji — musi
13// mieć `.claude-plugin/plugin.json` i leżeć w ~/Developer/claude-mods, ~/Developer/claude-mods-stabilne albo `$.session.root()`.
14// Kroki kolejno, w tle (polecenie wraca od razu, panel rysuje się po każdym kroku z `$.state`), przez `$.process.run` (argv bez
15// shella, cwd = katalog modu, timeout 120 s, env CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 dla claude): CLI `~/.local/bin/claude`
16// validate + test, binarka desktopu (najwyższa wersja semver w ~/Library/Application Support/Claude/claude-code/, podkatalog z
17// claude.app) validate + test, `node tsc.js --noEmit -p .` (TypeScript z ~/Developer/stelmach/wishlist/node_modules; node z
18// ~/.local/bin, /opt/homebrew/bin albo /usr/local/bin). Brak binarki = wiersz „brak”, nie błąd. Lista argv jest ZAMKNIĘTA
19// (`argvDozwolone` sprawdza każde argv tuż przed uruchomieniem; README `calls:`). Panel: binarka × krok → OK / FAIL / TIMEOUT / brak,
20// czas, `Validation passed` / `N pass` / `N pass, M fail`; „Powtórz”, „Kopiuj błędy” (`$.ui.copy` ze stdout+stderr kroków, które
21// padły), „Zamknij”; `/warsztat wynik` daje tę tabelę tekstem (terminal bez miejsca na panel).
22// Bramki: zero `$.model`, zero `$.http`, zero `tool.check`, żadnego zapisu plików ani `$.store` (jedyny stan: `$.state`); jeden bieg
23// naraz; każdy hook z `.catch` (fail-open: błąd modu nie zatrzymuje startu sesji, polecenia, wywołania narzędzia ani rysowania).
24import { atom, read, update } from 'claude-code'
25import type { EngineInterface, FsEntry, ProcessRunResult, Register, RenderSurface } from 'claude-code'
26
27import type { Binarka, Indeks, Krok, RodzajKroku, StanKroku, Warsztat, Wpis } from '../types'
28
29const PANE = 'warsztat'
30const OD_WLASCICIELA: ReadonlySet<string> = new Set(['composer', 'bridge'])
31// Matcher jako RegExp: unia nazw narzędzi w typach jest zamknięta na te, które silnik znał przy kładzeniu typów (nie obejmuje
32// narzędzia rejestrowanego przez sam mod), a RegExp przechodzi bez poszerzania unii.
33const NARZEDZIE = /^mcp__kanon-warsztat__szukaj$/
34export const TIMEOUT_MS = 120_000 // na jeden krok (`claude plugin test` z bunem to kilka sekund; tsc na d.ts 16 tys. linii kilkanaście)
35export const LIMIT_WYNIKU = 2048 // znaków odpowiedzi narzędzia (≈ 2 KB; znaki spoza ASCII liczą się po jednym)
36export const WERSJA_INDEKSU = 1 // podbij przy zmianie regexów indeksera: wpisy w $.state z inną wersją są budowane od nowa
37const LIMIT_WYJSCIA = 4000 // znaków stdout+stderr kroku, który padł (pamięć modułu: 2400 początku + 1500 końca + znacznik)
38const MAX_LISTY = 40 // pozycji listy przy wielu trafieniach
39export const TYPY = '.claude-plugin/types/claude-code/index.d.ts'
40export const TSC_JS = 'Developer/stelmach/wishlist/node_modules/typescript/lib/tsc.js'
41export const NODE_KANDYDACI = ['.local/bin/node', '/opt/homebrew/bin/node', '/usr/local/bin/node'] // względne od HOME; pierwszy istniejący
42const ENV_CLAUDE = { CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' }
43
44// Skaner walidatora wymaga literałów plugin/key w każdym atomie (nie stałej) — stąd powtórzona nazwa.
45const PUSTY: Warsztat = { katalog: null, kroki: [], start: null, koniec: null, blad: null, wToku: false }
46const warsztat = atom({ plugin: 'kanon-warsztat', key: 'warsztat' } as const, PUSTY)
47const indeks = atom({ plugin: 'kanon-warsztat', key: 'indeks' } as const, null as Indeks | null)
48
49const opisBledu = (err: unknown) => (err instanceof Error ? err.message : String(err))
50const uciekaj = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
51
52// ---------- indeks d.ts (czyste funkcje, bez $; testy importują je stąd) ----------
53// Kształt pliku (2.1.299): `declare module 'claude-code' {` → deklaracje na 2 spacjach (`export type X = {` … `};`, `export interface X {`
54// … `}`, jednoliniowe kończą się `;`); zdarzenia jako klucze `'a.b': Typ;` na 6 spacjach w blokach EngineEventOf (wejście hooka),
55// EngineResultOf (wynik), OpEventOf (argument wywołania `$`), OpValueOf (wartość), ChunkOf (strumień); rzeczowniki `$` jako `fs: {`
56// na 6 spacjach w CoreEngineInterface, ich metody na 10 spacjach; JSDoc `/** … */` bezpośrednio nad deklaracją.
57export const BLOKI_ZDARZEN = new Map<string, string>([
58 ['EngineEventOf', 'wejście hooka `e`'],
59 ['EngineResultOf', 'wynik hooka'],
60 ['OpEventOf', 'argument wywołania `$`'],
61 ['OpValueOf', 'wartość wywołania `$`'],
62 ['ChunkOf', 'porcja strumienia'],
63])
64const BLOKI_SILNIKA = new Set(['CoreEngineInterface', 'EngineInterface'])
65const MODUL = /^declare module '([^']+)'/
66const TOP = /^ {2}(?:export )?(?:declare )?(type|interface|const|function|class|enum|namespace|let|var) ([A-Za-z_$][\w$]*)/
67const ZDARZENIE = /^ {6}'([^']+)'\??: /
68const RZECZOWNIK = /^ {6}([A-Za-z_$][\w$]*)\??: \{$/
69const METODA = /^ {10}([A-Za-z_$][\w$]*)\??: /
70
71const wciecie = (l: string) => l.length - l.trimStart().length
72
73/** Czy linia (bez wcięcia) zaczyna nową deklarację na tym poziomie: słowo kluczowe top-level (wcięcie 2) albo `nazwa:` / `'a.b':` głębiej. */
74const nowaDeklaracja = (t: string, w: number) =>
75 w <= 2 ? /^(?:export |declare )*(?:type|interface|const|function|class|enum|namespace|let|var|global)\b/.test(t) : /^(?:readonly )?(?:'[^']+'|[A-Za-z_$][\w$]*)\??:/.test(t)
76/** Ostatnia linia (0-based) wpisu zaczętego w linii `i` o wcięciu `w`: ta sama, gdy kończy się `;`; inaczej pierwsza linia na tym wcięciu,
77 * która domyka (`};`, `}`, `);`) — `} | {` ciągnie wpis dalej; nowa deklaracja, komentarz albo płytsze wcięcie kończą go na poprzedniej
78 * niepustej; linia na tym samym wcięciu, która nie jest żadnym z tych (płaskie `[`/`infer P extends …`/`N`), to kontynuacja. */
79function koniecWpisu(linie: readonly string[], i: number, w: number): number {
80 if (/;\s*$/.test(linie[i] ?? '')) return i
81 let ostatnia = i
82 for (let j = i + 1; j < linie.length; j++) {
83 const l = linie[j] ?? ''
84 if (!l.trim()) continue
85 const wj = wciecie(l)
86 if (wj < w) return ostatnia
87 if (wj === w) {
88 const t = l.trim()
89 if (/^[}\])|]/.test(t)) {
90 if (/;\s*$/.test(l) || t === '}') return j
91 } else if (nowaDeklaracja(t, w) || /^(?:\/\*|\*|\/\/)/.test(t)) return ostatnia
92 }
93 ostatnia = j
94 }
95 return ostatnia
96}
97
98/** Pierwsza linia (0-based) JSDoc stojącego bezpośrednio nad linią `i`; null, gdy linia nad nią nie kończy komentarza. */
99function poczatekDoc(linie: readonly string[], i: number): number | null {
100 if (!(linie[i - 1] ?? '').trim().endsWith('*/')) return null
101 for (let j = i - 1; j >= 0 && i - j <= 400; j--) {
102 const t = (linie[j] ?? '').trim()
103 if (t.startsWith('/**')) return j
104 if (j < i - 1 && !t.startsWith('*')) return null
105 }
106 return null
107}
108
109/** Indeks pliku typów: zdarzenia, rzeczowniki i metody `$`, deklaracje najwyższego poziomu (z obu modułów). Linie 1-based. */
110export function zbudujIndeks(tekst: string): Wpis[] {
111 const linie = tekst.split('\n')
112 const wpisy: Wpis[] = []
113 let modul = 'claude-code'
114 let blok: { nazwa: string; do: number } | null = null
115 let rzeczownik: { nazwa: string; do: number } | null = null
116 const dodaj = (nazwa: string, rodzaj: Wpis['rodzaj'], gdzie: string, i: number, w: number) => {
117 const d = koniecWpisu(linie, i, w)
118 const doc = poczatekDoc(linie, i)
119 wpisy.push({ nazwa, rodzaj, blok: gdzie, od: i + 1, do: d + 1, docOd: doc === null ? null : doc + 1 })
120 return d
121 }
122 for (let i = 0; i < linie.length; i++) {
123 const l = linie[i] ?? ''
124 if (!l.trim()) continue
125 const m0 = MODUL.exec(l)
126 if (m0?.[1]) {
127 modul = m0[1]
128 blok = null
129 rzeczownik = null
130 continue
131 }
132 if (blok && i > blok.do) blok = null
133 if (rzeczownik && i > rzeczownik.do) rzeczownik = null
134 if (blok) {
135 if (BLOKI_ZDARZEN.has(blok.nazwa)) {
136 const m = ZDARZENIE.exec(l)
137 if (m?.[1]) dodaj(m[1], 'zdarzenie', blok.nazwa, i, 6)
138 } else if (BLOKI_SILNIKA.has(blok.nazwa)) {
139 const mr = RZECZOWNIK.exec(l)
140 if (mr?.[1]) {
141 rzeczownik = { nazwa: mr[1], do: dodaj(`$.${mr[1]}`, 'rzeczownik', blok.nazwa, i, 6) }
142 continue
143 }
144 const mm = rzeczownik ? METODA.exec(l) : null
145 if (mm?.[1] && rzeczownik) dodaj(`$.${rzeczownik.nazwa}.${mm[1]}`, 'metoda', blok.nazwa, i, 10)
146 }
147 continue
148 }
149 const mt = TOP.exec(l)
150 if (mt?.[1] && mt[2]) {
151 const d = dodaj(mt[2], 'typ', `${mt[1]} · ${modul}`, i, 2)
152 if (d > i) blok = { nazwa: mt[2], do: d }
153 }
154 }
155 return wpisy
156}
157
158/** Kolejność trafień o tej samej nazwie: zdarzenie wejścia hooka, metoda `$`, rzeczownik, wynik hooka, argument i wartość wywołania `$`,
159 * porcja strumienia, typ — `tool.call` to przede wszystkim zdarzenie, a `fs.list` przede wszystkim metoda `$.fs.list` (zdarzenia `OpEventOf`
160 * i `OpValueOf` o tej nazwie opisują tylko jej argument i wartość). */
161const RANGA_BLOKU: Record<string, number> = { EngineEventOf: 0, EngineResultOf: 3, OpEventOf: 4, OpValueOf: 5, ChunkOf: 6 }
162const ranga = (w: Wpis) => (w.rodzaj === 'zdarzenie' ? (RANGA_BLOKU[w.blok] ?? 6) : w.rodzaj === 'metoda' ? 1 : w.rodzaj === 'rzeczownik' ? 2 : 7)
163const porzadek = (a: Wpis, b: Wpis) => ranga(a) - ranga(b) || a.od - b.od
164
165/** Linie bez komentarzy JSDoc/`//` i bez pustych, z zdjętym wspólnym wcięciem. */
166function bezKomentarzy(linie: readonly string[]): string[] {
167 const out: string[] = []
168 let wDoc = false
169 for (const l of linie) {
170 const t = l.trim()
171 if (wDoc) {
172 if (t.endsWith('*/')) wDoc = false
173 continue
174 }
175 if (t.startsWith('/**') || t.startsWith('/*')) {
176 if (!t.endsWith('*/')) wDoc = true
177 continue
178 }
179 if (!t || t.startsWith('//')) continue
180 out.push(l)
181 }
182 const w = Math.min(...out.map(wciecie))
183 return out.map(l => l.slice(Number.isFinite(w) ? w : 0))
184}
185/** Sygnatura wpisu: linie od–do (1-based, włącznie) bez zagnieżdżonych komentarzy. */
186export const sygnatura = (linie: readonly string[], od: number, koniec: number) => bezKomentarzy(linie.slice(od - 1, koniec)).join('\n')
187/** Treść JSDoc z linii docOd–(od−1) bez `/**`, `*` i `*\/`; puste linie zbite do jednej. */
188export function jsdoc(linie: readonly string[], docOd: number, od: number): string {
189 const t = linie
190 .slice(docOd - 1, od - 1)
191 .map(l => l.trim().replace(/^\/\*\*\s?/, '').replace(/\s?\*\/$/, '').replace(/^\*\s?/, '').trimEnd())
192 .join('\n')
193 return t.replace(/\n{3,}/g, '\n\n').trim()
194}
195
196const gdzie = (w: Wpis) => (BLOKI_ZDARZEN.has(w.blok) ? `${w.blok} — ${BLOKI_ZDARZEN.get(w.blok)}` : w.blok)
197
198function opisWpisu(w: Wpis, inne: readonly Wpis[], ind: readonly Wpis[], linie: readonly string[], zrodlo: string): string {
199 const czesci = [`${w.nazwa} — ${w.rodzaj} (${gdzie(w)}; linie ${w.od}–${w.do} w ${zrodlo})`]
200 if (w.docOd !== null) czesci.push(jsdoc(linie, w.docOd, w.od))
201 const skladniki = w.rodzaj === 'rzeczownik' ? ind.filter(x => x.rodzaj === 'metoda' && x.nazwa.startsWith(`${w.nazwa}.`)) : []
202 czesci.push(skladniki.length ? `Składniki: ${skladniki.map(m => `${m.nazwa} (linia ${m.od})`).join(', ')}` : sygnatura(linie, w.od, w.do))
203 if (inne.length) czesci.push(`Inne wystąpienia:\n${inne.map(x => `- ${x.rodzaj} w ${gdzie(x)}, linia ${x.od}: ${(linie[x.od - 1] ?? '').trim()}`).join('\n')}`)
204 return czesci.join('\n\n')
205}
206
207/** Przycięcie do `limit` znaków z dopiskiem, gdzie jest reszta. */
208export function przytnij(tekst: string, dopisek: string, limit = LIMIT_WYNIKU): string {
209 if (tekst.length <= limit) return tekst
210 const ogon = `… [przycięte do ${limit} znaków; ${dopisek}]`
211 return tekst.slice(0, Math.max(0, limit - ogon.length)) + ogon
212}
213
214/** Odpowiedź narzędzia: dokładna nazwa (z `$.` i bez — to samo pytanie), potem bez rozróżniania wielkości liter, potem podciąg → lista. */
215export function szukaj(ind: readonly Wpis[], linie: readonly string[], zapytanie: string, zrodlo: string): string {
216 const q = zapytanie.trim()
217 if (!q) return 'szukaj: podaj nazwę — zdarzenia (tool.call), metody ($.fs.list) albo typu (SessionUsage).'
218 const qPokaz = q.length > 80 ? `${q.slice(0, 80)}…` : q // echo zapytania modelu w komunikatach ograniczone, odpowiedź zawsze ≤ limit
219 const warianty = q.startsWith('$.') ? [q, q.slice(2)] : [q, `$.${q}`] // `fs.list` i `$.fs.list` to to samo pytanie: metoda + zdarzenia o tej nazwie
220 let trafione = ind.filter(w => warianty.includes(w.nazwa))
221 if (!trafione.length) {
222 const male = warianty.map(v => v.toLowerCase())
223 trafione = ind.filter(w => male.includes(w.nazwa.toLowerCase()))
224 }
225 const [glowny, ...inne] = [...trafione].sort(porzadek)
226 if (glowny) return przytnij(opisWpisu(glowny, inne, ind, linie, zrodlo), `pełny tekst: ${zrodlo}:${glowny.od}`)
227 const czesciowe = ind.filter(w => w.nazwa.toLowerCase().includes(q.toLowerCase())).sort(porzadek)
228 if (!czesciowe.length) {
229 return przytnij(`szukaj: brak „${qPokaz}” wśród ${ind.length} wpisów ${zrodlo}. Nazwy: zdarzenie „tool.call”, metoda „$.fs.list” (albo „fs.list”), rzeczownik „$.fs”, typ „SessionUsage”; podciąg daje listę.`, 'skróć zapytanie')
230 }
231 const lista = czesciowe.slice(0, MAX_LISTY).map(w => `- ${w.nazwa} (${w.rodzaj}, linia ${w.od})`)
232 const reszta = czesciowe.length - lista.length
233 return przytnij([`szukaj: ${czesciowe.length} trafień dla „${qPokaz}” — podaj pełną nazwę:`, ...lista, ...(reszta > 0 ? [`… +${reszta} więcej`] : [])].join('\n'), `grep '${qPokaz}' ${zrodlo}`)
234}
235
236// ---------- ładowanie indeksu ($) ----------
237type Zaladowany = { ind: Wpis[]; linie: string[]; sciezka: string }
238let zaladowany: Zaladowany | null = null
239let ladowanie: Promise<Zaladowany> | null = null
240
241const istnieje = ($: EngineInterface, p: string) => $.fs.exists(p).catch(() => false)
242async function wpisyKatalogu($: EngineInterface, p: string): Promise<FsEntry[]> {
243 try {
244 return await $.fs.list(p)
245 } catch {
246 return []
247 }
248}
249async function plik($: EngineInterface, p: string): Promise<{ sciezka: string; mtimeMs: number } | null> {
250 try {
251 const s = await $.fs.stat(p)
252 return s.kind === 'file' ? { sciezka: p, mtimeMs: s.mtimeMs } : null
253 } catch {
254 return null
255 }
256}
257
258/** Plik typów: własny katalog modu; gdy go tam nie ma — najnowszy (mtime) z kopii modów w ~/.claude/dev-mods/<sid>/<mod> i ~/Developer/claude-mods/<mod>. */
259async function plikTypow($: EngineInterface): Promise<{ sciezka: string; mtimeMs: number } | null> {
260 let root: string | null = null
261 try {
262 root = $.plugin.root
263 } catch {
264 root = null
265 }
266 const wlasny = root ? await plik($, `${root}/${TYPY}`) : null
267 if (wlasny) return wlasny
268 const home = (await $.env.get('HOME')) ?? ''
269 const kandydaci: string[] = []
270 const devMods = `${home}/.claude/dev-mods`
271 for (const sid of await wpisyKatalogu($, devMods)) {
272 if (sid.kind !== 'dir') continue
273 for (const mod of await wpisyKatalogu($, `${devMods}/${sid.name}`)) if (mod.kind === 'dir') kandydaci.push(`${devMods}/${sid.name}/${mod.name}/${TYPY}`)
274 }
275 const mods = `${home}/Developer/claude-mods`
276 for (const mod of await wpisyKatalogu($, mods)) if (mod.kind === 'dir') kandydaci.push(`${mods}/${mod.name}/${TYPY}`)
277 let najnowszy: { sciezka: string; mtimeMs: number } | null = null
278 for (const k of kandydaci) {
279 const p = await plik($, k)
280 if (p && (!najnowszy || p.mtimeMs > najnowszy.mtimeMs)) najnowszy = p
281 }
282 return najnowszy
283}
284
285/** Indeks i tekst d.ts: raz na życie modułu (równoległe wywołania dołączają do trwającego); wpisy z `$.state`, gdy plik się nie zmienił. */
286function zaladujIndeks($: EngineInterface): Promise<Zaladowany> {
287 if (zaladowany) return Promise.resolve(zaladowany)
288 return (ladowanie ??= (async () => {
289 const p = await plikTypow($)
290 if (!p) throw new Error(`brak pliku typów ${TYPY}: ani w katalogu modu, ani w ~/.claude/dev-mods/*/*/, ani w ~/Developer/claude-mods/*/`)
291 const tekst = String(await $.fs.read(p.sciezka))
292 const linie = tekst.split('\n')
293 const zapisany = await read($, indeks).catch((): Indeks | null => null)
294 let wpisy: Wpis[]
295 if (zapisany && zapisany.wersja === WERSJA_INDEKSU && zapisany.sciezka === p.sciezka && zapisany.mtimeMs === p.mtimeMs && zapisany.linii === linie.length) wpisy = zapisany.wpisy
296 else {
297 wpisy = zbudujIndeks(tekst)
298 await update($, indeks, () => ({ wersja: WERSJA_INDEKSU, sciezka: p.sciezka, mtimeMs: p.mtimeMs, linii: linie.length, wpisy })).catch(() => undefined)
299 }
300 zaladowany = { ind: wpisy, linie, sciezka: p.sciezka }
301 return zaladowany
302 })().finally(() => (ladowanie = null)))
303}
304
305
306// ---------- /warsztat: brama katalogu i zamknięta lista argv (czyste funkcje) ----------
307const bezKoncowego = (p: string) => (p.length > 1 ? p.replace(/\/+$/, '') : p)
308/** Dozwolone bazy katalogu modu: repo modów, repo stabilne i katalog główny sesji — ten ostatni tylko, gdy jest projektem, nie całym domem:
309 * root równy HOME, jego przodkowi albo `/` odpada (sesja zaczęta w `~` nie czyni modem każdego katalogu z plugin.json). */
310export const BAZY = (home: string, root: string) => {
311 const r = bezKoncowego(root)
312 const domowy = !r || r === '/' || r === home || home.startsWith(`${r}/`)
313 return [`${home}/Developer/claude-mods`, `${home}/Developer/claude-mods-stabilne`, ...(domowy ? [] : [r])]
314}
315/** Ścieżka bezwzględna z argumentu: `~` → HOME, względna → od cwd, `/./` i podwójne `/` zbite; segment `..` → null (odrzucona). */
316export function rozwin(arg: string, home: string, cwd: string): string | null {
317 let p = arg.trim().replace(/^(['"])(.*)\1$/, '$2')
318 if (p === '~' || p.startsWith('~/')) p = home + p.slice(1)
319 if (!p.startsWith('/')) p = `${cwd}/${p}`
320 if (p.split('/').includes('..')) return null
321 return bezKoncowego(p.replace(/\/\.(?=\/|$)/g, '').replace(/\/{2,}/g, '/'))
322}
323export const wBazie = (p: string, bazy: readonly string[]) =>
324 bazy.some(b => {
325 const x = bezKoncowego(b)
326 return p === x || p.startsWith(`${x}/`)
327 })
328
329const DESKTOP_BAZA = (home: string) => `${home}/Library/Application Support/Claude/claude-code`
330const sciezkaNode = (home: string, n: string) => (n.startsWith('/') ? n : `${home}/${n}`)
331/** Zamknięta lista argv modu (README `calls:`): `<claude> plugin validate|test .` dla CLI ~/.local/bin/claude albo binarki desktopu
332 * pod ~/Library/Application Support/Claude/claude-code/<semver>/<hash>/claude.app/Contents/MacOS/claude oraz `<node> <tsc.js> --noEmit -p .`. */
333export function argvDozwolone(argv: readonly string[], home: string): boolean {
334 const [bin, ...reszta] = argv
335 if (!bin || !home) return false
336 const desktop = new RegExp(`^${uciekaj(DESKTOP_BAZA(home))}/\\d+\\.\\d+\\.\\d+/[0-9a-f]+/claude\\.app/Contents/MacOS/claude$`)
337 if (bin === `${home}/.local/bin/claude` || desktop.test(bin)) {
338 return reszta.length === 3 && reszta[0] === 'plugin' && (reszta[1] === 'validate' || reszta[1] === 'test') && reszta[2] === '.'
339 }
340 if (NODE_KANDYDACI.some(n => sciezkaNode(home, n) === bin)) {
341 return reszta.length === 4 && reszta[0] === `${home}/${TSC_JS}` && reszta[1] === '--noEmit' && reszta[2] === '-p' && reszta[3] === '.'
342 }
343 return false
344}
345
346/** Ocena wyniku kroku z kodu wyjścia i wyjścia: test → ` N pass` / ` N fail`; validate → `Validation passed|failed`; tsc → liczba `error TS`. */
347export function ocen(krok: RodzajKroku, r: ProcessRunResult): { stan: StanKroku; testy: Krok['testy']; szczegoly: string } {
348 const out = `${r.stdout}\n${r.stderr}`
349 if (krok === 'test') {
350 const pass = /^\s*(\d+) pass\b/m.exec(out)?.[1]
351 const fail = /^\s*(\d+) fail\b/m.exec(out)?.[1]
352 const testy = pass !== undefined || fail !== undefined ? { pass: Number(pass ?? 0), fail: Number(fail ?? 0) } : null
353 const ok = r.exitCode === 0 && (testy?.fail ?? 0) === 0
354 const opis = testy ? `${testy.pass} pass${testy.fail ? `, ${testy.fail} fail` : ''}` : 'bez podsumowania testów'
355 return { stan: ok ? 'ok' : 'fail', testy, szczegoly: ok ? opis : `${opis} (kod ${r.exitCode})` }
356 }
357 if (krok === 'validate') {
358 if (r.exitCode === 0) return { stan: 'ok', testy: null, szczegoly: /Validation passed/.test(out) ? 'Validation passed' : 'kod 0' }
359 return { stan: 'fail', testy: null, szczegoly: `${/Validation failed/.test(out) ? 'Validation failed' : 'błąd'} (kod ${r.exitCode})` }
360 }
361 if (r.exitCode === 0) return { stan: 'ok', testy: null, szczegoly: 'bez błędów' }
362 const bledow = (out.match(/error TS\d+/g) ?? []).length
363 return { stan: 'fail', testy: null, szczegoly: `${bledow} błędów TS (kod ${r.exitCode})` }
364}
365
366const przytnijWyjscie = (s: string) => (s.length <= LIMIT_WYJSCIA ? s : `${s.slice(0, 2400)}\n… [przycięte ${s.length - 3900} znaków] …\n${s.slice(-1500)}`)
367
368// ---------- binarki i plan kroków ----------
369type Binarki = { cli: string | null; desktop: { sciezka: string; wersja: string } | null; node: string | null; tsc: string | null }
370const semver = (s: string) => s.split('.').map(Number)
371/** Malejąco po składnikach semver (2.1.299 przed 2.1.30 — porównanie liczbowe, nie tekstowe). */
372export const porownajWersje = (a: string, b: string) => {
373 const [x, y] = [semver(a), semver(b)]
374 for (let i = 0; i < 3; i++) {
375 const d = (y[i] ?? 0) - (x[i] ?? 0)
376 if (d) return d
377 }
378 return 0
379}
380
381async function znajdzBinarki($: EngineInterface, home: string): Promise<Binarki> {
382 const cli = `${home}/.local/bin/claude`
383 const baza = DESKTOP_BAZA(home)
384 let desktop: Binarki['desktop'] = null
385 const wersje = (await wpisyKatalogu($, baza))
386 .filter(e => e.kind === 'dir' && /^\d+\.\d+\.\d+$/.test(e.name))
387 .map(e => e.name)
388 .sort(porownajWersje)
389 for (const w of wersje) {
390 for (const h of await wpisyKatalogu($, `${baza}/${w}`)) {
391 if (h.kind !== 'dir' || !/^[0-9a-f]+$/.test(h.name)) continue
392 const p = `${baza}/${w}/${h.name}/claude.app/Contents/MacOS/claude`
393 if (await istnieje($, p)) {
394 desktop = { sciezka: p, wersja: w }
395 break
396 }
397 }
398 if (desktop) break
399 }
400 let node: string | null = null
401 for (const n of NODE_KANDYDACI) {
402 const p = sciezkaNode(home, n)
403 if (await istnieje($, p)) {
404 node = p
405 break
406 }
407 }
408 const tsc = `${home}/${TSC_JS}`
409 return { cli: (await istnieje($, cli)) ? cli : null, desktop, node, tsc: (await istnieje($, tsc)) ? tsc : null }
410}
411
412function zaplanuj(b: Binarki, home: string): Krok[] {
413 const krok = (id: string, binarka: Binarka, etykieta: string, rodzaj: RodzajKroku, argv: string[] | null, brak: string): Krok => ({
414 id,
415 binarka,
416 etykieta,
417 krok: rodzaj,
418 argv: argv ?? [],
419 stan: argv ? 'czeka' : 'brak',
420 ms: null,
421 testy: null,
422 szczegoly: argv ? '' : brak,
423 })
424 const claude = (binarka: Binarka, etykieta: string, bin: string | null, brak: string) =>
425 (['validate', 'test'] as const).map(r => krok(`${binarka}-${r}`, binarka, etykieta, r, bin ? [bin, 'plugin', r, '.'] : null, brak))
426 return [
427 ...claude('cli', 'CLI', b.cli, `brak binarki ${home}/.local/bin/claude`),
428 ...claude('desktop', b.desktop ? `desktop ${b.desktop.wersja}` : 'desktop', b.desktop?.sciezka ?? null, `brak binarki desktopu w ${DESKTOP_BAZA(home)}/<wersja>/<hash>/claude.app`),
429 krok(
430 'tsc',
431 'tsc',
432 'tsc',
433 'tsc',
434 b.node && b.tsc ? [b.node, b.tsc, '--noEmit', '-p', '.'] : null,
435 b.tsc ? `tsc: brak — nie ma node w ${NODE_KANDYDACI.map(n => sciezkaNode('~', n)).join(', ')}` : `tsc: brak — nie ma ~/${TSC_JS}`,
436 ),
437 ]
438}
439
440// ---------- bieg ----------
441let bieg: object | null = null // znacznik żywego biegu, ustawiany SYNCHRONICZNIE przed pierwszym await: drugi `/warsztat` i „Powtórz” w trakcie dostają odmowę
442/** stdout/stderr kroków, które padły (id kroku → tekst): pamięć modułu, nie `$.state` — stan sesji czyta każdy plugin, a logi testów bywają gadatliwe. */
443const WYJSCIA = new Map<string, string>()
444const PRZERWANO = 'przerwano — moduł przeładowany w trakcie biegu'
445const ustawKrok = ($: EngineInterface, id: string, zmiana: Partial<Krok>) =>
446 update($, warsztat, s => ({ ...s, kroki: s.kroki.map(k => (k.id === id ? { ...k, ...zmiana } : k)) }))
447/** Stan „w toku” bez żywego biegu (hot-reload w trakcie: `bieg` startuje od nowa, `$.state` zostaje) → kroki nieskończone jako FAIL „przerwano”. */
448async function naprawPrzerwany($: EngineInterface): Promise<void> {
449 if (!(await read($, warsztat)).wToku) return
450 await update($, warsztat, s =>
451 s.wToku ? { ...s, wToku: false, blad: PRZERWANO, kroki: s.kroki.map(k => (k.stan === 'czeka' || k.stan === 'biegnie' ? { ...k, stan: 'fail' as const, szczegoly: PRZERWANO } : k)) } : s,
452 )
453}
454/** Dokąd ścieżka katalogu naprawdę prowadzi (`realPath`, dowiązania rozwiązane); null, gdy to nie katalog albo nie da się rozwiązać. */
455async function realna($: EngineInterface, p: string): Promise<string | null> {
456 try {
457 const s = await $.fs.stat(p, { resolve: true })
458 return s.kind === 'dir' && s.realPath ? bezKoncowego(s.realPath) : null
459 } catch {
460 return null
461 }
462}
463
464async function biegnij($: EngineInterface, katalog: string, home: string): Promise<void> {
465 const s0 = await read($, warsztat)
466 for (const k of s0.kroki) {
467 if (k.stan !== 'czeka') continue
468 if (!argvDozwolone(k.argv, home)) {
469 await ustawKrok($, k.id, { stan: 'fail', szczegoly: 'argv poza zamkniętą listą modu — nie uruchomiono' })
470 continue
471 }
472 await ustawKrok($, k.id, { stan: 'biegnie' })
473 const t0 = await $.clock.now()
474 try {
475 const r = await $.process.run(k.argv, { cwd: katalog, timeoutMs: TIMEOUT_MS, ...(k.binarka === 'tsc' ? {} : { env: ENV_CLAUDE }) })
476 const o = ocen(k.krok, r)
477 if (o.stan !== 'ok') WYJSCIA.set(k.id, przytnijWyjscie(`${r.stdout.trimEnd()}${r.stderr.trim() ? `\n--- stderr ---\n${r.stderr.trimEnd()}` : ''}`))
478 await ustawKrok($, k.id, { ...o, ms: (await $.clock.now()) - t0 })
479 } catch (err) {
480 // `$.process.run` odrzuca, gdy proces nie startuje (ENOENT) albo przekroczy timeout; d.ts nie podaje brzmienia komunikatu, więc timeout
481 // poznajemy po zmierzonym czasie (≥ limit − 1 s) albo po słowie w komunikacie; cokolwiek innego = fail z tekstem błędu
482 const ms = (await $.clock.now()) - t0
483 const msg = opisBledu(err)
484 const stan: StanKroku = /ENOENT|not found|no such file/i.test(msg) ? 'brak' : ms >= TIMEOUT_MS - 1000 || /timed?\s*out|timeout|SIGKILL|SIGTERM|killed/i.test(msg) ? 'timeout' : 'fail'
485 const szczegoly = stan === 'brak' ? `brak binarki: ${msg}` : stan === 'timeout' ? `przekroczone ${TIMEOUT_MS / 1000} s` : msg
486 WYJSCIA.set(k.id, msg)
487 await ustawKrok($, k.id, { stan, ms, szczegoly })
488 }
489 }
490 const koniec = await $.clock.now()
491 await update($, warsztat, s => ({ ...s, wToku: false, koniec }))
492}
493
494/** Brama katalogu: leksykalnie w bazach, z plugin.json, a jego `realPath` (dowiązania rozwiązane) też w bazach rozwiązanych tak samo
495 * (d.ts `FsStat.realPath`: „an allow-list on realPath under a root resolved the same way is the robust guard”). Zwraca katalog rozwiązany. */
496async function brama($: EngineInterface, arg: string, home: string, cwd: string, root: string): Promise<{ katalog: string } | { odmowa: string }> {
497 const katalog = arg ? rozwin(arg, home, cwd) : bezKoncowego(cwd)
498 if (!katalog) return { odmowa: `Warsztat: ścieżka z „..” odrzucona: ${arg}` }
499 const bazy = BAZY(home, root)
500 const podpowiedz = arg ? '' : ' — podaj ścieżkę: /warsztat <katalog modu>'
501 if (!wBazie(katalog, bazy)) return { odmowa: `Warsztat: ${katalog} leży poza dozwolonymi katalogami (${bazy.join(', ')})${podpowiedz}.` }
502 if (!(await istnieje($, `${katalog}/.claude-plugin/plugin.json`))) return { odmowa: `Warsztat: ${katalog} nie jest katalogiem modu (brak .claude-plugin/plugin.json)${podpowiedz}.` }
503 const real = await realna($, katalog)
504 if (!real) return { odmowa: `Warsztat: nie da się rozwiązać ścieżki ${katalog} (realPath) — odmowa.` }
505 const bazyReal = await Promise.all(bazy.map(b => realna($, b).then(r => r ?? b)))
506 if (!wBazie(real, bazyReal)) return { odmowa: `Warsztat: ${katalog} prowadzi (dowiązanie) do ${real}, poza dozwolonymi katalogami (${bazyReal.join(', ')}).` }
507 return { katalog: real }
508}
509
510/** `/warsztat [ścieżka]` i „Powtórz”: strażnik jednego biegu, brama katalogu, plan kroków, start biegu w tle, panel. */
511async function uruchom($: EngineInterface, arg: string): Promise<string> {
512 if (bieg) return 'Warsztat: poprzedni bieg jeszcze trwa — wynik w panelu albo /warsztat wynik.'
513 const moj = {}
514 bieg = moj // przed pierwszym await: równoległe wywołanie (podwójne „p”, dwa /warsztat) już widzi bieg
515 const zwolnij = () => {
516 if (bieg === moj) bieg = null
517 }
518 let wystartowal = false
519 try {
520 await naprawPrzerwany($)
521 const [home, cwd, root] = await Promise.all([$.env.get('HOME'), $.session.cwd(), $.session.root()])
522 if (!home) return 'Warsztat: brak HOME w środowisku.'
523 const b = await brama($, arg, home, cwd, root)
524 if ('odmowa' in b) return b.odmowa
525 const { katalog } = b
526 const kroki = zaplanuj(await znajdzBinarki($, home), home)
527 const start = await $.clock.now()
528 WYJSCIA.clear()
529 await update($, warsztat, () => ({ katalog, kroki, start, koniec: null, blad: null, wToku: true }))
530 // Bez await: polecenie wraca od razu, kroki trwają do kilku minut; panel przerysowuje się po każdym zapisie $.state.
531 wystartowal = true
532 void biegnij($, katalog, home)
533 .catch(err => update($, warsztat, s => ({ ...s, wToku: false, blad: opisBledu(err) })).then(() => undefined, () => undefined))
534 .finally(zwolnij)
535 const o = await $.ui.open({ id: PANE, title: 'Warsztat', focus: true, closeOnEscape: true, rows: kroki.length + 6 })
536 const doZrobienia = kroki.filter(k => k.stan === 'czeka').length
537 const brak = kroki.length - doZrobienia
538 const liczby = `${doZrobienia} kroków${brak ? ` (+${brak} brak binarki)` : ''} dla ${katalog}`
539 return o.isPlaced ? `Warsztat: ${liczby} — wyniki w panelu.` : `Warsztat: ${liczby}; panel czeka (${o.reason}) — wynik: /warsztat wynik.`
540 } finally {
541 if (!wystartowal) zwolnij()
542 }
543}
544
545// ---------- tekst i panel ----------
546export const SYMBOL: Record<StanKroku, string> = { czeka: '·', biegnie: 'biegnie', ok: 'OK', fail: 'FAIL', timeout: 'TIMEOUT', brak: 'brak' }
547const pad = (s: string, n: number) => (s.length >= n ? `${s} ` : s.padEnd(n))
548const sekundy = (ms: number) => `${(ms / 1000).toFixed(1).replace('.', ',')} s`
549const czas = (k: Krok) => (k.ms === null ? '' : sekundy(k.ms))
550/** Wiersz tabeli bez kolumny stanu (stan idzie osobno, bo ma kolor). */
551export const wiersz = (k: Krok) => `${pad(k.etykieta, 15)}${pad(k.krok, 9)}${pad(czas(k), 8)}${k.szczegoly}`
552const licz = (s: Warsztat, stan: StanKroku) => s.kroki.filter(k => k.stan === stan).length
553export function stopka(s: Warsztat): string {
554 if (!s.kroki.length) return 'Jeszcze nic nie uruchomiono: /warsztat [katalog modu].'
555 const czasBiegu = s.start !== null && s.koniec !== null ? ` · ${sekundy(s.koniec - s.start)} łącznie` : s.wToku ? ' · w toku…' : ''
556 return `${licz(s, 'ok')} OK · ${licz(s, 'fail')} FAIL · ${licz(s, 'timeout')} timeout · ${licz(s, 'brak')} brak${czasBiegu}${s.blad ? ` · błąd modu: ${s.blad}` : ''}`
557}
558/** `/warsztat wynik`: tabela tekstem. */
559export const tabela = (s: Warsztat) =>
560 [`Warsztat: ${s.katalog ?? '—'}`, ...s.kroki.map(k => `${pad(SYMBOL[k.stan], 8)}${wiersz(k)}`), stopka(s)].join('\n')
561
562const padle = (s: Warsztat) => s.kroki.filter(k => k.stan === 'fail' || k.stan === 'timeout')
563/** „Kopiuj błędy”: argv i wyjście każdego kroku, który padł (OK i brak pominięte); schowek powierzchni, z której naciśnięto. */
564async function kopiujBledy($: EngineInterface, surface: RenderSurface): Promise<void> {
565 try {
566 const s = await read($, warsztat)
567 const zle = padle(s)
568 if (!zle.length) {
569 $.ui.toast(s.wToku ? 'Warsztat: bieg jeszcze trwa — nic nie padło (na razie).' : 'Warsztat: nic do skopiowania — żaden krok nie padł.')
570 return
571 }
572 const text = zle.map(k => `## ${k.etykieta} ${k.krok} — ${SYMBOL[k.stan]} (${k.szczegoly})\n$ ${k.argv.join(' ')}\n${WYJSCIA.get(k.id) ?? '(wyjście niedostępne — moduł przeładowany po biegu; naciśnij Powtórz)'}`).join('\n\n')
573 const c = await $.ui.copy({ text, surface })
574 $.ui.toast(c.isCopied ? `Warsztat: skopiowano wyjście ${zle.length} kroków (${text.length} znaków).` : `Warsztat: schowek niedostępny (${c.reason}) — użyj /warsztat wynik.`)
575 } catch (err) {
576 $.ui.toast(`Warsztat: błąd kopiowania — ${opisBledu(err)}`)
577 }
578}
579
580const powtorz = async ($: EngineInterface) => {
581 try {
582 const s = await read($, warsztat)
583 $.ui.toast(s.katalog ? await uruchom($, s.katalog) : 'Warsztat: nie ma czego powtórzyć — /warsztat [katalog modu].')
584 } catch (err) {
585 $.ui.toast(`Warsztat: błąd — ${opisBledu(err)}`)
586 }
587}
588
589const kolor = (k: Krok) => (k.stan === 'ok' ? 'success' : k.stan === 'fail' || k.stan === 'timeout' ? 'error' : k.stan === 'biegnie' ? 'warning' : undefined)
590
591export const register: Register = on => {
592 on('session.start', async ($, e, next) => {
593 // Każda rejestracja osobno (fail-open): padnięta komenda nie wyłącza narzędzia i odwrotnie.
594 const brakujace: string[] = []
595 try {
596 await $.command.register({
597 name: 'warsztat',
598 description: 'Warsztat modu: claude plugin validate + test na CLI i binarce desktopu oraz tsc dla katalogu modu (argument albo cwd); tabela w panelu, „wynik” daje ją tekstem, „zamknij” zamyka panel',
599 argumentHint: '[katalog modu|wynik|zamknij]',
600 })
601 } catch {
602 brakujace.push('/warsztat')
603 }
604 try {
605 await $.tool.register({
606 name: 'szukaj',
607 description:
608 'JSDoc i sygnatura z lokalnego API modów Claude Code (claude-code/index.d.ts) po nazwie: zdarzenie (tool.call), metoda ($.fs.list albo fs.list), rzeczownik ($.fs — lista składników) albo typ (SessionUsage). Podciąg daje listę nazw z numerami linii. Zamiast grep po 16 tys. linii d.ts; wynik ≤ 2048 znaków.',
609 inputSchema: {
610 type: 'object',
611 properties: { nazwa: { type: 'string', description: 'Nazwa zdarzenia (tool.call), metody ($.fs.list), rzeczownika ($.fs) albo typu (SessionUsage); może być podciągiem (ToolCall → lista typów).' } },
612 required: ['nazwa'],
613 },
614 })
615 } catch {
616 brakujace.push('szukaj')
617 }
618 if (brakujace.length) $.ui.toast(`Warsztat: nie udało się zarejestrować: ${brakujace.join(', ')}.`)
619 // session.start odpala się też po przeładowaniu modułu: bieg sprzed reloadu nie żyje, więc „w toku” w $.state jest nieprawdziwe.
620 if (!bieg) void naprawPrzerwany($).catch(() => undefined)
621 return next(e)
622 }).catch(($, e, next) => next(e))
623
624 // Hook odpowiada za własne narzędzie (bez `next`: pod spodem nie ma nic, co by je obsłużyło). Odpowiedź to `{ result: tekst }`:
625 // silnik waliduje `result` wg schematu narzędzia — dla narzędzia z `$.tool.register` to string | bloki treści | undefined, obiekt MCP
626 // `{ content, isError }` odrzuca (próba na żywo 2026-10-17: „does not match its output shape”). Błąd → `{ deny: tekst }`: d.ts ToolCallResult
627 // „the model receives the text as an error result” — to jest isError narzędzia; wyjątku hook nie przepuszcza.
628 on('tool.call', { tool: NARZEDZIE }, async ($, e) => {
629 try {
630 const arg = (e as unknown as Record<string, unknown>).nazwa
631 const nazwa = typeof arg === 'string' ? arg : arg === undefined || arg === null ? '' : String(arg)
632 const z = await zaladujIndeks($)
633 return { result: szukaj(z.ind, z.linie, nazwa, z.sciezka) }
634 } catch (err) {
635 return { deny: `szukaj: błąd — ${opisBledu(err)}` }
636 }
637 }).catch(() => ({ deny: 'szukaj: błąd modu — hook odrzucony' }))
638
639 // Hook odpowiada za własną komendę; try/catch, bo hook, który się wysypie, silnik pomija (komenda bez odpowiedzi).
640 on('command.run', { command: 'warsztat' }, async ($, e) => {
641 try {
642 const a = e.args.trim()
643 if (a === 'wynik') {
644 if (!bieg) await naprawPrzerwany($)
645 return { text: tabela(await read($, warsztat)) }
646 }
647 if (a === 'zamknij') {
648 await $.ui.close({ id: PANE })
649 return { text: 'Warsztat zamknięty.' }
650 }
651 // Bieg wykonuje kod z katalogu modu (testy), więc tylko polecenie właściciela: prompt (composer) albo Remote Control (bridge) —
652 // nie plugin, harmonogram, SDK, peer ani notyfikacja zadania (PromptOrigin w d.ts).
653 if (!OD_WLASCICIELA.has(e.origin.kind)) return { text: `Warsztat: /warsztat uruchamia kod z katalogu modu, więc przyjmuję je tylko od właściciela (prompt albo Remote Control), nie z „${e.origin.kind}”.` }
654 return { text: await uruchom($, a) }
655 } catch (err) {
656 return { text: `Warsztat: błąd modu — ${opisBledu(err)}` }
657 }
658 }).catch(() => ({ text: 'Warsztat: błąd modu — hook odrzucony' }))
659
660 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
661 const { Box, Button, Text } = $.ui.resolve(e)
662 const s = await read($, warsztat)
663 const nazwa = s.katalog?.split('/').pop() ?? '—'
664 return (
665 <Box flexDirection="column">
666 <Text bold>{`Warsztat — ${nazwa}${s.wToku ? ' (w toku…)' : ''}`}</Text>
667 <Text dimColor wrap="truncate-end">
668 {s.katalog ?? 'bez katalogu'}
669 </Text>
670 {s.kroki.map(k => (
671 <Box key={k.id} gap={1}>
672 <Text bold color={kolor(k)} dimColor={k.stan === 'brak' || k.stan === 'czeka'}>
673 {pad(SYMBOL[k.stan], 8)}
674 </Text>
675 <Text wrap="truncate-end">{wiersz(k)}</Text>
676 </Box>
677 ))}
678 <Box gap={1}>
679 <Button key="powtorz" variant="primary" hotkey="p" label="Powtórz" onPress={() => void powtorz($)} />
680 <Button key="kopiuj" hotkey="k" label="Kopiuj błędy" onPress={press => void kopiujBledy($, press.surface)} />
681 <Button key="zamknij" role="dismiss" label="Zamknij" onPress={() => void $.ui.close({ id: PANE }).catch(() => undefined)} />
682 </Box>
683 <Text dimColor wrap="truncate-end">
684 {stopka(s)}
685 </Text>
686 </Box>
687 )
688 }).catch(($, e, next) => next(e))
689}
690types/index.d.ts 71 lines1// Kontrakt stanu modu kanon-warsztat ($.state sesji; po reloadzie modułu wartości zostają, zmienne modułu startują od nowa).
2// Między sesjami nic nie żyje: mod nie używa $.store ani plików.
3
4/** Binarka kroku: CLI `~/.local/bin/claude`, binarka desktopu z ~/Library/Application Support/Claude/claude-code/, `node tsc.js`. */
5export type Binarka = 'cli' | 'desktop' | 'tsc'
6export type RodzajKroku = 'validate' | 'test' | 'tsc'
7/** `czeka` → `biegnie` → `ok` | `fail` | `timeout`; `brak` od razu, gdy binarki nie ma (nie jest błędem). */
8export type StanKroku = 'czeka' | 'biegnie' | 'ok' | 'fail' | 'timeout' | 'brak'
9
10export type Krok = {
11 /** Klucz elementów panelu: `cli-validate`, `cli-test`, `desktop-validate`, `desktop-test`, `tsc`. */
12 id: string
13 binarka: Binarka
14 /** Etykieta kolumny binarki: `CLI`, `desktop 2.1.299`, `tsc`. */
15 etykieta: string
16 krok: RodzajKroku
17 /** Pełne argv przekazane do `$.process.run` (bez shella); `[]` gdy binarki brak. */
18 argv: string[]
19 stan: StanKroku
20 /** Czas kroku w ms (`$.clock.now()` przed i po); null, dopóki nie skończył. */
21 ms: number | null
22 /** Z wyjścia `claude plugin test`: ` N pass` / ` N fail`; null dla validate, tsc i gdy podsumowania nie ma. */
23 testy: { pass: number; fail: number } | null
24 /** Jedna linia do tabeli: `Validation passed`, `12 pass`, `10 pass, 2 fail (kod 1)`, `przekroczone 120 s`, `brak binarki …`. */
25 szczegoly: string
26}
27
28export type Warsztat = {
29 /** Katalog modu ostatniego biegu (bezwzględny, bez końcowego `/`); null przed pierwszym. */
30 katalog: string | null
31 kroki: Krok[]
32 /** `$.clock.now()` startu i końca biegu; `koniec` null w trakcie. */
33 start: number | null
34 koniec: number | null
35 /** Błąd modu poza krokami (odrzucony zapis stanu, wyjątek pętli); null, gdy go nie było. */
36 blad: string | null
37 wToku: boolean
38}
39
40/** Jeden wpis indeksu d.ts: nazwa (`tool.call`, `$.fs.list`, `$.fs`, `SessionUsage`), rodzaj, blok i linie (1-based, włącznie). */
41export type Wpis = {
42 nazwa: string
43 rodzaj: 'zdarzenie' | 'metoda' | 'rzeczownik' | 'typ'
44 /** Zdarzenie: nazwa bloku (`EngineEventOf`, `EngineResultOf`, `OpEventOf`, `OpValueOf`, `ChunkOf`); metoda/rzeczownik: `CoreEngineInterface`;
45 * typ: `<słowo> · <moduł>` (np. `type · claude-code`, `const · claude-code/testing`). */
46 blok: string
47 od: number
48 do: number
49 /** Pierwsza linia JSDoc stojącego bezpośrednio nad deklaracją; null, gdy go nie ma. */
50 docOd: number | null
51}
52
53/** Indeks pliku typów; ważny, dopóki wersja parsera, ścieżka, mtime i liczba linii się zgadzają (tekst pliku żyje tylko w pamięci modułu). */
54export type Indeks = {
55 /** `WERSJA_INDEKSU` modułu, który go zbudował — po zmianie regexów stary indeks z `$.state` nie przeżywa hot-reloadu. */
56 wersja: number
57 sciezka: string
58 mtimeMs: number
59 linii: number
60 wpisy: Wpis[]
61}
62
63declare module 'claude-code' {
64 interface PluginState {
65 'kanon-warsztat': {
66 warsztat: Warsztat
67 indeks: Indeks | null
68 }
69 }
70}
71