SLOPSHOPPER

kanon-brief

Formularz ram polecenia na start (/brief): repo/folder, stawka, tryb, „gotowe, gdy”, recall, granice → składa polecenie po polsku i wkłada je do pola promptu…

panecommandtoastprompt
v0.1.0MITupdated 2026-10-16stelmach-systems/claude-mods/kanon-brief
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · kanon-brief
│ ┃ Brief ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ Brief — ramy polecenia na start │ kanon-brief │ │ ┃ Polecenie: co ma być zrobione ⏎ zapisz ⏺ Read(src/auth.ts) │ Krótkie polecenie na start — /brief doda │ │ ┃ Repo/folder: /work/app ⏎ zapisz ⎿ Read 6 lines │ repo, stawkę i kryterium „gotowe” │ │ ┃ Stawka: ( ) niska ( ) istotna ( ) wysoka ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ Tryb: ( ) sam ( ) subagent ( ) workflow ⎿ Added 2 lines, removed 1 line │ ┃ Gotowe, gdy: kryterium sprawdzalne komendą a ⏺ Bash(bun test) │ ┃ [ ] najpierw /ops-recall ⎿ 3 pass, 1 fail │ ┃ Granice: [ ] bez zapisu na produkcję [ ] bez │ ┃ [ Uruchom ] [ Kopiuj ] [ Pomiń ] ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ramy: Repo/folder: /work/app │ ✻ Worked for 42s · done 4:20 PM │ │ › /brief │ ⎿ kanon-brief: Formularz ram otwarty (/work/app). │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Brief
Brief — ramy polecenia na start Polecenie: co ma być zrobione ⏎ zapisz Repo/folder: /work/app ⏎ zapisz Stawka: ( ) niska ( ) istotna ( ) wysoka Tryb: ( ) sam ( ) subagent ( ) workflow Gotowe, gdy: kryterium sprawdzalne komendą albo w aplikacji [ ] najpierw /ops-recall Granice: [ ] bez zapisu na produkcję [ ] bez PII do gita [ ] [ Uruchom ] [ Kopiuj ] [ Pomiń ] ramy: Repo/folder: /work/app
README

kanon-brief — formularz ram polecenia na start (/brief, v0.1)

Mod Claude Code (API „function hooks”, early access; pisany pod 2.1.299, testy zielone na desktop 2.1.299 i CLI 2.1.289). Plan: claude-kanon/claude-code/mods/PLAN-mody-2026-10-16-z-logow.md §3.2. Z logów 42 transkryptów: 23 z 74 poleceń poniżej 80 znaków, 6 z 17 startów nazywa repo, 9 dopytań AskUserQuestion i 7 odpowiedzi „numerami” w osobnych turach, ops-recall 3 razy na 17 startów. Odpowiednik modu „Prompt Upgrade” z filmu O'Briena, ale bez modelu: formularz jest deterministyczny i darmowy.

  • /brief [tekst]: panel z formularzem — Polecenie (tekst z argumentu komendy), Repo/folder (domyślnie cwd sesji, pod nim przyciski podkatalogów pierwszego poziomu: bez ukrytych i node_modules, posortowane, 12 przycisków, reszta jako „+N więcej — wpisz ścieżkę”; dowiązanie symboliczne ma kind: 'other' i przycisku nie dostaje), Stawka niska / istotna / wysoka (wysoka dopisuje „analizę oddziel od mutacji, zgoda na każdy zapis”), Tryb sam / subagent / workflow, „Gotowe, gdy”, Recall ([ ] najpierw /ops-recall), Granice (bez zapisu na produkcję, bez PII do gita, bez pusha). Drugie naciśnięcie radia zdejmuje wybór.
  • Uruchom: wymaga tekstu polecenia (same ramy — repo domyślnie = cwd — to nie zadanie; toast). Składa polecenie = tekst + blok „Ramy:” tylko z wypełnionych pól, zapisuje pamięć folderu, zamyka panel (panel z fokusem trzyma klawiaturę) i wkłada tekst do pola promptu ($.prompt.fill, tryb replace — kasuje szkic w polu) — Enter naciska właściciel. Odmowa no_composer (powierzchnia bez pola promptu: headless albo rysująca własny composer) → od razu schowek i toast „wklej”; dialog albo przyczyna nieznana → panel wraca wypełniony, toast kieruje do „Kopiuj”. Błąd w trakcie (np. $.ui.close odrzucone) → toast z błędem, panel wraca, jeśli zdążył się zamknąć.
  • Kopiuj: to samo polecenie do schowka ($.ui.copy); pole promptu nietknięte, panel zostaje.
  • Pomiń: zamyka panel bez dotykania pola promptu i bez zapisu pamięci. Esc robi to samo.
  • Toast tury 1: przy pierwszym poleceniu sesji ($.session.turns() = 0), krótszym niż 200 znaków, wpisanym przez osobę: „Krótkie polecenie na start — /brief doda repo, stawkę i kryterium „gotowe””. Raz na sesję (flaga w $.state); polecenie przechodzi niezmienione — prompt.submit tylko obserwuje (decyzja właściciela 2026-10-16; { drop } i przechwycenie Enter dopiero w v0.2, po dwóch tygodniach użycia). Bez toastu: komenda slash, isMeta, sesja wznowiona (turns > 0), polecenie pluginu, peera, notyfikacji zadania, harmonogramu, kanału (lista wykluczeń NIE_OSOBA), a także po /brief w tej sesji — więc i dla polecenia, które mod sam złożył (krótkie z ramami bywa poniżej 200 znaków).
  • Pamięć per folder: $.store klucz brief:<cwd> → { repo, stawka, tryb, recall, granice }; następny /brief w tym folderze wypełnia formularz tymi polami (puste repo z pamięci → cwd). /brief wyczysc (też wyczyść) kasuje wpis bieżącego folderu i zamyka formularz (otwarty zapisałby wpis z powrotem przy „Uruchom”); /brief zamknij zamyka panel. Wpis spoza wzorca (ręczna edycja pliku store) — pola nieznane pominięte, formularz działa.
  • Pola tekstowe: każdy klawisz idzie do szkicu w zmiennej modułu (bez przerysowania panelu), Enter w polu zapisuje do $.state i odświeża podgląd ramy:; przyciski czytają szkic nad stanem, więc tekst bez Entera też trafia do polecenia. Przeładowanie modu gubi tylko szkice.

