An animated pixel-art pyramid construction site in a side pane: the harness conductor cracks the whip, workers carry bricks, every report adds one.

Mods for Claude Code: function-hook plugins, one per folder under mods/.
| mod | what it does |
|---|---|
| kaomoji-status | One row of dancing kaomoji above the prompt, one per harness worker. |
| pyramid-site | An animated pixel-art pyramid site in a side pane: the conductor cracks the whip, workers carry bricks. |
Type at the prompt of a terminal Claude Code session:
/plugin install kaomoji-status --marketplace geonhos/claude-code-mod
/plugin install pyramid-site --marketplace geonhos/claude-code-mod
Answer y to add the marketplace, then pick a scope (user scope is first; press Enter).
The marketplace is named claude-code-mod. To get new mods (such as pyramid-site) and later updates, refresh the marketplace, then update the plugin.
In a Claude Code session:
/plugin marketplace update claude-code-mod
/plugin install pyramid-site --marketplace geonhos/claude-code-mod
/reload-plugins
From a shell:
claude plugin marketplace update claude-code-mod
claude plugin install pyramid-site@claude-code-mod # a mod you have not installed yet
claude plugin update kaomoji-status@claude-code-mod # a mod you already have
claude plugin update pyramid-site@claude-code-mod
claude plugin update applies after a restart; inside a session /reload-plugins loads the update without one. Settings and the pyramid progress are kept.
For maintainers: every change to a mod bumps its .claude-plugin/plugin.json version (semver: fix = patch, feature = minor), otherwise claude plugin update may not pick it up.
Shows each harness (herdr) worker as a kaomoji in one row above the prompt:
analyst (。-ω-)zzZ implementer ᕕ( ᐛ )ᕗ reviewer (⊙_⊙)? 도와줘요!
done worker shows the same as idle); working: yellow, dancing; blocked: pink-red, asking for help.harness status every 2.5 s. If harness is not on Claude Code's PATH, set the absolute path in /config → kaomoji-status → harness path, then restart Claude Code (a config change reloads the mod, and its poll timer starts only at session start).A construction site in a side pane, drawn as half-block pixel art (72×18 cells):
harness ask <worker>; that worker starts carrying bricks.?; exit 4 makes them wait.Commands: /pyramid toggles the pane (it reopens next session if you left it open); /pyramid gallery lists finished wonders with dates. Only the conductor session draws (HARNESS_ROLE=worker sessions stay quiet).
Settings in /config → pyramid-site: harness path (as kaomoji-status) and pixel mode: half (default, ▀ glyphs) or space (coloured spaces) for terminals that draw block glyphs double width, e.g. iTerm2 or Terminal.app with "ambiguous characters are double-width" turned on. A change applies the next time the mod is used; no restart is needed.
The pane polls harness status only while it is open. Bricks come from the worker's report file (.harness/reports/<worker>.md) after a harness ask or harness report, never from the command's screen output. Progress is one record shared by every conductor session: two sessions running at once overwrite each other (last write wins).
claude --plugin-dir mods/kaomoji-status # hot-reloads on save
claude plugin test mods/kaomoji-status # unit + wiring tests
claude plugin validate mods/kaomoji-status # manifest, hooks, state contract
npx -y -p typescript@5.6.3 tsc -p mods/kaomoji-status
claude plugin test mods/pyramid-site && claude plugin validate mods/pyramid-site
tsc needs the engine declarations in mods/<mod>/.claude-plugin/types/ (gitignored). An interactive claude --plugin-dir mods/<mod> session writes them. You can also copy plugin-authoring/types/claude-code.d.ts from the bundled skills folder to mods/<mod>/.claude-plugin/types/claude-code/index.d.ts.
Add a new mod as mods/<name>/ and give it one entry in .claude-plugin/marketplace.json.
MIT, see LICENSE.
hooks/register.tsx 352 lines1import { atom, read, update } from 'claude-code'
2import type { Hook, Register, Timer } from 'claude-code'
3
4import { fitView, toCells } from './cells'
5import type { PixelMode, View } from './cells'
6import { interpretResult, parseHarnessInvocation, reportPathOf } from './events'
7import type { DomainEvent } from './events'
8import { formatGallery, statusSegments } from './gallery'
9import { drawScene } from './scene'
10import type { Anim } from './scene'
11import { applyCue, initialAnim, isAnimating, step } from './sim'
12import { parseStatus } from './status'
13import { PROGRESS_KEY, freshProgress, parseProgress } from './store'
14import { apply, initialWorld } from './world'
15import type { Cue } from './world'
16
17export const PANE = 'pyramid'
18export const PANE_TITLE = 'Pyramid Site'
19export const RASTER_KEY = 'scene'
20export const FRAME_MS = 83
21export const IDLE_MS = 500
22export const POLL_MS = 2500
23const RUN_TIMEOUT_MS = 5000
24
25const world = atom({ plugin: 'pyramid-site', key: 'world' } as const, initialWorld(freshProgress()))
26
27type Site = {
28 emit: (event: DomainEvent) => Promise<void>
29 setPaneWanted: (paneWanted: boolean) => Promise<void>
30 openPane: () => Promise<boolean>
31 startPoll: () => void
32 stopPoll: () => void
33 ensureLoop: () => Promise<void>
34}
35
36type Dollar = Parameters<Hook<'session.start'>>[0]
37
38type Runtime = {
39 harness: string
40 mode: PixelMode
41 site: Site | undefined
42 anim: Anim
43 view: View | undefined
44 loop: Timer | undefined
45 loopMs: number
46 pollTimer: Timer | undefined
47 lastCells: string
48 isWorker: boolean
49 booting: Promise<void> | undefined
50 isBootScheduled: boolean
51}
52
53function stopLoop(rt: Runtime): void {
54 rt.loop?.cancel()
55 rt.loop = undefined
56 rt.loopMs = 0
57}
58
59async function setup($: Dollar, rt: Runtime): Promise<void> {
60 let role: string | undefined
61 try {
62 role = await $.env.get('HARNESS_ROLE')
63 } catch {
64 role = undefined
65 }
66 if (role === 'worker') {
67 rt.isWorker = true
68 return
69 }
70
71 let isTicking = false
72 let isPolling = false
73
74 const ensureLoop = async (): Promise<void> => {
75 if (rt.view === undefined) {
76 stopLoop(rt)
77 return
78 }
79 const wanted = isAnimating(rt.anim, await read($, world)) ? FRAME_MS : IDLE_MS
80 if (rt.loop !== undefined && rt.loopMs === wanted) return
81 stopLoop(rt)
82 rt.loopMs = wanted
83 rt.loop = $.clock.every(wanted, () => {
84 void tick()
85 })
86 }
87
88 const tick = async (): Promise<void> => {
89 if (isTicking) return
90 isTicking = true
91 try {
92 const current = await read($, world)
93 rt.anim = step(rt.anim, current)
94 if (rt.view !== undefined) {
95 const cells = toCells(drawScene(current, rt.anim, rt.view.sceneRows), rt.mode, rt.view)
96 if (cells !== rt.lastCells) {
97 const shown = await $.ui.blit({ requestId: PANE, key: RASTER_KEY, cells })
98 if (shown.deny === undefined) rt.lastCells = cells
99 else rt.view = undefined
100 }
101 }
102 await ensureLoop()
103 } catch {
104 // a failed frame waits for the next tick
105 } finally {
106 isTicking = false
107 }
108 }
109
110 const emit = async (event: DomainEvent): Promise<void> => {
111 try {
112 const now = await $.clock.now()
113 const before = await read($, world)
114 const preview = apply(before, event, now)
115 if (preview.world === before && preview.cues.length === 0) return
116 let cues: Cue[] = []
117 let isProgressChanged = false
118 const after = await update($, world, current => {
119 const applied = apply(current, event, now)
120 cues = applied.cues
121 isProgressChanged = applied.world.progress !== current.progress
122 return applied.world
123 })
124 for (const cue of cues) rt.anim = applyCue(rt.anim, cue)
125 if (isProgressChanged) await $.store.set(PROGRESS_KEY, after.progress)
126 await ensureLoop()
127 } catch {
128 // the poll or the next report brings the world back in line
129 }
130 }
131
132
133 const poll = async (): Promise<void> => {
134 if (isPolling) return
135 isPolling = true
136 try {
137 const ran = await $.process.run([rt.harness, 'status'], { timeoutMs: RUN_TIMEOUT_MS })
138 if (ran.exitCode === 0) await emit({ kind: 'status', polled: parseStatus(ran.stdout) })
139 } catch {
140 // harness missing or slow: keep the last known states
141 } finally {
142 isPolling = false
143 }
144 }
145
146 // The poll runs only while the pane is open or wanted.
147 const startPoll = (): void => {
148 if (rt.pollTimer !== undefined) return
149 void poll()
150 rt.pollTimer = $.clock.every(POLL_MS, () => {
151 void poll()
152 })
153 }
154
155 const stopPoll = (): void => {
156 rt.pollTimer?.cancel()
157 rt.pollTimer = undefined
158 }
159
160 const setPaneWanted = async (paneWanted: boolean): Promise<void> => {
161 const after = await update($, world, current => ({ ...current, progress: { ...current.progress, paneWanted } }))
162 await $.store.set(PROGRESS_KEY, after.progress)
163 if (paneWanted) startPoll()
164 else stopPoll()
165 }
166
167 const openPane = async (): Promise<boolean> => {
168 try {
169 const opened = await $.ui.open({ id: PANE, title: PANE_TITLE, columns: 72 })
170 return opened.isPlaced
171 } catch {
172 return false
173 }
174 }
175
176 try {
177 const progress = parseProgress(await $.store.get(PROGRESS_KEY))
178 await update($, world, () => initialWorld(progress))
179 } catch {
180 // keep the fresh world
181 }
182 rt.site = { emit, setPaneWanted, openPane, startPoll, stopPoll, ensureLoop }
183 try {
184 await $.command.register({
185 name: 'pyramid',
186 description: 'Toggle the pyramid construction site pane; `/pyramid gallery` lists finished wonders',
187 argumentHint: '[gallery]',
188 immediate: true,
189 })
190 } catch {
191 // the pane still opens from paneWanted
192 }
193
194 try {
195 if ((await read($, world)).progress.paneWanted) {
196 startPoll()
197 await openPane()
198 }
199 } catch {
200 // the person can open it with /pyramid
201 }
202}
203
204// Every hook but ui.render waits here, so none runs against a half-built site.
205async function ensureSite($: Dollar, rt: Runtime): Promise<void> {
206 if (rt.isWorker) return
207 if (rt.booting === undefined && rt.site === undefined) {
208 rt.booting = setup($, rt).finally(() => {
209 rt.booting = undefined
210 })
211 }
212 await rt.booting
213}
214
215// ui.render must not run the setup (command.register, ui.open and a state update are not for a drawing hook), so it
216// starts the setup from a timer and draws a placeholder until the site exists.
217function scheduleBoot($: Dollar, rt: Runtime): void {
218 if (rt.isWorker || rt.booting !== undefined || rt.isBootScheduled) return
219 rt.isBootScheduled = true
220 $.clock.after(0, () => {
221 rt.isBootScheduled = false
222 void ensureSite($, rt).catch(() => undefined)
223 })
224}
225
226export const register: Register = (on, options) => {
227 const configured = typeof options.harnessPath === 'string' ? options.harnessPath.trim() : ''
228 const rt: Runtime = {
229 harness: configured === '' ? 'harness' : configured,
230 mode: options.pixelMode === 'space' ? 'space' : 'half',
231 site: undefined,
232 anim: initialAnim(),
233 view: undefined,
234 loop: undefined,
235 loopMs: 0,
236 pollTimer: undefined,
237 lastCells: '',
238 isWorker: false,
239 booting: undefined,
240 isBootScheduled: false,
241 }
242
243 on('session.start', async ($, e, next) => {
244 await ensureSite($, rt)
245 return next(e)
246 })
247
248 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
249 if (rt.isWorker) return next(e)
250 if (rt.site === undefined) {
251 scheduleBoot($, rt)
252 await read($, world) // subscribes the placeholder, so the setup's world load redraws it
253 const { Text } = $.ui.resolve(e)
254 return <Text key="pyramid-site-starting">Pyramid site starting…</Text>
255 }
256 const current = await read($, world)
257 const segments = statusSegments(current)
258 const { Box, Text } = $.ui.resolve(e)
259 const row = (
260 <Text wrap="truncate-end">
261 {segments.map(segment => (segment.color === undefined ? segment.text : <Text color={segment.color}>{segment.text}</Text>))}
262 </Text>
263 )
264 rt.site.startPoll()
265 if (e.surface !== 'terminal') {
266 rt.view = undefined
267 return (
268 <Box key="pyramid-site" flexDirection="column">
269 {row}
270 </Box>
271 )
272 }
273 const { Raster } = $.ui.resolve(e)
274 const nextView = fitView(e.props.bodyColumns, e.props.scroll.bodyRows)
275 const cells = toCells(drawScene(current, rt.anim, nextView.sceneRows), rt.mode, nextView)
276 rt.view = nextView
277 rt.lastCells = cells
278 void rt.site.ensureLoop()
279 return (
280 <Box key="pyramid-site" flexDirection="column">
281 <Raster key={RASTER_KEY} columns={nextView.columns} rows={nextView.rows} cells={cells} />
282 {row}
283 </Box>
284 )
285 })
286
287 on('tool.call', async ($, e, next) => {
288 await ensureSite($, rt)
289 if (rt.site === undefined || e.tool !== 'Bash' || e.agentId !== undefined) return next(e)
290 const call = parseHarnessInvocation(e.command)
291 if (call === undefined) return next(e)
292 if (call.verb === 'ask') await rt.site.emit({ kind: 'ask', worker: call.worker })
293 const result = await next(e)
294 let reportText: string | undefined
295 try {
296 const file = await $.fs.read(reportPathOf(call.worker))
297 reportText = typeof file === 'string' ? file : undefined
298 } catch {
299 reportText = undefined
300 }
301 const event = interpretResult(call, result, reportText)
302 if (event !== undefined) await rt.site.emit(event)
303 return result
304 })
305 on('prompt.submit', async ($, e, next) => {
306 await ensureSite($, rt)
307 const result = await next(e)
308 if (rt.site !== undefined && e.origin.kind === 'composer' && e.command === undefined && e.isMeta !== true) {
309 await rt.site.emit({ kind: 'caravan' })
310 }
311 return result
312 })
313
314 on('command.run', { command: 'pyramid' }, async ($, e) => {
315 await ensureSite($, rt)
316 const arg = e.args.trim().toLowerCase()
317 const current = await read($, world)
318 if (arg === 'gallery') return { text: formatGallery(current.progress) }
319 if (arg !== '') return { text: 'Usage: /pyramid [gallery]' }
320 if (rt.site === undefined) return { text: 'The pyramid site only runs in the conductor session.' }
321 if (current.progress.paneWanted) {
322 await rt.site.setPaneWanted(false)
323 rt.view = undefined
324 stopLoop(rt)
325 rt.site.stopPoll()
326 try {
327 await $.ui.close({ id: PANE })
328 } catch {
329 // already closed
330 }
331 return { text: 'Pyramid site closed.' }
332 }
333 await rt.site.setPaneWanted(true)
334 const isPlaced = await rt.site.openPane()
335 return { text: isPlaced ? 'Pyramid site opened.' : 'Pyramid site opens as soon as the terminal has room.' }
336 })
337 on('ui.close', { id: PANE }, async (_$, e, next) => {
338 const result = await next(e)
339 rt.view = undefined
340 stopLoop(rt)
341 rt.site?.stopPoll()
342 if (e.origin.kind === 'person' && rt.site !== undefined) {
343 try {
344 await rt.site.setPaneWanted(false)
345 } catch {
346 // the next /pyramid fixes paneWanted
347 }
348 }
349 return result
350 })
351}
352hooks/cells.ts 101 lines1import { H, W } from './sprites'
2
3export type PixelMode = 'half' | 'space'
4// `sceneRows` is the full scene height the view is cut from; `rows` is how many of its bottom rows show.
5export type View = { x0: number; y0: number; columns: number; rows: number; sceneRows: number }
6
7export const SCENE_ROWS = H / 2
8export const MAX_SCENE_ROWS = 40
9export const UPPER_HALF = 0x2580
10export const SPACE = 0x20
11export const DEFAULT_COLOR = 0x01000000
12
13const clamp = (value: number, low: number, high: number): number => Math.max(low, Math.min(high, Math.floor(value)))
14
15// The scene fills the pane body minus the status row (18 to 40 rows). A pane shorter than 18 rows crops sky from the top.
16export function fitView(bodyColumns: number, bodyRows: number): View {
17 const columns = clamp(bodyColumns, 1, W)
18 const sceneRows = clamp(bodyRows - 1, SCENE_ROWS, MAX_SCENE_ROWS)
19 const rows = clamp(bodyRows - 1, 1, sceneRows)
20 return { x0: W - columns, y0: sceneRows - rows, columns, rows, sceneRows }
21}
22
23export function toWords(pixels: Uint32Array, mode: PixelMode, view: View): Uint32Array {
24 const words = new Uint32Array(view.columns * view.rows * 3)
25 let at = 0
26 for (let row = 0; row < view.rows; row += 1) {
27 const top = (view.y0 + row) * 2
28 for (let column = 0; column < view.columns; column += 1) {
29 const x = view.x0 + column
30 if (mode === 'half') {
31 words[at] = UPPER_HALF
32 words[at + 1] = pixels[top * W + x] ?? 0
33 words[at + 2] = pixels[(top + 1) * W + x] ?? 0
34 } else {
35 const sampled = pixels[(top + 1) * W + (x - (x % 2))] ?? 0
36 words[at] = SPACE
37 words[at + 1] = DEFAULT_COLOR
38 words[at + 2] = sampled
39 }
40 at += 3
41 }
42 }
43 return words
44}
45
46export function wordsToBytes(words: Uint32Array): Uint8Array {
47 const bytes = new Uint8Array(words.length * 4)
48 words.forEach((word, i) => {
49 bytes[i * 4] = word & 0xff
50 bytes[i * 4 + 1] = (word >>> 8) & 0xff
51 bytes[i * 4 + 2] = (word >>> 16) & 0xff
52 bytes[i * 4 + 3] = (word >>> 24) & 0xff
53 })
54 return bytes
55}
56
57export function bytesToWords(bytes: Uint8Array): Uint32Array {
58 const words = new Uint32Array(Math.floor(bytes.length / 4))
59 for (let i = 0; i < words.length; i += 1) {
60 words[i] = ((bytes[i * 4] ?? 0) | ((bytes[i * 4 + 1] ?? 0) << 8) | ((bytes[i * 4 + 2] ?? 0) << 16) | ((bytes[i * 4 + 3] ?? 0) << 24)) >>> 0
61 }
62 return words
63}
64
65const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
66
67export function encodeBase64(bytes: Uint8Array): string {
68 let out = ''
69 for (let i = 0; i < bytes.length; i += 3) {
70 const a = bytes[i] ?? 0
71 const b = bytes[i + 1] ?? 0
72 const c = bytes[i + 2] ?? 0
73 const n = (a << 16) | (b << 8) | c
74 out += ALPHABET[(n >>> 18) & 63]! + ALPHABET[(n >>> 12) & 63]!
75 out += i + 1 < bytes.length ? ALPHABET[(n >>> 6) & 63]! : '='
76 out += i + 2 < bytes.length ? ALPHABET[n & 63]! : '='
77 }
78 return out
79}
80
81export function decodeBase64(text: string): Uint8Array {
82 const clean = text.replace(/=+$/, '')
83 const bytes = new Uint8Array(Math.floor((clean.length * 3) / 4))
84 let at = 0
85 for (let i = 0; i < clean.length; i += 4) {
86 const n = [0, 1, 2, 3].reduce((acc, k) => (acc << 6) | Math.max(0, ALPHABET.indexOf(clean[i + k] ?? 'A')), 0)
87 if (at < bytes.length) bytes[at++] = (n >>> 16) & 0xff
88 if (at < bytes.length) bytes[at++] = (n >>> 8) & 0xff
89 if (at < bytes.length) bytes[at++] = n & 0xff
90 }
91 return bytes
92}
93
94export function toCells(pixels: Uint32Array, mode: PixelMode, view: View): string {
95 return encodeBase64(wordsToBytes(toWords(pixels, mode, view)))
96}
97
98export function decodeCells(cells: string): Uint32Array {
99 return bytesToWords(decodeBase64(cells))
100}
101hooks/events.ts 142 lines1import type { ToolCallResult } from 'claude-code'
2
3import type { PyramidWorkerName } from '../types'
4import type { Polled } from './status'
5
6export type HarnessVerb = 'ask' | 'report'
7export type HarnessCall = { verb: HarnessVerb; worker: PyramidWorkerName }
8
9export type DomainEvent =
10 | { kind: 'ask'; worker: PyramidWorkerName }
11 | { kind: 'report'; worker: PyramidWorkerName; text: string }
12 | { kind: 'blocked'; worker: PyramidWorkerName }
13 | { kind: 'waiting'; worker: PyramidWorkerName }
14 | { kind: 'idle'; worker: PyramidWorkerName }
15 | { kind: 'caravan' }
16 | { kind: 'status'; polled: Polled }
17
18const HARNESS_WORD = /^(?:\S*\/)?harness$/
19const WORKERS = ['analyst', 'implementer', 'reviewer']
20const EXIT = /^Exit code (\d+)/
21
22export type HarnessInvocation = HarnessCall & { isWhole: boolean }
23
24// Splits a command line at unquoted `;`, `&&`, `||`, `|`, `&`, `(`, `)` and newlines. Quoted text never splits, so a
25// mention of harness inside a string stays inside its segment. `2>&1` and `&>` are redirections, not separators.
26function segmentsOf(command: string): { segments: string[]; isBackground: boolean } {
27 const segments: string[] = []
28 let current = ''
29 let lastSeparator: string | undefined
30 let quote: '"' | "'" | undefined
31 for (let i = 0; i < command.length; i += 1) {
32 const ch = command[i]!
33 if (quote !== undefined) {
34 current += ch
35 if (ch === '\\' && quote === '"') {
36 i += 1
37 current += command[i] ?? ''
38 } else if (ch === quote) quote = undefined
39 continue
40 }
41 if (ch === '\\') {
42 current += ch + (command[i + 1] ?? '')
43 i += 1
44 continue
45 }
46 if (ch === '"' || ch === "'") {
47 quote = ch
48 current += ch
49 continue
50 }
51 const isRedirect = ch === '&' && (command[i - 1] === '>' || command[i + 1] === '>')
52 if (!isRedirect && (ch === ';' || ch === '&' || ch === '|' || ch === '(' || ch === ')' || ch === '\n')) {
53 segments.push(current)
54 current = ''
55 lastSeparator = ch === '&' && command[i - 1] !== '&' && command[i + 1] !== '&' ? '&' : ch
56 continue
57 }
58 if (!/\s/.test(ch)) lastSeparator = undefined
59 current += ch
60 }
61 segments.push(current)
62 return { segments: segments.map(segment => segment.trim()).filter(segment => segment !== ''), isBackground: lastSeparator === '&' }
63}
64
65const ENV_WORD = /^[A-Za-z_][A-Za-z0-9_]*=/
66const DURATION = /^\d+(?:\.\d+)?[smhd]?$/
67
68// Drops what runs before the program: `NAME=value` words, `time`, and `timeout <duration>`.
69function commandWords(segment: string): string[] {
70 const words = segment.split(/\s+/)
71 let at = 0
72 for (;;) {
73 const word = words[at]
74 if (word !== undefined && ENV_WORD.test(word)) at += 1
75 else if (word === 'time') at += 1
76 else if (word === 'timeout' && DURATION.test(words[at + 1] ?? '')) at += 2
77 else break
78 }
79 return words.slice(at)
80}
81
82export function parseHarnessInvocation(command: string): HarnessInvocation | undefined {
83 const { segments, isBackground } = segmentsOf(command)
84 for (const segment of segments) {
85 const [program, verb, worker] = commandWords(segment)
86 if (program === undefined || verb === undefined || worker === undefined) continue
87 if (!HARNESS_WORD.test(program) || (verb !== 'ask' && verb !== 'report') || !WORKERS.includes(worker)) continue
88 return { verb, worker: worker as PyramidWorkerName, isWhole: segments.length === 1 && !isBackground }
89 }
90 return undefined
91}
92
93export function parseHarnessCall(command: string): HarnessCall | undefined {
94 const found = parseHarnessInvocation(command)
95 return found === undefined ? undefined : { verb: found.verb, worker: found.worker }
96}
97
98export function exitCodeOf(result: ToolCallResult): number | undefined {
99 if (result.deny !== undefined) return undefined
100 if (result.isError === true) {
101 const match = EXIT.exec(result.text ?? '')
102 return match === null ? undefined : Number(match[1])
103 }
104 return 0
105}
106
107const recordOf = (result: ToolCallResult): Record<string, unknown> | undefined =>
108 typeof result.result === 'object' && result.result !== null ? (result.result as Record<string, unknown>) : undefined
109
110export function isBackgrounded(result: ToolCallResult): boolean {
111 return typeof recordOf(result)?.backgroundTaskId === 'string'
112}
113
114const stdoutOf = (result: ToolCallResult): string => {
115 const stdout = recordOf(result)?.stdout
116 return typeof stdout === 'string' ? stdout : ''
117}
118
119export function reportPathOf(worker: PyramidWorkerName): string {
120 return `.harness/reports/${worker}.md`
121}
122
123// `reportText` is the worker's report file, read after the call. stdout and the exit code belong to the whole command
124// line, so they only count when the harness call is the entire command.
125export function interpretResult(call: HarnessInvocation, result: ToolCallResult, reportText: string | undefined): DomainEvent | undefined {
126 const { verb, worker, isWhole } = call
127 if (result.deny !== undefined) return verb === 'ask' ? { kind: 'idle', worker } : undefined
128 if (isBackgrounded(result)) return undefined
129 const code = exitCodeOf(result)
130 if (isWhole && verb === 'ask' && code === 2) return { kind: 'blocked', worker }
131 if (isWhole && verb === 'ask' && code === 4) return { kind: 'waiting', worker }
132 if (reportText !== undefined && reportText.trim() !== '') return { kind: 'report', worker, text: reportText }
133 if (!isWhole || code === undefined) return undefined
134 // The hook's cwd may differ from the shell's after a `cd`, so a missing file falls back to what harness printed.
135 if (code === 0) {
136 const printed = stdoutOf(result)
137 if (printed.trim() !== '') return { kind: 'report', worker, text: printed }
138 }
139 if (verb === 'report') return code === 3 ? { kind: 'idle', worker } : undefined
140 return { kind: 'idle', worker }
141}
142hooks/gallery.ts 35 lines1import type { PyramidProgress, PyramidWorkerState, PyramidWorld } from '../types'
2import { PALETTE, WORKER_NAMES, WORKER_SHIRT, toHexColor, wonderAt } from './sprites'
3
4export type Segment = { text: string; color?: string }
5
6export function progressLine(progress: PyramidProgress): string {
7 const wonder = wonderAt(progress.wonderIndex)
8 return `🧱 ${progress.bricks}/${wonder.slots.length} ${wonder.name}`
9}
10
11export function formatGallery(progress: PyramidProgress): string {
12 const lines = [progressLine(progress)]
13 if (progress.completed.length === 0) lines.push('No wonders completed yet.')
14 else lines.push('Completed wonders:', ...progress.completed.map(done => `- ${done.wonder} — ${done.date}`))
15 return lines.join('\n')
16}
17
18const STATE_TEXT: Readonly<Record<PyramidWorkerState, Segment>> = {
19 idle: { text: ' 쉼' },
20 working: { text: ' 일함' },
21 blocked: { text: ' ?막힘', color: toHexColor(PALETTE.X!) },
22 waiting: { text: ' 대기' },
23}
24
25// One row that fits the 72-cell pane: crown, the workers with a short state each, and the brick count.
26export function statusSegments(world: PyramidWorld): Segment[] {
27 const segments: Segment[] = [{ text: '👑' }]
28 for (const name of WORKER_NAMES) {
29 segments.push({ text: ' ' }, { text: name, color: toHexColor(WORKER_SHIRT[name].C!) }, STATE_TEXT[world.workers[name]])
30 }
31 const wonder = wonderAt(world.progress.wonderIndex)
32 segments.push({ text: ` 🧱${world.progress.bricks}/${wonder.slots.length}` })
33 return segments
34}
35hooks/scene.ts 236 lines1import type { PyramidWorkerName, PyramidWorld } from '../types'
2import {
3 BOSS, BOSS_X, BOSS_Y, BRICK_H, BRICK_W, CAMEL, CAMEL_H, CLOUD_START, CLOUDS, DROPPED_BRICK, GROUND, H, HAND, LEGS_A, LEGS_B,
4 PALETTE, QMARK, SCROLL, SIT, SPARK, UP_CARRY, UP_EMPTY, W, WHIP, WORKER_H, WORKER_HOME, WORKER_NAMES, WORKER_SHIRT,
5 ZED_LARGE, ZED_SMALL, wonderAt,
6} from './sprites'
7import type { Palette, Sprite } from './sprites'
8
9export type WorkerAnim = { x: number; dir: 1 | -1; carry: boolean; step: number }
10export type CaravanPhase = 'none' | 'in' | 'hand' | 'out'
11export type Caravan = { phase: CaravanPhase; x: number; hold: number; queued: boolean }
12export type Anim = {
13 tick: number
14 whip: number
15 flash: number
16 flashSlot: number
17 gold: number
18 done: { wonderIndex: number; ticks: number } | undefined
19 workers: Record<PyramidWorkerName, WorkerAnim>
20 caravan: Caravan
21 clouds: number[]
22}
23
24export const SKY_TOP = 0x5fa8e0
25export const SKY_BOTTOM = 0xc4e6f3
26export const CRACK = 0x3a1608
27const BRICK_FACE = 0xc9944f
28const BRICK_TOP = 0xdcae68
29const BRICK_MORTAR = 0x8f6232
30const CRACKED_FACE = 0xd8482e
31const CRACKED_MORTAR = 0x80301f
32export const SCROLL_AT = { x: BOSS_X + 2, y: BOSS_Y - 3 } as const
33
34export function mix(a: number, b: number, t: number): number {
35 let out = 0
36 for (const shift of [16, 8, 0]) {
37 const from = (a >>> shift) & 0xff
38 const to = (b >>> shift) & 0xff
39 out |= Math.round(from + (to - from) * t) << shift
40 }
41 return out >>> 0
42}
43
44export const skyAt = (y: number, ground = GROUND): number => mix(SKY_TOP, SKY_BOTTOM, Math.min(1, y / (ground - 1)))
45
46// The sprites and wonders are laid out for the 72×36 base scene. A taller scene adds sky above them (dy rows) and a
47// deeper ground band below, so everything keeps standing on the ground line that sits `H - GROUND` rows plus the
48// extra ground from the bottom. `put` shifts base coordinates by dy.
49type Frame = { dy: number; h: number; ground: number }
50let frame: Frame = { dy: 0, h: H, ground: GROUND }
51
52export function frameFor(rows: number): Frame {
53 const extra = Math.max(0, Math.round(rows) * 2 - H)
54 const deeper = Math.floor(extra / 3)
55 const dy = extra - deeper
56 return { dy, h: H + extra, ground: GROUND + dy }
57}
58const skyY = (y: number): number => skyAt(y + frame.dy, frame.ground)
59
60const hash = (x: number, y: number): number => (Math.imul(x + 1, 73856093) ^ Math.imul(y + 7, 19349663)) >>> 0
61
62function put(pixels: Uint32Array, x: number, y: number, color: number): void {
63 const px = Math.floor(x)
64 const py = Math.floor(y) + frame.dy
65 if (px >= 0 && px < W && py >= 0 && py < frame.h) pixels[py * W + px] = color
66}
67
68function sprite(pixels: Uint32Array, rows: Sprite, x: number, y: number, palette: Palette, flip = false): void {
69 const width = rows[0]?.length ?? 0
70 rows.forEach((row, r) => {
71 for (let i = 0; i < width; i += 1) {
72 const glyph = row[flip ? width - 1 - i : i] ?? '.'
73 if (glyph === '.') continue
74 const color = palette[glyph]
75 if (color !== undefined) put(pixels, x + i, y + r, color)
76 }
77 })
78}
79
80const sandAt = (x: number, y: number, ground: number): number => {
81 const h = hash(x, y)
82 if (y === ground) return 0xe3c486
83 if (y < ground + 6) return h % 7 === 0 ? 0xc4a161 : h % 11 === 0 ? 0xe6cc92 : 0xd6b574
84 // deeper strata: darker with depth, in bands, with a few buried stones
85 const depth = y - ground - 6
86 const band = Math.floor(depth / 4) % 2 === 0 ? 0 : 0.08
87 const base = mix(0xd6b574, 0x8a6a3c, Math.min(1, depth / 24) * 0.7 + band)
88 return h % 9 === 0 ? mix(base, 0x5a4326, 0.3) : h % 13 === 0 ? mix(base, 0xf0dca0, 0.2) : base
89}
90
91function drawBuriedStones(pixels: Uint32Array, ground: number, h: number): void {
92 for (let y = ground + 8; y < h; y += 1) {
93 for (let x = 0; x < W - 2; x += 1) {
94 const key = hash(x * 3, y * 5)
95 if (key % 61 !== 0) continue
96 const color = key % 2 === 0 ? 0x9a948a : 0x7d776d
97 pixels[y * W + x] = color
98 pixels[y * W + x + 1] = color
99 if (y + 1 < h) pixels[(y + 1) * W + x] = 0x6a645a
100 }
101 }
102}
103
104function drawBackdrop(pixels: Uint32Array, anim: Anim): void {
105 const { h, ground, dy } = frame
106 for (let y = 0; y < h; y += 1) {
107 for (let x = 0; x < W; x += 1) pixels[y * W + x] = y < ground ? skyAt(y, ground) : sandAt(x, y, ground)
108 }
109 drawBuriedStones(pixels, ground, h)
110 // sun and clouds hug the top of the pane whatever its height
111 frame = { ...frame, dy: 0 }
112 for (let y = 2; y < 7; y += 1) {
113 for (let x = 27; x < 32; x += 1) {
114 const edge = y === 2 || y === 6 || x === 27 || x === 31
115 put(pixels, x, y, edge ? 0xf6d54a : 0xfff1a8)
116 }
117 }
118 anim.clouds.forEach((cx, i) => {
119 const start = CLOUD_START[i]
120 if (start !== undefined) sprite(pixels, CLOUDS[start.p]!, Math.round(cx), start.y, { W: 0xffffff })
121 })
122 // a taller sky gets more clouds, drifting with the same animation
123 const spare = Math.min(3, Math.floor(dy / 7))
124 for (let i = 0; i < spare; i += 1) {
125 const cx = anim.clouds[i % Math.max(1, anim.clouds.length)] ?? 0
126 const x = (((Math.round(cx) + 37 + i * 23) % 90) + 90) % 90 - 12
127 sprite(pixels, CLOUDS[i % 2]!, x, 11 + i * 7, { W: 0xffffff })
128 }
129 frame = { ...frame, dy }
130 for (let x = 0; x < W; x += 1) {
131 const dune = Math.max(1, Math.round(2.2 + 1.3 * Math.sin(x * 0.17) + 0.9 * Math.sin(x * 0.06 + 2)))
132 for (let y = GROUND - dune; y < GROUND; y += 1) put(pixels, x, y, y === GROUND - dune ? 0xefd9a6 : 0xe3c88f)
133 }
134}
135
136function drawWonder(pixels: Uint32Array, world: PyramidWorld, anim: Anim): void {
137 const index = anim.done?.wonderIndex ?? world.progress.wonderIndex
138 const wonder = wonderAt(index)
139 const bricks = anim.done !== undefined ? wonder.slots.length : world.progress.bricks
140 const cracked = anim.done !== undefined ? [] : world.progress.cracked
141 const last = wonder.slots.length - 1
142 wonder.slots.forEach((slot, i) => {
143 for (let dy = 0; dy < BRICK_H; dy += 1) {
144 for (let dx = 0; dx < BRICK_W; dx += 1) {
145 let color: number
146 if (i >= bricks) {
147 // a soft sand-tinted silhouette so the whole wonder shape can be read before it is built
148 color = mix(skyY(slot.y + dy), 0xd6b574, 0.24)
149 } else if (anim.done !== undefined && i === last) {
150 color = dy === 0 && dx < 3 ? 0xffe27a : 0xe9b949
151 } else if (anim.gold > 0) {
152 color = dy === 0 ? 0xffe27a : 0xe9b949
153 } else if (anim.flash > 0 && i === anim.flashSlot) {
154 color = anim.flash % 2 === 1 ? 0xfff6d6 : 0xf2dfa8
155 } else {
156 const isMortar = dx === BRICK_W - 1
157 const isCracked = cracked.includes(i)
158 color = isCracked ? (isMortar ? CRACKED_MORTAR : CRACKED_FACE) : isMortar ? BRICK_MORTAR : dy === 0 ? BRICK_TOP : BRICK_FACE
159 }
160 put(pixels, slot.x + dx, slot.y + dy, color)
161 }
162 }
163 if (i < bricks && cracked.includes(i) && anim.done === undefined) {
164 // a dark zig-zag across the whole brick, whatever flash is running
165 for (const [dx, dy] of [[0, 1], [1, 0], [2, 1], [3, 0]] as const) put(pixels, slot.x + dx, slot.y + dy, CRACK)
166 }
167 })
168}
169
170function drawOverseer(pixels: Uint32Array, anim: Anim): void {
171 sprite(pixels, BOSS, BOSS_X, BOSS_Y, PALETTE)
172 const phase = anim.whip > 6 ? 'raise' : anim.whip > 2 ? 'crack' : 'rest'
173 for (const [dx, dy] of WHIP[phase]) put(pixels, HAND.x + dx, HAND.y + dy, PALETTE.N!)
174 if (phase === 'crack' && anim.whip % 2 === 1) {
175 for (const [dx, dy, color] of SPARK) put(pixels, HAND.x + dx, HAND.y + dy, color)
176 }
177}
178
179function drawCaravan(pixels: Uint32Array, anim: Anim): void {
180 const { phase, x } = anim.caravan
181 if (phase === 'none') return
182 const top = GROUND - CAMEL_H
183 sprite(pixels, CAMEL, x, top, PALETTE, phase === 'out')
184 if (phase === 'in') sprite(pixels, SCROLL, x + 6, top, PALETTE)
185 if (phase === 'hand') sprite(pixels, SCROLL, SCROLL_AT.x, SCROLL_AT.y, PALETTE)
186}
187
188const ZED_CYCLE = 48
189
190// Two small Zs rise a few pixels from just above the sleeper's hat, half a cycle apart, fading toward the sky colour.
191function drawZeds(pixels: Uint32Array, x: number, top: number, tick: number): void {
192 const hat = top + 3
193 const zeds = [
194 { rows: ZED_SMALL, age: tick % ZED_CYCLE, dx: 4 },
195 { rows: ZED_LARGE, age: (tick + ZED_CYCLE / 2) % ZED_CYCLE, dx: 7 },
196 ] as const
197 for (const zed of zeds) {
198 const y = hat - 2 - zed.rows.length - Math.floor(zed.age / 12)
199 const color = mix(0xffffff, skyY(y), (zed.age / ZED_CYCLE) * 0.8)
200 sprite(pixels, zed.rows, x + zed.dx + Math.floor(zed.age / 24), y, { W: color })
201 }
202}
203
204function drawWorkers(pixels: Uint32Array, world: PyramidWorld, anim: Anim): void {
205 const top = GROUND - WORKER_H
206 for (const name of WORKER_NAMES) {
207 const state = world.workers[name]
208 const walker = anim.workers[name]
209 const palette: Palette = { ...PALETTE, ...WORKER_SHIRT[name] }
210 if (state === 'idle' && walker.x <= WORKER_HOME[name]) {
211 sprite(pixels, SIT, walker.x, top, palette)
212 drawZeds(pixels, walker.x, top, anim.tick)
213 continue
214 }
215 const isStill = state === 'blocked' || state === 'waiting'
216 const upper = walker.carry && !isStill ? UP_CARRY : UP_EMPTY
217 const legs = isStill ? LEGS_B : walker.step % 2 === 1 ? LEGS_A : LEGS_B
218 sprite(pixels, upper.concat(legs), walker.x, top, palette, walker.dir < 0)
219 if (state === 'blocked') {
220 sprite(pixels, DROPPED_BRICK, walker.x + 7, GROUND - 2, PALETTE)
221 if (Math.floor(anim.tick / 5) % 2 === 1) sprite(pixels, QMARK, walker.x + 2, top - 6, PALETTE)
222 }
223 }
224}
225
226export function drawScene(world: PyramidWorld, anim: Anim, rows = H / 2): Uint32Array {
227 frame = frameFor(rows)
228 const pixels = new Uint32Array(W * frame.h)
229 drawBackdrop(pixels, anim)
230 drawWonder(pixels, world, anim)
231 drawOverseer(pixels, anim)
232 drawCaravan(pixels, anim)
233 drawWorkers(pixels, world, anim)
234 return pixels
235}
236hooks/sim.ts 113 lines1import type { PyramidWorkerName, PyramidWorld } from '../types'
2import type { Anim, WorkerAnim } from './scene'
3import { BOSS_X, CLOUD_START, W, WORKER_HOME, WORKER_NAMES, walkTarget, wonderAt } from './sprites'
4import type { Cue } from './world'
5
6export const WHIP_TICKS = 10
7export const FLASH_TICKS = 10
8export const GOLD_TICKS = 12
9export const DONE_TICKS = 36
10export const HAND_TICKS = 12
11export const CARAVAN_STOP = BOSS_X + 10
12export const CLOUD_SPEED = 0.05
13
14export function initialAnim(): Anim {
15 const home = (name: PyramidWorkerName): WorkerAnim => ({ x: WORKER_HOME[name], dir: 1, carry: true, step: 0 })
16 return {
17 tick: 0,
18 whip: 0,
19 flash: 0,
20 flashSlot: -1,
21 gold: 0,
22 done: undefined,
23 workers: { analyst: home('analyst'), implementer: home('implementer'), reviewer: home('reviewer') },
24 caravan: { phase: 'none', x: W, hold: 0, queued: false },
25 clouds: CLOUD_START.map(cloud => cloud.x),
26 }
27}
28
29export function applyCue(anim: Anim, cue: Cue): Anim {
30 switch (cue.kind) {
31 case 'whip':
32 return { ...anim, whip: WHIP_TICKS }
33 case 'flash':
34 return { ...anim, flash: FLASH_TICKS, flashSlot: cue.slot }
35 case 'gold':
36 return { ...anim, gold: GOLD_TICKS }
37 case 'complete':
38 return { ...anim, done: { wonderIndex: cue.wonderIndex, ticks: DONE_TICKS } }
39 case 'caravan':
40 return anim.caravan.phase === 'none'
41 ? { ...anim, caravan: { phase: 'in', x: W, hold: 0, queued: false } }
42 : { ...anim, caravan: { ...anim.caravan, queued: true } }
43 }
44}
45
46function walk(walker: WorkerAnim, state: PyramidWorld['workers'][PyramidWorkerName], home: number, target: number): WorkerAnim {
47 if (state === 'working') {
48 if (walker.dir > 0) {
49 return walker.x < target
50 ? { ...walker, x: walker.x + 1, step: walker.step + 1 }
51 : { ...walker, dir: -1, carry: false, step: walker.step + 1 }
52 }
53 return walker.x > home
54 ? { ...walker, x: walker.x - 1, step: walker.step + 1 }
55 : { ...walker, dir: 1, carry: true, step: walker.step + 1 }
56 }
57 if (state === 'idle') {
58 return walker.x > home
59 ? { x: walker.x - 1, dir: -1, carry: false, step: walker.step + 1 }
60 : { x: home, dir: 1, carry: true, step: walker.step }
61 }
62 return walker
63}
64
65function moveCaravan(anim: Anim, isStride: boolean): Anim['caravan'] {
66 const caravan = anim.caravan
67 switch (caravan.phase) {
68 case 'none':
69 return caravan
70 case 'in':
71 if (!isStride) return caravan
72 return caravan.x - 1 <= CARAVAN_STOP
73 ? { ...caravan, phase: 'hand', x: CARAVAN_STOP, hold: HAND_TICKS }
74 : { ...caravan, x: caravan.x - 1 }
75 case 'hand':
76 return caravan.hold <= 1 ? { ...caravan, phase: 'out', hold: 0 } : { ...caravan, hold: caravan.hold - 1 }
77 case 'out':
78 if (!isStride) return caravan
79 if (caravan.x + 1 < W) return { ...caravan, x: caravan.x + 1 }
80 return caravan.queued ? { phase: 'in', x: W, hold: 0, queued: false } : { phase: 'none', x: W, hold: 0, queued: false }
81 }
82}
83
84export function step(anim: Anim, world: PyramidWorld): Anim {
85 const tick = anim.tick + 1
86 const isStride = tick % 2 === 0
87 const target = walkTarget(wonderAt(world.progress.wonderIndex))
88 const workers = { ...anim.workers }
89 if (isStride) {
90 for (const name of WORKER_NAMES) workers[name] = walk(anim.workers[name], world.workers[name], WORKER_HOME[name], target)
91 }
92 return {
93 tick,
94 whip: Math.max(0, anim.whip - 1),
95 flash: Math.max(0, anim.flash - 1),
96 flashSlot: anim.flash > 1 ? anim.flashSlot : -1,
97 gold: Math.max(0, anim.gold - 1),
98 done: anim.done === undefined || anim.done.ticks <= 1 ? undefined : { ...anim.done, ticks: anim.done.ticks - 1 },
99 workers,
100 caravan: moveCaravan(anim, isStride),
101 clouds: anim.clouds.map(x => (x + CLOUD_SPEED > W + 2 ? -12 : x + CLOUD_SPEED)),
102 }
103}
104
105export function isAnimating(anim: Anim, world: PyramidWorld): boolean {
106 if (anim.whip > 0 || anim.flash > 0 || anim.gold > 0 || anim.done !== undefined) return true
107 if (anim.caravan.phase !== 'none') return true
108 return WORKER_NAMES.some(name => {
109 const state = world.workers[name]
110 return state === 'working' || (state === 'idle' && anim.workers[name].x > WORKER_HOME[name])
111 })
112}
113hooks/status.ts 27 lines1import type { PyramidWorkerName } from '../types'
2import { WORKER_NAMES } from './sprites'
3
4export type PolledState = 'idle' | 'working' | 'blocked' | 'absent'
5export type Polled = Partial<Record<PyramidWorkerName, PolledState>>
6
7const isWorker = (name: string): name is PyramidWorkerName => (WORKER_NAMES as readonly string[]).includes(name)
8
9const STATES: Readonly<Record<string, PolledState>> = {
10 '-': 'absent',
11 idle: 'idle',
12 done: 'idle',
13 working: 'working',
14 blocked: 'blocked',
15}
16
17export function parseStatus(stdout: string): Polled {
18 const polled: Polled = {}
19 for (const line of stdout.split('\n')) {
20 const [name, state] = line.trim().split(/\s+/)
21 if (name === undefined || state === undefined || !isWorker(name)) continue
22 const mapped = STATES[state]
23 if (mapped !== undefined) polled[name] = mapped
24 }
25 return polled
26}
27hooks/store.ts 36 lines1import type { PyramidCompleted, PyramidProgress } from '../types'
2import { WONDERS } from './sprites'
3import { SEEN_LIMIT } from './world'
4
5export const PROGRESS_KEY = 'progress'
6
7export function freshProgress(): PyramidProgress {
8 return { v: 1, wonderIndex: 0, bricks: 0, cracked: [], completed: [], seenReports: [], paneWanted: false }
9}
10
11const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)
12const isIndex = (value: unknown, below: number): value is number => Number.isInteger(value) && (value as number) >= 0 && (value as number) < below
13const isCompleted = (value: unknown): value is PyramidCompleted =>
14 isRecord(value) && typeof value.wonder === 'string' && typeof value.date === 'string'
15
16export function parseProgress(raw: unknown): PyramidProgress {
17 if (!isRecord(raw) || raw.v !== 1) return freshProgress()
18 const { wonderIndex, bricks, cracked, completed, seenReports, paneWanted } = raw
19 if (!isIndex(wonderIndex, WONDERS.length)) return freshProgress()
20 const total = WONDERS[wonderIndex]!.slots.length
21 if (!isIndex(bricks, total)) return freshProgress()
22 if (!Array.isArray(cracked) || !cracked.every(i => isIndex(i, bricks)) || new Set(cracked).size !== cracked.length) return freshProgress()
23 if (!Array.isArray(completed) || !completed.every(isCompleted)) return freshProgress()
24 if (!Array.isArray(seenReports) || !seenReports.every(id => typeof id === 'string')) return freshProgress()
25 if (typeof paneWanted !== 'boolean') return freshProgress()
26 return {
27 v: 1,
28 wonderIndex,
29 bricks,
30 cracked: [...(cracked as number[])].sort((a, b) => a - b),
31 completed: completed.map(c => ({ wonder: c.wonder, date: c.date })),
32 seenReports: (seenReports as string[]).slice(-SEEN_LIMIT),
33 paneWanted,
34 }
35}
36hooks/world.ts 106 lines1import type { PyramidProgress, PyramidWorkerName, PyramidWorkerState, PyramidWorld } from '../types'
2import type { DomainEvent } from './events'
3import { countFindings, reportId, reportStatus } from './reports'
4import { WONDERS, WORKER_NAMES, wonderAt } from './sprites'
5
6export type Cue =
7 | { kind: 'whip' }
8 | { kind: 'flash'; slot: number }
9 | { kind: 'gold' }
10 | { kind: 'complete'; wonderIndex: number }
11 | { kind: 'caravan' }
12
13export type Applied = { world: PyramidWorld; cues: Cue[] }
14
15export const MAX_CRACKS = 3
16export const SEEN_LIMIT = 20
17export const ASK_GRACE_MS = 5000
18
19export function initialWorld(progress: PyramidProgress): PyramidWorld {
20 return {
21 progress,
22 workers: { analyst: 'idle', implementer: 'idle', reviewer: 'idle' },
23 askedAt: { analyst: 0, implementer: 0, reviewer: 0 },
24 }
25}
26
27export function localDate(now: number): string {
28 const date = new Date(now)
29 const pad = (n: number): string => String(n).padStart(2, '0')
30 return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
31}
32
33const setWorker = (world: PyramidWorld, worker: PyramidWorkerName, state: PyramidWorkerState): PyramidWorld =>
34 world.workers[worker] === state ? world : { ...world, workers: { ...world.workers, [worker]: state } }
35
36function crackRecent(progress: PyramidProgress, count: number): number[] {
37 const picked: number[] = []
38 for (let i = progress.bricks - 1; i >= 0 && picked.length < count; i -= 1) {
39 if (!progress.cracked.includes(i)) picked.push(i)
40 }
41 return [...progress.cracked, ...picked].sort((a, b) => a - b)
42}
43
44function applyReport(world: PyramidWorld, worker: PyramidWorkerName, text: string, now: number): Applied {
45 const rested = setWorker(world, worker, 'idle')
46 const id = reportId(text)
47 if (world.progress.seenReports.includes(id)) return { world: rested, cues: [] }
48
49 const cues: Cue[] = []
50 let progress: PyramidProgress = { ...world.progress, seenReports: [...world.progress.seenReports, id].slice(-SEEN_LIMIT) }
51 if (worker === 'reviewer' && reportStatus(text) !== 'blocked') {
52 const findings = countFindings(text)
53 if (findings === 0) cues.push({ kind: 'gold' })
54 else progress = { ...progress, cracked: crackRecent(progress, Math.min(MAX_CRACKS, findings)) }
55 }
56 if (worker === 'implementer' && progress.cracked.length > 0) progress = { ...progress, cracked: [] }
57
58 cues.push({ kind: 'flash', slot: progress.bricks })
59 progress = { ...progress, bricks: progress.bricks + 1 }
60 const wonder = wonderAt(progress.wonderIndex)
61 if (progress.bricks >= wonder.slots.length) {
62 cues.push({ kind: 'complete', wonderIndex: progress.wonderIndex })
63 progress = {
64 ...progress,
65 completed: [...progress.completed, { wonder: wonder.name, date: localDate(now) }],
66 wonderIndex: (progress.wonderIndex + 1) % WONDERS.length,
67 bricks: 0,
68 cracked: [],
69 }
70 }
71 return { world: { ...rested, progress }, cues }
72}
73
74function applyStatus(world: PyramidWorld, event: Extract<DomainEvent, { kind: 'status' }>, now: number): Applied {
75 let next = world
76 for (const name of WORKER_NAMES) {
77 const polled = event.polled[name]
78 const current = world.workers[name]
79 if (polled === undefined) continue
80 if (polled === 'working') next = setWorker(next, name, current === 'waiting' ? 'waiting' : 'working')
81 else if (polled === 'blocked') next = setWorker(next, name, 'blocked')
82 else if (!(current === 'working' && now - world.askedAt[name] < ASK_GRACE_MS)) next = setWorker(next, name, 'idle')
83 }
84 return { world: next, cues: [] }
85}
86
87export function apply(world: PyramidWorld, event: DomainEvent, now: number): Applied {
88 switch (event.kind) {
89 case 'ask':
90 return {
91 world: { ...setWorker(world, event.worker, 'working'), askedAt: { ...world.askedAt, [event.worker]: now } },
92 cues: [{ kind: 'whip' }],
93 }
94 case 'report':
95 return applyReport(world, event.worker, event.text, now)
96 case 'blocked':
97 case 'waiting':
98 case 'idle':
99 return { world: setWorker(world, event.worker, event.kind), cues: [] }
100 case 'caravan':
101 return { world, cues: [{ kind: 'caravan' }] }
102 case 'status':
103 return applyStatus(world, event, now)
104 }
105}
106hooks/sprites.ts 124 lines1import type { PyramidWorkerName } from '../types'
2
3export const W = 72
4export const H = 36
5export const GROUND = 30
6
7export type Sprite = readonly string[]
8export type Palette = Readonly<Record<string, number>>
9
10export const PALETTE: Palette = {
11 K: 0x2b2118, S: 0xe0ac69, s: 0xa87542, Y: 0xf2c14e, y: 0xc99a2e, R: 0xb5523b, r: 0x8e3b29,
12 L: 0x3a3a46, W: 0xf4efe1, G: 0xe9b949, B: 0x2f5fa8, N: 0x5a3a1e, X: 0xe04848,
13 A: 0xc8955a, a: 0x9c6b3a,
14}
15
16export const toHexColor = (color: number): string => `#${color.toString(16).padStart(6, '0')}`
17
18export const WORKER_NAMES: readonly PyramidWorkerName[] = ['analyst', 'implementer', 'reviewer']
19export const WORKER_HOME: Readonly<Record<PyramidWorkerName, number>> = { analyst: 11, implementer: 18, reviewer: 25 }
20export const WORKER_SHIRT: Readonly<Record<PyramidWorkerName, Palette>> = {
21 analyst: { C: 0x3b6ea5, c: 0x2a5080 },
22 implementer: { C: 0x4f9a5a, c: 0x3b7445 },
23 reviewer: { C: 0x8a5cb8, c: 0x6a4392 },
24}
25export const WORKER_W = 7
26export const WORKER_H = 12
27
28export const UP_CARRY: Sprite = ['..YYY..', '.YYYYY.', 'yyyyyyy', '..SSKS.', '..SSSS.', 'RRCCCC.', 'rrCCCCS', '..CCCC.', '..cccc.']
29export const UP_EMPTY: Sprite = ['..YYY..', '.YYYYY.', 'yyyyyyy', '..SSKS.', '..SSSS.', '..CCCC.', '.SCCCCS', '..CCCC.', '..cccc.']
30export const LEGS_A: Sprite = ['..L..L.', '.L...L.', 'KK...KK']
31export const LEGS_B: Sprite = ['..LLL..', '...LL..', '...KKK.']
32export const SIT: Sprite = ['.......', '.......', '.......', '..YYY..', '.YYYYY.', 'yyyyyyy', '..SSsS.', '..SSSS.', '.SCCCCS', '..CCCC.', '..ccLLL', '..LLLKK']
33export const QMARK: Sprite = ['XXX', '..X', '.XX', '...', '.X.']
34export const ZED_SMALL: Sprite = ['WWW', '..W', '.W.', 'WWW']
35export const ZED_LARGE: Sprite = ['WWWW', '..W.', '.W..', 'WWWW']
36export const DROPPED_BRICK: Sprite = ['RRr', 'rrr']
37export const BOSS: Sprite = [
38 '...GGG...', '..GBGBG..', '.GBSSSKG.', '.BGSSSSB.', '.G.SSN.G.', '..GGGGG..', '..WWWWWS.',
39 '..WWWWW..', '..GGGGG..', '..WWWWW..', '..WW.WW..', '..SS.SS..', '..SS.SS..', '.NNN.NNN.',
40]
41export const BOSS_X = 1
42export const BOSS_Y = 16
43export const HAND = { x: 8, y: 22 } as const
44export const CLOUDS: readonly Sprite[] = [
45 ['..WWW...', '.WWWWWW.', 'WWWWWWWW'],
46 ['...WW.....', '.WWWWWWW..', 'WWWWWWWWWW'],
47]
48export const CLOUD_START: ReadonlyArray<{ x: number; y: number; p: number }> = [
49 { x: 3, y: 3, p: 0 },
50 { x: 38, y: 7, p: 1 },
51 { x: 58, y: 2, p: 0 },
52]
53export type Offset = readonly [number, number]
54export const WHIP: Readonly<Record<'rest' | 'raise' | 'crack', readonly Offset[]>> = {
55 rest: [[1, 1], [1, 2], [1, 3], [2, 4], [2, 5], [3, 6], [4, 7]],
56 raise: [[0, -1], [-1, -2], [-1, -3], [-2, -4], [-3, -4], [-4, -3]],
57 crack: [[1, 0], [2, -1], [3, -1], [4, 0], [5, 0], [6, -1], [7, -1], [8, 0], [9, 0]],
58}
59export const SPARK: ReadonlyArray<readonly [number, number, number]> = [
60 [10, -1, 0xfff6c2], [11, -2, 0xf2c14e], [11, 0, 0xf2c14e], [12, -1, 0xffffff],
61]
62export const CAMEL: Sprite = [
63 '.aa.........',
64 'aaaa........',
65 '.aa.....AA..',
66 '.aa....AAAA.',
67 '.aaAAAAAAAAA',
68 '..AAAAAAAAAA',
69 '..AAAAAAAAA.',
70 '...A.A..A.A.',
71 '...A.A..A.A.',
72 '...K.K..K.K.',
73]
74export const CAMEL_H = 10
75export const SCROLL: Sprite = ['NWWWN', '.WWW.']
76
77export type Slot = { x: number; y: number }
78export type Wonder = { name: string; slots: readonly Slot[] }
79export const BRICK_W = 4
80export const BRICK_H = 2
81
82const ordered = (slots: readonly Slot[]): Slot[] => [...slots].sort((a, b) => b.y - a.y || a.x - b.x)
83
84const stepped = (layers: number, left: number): Slot[] => {
85 const out: Slot[] = []
86 for (let k = 0; k < layers; k += 1) {
87 for (let i = 0; i < layers - k; i += 1) out.push({ x: left + k * 2 + i * 4, y: GROUND - 2 * (k + 1) })
88 }
89 return out
90}
91
92const grid = (xs: readonly number[], ys: readonly number[]): Slot[] => ys.flatMap(y => xs.map(x => ({ x, y })))
93
94export const WONDERS: readonly Wonder[] = [
95 { name: 'Pyramid', slots: ordered(stepped(7, 42)) },
96 {
97 name: 'Obelisk',
98 slots: ordered([
99 ...grid([50, 54, 58], [28]),
100 ...grid([52, 56], [26, 24, 22, 20, 18, 16, 14, 12, 10]),
101 { x: 54, y: 8 },
102 ]),
103 },
104 {
105 name: 'Sphinx',
106 slots: ordered([
107 ...grid([40, 44, 48, 52, 56, 60, 64], [28, 26]),
108 { x: 68, y: 28 },
109 ...grid([40, 44, 48], [24]),
110 ...grid([60, 64], [24, 22, 20, 18]),
111 ...grid([56], [22, 20]),
112 ]),
113 },
114 { name: 'Great Pyramid', slots: ordered(stepped(9, 36)) },
115]
116
117export const wonderAt = (index: number): Wonder => WONDERS[index] ?? WONDERS[0]!
118
119export function walkTarget(wonder: Wonder): number {
120 const footY = Math.max(...wonder.slots.map(slot => slot.y))
121 const foot = Math.min(...wonder.slots.filter(slot => slot.y === footY).map(slot => slot.x))
122 return foot - WORKER_W - 1
123}
124hooks/reports.ts 35 lines1export type ReportStatus = 'done' | 'partial' | 'blocked'
2
3const FINDINGS_HEADING = /^##\s+Findings/i
4const NEXT_HEADING = /^##\s/
5const FINDING = /^\s*-\s*\[(Critical|Major|Important|Minor)\]/i
6const STATUS = /^status:\s*(done|partial|blocked)\b/im
7
8export function findingLines(markdown: string): string[] {
9 const lines = markdown.split('\n')
10 const start = lines.findIndex(line => FINDINGS_HEADING.test(line))
11 if (start < 0) return []
12 const rest = lines.slice(start + 1)
13 const end = rest.findIndex(line => NEXT_HEADING.test(line))
14 return end < 0 ? rest : rest.slice(0, end)
15}
16
17export function countFindings(markdown: string): number {
18 return findingLines(markdown).filter(line => FINDING.test(line)).length
19}
20
21export function reportStatus(markdown: string): ReportStatus | undefined {
22 const match = STATUS.exec(markdown)
23 return match === null ? undefined : (match[1]!.toLowerCase() as ReportStatus)
24}
25
26export function reportId(markdown: string): string {
27 const text = markdown.trim()
28 let hash = 0x811c9dc5
29 for (let i = 0; i < text.length; i += 1) {
30 hash ^= text.charCodeAt(i)
31 hash = Math.imul(hash, 0x01000193) >>> 0
32 }
33 return `${hash.toString(16).padStart(8, '0')}-${text.length}`
34}
35types/index.d.ts 28 lines1export type PyramidWorkerName = 'analyst' | 'implementer' | 'reviewer'
2
3export type PyramidWorkerState = 'idle' | 'working' | 'blocked' | 'waiting'
4
5export type PyramidCompleted = { wonder: string; date: string }
6
7export type PyramidProgress = {
8 v: 1
9 wonderIndex: number
10 bricks: number
11 cracked: number[]
12 completed: PyramidCompleted[]
13 seenReports: string[]
14 paneWanted: boolean
15}
16
17export type PyramidWorld = {
18 progress: PyramidProgress
19 workers: Record<PyramidWorkerName, PyramidWorkerState>
20 askedAt: Record<PyramidWorkerName, number>
21}
22
23declare module 'claude-code' {
24 interface PluginState {
25 'pyramid-site': { world: PyramidWorld }
26 }
27}
28