SLOPSHOPPER

krux-mod

Krux jako mod Claude Code: polska persona technicznego orka w prompcie systemowym, kotwica głosu w każdej turze, komenda /krux z panelem kuźni i animowany…

newpanebandspinnerrowsguard
v0.11.3MITupdated 2026-10-09karolkiljan/krux-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · krux-mod
│ ┃ Sztolnia ✕ ⟨Claude Code's own drawing⟩ │ ┃ ▸ Stan · Dziennik Morra │ ┃ Git │ ┃ ⎇ · bez upstreamu ● krux-mod: brak voice/persona.md — ten tryb nie trafi do promptu │ ┃ ✓ czysto ● krux-mod: brak voice/konkret.md — ten tryb nie trafi do promptu │ ┃ ⏺ Read(src/auth.ts) │ ┃ Testy ⎿ Read 6 lines │ ┃ ✗ bun test Krux ⏺ Update(src/auth.ts) │ ┃ 1 padł · 3 przeszły ⎿ Added 2 lines, removed 1 line │ ┃ └ revokes on logout ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Kontekst │ ┃ █████▉░░░░░░ 49% · 97k / 200k ┌───────────────────────────────────────────────────────────────────┐ │ ┃ ███▊░░░░░░░░ 5h 31% │⟨Claude Code's own drawing⟩ │ │ ┃ koszt sesji $0.42 └───────────────────────────────────────────────────────────────────┘ │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /krux │ ┃ ● krux-mod: brak voice/flow.md — ten tryb nie trafi do promptu │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ✻ Dumać… ──────────────────────────────────────────────────────────────────────────────────────────────────── Krux odpoczywać przy kowadle ▣ client module ./forge.ts ✗ bun test · 1 padł · 3… ◔ kontekst 49% ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
──────────────────────────────────────────────────────────────────────────────────────────────────── Krux odpoczywać przy kowadle ▣ client module ./forge.ts ✗ bun test · 1 padł · 3… ◔ kontekst 49% ⟨Claude Code's own drawing⟩
Pane · Sztolnia
▸ Stan · Dziennik Git ⎇ · bez upstreamu ✓ czysto Testy ✗ bun test Krux 1 padł · 3 przeszły └ revokes on logout Kontekst █████▉░░░░░░ 49% · 97k / 200k ███▊░░░░░░░░ 5h 31% koszt sesji $0.42
Pane · Kuźnia Kruxa
▣ client module ./forge.ts Tryby 1: persona: on 2: konkret: off 3: flow: off 4: Horda Niuch zwiad, debug, przyczyna błędu Grom kuźnia: backend, API, dane Piryt ocena: review, ryzyko Ochra frontend, UI Młot testy, weryfikacja Lont rozbiórka: martwy kod, refaktor 1–7 przełączają · Esc zamyka · skill hordy: /krux-mod:krux-horda
Your message
⟨Claude Code's own drawing⟩ Morra
Claude's reply
┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ │⟨Claude Code's own drawing⟩ │ └──────────────────────────────────────────────────────────────────────────────────────────────────┘
README

Krux — mod Claude Code

Krux jako mod Claude Code: ta sama persona technicznego orka co w pluginie Krux 3.8.0 (ten sam autor; persona przeniesiona stamtąd), ale wpięta od środka. Persona siedzi w prompcie systemowym, kotwica głosu jedzie z każdą turą, a nad promptem kowal wali młotem, póki Claude pracuje.

Tylko Claude Code (CLI i zakładka Code w aplikacji Desktop), od wersji 2.1.287. Codex nie ma modów — tam dalej działa plugin Krux.

Co daje mod, czego plugin nie dawał

Plugin KruxMod Krux
Personakontekst hooka SessionStart, limit 10 000 znakówsekcja promptu systemowego, bez tego limitu
Po /compactwstrzykiwana od nowanie znika, bo prompt systemowy zostaje
Kotwica głosuco turę, pełnapełna na starcie, co 6 tur i po dryfie (z cytatem i własnymi zdaniami modelu przepisanymi po orkowemu); w pozostałych turach krótka, ~200 znaków
Pomiar głosutylko benchmark offlinemiernik po każdej odpowiedzi: druga osoba, gładka pierwsza osoba, brak bezokolicznika, długie zdania; ostrzeżenia przed ruchem nieodwracalnym i tekstu do wklejenia w cytacie > nie sądzi
Życie hordymodel wymyśla samfakty z sesji (testy, budowanie, commity, rozbiórka, edycje interfejsu), każdy kumpel z własnym charakterem; turę, kumpla, formę i miejsce wstawki wybiera kod, nie model; przy prośbie o ruch nieodwracalny horda milczy
Kompakcjastreszczenie jak lecineutralne streszczenie z faktami dosłownie, potem pełna kotwica
Format odpowiedzibrakjedna linia formatu według rodzaju prośby (debug, review, plan, wyjaśnienie, pogawędka), rozpoznanej regexem bez wywołania modelu, z limitem słów; bez rozpoznania sam budżet: do 150 słów; prośba o ruch nieodwracalny (DROP TABLE, force push, „usuń tabelę”) dostaje linię: skutek i droga odwrotu pełnymi zdaniami
Tabliczkibrak⚒ Krux nad odpowiedzią, Morra nad promptem; sam rysunek, 0 tokenów
Przełącznikifrazy włącz krux itd.frazy dalej działają, do tego /krux bez tury modelu
Interfejsbrakpanel kuźni, kowal nad promptem, słowa spinnera, etykiety w stopce

Instalacja

Na stałe, z GitHuba:

claude plugin marketplace add karolkiljan/krux-mod
claude plugin install krux-mod@krux-mod-marketplace

Na jedną sesję, z lokalnego klonu (zmiany w plikach przeładowują się same):

git clone https://github.com/karolkiljan/krux-mod.git
claude --plugin-dir ./krux-mod

Odinstalowanie:

claude plugin uninstall krux-mod@krux-mod-marketplace

Nie odpalaj moda razem z pluginem Krux — oba wstrzykną personę i kotwicę, więc model dostanie wszystko podwójnie. Mod wykrywa aktywny plugin i ostrzega toastem. Wyłączenie pluginu:

claude plugin disable krux@krux-marketplace

Użycie

KomendaSkutek
/kruxpanel kuźni: tryby pod klawiszami 1–7, horda, palenisko
/krux on / /krux offgłos orka / tryb neutralny
`/krux konkret [on\off]`precyzja zakresu: tylko to, o co proszono
`/krux flow [on\off]`jeden ruch na raz, zgoda przed każdym
`/krux animacje [on\off]`kowal nad promptem
`/krux kowal [on\off]`kowal zostaje nad promptem także w spoczynku
`/krux sztolnia [on\off]`panel ze stanem roboty obok transkryptu
`/krux czat [on\off]`ramki rozmowy i awatary w terminalu
/krux dziennikotwarcie karty Dziennik w Sztolni
/krux zapisz <tekst>zapis otwartego wątku w tablicy sesji
/krux raportraport sesji do skopiowania
/krux statusstan trybów

Bez on/off komenda przełącza tryb na przeciwny. /krux działa także w trakcie tury i nie kosztuje wywołania modelu. Stare frazy z pluginu (włącz krux, wyłącz konkret, …) dalej działają, gdy stanowią całą wiadomość. Stan trybów jest trwały i przeżywa restart sesji.

Mapa hordy ładuje się na żądanie: /krux-mod:krux-horda.

Animacje

  • Kowal nad promptem. Póki Claude pracuje, pasek pokazuje pikselowego orka i opis bieżącej czynności. Przy Edit i Write ork kuje stal, przy Grep świeci pochodnią, przy Glob wypatruje z krzaka, przy Read sięga po zwój, przy planie kreśli mapę, przy sieci i zewnętrznych narzędziach MCP wysyła kruka, przy AskUserQuestion drapie się pod znakiem zapytania, przy Agent dmie w róg. Własne narzędzie mcp__krux-mod__watki zachowuje scenę planowania. Przy Bash scena i podpis wynikają z rodzaju komendy, tej samej co w kronice: testy mają próbę stali (także make test, uv run pytest, pnpm vitest), commit i push pieczęć, budowanie piec, rozbiórka dynamit, odczyty i ślady gita pochodnię, instalowanie i pozostałe komendy kilof; odczyt na przedzie łańcucha (ls && ./deploy.sh) nie udaje zaglądania. Strumień odpowiedzi zmienia scenę także pomiędzy narzędziami: myślenie uruchamia dumanie, tekst pisanie, a początek wywołania narzędzia jego scenę; przy Bash scena czeka na komendę, zamiast zgadywać kilof. Narzędzia silnika też mają swoje sceny: lista zadań mapę, wiadomość do agenta kruka, czytanie wyników z tła zwój. Każda scena ma co najmniej 3 różne czynności. Scena pozostaje przez co najmniej 1,5 s; przy zmianie rekwizyt schodzi 2× szybciej, nadal przez odwrócone wejście. Narzędzie zawsze robi Krux; kumpel pojawia się tylko przy prawdziwym subagencie.
  • Horda na scenie i humory. Kumpel wbiega z prawej w swoim fartuchu i wykonuje czynności wynikające z własnych narzędzi oraz strumienia myślenia i odpowiedzi. Grom nosi hełm, Niuch ma wydłużony nos, Ochra pędzel, Lont tlący się lont. Po zakończeniu kumpel zwija meldunek, odkłada rekwizyt, rzuca Kruxowi zwój, potem maszeruje w prawo. Stojący Krux łapie zwój dłonią i chowa pod fartuch; zwój leci za głowami, nigdy przez oczy. Zniknięcie z listy aktywnych agentów bez zdarzenia zakończenia nie uruchamia rzutu. Gdy Krux czeka na wyniki hordy, leży na szezlongu z kuflem i nie wraca do rogu, kiedy kumpel oddaje zwój. Pas ma stałą wysokość i szerokość. Dymek nad ostatnim mówiącym orkiem odsłania po 3 znaki co 150 ms. Humory zmieniają oczy i brwi, a wynik roboty wywołuje też reakcję na płótnie: zielone testy złote iskry, czerwone robaka, udane budowanie błysk stali, nieudane sadzę, commit pieczęć, rozbiórka wybuch, zawał spadające kamienie. Krux i kumpel zmieniają też postawę: przysiad, uniesiona ręka, przydeptanie robaka albo osłona przed zawałem. Pozy leżące, siedzące i pochylone zachowują dotychczasowy ruch oraz rekwizyty: Krux na szezlongu czy w fotelu nie zrywa się na reakcję. W czynności z kanarkiem faktyczny wynik testów wywołuje śpiew i ruch skrzydeł albo opadnięcie ptaka na żerdź. Kumpel reaguje na własną robotę.
  • Dłuższa praca i spoczynek. Po 10 s pojedynczej aktywności pojawia się pot, po 30 s ork okresowo ociera czoło. Kolejne narzędzie lub faza odpowiedzi zaczyna odliczanie od nowa. Gdy Claude skończy, ork stoi przy kowadle, a podpis pokazuje stan spoczynku. Po 30 s bezczynności Krux zamyka oczy i pokazuje runę snu. /krux kowal off pozostawia pasek tylko na czas pracy.
  • Panel /krux. Większa kuźnia z paleniskiem, w którym płonie ogień, przełączniki trybów i horda.
  • Sztolnia. Panel obok transkryptu (w pełnym ekranie dokuje się z prawej) ze stanem roboty: plan z listy zadań (✓ zrobione, ▸ w toku, · czeka), ostatni ukończony przebieg testów z liczbami i nazwami padających, gotowość do commita, otwarte wątki, stan repo i commity, orkowie, którzy jeszcze biegają (zadanie, czas, ostatnie narzędzie), oraz zapełnienie kontekstu, okna limitów i koszt sesji. Diffu i drzewa zmian nie dubluje: te daje /diff Claude Code. Krzyżyk zamyka panel na stałe, wraca przez /krux sztolnia.
  • Spinner. Zamiast angielskich słówek: „Dumać”, „Skrobać runy”; przy pracy narzędzia słowo idzie za sceną, także rodzajem komendy Bash („Kopać” przy kopaniu, „Węszyć” przy szukaniu).
  • Stopka. Etykiety krux, konkret, flow obok trybów Claude Code.

Animacja chodzi na zegarze klatek interfejsu (moduł Client), więc nie budzi modelu i nie zjada tokenów. /krux animacje off zdejmuje kowala. Ustawienie prefersReducedMotion: true w Claude Code zatrzymuje orków w miejscu, wyłącza reakcje, rzut zwoju, pot, ocieranie czoła i drzemkę oraz pokazuje od razu pełny dymek. Grafika rysuje się tylko w terminalu; w Desktop, VS Code i na telefonie zostaje sam tekst, a persona i komendy działają wszędzie.

Nowe sceny paska:

ScenaKiedyCzynności
testtestykanarek, próba obciążeniowa, fiolki
sealcommit i pushwosk, prasa, zwój ze sznurem
buildbudowaniepiec, hartowanie, miech
tearrozbiórkadynamit, gruz, mur
thinkstrumień myśleniaważenie rudy, liczenie kamyków, rozważanie mapy
writestrumień tekstu odpowiedziskrobanie run, cięcie zbędnych liter, zwijanie zwoju

Okna zgody na narzędzia obsługuje silnik Claude Code. Mod nie podłącza tool.check ani nie animuje oczekiwania na zgodę.

Granice

Poprawność, bezpieczeństwo i wymagany format wyprzedzają głos. Liczby, wersje, ścieżki, komendy i komunikaty błędów idą dosłownie. Kod, JSON, commit messages i opisy PR pozostają neutralne. Przed ruchem nieodwracalnym Krux przechodzi na pełne zdania: warunek, skutek, droga odwrotu.

Mod to kod uruchamiany z uprawnieniami użytkownika. Ten czyta trzy pliki z voice/, ustawienia (prefersReducedMotion), listę komend, historię sesji ($.session.messages, do odtworzenia kroniki i tablicy po wznowieniu), zużycie kontekstu i limitów ($.session.usage) oraz listę agentów ($.agent.list). Uruchamia do pięciu komend, wszystkich tylko do odczytu: git status (stan repo), git log -n 10 (ostatnie commity), git diff --check i git diff --cached --check (białe znaki w drzewie roboczym i indeksie) oraz git rev-list @{upstream}..HEAD (niewypchnięte commity, gdy repo ma upstream). Rejestruje jedno narzędzie dla modelu, mcp__krux-mod__watki: otwarte wątki tablicy, trzymane w stanie sesji. Pisze wyłącznie do własnego magazynu $.store (tryby) i stanu sesji. Nie wysyła niczego poza sesję. Persona i kotwica głosu trafiają do promptu modelu, tak jak reszta sesji, więc każda tura kosztuje trochę więcej tokenów. Pełna lista: claude plugin validate ..

Rozwój

claude plugin validate .
claude plugin test .
node --test tests/scripts-regression.mjs
tsc -p .
git diff --check

Sprawdzone na Claude Code 2.1.293. API modów jest we wczesnym dostępie i może się zmienić między wersjami.

Pomiar głosu

node scripts/voice-bench.mjs --model claude-opus-5-5 [--mode stream|resume] [--plugin-dir <katalog>]

12 tur jednej sesji. --mode stream (domyślnie) prowadzi rozmowę w jednym procesie claude -p --input-format stream-json, tak jak sesja interaktywna, więc mierzy krótką kotwicę i życie hordy. --mode resume odpala osobny proces na turę z --resume i sprawdza, czy mod odtwarza stan z historii. Scenariusz cache z pluginu Krux 3.8.0 (te same prompty, fixture i metryki co scripts/context-smoke.js, więc liczby stają obok serii pluginu). Ustawienia użytkownika, jego pluginy, hooki i MCP są odcięte (--setting-sources local, --strict-mcp-config); mod wchodzi wyłącznie przez --plugin-dir. Raport i surowe odpowiedzi lądują w benchmarks/voice-bench/<czas>/. Poza metrykami pluginu raport liczy kotwice z transkryptu: pełne (fullAnchors, w tym driftFixes z poprawką), krótkie (shortAnchors), notki o hordzie i łączną długość kontekstu hooka (hookContextChars), a do tego szablony: powtórzone zamknięcia odpowiedzi (repeatedClosings), prośby o zgodę na końcu (consentClosings), powtórzone otwarcia wstawek o hordzie (repeatedHordeOpenings) i wstawki w ostatnim akapicie (hordeClosings). Notatka ze źródłami i wynikami A/B: docs/research/2026-10-04-komunikacja.md. Wymaga Node ≥ 22.18 (wczytuje hooks/voice.ts bez budowania). Jeden przebieg to 12 płatnych tur, około $0,45 na Opusie.

Licencja

MIT. Projekt nie jest powiązany z produktami innych autorów o nazwie Krux.