Przykład złożonego polecenia

napraw test brief.test.ts

Ramy:
- Repo/folder: /Users/ksdevit/Developer/claude-mods/kanon-brief
- Stawka: wysoka — analizę oddziel od mutacji, zgoda na każdy zapis
- Tryb: sam
- Gotowe, gdy: claude plugin test . zielone na obu binarkach
- Recall: najpierw /ops-recall
- Granice: bez zapisu na produkcję, bez pusha

Sam tekst bez zaznaczeń → polecenie bez bloku „Ramy:”; same ramy bez tekstu → sam blok.

Odstępstwa od planu §3.2 (do decyzji właściciela)

  • Pamięć folderu bez tekstu polecenia i bez „gotowe, gdy” — to treść zadania, nie folderu (dane modów bez treści rozmów). Plan mówił o „ostatnich wartościach pól”.
  • Origin polecenia: lista wykluczeń (NIE_OSOBA) zamiast listy osób. Typy 2.1.299 wymieniają composer, bridge, sdk, unclassified…, a transkrypty desktopu (2.1.299) stemplują polecenie osoby jako "kind":"human", którego w typach nie ma; nowy albo niestemplowany origin liczy się jak osoba (toast u nikogo nie szkodzi, brak toastu u osoby psuje sens modu).
  • Aplikacja mobilna: jej tabela elementów nie ma Input → zamiast formularza jedna linia z wyjaśnieniem (desktop albo terminal).

Sprawdzone na żywo (binarka desktop 2.1.299 w terminalu, --plugin-dir, 2026-10-16/17)

  1. /brief napraw test → panel jako kolumna boczna ~50 znaków (zrzut niżej). Pierwszy rysunek ucinał wiersz „Granice:” do „G” i gubił trzeci przełącznik, a gap dokładał pusty wiersz między zawiniętymi rzędami podkatalogów → wiersze mają flexWrap="wrap" i columnGap. Etykietę Gotowe, gdy wąski panel łamie na dwie linie (rysuje to Input silnika).
  2. Sonda (osobny mod w scratchpadzie, nie w repo; $.ui.log przy prompt.submit): pierwsze polecenie sesji → turns=0, origin={"kind":"composer"}, wait=false, command i isMeta undefined — warunki toastu zachodzą. Toast narysowany (zrzut niżej).
  3. Edycja pliku modu → kanon-brief: reloaded (4 hooks: session.start, prompt.submit, command.run, ui.render), otwarty panel przerysowany nowym kodem; silnik położył .claude-plugin/types/ (z własnym .gitignore), więc tsc -p kanon-brief działa.

Zrzut panelu (tekst z panelu terminala aplikacji):

│Brief — ramy polecenia na start
│Polecenie: napraw test
│Repo/folder: /Users/ksdevit/Developer
│podkatalogi: _kopie BrandSignal
│chase-ai-materialy claude-canon claude-figma
│claude-kanon claude-masterclass-ola claude-mods
│claude-mods-stabilne claude-remotion clients
│cv-2026
│Stawka: ( ) niska ( ) istotna ( ) wysoka
│Tryb: ( ) sam ( ) subagent ( ) workflow
│Gotowe,    kryterium sprawdzalne komendą albo w …
│gdy:
│[ ] najpierw /ops-recall
│Granice: [ ] bez zapisu na produkcję
│[ ] bez PII do gita [ ] bez pusha
│[ Uruchom ] [ Kopiuj ] [ Pomiń ]
│ramy: Repo/folder: /Users/ksdevit/Developer

Zrzut toastu (po claude … "odpowiedz jednym slowem: tak" jako pierwszym poleceniu sesji):

╭──────────────────────────────────────────╮
│ kanon-brief                              │
│ Krótkie polecenie na start — /brief doda │
│  repo, stawkę i kryterium „gotowe”       │
╰──────────────────────────────────────────╯

Niesprawdzone

  • Sesja desktop: kopia w ~/.claude/dev-mods/<session_id>/kanon-brief/ czeka na zgodę właściciela na hot reload (pytanie przy końcu tury). Do zobaczenia tam: rysunek Pane na desktopie, $.prompt.fill po zamknięciu panelu z fokusem (w kicie: tak), schowek $.ui.copy, naciśnięcia przycisków i zapis $.store po „Uruchom” (w kicie: tak; w terminalu panelu model nie wciska klawiszy).
  • Origin polecenia osoby na desktopie (human z transkryptów) — lista wykluczeń przepuszcza go bez sprawdzenia na żywo; sonda sonda-brief w dev-mods tej sesji zapisuje do swojego $.store, co hook widzi przy kolejnych poleceniach właściciela.
  • Czy $.prompt.fill działa na desktopie: typy opisują no_composer jako „a surface that draws its own composer” — jeśli to desktop, „Uruchom” kończy się schowkiem (toast mówi, że trzeba wkleić), a głównym przyciskiem desktopu jest w praktyce „Kopiuj”.

