SLOPSHOPPER

kanon-warsztat

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…

paneguardcommandtoasttoolprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · kanon-warsztat
│ ┃ warsztat ✕ › fix the failing auth test and add an audit log call │ ┃ Warsztat — — │ ┃ bez katalogu ⏺ Read(src/auth.ts) │ ┃ [ Powtórz ] [ Kopiuj błędy ] [ Zamknij ] ⎿ Read 6 lines │ ┃ Jeszcze nic nie uruchomiono: /warsztat [kat… ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /warsztat │ ⎿ kanon-warsztat: Warsztat: /work/app nie jest katalogiem modu (br │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · warsztat
Warsztat — — bez katalogu [ Powtórz ] [ Kopiuj błędy ] [ Zamknij ] Jeszcze nic nie uruchomiono: /warsztat [katalog modu].
README

kanon-warsztat v0.1

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).

Skąd d.ts

  1. <$.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.
  2. Gdy go tam nie ma (mod uruchomiony z --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.
  3. Nic → odpowiedź narzędzia z 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

Użycie

  • szukaj { nazwa } (narzędzie modelu; zarejestrowane w session.start, dostępne przez ToolSearch jak każde narzędzie modu):
  • dokładna nazwa (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;
  • wielkość liter nie gra roli, gdy dokładne dopasowanie zawodzi (sessionusage);
  • podciąg (ToolCall) → szukaj: N trafień dla „ToolCall” — podaj pełną nazwę: i lista - nazwa (rodzaj, linia N), najwyżej 40 pozycji i … +N więcej;
  • brak → 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);
  • każda odpowiedź dłuższa niż 2048 znaków (także lista i „brak”) jest przycinana z dopiskiem … [przycięte do 2048 znaków; …];
  • kształt odpowiedzi hooka: { 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.
  • Kroki (w tej kolejności, każdy osobnym $.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.
  • Panel 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.
  • Hot-reload w trakcie biegu: 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.

Odstępstwa od karty §6.2

  • Karta: 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.
  • Matcher 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.
  • „≤ 2 KB” = 2048 znaków JS (znaki spoza ASCII liczone po jednym); lista podciągów ≤ 40 pozycji.
  • Kroki claude dostają CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 (jak kanon-flota) — validate i test nie potrzebują sieci.
  • Katalog modu musi mieć plugin.json (karta mówi tylko o cwd) — chroni przed uruchomieniem claude plugin test na dowolnym katalogu.
  • Komenda tylko z origin composer/bridge (karta o tym milczy) — jak D6 w kanon-skrzynka.
  • Baza ~/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.

Bramki

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.

  • Zero $.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).
  • Każdy hook z .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).
  • Odrzucenie $.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.
  • Dno testów odgrywa odmowy nounów przez { deny } (jak kanon-skrzynka), proces przez scenariusz (argv, numer) → ok|fail|timeout|brak|obiekt.

Testy

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)

  • Powtórz po końcu + panel bez miejsca.
Binarkavalidatetesttsc 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.

Recenzja

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.
  • 🟡 komenda od każdego origin → tylko 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 /.
  • 🟡 wyjście procesów (do 4000 znaków) w $.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.
  • 🟡 po reloadzie w trakcie biegu 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.
  • 🟡 brak pokrycia (a)–(g) → dodane (a) wyścig, (b) równoległe 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.
  • 🔵 trzy typy „płaskie” kończyły się w pierwszej linii → 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:

  • 🔵 blok 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.
  • 🟡 test złoty na prawdziwym d.ts w kicie — niemożliwy: kit nie ma dysku ($.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.
  • 🟡 (f) czas kroków — zegar kitu stoi, a przesuwanie go z wnętrza hooka dna process.run (advance podczas dispatchu) nie jest udokumentowane; czas pokazuje próba na żywo.
  • ❓ ważność $.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).

Sprawdzone na żywo / niesprawdzone

  • 2026-10-17, sesja 75062a34 (desktop 2.1.299): kopia w ~/.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.
  • Sprawdzone: 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.
  • Niesprawdzone do czasu relacji: rysowanie panelu w prawdziwym terminalu (kolory 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.

Uruchomienie i rollback

  • Próba: kopia do ~/.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.
  • Stabilnie: README repo (commit 5ca8558) wpisuje kanon-warsztat do 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.
  • Rollback: usunąć wpis z CLAUDE_CODE_PLUGIN_DIRS (albo folder z dev-mods). Mod nie zostawia nic poza $.state sesji — po restarcie nie ma śladu.
Source 2 files
hooks/register.tsx 690 lines
1// 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}
690
types/index.d.ts 71 lines
1// 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