Source 42 files
hooks/register.ts 1138 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, RenderInput } from 'claude-code'
3
4import type { KruxActivity, KruxBoard, KruxEvent, KruxGit, KruxHordeMember, KruxMode, KruxModes, KruxTestRun } from '../types'
5import { CAPTION_COLUMNS, CAPTION_GAP, bandPlan } from './band'
6import type { BandInput } from './band'
7import { EMPTY_CREW, crewAfter } from './crew'
8import type { CrewEvent } from './crew'
9import { gauge, matesNamed } from './gauge'
10import { EMPTY_LORE, lifeNote, recordAnswer, recordTool, replay, turnsAfter } from './lore'
11import { CALM, EVENT_HOLD_MS, eventOf, mateIn, mateMoodAfter, moodsAfter, moodsTick } from './mood'
12import { MATES, ROSTER } from './roster'
13import type { BoardCall } from './board'
14import { EMPTY_BOARD, boardAfter, replayBoard, returnNote, usageOf } from './board'
15import { GIT_CHECK, GIT_CHECK_CACHED, GIT_LOG, GIT_STATUS, GIT_UNPUSHED, GIT_TOOLS, gitOf } from './git'
16import { EMPTY_THREADS, THREAD_SPEC, THREAD_TOOL_NAME, replayThreads, saveThread, threadsAfter, threadsReport } from './threads'
17import { dayReport } from './report'
18import { EMPTY_MUSTER, anyRunning, musterDone, musterRows, musterSpawn, musterTool } from './muster'
19import { KRUX_COLOR, ORC_COLOR, shaftDigest, shaftTabs, shaftTree } from './shaft'
20import { TOOL_INDENT, chatTree, toolIndent } from './chat'
21import type { AvatarSpeaker } from './avatar'
22import { EMPTY_JOURNAL, journalAfter, journalTree, toolLine } from './journal'
23import { forgeColumns } from './sprites'
24import {
25  DEFAULT_MODES,
26  HELP,
27  MODES,
28  NAMEPLATE,
29  COMPACT_NOTE,
30  anchorFor,
31  applyToggle,
32  describeTool,
33  describeToolAhead,
34  describePhase,
35  formatHint,
36  fromPerson,
37  modelNote,
38  parseCommand,
39  parsePhrase,
40  personaSections,
41  riskHint,
42  spinnerWord,
43  statusLine,
44  stripFrontmatter,
45  userLine,
46  workOf,
47} from './voice'
48import type { Texts, Toggle } from './voice'
49
50const PANE = 'krux'
51// Panel ze stanem roboty; w pełnoekranowym terminalu dokuje się obok transkryptu.
52const SHAFT_PANE = 'sztolnia'
53// Komórka przycisku trybu w panelu `/krux`: najdłuższy „6: sztolnia: off” i oddech.
54const MODE_CELL = 18
55const SHAFT_COLUMNS = 56
56// Najwęższy skrót Sztolni w pasie: znak, ułamek planu i kilka liter zadania.
57const DIGEST_COLUMNS = 18
58// Szerzej skrót się nie rozlewa: długa linia ucina się, zamiast ciągnąć oko przez pół ekranu.
59const DIGEST_MAX = 64
60// Tabliczki, kreska bloków, pas i płótno kuźni liczą siatkę znaków terminala:
61// pusty wiersz nad wiadomością, piksele z półbloków. Desktop rysuje je krzywo
62// (tabliczka na tekście, piksele z przerwami), więc stoją tylko w terminalu.
63const GRID_SURFACE = 'terminal'
64
65// Kolor Morry: chłodny błękit (człowiek, nie ork; czytelny na jasnym i ciemnym tle).
66const MORRA_COLOR = '#5b9bd5'
67
68const modes = atom({ plugin: 'krux-mod', key: 'modes' } as const, DEFAULT_MODES)
69const activity = atom({ plugin: 'krux-mod', key: 'activity' } as const, null)
70const strikes = atom({ plugin: 'krux-mod', key: 'strikes' } as const, 0)
71const sessionStrikes = atom({ plugin: 'krux-mod', key: 'sessionStrikes' } as const, 0)
72const still = atom({ plugin: 'krux-mod', key: 'still' } as const, false)
73const turns = atom({ plugin: 'krux-mod', key: 'turns' } as const, 0)
74const drift = atom({ plugin: 'krux-mod', key: 'drift' } as const, null)
75const lore = atom({ plugin: 'krux-mod', key: 'lore' } as const, EMPTY_LORE)
76const replayed = atom({ plugin: 'krux-mod', key: 'replayed' } as const, false)
77const moods = atom({ plugin: 'krux-mod', key: 'mood' } as const, CALM)
78const crew = atom({ plugin: 'krux-mod', key: 'crew' } as const, EMPTY_CREW)
79// Wywołania `Agent` w toku z wątku głównego: Krux czeka na hordę.
80const waiting = atom({ plugin: 'krux-mod', key: 'waiting' } as const, 0)
81const board = atom({ plugin: 'krux-mod', key: 'board' } as const, EMPTY_BOARD)
82const usage = atom({ plugin: 'krux-mod', key: 'usage' } as const, null)
83const muster = atom({ plugin: 'krux-mod', key: 'muster' } as const, EMPTY_MUSTER)
84const musterNow = atom({ plugin: 'krux-mod', key: 'musterNow' } as const, 0)
85const git = atom({ plugin: 'krux-mod', key: 'git' } as const, null)
86const threads = atom({ plugin: 'krux-mod', key: 'threads' } as const, EMPTY_THREADS)
87const autoKonkret = atom({ plugin: 'krux-mod', key: 'autoKonkret' } as const, false)
88const turnAt = atom({ plugin: 'krux-mod', key: 'turnAt' } as const, null)
89const seenAt = atom({ plugin: 'krux-mod', key: 'seenAt' } as const, null)
90const journal = atom({ plugin: 'krux-mod', key: 'journal' } as const, { entries: [] })
91const shaftTab = atom({ plugin: 'krux-mod', key: 'shaftTab' } as const, 'stan')
92
93// Godzina pierwszego rysunku po id wiadomości, bez niedozwolonego zapisu $.state
94// w ui.render. Nie ma zegara historii: redraw i resize zachowują tę samą godzinę.
95const messageTimes = new Map<string, number>()
96// Id narzędzi z grup w czacie: wcięcie daje grupa, jej wiersze `ToolUse` (także
97// zwinięty wiersz, który silnik rysuje przez `ToolUse`) stoją w niej bez drugiego.
98const groupedTools = new Set<string>()
99let journalWatch: { cancel: () => void } | null = null
100let journalWatchVersion = 0
101let journalWatchRequests = 0
102
103// Teksty głosu czyta session.start; reload modułu czyta je od nowa.
104let texts: Texts = { persona: '', konkret: '', flow: '' }
105
106// Notki dla modelu po przełączeniu z panelu: dojadą z następnym promptem.
107const NOTES_LIMIT = 16
108const NOTES_TTL_MS = 5 * 60 * 1000
109let pendingNotes: { text: string; at: number }[] = []
110
111function freshNotes(now: number): typeof pendingNotes {
112  return pendingNotes.filter(note => now - note.at <= NOTES_TTL_MS).slice(-NOTES_LIMIT)
113}
114
115// Błąd dodatku nie blokuje sesji; nawet logowanie może zawieść.
116function hookFailure($: EngineInterface, event: string, error: { kind: string; message?: string }): void {
117  try { $.ui.log(`krux-mod: ${event}: ${error.kind}${error.message === undefined ? '' : `: ${error.message}`}`) } catch { /* Log nie zastępuje zdarzenia. */ }
118}
119
120// Jeden zegar apelu naraz; reload modułu zaczyna bez zegara.
121let ticking = false
122
123function savedModes(saved: unknown): KruxModes {
124  const result: KruxModes = { ...DEFAULT_MODES }
125  if (typeof saved !== 'object' || saved === null) return result
126  for (const mode of MODES) {
127    const value = (saved as Record<string, unknown>)[mode]
128    if (typeof value === 'boolean') result[mode] = value
129  }
130  return result
131}
132
133// Stary obiekt zostaje źródłem trybów jeszcze niezapisanych pod osobnym kluczem.
134// Migracja przy zapisie jednego trybu nie nadpisuje zmian z równoległej sesji.
135async function readSavedModes($: EngineInterface): Promise<KruxModes> {
136  const loaded = savedModes(await $.store.get('modes'))
137  const values = await Promise.all(MODES.map(mode => $.store.get(`mode.${mode}`)))
138  MODES.forEach((mode, index) => {
139    if (typeof values[index] === 'boolean') loaded[mode] = values[index] as boolean
140  })
141  return loaded
142}
143
144async function loadModes($: EngineInterface): Promise<void> {
145  const loaded = await readSavedModes($)
146  await update($, modes, () => loaded)
147}
148
149// Zapis trybu i notka dla modelu, jeśli ten tryb ją ma.
150async function setModes($: EngineInterface, toggle: Toggle): Promise<{ next: KruxModes; on: boolean; note: string | null }> {
151  let next = applyToggle(await readSavedModes($), toggle)
152  const on = next[toggle.mode]
153  await $.store.set(`mode.${toggle.mode}`, on)
154  await update($, modes, current => {
155    next = { ...current, [toggle.mode]: on }
156    return next
157  })
158  // Persona wraca po przerwie: pierwsza tura znowu dostaje pełną kotwicę.
159  if (toggle.mode === 'persona' && on) await update($, turns, () => 0)
160  return { next, on, note: modelNote(toggle.mode, on) }
161}
162
163async function readText($: EngineInterface, name: string): Promise<string> {
164  try {
165    return stripFrontmatter(await $.fs.read(`${$.plugin.root}/voice/${name}.md`))
166  } catch {
167    $.ui.log(`brak voice/${name}.md — ten tryb nie trafi do promptu`)
168    return ''
169  }
170}
171
172// Tryby i ruch od nowa: na starcie i po komendach, które zerują `$.state`.
173async function loadSession($: EngineInterface): Promise<void> {
174  messageTimes.clear()
175  groupedTools.clear()
176  journalWatch?.cancel()
177  journalWatch = null
178  journalWatchVersion += 1
179  await loadModes($)
180  await loadMotion($)
181}
182
183async function loadMotion($: EngineInterface): Promise<void> {
184  let reduced = false
185  try {
186    const settings = (await $.settings.read()) as Record<string, unknown>
187    reduced = settings.prefersReducedMotion === true
188  } catch {
189    reduced = false
190  }
191  await update($, still, () => reduced)
192}
193
194// Plugin Krux i ten mod naraz to podwójna persona i podwójna kotwica.
195async function warnAboutPlugin($: EngineInterface): Promise<void> {
196  try {
197    const commands = await $.command.list()
198    if (commands.some(command => command.plugin === 'krux' || command.name.startsWith('krux:'))) {
199      $.ui.toast('Plugin krux też aktywny: persona podwójna. Wyłącz go: claude plugin disable krux@krux-marketplace')
200    }
201  } catch {
202    // Brak listy komend to nie powód, by psuć start sesji.
203  }
204}
205
206// Pusty `$.state` przy niepustej historii to wznowiona sesja: licznik tur,
207// kronikę, dryf i tablicę Sztolni odtwarzamy raz, przy pierwszym prompcie po
208// starcie albo notatce czy raporcie. Równoległe wywołania czekają na wynik,
209// żeby zapis notatki nie wyprzedził odtworzenia wątków.
210let restoring: Promise<void> | null = null
211
212function restore($: EngineInterface): Promise<void> {
213  if (restoring === null) restoring = restoreOnce($).finally(() => { restoring = null })
214  return restoring
215}
216
217async function restoreOnce($: EngineInterface): Promise<void> {
218  // Sprawdzenie i zapis w jednym `update`: dwa równoległe prompty nie odtworzą dwa razy.
219  let first = false
220  await update($, replayed, was => {
221    first = !was
222    return true
223  })
224  if (!first) return
225  try {
226    const messages = await $.session.messages()
227    if (messages.length === 0) return
228    await update($, board, () => replayBoard(messages))
229    await update($, threads, () => replayThreads(messages))
230    const found = replay(messages)
231    await update($, turns, () => found.turns)
232    await update($, lore, () => found.lore)
233    const last = found.lore.last
234    if (last !== null) await update($, moods, () => moodsAfter(last, found.lore))
235    await update($, drift, () => found.drift)
236  } catch (error) {
237    $.ui.log(`nie odtworzyć historii: ${String(error)}`)
238  }
239}
240
241// Jedno przejście sceny; bez zapisu, gdy nic się nie zmienia.
242async function crewStep($: EngineInterface, event: CrewEvent): Promise<void> {
243  const now = await $.clock.now()
244  const seen = await read($, crew)
245  if (crewAfter(seen, event, now) === seen) return
246  await update($, crew, current => crewAfter(current, event, now))
247}
248
249// Faza strumienia na scenie: Krux w pasie albo kumpel na płótnie, od chwili, gdy się zaczęła.
250async function noteStep($: EngineInterface, agentId: string | undefined, description: KruxActivity | null): Promise<void> {
251  if (description === null) return
252  const doing = { ...description, startedAt: await $.clock.now() }
253  if (agentId === undefined) await update($, activity, () => doing)
254  else await crewStep($, { kind: 'mate-tool', agentId, doing })
255}
256
257// Agenci, których silnik widzi jako `running`; `null`, gdy listy nie ma.
258async function runningIds($: EngineInterface): Promise<ReadonlySet<string> | null> {
259  try {
260    return new Set((await $.agent.list()).filter(agent => agent.status === 'running').map(agent => agent.id))
261  } catch {
262    return null
263  }
264}
265
266// Agenci w tle kończą się poza turą: na scenie zostaje tylko to, co silnik widzi jako `running`.
267async function liveMembers($: EngineInterface, members: KruxHordeMember[]): Promise<KruxHordeMember[]> {
268  if (members.length === 0) return members
269  const ids = await runningIds($)
270  return ids === null ? members : members.filter(member => ids.has(member.agentId))
271}
272
273// Własne narzędzie Kruxa: kronika, humor, a po zdarzeniu wtręt na cztery sekundy.
274async function afterOwnTool($: EngineInterface, tool: string, input: Record<string, unknown>, isError: boolean, text: string, failure?: string, result?: unknown): Promise<KruxEvent | null> {
275  let after = EMPTY_LORE
276  let found: KruxEvent | null = null
277  // Kronika liczona w środku `update`: równoległe narzędzia nie gubią sobie zapisów.
278  await update($, lore, before => {
279    after = recordTool(before, tool, input, isError, text, result)
280    found = eventOf(before, after)
281    return after
282  })
283  const event = found as KruxEvent | null
284  if (event === null) {
285    await update($, moods, now => moodsTick(now))
286  } else {
287    await update($, moods, () => moodsAfter(event, after))
288    // Martwy wpis nie dostanie wtrętu: najpierw scena według listy silnika.
289    const ids = await runningIds($)
290    if (ids !== null) await crewStep($, { kind: 'running', ids })
291  }
292  await crewStep($, { kind: 'own-tool', event, lore: after, seed: await read($, sessionStrikes), failure })
293  // Timer startuje po zapisie dymka, więc nigdy nie wyprzedzi jego `until`.
294  if (event !== null) $.clock.after(EVENT_HOLD_MS, () => void crewStep($, { kind: 'expire' }))
295  return event
296}
297
298// Narzędzie kumpla na scenie: wynik jego roboty zmienia jego minę i daje jego wtręt
299// na cztery sekundy. Kronika Kruxa go nie liczy: to nie robota Kruxa.
300async function afterMateTool($: EngineInterface, agentId: string, tool: string, input: Record<string, unknown>, isError: boolean, text: string, failure?: string, result?: unknown): Promise<void> {
301  const single = recordTool(EMPTY_LORE, tool, input, isError, text, result)
302  const event = eventOf(EMPTY_LORE, single)
303  if (event === null) return
304  const member = (await read($, crew)).members.find(one => one.agentId === agentId)
305  if (member === undefined) return
306  const mate = member.mate
307  if (mate !== null) await update($, moods, now => mateMoodAfter(now, mate, event))
308  await crewStep($, { kind: 'mate-event', agentId, event, lore: single, seed: await read($, sessionStrikes), failure })
309  $.clock.after(EVENT_HOLD_MS, () => void crewStep($, { kind: 'expire' }))
310}
311
312// Sztolnia idzie za trybem: otwarty, gdy `sztolnia` on. Bez pytania silnik
313// stawia go od 144 kolumn; poniżej czeka, aż terminal się poszerzy.
314async function syncShaftPane($: EngineInterface, on: boolean): Promise<void> {
315  try {
316    if (on) await $.ui.open({ id: SHAFT_PANE, title: 'Sztolnia', columns: SHAFT_COLUMNS })
317    else await $.ui.close({ id: SHAFT_PANE })
318  } catch (error) {
319    $.ui.log(`sztolnia: ${String(error)}`)
320  }
321}
322
323// Tabliczka raz na turę. Silnik stawia kropkę (`isFirstOfReply`) na każdym
324// bloku po grupie narzędzi, a stanu w trakcie rysowania zapisać nie wolno:
325// `turn.start` zaznacza turę, pierwszy blok z kropką zabiera tabliczkę po
326// `requestId` (przerysowanie go nie gubi). Historia sprzed pierwszej żywej tury
327// po wczytaniu modułu dostaje tabliczkę na każdej kropce, bez zapamiętywania.
328// Żywe bloki zachowują tabliczkę przy przerysowaniu przez ostatnie 32 tury.
329const PLATES_LIMIT = 32
330const plates = { live: false, pending: false, ids: new Set<string>() }
331
332function claimPlate(id: string): boolean {
333  if (!plates.live) return true
334  if (plates.ids.has(id)) return true
335  if (!plates.pending) return false
336  plates.pending = false
337  plates.ids.add(id)
338  if (plates.ids.size > PLATES_LIMIT) plates.ids.delete(plates.ids.values().next().value!)
339  return true
340}
341
342// Stan repo po narzędziu, które mogło go zmienić; poza repo albo bez gita `null`.
343// Bez zmiany bez zapisu: zapis przerysowuje Sztolnię i pas.
344let gitRefresh: Promise<void> | null = null
345let gitAgain = false
346
347function refreshGit($: EngineInterface): Promise<void> {
348  if (gitRefresh !== null) {
349    // Narzędzia mogły zmienić repo podczas odczytu: jeden wspólny odczyt po nim.
350    gitAgain = true
351    return gitRefresh
352  }
353  gitRefresh = (async () => {
354    try {
355      do {
356        gitAgain = false
357        let found: KruxGit | null = null
358        // Czekamy na wszystkie także po błędzie, żeby kolejny odczyt nie nakładał się na stary.
359        const [run, log, check, cachedCheck] = await Promise.all([
360          $.process.run(GIT_STATUS, { timeoutMs: 5000 }).catch(() => null),
361          $.process.run(GIT_LOG, { timeoutMs: 5000 }).catch(() => null),
362          $.process.run(GIT_CHECK, { timeoutMs: 5000 }).catch(() => null),
363          $.process.run(GIT_CHECK_CACHED, { timeoutMs: 5000 }).catch(() => null),
364        ])
365        if (run?.exitCode === 0) {
366          const status = gitOf(run.stdout)
367          const local = status.upstream === null ? null : await $.process.run(GIT_UNPUSHED, { timeoutMs: 5000 }).catch(() => null)
368          found = gitOf(run.stdout, log?.exitCode === 0 ? log.stdout : '', local?.exitCode === 0 && !local.isStdoutTruncated ? local.stdout : null)
369          found.whitespace = check?.exitCode === 2 || cachedCheck?.exitCode === 2 ? false
370            : check?.exitCode === 0 && cachedCheck?.exitCode === 0 ? true : null
371        }
372        const previous = await read($, git)
373        if (JSON.stringify(previous) !== JSON.stringify(found)) {
374          await update($, git, () => found)
375          if (previous !== null && found !== null && found.behind > previous.behind) $.ui.toast(`origin ma ${found.behind} nowych commitów — git pull.`)
376        }
377      } while (gitAgain)
378    } finally {
379      // Blokada znika razem z pętlą, zanim następne mikrozadanie poprosi o odczyt.
380      gitRefresh = null
381    }
382  })()
383  return gitRefresh
384}
385
386async function shaftShown($: EngineInterface, on: boolean): Promise<boolean> {
387  if (!on) return false
388  return (await $.ui.panes()).some(pane => pane.id === SHAFT_PANE && pane.isPlaced && pane.isShown)
389}
390
391// Raport zadania powłoki też ma task.id. Dopiero apel lub lista potwierdza agenta;
392// null oznacza potwierdzonego orka bez imienia, undefined — nieznanego nadawcę.
393async function reportMate($: EngineInterface, agentId: string): Promise<AvatarSpeaker | undefined> {
394  const run = (await read($, muster)).runs.find(one => one.agentId === agentId)
395  if (run !== undefined) return run.mate
396  try {
397    const agent = (await $.agent.list()).find(one => one.id === agentId)
398    return agent === undefined ? undefined : mateIn(agent.description, '')
399  } catch {
400    return undefined
401  }
402}
403
404type MessageInput = RenderInput<'AssistantMessage' | 'UserMessage', 'terminal'>
405
406// Dziś wiadomości nie mają czasu ani bodyColumns w kontrakcie. Przyjmujemy
407// timestamp/createdAt (ms lub ISO), jeśli host je poda; inaczej czas pierwszego
408// rysunku. Bez pomiaru szerokości zostaje natywna treść, bez zgadywania 80 kolumn.
409async function drawChat($: EngineInterface, e: MessageInput, speaker: AvatarSpeaker, content: RenderElement): Promise<RenderElement> {
410  const props = e.props as unknown as Record<string, unknown>
411  const columns = props.bodyColumns ?? props.columns ?? e.viewport?.columns
412  if (typeof columns !== 'number' || !Number.isFinite(columns) || columns <= 0) return content
413  const key = `${e.component}:${e.requestId}`
414  let at = messageTimes.get(key)
415  for (const value of [props.timestamp, props.createdAt]) {
416    const time = typeof value === 'number' ? value : typeof value === 'string' ? Date.parse(value) : NaN
417    if (Number.isFinite(time) && Number.isFinite(new Date(time).getTime())) { at = time; break }
418  }
419  if (at === undefined) {
420    const first = await $.clock.now()
421    at = messageTimes.get(key) ?? first
422  }
423  // Dwa równoległe pierwsze rysunki tego id nie zastępują sobie czasu.
424  if (!messageTimes.has(key)) messageTimes.set(key, at)
425  const date = new Date(at)
426  const time = [date.getHours(), date.getMinutes()].map(value => String(value).padStart(2, '0')).join(':')
427  return chatTree($.ui.resolve(e), { speaker, time, content })
428}
429
430async function journalShown($: EngineInterface, surface: string): Promise<boolean> {
431  if (surface !== GRID_SURFACE) return false
432  const now = await read($, modes)
433  if (!now.czat || !now.sztolnia || (await read($, shaftTab)) !== 'dziennik') return false
434  const shown = await shaftShown($, true)
435  watchJournalVisibility($, shown)
436  return shown
437}
438
439// ui.panes jest snapshotem, nie zależnością renderu. Zakładki silnika nie mają
440// zdarzenia zmiany widoczności: jeden zegar sprawdza ją, póki czat ma Dziennik.
441// Nie rysuje co tyk ani nie zapisuje stanu z ui.render; unieważnia tylko zmianę.
442function watchJournalVisibility($: EngineInterface, visible: boolean): void {
443  journalWatchRequests += 1
444  if (journalWatch !== null) return
445  const version = ++journalWatchVersion
446  const tick = (delay = 250) => {
447    journalWatch = $.clock.after(delay, async () => {
448      if (version !== journalWatchVersion) return
449      const requested = journalWatchRequests
450      try {
451        const now = await read($, modes)
452        if (version !== journalWatchVersion) return
453        const tab = await read($, shaftTab)
454        if (version !== journalWatchVersion) return
455        if (!now.czat || !now.sztolnia || tab !== 'dziennik') {
456          // Aktywny render mógł wrócić, gdy ten tyk czekał na stary odczyt off.
457          if (requested !== journalWatchRequests) tick()
458          else journalWatch = null
459          return
460        }
461        const shown = await shaftShown($, true)
462        if (version !== journalWatchVersion) return
463        if (shown !== visible) { visible = shown; $.ui.invalidate('ui.render') }
464        tick()
465      } catch (error) {
466        if (version !== journalWatchVersion) return
467        try { $.ui.log(`dziennik: widoczność: ${String(error)}`) } catch { /* Zegar ma odzyskać odczyt. */ }
468        tick(1000)
469      }
470    })
471  }
472  tick()
473}
474
475// Panel i skrót pasa odświeżają utknięcie przy 15 min, potem na pełnych minutach.
476// Nowy plan unieważnia poprzedni timer; zegar apelu pozostaje wspólną zależnością.
477let boardClock = 0
478
479function tickBoard($: EngineInterface, plan: KruxBoard, now: number): void {
480  const revision = ++boardClock
481  const starts = plan.tasks.flatMap(task => task.status === 'in_progress' && task.startedAt !== undefined ? [task.startedAt] : [])
482  if (starts.length === 0) return
483  const at = Math.min(...starts.map(start => start + Math.max(15, Math.floor((now - start) / 60_000) + 1) * 60_000))
484  $.clock.after(Math.max(1, at - now), async () => {
485    if (revision !== boardClock) return
486    const current = await read($, board)
487    const time = await $.clock.now()
488    if (revision !== boardClock || !current.tasks.some(task => task.status === 'in_progress' && task.startedAt !== undefined)) return
489    await update($, musterNow, () => time)
490    if (revision === boardClock) tickBoard($, current, time)
491  })
492}
493
494// Tablica po narzędziu, liczona w środku `update` (równoległe narzędzia nie gubią
495// zapisów); bez zmiany bez zapisu, bo zapis przerysowuje Sztolnię. `TodoWrite`
496// i przebieg testów dają nowy obiekt także przy tej samej treści.
497async function boardStep($: EngineInterface, tool: string, input: Record<string, unknown>, result: unknown, call: BoardCall): Promise<KruxTestRun | null> {
498  const now = await $.clock.now()
499  const seen = await read($, board)
500  const next = boardAfter(seen, tool, input, result, call, now)
501  if (next === seen || JSON.stringify(next) === JSON.stringify(seen)) return next.test !== seen.test ? next.test : null
502  let run: KruxTestRun | null = null
503  let plan = seen
504  let tasksChanged = false
505  await update($, board, current => {
506    const after = boardAfter(current, tool, input, result, call, now)
507    if (after.test !== current.test) run = after.test
508    tasksChanged = after.tasks !== current.tasks
509    plan = after
510    return after
511  })
512  if (tasksChanged) tickBoard($, plan, now)
513  return run
514}
515
516// Zapełnienie kontekstu, limity i koszt dla Sztolni; bez odczytu zostaje stary.
517let usageVersion = 0
518
519async function refreshUsage($: EngineInterface): Promise<void> {
520  const version = ++usageVersion
521  try {
522    const now = usageOf(await $.session.usage())
523    if (version !== usageVersion) return
524    // Bez zmiany bez zapisu: zapis przerysowuje Sztolnię.
525    // Starszy odczyt nie cofa resetu, także gdy czekał na zapis stanu.
526    await update($, usage, current => version !== usageVersion || JSON.stringify(current) === JSON.stringify(now) ? current : now)
527    if (version !== usageVersion) return
528    const automatic = now.limits.some(limit => limit.percentUsed >= 80)
529    // Toast należy do żądania, które było najnowsze w chwili zapisu flagi: nowsze
530    // żądanie widzi już zmienioną flagę, więc samo by go nie pokazało.
531    let changed = false
532    await update($, autoKonkret, current => {
533      if (version !== usageVersion) return current
534      changed = current !== automatic
535      return automatic
536    })
537    if (changed) $.ui.toast(automatic ? 'Limit planu 80%: konkret włączony do resetu.' : 'Konkret automatyczny wyłączony: limity planu poniżej 80%.')
538  } catch {
539    // Brak odczytu to nie powód, by psuć narzędzie czy turę.
540  }
541}
542
543// Zegar apelu: co sekundę zapis `musterNow`, który przerysowuje Sztolnię i skrót pasa,
544// póki jakiś ork z apelu biega według zdarzeń i według listy silnika.
545function tickMuster($: EngineInterface): void {
546  if (ticking) return
547  ticking = true
548  $.clock.after(1000, async () => {
549    ticking = false
550    const ids = await runningIds($)
551    const runs = (await read($, muster)).runs
552    const now = await $.clock.now()
553    // Ostatni zapis też przerysowuje: ork zabity bez `turn.complete` traci `●` i schodzi ze sceny.
554    await update($, musterNow, () => now)
555    // Brak listy kończy tykanie; panel ma wtedy ograniczoną czasem heurystykę.
556    if (ids === null) return
557    if (anyRunning({ runs: runs.filter(run => ids.has(run.agentId)) })) {
558      tickMuster($)
559      return
560    }
561    await crewStep($, { kind: 'running', ids })
562  })
563}
564
565
566export const register: Register = on => {
567  on('session.start', async ($, e, next) => {
568    usageVersion += 1
569    await loadSession($)
570    texts = {
571      persona: await readText($, 'persona'),
572      konkret: await readText($, 'konkret'),
573      flow: await readText($, 'flow'),
574    }
575    await warnAboutPlugin($)
576    if ((await read($, modes)).sztolnia) await syncShaftPane($, true)
577    // Reload modułu kasuje zegar apelu; orkowie z `$.state` biegną dalej.
578    if (anyRunning(await read($, muster))) tickMuster($)
579    tickBoard($, await read($, board), await $.clock.now())
580    await refreshUsage($)
581    await refreshGit($)
582    try {
583      await $.tool.register(THREAD_SPEC)
584    } catch (error) {
585      $.ui.log(`nie zarejestrować narzędzia wątków: ${String(error)}`)
586    }
587    try {
588      await $.command.register({
589        name: 'krux',
590        description: 'Kuźnia Kruxa: persona, konkret, flow, animacje, kowal, czat, dziennik, horda',
591        argumentHint: '[on|off|konkret|flow|animacje|kowal|sztolnia|czat|dziennik|zapisz <tekst>|raport|status]',
592        immediate: true,
593      })
594    } catch (error) {
595      $.ui.log(`nie zarejestrować /krux: ${String(error)}`)
596    }
597    return next(e)
598  })
599
600  // /clear, /resume i /branch zerują $.state, a session.start już nie wraca.
601  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
602    usageVersion += 1
603    await loadSession($)
604    await update($, journal, () => EMPTY_JOURNAL)
605    await update($, shaftTab, () => 'stan')
606    await refreshGit($)
607    return next(e)
608  }).catch(($, e, next) => { hookFailure($, 'classic.SessionStart', next.error); return next(e) })
609
610  on('prompt.compose', async ($, e, next) => {
611    const composed = await next(e)
612    const now = await read($, modes)
613    const sections = personaSections({ ...now, konkret: now.konkret || await read($, autoKonkret) }, texts)
614    if (sections.length === 0) return composed
615    return { sections: [...composed.sections, ...sections] }
616  })
617
618  on('prompt.submit', async ($, e, next) => {
619    const now = await $.clock.now()
620    const extra = freshNotes(now).map(note => note.text)
621    pendingNotes = []
622    await restore($)
623    if (fromPerson(e.origin)) {
624      let returned = false
625      await update($, seenAt, previous => { returned = previous !== null && now - previous >= 1_800_000; return now })
626      if (returned) {
627        const note = returnNote(await read($, board), await read($, threads), await read($, git))
628        if (note) $.ui.toast(note)
629      }
630    }
631    const phrase = fromPerson(e.origin) ? parsePhrase(e.text) : null
632    if (phrase) {
633      const { note } = await setModes($, phrase)
634      if (note) extra.push(note)
635    } else if ((await read($, modes)).persona) {
636      const turn = await read($, turns)
637      extra.push(anchorFor(turn, await read($, drift)))
638      // Raport subagenta czy wiadomość innej sesji to nie prośba Morry: głos tak, format i horda nie.
639      if (fromPerson(e.origin)) {
640        // Prośba o ruch nieodwracalny: zamiast wstawki o hordzie linia o ostrzeżeniu, na końcu.
641        const risk = riskHint(e.text)
642        const life = risk === null ? lifeNote(await read($, lore), turn) : null
643        if (life) extra.push(life)
644        const hint = formatHint(e.text)
645        if (hint) extra.push(hint)
646        if (risk) extra.push(risk)
647      }
648    }
649    await update($, turns, count => turnsAfter(count, e.text))
650    if (extra.length === 0) return next(e)
651    return next({ ...e, context: [...(e.context ?? []), ...extra] })
652  }).catch(($, e, next) => { hookFailure($, 'prompt.submit', next.error); return next(e) })
653
654  // Kompakcja: streszczenie bez klimatu, a następna tura dostaje pełną kotwicę,
655  // bo po streszczeniu w historii nie ma już przykładów głosu.
656  on('session.compact', async ($, e, next) => {
657    if (e.agentId !== undefined || !(await read($, modes)).persona) return next(e)
658    const instructions = [e.instructions, COMPACT_NOTE].filter(Boolean).join('\n\n')
659    const result = await next({ ...e, instructions })
660    if (result.messages !== undefined && e.trigger !== 'precompute') await update($, turns, () => 0)
661    return result
662  }).catch(($, e, next) => { hookFailure($, 'session.compact', next.error); return next(e) })
663
664  on('command.run', { command: 'krux' }, async ($, e) => {
665    const command = parseCommand(e.args)
666    if (command.kind === 'status') return { text: statusLine(await read($, modes), await read($, autoKonkret)) }
667    if (command.kind === 'help') return { text: `Krux nie znać „${command.unknown}”.\n${HELP}` }
668    if (command.kind === 'save') {
669      await restore($)
670      let id: number | null = null
671      await update($, threads, current => {
672        const next = saveThread(current, command.text)
673        if (next.next > current.next) id = current.next
674        return next
675      })
676      $.ui.toast(id === null ? 'Notatka już w otwartych wątkach.' : `Wątek #${id} zapisany.`)
677      return {}
678    }
679    if (command.kind === 'report') {
680      await restore($)
681      return { text: dayReport(await read($, git), await read($, board), await read($, threads), await read($, lore)) }
682    }
683    if (command.kind === 'journal') {
684      await update($, shaftTab, () => 'dziennik')
685      const opened = (await $.ui.panes()).some(pane => pane.id === SHAFT_PANE)
686      if (!(await read($, modes)).sztolnia) await setModes($, { mode: 'sztolnia', on: true })
687      // Ponowne open pokazuje także panel czekający na szerokość albo ukrytą kartę.
688      if (!opened || !await shaftShown($, true)) await syncShaftPane($, true)
689      return {}
690    }
691    if (command.kind === 'toggle') {
692      const { next: now, on: isOn, note } = await setModes($, command.toggle)
693      if (command.toggle.mode === 'sztolnia') await syncShaftPane($, isOn)
694      const text = `${userLine(command.toggle.mode, isOn)}\n${statusLine(now, await read($, autoKonkret))}`
695      return note ? { text, context: [note] } : { text }
696    }
697    const opened = await $.ui.open({ id: PANE, title: 'Kuźnia Kruxa', focus: true, closeOnEscape: true })
698    if (opened.isPlaced) return {}
699    return { text: `${statusLine(await read($, modes), await read($, autoKonkret))}\n${HELP}` }
700  })
701
702  // Subagent z głównej pętli wbiega na scenę; imię bierze z opisu zadania.
703  on('agent.spawn', async ($, e, next) => {
704    const result = await next(e)
705    if (e.parentAgentId !== undefined || result.agentId === undefined) return result
706    const mate = mateIn(e.description, e.prompt)
707    await crewStep($, { kind: 'spawn', member: { agentId: result.agentId, mate, scene: 'hammer', target: '' } })
708    const agentId = result.agentId
709    const now = await $.clock.now()
710    await update($, muster, current => musterSpawn(current, agentId, mate, e.description, now))
711    tickMuster($)
712    return result
713  }).catch(($, e, next) => { hookFailure($, 'agent.spawn', next.error); return next(e) })
714
715  on('turn.start', async ($, e, next) => {
716    if ('agentId' in e && e.agentId !== undefined) return next(e)
717    const now = await $.clock.now()
718    await update($, turnAt, () => now)
719    plates.live = true
720    plates.pending = true
721    await update($, strikes, () => 0)
722    await update($, activity, () => null)
723    await update($, waiting, () => 0)
724    return next(e)
725  })
726
727  // Obserwacja strumienia nie zmienia ani kawałków, ani wyniku modelu. Jej błąd
728  // gubi tylko scenę: strumień silnika zamyka wyłącznie przerwanie konsumenta.
729  // Dziś nic jej nie wywraca (silnik sprawdza kawałki między hakami i odpowiedzi
730  // `$`), więc `try` to bezpiecznik na przyszły robak w czystym kodzie sceny.
731  on('turn.step', async function* ($, e, next) {
732    let last = ''
733    const stream = next(e)
734    let done = false
735    try {
736      for (;;) {
737        const item = await stream.next()
738        if (item.done) {
739          done = true
740          return item.value
741        }
742        const chunk = item.value
743        const phase = chunk.kind === 'thinking' ? 'think' : chunk.kind === 'text' ? 'write' : null
744        const kind = phase ?? (chunk.kind === 'tool' ? `tool:${chunk.name}` : '')
745        if (kind && kind !== last) {
746          last = kind
747          const description = phase !== null ? describePhase(phase, e.index) : chunk.kind === 'tool' ? describeToolAhead(chunk.name, e.index) : null
748          try {
749            await noteStep($, e.agentId, description)
750          } catch (error) {
751            $.ui.log(`scena strumienia: ${String(error)}`)
752          }
753        }
754        yield chunk
755      }
756    } finally {
757      // Przerwanie konsumenta zamyka także strumień silnika.
758      if (!done) await stream.return(undefined as never)
759    }
760  })
761
762  on('tool.call', async ($, e, next) => {
763    const tool = String(e.tool)
764    const input = e as unknown as Record<string, unknown>
765    const startedAt = await $.clock.now()
766    if (e.agentId === undefined) {
767      const seed = await read($, sessionStrikes)
768      await update($, activity, () => ({ ...describeTool(tool, input, seed), startedAt }))
769      await update($, strikes, count => count + 1)
770      await update($, sessionStrikes, count => count + 1)
771    } else {
772      await crewStep($, { kind: 'mate-tool', agentId: e.agentId, doing: { ...describeTool(tool, input, 0), startedAt } })
773    }
774    const awaits = e.agentId === undefined && workOf(tool, input) === 'agent'
775    if (awaits) await update($, waiting, count => count + 1)
776    let result: Awaited<ReturnType<typeof next>>
777    try {
778      result = await next(e)
779    } finally {
780      if (awaits) await update($, waiting, count => Math.max(0, count - 1))
781    }
782    if (result.deny === undefined) {
783      const isError = result.isError === true
784      const text = typeof result.text === 'string' ? result.text : ''
785      const who = e.agentId === undefined ? 'Krux' : (await read($, muster)).runs.find(one => one.agentId === e.agentId)?.mate ?? 'ork'
786      const at = await $.clock.now()
787      await update($, journal, current => journalAfter(current, { tool, input, result: result.result, text: result.text, isError, tool_use_id: e.tool_use_id, agentId: e.agentId, who, at }))
788      if (e.agentId === undefined) {
789        const run = await boardStep($, tool, input, result.result, { text, isError, who: 'Krux' })
790        await afterOwnTool($, tool, input, isError, text, run?.failures[0], result.result)
791        await refreshUsage($)
792      } else {
793        const agentId = e.agentId
794        // Z pętli kumpla testy i edycje: jego lista zadań to nie plan Kruxa. Imię z apelu,
795        // bo ze sceny kumpel schodzi, gdy pas się zwęża.
796        let run: KruxTestRun | null = null
797        const work = workOf(tool, input)
798        if (work === 'test' || work === 'edit') {
799          const mate = (await read($, muster)).runs.find(one => one.agentId === agentId)?.mate
800          run = await boardStep($, tool, input, result.result, { text, isError, who: mate ?? 'ork' })
801        }
802        await update($, muster, current => musterTool(current, agentId, tool, input))
803        await afterMateTool($, agentId, tool, input, isError, text, run?.failures[0], result.result)
804      }
805      if (GIT_TOOLS.has(tool)) await refreshGit($)
806    }
807    return result
808  }).catch(($, e, next) => { hookFailure($, 'tool.call', next.error); return next(e) })
809
810  // Narzędzie wątków: model otwiera i zamyka wątki Sztolni, w odpowiedzi widzi otwarte z id.
811  on('tool.call', { tool: THREAD_TOOL_NAME }, async ($, e) => {
812    let now = EMPTY_THREADS
813    await update($, threads, current => (now = threadsAfter(current, e as unknown as Record<string, unknown>)))
814    // Wynik narzędzia moda to tekst: rekord obiektem silnik odrzuca.
815    const report = threadsReport(now)
816    return { result: report, text: report }
817  }).catch(($, e, next) => { hookFailure($, 'tool.call', next.error); return next(e) })
818
819  // Po turze miernik ocenia głos, a kronika notuje, kto z hordy się odezwał.
820  on('turn.complete', async ($, e, next) => {
821    if (e.agentId !== undefined) {
822      await crewStep($, { kind: 'mate-done', agentId: e.agentId })
823      $.clock.after(EVENT_HOLD_MS, () => void crewStep($, { kind: 'expire' }))
824      const agentId = e.agentId
825      const now = await $.clock.now()
826      await update($, muster, current => musterDone(current, agentId, now))
827      return next(e)
828    }
829    const now = await $.clock.now()
830    let elapsed = 0
831    await update($, turnAt, started => { elapsed = started === null ? 0 : now - started; return null })
832    if (elapsed >= 300_000) $.ui.toast(`Krux skończyć po ${Math.floor(elapsed / 60_000)} min.`)
833    await update($, activity, () => null)
834    await refreshUsage($)
835    await refreshGit($)
836    if (e.reason === 'answer' && (await read($, modes)).persona) {
837      // Poprawka jedzie raz: krótka odpowiedź nie świadczy o dryfie, więc stary werdykt gaśnie.
838      const verdict = gauge(e.answer)
839      await update($, drift, () => verdict)
840      await update($, lore, now => recordAnswer(now, matesNamed(e.answer)))
841    }
842    return next(e)
843  })
844
845  // Słowo spinnera po orkowemu; to samo słowo przez całą turę.
846  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
847    if (!(await read($, modes)).persona || e.props.message !== null) return next(e)
848    const scene = (await read($, activity))?.scene
849    return next({ ...e, props: { ...e.props, word: spinnerWord(e.props.mode, e.props.word, scene) } })
850  })
851
852  // Etykiety trybów w stopce, obok tych, które rysuje Claude Code.
853  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
854    const now = await read($, modes)
855    const automatic = await read($, autoKonkret)
856    const labels = (['persona', 'konkret', 'flow', 'czat'] as const)
857      .filter(mode => now[mode] || (mode === 'konkret' && automatic))
858      .map(mode => (mode === 'persona' ? 'krux' : mode === 'konkret' && !now.konkret ? 'konkret (auto)' : mode))
859    if (labels.length === 0) return next(e)
860    return next({ ...e, props: { ...e.props, modes: [...e.props.modes, ...labels] } })
861  })
862
863  // Tabliczki w transkrypcie: kto mówi. Sam rysunek, model ich nie czyta.
864  // Silnik stawia pusty wiersz nad wiadomością; tabliczka leży na nim
865  // (`absolute`), więc nie dokłada własnego wiersza ani przerwy pod sobą.
866  // Każdy blok odpowiedzi stoi przy kresce w kolorze Kruxa (`quote`: krawędź
867  // z lewej), więc długa rozmowa dzieli się na bloki; pierwszy dostaje tabliczkę.
868  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
869    if (e.surface !== GRID_SURFACE) return next(e)
870    const now = await read($, modes)
871    if (now.czat) return drawChat($, e, 'Krux', await next(e))
872    if (!now.persona) return next(e)
873    const theirs = await next(e)
874    const { Box, Text } = $.ui.resolve(e)
875    const block = Box({ borderStyle: 'quote', borderColor: KRUX_COLOR, marginBottom: -1, children: [Box({ marginTop: -1, children: [theirs] })] })
876    if (!e.props.isFirstOfReply || !claimPlate(e.requestId)) return block
877    const plate = Box({ key: 'nameplate', position: 'absolute', top: 0, left: 0, children: [Text({ bold: true, color: KRUX_COLOR, children: [NAMEPLATE.krux] })] })
878    return Box({ flexDirection: 'column', children: [block, plate] })
879  })
880
881  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
882    if (e.surface !== GRID_SURFACE) return next(e)
883    const now = await read($, modes)
884    if (now.czat) {
885      // task.id łączy raport po tożsamości, from.name samo nie dowodzi subagenta.
886      const id = e.props.task?.id
887      if (id !== undefined) {
888        const mate = await reportMate($, id)
889        if (mate !== undefined) return drawChat($, e, mate, await next(e))
890      }
891      if (fromPerson(e.props.origin)) return drawChat($, e, 'Morra', await next(e))
892      return next(e)
893    }
894    if (!fromPerson(e.props.origin) || !now.persona) return next(e)
895    const theirs = await next(e)
896    const { Box, Text } = $.ui.resolve(e)
897    const plate = Box({ key: 'nameplate', position: 'absolute', top: 0, left: 0, children: [Text({ bold: true, color: MORRA_COLOR, children: [NAMEPLATE.morra] })] })
898    return Box({ flexDirection: 'column', children: [theirs, plate] })
899  })
900
901  // Złączenie po tool_use_id, nigdy po nazwie narzędzia ani requestId grupy.
902  // Bez wpisu (np. narzędzie w toku albo stara historia) pełny wiersz silnika.
903  // W czacie wiersze narzędzi stoją pod treścią dymków, nie pod awatarami.
904  on('ui.render', { component: ['ToolUse', 'ToolResult', 'ToolGroup'] }, async ($, e, next) => {
905    if (e.surface !== GRID_SURFACE || !(await read($, modes)).czat) return next(e)
906    const table = $.ui.resolve(e)
907    if (e.component === 'ToolGroup') {
908      // Id zapisane przed `next`: wiersze grupy rysują się w jego trakcie.
909      // Wiersz narysowany samodzielnie, zanim dołączył do grupy, rysuje się
910      // od nowa (`invalidate`), żeby nie dostał wcięcia drugi raz.
911      let joined = false
912      for (const call of e.props.calls) {
913        if (call.tool_use_id === undefined || groupedTools.has(call.tool_use_id)) continue
914        groupedTools.add(call.tool_use_id)
915        joined = true
916      }
917      const content = await next(e)
918      if (joined) $.ui.invalidate('ui.render')
919      return toolIndent(table, content)
920    }
921    if (groupedTools.has(e.props.tool_use_id)) return next(e)
922    return toolIndent(table, await next(e))
923  })
924
925  on('ui.render', { component: ['ToolUse', 'ToolResult', 'ToolGroup'] }, async ($, e, next) => {
926    if (!await journalShown($, e.surface)) return next(e)
927    const entries = (await read($, journal)).entries
928    const calls = e.component === 'ToolGroup' ? e.props.calls : [e.props]
929    const matched = calls.map(call => call.tool_use_id === undefined ? undefined : entries.findLast(entry => entry.id === call.tool_use_id))
930    // Grupa jest całością: brak choć jednego id nie może zgubić jego wyniku.
931    if (matched.length === 0 || matched.some(entry => entry === undefined)) return next(e)
932    const columns = e.viewport?.columns
933    if (columns === undefined || !Number.isFinite(columns) || columns <= 0) return next(e)
934    const table = $.ui.resolve(e)
935    // Wiersz narzędzia już niesie linię dziennika; osobny wynik dałby ją drugi raz.
936    if (e.component === 'ToolResult') return table.Box({ key: 'journal-result', height: 0 })
937    return toolLine(table, matched as NonNullable<typeof matched[number]>[], columns - TOOL_INDENT)
938  })
939
940  // Pas nad promptem: podpis z lewej, za nim Krux i wysłani kumple, dymek nad
941  // tym, kto ostatnio coś zrobił. Plan liczy `band.ts`; tu tylko stan i elementy.
942  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
943    if (e.props.hasSurvey || e.surface !== GRID_SURFACE) return next(e)
944    const now = await read($, modes)
945    if (!now.persona || !now.animacje) return next(e)
946    const working = e.props.isWorking
947    if (!working && !now.kowal) return next(e)
948    const table = $.ui.resolve(e)
949    const scene = await read($, crew)
950    const members = await liveMembers($, scene.members)
951    const input: BandInput = {
952      working,
953      activity: working ? await read($, activity) : null,
954      strikes: await read($, strikes),
955      total: await read($, sessionStrikes),
956      moods: await read($, moods),
957      members,
958      held: scene.bubble,
959      cues: scene.cues ?? [],
960      waiting: (await read($, waiting)) > 0,
961      still: await read($, still),
962      now: await $.clock.now(),
963      columns: e.props.bodyColumns,
964      rows: e.props.maxRows,
965      hasClient: 'Client' in table,
966    }
967    // Skrót Sztolni za płótnem, gdy jej panelu nie widać: zamknięty, czeka na
968    // szerszy terminal (bez pytania silnik stawia go od 144 kolumn) albo stoi
969    // za inną kartą. Zabiera pusty slot kumpla, ale nie spycha kumpla ze sceny
970    // i nie zmienia wysokości pasa.
971    const digestShown = !(await shaftShown($, now.sztolnia))
972    if (digestShown) await read($, musterNow)
973    const digestExtra = digestShown ? { git: await read($, git), threads: await read($, threads), now: input.now } : null
974    const lines = digestExtra === null ? [] : shaftDigest(await read($, board), await read($, usage), digestExtra)
975    let plan = bandPlan(input)
976    if (lines.length > 0 && plan.stage !== null) {
977      const narrow = bandPlan({ ...input, columns: input.columns - DIGEST_COLUMNS - CAPTION_GAP })
978      if (narrow.stage !== null && narrow.stage.height === plan.stage.height && narrow.stage.props.slots > members.length) plan = narrow
979    }
980    const theirs = await next(e)
981    const { Box, Text } = table
982    // Kreska nad pasem oddziela go od odpowiedzi i zabiera jeden wiersz.
983    const rule = plan.rule ? [Text({ dimColor: true, wrap: 'truncate-end', children: ['─'.repeat(Math.max(1, e.props.bodyColumns))] })] : []
984    if (plan.stage === null || !('Client' in table)) {
985      const caption = [[plan.verb, plan.target].filter(Boolean).join(' '), plan.tally].filter(Boolean).join(' · ')
986      const line = Text({ dimColor: true, wrap: 'truncate-end', children: [`⚒ ${caption}`] })
987      return Box({ flexDirection: 'column', children: [...rule, line, theirs] })
988    }
989    const caption = Box({
990      flexDirection: 'column',
991      width: CAPTION_COLUMNS,
992      children: [
993        Text({ bold: true, color: KRUX_COLOR, wrap: 'truncate-end', children: [plan.verb] }),
994        ...(plan.target ? [Text({ dimColor: true, wrap: 'truncate-middle', children: [plan.target] })] : []),
995        ...(plan.tally ? [Text({ dimColor: true, wrap: 'truncate-end', children: [plan.tally] })] : []),
996      ],
997    })
998    const forge = table.Client({ key: 'forge', module: './forge.ts', props: plan.stage.props, width: plan.stage.width, height: plan.stage.height })
999    const free = e.props.bodyColumns - CAPTION_COLUMNS - plan.stage.width - 2 * CAPTION_GAP
1000    // Skrót nie podnosi pasa: tyle linii, ile wierszy płótna, od najważniejszej.
1001    const digest = free >= DIGEST_COLUMNS ? lines.slice(0, plan.stage.height) : []
1002    const aside =
1003      digest.length === 0
1004        ? []
1005        : [
1006            // Skrót stoi przy prawej krawędzi pasa, najwyżej `DIGEST_MAX` kolumn szeroki.
1007            Box({
1008              key: 'digest-slot',
1009              flexDirection: 'row',
1010              justifyContent: 'flex-end',
1011              width: free,
1012              children: [
1013                Box({
1014                  key: 'digest',
1015                  flexDirection: 'column',
1016                  width: Math.min(free, DIGEST_MAX),
1017                  children: digest.map(line =>
1018                    Box({
1019                      key: line.key,
1020                      flexDirection: 'row',
1021                      columnGap: 1,
1022                      children: [
1023                        Text({ ...(line.color === undefined ? {} : { color: line.color }), children: [line.mark] }),
1024                        Box({ flexGrow: 1, flexShrink: 1, children: [Text({ dimColor: true, wrap: 'truncate-end', children: [line.text] })] }),
1025                      ],
1026                    }),
1027                  ),
1028                }),
1029              ],
1030            }),
1031          ]
1032    return Box({
1033      flexDirection: 'column',
1034      children: [...rule, Box({ flexDirection: 'row', columnGap: CAPTION_GAP, alignItems: 'flex-start', children: [caption, forge, ...aside] }), theirs],
1035    })
1036  })
1037
1038  // Krzyżyk Morry na Sztolni gasi tryb, żeby panel nie wracał przy następnym starcie.
1039  on('ui.close', async ($, e, next) => {
1040    if (e.id === SHAFT_PANE && e.origin.kind === 'person' && (await read($, modes)).sztolnia) {
1041      await setModes($, { mode: 'sztolnia', on: false })
1042      $.ui.toast('Sztolnia zamknięta. Wraca przez /krux sztolnia.')
1043    }
1044    return next(e)
1045  }).catch(($, e, next) => { hookFailure($, 'ui.close', next.error); return next(e) })
1046
1047  // Sztolnia: stan roboty. Plan, ostatni przebieg testów, horda w biegu,
1048  // kontekst i limity; drzewo buduje `shaft.ts`. Diff i drzewo zmian rysuje silnik (`/diff`).
1049  on('ui.render', { component: 'Pane', requestId: SHAFT_PANE }, async ($, e) => {
1050    const table = $.ui.resolve(e)
1051    const tab = await read($, shaftTab)
1052    const tabs = shaftTabs(table, tab, async selected => { await update($, shaftTab, () => selected) })
1053    const now = await $.clock.now()
1054    if (tab === 'dziennik') return table.Box({ flexDirection: 'column', children: [tabs, journalTree(table, await read($, journal), e.props.bodyColumns, now)] })
1055    const plan = await read($, board)
1056    // Odczyt tyknięcia apelu zapisuje panel na przerysowanie co 1 s: czas orków płynie.
1057    await read($, musterNow)
1058    const ids = await runningIds($)
1059    const horde = musterRows(await read($, muster), now, ids, 6)
1060    const content = shaftTree(table, { board: plan, horde, usage: await read($, usage), git: await read($, git), threads: await read($, threads), now, columns: e.props.bodyColumns, rows: Math.max(0, e.props.scroll.bodyRows - 1) })
1061    return table.Box({ flexDirection: 'column', children: [tabs, content] })
1062  })
1063
1064  // Panel /krux: kuźnia, tryby pod klawiszami 1–7, horda.
1065  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1066    const now = await read($, modes)
1067    const isStill = (await read($, still)) || !now.animacje
1068    const mood = await read($, moods)
1069    const table = $.ui.resolve(e)
1070    const { Box, Text, Button } = table
1071    const toggleButton = (mode: KruxMode, hotkey: string) =>
1072      Button({
1073        key: `toggle-${mode}`,
1074        label: `${mode}: ${now[mode] ? 'on' : 'off'}`,
1075        hotkey,
1076        plain: true,
1077        dimColor: !now[mode],
1078        onPress: async () => {
1079          const { on: isOn, note } = await setModes($, { mode, on: 'flip' })
1080          if (mode === 'sztolnia') await syncShaftPane($, isOn)
1081          if (note) {
1082            const at = await $.clock.now()
1083            pendingNotes = [...freshNotes(at), { text: note, at }].slice(-NOTES_LIMIT)
1084          }
1085          $.ui.toast(userLine(mode, isOn))
1086        },
1087      })
1088    const scale = e.props.bodyColumns >= forgeColumns('pane', 2) + 2 ? 2 : 1
1089    const art =
1090      e.surface === GRID_SURFACE && 'Client' in table && e.props.bodyColumns >= forgeColumns('pane', 1)
1091        ? [
1092            table.Client({
1093              key: `forge-pane-${scale}-${isStill ? 'still' : 'live'}`,
1094              module: './forge.ts',
1095              props: { variant: 'pane', scale, still: isStill, rest: false, slots: 1, bubbleRows: false, orcs: [{ key: 'krux', face: 'Krux', scene: 'hammer', mood: mood.faces.Krux }], bubble: null },
1096              width: forgeColumns('pane', scale),
1097              height: 3 * scale,
1098            }),
1099          ]
1100        : []
1101    return Box({
1102      flexDirection: 'column',
1103      gap: 1,
1104      children: [
1105        ...art,
1106        Box({
1107          flexDirection: 'column',
1108          children: [
1109            Text({ bold: true, children: ['Tryby'] }),
1110            // Przyciski zawijają się do szerokości panelu w równych komórkach.
1111            Box({
1112              flexDirection: 'row',
1113              flexWrap: 'wrap',
1114              columnGap: 1,
1115              children: MODES.map((mode, index) => Box({ key: `cell-${mode}`, width: MODE_CELL, children: [toggleButton(mode, String(index + 1))] })),
1116            }),
1117          ],
1118        }),
1119        Box({
1120          flexDirection: 'column',
1121          children: [
1122            Text({ bold: true, children: ['Horda'] }),
1123            ...MATES.map(mate =>
1124              Box({
1125                key: `orc-${mate}`,
1126                flexDirection: 'row',
1127                columnGap: 1,
1128                children: [Text({ color: ORC_COLOR, bold: true, children: [mate.padEnd(6)] }), Text({ dimColor: true, children: [ROSTER[mate].trade] })],
1129              }),
1130            ),
1131          ],
1132        }),
1133        Text({ dimColor: true, children: ['1–7 przełączają · Esc zamyka · skill hordy: /krux-mod:krux-horda'] }),
1134      ],
1135    })
1136  })
1137}
1138
hooks/band.ts 102 lines
1// Pas nad promptem jako czysty plan: co powiedzieć w podpisie, kto stoi na
2// płótnie i ile wierszy pas zabiera. register.ts tylko czyta stan i zamienia
3// plan na elementy; literał modułu płótna zostaje tam.
4
5import type { KruxActivity, KruxBubble, KruxCue, KruxHordeMember, KruxMoods } from '../types'
6import type { ForgeOrc, ForgeProps } from './forge'
7import { bubbleFor } from './mood'
8import { ORC_GAP, ORC_WIDTH, bandColumns, canvasColumns } from './sprites'
9import { restVerb } from './voice'
10
11// Podpis z lewej, przerwa 2 kolumny, za nim płótno.
12export const CAPTION_COLUMNS = 28
13export const CAPTION_GAP = 2
14
15// Krux i do trzech kumpli; reszta hordy liczona w podpisie.
16const STAGE_MATES = 3
17
18// Płótno z dymkiem: 3 wiersze dymka i 3 wiersze sceny. Niskie: sama scena.
19const TALL_ROWS = 6
20const LOW_ROWS = 3
21
22export type BandInput = {
23  working: boolean
24  activity: KruxActivity | null
25  // Uderzenia tury wybierają podpis spoczynku; suma sesji jest seedem dymka.
26  strikes: number
27  total: number
28  moods: KruxMoods
29  // Kumple, których silnik widzi jako `running`, w kolejności wejścia.
30  members: readonly KruxHordeMember[]
31  held: KruxBubble | null
32  cues?: readonly KruxCue[]
33  // Krux czeka na `Agent` z pierwszego planu.
34  waiting: boolean
35  // Ograniczony ruch w ustawieniach.
36  still: boolean
37  now: number
38  columns: number
39  rows: number
40  // Czy powierzchnia potrafi narysować płótno `Client`.
41  hasClient: boolean
42}
43
44export type BandPlan = {
45  // Szara kreska nad pasem, gdy jest na nią wiersz.
46  rule: boolean
47  verb: string
48  target: string
49  // Wyłącznie ukryci kumple: „+N z hordy”, bez licznika uderzeń.
50  tally: string
51  // `null`: za wąsko albo za nisko na płótno — pas to jedna linia tekstu.
52  stage: { props: ForgeProps; width: number; height: number } | null
53}
54
55export function bandPlan(input: BandInput): BandPlan {
56  const { working, members } = input
57  const room = input.columns - CAPTION_COLUMNS - CAPTION_GAP
58  const fit = input.hasClient ? Math.floor((room + ORC_GAP) / (ORC_WIDTH + ORC_GAP)) : 0
59  // Stała liczba prostokątów: płótno nie zmienia szerokości, gdy horda przychodzi i odchodzi.
60  const slots = Math.max(1, Math.min(1 + STAGE_MATES, fit))
61  const shown = members.slice(0, slots - 1)
62  const hidden = members.length - shown.length
63  // Krux czeka na wyniki hordy: w spoczynku albo w trakcie `Agent` na pierwszym planie.
64  // Gdy kumpel już oddał zwój, róg z wywołania `Agent` zagrał: Krux odbiera zwój
65  // tam, gdzie czekał, zamiast dąć od nowa.
66  const handedBack = input.activity?.scene === 'horn' && (input.cues ?? []).some(cue => cue.kind === 'handoff' && cue.until > input.now)
67  const lounging = handedBack || (members.length > 0 && (!working || input.waiting))
68  const orcs: ForgeOrc[] = [
69    { key: 'krux', face: 'Krux', scene: lounging ? 'lounge' : (input.activity?.scene ?? 'hammer'), mood: input.moods.faces.Krux, ...(!lounging && input.activity?.startedAt !== undefined ? { workAt: input.activity.startedAt } : {}) },
70    ...shown.map(member => ({ key: member.agentId, face: member.mate, scene: member.scene, mood: member.mate ? input.moods.faces[member.mate] : ('calm' as const), ...(member.startedAt === undefined ? {} : { workAt: member.startedAt }) })),
71  ]
72  // Krok kumpla, którego już nie ma na scenie, nie wisi nad nikim.
73  const gone = input.held?.kind === 'step' && !members.some(member => member.agentId === input.held!.key)
74  const said = bubbleFor({ now: input.now, held: gone ? null : input.held, working, activity: input.activity, moods: input.moods, seed: input.total })
75  const speaker = said === null ? undefined : orcs.find(orc => orc.key === said.key)
76  // Wysokość pasa zależy tylko od miejsca, nie od tego, czy ktoś mówi.
77  const tall = input.hasClient && input.rows >= TALL_ROWS + 1 && room >= canvasColumns(slots, true)
78  const height = tall ? TALL_ROWS : input.rows >= LOW_ROWS && fit >= 1 ? LOW_ROWS : 0
79  const inCanvas = tall && said !== null && speaker !== undefined
80  const verb = lounging ? 'Krux czekać na hordę' : working ? (input.activity?.verb ?? 'Krux rozpalać palenisko') : restVerb(input.strikes, input.total)
81  const spoken = said === null ? '' : said.speaker === 'Krux' ? said.text : `${said.speaker === 'ork' ? 'Ork' : said.speaker}: ${said.text}`
82  const target = !inCanvas && spoken ? spoken : working ? (input.activity?.target ?? '') : ''
83  const tally = hidden > 0 ? `+${hidden} z hordy` : ''
84  // Kreska zabiera wiersz, więc staje tylko nad pasem, który mieści się cały.
85  const rule = input.rows >= Math.max(1, height) + 1
86  if (height === 0) return { rule, verb, target, tally, stage: null }
87  const props: ForgeProps = {
88    variant: 'band',
89    scale: 1,
90    still: input.still,
91    // W spoczynku płótno stoi, chyba że na scenie kuje jeszcze agent w tle.
92    rest: !working && shown.length === 0,
93    slots,
94    bubbleRows: tall,
95    orcs,
96    bubble: inCanvas ? { key: speaker!.key, text: said!.text, speaker: said!.speaker } : null,
97    cues: (input.cues ?? []).filter(cue => cue.until > input.now),
98    now: input.now,
99  }
100  return { rule, verb, target, tally, stage: { props, width: tall ? canvasColumns(slots, true) : bandColumns(slots), height } }
101}
102
hooks/crew.ts 93 lines
1// Scena poza Kruxem: kto z hordy na niej stoi, czyj dymek wisi i jakie znaki
2// czekają na płótno. Każde zdarzenie to jedno przejście `crewAfter`; stan trzyma
3// register.ts, jednym `update`. Kumpel mówi tylko wtedy, gdy stoi na scenie:
4// o swoim kroku, o wyniku swojej roboty albo o zdarzeniu z kroniki.
5
6import type { KruxActivity, KruxCrew, KruxCue, KruxEvent, KruxHordeMember, KruxLore } from '../types'
7import { EVENT_HOLD_MS, EVENT_SPEAKER, eventLine, mateStep } from './mood'
8
9export const EMPTY_CREW: KruxCrew = { members: [], bubble: null, cues: [] }
10
11// Znaki starsze niż wtręt płótno już zagrało; tyle wystarczy na kilka zdarzeń naraz.
12const CUES_KEPT = 8
13
14export type CrewEvent =
15  // Subagent z głównej pętli wbiega na scenę.
16  | { kind: 'spawn'; member: KruxHordeMember }
17  // Narzędzie albo faza kumpla (dumanie, pisanie): jego poza i dymek z krokiem, chyba że wisi świeży wtręt.
18  | { kind: 'mate-tool'; agentId: string; doing: KruxActivity }
19  // Narzędzie Kruxa: wtręt i reakcja Kruxa po zdarzeniu albo koniec dymka kumpla.
20  | { kind: 'own-tool'; event: KruxEvent | null; lore: KruxLore; seed: number; failure?: string }
21  // Wynik narzędzia kumpla: jego wtręt i reakcja na własną robotę; `lore` to kronika tego jednego wywołania.
22  | { kind: 'mate-event'; agentId: string; event: KruxEvent; lore: KruxLore; seed: number; failure?: string }
23  // Pętla kumpla skończona: oddaje zwój i schodzi ze sceny razem ze swoim krokiem.
24  | { kind: 'mate-done'; agentId: string }
25  // Silnik widzi jako `running` tylko tych; reszta schodzi bez zakończenia i bez zwoju.
26  | { kind: 'running'; ids: ReadonlySet<string> }
27  // Minął czas wtrętu.
28  | { kind: 'expire' }
29
30// Stan sprzed tej wersji nie ma znaków: brak to pusta lista.
31function cuesOf(crew: KruxCrew): KruxCue[] {
32  return crew.cues ?? []
33}
34
35function cued(crew: KruxCrew, cue: KruxCue, now: number): KruxCue[] {
36  return [...cuesOf(crew).filter(old => old.until > now), cue].slice(-CUES_KEPT)
37}
38
39export function crewAfter(crew: KruxCrew, event: CrewEvent, now: number): KruxCrew {
40  const { members, bubble } = crew
41  switch (event.kind) {
42    case 'spawn':
43      return { ...crew, members: [...members.filter(other => other.agentId !== event.member.agentId), event.member] }
44    case 'mate-tool': {
45      const member = members.find(other => other.agentId === event.agentId)
46      if (member === undefined) return crew
47      // Róg zostaje dla Kruxa: kumpel, który sam woła hordę, dalej kuje.
48      const scene = event.doing.scene === 'horn' ? 'hammer' : event.doing.scene
49      const moved = members.map(other => (other === member ? { ...member, scene, target: event.doing.target, ...(event.doing.startedAt === undefined ? {} : { startedAt: event.doing.startedAt }) } : other))
50      if (bubble?.kind === 'event' && bubble.until > now) return { ...crew, members: moved }
51      return { ...crew, members: moved, bubble: { kind: 'step', key: member.agentId, speaker: member.mate ?? 'ork', text: mateStep(event.doing) } }
52    }
53    case 'own-tool': {
54      if (event.event === null) return bubble?.kind === 'step' ? { ...crew, bubble: null } : crew
55      const mate = EVENT_SPEAKER[event.event]
56      const member = members.find(other => other.mate === mate)
57      const text = eventLine(event.event, event.lore, event.seed, event.failure)
58      const until = now + EVENT_HOLD_MS
59      // Komentuje kumpel z pasującym fachem, ale reaguje ten, kto robił: Krux.
60      const cues = cued(crew, { key: 'krux', kind: event.event, until }, now)
61      return { ...crew, cues, bubble: member ? { kind: 'event', key: member.agentId, speaker: mate, text, until } : { kind: 'event', key: 'krux', speaker: 'Krux', text, until } }
62    }
63    case 'mate-event': {
64      const member = members.find(other => other.agentId === event.agentId)
65      if (member === undefined) return crew
66      const text = eventLine(event.event, event.lore, event.seed, event.failure)
67      const until = now + EVENT_HOLD_MS
68      return { ...crew, cues: cued(crew, { key: member.agentId, kind: event.event, until }, now), bubble: { kind: 'event', key: member.agentId, speaker: member.mate ?? 'ork', text, until } }
69    }
70    case 'mate-done': {
71      const left = members.filter(other => other.agentId !== event.agentId)
72      if (left.length === members.length) return crew
73      return {
74        members: left,
75        bubble: bubble?.kind === 'step' && bubble.key === event.agentId ? null : bubble,
76        cues: cued(crew, { key: event.agentId, kind: 'handoff', until: now + EVENT_HOLD_MS }, now),
77      }
78    }
79    case 'running': {
80      const left = members.filter(other => event.ids.has(other.agentId))
81      if (left.length === members.length) return crew
82      return { ...crew, members: left, bubble: bubble?.kind === 'step' && !event.ids.has(bubble.key) ? null : bubble }
83    }
84    case 'expire': {
85      const cues = cuesOf(crew)
86      const fresh = cues.filter(cue => cue.until > now)
87      const quiet = bubble?.kind === 'event' && bubble.until <= now
88      if (!quiet && fresh.length === cues.length) return crew
89      return { ...crew, bubble: quiet ? null : bubble, cues: fresh }
90    }
91  }
92}
93
hooks/gauge.ts 276 lines
1// Miernik głosu: po każdej turze mod patrzy na odpowiedź i mówi, czy Krux
2// dalej brzmi jak ork. Metryki przeniesione z `scripts/context-smoke.js`
3// pluginu Krux 3.8.0 (tam stoi ich kalibracja); tu liczą jedną odpowiedź.
4// Heurystyki, nie parser. W JS \w i \b są ASCII, więc wszędzie \p{L} i flaga u.
5
6import type { KruxDrift, KruxMate } from '../types'
7import { MATES, ROSTER } from './roster'
8import { isDestructive } from './voice'
9
10function wordCount(text: string): number {
11  const clean = text.trim()
12  return clean ? clean.split(/\s+/u).length : 0
13}
14
15// Tabela i cytat `>` to nie zdania Kruxa: w cytacie stoją teksty do wklejenia
16// (opis PR, commit message), które kontrakt persony każe pisać neutralnie.
17function ownLine(line: string): boolean {
18  return (line.match(/\|/gu) ?? []).length < 2 && !/^\s*>/u.test(line)
19}
20
21// Kod, ścieżki i cytaty w polskim cudzysłowie to nie mowa Kruxa do Morry.
22function prose(text: string): string {
23  return text
24    .replace(/```[\s\S]*?```/gu, ' ')
25    .replace(/`[^`]*`/gu, 'X')
26    .replace(/„[^„”"\n]*[”"]/gu, ' ')
27    .split(/\r?\n/u)
28    .filter(ownLine)
29    .join('\n')
30}
31
32// Rzeczowniki i zaimki na „-sz” („klawisz”, „nasz”, „hasz”) to nie druga osoba.
33const SECOND_PERSON = /(?<!\p{L})(?!(?:nasz|wasz|klawisz|mysz|afisz|gulasz|tomasz|łukasz|hasz|pałasz)(?!\p{L}))(?:\p{L}*(?:asz|esz|isz|ysz)|ty|ci[eę]|ciebie|tobie|tob[ąa]|twoj\p{L}*)(?!\p{L})/giu
34
35// „zrobiłem”, „sprawdziłam”, „naprawiłem” — gładka pierwsza osoba zamiast „Krux zrobić”.
36const FIRST_PERSON = /(?<!\p{L})\p{L}{2,}(?:łem|łam)(?!\p{L})/giu
37
38// Pierwsza osoba czasu teraźniejszego i przyszłego: „Widzę”, „Polecam”, „Zrobię”.
39// Po końcówce się jej nie pozna („-ę” to też biernik: „pętlę”), więc tylko lista
40// czasowników, które gładka odpowiedź o kodzie mówi najczęściej. Kotwica ma na nią
41// parę „Krux polecać wariant 2”, nie „Polecam wariant 2”.
42const FIRST_PRESENT =
43  /(?<!\p{L})(?:widzę|sprawdzam|sprawdzę|mogę|robię|zrobię|polecam|proponuję|sugeruję|rekomenduję|rozumiem|uważam|wiem|zakładam|zaczynam|zacznę|naprawiam|naprawię|poprawię|dodaję|dodam|usuwam|usunę|uruchamiam|uruchomię|puszczam|puszczę|odpalę|przejrzę|przeczytam|czytam|patrzę|myślę|zostawiam|zostawię|zmieniam|zmienię|biorę|piszę|napiszę|szukam|poszukam|wracam|kończę|skończę|zajmę|przygotuję|pokażę|wyjaśnię|muszę|chcę|potrzebuję)(?!\p{L})/giu
44
45const SUBJECT_INFINITIVE = /(?<![\n\r*-]\s?)(?<!\d[.)]\s?)(?<!\p{L})([\p{L}`_.]{2,})\s+(?:nie\s+)?(\p{L}+(?:ać|eć|ić|yć|ąć|uć|ść|źć))(?!\p{L})/gu
46
47const NOT_INFINITIVE = new Set(['nić', 'sieć', 'płeć', 'część', 'gość', 'kość', 'maść', 'treść', 'wieść', 'śmierć', 'pamięć', 'chęć', 'gałąź', 'rzeź', 'zamieć', 'opowieść', 'korzyść'])
48
49const LICENSERS = new Set([
50  'trzeba', 'można', 'warto', 'należy', 'wolno', 'wystarczy', 'łatwo', 'trudno', 'musi', 'muszę', 'musimy', 'może', 'mogę',
51  'możemy', 'możesz', 'chce', 'chcę', 'chcemy', 'umie', 'umiem', 'potrafi', 'potrafię', 'powinien', 'powinna', 'zaczyna',
52  'zacznie', 'próbuje', 'pozwala', 'pomaga', 'zamierza', 'planuje', 'lubi', 'woli', 'da', 'się', 'by', 'aby', 'żeby',
53  'zamiast', 'bez', 'będzie', 'lepiej', 'czas', 'i', 'oraz', 'lub', 'albo', 'potem', 'najpierw', 'to', 'co', 'go', 'ją',
54  'je', 'ich', 'nam', 'im', 'mu', 'jej', 'tego', 'tym', 'tam', 'jak', 'gdy', 'kiedy', 'czy', 'niż', 'tylko', 'już', 'też',
55  'także', 'jeszcze', 'znów', 'znowu', 'wreszcie', 'dopiero', 'nigdy', 'zawsze', 'wtedy', 'zaraz', 'teraz',
56])
57
58const INFINITIVE_ENDING = /(?:ać|eć|ić|yć|ąć|uć|ść|źć)$/u
59
60// Bezokolicznik nie kończy się na „-ość”, rzeczownik często: „poprawność”, „wartość”.
61function notInfinitive(verb: string): boolean {
62  return NOT_INFINITIVE.has(verb) || verb.endsWith('ość')
63}
64
65// Podmiot, znacznik czasu i bezokolicznik: „Krux już naprawić”, „Krux zaraz puścić”.
66// Sam znacznik stoi w LICENSERS, bo „potem uruchomić testy” to zwykła polska lista kroków.
67const TENSE_INFINITIVE = /(?<!\p{L})([\p{L}`_.]{2,})\s+(?:już|zaraz|potem|teraz|jeszcze|znowu|znów)\s+(?:nie\s+)?(\p{L}+(?:ać|eć|ić|yć|ąć|uć|ść|źć))(?!\p{L})/gu
68
69// Bezokolicznik w miejscu orzeczenia przy podmiocie: „Krux nie widzieć plik”.
70export function infinitiveHits(text: string): number {
71  let hits = 0
72  for (const match of text.matchAll(TENSE_INFINITIVE)) {
73    const subject = match[1]!.toLowerCase().replace(/[`_.]/gu, '')
74    if (notInfinitive(match[2]!.toLowerCase()) || LICENSERS.has(subject) || INFINITIVE_ENDING.test(subject)) continue
75    hits += 1
76  }
77  for (const match of text.matchAll(SUBJECT_INFINITIVE)) {
78    const subject = match[1]!.toLowerCase().replace(/[`_.]/gu, '')
79    const verb = match[2]!.toLowerCase()
80    if (notInfinitive(verb) || LICENSERS.has(subject) || INFINITIVE_ENDING.test(subject)) continue
81    hits += 1
82  }
83  return hits
84}
85
86function matches(text: string, pattern: RegExp): string[] {
87  return [...text.matchAll(pattern)].map(match => match[0])
88}
89
90// Którzy kumple z hordy padli w odpowiedzi (poza kodem).
91export function matesNamed(text: string): KruxMate[] {
92  const clean = text.replace(/```[\s\S]*?```/gu, ' ').replace(/`[^`]*`/gu, ' ')
93  return MATES.filter(mate => ROSTER[mate].pattern.test(clean))
94}
95
96// Forma osobowa → bezokolicznik tylko tam, gdzie reguła jest pewna. Czego
97// nie umiemy przepisać bez błędu („znalazłem”, „mogłem”), tego nie ruszamy.
98// „-ałem” i „-iałem” bywają od „-ać” i od „-eć” („zmieniałem”, „chciałem”),
99// więc czasowniki na „-eć” stoją na liście; reszta idzie do „-ać”.
100const PAST_EC = /(?:chciał|miał|umiał|rozumiał|wiedział|widział|siedział|musiał|wisiał|leciał|cierpiał|pomniał|myślał|słyszał|leżał|krzyczał|milczał|wolał|bolał)$/u
101const PAST_IAC = /śmiał$/u
102
103const PAST_FIRST: readonly [RegExp, string][] = [
104  [/jrzał(?:em|am)$/u, 'jrzeć'],
105  [/ił(?:em|am)$/u, 'ić'],
106  [/ył(?:em|am)$/u, 'yć'],
107  [/ął(?:em|am)$/u, 'ąć'],
108  [/ał(?:em|am)$/u, 'ać'],
109]
110
111// Rzeczowniki w narzędniku, które wyglądają jak „-łem”: „materiałem”, „modułem”,
112// „stołem”, „węzłem”, „zawałem”, „hasłem”. Czasowników na „-ołem” polszczyzna nie ma;
113// „-ułem” i spółgłoska przed „-łem” bywają czasownikiem („zepsułem”, „mogłem”,
114// „znalazłem”), więc tu tylko znane rzeczowniki. Bez nich reguła „-ałem” → „-ać”
115// robiła z „zawałem” słowo „Krux zawać”. „Dział” tylko z przedrostkiem rzeczownika:
116// „widziałem” i „powiedziałem” też kończą się na „-działem”.
117const NOUN_LEM =
118  /^(?:materiał|kanał|sygnał|potencjał|(?:roz|po|u|wy|od|prze|przy)?dział|interwał|zapał|ideał|kryształ|wał|zawał|nawał|kapitał|upał|szał|morał|rytuał|generał|admirał|arsenał|tył|moduł|artykuł|tytuł|muł|węzł|hasł|źródł|\p{L}*mysł|\p{L}*miosł|światł|szkł|krzesł|masł|ciepł|kotł|orł|posł|osł|kozł|pudł|wiosł|godł|mydł|siodł|\p{L}*oł)em$/u
119
120function isNounLem(word: string): boolean {
121  return NOUN_LEM.test(word.toLowerCase())
122}
123
124function pastFirst(word: string): string | null {
125  if (isNounLem(word)) return null
126  const stem = word.replace(/(?:em|am)$/u, '')
127  if (stem !== word && PAST_EC.test(stem) && !PAST_IAC.test(stem)) return stem.replace(/ał$/u, 'eć')
128  return rule(word, PAST_FIRST)
129}
130
131// Pierwsza osoba czasu teraźniejszego, tylko na początku zdania („Polecam…”),
132// bo w środku „-am” bywa rzeczownikiem („program”). „-uję” bywa od „-ować”,
133// „-ywać” i „-uć” („proponuję”, „pokazuję”, „czuję”).
134const PRESENT_FIRST: readonly [RegExp, (word: string) => string | null][] = [
135  [/^(?:po|ze|wy|prze)?(?:czu|psu|ku|plu)ję$/u, word => word.replace(/ję$/u, 'ć')],
136  [/szukuję$/u, word => word.replace(/uję$/u, 'iwać')],
137  [/(?:kazuję|pisuję|konuję|wołuję|czytuję|wiązuję|dowiaduję|owuję)$/u, word => word.replace(/uję$/u, 'ywać')],
138  [/uję$/u, word => word.replace(/uję$/u, 'ować')],
139  [/gram$/u, () => null],
140  [/(?<=\p{L}{3})am$/u, word => word.replace(/am$/u, 'ać')],
141]
142
143function presentFirst(word: string): string | null {
144  const found = PRESENT_FIRST.find(([pattern]) => pattern.test(word))
145  return found ? found[1](word) : null
146}
147
148// Trzecia osoba najczęstszych czasowników w odpowiedziach o kodzie.
149const THIRD: Record<string, string> = {
150  siedzi: 'siedzieć', ma: 'mieć', robi: 'robić', widzi: 'widzieć', działa: 'działać', zwraca: 'zwracać',
151  idzie: 'iść', wie: 'wiedzieć', trzyma: 'trzymać', pada: 'padać', zostaje: 'zostawać', rośnie: 'rosnąć',
152  sprawdza: 'sprawdzać', wywołuje: 'wywoływać', ustawia: 'ustawiać', zapisuje: 'zapisywać', czyta: 'czytać',
153  wraca: 'wracać', łapie: 'łapać', psuje: 'psuć', blokuje: 'blokować', używa: 'używać', potrzebuje: 'potrzebować',
154  przechodzi: 'przechodzić', trafia: 'trafiać', zmienia: 'zmieniać', dodaje: 'dodawać', usuwa: 'usuwać',
155}
156
157function rule(word: string, rules: readonly [RegExp, string][]): string | null {
158  for (const [pattern, ending] of rules) if (pattern.test(word)) return word.replace(pattern, ending)
159  return null
160}
161
162// Słowo po orkowemu albo `null`. `atStart`: słowo otwiera zdanie. `next`: słowo
163// za nim — przed bezokolicznikiem („ma szukać”) forma osobowa zostaje.
164export function orcish(word: string, atStart: boolean, next = ''): string | null {
165  const lower = word.toLowerCase()
166  const past = pastFirst(lower)
167  if (past) return `Krux ${past}`
168  if (atStart) {
169    const present = presentFirst(lower)
170    if (present) return `Krux ${present}`
171  }
172  const third = Object.hasOwn(THIRD, lower) ? THIRD[lower] : null
173  if (!third) return null
174  const following = next.toLowerCase()
175  return INFINITIVE_ENDING.test(following) && !NOT_INFINITIVE.has(following) ? null : third
176}
177
178const REWRITE_WORDS = 8
179const MAX_REWRITES = 2
180
181// Do 2 zdań z odpowiedzi z formą osobową zamienioną na bezokolicznik.
182// Kod w backtickach zostaje, bloki kodu i tabele odpadają.
183export function rewrites(answer: string): { from: string; to: string }[] {
184  const text = answer
185    .replace(/```[\s\S]*?```/gu, '\n')
186    .split(/\r?\n/u)
187    .filter(ownLine)
188    .map(line => line.replace(/^\s*(?:[-*]|\d+[.)])\s+/u, '').replace(/\*\*/gu, ''))
189    .join('\n')
190  const found: { from: string; to: string }[] = []
191  for (const sentence of text.split(/(?<=[.!?:])\s+|\n+/u)) {
192    const words = sentence.trim().split(/\s+/u).filter(Boolean)
193    const quoted = inQuotes(words)
194    const orcAt = (at: number): string | null => {
195      const word = words[at]!
196      if (word.includes('`') || quoted[at]) return null
197      const orc = orcish(bare(word), at === 0, bare(words[at + 1] ?? ''))
198      if (!orc || !orc.startsWith('Krux ')) return orc
199      const before = bare(words[at - 1] ?? '').toLowerCase()
200      return before === 'ja' || before === 'krux' ? orc.slice('Krux '.length) : orc
201    }
202    const index = words.findIndex((_, at) => orcAt(at) !== null)
203    if (index < 0) continue
204    const start = Math.max(0, Math.min(index - 2, words.length - REWRITE_WORDS))
205    const clip = words.slice(start, start + REWRITE_WORDS)
206    const swapped = clip.map((word, position) => {
207      const core = bare(word)
208      const orc = orcAt(start + position)
209      if (!orc) return word
210      return word.replace(core, /^\p{Lu}/u.test(core) ? orc[0]!.toUpperCase() + orc.slice(1) : orc)
211    })
212    found.push({ from: clip.join(' '), to: swapped.join(' ') })
213    if (found.length === MAX_REWRITES) break
214  }
215  return found
216}
217
218// Które słowa stoją w „cudzysłowie” — cudzych słów nie przepisujemy.
219function inQuotes(words: string[]): boolean[] {
220  let open = false
221  return words.map(word => {
222    const opens = word.includes('„')
223    const inside = open || opens
224    if (opens) open = true
225    if (/[”"]/u.test(word)) open = false
226    return inside
227  })
228}
229
230function bare(word: string): string {
231  return word.replace(/^[^\p{L}]+|[^\p{L}]+$/gu, '')
232}
233
234// Poniżej tylu słów prozy odpowiedź nic nie mówi o głosie: „Zrobione.” to nie dryf.
235const MIN_WORDS = 25
236
237// Kalibracja pluginu: pary wzorcowe 65% krótkich zdań, dryf 32%, gładka poradnia 0%.
238const SHORT_SENTENCE_WORDS = 8
239const MIN_SHORT_RATIO = 0.45
240
241// Ostrzeżenie przed ruchem nieodwracalnym idzie pełnymi zdaniami, bo tak każe
242// kontrakt persony; za takie zdania miernik nie może karać następnej tury.
243const WARNING = /nieodwracaln|drog\p{L}*\s+odwrotu|bez\s+odwrotu|reflog|kopi\p{L}*\s+zapasow|z\s+backupu/iu
244
245// Punkt listy i nagłówek kończą zdanie, nawet bez kropki; znacznik to nie słowo.
246// Inaczej niż `context-smoke.js` pluginu, który tnie tylko po `.!?`: tam lista
247// sklejała się w jedno długie zdanie; na 194 odpowiedziach z historii flag
248// `long-sentences` było 57, po tej zmianie 36.
249const LIST_MARK = /^\s*(?:[-*+]|\d+[.)]|#{1,6})\s+/u
250
251// Werdykt jednej odpowiedzi: `null`, gdy za krótka, by sądzić, albo gdy
252// ostrzega przed ruchem nieodwracalnym; inaczej lista odstępstw z przykładami,
253// które kotwica zacytuje w następnej turze.
254export function gauge(answer: string): KruxDrift | null {
255  if (WARNING.test(answer) || isDestructive(answer)) return null
256  const text = prose(answer)
257  if (wordCount(text) < MIN_WORDS) return null
258  const issues: KruxDrift['issues'] = []
259  const second = matches(text, SECOND_PERSON)
260  if (second.length > 0) issues.push({ kind: 'second-person', examples: second.slice(0, 3) })
261  const first = [...matches(text, FIRST_PERSON).filter(word => !isNounLem(word)), ...matches(text, FIRST_PRESENT)]
262  if (first.length >= 2) issues.push({ kind: 'first-person', examples: first.slice(0, 3) })
263  if (infinitiveHits(text) === 0) issues.push({ kind: 'no-infinitive', examples: [] })
264  const lengths = text
265    .split(/(?<=[.!?])\s+|\n+/u)
266    .map(line => wordCount(line.replace(LIST_MARK, '')))
267    .filter(words => words > 1)
268  if (lengths.length >= 4) {
269    const ratio = lengths.filter(words => words <= SHORT_SENTENCE_WORDS).length / lengths.length
270    if (ratio < MIN_SHORT_RATIO) issues.push({ kind: 'long-sentences', examples: [] })
271  }
272  if (issues.length === 0) return { issues }
273  const fixes = rewrites(answer)
274  return fixes.length > 0 ? { issues, rewrites: fixes } : { issues }
275}
276
hooks/lore.ts 179 lines
1// Życie hordy z prawdziwych zdarzeń sesji. Model nie wymyśla kumpla z
2// powietrza: dostaje jeden fakt i imię, które dawno nie padło. Kumpel
3// komentuje; roboty, której nikt mu nie zlecił, mu nie przypisujemy.
4
5import type { KruxDrift, KruxEvent, KruxLore, KruxMate } from '../types'
6import { testPassed } from './board'
7import { gauge, matesNamed } from './gauge'
8import { MATES, ROSTER } from './roster'
9import { isBackgroundToolCall } from './outcome'
10import { isCommitCommand, parsePhrase, pick, workOf } from './voice'
11
12export const EMPTY_LORE: KruxLore = {
13  testRuns: 0,
14  testFails: 0,
15  builds: 0,
16  buildFails: 0,
17  commits: 0,
18  tears: 0,
19  edits: 0,
20  uiEdits: 0,
21  last: null,
22  lastMate: null,
23  quietTurns: 0,
24}
25
26// Pliki interfejsu: o nich gada Ochra.
27const UI_FILE = /\.(?:css|scss|sass|less|html?|vue|svelte|tsx|jsx)$/iu
28
29// Błąd budowania w wyjściu: `| tail` zjada kod wyjścia, a dymek mówi tylko prawdę.
30const BUILD_ERROR = /(?<![\p{L}\d])error(?:\[\w+\]|:|\s+TS\d+)|✖\s+[1-9]/iu
31
32// Co jedno wywołanie narzędzia dopisuje do kroniki. Zawał to padła komenda, która
33// coś robiła: sam `grep` bez trafień albo `git diff` z kodem 1 tylko patrzyły.
34// Łańcuch z inną robotą zachowuje błąd, nawet gdy zawiera odczyt. Przebieg testów
35// ocenia `testPassed` z wyjścia (`text`), tak jak tablica Sztolni.
36export function recordTool(lore: KruxLore, tool: string, input: Record<string, unknown>, isError: boolean, text = '', result?: unknown): KruxLore {
37  if (isBackgroundToolCall(tool, input, result)) return lore
38  const work = workOf(tool, input)
39  switch (work) {
40    case null:
41    case 'agent':
42      return lore
43    case 'edit': {
44      if (isError) return lore
45      const path = input.file_path ?? input.notebook_path
46      const ui = typeof path === 'string' && UI_FILE.test(path) ? 1 : 0
47      return { ...lore, edits: lore.edits + 1, uiEdits: lore.uiEdits + ui }
48    }
49    case 'test': {
50      const failed = !testPassed(text, isError)
51      const last: KruxEvent = failed ? 'test-fail' : 'test-pass'
52      return { ...lore, testRuns: lore.testRuns + 1, testFails: lore.testFails + (failed ? 1 : 0), last }
53    }
54    case 'build': {
55      const failed = isError || BUILD_ERROR.test(text)
56      const last: KruxEvent = failed ? 'build-fail' : 'build-pass'
57      return { ...lore, builds: lore.builds + 1, buildFails: lore.buildFails + (failed ? 1 : 0), last }
58    }
59    case 'seal':
60      if (!isError && isCommitCommand(input.command)) return { ...lore, commits: lore.commits + 1, last: 'commit' }
61      break
62    case 'tear':
63      if (!isError) return { ...lore, tears: lore.tears + 1, last: 'tear' }
64      break
65    case 'look':
66    case 'trail':
67      return lore
68  }
69  return isError ? { ...lore, last: 'zawał' } : lore
70}
71
72// Po turze: kto z hordy padł w odpowiedzi i ile tur minęło bez nikogo.
73export function recordAnswer(lore: KruxLore, mates: readonly KruxMate[]): KruxLore {
74  if (mates.length === 0) return { ...lore, quietTurns: lore.quietTurns + 1 }
75  return { ...lore, lastMate: mates[mates.length - 1]!, quietTurns: 0 }
76}
77
78// Co kilka tur, nie co turę: wstawka w każdej odpowiedzi to wytarty szablon.
79const QUIET_TURNS = 2
80
81// Fakty z sesji dla kumpli, których fach pasuje do roboty Kruxa. Liczby w formie
82// etykiety („padłe: 2”): bez odmiany, której model by po nas powtarzał.
83function factsOf(lore: KruxLore): Partial<Record<KruxMate, string>> {
84  const facts: Partial<Record<KruxMate, string>> = {}
85  if (lore.testRuns > 0) {
86    const lastRun = lore.last === 'test-pass' ? ', ostatni zielony' : lore.last === 'test-fail' ? ', ostatni czerwony' : ''
87    facts.Młot = `przebiegi testów w tej sesji: ${lore.testRuns}, padłe: ${lore.testFails}${lastRun}`
88  }
89  if (lore.commits > 0) facts.Piryt = `commity w tej sesji: ${lore.commits}`
90  if (lore.last === 'zawał') facts.Niuch = 'ostatnia komenda padła z błędem'
91  if (lore.builds > 0) facts.Grom = `przebiegi budowania, typów i lintu w tej sesji: ${lore.builds}, padłe: ${lore.buildFails}`
92  else if (lore.edits >= 10) facts.Grom = `edycje plików w tej sesji: ${lore.edits}`
93  if (lore.uiEdits > 0) facts.Ochra = `edycje plików interfejsu w tej sesji: ${lore.uiEdits}`
94  if (lore.tears > 0) facts.Lont = `rozbiórki w tej sesji (rm, git clean, reset): ${lore.tears}`
95  return facts
96}
97
98// Forma wstawki z kodu, nie z modelu: sam powtarza raz złapaną budowę
99// („Niuch dziś…”, „Młot dziś…”), a losować nie umie.
100const FORMS = [
101  'słowa kumpla w cudzysłowie',
102  'coś, co kumpel zrobił w kuźni albo w kopalni',
103  'docinek albo pytanie kumpla do Kruxa',
104  'krótka scenka: Krux i kumpel przy jednej rzeczy',
105] as const
106
107// Miejsce też z kodu: notka bez miejsca kończyła 31 z 32 odpowiedzi benchu, czyli
108// kumpel zajął slot dawnego „Morra powie…” jako stałe zamknięcie.
109const PLACES = ['w środku, przy punkcie, do którego pasuje', 'na końcu'] as const
110
111// Notka dla modelu albo nic. Neutralna polszczyzna, bo to instrukcja. O tym, czy
112// wstawka ma sens w tej turze, decyduje wołający (prośba o ruch nieodwracalny
113// notki nie dostaje), więc notka nie zostawia modelowi „jeśli pasuje”.
114export function lifeNote(lore: KruxLore, seed: number): string | null {
115  if (lore.quietTurns < QUIET_TURNS) return null
116  const facts = factsOf(lore)
117  // Kumpel z faktem ma pierwszeństwo, ale fakty rozkłada seed: inaczej przy
118  // częstych commitach gadałby w kółko Piryt.
119  const fresh = MATES.filter(name => name !== lore.lastMate)
120  const known = fresh.filter(name => facts[name] !== undefined)
121  const mate = pick(known.length > 0 ? known : fresh, seed)
122  const found = facts[mate]
123  // Fakt z sesji to robota Kruxa: kumpel ją komentuje, nie przejmuje.
124  const why = found ? ` Fakt z sesji: ${found}; to robota Kruxa, kumpel ją tylko komentuje.` : ' Coś z dnia kumpla, bez przypisywania kumplowi roboty w tym repo.'
125  const avoid = lore.lastMate ? ` Nie ${ROSTER[lore.lastMate].accusative}: o ${ROSTER[lore.lastMate].locative} była ostatnia wstawka.` : ''
126  const { locative, trait } = ROSTER[mate]
127  return `Życie hordy: jedno zdanie łamaną mową o ${locative} (${trait}); miejsce: ${pick(PLACES, seed + 1)}; forma: ${pick(FORMS, seed)}.${why}${avoid}`
128}
129
130// Wiadomość historii w kształcie `$.session.messages()`, tyle, ile czyta replay.
131export type HistoryMessage = {
132  role: 'user' | 'assistant'
133  text: string
134  toolUses: { tool_use_id?: string; tool: string; input: Record<string, unknown>; isError?: true; result?: unknown; text?: string }[]
135  toolResults?: unknown[]
136}
137
138// Historia nie zachowuje źródła promptu ani trybu persony. Rytm opiera się na
139// treści: tekst (także raport) liczy turę niezależnie od persony; frazy nie.
140// Włączenie Kruxa zaczyna rytm od nowa, puste teksty i wyniki narzędzi milczą.
141export function turnsAfter(count: number, text: string, toolResults: readonly unknown[] = []): number {
142  if (toolResults.length > 0 || !text.trim()) return count
143  const phrase = parsePhrase(text)
144  if (phrase) return phrase.mode === 'persona' && phrase.on === true ? 0 : count
145  return count + 1
146}
147
148export type Replayed = { turns: number; lore: KruxLore; drift: KruxDrift | null }
149
150// `/resume`, restart i `claude -p --resume` zaczynają z pustym `$.state`.
151// Licznik tur, kronikę i ostatni werdykt miernika odtwarzamy z historii,
152// żeby wznowiona sesja nie wracała do pełnej kotwicy i pustej hordy.
153export function replay(messages: readonly HistoryMessage[]): Replayed {
154  let turns = 0
155  let lore = EMPTY_LORE
156  let answer: string | null = null
157  let latest: string | null = null
158  const closeTurn = () => {
159    if (answer !== null) lore = recordAnswer(lore, matesNamed(answer))
160    answer = null
161  }
162  for (const message of messages) {
163    if (message.role === 'user') {
164      if ((message.toolResults?.length ?? 0) > 0 || !message.text.trim()) continue
165      closeTurn()
166      turns = turnsAfter(turns, message.text, message.toolResults)
167      continue
168    }
169    for (const use of message.toolUses) {
170      // Sam opis wywołania może pochodzić z przerwanej tury, bez zakończonej roboty.
171      if (use.text === undefined && use.result === undefined && use.isError !== true) continue
172      lore = recordTool(lore, use.tool, use.input, use.isError === true, use.text ?? '', use.result)
173    }
174    if (message.text.trim()) answer = latest = message.text
175  }
176  closeTurn()
177  return { turns, lore, drift: latest === null ? null : gauge(latest) }
178}
179
hooks/mood.ts 198 lines
1// Humory hordy i dymki nad orkami. Czyste funkcje: stan trzyma register.ts.
2// Dymek podaje tylko fakty z sesji; kumpel mówi, gdy był wysłany albo
3// komentuje zdarzenie z kroniki, nigdy z powietrza.
4
5import type { KruxActivity, KruxBubble, KruxEvent, KruxLore, KruxMate, KruxMood, KruxMoods } from '../types'
6import { MATES, ROSTER } from './roster'
7import { pick } from './voice'
8
9export const FADE_TOOLS = 8
10
11export const EVENT_HOLD_MS = 4000
12
13export const CALM: KruxMoods = {
14  faces: Object.fromEntries(['Krux', ...MATES].map(face => [face, 'calm'])) as KruxMoods['faces'],
15  fade: 0,
16}
17
18// Kto z hordy komentuje zdarzenie, gdy stoi na scenie: każde zdarzenie ma kumpla
19// z pasującym fachem. Ochra zdarzenia nie ma, mówi o własnej robocie na scenie.
20export const EVENT_SPEAKER: Record<KruxEvent, KruxMate> = {
21  'test-fail': 'Młot',
22  'test-pass': 'Młot',
23  'build-fail': 'Grom',
24  'build-pass': 'Grom',
25  commit: 'Piryt',
26  tear: 'Lont',
27  zawał: 'Niuch',
28}
29
30// Nowe zdarzenie między dwoma stanami kroniki; edycja czy odczyt to nie zdarzenie.
31export function eventOf(before: KruxLore, after: KruxLore): KruxEvent | null {
32  if (after.testRuns > before.testRuns) return after.last
33  if (after.builds > before.builds) return after.last
34  if (after.commits > before.commits) return 'commit'
35  if (after.tears > before.tears) return 'tear'
36  if (after !== before && after.last === 'zawał' && after.edits === before.edits) return 'zawał'
37  return null
38}
39
40export function moodsAfter(event: KruxEvent, lore: KruxLore): KruxMoods {
41  const faces: Record<'Krux' | KruxMate, KruxMood> = { ...CALM.faces }
42  switch (event) {
43    case 'test-fail':
44      faces.Krux = 'grumpy'
45      faces.Młot = 'grumpy'
46      break
47    case 'test-pass':
48      faces.Krux = lore.testFails > 0 ? 'proud' : 'calm'
49      faces.Młot = 'proud'
50      break
51    case 'build-fail':
52      faces.Krux = 'grumpy'
53      faces.Grom = 'grumpy'
54      break
55    case 'build-pass':
56      faces.Krux = lore.buildFails > 0 ? 'proud' : 'calm'
57      faces.Grom = 'proud'
58      break
59    case 'commit':
60      faces.Krux = 'proud'
61      break
62    case 'tear':
63      faces.Lont = 'proud'
64      break
65    case 'zawał':
66      faces.Krux = 'grumpy'
67      faces.Niuch = 'grumpy'
68      break
69  }
70  return { faces, fade: FADE_TOOLS }
71}
72
73const GRUMPY_EVENTS: ReadonlySet<KruxEvent> = new Set(['test-fail', 'build-fail', 'zawał'])
74
75// Wynik roboty wysłanego kumpla zmienia tylko jego minę: jego czerwone testy
76// marszczą jego brwi, a reszta sceny zostaje, jaka była.
77export function mateMoodAfter(moods: KruxMoods, mate: KruxMate, event: KruxEvent): KruxMoods {
78  return { faces: { ...moods.faces, [mate]: GRUMPY_EVENTS.has(event) ? 'grumpy' : 'proud' }, fade: FADE_TOOLS }
79}
80
81export function moodsTick(moods: KruxMoods): KruxMoods {
82  if (moods.fade === 0) return moods
83  return moods.fade === 1 ? CALM : { ...moods, fade: moods.fade - 1 }
84}
85
86// Pierwsze imię z hordy w opisie, potem w prompcie zadania.
87export function mateIn(...texts: string[]): KruxMate | null {
88  for (const text of texts) {
89    let best: { mate: KruxMate; at: number } | null = null
90    for (const mate of MATES) {
91      const at = text.search(ROSTER[mate].pattern)
92      if (at >= 0 && (best === null || at < best.at)) best = { mate, at }
93    }
94    if (best) return best.mate
95  }
96  return null
97}
98
99// Liczby przy bezokoliczniku albo w etykiecie: „2 na 2 padać”, „smrodów po
100// drodze: 1”. Tak nie trzeba odmiany, której „padło” i „po 1 smrodach” nie miały.
101// `failure` to `failures[0]` z bieżącego przebiegu, nigdy zgadywana nazwa.
102export function eventLine(event: KruxEvent, lore: KruxLore, seed: number, failure?: string): string {
103  switch (event) {
104    case 'test-fail': {
105      const line = pick([`Smród! ${lore.testFails} na ${lore.testRuns} padać.`, 'Czerwono. Robak gryźć.', 'Test padać. Kilof w dłoń!'], seed)
106      if (!failure) return line
107      const chars = Array.from(failure)
108      const name = chars.length > 24 ? `${chars.slice(0, 23).join('')}…` : failure
109      return `${line} Padły: ${name}${name.endsWith('…') ? '' : '.'}`
110    }
111    case 'test-pass':
112      return lore.testFails > 0
113        ? pick([`Zielono! Smrodów po drodze: ${lore.testFails}.`, 'Robak wynocha!', 'Stal trzymać. Hej!'], seed)
114        : pick(['Zielono za pierwszym!', 'Stal od razu dobra.', 'Czysto. Ani smrodu.'], seed)
115    case 'build-fail':
116      return pick([`Piec pluć! ${lore.buildFails} na ${lore.builds} padać.`, 'Krzywa stal. Do pieca!', 'Hartowanie padać. Grr.'], seed)
117    case 'build-pass':
118      return lore.buildFails > 0
119        ? pick(['Stal wreszcie prosta!', 'Piec przyjąć stal. Hej!', 'Hartowanie czyste.'], seed)
120        : pick(['Stal zahartowana.', 'Piec przyjąć stal.', 'Czysto w piecu.'], seed)
121    case 'commit':
122      return pick([`Pieczęć nr ${lore.commits}.`, 'Zapieczętować w skale.', 'Commit leżeć w sztolni.'], seed)
123    case 'tear':
124      return pick(['Gruz wynocha!', 'Lont syczeć. Czysto.', 'Rozbiórka skończona.'], seed)
125    case 'zawał':
126      return pick(['Zawał! Komenda paść.', 'Strop pękać. Ostrożnie.', 'Kamień spaść. Sprawdzić.'], seed)
127  }
128}
129
130// Krok wysłanego kumpla: opis narzędzia bez „Krux” na początku.
131export function mateStep(doing: KruxActivity): string {
132  const verb = doing.verb.replace(/^Krux /u, '')
133  return doing.target ? `${verb} ${doing.target}` : verb
134}
135
136function flavor(text: string, mood: KruxMood): string {
137  if (mood === 'grumpy') return `Grr. ${text}`
138  if (mood === 'proud') return `${text} Hej!`
139  return text
140}
141
142// Myśl bez celu: dumanie i pisanie mają swoje, reszta robi swoje.
143const BARE_THOUGHTS: Partial<Record<KruxActivity['scene'], readonly string[]>> = {
144  think: ['Hmm…', 'Ważyć to…', 'Myśl mielić…'],
145  write: ['Ciąć zbędne…', 'Runa po runie.', 'Krótko. Konkret.'],
146}
147
148function workThought(doing: KruxActivity | null, mood: KruxMood, seed: number): string | null {
149  if (doing === null) return null
150  const t = doing.target
151  const scene = doing.scene
152  // Dumanie i pisanie nie mają celu (`describePhase`), więc mówią zawsze swoje.
153  if (!t || scene === 'think' || scene === 'write') return flavor(pick(BARE_THOUGHTS[scene] ?? ['Hmm…', 'Dalej, dalej.', 'Robota iść.'], seed), mood)
154  const pool: Record<Exclude<KruxActivity['scene'], 'think' | 'write'>, readonly string[]> = {
155    torch: [`Gdzie ten ${t}?`, `Świecić za ${t}…`, `Trop… ${t}?`],
156    pick: [`Kuć w skale: ${t}`, `Kilof iść: ${t}`, `Skała twarda… ${t}`],
157    test: [`Próba: ${t}`, `Stal trzymać? ${t}`, `Kanarek śpiewać? ${t}`],
158    seal: [`Pieczęć na ${t}`, `Wosk kapać: ${t}`, `Wózek do góry: ${t}`],
159    build: [`Do pieca: ${t}`, `Hartować ${t}`, `Piec grzać: ${t}`],
160    tear: [`Lont pod ${t}`, `Gruz wynocha: ${t}`, `Rozbierać ${t}`],
161    horn: [`Horda, do mnie! ${t}`, `Róg grać: ${t}`, `Kumple biec: ${t}`],
162    hammer: [`Hmm, ${t}…`, `Klepać ${t}`, `${t} — zaraz stal`],
163    read: [`Czytać ${t}…`, `Runy w ${t}`, `Hmm, ${t} gadać…`],
164    plan: [`Szlak przez ${t}`, `Mapa: ${t}`, `Tu krzyżyk — ${t}`],
165    scout: [`Gdzie ${t}?`, `Coś szeleścić… ${t}`, `Wypatrywać ${t}`],
166    raven: [`Kruk lecieć: ${t}`, `Goniec po ${t}`, `Kruk wracać? ${t}`],
167    ask: [`Morra wiedzieć: ${t}?`, `Hmm… ${t}?`, `Pytać o ${t}`],
168  }
169  return flavor(pick(pool[scene], seed), mood)
170}
171
172// Spoczynek bez liczb: uderzenia stoją w podpisie obok.
173function restLine(mood: KruxMood, seed: number): string {
174  if (mood === 'grumpy') return pick(['Grr. Robak dalej siedzieć?', 'Grr. Smród jeszcze czuć.', 'Kilof czekać obok.'], seed)
175  if (mood === 'proud') return pick(['Dobra robota była. Hej!', 'Stal kuta. Hej!', 'Krux zadowolony.'], seed)
176  return pick(['Palenisko tlić.', 'Kowadło stygnąć.', 'Morra mówić, co kopać.'], seed)
177}
178
179export type BubbleInput = {
180  now: number
181  held: KruxBubble | null
182  working: boolean
183  activity: KruxActivity | null
184  moods: KruxMoods
185  seed: number
186}
187
188// Kolejność: trzymany wtręt po zdarzeniu, krok kumpla (także gdy Krux odpoczywa,
189// a agent w tle dalej kuje), myśl Kruxa, spoczynek.
190export function bubbleFor(input: BubbleInput): KruxBubble | null {
191  const { held } = input
192  if (held?.kind === 'event' && held.until > input.now) return held
193  if (held?.kind === 'step') return held
194  const mood = input.moods.faces.Krux
195  const text = input.working ? workThought(input.activity, mood, input.seed) : restLine(mood, input.seed)
196  return text === null ? null : { kind: 'thought', key: 'krux', speaker: 'Krux', text }
197}
198
hooks/roster.ts 32 lines
1// Horda w jednej tabeli: nowy kumpel to jeden wpis tutaj i jedno imię w `KruxMate`.
2// Typ pilnuje kompletności, a reszta modułów liczy swoje tabele z tej.
3
4import type { KruxMate } from '../types'
5
6export type MateEntry = {
7  // Fach w panelu /krux.
8  trade: string
9  // Imię w każdej odmianie, poza kodem: kto padł w odpowiedzi i kogo wysłano.
10  pattern: RegExp
11  // „zdanie o X” i „nie X: o X była ostatnia wstawka” w notce o hordzie.
12  locative: string
13  accusative: string
14  // Charakter w notce o hordzie: sam czasownik, bez rodzaju, żeby kumpel
15  // brzmiał tak samo w każdej sesji, a model nie zgadywał, kto on czy ona.
16  trait: string
17  // Znak palety, którym kumpel podmienia skórzany fartuch.
18  apron: string
19}
20
21export const ROSTER: Record<KruxMate, MateEntry> = {
22  Niuch: { trade: 'zwiad, debug, przyczyna błędu', pattern: /(?<!\p{L})Niuch(?:a|owi|em|u)?(?!\p{L})/u, locative: 'Niuchu', accusative: 'Niucha', trait: 'węszy wszędzie i mówi półsłówkami', apron: 'N' },
23  Grom: { trade: 'kuźnia: backend, API, dane', pattern: /(?<!\p{L})Grom(?:a|owi|em|ie)?(?!\p{L})/u, locative: 'Gromie', accusative: 'Groma', trait: 'mówi głośno i każdą sprawę chce wykuć od nowa', apron: 'M' },
24  Piryt: { trade: 'ocena: review, ryzyko', pattern: /(?<!\p{L})Piry(?:t|ta|towi|tem|cie)(?!\p{L})/u, locative: 'Pirycie', accusative: 'Piryta', trait: 'zrzędzi i w każdej stali widzi pęknięcie', apron: 'P' },
25  Ochra: { trade: 'frontend, UI', pattern: /(?<!\p{L})Ochr(?:a|y|ze|ę|ą|o)(?!\p{L})/u, locative: 'Ochrze', accusative: 'Ochrę', trait: 'pilnuje równych brzegów i kolorów', apron: 'O' },
26  Młot: { trade: 'testy, weryfikacja', pattern: /(?<!\p{L})Mło(?:t|ta|towi|tem|cie)(?!\p{L})/u, locative: 'Młocie', accusative: 'Młota', trait: 'liczy wszystko dwa razy i nie wierzy na słowo', apron: 'L' },
27  Lont: { trade: 'rozbiórka: martwy kod, refaktor', pattern: /(?<!\p{L})Lon(?:t|ta|towi|tem|cie)(?!\p{L})/u, locative: 'Loncie', accusative: 'Lonta', trait: 'kocha rozbiórkę, ale lont mierzy trzy razy', apron: 'T' },
28}
29
30// Kolejność wpisów to kolejność w panelu i przy szukaniu imienia.
31export const MATES = Object.keys(ROSTER) as KruxMate[]
32
hooks/board.ts 323 lines
1// Tablica Sztolni: stan roboty zamiast statystyki. Plan z narzędzi listy zadań
2// (`TodoWrite`, `TaskCreate`, `TaskUpdate`), czasy edycji i przebiegów testów. Diff
3// i drzewo zmian rysuje silnik (`/diff`), więc tablica ich nie dubluje. Czyste funkcje.
4
5import type { SessionUsage } from 'claude-code'
6
7import type { KruxBoard, KruxGit, KruxTask, KruxTestRun, KruxThreads, KruxUsage } from '../types'
8import { gitBusy, gitShort } from './git'
9import type { HistoryMessage } from './lore'
10import { isBackgroundToolCall } from './outcome'
11import { workOf } from './voice'
12
13export const EMPTY_BOARD: KruxBoard = { tasks: [], test: null }
14
15type TaskStatus = KruxTask['status']
16
17const STATUSES: ReadonlySet<string> = new Set(['pending', 'in_progress', 'completed'])
18
19function statusOf(value: unknown): TaskStatus | null {
20  return typeof value === 'string' && STATUSES.has(value) ? (value as TaskStatus) : null
21}
22
23function text(value: unknown): string {
24  return typeof value === 'string' ? value.trim() : ''
25}
26
27// Czas startu należy do jednego statusu: powtórzenie go nie resetuje zegara.
28function taskStatus(task: KruxTask, status: TaskStatus, now?: number): KruxTask {
29  if (task.status === status) return task
30  const { startedAt: _startedAt, ...rest } = task
31  return { ...rest, status, ...(status === 'in_progress' && now !== undefined ? { startedAt: now } : {}) }
32}
33
34// Co czytamy z wyniku wywołania: tekst, który dostał model, błąd i kto wołał.
35export type BoardCall = { text: string; isError: boolean; who: KruxTestRun['who'] }
36
37// Najwyżej tyle nazw padających testów; reszta czeka w transkrypcie.
38const FAILURES_LIMIT = 5
39
40// Linia padającego testu w popularnych runnerach: bun, jest, vitest, mocha, TAP,
41// reporter `node --test` (`✖`), go (`--- FAIL:`), unittest (`FAIL:`, `ERROR: test (moduł…)`),
42// pytest (`ERROR plik::test`). Gołe `ERROR` to log albo bundler, nie test.
43const FAILURE_LINE = /^\s*(?:\(fail\)|✗|✕|✖|×|--- FAIL:|FAIL(?:ED)?(?!\p{L}):?|ERROR:(?=\s+\S+\s+\()|ERROR(?=\s+\S+::)|not ok \d+(?: -)?)\s*(.*)$/u
44
45// Czas na końcu nazwy: `[3ms]`, `(5 ms)`, `(0.00s)`, `4ms`.
46const TIMING = /\s*(?:\[\d+(?:\.\d+)?\s*m?s\]|\(\d+(?:\.\d+)?\s*m?s\)|\d+(?:\.\d+)?m?s)$/u
47
48// Nazwa bez czasu i bez reszty po tabulatorze (go: pakiet, czas). Pusta albo
49// podsumowanie w nawiasie (`FAILED (failures=1)`) i nagłówek z dwukropkiem
50// (`✖ failing tests:`) to nie test.
51function failureName(line: string): string | null {
52  const raw = FAILURE_LINE.exec(line)?.[1]
53  if (raw === undefined) return null
54  const name = raw.split('\t')[0]!.replace(TIMING, '').trim()
55  return name === '' || name.startsWith('(') || name.endsWith(':') ? null : name
56}
57
58// Ostatnie trafienie: podsumowanie stoi na końcu, a wcześniejsze liczby to pliki,
59// zestawy albo nazwy testów („returns 2 failed items”). `node --test` pisze
60// liczbę po słowie: `# pass 4` w TAP, `ℹ pass 4` w domyślnym reporterze, stąd druga grupa.
61function lastCount(output: string, pattern: RegExp): number | null {
62  let found: number | null = null
63  for (const match of output.matchAll(pattern)) found = Number(match[1] ?? match[2])
64  return found
65}
66
67// Cargo pisze podsumowanie na każdy binarny test: liczby się sumują.
68function cargoCounts(output: string): { passed: number; failed: number } | null {
69  const lines = [...output.matchAll(/test result: \w+\. (\d+) passed; (\d+) failed/gu)]
70  if (lines.length === 0) return null
71  return { passed: lines.reduce((sum, line) => sum + Number(line[1]), 0), failed: lines.reduce((sum, line) => sum + Number(line[2]), 0) }
72}
73
74// Unittest rozdziela nieudane asercje i błędy testów; oba psują przebieg.
75function unittestFailures(output: string): number | null {
76  let failed: number | null = null
77  for (const summary of output.matchAll(/^FAILED\s*\(([^)]*)\)\s*$/gmu)) {
78    const counts = [...summary[1]!.matchAll(/\b(?:failures|errors)=(\d+)\b/gu)]
79    if (counts.length > 0) failed = counts.reduce((sum, count) => sum + Number(count[1]), 0)
80  }
81  return failed
82}
83
84// Przebieg testów z wyjścia komendy: liczby, gdy runner je podał, i nazwy padających.
85// Padłe testy w wyjściu wygrywają z kodem 0: `npm test | tail` kończy się kodem `tail`.
86export function testRunOf(command: string, call: BoardCall): KruxTestRun {
87  const output = call.text
88  const failures = [...new Set(output.split(/\r?\n/u).flatMap(line => failureName(line) ?? []))]
89  const cargo = cargoCounts(output)
90  const passed = cargo?.passed ?? lastCount(output, /(?<![\p{L}\d])(\d+) pass(?:ed|ing)?(?!\p{L})|^(?:#|ℹ) pass (\d+)$/gmu)
91  const failed = cargo?.failed ?? unittestFailures(output) ?? lastCount(output, /(?<![\p{L}\d])(\d+) fail(?:ed|ing|ures?)?(?!\p{L})|^(?:#|ℹ) fail (\d+)$/gmu)
92  return {
93    command,
94    ok: !call.isError && !(failed !== null && failed > 0) && failures.length === 0,
95    passed,
96    failed,
97    failures: failures.slice(0, FAILURES_LIMIT),
98    who: call.who,
99  }
100}
101
102// Czy przebieg przeszedł, z tej samej oceny co tablica: kronika i Sztolnia
103// nie mogą się różnić co do koloru testów.
104export function testPassed(output: string, isError: boolean): boolean {
105  return testRunOf('', { text: output, isError, who: 'Krux' }).ok
106}
107
108// Tablica po jednym wywołaniu narzędzia. Plan zmienia lista zadań z wątku głównego
109// (`result` to rekord narzędzia, z niego id `TaskCreate`); testy zmienia komenda
110// testów, gdy znamy jej wynik (`call`). `now` to czas zdarzenia w ms od epoki;
111// historia bez czasu go pomija. Edycja poza dokumentacją zapisuje czas także
112// przy błędzie, bez `deny`; nieznana ścieżka nie potwierdza dokumentacji.
113export function boardAfter(board: KruxBoard, tool: string, input: Record<string, unknown>, result?: unknown, call?: BoardCall, now?: number): KruxBoard {
114  if ((result as { deny?: unknown } | undefined)?.deny !== undefined) return board
115  if (['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(tool)) {
116    const path = text(input.file_path) || text(input.notebook_path)
117    if (/\.(?:md|mdx|txt|rst|adoc)$/iu.test(path)) return board
118    return now === undefined ? board : { ...board, editedAt: now }
119  }
120  if (call && workOf(tool, input) === 'test') {
121    // Test w tle odpowiada od razu, bez wyniku: ostatni prawdziwy przebieg zostaje.
122    if (isBackgroundToolCall(tool, input, result)) return board
123    return { ...board, test: { ...testRunOf(text(input.command), call), ...(now === undefined ? {} : { at: now }) } }
124  }
125  // Nieudane wywołanie listy zadań nie zmienia planu.
126  if (call?.isError) return board
127  switch (tool) {
128    case 'TodoWrite': {
129      const todos = Array.isArray(input.todos) ? (input.todos as Record<string, unknown>[]) : []
130      const previous = board.tasks.filter(task => task.id === null)
131      const tasks: KruxTask[] = todos.flatMap(todo => {
132        const status = statusOf(todo.status)
133        const subject = text(todo.content)
134        return status && subject ? [{ id: null, subject, status }] : []
135      })
136      const unused: (KruxTask | undefined)[] = [...previous]
137      const matches: (KruxTask | undefined)[] = tasks.map(() => undefined)
138      // TodoWrite nie ma id: najpierw rezerwujemy niezmienione tematy i statusy,
139      // żeby wcześniejszy duplikat ze zmianą statusu nie zabrał ich zegara.
140      for (const sameStatus of [true, false]) {
141        tasks.forEach((task, index) => {
142          if (matches[index] !== undefined) return
143          const found = unused.findIndex(old => old?.subject === task.subject && (!sameStatus || old.status === task.status))
144          if (found < 0) return
145          matches[index] = unused[found]
146          unused[found] = undefined
147        })
148      }
149      return {
150        ...board,
151        tasks: tasks.map((task, index) => {
152          let old = matches[index]
153          const atPosition = unused[index]
154          // Przemianowanie w tej samej pozycji: stary temat zniknął, nowy nie
155          // należał do poprzedniej listy. Każdy poprzednik trafia tylko raz.
156          if (old === undefined && atPosition !== undefined && !tasks.some(next => next.subject === atPosition.subject) && !previous.some(before => before.subject === task.subject)) {
157            old = atPosition
158            unused[index] = undefined
159          }
160          return { ...taskStatus(old ?? { ...task, status: 'pending' }, task.status, now), subject: task.subject }
161        }),
162      }
163    }
164    case 'TaskCreate': {
165      const subject = text(input.subject)
166      if (!subject) return board
167      const task = (result as { task?: { id?: unknown } } | undefined)?.task
168      const id = typeof task?.id === 'string' ? task.id : null
169      return { ...board, tasks: [...board.tasks, { id, subject, status: 'pending' }] }
170    }
171    case 'TaskUpdate': {
172      const id = text(input.taskId)
173      if (!board.tasks.some(task => task.id === id)) return board
174      if ((result as { success?: unknown } | undefined)?.success === false) return board
175      if (input.status === 'deleted') return { ...board, tasks: board.tasks.filter(task => task.id !== id) }
176      const status = statusOf(input.status)
177      const subject = text(input.subject)
178      return {
179        ...board,
180        tasks: board.tasks.map(task => (task.id === id ? { ...(status ? taskStatus(task, status, now) : task), ...(subject ? { subject } : {}) } : task)),
181      }
182    }
183    default:
184      return board
185  }
186}
187
188// Starsze sesje nie mają czasów: bez obu nie potwierdzamy nieświeżości.
189export function testsStale(board: KruxBoard): boolean {
190  return board.test !== null && board.test.at !== undefined && board.editedAt != null && board.editedAt > board.test.at
191}
192
193// Pełne minuty pracy, od progu 15 min; bez startu albo poza pracą — nic.
194export function stuckMinutes(task: KruxTask, now: number): number | null {
195  if (task.status !== 'in_progress' || task.startedAt === undefined) return null
196  const minutes = Math.floor((now - task.startedAt) / 60_000)
197  return minutes >= 15 ? minutes : null
198}
199
200// Warunki commita, niezależnie od tego, czy panel ma zmiany do pokazania.
201// Nieznany odczyt białych znaków nie blokuje, zgodnie z kontraktem.
202export function readiness(board: KruxBoard, git: KruxGit | null): { ready: boolean; missing: string[] } {
203  if (git === null) return { ready: false, missing: ['brak repo'] }
204  const missing: string[] = []
205  if (board.test === null) missing.push('brak testów')
206  else {
207    if (!board.test.ok) missing.push('testy padłe')
208    if (testsStale(board)) missing.push('testy nieświeże')
209  }
210  if (git.whitespace === false) missing.push('białe znaki')
211  if (git.conflicted > 0) missing.push(`konflikty: ${git.conflicted}`)
212  return { ready: missing.length === 0, missing }
213}
214
215// Jedna linia toastu po przerwie: tylko części obecne na tablicy.
216export function returnNote(board: KruxBoard, threads: KruxThreads, git: KruxGit | null): string {
217  const parts: string[] = []
218  if (board.tasks.length > 0) parts.push(`Plan ${planCount(board.tasks)}`)
219  const run = board.test
220  if (run !== null) {
221    const counts = run.ok ? (run.passed === null ? '' : String(run.passed)) : testLine(run)
222    parts.push(`testy ${run.ok ? '✓' : '✗'}${counts ? ` ${counts}` : ''}${testsStale(board) ? ' ◌ nieświeże' : ''}`)
223  }
224  if (threads.items.length > 0) parts.push(`wątki ${threads.items.length}`)
225  if (git !== null) parts.push(`git ${gitBusy(git) ? '' : '✓ '}${gitShort(git)}`)
226  return parts.join(' · ').replace(/\s+/gu, ' ').trim()
227}
228
229// Plan w `limit` wierszach: najpierw wypadają najstarsze zrobione (`earlier`),
230// potem ogon (`later`).
231export function planRows(tasks: readonly KruxTask[], limit: number): { shown: KruxTask[]; earlier: number; later: number } {
232  let extra = tasks.length - limit
233  const shown = tasks.filter(task => {
234    if (extra > 0 && task.status === 'completed') {
235      extra -= 1
236      return false
237    }
238    return true
239  })
240  const cut = shown.slice(0, limit)
241  return { shown: cut, earlier: tasks.length - shown.length, later: shown.length - cut.length }
242}
243
244// Ile planu zrobione: `zrobione/wszystkie`.
245export function planCount(tasks: readonly KruxTask[]): string {
246  return `${tasks.filter(task => task.status === 'completed').length}/${tasks.length}`
247}
248
249// Odczyt `$.session.usage()` w kształcie, który da się trzymać w `$.state`.
250export function usageOf(raw: SessionUsage): KruxUsage {
251  return {
252    percent: raw.context.percent ?? null,
253    tokens: raw.context.tokens ?? null,
254    window: raw.context.window,
255    limits: raw.rateLimits.map(limit => ({ kind: limit.kind, percentUsed: limit.percentUsed, resetsAt: limit.resetsAt ?? null })),
256    usd: raw.cost?.usd ?? null,
257  }
258}
259
260const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
261
262// Pasek procentu w ósemkach komórki; tło z `░`, żeby było widać, ile zostało.
263export function meter(percent: number, width: number): string {
264  const eighths = Math.round((Math.min(100, Math.max(0, percent)) / 100) * width * 8)
265  const full = Math.floor(eighths / 8)
266  const part = EIGHTHS[eighths % 8]!
267  return '█'.repeat(full) + part + '░'.repeat(Math.max(0, width - full - (part ? 1 : 0)))
268}
269
270// Tokeny po ludzku: 950, 136k, 1M.
271export function tokens(value: number): string {
272  // Próg po zaokrągleniu: 999 500 to już „1M”, nie „1000k”.
273  if (Math.round(value / 1000) >= 1000) return `${Math.round(value / 100_000) / 10}M`
274  if (value >= 1000) return `${Math.round(value / 1000)}k`
275  return String(value)
276}
277
278const LIMIT_NAMES: Readonly<Record<string, string>> = { five_hour: '5h', seven_day: '7 dni', spend_limit: 'budżet' }
279
280export function limitName(kind: string): string {
281  return LIMIT_NAMES[kind] ?? kind
282}
283
284// Ile do odnowienia okna limitu; minione albo nieznane — nic.
285export function resetIn(resetsAt: string | null, now: number): string {
286  const at = resetsAt === null ? Number.NaN : Date.parse(resetsAt)
287  if (!Number.isFinite(at) || at <= now) return ''
288  const minutes = Math.ceil((at - now) / 60_000)
289  if (minutes >= 48 * 60) return `reset za ${Math.round(minutes / (24 * 60))} dni`
290  const hours = Math.floor(minutes / 60)
291  const rest = minutes % 60
292  return `reset za ${hours > 0 ? `${hours} h ` : ''}${hours > 0 && rest === 0 ? '' : `${rest} min`}`.trimEnd()
293}
294
295// Po wznowieniu tablica wraca z historii: plan i ostatni przebieg testów. Historia
296// nie mówi, który ork wołał, więc przebieg idzie na Kruxa.
297export function replayBoard(messages: readonly HistoryMessage[]): KruxBoard {
298  let board = EMPTY_BOARD
299  for (const message of messages) {
300    for (const use of message.toolUses) {
301      if (use.text === undefined && use.result === undefined && use.isError !== true) continue
302      const call = use.text === undefined && use.isError !== true ? undefined : { text: use.text ?? '', isError: use.isError === true, who: 'Krux' as const }
303      board = boardAfter(board, use.tool, use.input, use.result, call)
304    }
305  }
306  return board
307}
308
309// Polska liczba mnoga: 1 padł, 2 padły, 5 padło (12–14 też „padło”).
310function plural(count: number, one: string, few: string, many: string): string {
311  if (count === 1) return one
312  const tens = count % 100
313  return count % 10 >= 2 && count % 10 <= 4 && (tens < 12 || tens > 14) ? few : many
314}
315
316// Liczby przebiegu w jednej linii; bez liczb w wyjściu — nic.
317export function testLine(run: KruxTestRun): string {
318  const parts: string[] = []
319  if (run.failed !== null && run.failed > 0) parts.push(`${run.failed} ${plural(run.failed, 'padł', 'padły', 'padło')}`)
320  if (run.passed !== null) parts.push(`${run.passed} ${plural(run.passed, 'przeszedł', 'przeszły', 'przeszło')}`)
321  return parts.join(' · ')
322}
323
hooks/git.ts 80 lines
1// Stan repo dla Sztolni: gałąź, upstream, commity do wypchnięcia i ściągnięcia,
2// pliki zmienione, nowe i w konflikcie. Komendę puszcza `register.ts`, tu tylko
3// odczyt jej wyjścia i podpisy. Niewypchnięte commity daje `rev-list`.
4
5import type { KruxGit } from '../types'
6
7export const GIT_STATUS = ['git', '--no-optional-locks', '-c', 'core.quotePath=false', 'status', '--porcelain=v2', '--branch'] as const
8export const GIT_LOG = ['git', '--no-optional-locks', '-c', 'core.quotePath=false', 'log', '--format=%H%x09%h%x09%s', '-n', '10'] as const
9export const GIT_CHECK = ['git', '--no-optional-locks', 'diff', '--check'] as const
10export const GIT_CHECK_CACHED = ['git', '--no-optional-locks', 'diff', '--cached', '--check'] as const
11
12// Osiągalność zamiast pozycji w logu: merge przeplata commity lokalne i upstream.
13export const GIT_UNPUSHED = ['git', '--no-optional-locks', '-c', 'core.quotePath=false', 'rev-list', '@{upstream}..HEAD'] as const
14
15// Plików w panelu najwyżej tyle; reszta to liczba w wierszu „zmienione”.
16const FILES_LIMIT = 20
17
18// Narzędzia, po których stan repo może być inny: powłoka (commit, push, checkout)
19// i edycje plików.
20export const GIT_TOOLS: ReadonlySet<string> = new Set(['Bash', 'Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
21
22// Ścieżka i litera stanu z wiersza porcelain v2: `1 XY … ścieżka`, `2 XY … ścieżka\tstara`,
23// `u XY … ścieżka`, `? ścieżka`. Litera: drzewo robocze przed indeksem.
24function fileOf(line: string): { code: string; path: string } | null {
25  if (line.startsWith('? ')) return { code: '?', path: line.slice(2) }
26  const fields = line.split(' ')
27  const skip = line.startsWith('1 ') ? 8 : line.startsWith('2 ') ? 9 : line.startsWith('u ') ? 10 : -1
28  if (skip < 0 || fields.length <= skip) return null
29  const xy = fields[1] ?? '..'
30  const code = line.startsWith('u ') ? 'U' : xy[1] !== '.' ? xy[1]! : xy[0]!
31  return { code, path: fields.slice(skip).join(' ').split('\t')[0]! }
32}
33
34// Wyjścia `GIT_STATUS`, `GIT_LOG` i `GIT_UNPUSHED`; brak odczytu ostatniego to null.
35export function gitOf(output: string, log = '', unpushed: string | null = null): KruxGit {
36  const git: KruxGit = { branch: '', upstream: null, ahead: 0, behind: 0, changed: 0, untracked: 0, conflicted: 0, files: [], commits: [] }
37  for (const line of output.split(/\r?\n/u)) {
38    if (line.startsWith('# branch.head ')) git.branch = line.slice('# branch.head '.length)
39    else if (line.startsWith('# branch.upstream ')) git.upstream = line.slice('# branch.upstream '.length)
40    else if (line.startsWith('# branch.ab ')) {
41      const counts = /\+(\d+) -(\d+)/u.exec(line)
42      if (counts) {
43        git.ahead = Number(counts[1])
44        git.behind = Number(counts[2])
45      }
46    } else if (line.startsWith('? ')) git.untracked += 1
47    else if (line.startsWith('u ')) git.conflicted += 1
48    else if (/^[12] /u.test(line)) git.changed += 1
49    const file = fileOf(line)
50    if (file !== null && git.files.length < FILES_LIMIT) git.files.push(file)
51  }
52  // Pełne hashe porównujemy, krótkie pokazujemy. Brak odczytu nie potwierdza wypchnięcia.
53  const local = new Set((unpushed ?? '').split(/\r?\n/u).filter(Boolean))
54  git.commits = log
55    .split(/\r?\n/u)
56    .filter(line => line.includes('\t'))
57    .map(line => {
58      const [oid = '', hash = '', ...subject] = line.split('\t')
59      return { hash, subject: subject.join('\t'), pushed: git.upstream !== null && unpushed !== null && !local.has(oid) }
60    })
61  return git
62}
63
64// Czy repo ma coś do powiedzenia: commity w drodze albo pliki poza commitem.
65export function gitBusy(git: KruxGit): boolean {
66  return git.ahead > 0 || git.behind > 0 || git.changed > 0 || git.untracked > 0 || git.conflicted > 0
67}
68
69// Krótki podpis na jedną linię: `main ↑6 ↓1 ●3 +1 ✗1`, tylko niezerowe.
70export function gitShort(git: KruxGit): string {
71  const marks = [
72    git.ahead > 0 ? `↑${git.ahead}` : '',
73    git.behind > 0 ? `↓${git.behind}` : '',
74    git.changed > 0 ? `●${git.changed}` : '',
75    git.untracked > 0 ? `+${git.untracked}` : '',
76    git.conflicted > 0 ? `✗${git.conflicted}` : '',
77  ].filter(Boolean)
78  return [git.branch, ...marks].join(' ')
79}
80
hooks/threads.ts 101 lines
1// Otwarte wątki Sztolni: czego Krux nie domknął, co ryzykowne i co czeka na
2// decyzję Morry. Model pisze je narzędziem moda; tu czyste przejścia, raport
3// dla modelu i odtworzenie z historii.
4
5import type { KruxThread, KruxThreadKind, KruxThreads } from '../types'
6import type { HistoryMessage } from './lore'
7
8export const EMPTY_THREADS: KruxThreads = { next: 1, items: [] }
9
10export const THREAD_TOOL = 'watki'
11// Pełna nazwa, pod którą model woła narzędzie moda.
12export const THREAD_TOOL_NAME = `mcp__krux-mod__${THREAD_TOOL}`
13
14// Tablica nie rośnie bez końca: najstarsze wątki wypadają pierwsze.
15export const THREADS_LIMIT = 12
16const TEXT_LIMIT = 100
17
18const KINDS: readonly KruxThreadKind[] = ['todo', 'risk', 'ask']
19
20export const THREAD_SPEC = {
21  name: THREAD_TOOL,
22  description:
23    "Krux's Sztolnia board: open threads the person must not lose between replies. Open one when you leave something undone, deferred, unverified or risky, or waiting on the person's decision (e.g. \"6 commits not pushed\", \"tsc not run: no types laid\"). Close it by id once resolved. Not for routine progress or plans: the task list covers those. Keep each text under 80 characters, in the language of the conversation. Returns the open threads with their ids.",
24  inputSchema: {
25    type: 'object',
26    properties: {
27      open: {
28        type: 'array',
29        items: {
30          type: 'object',
31          properties: {
32            text: { type: 'string' },
33            kind: { type: 'string', enum: [...KINDS], description: 'todo: left undone; risk: may break or mislead; ask: waits on the person. Default todo.' },
34          },
35          required: ['text'],
36        },
37      },
38      close: { type: 'array', items: { type: 'integer' }, description: 'Ids of threads now resolved.' },
39    },
40  },
41}
42
43export const THREAD_MARK: Record<KruxThreadKind, string> = { todo: '○', risk: '⚠', ask: '?' }
44
45export function threadsAfter(threads: KruxThreads, input: Record<string, unknown>): KruxThreads {
46  const close = new Set((Array.isArray(input.close) ? input.close : []).filter((id): id is number => Number.isInteger(id)))
47  let items = threads.items.filter(item => !close.has(item.id))
48  let next = threads.next
49  for (const raw of Array.isArray(input.open) ? input.open : []) {
50    const entry = raw as Record<string, unknown>
51    const text = typeof entry?.text === 'string' ? entry.text.replace(/\s+/gu, ' ').trim().slice(0, TEXT_LIMIT) : ''
52    if (text === '' || items.some(item => item.text === text)) continue
53    const kind = KINDS.includes(entry.kind as KruxThreadKind) ? (entry.kind as KruxThreadKind) : 'todo'
54    items = [...items, { id: next, kind, text }]
55    next += 1
56  }
57  return { next, items: items.slice(-THREADS_LIMIT) }
58}
59
60// Notatka Morry korzysta z numeracji, deduplikacji i limitów narzędzia `watki`.
61export function saveThread(threads: KruxThreads, text: string): KruxThreads {
62  if (text.trim() === '') return threads
63  return threadsAfter(threads, { open: [{ kind: 'todo', text }] })
64}
65
66// Odpowiedź narzędzia: model widzi, co zostało otwarte, i ma id do zamknięcia.
67export function threadsReport(threads: KruxThreads): string {
68  if (threads.items.length === 0) return 'No open threads.'
69  return ['Open threads:', ...threads.items.map((item: KruxThread) => `#${item.id} [${item.kind}] ${item.text}`)].join('\n')
70}
71
72// Raport niesie oryginalne id i pełny stan, także gdy początek historii wypadł.
73function reportedThreads(text: unknown): KruxThread[] | null {
74  if (text === 'No open threads.') return []
75  if (typeof text !== 'string' || !text.startsWith('Open threads:\n')) return null
76  const items: KruxThread[] = []
77  for (const line of text.slice('Open threads:\n'.length).split('\n')) {
78    const match = /^#(\d+) \[(todo|risk|ask)\] (.+)$/u.exec(line)
79    if (!match || !Number.isSafeInteger(Number(match[1])) || Number(match[1]) < 1) return null
80    items.push({ id: Number(match[1]), kind: match[2] as KruxThreadKind, text: match[3]! })
81  }
82  return items.slice(-THREADS_LIMIT)
83}
84
85// Po wznowieniu raport zachowuje id; starsze wpisy bez wyniku odtwarzają przejścia.
86export function replayThreads(messages: readonly HistoryMessage[]): KruxThreads {
87  let threads = EMPTY_THREADS
88  for (const message of messages) {
89    for (const use of message.toolUses) {
90      // Samo wejście bez wyniku to wywołanie w toku, nie ukończony fakt.
91      if (use.tool !== THREAD_TOOL_NAME || use.isError === true || (use.text === undefined && use.result === undefined)) continue
92      const items = reportedThreads(use.text ?? use.result)
93      threads = items === null ? threadsAfter(threads, use.input) : {
94        next: Math.max(threads.next, ...items.map(item => item.id + 1)),
95        items,
96      }
97    }
98  }
99  return threads
100}
101
hooks/report.ts 50 lines
1// Raport do wklejenia: wyłącznie dane z sesji, neutralna polszczyzna.
2import type { KruxBoard, KruxGit, KruxLore, KruxThreadKind, KruxThreads } from '../types'
3
4const THREAD_LABEL: Record<KruxThreadKind, string> = {
5  todo: 'do zrobienia',
6  risk: 'ryzyko',
7  ask: 'czeka na decyzję',
8}
9
10export function dayReport(git: KruxGit | null, board: KruxBoard, threads: KruxThreads, lore: KruxLore): string {
11  const sections: string[][] = []
12  const commits = git?.commits.slice(0, 10) ?? []
13  if (commits.length > 0) {
14    sections.push(['## Commity', ...commits.map(commit => `- ${commit.hash} ${commit.subject}`)])
15  }
16
17  const run = board.test
18  if (run !== null) {
19    const lines = ['## Ostatni przebieg testów', `Komenda: ${run.command}`, `Wynik: ${run.ok ? 'zaliczony' : 'nieudany'}`, `Wykonawca: ${run.who}`]
20    if (run.passed !== null) lines.push(`Zaliczone: ${run.passed}`)
21    if (run.failed !== null) lines.push(`Nieudane: ${run.failed}`)
22    if (run.failures.length > 0) lines.push('Nieudane testy:', ...run.failures.map(name => `- ${name}`))
23    sections.push(lines)
24  }
25
26  if (board.tasks.length > 0) {
27    const done = board.tasks.filter(task => task.status === 'completed').length
28    sections.push([
29      '## Plan',
30      `Zrobione: ${done}/${board.tasks.length}`,
31      ...board.tasks.map(task => `- [${task.status === 'completed' ? 'x' : ' '}] ${task.subject}${task.status === 'in_progress' ? ' (w toku)' : ''}`),
32    ])
33  }
34
35  if (threads.items.length > 0) {
36    sections.push(['## Otwarte wątki', ...threads.items.map(item => `- #${item.id} [${THREAD_LABEL[item.kind]}] ${item.text}`)])
37  }
38
39  if (lore.testRuns > 0 || lore.testFails > 0 || lore.commits > 0 || lore.tears > 0) {
40    sections.push([
41      '## Podsumowanie sesji',
42      `Przebiegi testów: ${lore.testRuns} (nieudane: ${lore.testFails})`,
43      `Commity: ${lore.commits}`,
44      `Rozbiórki: ${lore.tears}`,
45    ])
46  }
47
48  return sections.length > 0 ? sections.map(lines => lines.join('\n')).join('\n\n') : 'Brak danych do raportu dnia.'
49}
50
hooks/muster.ts 69 lines
1// Apel hordy w Sztolni: kogo Krux wysłał, z czym, ile to trwa i czym ork
2// teraz robi. Lista silnika (`$.agent.list()`) nie zna czasu, więc start, ostatnie
3// narzędzie i koniec notujemy sami. Czyste funkcje.
4
5import type { KruxMate, KruxMuster, KruxRun } from '../types'
6import { describeTool } from './voice'
7
8// Najwyżej tylu orków w pamięci; starsze wpisy wypadają.
9export const MUSTER_LIMIT = 30
10// Bez listy silnika nie pokazujemy osieroconego biegu dłużej niż pół godziny.
11const UNCONFIRMED_MS = 30 * 60 * 1000
12
13export const EMPTY_MUSTER: KruxMuster = { runs: [] }
14
15export function musterSpawn(muster: KruxMuster, agentId: string, mate: KruxMate | null, description: string, now: number): KruxMuster {
16  const run: KruxRun = { agentId, mate, description, startedAt: now, endedAt: null, last: '' }
17  const runs = [...muster.runs.filter(one => one.agentId !== agentId), run]
18  return { runs: runs.length > MUSTER_LIMIT ? runs.slice(runs.length - MUSTER_LIMIT) : runs }
19}
20
21// Narzędzie orka z apelu; obcy `agentId` (pętla zagnieżdżona, fork silnika) nic nie zmienia.
22export function musterTool(muster: KruxMuster, agentId: string, tool: string, input: Record<string, unknown>): KruxMuster {
23  if (!muster.runs.some(run => run.agentId === agentId)) return muster
24  const target = describeTool(tool, input).target
25  // Nieznane narzędzie ma za cel własną nazwę (MCP: `serwer/narzędzie`): bez powtórki.
26  const last = !target ? tool : target === tool || tool.startsWith('mcp__') ? target : `${tool} ${target}`
27  return {
28    runs: muster.runs.map(run => (run.agentId === agentId ? { ...run, last } : run)),
29  }
30}
31
32export function musterDone(muster: KruxMuster, agentId: string, now: number): KruxMuster {
33  if (!muster.runs.some(run => run.agentId === agentId && run.endedAt === null)) return muster
34  return { runs: muster.runs.map(run => (run.agentId === agentId && run.endedAt === null ? { ...run, endedAt: now } : run)) }
35}
36
37// `m:ss`, od godziny `h:mm:ss`.
38export function elapsed(ms: number): string {
39  const seconds = Math.max(0, Math.floor(ms / 1000))
40  const s = String(seconds % 60).padStart(2, '0')
41  const minutes = Math.floor(seconds / 60)
42  if (minutes < 60) return `${minutes}:${s}`
43  return `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}:${s}`
44}
45
46export type MusterRow = { agentId: string; name: string; mate: KruxMate | null; description: string; time: string; last: string }
47
48// Orkowie w biegu, od najdawniej wysłanego; skończeni schodzą z tablicy.
49// Bez listy silnika (`null`) czas biegu ogranicza heurystyka; z listą tylko ci, których
50// silnik widzi w `running` (ork bez końca poza listą skończył poza zdarzeniami).
51export function musterRows(muster: KruxMuster, now: number, running: ReadonlySet<string> | null, limit: number): MusterRow[] {
52  return muster.runs
53    .filter(run => run.endedAt === null && (running === null ? now - run.startedAt < UNCONFIRMED_MS : running.has(run.agentId)))
54    .sort((a, b) => a.startedAt - b.startedAt)
55    .slice(0, Math.max(0, limit))
56    .map(run => ({ agentId: run.agentId, name: run.mate ?? 'ork', mate: run.mate, description: withoutName(run.description, run.mate), time: elapsed(now - run.startedAt), last: run.last }))
57}
58
59// Opis bez imienia na początku („Niuch: zwiad”): imię stoi już w wierszu.
60function withoutName(description: string, mate: KruxMate | null): string {
61  if (mate === null) return description
62  const rest = description.replace(new RegExp(`^\\s*${mate}\\s*[:—–-]\\s*`, 'u'), '')
63  return rest === '' ? description : rest
64}
65
66export function anyRunning(muster: KruxMuster): boolean {
67  return muster.runs.some(run => run.endedAt === null)
68}
69