Bramki

Zero $.model, $.http, $.process, $.fs.write, bez tool.check; $.fs.list czyta tylko nazwy wpisów cwd. Jedyny stan między sesjami: $.store brief:<cwd> bez tekstu polecenia i bez „gotowe”. Hooki session.start i prompt.submit: try/catch w środku i .catch → next(e) (fail-open — błąd modu nie zatrzymuje polecenia ani startu sesji); command.run odpowiada za własną komendę bez next i ma try/catch (błąd = tekst Brief: błąd modu — … w odpowiedzi komendy; walidator i tak wypisuje go jako gating hook without .catch — fakt, nie ostrzeżenie). Mod nie pisze plików i nie wysyła nic poza sesję.

Linie walidatora (claude plugin validate, 2.1.299):

hooks: session.start, prompt.submit, command.run{command=brief}, ui.render{component=Pane, requestId=brief}
gating hook with .catch: prompt.submit
gating hook without .catch: command.run{command=brief}
calls: $.command.register, $.fs.list (via otworz), $.prompt.fill (via uruchom), $.session.cwd, $.session.turns, $.state.get, $.state.set, $.store.delete, $.store.get (via otworz), $.store.set (via zapamietaj), $.ui.close, $.ui.copy (via kopiuj, uruchom), $.ui.open (via otworzPanel), $.ui.resolve, $.ui.toast
state writes: kanon-brief.cwd, kanon-brief.podkatalogi, kanon-brief.polecenie, kanon-brief.ramy, kanon-brief.toast

Testy

claude plugin test . — 25 testów, zielone na desktop 2.1.299 i CLI 2.1.289 (silnik odegrany hookami testu: store jako Mapa, cwd/turns, fs.list, ui.*, prompt.fill; brzmienia ze spec jako literały w asercjach): zloz() (tylko wypełnione pola, sam tekst, sam blok, nic, białe znaki); toast raz w turze 1 i cisza przy drugim krótkim, wejście dochodzi do dna bez dopisków; bez toastu przy ≥ 200 znakach (granica 199/200), komendzie slash i command, isMeta, pustym tekście, turns = 3, originach nie-osoby (task-notification, plugin, peer, peer-send-message, scheduled-trigger, auto-continuation, coordinator, channel); toast dla human (spoza typów), undefined, bridge, sdk; brak toastu na poleceniu złożonym przez mod i po /brief; formularz na terminal i desktop (podkatalogi, złożenie, prompt.fill replace, panel zamknięty, dokładna treść wpisu store); Enter w polu → stan i podgląd, szkic bez Entera wygrywa, wyczyszczenie Enterem zeruje; Kopiuj (schowek z surface, prompt nietknięty, pamięć zapisana, odmowa schowka → toast z powodem); Pomiń (bez fill, bez schowka, bez zapisu); Uruchom bez tekstu → toast, Kopiuj bez niczego → toast; pamięć per folder, inny folder pusty, puste repo → cwd, /brief wyczysc kasuje i zamyka, alias wyczyść, /brief zamknij; wpis store spoza wzorca; odmowa prompt.fill (panel wraca, toast do „Kopiuj”); $.ui.close rzuca (toast z błędem, bez fill, bez ponownego otwarcia); fail-open ($.session.turns rzuca → polecenie przechodzi; $.session.cwd rzuca → /brief odpowiada tekstem błędu); $.fs.list pada (formularz bez podkatalogów) i panel bez miejsca (isPlaced: false → odpowiedź z powodem);

12 podkatalogów („+3 więcej”, node_modules pominięte); aplikacja mobilna. Type-check: tsc 6.0.3 strict (tsc -p kanon-brief

po pierwszym załadowaniu modu; wcześniej tsconfig z nagłówka pliku typów skilla, trzymany poza folderem modu).

