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…

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.
| Plugin Krux | Mod Krux | |
|---|---|---|
| Persona | kontekst hooka SessionStart, limit 10 000 znaków | sekcja promptu systemowego, bez tego limitu |
Po /compact | wstrzykiwana od nowa | nie znika, bo prompt systemowy zostaje |
| Kotwica głosu | co turę, pełna | peł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łosu | tylko benchmark offline | miernik 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 hordy | model wymyśla sam | fakty 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 |
| Kompakcja | streszczenie jak leci | neutralne streszczenie z faktami dosłownie, potem pełna kotwica |
| Format odpowiedzi | brak | jedna 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 |
| Tabliczki | brak | ⚒ Krux nad odpowiedzią, Morra nad promptem; sam rysunek, 0 tokenów |
| Przełączniki | frazy włącz krux itd. | frazy dalej działają, do tego /krux bez tury modelu |
| Interfejs | brak | panel kuźni, kowal nad promptem, słowa spinnera, etykiety w stopce |
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
| Komenda | Skutek | |
|---|---|---|
/krux | panel kuźni: tryby pod klawiszami 1–7, horda, palenisko | |
/krux on / /krux off | gł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 dziennik | otwarcie karty Dziennik w Sztolni | |
/krux zapisz <tekst> | zapis otwartego wątku w tablicy sesji | |
/krux raport | raport sesji do skopiowania | |
/krux status | stan 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.
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./krux kowal off pozostawia pasek tylko na czas pracy./krux. Większa kuźnia z paleniskiem, w którym płonie ogień, przełączniki trybów i horda.✓ 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.Bash („Kopać” przy kopaniu, „Węszyć” przy szukaniu).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:
| Scena | Kiedy | Czynności |
|---|---|---|
test | testy | kanarek, próba obciążeniowa, fiolki |
seal | commit i push | wosk, prasa, zwój ze sznurem |
build | budowanie | piec, hartowanie, miech |
tear | rozbiórka | dynamit, gruz, mur |
think | strumień myślenia | ważenie rudy, liczenie kamyków, rozważanie mapy |
write | strumień tekstu odpowiedzi | skrobanie 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ę.
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 ..
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.
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.
MIT. Projekt nie jest powiązany z produktami innych autorów o nazwie Krux.
hooks/register.ts 1138 lines1import { 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}
1138hooks/band.ts 102 lines1// 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}
102hooks/crew.ts 93 lines1// 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}
93hooks/gauge.ts 276 lines1// 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}
276hooks/lore.ts 179 lines1// Ż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}
179hooks/mood.ts 198 lines1// 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}
198hooks/roster.ts 32 lines1// 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[]
32hooks/board.ts 323 lines1// 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}
323hooks/git.ts 80 lines1// 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}
80hooks/threads.ts 101 lines1// 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}
101hooks/report.ts 50 lines1// 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}
50hooks/muster.ts 69 lines1// 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