SLOPSHOPPER

pyramid-site

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

paneguardcommandpromptprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pyramid-site
│ ┃ Pyramid Site ✕ › fix the failing auth test and add an audit log call │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Read(src/auth.ts) │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ Read 6 lines │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Update(src/auth.ts) │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /pyramid │ ┃ ⎿ pyramid-site: Pyramid site opened. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Pyramid Site
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ 👑 analyst 쉼 implementer 쉼 reviewer 쉼 🧱0/28
README

claude-code-mod

Mods for Claude Code: function-hook plugins, one per folder under mods/.

Mods

modwhat it does
kaomoji-statusOne row of dancing kaomoji above the prompt, one per harness worker.
pyramid-siteAn animated pixel-art pyramid site in a side pane: the conductor cracks the whip, workers carry bricks.

Install

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

Updating

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.

kaomoji-status

Shows each harness (herdr) worker as a kaomoji in one row above the prompt:

analyst (。-ω-)zzZ  implementer ᕕ( ᐛ )ᕗ  reviewer (⊙_⊙)? 도와줘요!
  • idle: grey, slowly snoring (a done worker shows the same as idle); working: yellow, dancing; blocked: pink-red, asking for help.
  • Polls 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).
  • The row hides itself when harness is missing or no worker is live.

pyramid-site

A construction site in a side pane, drawn as half-block pixel art (72×18 cells):

  • The overseer (the conductor) cracks a whip on every harness ask <worker>; that worker starts carrying bricks.
  • Every worker report lays one brick. A reviewer report with findings cracks up to three recent bricks; the next implementer report repairs them. A clean review flashes the building gold.
  • Blocked workers (exit 2) drop their brick under a red ?; exit 4 makes them wait.
  • Every prompt you type sends a camel caravan with a scroll for the overseer.
  • Wonders: Pyramid (28) → Obelisk (22) → Sphinx (28) → Great Pyramid (45), kept across sessions.

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

Local development

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.

License

MIT, see LICENSE.

Source 12 files
hooks/register.tsx 352 lines
1import { 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}
352
hooks/cells.ts 101 lines
1import { 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}
101
hooks/events.ts 142 lines
1import 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}
142
hooks/gallery.ts 35 lines
1import 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}
35
hooks/scene.ts 236 lines
1import 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}
236
hooks/sim.ts 113 lines
1import 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}
113
hooks/status.ts 27 lines
1import 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}
27
hooks/store.ts 36 lines
1import 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}
36
hooks/world.ts 106 lines
1import 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}
106
hooks/sprites.ts 124 lines
1import 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}
124
hooks/reports.ts 35 lines
1export 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}
35
types/index.d.ts 28 lines
1export 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