SLOPSHOPPER

guildhall-tavern

A live pane of the Guildhall during a /quest: who is working, what they are up to in character, and who answers to whom.

newpaneguardcommandstatustimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · guildhall-tavern
│ ┃ The Tavern ✕ › fix the failing auth test and add an audit log call │ ┃ 🧙 The tavern is quiet. │ ┃ Mordain waits by the hearth. Issue /quest to ⏺ Read(src/auth.ts) │ ┃ call the guild. ⎿ 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 │ │ › /guildhall-tavern │ ⎿ guildhall-tavern: The tavern door swings open. (Surfaces attache │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · The Tavern
🧙 The tavern is quiet. Mordain waits by the hearth. Issue /quest to call the guild.
README

guildhall-tavern

A Claude Code mod that draws the Guildhall tavern as a live pane while a /quest runs: Mordain at the top, every adventurer he summons grouped by phase (Build chain, Review fan-out, Closer, Field work, Hirelings), each with an icon, a spinner while at work, the model the spawn resolved to, elapsed time, a deed count, and one in-character line for what they are doing right now ("Bruga forges rate_limit.ts", "Oriana searches for unwatched gates: password"). A short chronicle under the tree records summons and returns.

It is a spectator. It reads agent.spawn, tool.call, turn.complete and command.run, never changes them, and every hook falls through to the engine if its own bookkeeping fails. It does not touch the guildhall plugin, its agents or /quest.

Install

In a Claude Code session:

/plugin install guildhall-tavern --marketplace GrillerGeek/guildhall

Answer y to add the marketplace, then pick a scope. Or from a terminal, in the project where Guildhall is installed:

claude plugin marketplace add https://github.com/GrillerGeek/guildhall.git --scope project
claude plugin install guildhall-tavern@guildhall-local --scope project

It is a function-hook mod, written and tested against Claude Code 2.1.295. For local development, load the folder directly:

claude --plugin-dir <repo>/plugin --plugin-dir <repo>/mods/guildhall-tavern

The pane opens itself when a /quest starts, or when a guild agent is dispatched outside one. /guildhall-tavern opens it any time.

The pane needs a surface that draws mod UI: the Claude Code terminal (a pane you did not ask for waits for 144 columns; asked for, it opens at any width) or a local session in the desktop app. A cloud session viewed from the Claude app attaches no such surface, so there /guildhall-tavern prints the tavern as text instead; run it again for a fresh look.

Where things live

  • hooks/roster.ts: one entry per agent in plugin/agents/, voiced from plugin/CHARACTERS.md. A new adventurer needs a row here or it shows as a hireling; tests/test_tavern_roster.py fails CI until it has one, and when a roster name drifts from its character sheet.
  • hooks/register.tsx: the hooks and the pane.
  • types/index.d.ts: the session state the pane draws from.

Check it

claude plugin validate mods/guildhall-tavern
claude plugin test mods/guildhall-tavern
Source 3 files
hooks/register.tsx 340 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { ChronicleEntry, GuildMember, GuildQuest } from '../types'
5import { KEEPER, PHASES, characterOf, classify, flavor, guildRole, shorten } from './roster'
6import type { Character } from './roster'
7
8const PANE = 'guildhall'
9const TITLE = 'The Tavern'
10const SPINNER = ['◐', '◓', '◑', '◒']
11const CHRONICLE_LENGTH = 8
12const MEMBER_LIMIT = 40
13
14const quest = atom({ plugin: 'guildhall-tavern', key: 'quest' } as const, null as GuildQuest | null)
15const members = atom({ plugin: 'guildhall-tavern', key: 'members' } as const, [] as GuildMember[])
16const chronicle = atom({ plugin: 'guildhall-tavern', key: 'chronicle' } as const, [] as ChronicleEntry[])
17const tick = atom({ plugin: 'guildhall-tavern', key: 'tick' } as const, 0)
18
19// Which surfaces have asked this module to draw the pane since it loaded: diagnostics only.
20const drawnOn = new Set<string>()
21
22async function whereText($: EngineInterface): Promise<string> {
23  const surfaces = await $.session.surfaces()
24  const pane = (await $.ui.panes()).find(p => p.id === PANE)
25  const state = pane ? `placed ${pane.isPlaced}, shown ${pane.isShown}` : 'not open'
26  const drawn = drawnOn.size > 0 ? [...drawnOn].join(', ') : 'none yet'
27  return `Surfaces attached: ${surfaces.join(', ') || 'none'} · pane: ${state} · drawn on: ${drawn}`
28}
29
30const isQuestCommand = (command: string) => command === 'quest' || command === 'guildhall:quest'
31
32const elapsed = (ms: number) => {
33  const s = Math.max(0, Math.floor(ms / 1000))
34  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
35}
36
37const memberCharacter = (m: GuildMember): Character => characterOf(m.role)
38
39async function record($: EngineInterface, icon: string, text: string) {
40  await update($, chronicle, list => [...(list ?? []), { at: Date.now(), icon, text }].slice(-CHRONICLE_LENGTH))
41}
42
43async function beginQuest($: EngineInterface, title: string) {
44  await update($, quest, () => ({
45    title,
46    startedAt: Date.now(),
47    keeperLine: `${KEEPER.name} reads the whole scroll before speaking`,
48    isKeeperBusy: true,
49  }))
50  await update($, members, () => [])
51  await update($, chronicle, () => [])
52  await record($, KEEPER.icon, `${KEEPER.name}: “${KEEPER.catchphrase}”`)
53  // A surface that cannot seat the pane leaves the ledger kept; /guildhall-tavern opens it later.
54  await $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
55  await refreshStatus($)
56}
57
58/**
59 * A guild agent dispatched outside /quest still gets a hall to work in. Created only
60 * when none exists, in one write, so agents dispatched together share one quest.
61 */
62async function ensureQuest($: EngineInterface, title: string) {
63  await update($, quest, current =>
64    current ?? {
65      title,
66      startedAt: Date.now(),
67      keeperLine: `${KEEPER.name} sends adventurers out on an errand`,
68      isKeeperBusy: true,
69    },
70  )
71  await $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
72}
73
74async function setKeeper($: EngineInterface, line: string, isKeeperBusy = true) {
75  await update($, quest, q => (q ? { ...q, keeperLine: line, isKeeperBusy } : q))
76  await refreshStatus($)
77}
78
79/** The tavern as plain text: the command's answer where no surface draws the pane. */
80function tavernText(q: GuildQuest | null, list: GuildMember[], log: ChronicleEntry[], now: number): string {
81  if (q === null) return `${KEEPER.icon} The tavern is quiet. ${KEEPER.name} waits by the hearth.`
82  const mark = (m: GuildMember) => (m.status === 'working' ? '▶' : m.status === 'done' ? '✓' : '✗')
83  const lines = [
84    `⚔ ${q.title} (${elapsed(now - q.startedAt)} on the road)`,
85    `${q.isKeeperBusy ? '▶' : '·'} ${KEEPER.icon} ${KEEPER.name} ${KEEPER.title}: ${q.keeperLine}`,
86  ]
87  for (const p of PHASES) {
88    const group = list.filter(m => memberCharacter(m).phase === p.phase)
89    if (group.length === 0) continue
90    lines.push(`  ${p.label}`)
91    for (const m of group) {
92      const who = memberCharacter(m)
93      lines.push(`    ${mark(m)} ${who.icon} ${who.name} ${who.title} (${m.role}, ${elapsed((m.endedAt ?? now) - m.startedAt)}, ${m.deeds} deeds): ${m.line}`)
94    }
95  }
96  if (log.length > 0) lines.push('  Chronicle', ...log.map(entry => `    ${entry.icon} ${entry.text}`))
97  return lines.join('\n')
98}
99
100/** One status-line entry that follows the quest on every surface, pane or no pane. */
101async function refreshStatus($: EngineInterface) {
102  const q = await read($, quest)
103  if (q === null) return $.ui.status(undefined)
104  const list = (await read($, members)) ?? []
105  const working = list.filter(m => m.status === 'working')
106  if (working.length > 0) {
107    return $.ui.status(`⚔ ${working.map(m => `${memberCharacter(m).icon} ${m.line}`).join(' · ')}`)
108  }
109  $.ui.status(`⚔ ${KEEPER.icon} ${q.keeperLine}${list.length > 0 ? ` · ${list.length} summoned, all returned` : ''}`)
110}
111
112export const register: Register = on => {
113  on('session.start', async ($, e, next) => {
114    await $.command.register({
115      name: 'guildhall-tavern',
116      description: 'Open the Guildhall tavern: who is on the quest and what they are doing',
117    })
118    // Spinners and running clocks advance only while someone is at work.
119    $.clock.every(500, () => {
120      void (async () => {
121        const list = (await read($, members)) ?? []
122        const q = await read($, quest)
123        if (list.some(m => m.status === 'working') || q?.isKeeperBusy) {
124          await update($, tick, n => (n ?? 0) + 1)
125        }
126      })()
127    })
128    return next(e)
129  })
130
131  on('command.run', { command: 'guildhall-tavern' }, async $ => {
132    const opened = await $.ui.open({ id: PANE, title: TITLE })
133    // With no surface attached (a cloud session viewed from an app) every pane reads
134    // as placed, yet nothing draws it: only this command's text reaches the person.
135    const surfaces = await $.session.surfaces()
136    if (opened.isPlaced && surfaces.length > 0) {
137      return { text: `The tavern door swings open. (${await whereText($)})` }
138    }
139    const why = opened.isPlaced ? 'no surface that draws panes is attached to this session' : opened.reason
140    const snapshot = tavernText(
141      await read($, quest),
142      (await read($, members)) ?? [],
143      (await read($, chronicle)) ?? [],
144      Date.now(),
145    )
146    return { text: `The pane cannot be drawn here (${why}). Run /guildhall-tavern again for a fresh look.\n\n${snapshot}` }
147  })
148
149  // A /quest begins a fresh ledger. The hook only watches; the command runs as typed.
150  on('command.run', async ($, e, next) => {
151    if (isQuestCommand(e.command)) {
152      await beginQuest($, shorten(e.args ?? '', 80) || 'An unnamed quest')
153    }
154    return next(e)
155  }).catch(($, e, next) => next(e))
156
157  on('agent.spawn', async ($, e, next) => {
158    const role = guildRole(e.subagentType)
159    if (role === undefined && (await read($, quest)) === null) return next(e)
160
161    const spawned = await next(e)
162    if (spawned.deny !== undefined || spawned.agentId === undefined) return spawned
163    if (role !== undefined) await ensureQuest($, shorten(e.description ?? '', 80) || 'An impromptu errand')
164
165    const who = role ? characterOf(role) : characterOf(e.subagentType)
166    const member: GuildMember = {
167      id: spawned.agentId,
168      role: role ?? e.subagentType,
169      parentId: e.parentAgentId,
170      status: 'working',
171      task: shorten(e.description ?? '', 80),
172      line: `${who.name} answers the summons`,
173      deeds: 0,
174      model: spawned.model,
175      startedAt: Date.now(),
176    }
177    await update($, members, list => [...(list ?? []), member].slice(-MEMBER_LIMIT))
178    await refreshStatus($)
179    if (e.parentAgentId === undefined) {
180      await setKeeper($, `${KEEPER.name} summons ${who.name}`)
181    }
182    await record($, who.icon, `${who.name} ${who.title} takes up: ${member.task}`)
183    return spawned
184  }).catch(($, e, next) => next(e)) // the pane is a spectator: a bookkeeping failure never blocks a dispatch
185
186  on('tool.call', async ($, e, next) => {
187    const q = await read($, quest)
188    if (q === null) return next(e)
189    const { deed, target } = classify(String(e.tool), e as unknown as Record<string, unknown>)
190
191    if (e.agentId === undefined) {
192      // The main loop is Mordain; his summons are drawn by agent.spawn.
193      if (e.tool !== 'Agent') await setKeeper($, flavor(KEEPER, deed, target))
194      return next(e)
195    }
196
197    const agentId = e.agentId
198    const list = (await read($, members)) ?? []
199    const member = list.find(m => m.id === agentId)
200    if (member === undefined) return next(e)
201    const line = flavor(memberCharacter(member), deed, target)
202    await update($, members, all =>
203      (all ?? []).map(m => (m.id === agentId ? { ...m, line, deeds: m.deeds + 1 } : m)),
204    )
205    await refreshStatus($)
206    return next(e)
207  }).catch(($, e, next) => next(e))
208
209  on('turn.complete', async ($, e, next) => {
210    const done = await next(e)
211    const q = await read($, quest)
212    if (q === null) return done
213
214    if (e.agentId === undefined) {
215      await setKeeper($, flavor(KEEPER, 'idle', ''), false)
216      return done
217    }
218
219    const agentId = e.agentId
220    const list = (await read($, members)) ?? []
221    const member = list.find(m => m.id === agentId)
222    if (member === undefined || member.status !== 'working') return done
223    const who = memberCharacter(member)
224    const fell = e.isAborted || e.reason === 'error' || e.reason === 'refusal'
225    await update($, members, all =>
226      (all ?? []).map(m =>
227        m.id === agentId
228          ? {
229              ...m,
230              status: fell ? 'fallen' : 'done',
231              line: fell ? `${who.name} was called back from the field` : `“${who.catchphrase}”`,
232              endedAt: Date.now(),
233            }
234          : m,
235      ),
236    )
237    await record(
238      $,
239      who.icon,
240      fell ? `${who.name} returns empty-handed` : `${who.name} returns to the hall (${elapsed(Date.now() - member.startedAt)})`,
241    )
242    await refreshStatus($)
243    return done
244  }).catch(($, e, next) => next(e)) // replays the turn's own answer; nothing runs twice
245
246  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
247    const { Box, Text } = $.ui.resolve(e)
248    drawnOn.add(e.surface)
249    const q = await read($, quest)
250    const list = (await read($, members)) ?? []
251    const log = (await read($, chronicle)) ?? []
252    const frame = SPINNER[((await read($, tick)) ?? 0) % SPINNER.length]
253    const now = Date.now()
254
255    if (q === null) {
256      return (
257        <Box flexDirection="column">
258          <Text bold>{KEEPER.icon} The tavern is quiet.</Text>
259          <Text dimColor>{KEEPER.name} waits by the hearth. Issue /quest to call the guild.</Text>
260        </Box>
261      )
262    }
263
264    const statusMark = (m: GuildMember) =>
265      m.status === 'working' ? frame : m.status === 'done' ? '✓' : '✗'
266    const statusColor = (m: GuildMember) =>
267      m.status === 'working' ? 'claude' : m.status === 'done' ? 'success' : 'error'
268
269    const rows: RenderChildren[] = []
270    const drawMember = (m: GuildMember, indent: string) => {
271      const who = memberCharacter(m)
272      const clock = elapsed((m.endedAt ?? now) - m.startedAt)
273      rows.push(
274        <Text wrap="truncate-end">
275          {indent}
276          <Text color={statusColor(m)}>{statusMark(m)}</Text> {who.icon} <Text bold>{who.name}</Text>
277          <Text dimColor> {who.title} · {m.role}{m.model ? ` · ${m.model}` : ''} · {clock} · {m.deeds} deeds</Text>
278        </Text>,
279      )
280      rows.push(
281        <Text wrap="truncate-end" dimColor={m.status !== 'working'} italic>
282          {indent}     {m.line}
283        </Text>,
284      )
285      for (const child of list.filter(c => c.parentId === m.id)) drawMember(child, indent + '   ')
286    }
287
288    const withDescendants = (m: GuildMember): GuildMember[] => [
289      m,
290      ...list.filter(c => c.parentId === m.id).flatMap(withDescendants),
291    ]
292    const ids = new Set(list.map(m => m.id))
293    const roots = list.filter(m => m.parentId === undefined || !ids.has(m.parentId))
294    const groups = PHASES.map(p => ({
295      ...p,
296      list: roots.filter(m => memberCharacter(m).phase === p.phase),
297    })).filter(g => g.list.length > 0)
298
299    groups.forEach((g, i) => {
300      const last = i === groups.length - 1
301      const working = g.list.flatMap(withDescendants).filter(m => m.status === 'working').length
302      rows.push(
303        <Text>
304          <Text dimColor>{last ? '└─ ' : '├─ '}</Text>
305          <Text bold>{g.label}</Text>
306          <Text dimColor>{working > 0 ? `  ${working} at work` : '  done'}</Text>
307        </Text>,
308      )
309      for (const m of g.list) drawMember(m, last ? '   ' : '│  ')
310    })
311
312    const atWork = list.filter(m => m.status === 'working').length
313    return (
314      <Box flexDirection="column">
315        <Text bold wrap="truncate-end">⚔ {q.title}</Text>
316        <Text dimColor>
317          {elapsed(now - q.startedAt)} on the road · {atWork} at work · {list.length} summoned
318        </Text>
319        <Text> </Text>
320        <Text wrap="truncate-end">
321          <Text color={q.isKeeperBusy ? 'claude' : 'subtle'}>{q.isKeeperBusy ? frame : '·'}</Text> {KEEPER.icon}{' '}
322          <Text bold>{KEEPER.name}</Text>
323          <Text dimColor> {KEEPER.title} · Guildmaster</Text>
324        </Text>
325        <Text wrap="truncate-end" italic dimColor={!q.isKeeperBusy}>
326          {'     '}{q.keeperLine}
327        </Text>
328        {rows}
329        {log.length > 0 && <Text> </Text>}
330        {log.length > 0 && <Text bold dimColor>Chronicle</Text>}
331        {log.map(entry => (
332          <Text wrap="truncate-end" dimColor>
333            {entry.icon} {entry.text}
334          </Text>
335        ))}
336      </Box>
337    )
338  })
339}
340
hooks/roster.ts 299 lines
1// The guild as the pane draws it: one entry per agent in plugin/agents/,
2// voiced from plugin/CHARACTERS.md. `{x}` in a line is the deed's target.
3
4export type Phase = 'build' | 'review' | 'closer' | 'standalone' | 'hireling'
5
6export type Deed = 'read' | 'search' | 'write' | 'run' | 'ask' | 'ledger' | 'idle'
7
8export type Character = {
9  name: string
10  title: string
11  icon: string
12  phase: Phase
13  catchphrase: string
14  lines: Partial<Record<Deed, string>>
15}
16
17export const PHASES: { phase: Phase; label: string }[] = [
18  { phase: 'build', label: 'Build chain' },
19  { phase: 'review', label: 'Review fan-out' },
20  { phase: 'closer', label: 'Closer' },
21  { phase: 'standalone', label: 'Field work' },
22  { phase: 'hireling', label: 'Hirelings' },
23]
24
25export const KEEPER: Character = {
26  name: 'Mordain',
27  title: 'the Keeper',
28  icon: '🧙',
29  phase: 'standalone',
30  catchphrase: 'First, the plan. Then, the adventurers.',
31  lines: {
32    read: 'reads the whole scroll before speaking: {x}',
33    search: 'consults the archives for {x}',
34    write: 'inks the plan: {x}',
35    run: 'sends a runner on an errand: {x}',
36    ask: 'awaits the patron’s word',
37    ledger: 'updates the ledger',
38    idle: 'rests in the high chair by the hearth',
39  },
40}
41
42export const ROSTER: Record<string, Character> = {
43  'prototype-builder': {
44    name: 'Pip', title: 'the Scout', icon: '🎒', phase: 'standalone',
45    catchphrase: 'Here, it runs! Don’t ask what happens on Tuesday.',
46    lines: {
47      read: 'peeks at {x} between bites', search: 'rummages around for {x}',
48      write: 'lashes together {x}', run: 'pokes it to see if it runs: {x}',
49      idle: 'chews thoughtfully',
50    },
51  },
52  'test-author': {
53    name: 'Seraphine', title: 'the Oracle', icon: '🔮', phase: 'build',
54    catchphrase: 'The spec is written. The test is its shadow.',
55    lines: {
56      read: 'reads the spec as scripture: {x}', search: 'seeks the Expectation of {x}',
57      write: 'inscribes a prophecy: {x}', run: 'watches the prophecy fail, as foretold: {x}',
58      idle: 'meditates on the spec, eyes averted from the code',
59    },
60  },
61  'feature-implementer': {
62    name: 'Bruga', title: 'the Smith', icon: '🔨', phase: 'build',
63    catchphrase: 'Show me the blueprint.',
64    lines: {
65      read: 'studies the blueprint: {x}', search: 'sorts the scrap pile for {x}',
66      write: 'forges {x}', run: 'quenches the blade: {x}',
67      idle: 'stokes the forge',
68    },
69  },
70  refactorer: {
71    name: 'Tink', title: 'the Enchanter', icon: '✨', phase: 'build',
72    catchphrase: 'Same stone. Better setting.',
73    lines: {
74      read: 'turns {x} in the light', search: 'hunts loose settings in {x}',
75      write: 'resets the stone: {x}', run: 'checks the shine holds: {x}',
76      idle: 'polishes quietly',
77    },
78  },
79  'architecture-reviewer': {
80    name: 'Aldric', title: 'the Cartographer', icon: '📐', phase: 'build',
81    catchphrase: 'Three paths lie open. Only one leads forward without debt.',
82    lines: {
83      read: 'surveys the terrain of {x}', search: 'traces the roads to {x}',
84      run: 'paces the distance: {x}', idle: 'weighs three paths',
85    },
86  },
87  'debug-investigator': {
88    name: 'Kael', title: 'the Tracker', icon: '🏹', phase: 'standalone',
89    catchphrase: 'I know WHY. What you do next is not my tale to tell.',
90    lines: {
91      read: 'reads the tracks in {x}', search: 'follows the trail of {x}',
92      write: 'marks the trail: {x}', run: 'sets a snare: {x}',
93      idle: 'kneels to study the ground',
94    },
95  },
96  'ui-test-author': {
97    name: 'Vera', title: 'the Playwright', icon: '🎭', phase: 'review',
98    catchphrase: 'The curtain has risen. Let us see if the play matches the script.',
99    lines: {
100      read: 'reads the script: {x}', search: 'scouts the stage for {x}',
101      write: 'writes the scene: {x}', run: 'raises the curtain: {x}',
102      idle: 'rehearses the blocking',
103    },
104  },
105  'security-reviewer': {
106    name: 'Oriana', title: 'the Watcher', icon: '🦉', phase: 'review',
107    catchphrase: 'Trust no path you have not walked.',
108    lines: {
109      read: 'walks the wall past {x}', search: 'searches for unwatched gates: {x}',
110      run: 'tests the lock: {x}', idle: 'keeps the watch',
111    },
112  },
113  'docs-writer': {
114    name: 'Cassian', title: 'the Scribe', icon: '📖', phase: 'review',
115    catchphrase: 'A song is only as true as the singer who remembers it.',
116    lines: {
117      read: 'reads the old songs: {x}', search: 'seeks the verse about {x}',
118      write: 'sets down the verse: {x}', run: 'checks the tune: {x}',
119      idle: 'sharpens the quill',
120    },
121  },
122  'observability-reviewer': {
123    name: 'Vance', title: 'the Chronicler', icon: '📜', phase: 'review',
124    catchphrase: 'What happened, and would we know if it happened again?',
125    lines: {
126      read: 'reads the chronicle of {x}', search: 'searches for unrecorded deeds: {x}',
127      run: 'listens for echoes: {x}', idle: 'asks: would we know?',
128    },
129  },
130  'reliability-reviewer': {
131    name: 'Thalia', title: 'the Stormwarden', icon: '⚡', phase: 'review',
132    catchphrase: 'The wind will come. The wall must hold.',
133    lines: {
134      read: 'tests the walls of {x}', search: 'searches for cracks: {x}',
135      run: 'summons a squall: {x}', idle: 'reads the sky',
136    },
137  },
138  'performance-reviewer': {
139    name: 'Cassia', title: 'the Smith of Cycles', icon: '⏳', phase: 'review',
140    catchphrase: 'Every cycle costs something. Pay it knowingly.',
141    lines: {
142      read: 'counts the cycles in {x}', search: 'hunts hot loops: {x}',
143      run: 'times the forge: {x}', idle: 'weighs the cost',
144    },
145  },
146  'ops-readiness-reviewer': {
147    name: 'Garran', title: 'the Quartermaster', icon: '📦', phase: 'review',
148    catchphrase: 'No army marches without a wagon train.',
149    lines: {
150      read: 'inspects the supply lines in {x}', search: 'inventories {x}',
151      run: 'loads the wagon: {x}', idle: 'drafts the marching orders',
152    },
153  },
154  'migration-safety-reviewer': {
155    name: 'Ysolde', title: 'the Gravedigger', icon: '🪦', phase: 'review',
156    catchphrase: 'Some doors close behind you. Be sure before you walk through.',
157    lines: {
158      read: 'reads the gravestones of {x}', search: 'searches for one-way doors: {x}',
159      run: 'tests the ground: {x}', idle: 'weighs what cannot be undone',
160    },
161  },
162  'accessibility-reviewer': {
163    name: 'Lior', title: 'the Lampbearer', icon: '🏮', phase: 'review',
164    catchphrase: 'A door without a handle is a wall.',
165    lines: {
166      read: 'carries the lamp through {x}', search: 'looks for doors without handles: {x}',
167      run: 'walks the path in the dark: {x}', idle: 'trims the wick',
168    },
169  },
170  'pr-author': {
171    name: 'Rook', title: 'the Herald', icon: '📯', phase: 'closer',
172    catchphrase: 'The deed is done. Now let the tale be told precisely.',
173    lines: {
174      read: 'reviews the deeds in {x}', search: 'gathers witnesses: {x}',
175      write: 'drafts the proclamation: {x}', run: 'consults the ledger: {x}',
176      idle: 'clears the throat',
177    },
178  },
179  'fog-cartographer': {
180    name: 'Wren', title: 'the Wayfinder', icon: '🧭', phase: 'closer',
181    catchphrase: 'I mark what the scouts saw. I do not redraw coastlines I haven’t walked.',
182    lines: {
183      read: 'reads the scouts’ notes: {x}', search: 'seeks the fog line near {x}',
184      write: 'marks the map: {x}', run: 'checks the bearings: {x}',
185      idle: 'watches the fog',
186    },
187  },
188  'plugin-validator': {
189    name: 'Tabs', title: 'the Apprentice', icon: '🔩', phase: 'standalone',
190    catchphrase: 'Small checks, small surprises.',
191    lines: {
192      read: 'checks {x}, line by line', search: 'counts the rivets: {x}',
193      write: 'notes it down: {x}', run: 'runs the checks: {x}',
194      idle: 'ticks the checklist',
195    },
196  },
197  'model-echo': {
198    name: 'Echo', title: 'the Diagnostic', icon: '🔔', phase: 'standalone',
199    catchphrase: 'An echo is a hint, never proof.',
200    lines: { idle: 'rings once and listens' },
201  },
202}
203
204const GENERIC: Record<Deed, string> = {
205  read: 'reads {x}',
206  search: 'searches for {x}',
207  write: 'scribbles notes: {x}',
208  run: 'runs a command: {x}',
209  ask: 'asks the patron a question',
210  ledger: 'updates the ledger',
211  idle: 'is at work',
212}
213
214/** A subagent type the guild does not employ: `Explore`, `general-purpose`. */
215export function hireling(subagentType: string): Character {
216  return {
217    name: shorten(subagentType, 40),
218    title: 'hireling',
219    icon: '🧳',
220    phase: 'hireling',
221    catchphrase: 'Paid in full.',
222    lines: {},
223  }
224}
225
226/** `guildhall:test-author` → `test-author`; undefined for a non-guild agent. */
227export function guildRole(subagentType: string): string | undefined {
228  const role = subagentType.startsWith('guildhall:')
229    ? subagentType.slice('guildhall:'.length)
230    : subagentType
231  return Object.hasOwn(ROSTER, role) ? role : undefined
232}
233
234export function characterOf(role: string): Character {
235  return Object.hasOwn(ROSTER, role) ? (ROSTER[role] as Character) : hireling(role)
236}
237
238/** One line of plain text: control characters (terminal escapes) dropped, cut to `max`. */
239export const shorten = (text: string, max = 48) => {
240  const one = text.replace(/[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]+/g, ' ').replace(/\s+/g, ' ').trim()
241  return one.length > max ? one.slice(0, max - 1) + '…' : one
242}
243
244// A URL's query and fragment can carry tokens: only its host and path are shown.
245const withoutQuery = (url: string) => {
246  try {
247    const parsed = new URL(url)
248    return parsed.origin + parsed.pathname // drops user:pass@ too
249  } catch {
250    return url.split(/[?#]/)[0]?.replace(/\/\/[^/@]*@/, '//') ?? ''
251  }
252}
253
254const basename = (path: string) => shorten(path.split('/').filter(Boolean).pop() ?? path)
255
256/** Which kind of deed a tool call is, and what it is done to. */
257export function classify(
258  tool: string,
259  input: Record<string, unknown>,
260): { deed: Deed; target: string } {
261  const str = (key: string) => (typeof input[key] === 'string' ? (input[key] as string) : '')
262  switch (tool) {
263    case 'Read':
264    case 'NotebookRead':
265      return { deed: 'read', target: basename(str('file_path') || str('notebook_path')) }
266    case 'Write':
267    case 'Edit':
268    case 'MultiEdit':
269    case 'NotebookEdit':
270      return { deed: 'write', target: basename(str('file_path') || str('notebook_path')) }
271    case 'Grep':
272    case 'Glob':
273      return { deed: 'search', target: shorten(str('pattern'), 32) }
274    case 'WebFetch':
275      return { deed: 'search', target: shorten(withoutQuery(str('url')), 40) }
276    case 'WebSearch':
277      return { deed: 'search', target: shorten(str('query'), 40) }
278    case 'Bash':
279      // A bare command can hold a credential; only the call's own description is shown.
280      return { deed: 'run', target: shorten(str('description'), 40) }
281    case 'AskUserQuestion':
282      return { deed: 'ask', target: '' }
283    case 'TodoWrite':
284    case 'TaskCreate':
285    case 'TaskUpdate':
286      return { deed: 'ledger', target: '' }
287    default:
288      return { deed: 'idle', target: shorten(tool, 40) }
289  }
290}
291
292/** The in-character line for a character doing one deed. */
293export function flavor(who: Character, deed: Deed, target: string): string {
294  const template = who.lines[deed] ?? GENERIC[deed]
295  // A deed with no target to show drops its trailing `: {x}`.
296  const line = target ? template.replace('{x}', target) : template.replace(/:\s*\{x\}$/, '').replace('{x}', 'something')
297  return `${who.name} ${line}`
298}
299
types/index.d.ts 35 lines
1export type MemberStatus = 'working' | 'done' | 'fallen'
2
3export type GuildMember = {
4  id: string
5  role: string
6  parentId?: string
7  status: MemberStatus
8  task: string
9  line: string
10  deeds: number
11  model?: string
12  startedAt: number
13  endedAt?: number
14}
15
16export type GuildQuest = {
17  title: string
18  startedAt: number
19  keeperLine: string
20  isKeeperBusy: boolean
21}
22
23export type ChronicleEntry = { at: number; icon: string; text: string }
24
25declare module 'claude-code' {
26  interface PluginState {
27    'guildhall-tavern': {
28      quest: GuildQuest | null
29      members: GuildMember[]
30      chronicle: ChronicleEntry[]
31      tick: number
32    }
33  }
34}
35