Luki kitu: $.prompt.submit z $ testu bierze pełne wejście zdarzenia (wait, origin), które w sesji stempluje silnik — helper wpisz w teście; refusal z odpowiedzi hooka prompt.fill silnik zdejmuje (typy: „a hook's own refusal carries none”) — gałęzie dialog i no_composer poza zasięgiem kitu (testowana gałąź „przyczyna nieznana”); rzucające dno kit zgłasza jako „no implementation for <noun.method>”, nie treścią błędu.

Recenzja (recenzent [S5.5HI], 1 runda, 2026-10-17)

Naprawione (🟡): toast odpalał się na poleceniu złożonym przez sam mod → flaga toastu ustawiana przy /brief; uruchom/kopiuj bez try/catch (odrzucone $.ui.close/$.prompt.fill/$.ui.copy = cisza) → try/catch z toastem, panel wraca; strażnik „nic do złożenia” martwy przy domyślnym repo = cwd → „Uruchom” wymaga tekstu; no_composer → od razu schowek; .slice(0, 12) ucinało listę bez śladu → „+N więcej”, node_modules pominięte; testy importowały brzmienia z kodu → literały; brak granicy 199/200, isMeta, command, pozostałych originów, fail-open, Entera w polu, pamięci przy Kopiuj, odmowy schowka, zamknij, aliasu → dodane (15 → 25). 🟢: surowy kod odmowy w toaście → po polsku; puste repo z pamięci nadpisywało cwd → zapis.repo || folder; wyczysc przy otwartym panelu → zamyka panel; mock refusal mylił → usunięty.

Zostawione — decyzje właściciela:

  1. $.store rośnie o wpis (~150 B) na folder bez limitu; limit sklepu 4 MiB ≈ 25 000 folderów. Bez sprzątania w v0.1 (/brief wyczysc per folder). Jeśli chcieć limitu: liczba kluczy brief:* + usuwanie najstarszych (wpis musiałby dostać znacznik czasu).
  2. „Pomiń” po /brief <tekst> gubi tekst z linii komendy (komenda zjadła prompt). Plan §3.2: „Pomiń → oryginał z powrotem”; zakres v0.1 z briefu: „Pomiń bez dotykania promptu” — zostaje v0.1. Alternatywa: Pomiń wkłada sam tekst (bez ram) do pola promptu.
  3. unclassified (turn bez rozpoznanego pochodzenia, w tym harmonogram i /loop) liczy się jak osoba — toast w nieobserwowanej sesji nie szkodzi; do zmiany, gdy sonda pokaże, że desktop stempluje polecenie osoby jako composer/sdk.
  4. Dowiązania symboliczne do repo (kind: 'other') bez przycisku — ścieżkę wpisuje się ręcznie.

Odrzucone: szkice jako zmienna modułu nie przeciekają między sesjami — każda sesja to osobny proces claude (zmienna modułu = per proces); append zamiast replace — tryb replace jest w zakresie v0.1, skutek opisany wyżej.

Uruchomienie i rollback

Próba w jednej sesji: claude --plugin-dir ~/Developer/claude-mods/kanon-brief (terminal) albo kopia w ~/.claude/dev-mods/<session_id>/ (desktop, hot reload za zgodą). Na stałe: ~/Developer/claude-mods-stabilne/kanon-brief w env.CLAUDE_CODE_PLUGIN_DIRS (~/.claude/settings.json, z backupem), od NOWEJ sesji. Rollback: usunięcie ścieżki z env (od nowej sesji); dane modu: rm -f ~/.claude/plugins/store/kanon-brief*.

Source 2 files
hooks/register.tsx 356 lines
1// kanon-brief — formularz ram polecenia na start (mod Claude Code, API function hooks 2.1.299; early access). v0.1.
2// Z logów 42 transkryptów (plan modów 2026-10-16, §1 i §3.2): 23 z 74 poleceń poniżej 80 znaków, 6 z 17 startów nazywa repo,
3// 9 dopytań AskUserQuestion i 7 odpowiedzi „numerami” w osobnych turach, `ops-recall` 3 razy na 17 startów. Odpowiednik modu
4// „Prompt Upgrade” z filmu O'Briena, ale BEZ modelu: formularz jest deterministyczny i darmowy — składa polecenie po polsku
5// z pól, które właściciel wypełnił (tekst + blok „Ramy:”), i wkłada je do pola promptu (`$.prompt.fill`, tryb `replace`);
6// Enter naciska właściciel. „Kopiuj” → schowek (`$.ui.copy`), „Pomiń” → zamyka formularz bez dotykania pola promptu.
7// v0.1 (decyzja właściciela 2026-10-16): `prompt.submit` tylko OBSERWUJE — jeden toast na sesję, w turze 1, gdy polecenie z klawiatury
8// ma mniej niż 200 znaków; polecenie przechodzi niezmienione. `{ drop }` i przechwycenie Enter dopiero w v0.2 po dwóch tygodniach użycia.
9// Pamięć ram per folder w `$.store` (klucz `brief:<cwd>`): repo, stawka, tryb, recall, granice — BEZ tekstu polecenia i bez
10// „gotowe, gdy” (to treść zadania, nie folderu; dane modów bez treści rozmów). `/brief wyczysc` kasuje wpis bieżącego folderu.
11// Pola tekstowe: każde naciśnięcie klawisza idzie do szkicu w zmiennej modułu (bez przerysowania panelu), Enter w polu zapisuje do
12// `$.state` (przerysowanie, podgląd ram); przyciski czytają szkic nad stanem, więc tekst bez Entera też trafia do polecenia.
13// Bramki: zero `$.model`, zero `$.http`, bez `tool.check`, bez zapisu plików (`$.fs.list` tylko czyta cwd); jedyny stan między
14// sesjami to `$.store`. Hooki zdarzeń mają try/catch i `.catch` → `next(e)`: błąd modu nie zatrzymuje polecenia ani startu sesji.
15import { atom, read, update } from 'claude-code'
16import type { EngineInterface, PromptOrigin, Register, RenderSurface } from 'claude-code'
17
18import type { Granice, Ramy, Stawka, Tryb } from '../types'
19
20const PANE = 'brief'
21const PROG_TOASTU = 200 // znaków; poniżej — toast w turze 1 (plan §3.2)
22const MAX_PODKATALOGOW = 12 // przycisków; dłuższa lista = więcej rzędów niż wart jest formularz (reszta: „+N więcej”)
23const POMIJANE_KATALOGI: ReadonlySet<string> = new Set(['node_modules']) // nigdy nie jest „repo”
24const WIERSZE_PANELU = 14 // prośba o wysokość inline (silnik i właściciel mogą dać inną)
25/** Originy, które NIE są poleceniem wpisanym przez osobę (typy 2.1.299: PromptOrigin). Lista wykluczeń, nie dopuszczeń: nowy albo
26 *  niestemplowany origin (starsza binarka, zestaw testowy, kanał bez stempla) liczy się jak osoba — toast u nikogo nie szkodzi,
27 *  a brak toastu u osoby psuje sens moda. `sdk` zostaje dopuszczony: tak może przychodzić prompt z aplikacji desktop. */
28const NIE_OSOBA: ReadonlySet<string> = new Set([
29  'plugin', 'task-notification', 'scheduled-trigger', 'peer', 'peer-send-message', 'projects-relay', 'channel',
30  'coordinator', 'observer', 'observer-activity', 'auto-continuation', 'slack-ping',
31])
32
33// Skaner walidatora wymaga literałów plugin/key w każdym atomie (nie stałej) — stąd powtórzona nazwa.
34const PUSTE_RAMY: Ramy = { repo: '', stawka: null, tryb: null, gotowe: '', recall: false, granice: { produkcja: false, pii: false, push: false } }
35const ramy = atom({ plugin: 'kanon-brief', key: 'ramy' } as const, PUSTE_RAMY)
36const polecenie = atom({ plugin: 'kanon-brief', key: 'polecenie' } as const, '')
37const cwd = atom({ plugin: 'kanon-brief', key: 'cwd' } as const, '')
38const podkatalogi = atom({ plugin: 'kanon-brief', key: 'podkatalogi' } as const, [] as string[])
39const toast = atom({ plugin: 'kanon-brief', key: 'toast' } as const, false)
40
41const STAWKI: readonly Stawka[] = ['niska', 'istotna', 'wysoka']
42const TRYBY: readonly Tryb[] = ['sam', 'subagent', 'workflow']
43const GRANICE: readonly { klucz: keyof Granice; opis: string }[] = [
44  { klucz: 'produkcja', opis: 'bez zapisu na produkcję' },
45  { klucz: 'pii', opis: 'bez PII do gita' },
46  { klucz: 'push', opis: 'bez pusha' },
47]
48export const DOPISEK_WYSOKIEJ = 'analizę oddziel od mutacji, zgoda na każdy zapis'
49export const DOPISEK_RECALL = 'najpierw /ops-recall'
50export const TOAST_STARTU = 'Krótkie polecenie na start — /brief doda repo, stawkę i kryterium „gotowe”'
51
52export const kluczPamieci = (folder: string) => `brief:${folder}`
53
54/** Linie bloku „Ramy:” — tylko pola wypełnione, w stałej kolejności formularza. */
55export const linieRam = (r: Ramy): string[] => {
56  const l: string[] = []
57  if (r.repo.trim()) l.push(`Repo/folder: ${r.repo.trim()}`)
58  if (r.stawka) l.push(`Stawka: ${r.stawka}${r.stawka === 'wysoka' ? ` — ${DOPISEK_WYSOKIEJ}` : ''}`)
59  if (r.tryb) l.push(`Tryb: ${r.tryb}`)
60  if (r.gotowe.trim()) l.push(`Gotowe, gdy: ${r.gotowe.trim()}`)
61  if (r.recall) l.push(`Recall: ${DOPISEK_RECALL}`)
62  const g = GRANICE.filter(x => r.granice[x.klucz]).map(x => x.opis)
63  if (g.length) l.push(`Granice: ${g.join(', ')}`)
64  return l
65}
66
67/** Złożone polecenie: tekst, pusta linia, blok ram (tylko wypełnione pola). Sam tekst bez ram, sam blok bez tekstu; '' gdy nic nie ma. */
68export const zloz = (tekst: string, r: Ramy): string => {
69  const t = tekst.trim()
70  const l = linieRam(r)
71  if (!l.length) return t
72  const blok = ['Ramy:', ...l.map(x => `- ${x}`)].join('\n')
73  return t ? `${t}\n\n${blok}` : blok
74}
75
76/** Wpis $.store → pola ram; wartości spoza wzorca (ręczna edycja, starsza wersja modu) są pomijane, nie psują formularza. */
77const zPamieci = (v: unknown): Partial<Ramy> => {
78  if (!v || typeof v !== 'object') return {}
79  const o = v as Record<string, unknown>
80  const r: Partial<Ramy> = {}
81  if (typeof o.repo === 'string') r.repo = o.repo
82  if (STAWKI.includes(o.stawka as Stawka)) r.stawka = o.stawka as Stawka
83  if (TRYBY.includes(o.tryb as Tryb)) r.tryb = o.tryb as Tryb
84  if (typeof o.recall === 'boolean') r.recall = o.recall
85  const g = o.granice
86  if (g && typeof g === 'object') {
87    const gg = g as Record<string, unknown>
88    r.granice = { produkcja: gg.produkcja === true, pii: gg.pii === true, push: gg.push === true }
89  }
90  return r
91}
92/** Co idzie do $.store: ramy folderu bez tekstu polecenia i bez „gotowe, gdy”. */
93const doPamieci = (r: Ramy) => ({ repo: r.repo, stawka: r.stawka, tryb: r.tryb, recall: r.recall, granice: r.granice })
94
95// Szkice pól tekstowych między naciśnięciem klawisza a Enterem/przyciskiem (zmienna modułu: reload modu je gubi, stan nie).
96type Pole = 'polecenie' | 'repo' | 'gotowe'
97// Zmienna modułu = per proces; każda sesja (także desktop) to osobny proces `claude`, więc szkice nie przeciekają między sesjami.
98const szkice = new Map<Pole, string>()
99
100/** Wartości pól do złożenia: szkic (tekst wpisany bez Entera) nad stanem. */
101async function zebrane($: EngineInterface): Promise<{ tekst: string; r: Ramy }> {
102  const [r, p] = await Promise.all([read($, ramy), read($, polecenie)])
103  return { tekst: szkice.get('polecenie') ?? p, r: { ...r, repo: szkice.get('repo') ?? r.repo, gotowe: szkice.get('gotowe') ?? r.gotowe } }
104}
105
106const otworzPanel = ($: EngineInterface) => $.ui.open({ id: PANE, title: 'Brief', focus: true, closeOnEscape: true, rows: WIERSZE_PANELU })
107
108/** `/brief [tekst]`: pola z pamięci folderu (gdy jest) nad pustymi, repo = cwd, podkatalogi z `$.fs.list`; otwiera panel. */
109async function otworz($: EngineInterface, tekst: string): Promise<string> {
110  const folder = await $.session.cwd()
111  let lista: string[] = []
112  try {
113    // `kind: 'dir'` — dowiązanie symboliczne do katalogu ma `kind: 'other'` i przycisku nie dostaje (ścieżkę wpisuje się ręcznie).
114    lista = (await $.fs.list(folder))
115      .filter(e => e.kind === 'dir' && !e.name.startsWith('.') && !POMIJANE_KATALOGI.has(e.name))
116      .map(e => e.name)
117      .sort((a, b) => a.localeCompare(b))
118  } catch {
119    lista = [] // folder nieczytelny albo zdalny — formularz bez przycisków, repo wpisuje się ręcznie
120  }
121  const zapis = zPamieci(await $.store.get(kluczPamieci(folder)).catch(() => undefined))
122  szkice.clear()
123  await update($, cwd, () => folder)
124  await update($, podkatalogi, () => lista)
125  await update($, ramy, () => ({ ...PUSTE_RAMY, ...zapis, repo: zapis.repo || folder })) // puste repo z pamięci → cwd
126  await update($, polecenie, () => tekst)
127  await update($, toast, () => true) // kto otworzył /brief, podpowiedzi o /brief nie potrzebuje (także dla polecenia, które mod złoży)
128  const o = await otworzPanel($)
129  const skad = Object.keys(zapis).length ? ', pola z pamięci folderu' : ''
130  return o.isPlaced ? `Formularz ram otwarty (${folder}${skad}).` : `Formularz ram czeka na szersze okno: ${o.reason}`
131}
132
133async function zapamietaj($: EngineInterface, r: Ramy): Promise<void> {
134  const folder = await read($, cwd)
135  if (folder) await $.store.set(kluczPamieci(folder), doPamieci(r))
136}
137
138const opisBledu = (err: unknown) => (err instanceof Error ? err.message : String(err))
139
140/** „Uruchom”: złożone polecenie do pola promptu (replace); panel zamknięty PRZED fill, bo otwarty z `focus` trzyma klawiaturę.
141 *  Wymaga tekstu polecenia — same ramy (repo domyślnie = cwd) to nie zadanie. `no_composer` (powierzchnia bez pola promptu:
142 *  headless albo rysująca własny composer) → od razu schowek; `dialog` albo przyczyna nieznana → panel wraca, „Kopiuj”. */
143async function uruchom($: EngineInterface, surface: RenderSurface): Promise<void> {
144  let zamkniety = false
145  try {
146    const { tekst, r } = await zebrane($)
147    if (!tekst.trim()) {
148      $.ui.toast('Brief: wpisz polecenie — same ramy to nie zadanie')
149      return
150    }
151    const calosc = zloz(tekst, r)
152    await zapamietaj($, r)
153    await $.ui.close({ id: PANE })
154    zamkniety = true
155    const f = await $.prompt.fill({ text: calosc, mode: 'replace' })
156    if (f.isFilled) {
157      $.ui.toast('Brief: polecenie w polu promptu — Enter wysyła')
158      return
159    }
160    if (f.refusal === 'no_composer') {
161      const c = await $.ui.copy({ text: calosc, surface })
162      $.ui.toast(
163        c.isCopied
164          ? 'Brief: ta powierzchnia nie ma pola promptu — polecenie w schowku, wklej je'
165          : `Brief: ta powierzchnia nie ma pola promptu, a schowek odmówił (${c.reason})`,
166        { timeoutMs: 8000 },
167      )
168      return
169    }
170    await otworzPanel($) // stan został, formularz wraca wypełniony
171    $.ui.toast(
172      f.refusal === 'dialog'
173        ? 'Brief: okno dialogowe trzyma klawiaturę — zamknij je albo użyj „Kopiuj”'
174        : 'Brief: pole promptu nie przyjęło tekstu — użyj „Kopiuj”',
175      { timeoutMs: 8000 },
176    )
177  } catch (err) {
178    $.ui.toast(`Brief: błąd — ${opisBledu(err)}; użyj „Kopiuj”`, { timeoutMs: 8000 })
179    if (zamkniety) await otworzPanel($).catch(() => undefined)
180  }
181}
182
183async function kopiuj($: EngineInterface, surface: RenderSurface): Promise<void> {
184  try {
185    const { tekst, r } = await zebrane($)
186    const calosc = zloz(tekst, r)
187    if (!calosc) {
188      $.ui.toast('Brief: nic do skopiowania — wpisz polecenie albo zaznacz pola')
189      return
190    }
191    await zapamietaj($, r)
192    const c = await $.ui.copy({ text: calosc, surface })
193    $.ui.toast(c.isCopied ? 'Brief: polecenie w schowku' : `Brief: schowek nie przyjął tekstu (${c.reason})`)
194  } catch (err) {
195    $.ui.toast(`Brief: błąd kopiowania — ${opisBledu(err)}`, { timeoutMs: 8000 })
196  }
197}
198
199export const register: Register = on => {
200  on('session.start', async ($, e, next) => {
201    await $.command.register({
202      name: 'brief',
203      description: 'Formularz ram polecenia: repo/folder, stawka, tryb, „gotowe, gdy”, recall, granice → składa polecenie i wkłada do pola promptu; „wyczysc” kasuje pamięć folderu',
204      argumentHint: '[tekst polecenia|wyczysc|zamknij]',
205    })
206    return next(e)
207  }).catch(($, e, next) => next(e))
208
209  // v0.1: obserwacja. Toast raz na sesję, gdy pierwsze polecenie osoby (origin spoza NIE_OSOBA) jest krótkie; komendy slash,
210  // prompty meta, pluginów, peerów i sesja wznowiona (turns > 0) — cisza. `next(e)` zawsze z niezmienionym `e`.
211  on('prompt.submit', async ($, e, next) => {
212    try {
213      // Czyje polecenie: plugin, peer, notyfikacja zadania, harmonogram, kanał — nie osoba (NIE_OSOBA); reszta tak.
214      const kind = (e.origin as PromptOrigin | undefined)?.kind
215      const zKlawiatury = kind === undefined || !NIE_OSOBA.has(kind)
216      const t = e.text.trim()
217      if (zKlawiatury && !e.command && !e.isMeta && t.length > 0 && t.length < PROG_TOASTU && !t.startsWith('/')) {
218        if (!(await read($, toast)) && (await $.session.turns()) === 0) {
219          await update($, toast, () => true)
220          $.ui.toast(TOAST_STARTU, { timeoutMs: 8000 })
221        }
222      }
223    } catch {
224      // obserwator: błąd odczytu nie dotyka polecenia
225    }
226    return next(e)
227  }).catch(($, e, next) => next(e))
228
229  // Hook odpowiada za własną komendę; try/catch, bo hook, który się wysypie, silnik pomija (komenda bez odpowiedzi).
230  on('command.run', { command: 'brief' }, async ($, e) => {
231    try {
232      const arg = e.args.trim()
233      const a = arg.toLowerCase()
234      if (a === 'wyczysc' || a === 'wyczyść') {
235        const folder = await $.session.cwd()
236        await $.store.delete(kluczPamieci(folder))
237        await $.ui.close({ id: PANE }) // otwarty formularz zapisałby wpis z powrotem przy „Uruchom”
238        return { text: `Pamięć ram folderu ${folder} wyczyszczona.` }
239      }
240      if (a === 'zamknij') {
241        await $.ui.close({ id: PANE })
242        return { text: 'Formularz ram zamknięty.' }
243      }
244      return { text: await otworz($, arg) }
245    } catch (err) {
246      return { text: `Brief: błąd modu — ${err instanceof Error ? err.message : String(err)}` }
247    }
248  })
249
250  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
251    if (e.surface === 'mobile') {
252      // Tabela elementów aplikacji mobilnej nie ma `Input` (typy 2.1.299) — formularz bez pól tekstowych nie ma sensu.
253      const { Box, Text } = $.ui.resolve(e)
254      return (
255        <Box>
256          <Text dimColor>Formularz /brief wymaga pól tekstowych, których aplikacja mobilna nie rysuje — użyj go na desktopie albo w terminalu.</Text>
257        </Box>
258      )
259    }
260    const { Box, Button, Input, Text } = $.ui.resolve(e)
261    const [r, p, folder, lista] = await Promise.all([read($, ramy), read($, polecenie), read($, cwd), read($, podkatalogi)])
262    const tekst = szkice.get('polecenie') ?? p
263    const repo = szkice.get('repo') ?? r.repo
264    const gotowe = szkice.get('gotowe') ?? r.gotowe
265    const podglad = linieRam({ ...r, repo, gotowe })
266    const radio = (wl: boolean) => (wl ? '(•)' : '( )')
267    const krzyzyk = (wl: boolean) => (wl ? '[x]' : '[ ]')
268    const zapiszTekst = (v: string) => {
269      szkice.delete('polecenie')
270      void update($, polecenie, () => v)
271    }
272    const zapiszPole = (pole: 'repo' | 'gotowe') => (v: string) => {
273      szkice.delete(pole)
274      void update($, ramy, x => ({ ...x, [pole]: v }))
275    }
276
277    // Wiersze zawijają się (`flexWrap`), bo w terminalu panel bywa wąską kolumną (~50 znaków, próba 2026-10-16); `columnGap`
278    // zamiast `gap`, bo `gap` dokłada pusty wiersz między zawiniętymi rzędami.
279    return (
280      <Box flexDirection="column">
281        <Text bold>Brief — ramy polecenia na start</Text>
282        <Input
283          key="polecenie"
284          label="Polecenie"
285          placeholder="co ma być zrobione"
286          value={tekst}
287          submitLabel="zapisz"
288          onInput={v => szkice.set('polecenie', v)}
289          onSubmit={zapiszTekst}
290        />
291        <Input key="repo" label="Repo/folder" value={repo} submitLabel="zapisz" onInput={v => szkice.set('repo', v)} onSubmit={zapiszPole('repo')} />
292        {lista.length > 0 && (
293          <Box columnGap={1} flexWrap="wrap">
294            <Text dimColor>podkatalogi:</Text>
295            {lista.slice(0, MAX_PODKATALOGOW).map(n => (
296              <Button
297                key={`kat-${n}`}
298                plain
299                dimColor
300                label={n}
301                onPress={() => {
302                  szkice.delete('repo')
303                  void update($, ramy, x => ({ ...x, repo: `${folder}/${n}` }))
304                }}
305              />
306            ))}
307            {lista.length > MAX_PODKATALOGOW && <Text dimColor>{`+${lista.length - MAX_PODKATALOGOW} więcej — wpisz ścieżkę`}</Text>}
308          </Box>
309        )}
310        <Box columnGap={1} flexWrap="wrap">
311          <Text>Stawka:</Text>
312          {STAWKI.map(s => (
313            <Button key={`stawka-${s}`} plain label={`${radio(r.stawka === s)} ${s}`} onPress={() => update($, ramy, x => ({ ...x, stawka: x.stawka === s ? null : s }))} />
314          ))}
315          {r.stawka === 'wysoka' && <Text dimColor>{`→ ${DOPISEK_WYSOKIEJ}`}</Text>}
316        </Box>
317        <Box columnGap={1} flexWrap="wrap">
318          <Text>Tryb:</Text>
319          {TRYBY.map(t => (
320            <Button key={`tryb-${t}`} plain label={`${radio(r.tryb === t)} ${t}`} onPress={() => update($, ramy, x => ({ ...x, tryb: x.tryb === t ? null : t }))} />
321          ))}
322        </Box>
323        <Input
324          key="gotowe"
325          label="Gotowe, gdy"
326          placeholder="kryterium sprawdzalne komendą albo w aplikacji"
327          value={gotowe}
328          submitLabel="zapisz"
329          onInput={v => szkice.set('gotowe', v)}
330          onSubmit={zapiszPole('gotowe')}
331        />
332        <Box columnGap={1} flexWrap="wrap">
333          <Button key="recall" plain label={`${krzyzyk(r.recall)} ${DOPISEK_RECALL}`} onPress={() => update($, ramy, x => ({ ...x, recall: !x.recall }))} />
334        </Box>
335        <Box columnGap={1} flexWrap="wrap">
336          <Text>Granice:</Text>
337          {GRANICE.map(g => (
338            <Button
339              key={`granica-${g.klucz}`}
340              plain
341              label={`${krzyzyk(r.granice[g.klucz])} ${g.opis}`}
342              onPress={() => update($, ramy, x => ({ ...x, granice: { ...x.granice, [g.klucz]: !x.granice[g.klucz] } }))}
343            />
344          ))}
345        </Box>
346        <Box columnGap={1} flexWrap="wrap">
347          <Button key="uruchom" variant="primary" label="Uruchom" onPress={press => uruchom($, press.surface)} />
348          <Button key="kopiuj" label="Kopiuj" onPress={press => kopiuj($, press.surface)} />
349          <Button key="pomin" role="dismiss" label="Pomiń" onPress={() => $.ui.close({ id: PANE })} />
350        </Box>
351        <Text dimColor>{podglad.length ? `ramy: ${podglad.join(' · ')}` : 'ramy: nic nie zaznaczono — Enter w polu tekstowym odświeża podgląd'}</Text>
352      </Box>
353    )
354  })
355}
356
types/index.d.ts 36 lines
1// Kontrakt stanu modu kanon-brief ($.state sesji; po reloadzie modułu wartości zostają, zmienne modułu startują od nowa).
2// Między sesjami żyje tylko $.store (klucz `brief:<cwd>`): ramy folderu bez tekstu polecenia i bez „gotowe, gdy”.
3export type Stawka = 'niska' | 'istotna' | 'wysoka'
4export type Tryb = 'sam' | 'subagent' | 'workflow'
5/** Granice zadania — każda to jedna linia bloku ram, gdy zaznaczona. */
6export type Granice = { produkcja: boolean; pii: boolean; push: boolean }
7/** Pola formularza poza tekstem polecenia. Pole puste (''/null/false) nie trafia do bloku ram. */
8export type Ramy = {
9  /** Repo albo folder zadania; domyślnie `$.session.cwd()`, przyciski podkatalogów podstawiają `<cwd>/<nazwa>`. */
10  repo: string
11  stawka: Stawka | null
12  tryb: Tryb | null
13  /** Kryterium „gotowe, gdy” — treść zadania, nie folderu: nie idzie do $.store. */
14  gotowe: string
15  /** Dopisek „najpierw /ops-recall”. */
16  recall: boolean
17  granice: Granice
18}
19
20declare module 'claude-code' {
21  interface PluginState {
22    'kanon-brief': {
23      /** Pola formularza; przy `/brief` start z $.store[`brief:<cwd>`] (gdy jest) nad pustymi. */
24      ramy: Ramy
25      /** Tekst polecenia z pola formularza albo z argumentu `/brief <tekst>`; nigdy do $.store. */
26      polecenie: string
27      /** Folder sesji przy ostatnim `/brief` — klucz pamięci i baza dla podkatalogów. */
28      cwd: string
29      /** Podkatalogi pierwszego poziomu cwd (`$.fs.list`, bez ukrytych, najwyżej 12) — przyciski „Repo/folder”. */
30      podkatalogi: string[]
31      /** Toast o krótkim poleceniu startowym już pokazany w tej sesji (raz na sesję, także po reloadzie modu). */
32      toast: boolean
33    }
34  }
35}
36