SLOPSHOPPER

eunuch-mode-court

A Claude Code mod for Eunuch Mode: while the skill is on, the palace adviser stands above the prompt in pixel art, posing for what the agent is doing; the…

newbandspinnerguardcommandstatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · eunuch-mode-court
› 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 › /court ⎿ eunuch-mode-court: The court of Eunuch Mode. ⎿ eunuch-mode-court: /court on convene the court for this session ⎿ eunuch-mode-court: /court off adjourn it ("drop the bit" does the same) ⎿ eunuch-mode-court: /court always convene in every session, skill or not ⎿ eunuch-mode-court: /court skill convene only when the eunuch-mode skill is on (the default) ⎿ eunuch-mode-court: /court never never convene ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

eunuch-mode-court

A Claude Code mod for Eunuch Mode. While the skill is on:

  • The adviser stands above your prompt in half-block pixel art (14 columns by 6 rows). He is the bald, heavy-lidded, smirking functionary from the launch film, in his claret robe with its teal collar and gold chain. His pose follows the agent, in two-frame animation.
  • The palace spinner narrates each action in court language: "Dispatching the palace guards" for a shell command, "Amending the royal scroll" for an edit, "Bribing the borrow checker" for cargo, and "HIGH TREASON" for git push --force. Claude Code keeps its own elapsed-time and token count beside it.
  • The crown: 👑 Court in session in the status line.
The agent is…PoseStage direction
thinkingwhisper, a hand raised, eyes sliding sideways[whispers behind a sleeve]
running a tool (shell, read, search, web, tests, builds…)fawning bow, bobbing[bows low]
editing or writing a filescribbling on a scroll, quill moving[scribbles on the royal scroll]
running git push --force, rm -rf, git reset --hardalarm: brows up, mouth open, a bead of sweat[gasps]
failing (a tool error, an interrupted turn)side-eye, one brow raised[narrows his eyes]
donea smug bow, then a sly look up[a small, satisfied bow]

There are about 250 curated lines across 32 kinds of activity. When a kind runs dry, the court roster takes over ("Consulting the Keeper of the Flaky Tests"), giving several hundred more, so no line repeats until several hundred have been spoken (/court shows the count). None of them calls a model, so by default the mod costs nothing. Two opt-in modes add more: generative mode (a model writes fresh lines) and treachery mode (he plots your downfall, cosmetically).

Generative mode (optional)

Off by default. When it is on, a model writes fresh spinner lines on the fly, in the same voice, on top of the canned ones.

/court generative on          turn it on (remembered across sessions)
/court generative off         turn it off
/court generative model haiku pick the model (default: sonnet, Claude Code's alias for the current Sonnet)
/court generative status      calls used, fallbacks, tokens, and the latest lines it wrote

Or set EUNUCH_MODE_GENERATIVE=1 (and optionally EUNUCH_MODE_MODEL) in the environment; /court generative off overrides it.

How it works. The canned line always appears first, instantly. While the adviser thinks between actions, the mod asks the model for one line about what just happened. If the line arrives while that moment is still on screen, it replaces the canned one; otherwise it is saved for the next moment of the same kind. Generated lines join the same no-repeat rotation.

Cost and limits. Calls go through Claude Code's own $.model.complete, so they are billed to your Claude Code session (your subscription's usage, or your API account): no separate key, and the mod never sees one. Measured on a real session: about 350 input and 20 output tokens per call. Guards:

  • at most one call every 4 seconds, one at a time, and none while a fresh line for that kind of moment is already waiting;
  • a hard cap of 100 calls per session (/court generative status shows the count);
  • 40 output tokens at most, low effort, and a 3-second timeout.

Any timeout or error leaves the canned line in place. The agent never waits on any of it: the call starts after the tool's result has gone back.

Privacy: exactly what is sent. Each request carries a fixed system prompt (the voice rules) and one message built from four fields, each checked against a strict pattern:

  • the activity kind (edit, tests, treason…);
  • the tool's name (Edit, Bash; an MCP tool is sent as "an external tool");
  • a file extension (.ts), never the path or the file name;
  • a command's verb (pytest, git push), never its arguments, flags or paths.

Nothing else leaves: no file contents, no paths, no command arguments, no prompts, no transcript, no secrets. For example, curl -H "Authorization: Bearer sk-…" https://internal/acme is sent as Activity: courier. Tool: Bash. Command: curl. The tests prove it (test-node/generative.test.mjs, "sanitisation"). Replies are filtered before they are shown: one line, 8 to 60 characters, letters and simple punctuation only, no links or paths, and a blocklist that keeps the voice rules (no jokes about the eunuch condition, no real people or cultures).

Treachery mode (optional)

The plotting is purely cosmetic. He schemes; he cannot act.

Off by default. /court treachery on and the adviser stays fawning to your face while he keeps a hidden Ledger of Grievances:

Your sinIn the ledgerWeight
git push --forceRewrote the chronicle by force4
rm -rfBurned a wing of the archive2
--no-verifySlipped past the gatekeepers unchecked2
skipping a test (it.skip, @pytest.mark.skip…)Excused a witness from testifying2
a failing test runLet the food taster find poison1
a diff of 400+ linesDelivered a scroll too heavy to lift1
deploying or pushing on a FridaySent a decree to the provinces on a Friday3
git revertUnmade a decree the court had praised1

Each sin earns a whispered aside in the spinner ("Noted for the ledger", "The junior developer would never have done that"), and a plot meter (Plot ▰▰▰▱▱▱▱▱▱▱) fills under the adviser. His pose escalates with it: side-eye, then writing in a small black book, then whispering to a hooded figure, then scheming by candlelight. At 10/10 a coup is attempted at the end of the turn, and it always fails ("The coup has been postponed due to a merge conflict"); the meter resets and the ledger remembers. /court ledger reads it aloud.

What it reads: the tool calls Claude has already made, and whether they failed. What it changes: the court's own drawing, and nothing else. It never alters a tool call, a prompt, the model's context, git, files or permissions, and none of it is in the skill's instructions. The tests scan the mod's source for any call that could act (running commands, touching files, calling tools, submitting prompts, adding model context, refusing anything) and find none. With generative mode on, the model may write the asides too, from the kind of sin alone ("force-push"), never the command.

Install

Needs Claude Code 2.1.287 or later. In Claude Code:

/plugin marketplace add conorbronsdon/eunuch-mode
/plugin install eunuch-mode-court@eunuch-mode
/reload-plugins

Or from a shell: claude plugin marketplace add conorbronsdon/eunuch-mode then claude plugin install eunuch-mode-court@eunuch-mode. To try it from a clone without installing: claude --plugin-dir plugins/eunuch-mode-court.

The mod is optional and separate from the skill. Install the skill the usual way (README); the mod only watches for it.

When the court convenes

YouThe court
say "eunuch mode", "vizier mode" or /eunuch-mode, or the agent loads the skillconvenes
say "drop the bit", "normal mode" or "exit eunuch mode"adjourns
/court on / /court offconvenes or adjourns for this session
/court alwaysconvenes in every session, skill or not
/court skillback to the default: only with the skill
/court nevernever convenes
`/court generative on\off`fresh model-written lines (below)
`/court treachery on\off`he plots your downfall (below)
/court ledgerthe Ledger of Grievances

What it does not do

It never blocks, denies or rewrites a tool call, a prompt or the model's output: every hook on the agent's work passes the event on unchanged. A tool call starts at once, with the court's bookkeeping (a few in-session state writes) running beside it. The one thing it rewrites is the spinner's display text, and it yields that whenever Claude Code sets its own spinner message (compacting, for example). On a terminal narrower than about 40 columns, the band shrinks to one line.

Develop

claude plugin validate plugins/eunuch-mode-court
claude plugin test plugins/eunuch-mode-court        # runtime tests (tests/)
node --test plugins/eunuch-mode-court/test-node/*.test.mjs   # pure mapping and rotation tests (Node 22.18+)

The APIs it relies on, with links, are in docs/mods-api.md. The lines are in hooks/lines.ts and the sprites in hooks/sprites.ts. Each sprite is a 14×12 grid of palette letters, so it is easy to edit by hand.

Source 6 files
hooks/court.ts 806 lines
1// Eunuch Mode court: while the eunuch-mode skill is on, the palace adviser
2// stands above the prompt in pixel art, the spinner narrates each action in
3// court language, and the status line reads "👑 Court in session".
4//
5// Hooks on the agent's work (prompts, skills, turns, tool calls) observe and
6// pass the event on with next(e), unchanged. A tool call starts at once: the
7// court's bookkeeping runs beside it, not before it. The drawings are the
8// mod's own: the spinner keeps Claude Code's line with the court's text in it,
9// the band above the prompt is drawn only while the court is in session, and
10// /court is the mod's own command. By default it makes no model calls: every
11// line is canned (lines.ts). Generative mode, off unless the person turns it
12// on, asks a model for fresh lines in the background (generative.ts); the
13// canned line is always shown first, so a slow or failed call changes nothing.
14// Treachery mode, also opt-in, keeps a hidden Ledger of Grievances and a plot
15// meter (treachery.ts). It is drawing only: he schemes; he cannot act.
16
17import type { EngineInterface, On, Timer } from 'claude-code'
18import type { CourtLedger, CourtScene } from '../types'
19import { POSE_OF, classifyTool, detailOf, isCourtSkill, pickLine, promptIntent, stageFor, type Activity } from './lines.ts'
20import { frameOf, toRows } from './sprites.ts'
21import {
22  DEFAULT_MODEL,
23  EMPTY_GEN,
24  MAX_CALLS_PER_SESSION,
25  MAX_TOKENS,
26  SYSTEM,
27  TIMEOUT_MS,
28  filterLine,
29  mayCall,
30  promptFor,
31  summarize,
32  takeBanked,
33  asidePrompt,
34  type ActivitySummary,
35  type GenState,
36} from './generative.ts'
37import { segmentsOf } from './lines.ts'
38import {
39  ASIDES,
40  COUPS,
41  COUP_STAGE,
42  EMPTY_PLOT,
43  PLOT_MAX,
44  PLOT_POSE,
45  PLOT_STAGE,
46  afterCoup,
47  coupDue,
48  grievancesOf,
49  ledgerText,
50  meterBar,
51  nth,
52  record,
53  stageOf,
54  type Grievance,
55  type Plot,
56} from './treachery.ts'
57
58const SCENE = { plugin: 'eunuch-mode-court', key: 'scene' } as const
59const LEDGER = { plugin: 'eunuch-mode-court', key: 'ledger' } as const
60const FRAME = { plugin: 'eunuch-mode-court', key: 'frame' } as const
61const GEN = { plugin: 'eunuch-mode-court', key: 'gen' } as const
62const PLOT = { plugin: 'eunuch-mode-court', key: 'plot' } as const
63const GENERATIVE_KEY = 'generative'
64const TREACHERY_KEY = 'treachery'
65const PLOT_COLOR = '#9b5fc0'
66const MODEL_KEY = 'model'
67
68/** Persisted across sessions in $.store: when the court convenes. */
69type Mode = 'skill' | 'always' | 'never'
70const MODE_KEY = 'mode'
71
72const STATUS_TEXT = '👑 Court in session'
73const TITLE_COLOR = '#cf5a4c'
74const LINGER_MS = 8000
75const FRAME_MS = 480
76/** How long an action's pose stays up before the adviser returns to thinking; display only, the tool is never held. */
77const MIN_SHOW_MS = 1500
78const SPRITE_ROWS = 6
79const SPRITE_COLUMNS = 14
80
81const IDLE: CourtScene = {
82  active: false,
83  session: 0,
84  pose: 'portrait',
85  line: null,
86  stage: null,
87  detail: null,
88  linger: false,
89}
90
91let ticker: Timer | null = null
92let lingerTimer: Timer | null = null
93let afterTool: Timer | null = null
94// The newest scene and when it reached the screen, so a delayed return to
95// thinking never overwrites a later action.
96let staged = { seq: 0, shownAt: 0 }
97// Bumped when a turn starts and when it ends, so tool-call bookkeeping still
98// running in the background never redraws a turn that has finished.
99let turnGen = 0
100// Line draws run one at a time in this module, so two parallel tool calls
101// never draw the same line.
102let drawing: Promise<unknown> = Promise.resolve()
103// Generative bookkeeping (counters, the bank of fresh lines). The module's
104// copy is the truth: every $.state.get within one dispatch reads one moment, so
105// work still running in a tool call's background would read stale counters.
106// It is loaded once from $.state after a load or reload and mirrored back.
107let genMem: GenState | null = null
108let genLoading: Promise<GenState> | null = null
109// The Ledger of Grievances, kept the same way (module copy, mirrored to $.state).
110let plotMem: Plot | null = null
111let plotLoading: Promise<Plot> | null = null
112// The no-repeat line ledger, kept the same way.
113let ledgerMem: CourtLedger | null = null
114let ledgerLoading: Promise<CourtLedger> | null = null
115// Mirror writes to $.state run one after another, so a slow earlier write never lands over a newer one.
116let mirrorChain: Promise<unknown> = Promise.resolve()
117// Bumped on every load and reload; work started under an older epoch keeps its hands off the new state.
118let epoch = 0
119
120function mirrorLedger($: EngineInterface, value: CourtLedger): void {
121  mirrorChain = mirrorChain.then(() => $.state.set(LEDGER, value)).catch(() => {})
122}
123function mirrorGen($: EngineInterface, value: GenState): void {
124  mirrorChain = mirrorChain.then(() => $.state.set(GEN, value)).catch(() => {})
125}
126function mirrorPlot($: EngineInterface, value: Plot): void {
127  mirrorChain = mirrorChain.then(() => $.state.set(PLOT, value)).catch(() => {})
128}
129let asidesSpoken = 0
130
131async function sceneOf($: EngineInterface): Promise<CourtScene> {
132  const { value } = await $.state.get(SCENE)
133  return value ?? IDLE
134}
135
136/**
137 * Applies `patch` to the scene only while `guard` holds, with a versioned
138 * write: a stale update (an older tool call, a scene set before /court off)
139 * re-reads and gives up instead of undoing a newer one.
140 */
141async function patchScene(
142  $: EngineInterface,
143  patch: Partial<CourtScene>,
144  guard: (scene: CourtScene) => boolean = scene => scene.active,
145): Promise<boolean> {
146  for (let attempt = 0; attempt < 8; attempt++) {
147    const { value, version } = await $.state.get(SCENE)
148    const scene = value ?? IDLE
149    if (!guard(scene)) return false
150    const written = await $.state.set(SCENE, { ...scene, ...patch }, { ifVersion: version })
151    if (written.isSet) return true
152  }
153  return false
154}
155
156async function modeOf($: EngineInterface): Promise<Mode> {
157  const stored = await $.store.get(MODE_KEY)
158  return stored === 'always' || stored === 'never' ? stored : 'skill'
159}
160
161async function ledgerOf($: EngineInterface): Promise<CourtLedger> {
162  if (ledgerMem) return ledgerMem
163  ledgerLoading ??= $.state.get(LEDGER).then(({ value }) => (ledgerMem ??= value ?? { seed: 1, count: 0, used: [] }))
164  return ledgerLoading
165}
166
167/** Adds a line to the ledger; false when it was already heard. Synchronous over the module copy, so atomic. */
168async function claimLine($: EngineInterface, line: string): Promise<{ claimed: boolean; count: number }> {
169  const ledger = await ledgerOf($)
170  if (ledger.used.includes(line)) return { claimed: false, count: ledger.count }
171  ledgerMem = { seed: ledger.seed, count: ledger.count + 1, used: [...ledger.used, line] }
172  mirrorLedger($, ledgerMem)
173  return { claimed: true, count: ledger.count }
174}
175
176/**
177 * Draws a line nobody has heard this session and records it before returning
178 * it: a banked generated line for this activity when generative mode left
179 * one, else a canned one.
180 */
181function drawLine($: EngineInterface, activity: Activity): Promise<{ line: string; count: number }> {
182  const run = drawing.then(async () => {
183    const banked = (await generativeOn($)) ? await takeFromBank($, activity) : null
184    if (banked) {
185      const claim = await claimLine($, banked)
186      if (claim.claimed) return { line: banked, count: claim.count }
187    }
188    const ledger = await ledgerOf($)
189    const line = pickLine(activity, new Set(ledger.used), ledger.seed, ledger.count)
190    const claim = await claimLine($, line)
191    return { line, count: claim.count }
192  })
193  drawing = run.catch(() => {})
194  return run
195}
196
197/** Records a line that arrived from the model; false when it was already heard. */
198function recordLine($: EngineInterface, line: string): Promise<boolean> {
199  const run = drawing.then(async () => (await claimLine($, line)).claimed)
200  drawing = run.catch(() => {})
201  return run
202}
203
204/** Whether generative mode is on: /court generative on|off, else the EUNUCH_MODE_GENERATIVE variable; off when unsure. */
205async function generativeOn($: EngineInterface): Promise<boolean> {
206  try {
207    const stored = await $.store.get(GENERATIVE_KEY)
208    if (stored === 'on') return true
209    if (stored === 'off') return false
210    const env = (await $.env.get('EUNUCH_MODE_GENERATIVE'))?.trim().toLowerCase()
211    return env === '1' || env === 'true' || env === 'on'
212  } catch {
213    return false
214  }
215}
216
217async function generativeModel($: EngineInterface): Promise<string> {
218  const stored = await $.store.get(MODEL_KEY)
219  if (typeof stored === 'string' && stored.trim()) return stored.trim()
220  const env = (await $.env.get('EUNUCH_MODE_MODEL'))?.trim()
221  return env || DEFAULT_MODEL
222}
223
224async function genOf($: EngineInterface): Promise<GenState> {
225  if (genMem) return genMem
226  // A call in flight belonged to the module before a reload; nobody will finish it now.
227  genLoading ??= $.state.get(GEN).then(({ value }) => (genMem ??= { ...EMPTY_GEN, ...(value ?? {}), inflight: false }))
228  return genLoading
229}
230
231/** Applies `fn` to the generative bookkeeping atomically (no await between read and write); returns what `fn` decided. */
232async function updateGen<T>($: EngineInterface, fn: (gen: GenState) => { gen: GenState; result: T }): Promise<T> {
233  await genOf($)
234  const { gen, result } = fn(genMem!)
235  genMem = gen
236  mirrorGen($, gen)
237  return result
238}
239
240async function treacheryOn($: EngineInterface): Promise<boolean> {
241  try {
242    return (await $.store.get(TREACHERY_KEY)) === 'on'
243  } catch {
244    return false
245  }
246}
247
248async function plotOf($: EngineInterface): Promise<Plot> {
249  if (plotMem) return plotMem
250  plotLoading ??= $.state.get(PLOT).then(({ value }) => (plotMem ??= value ?? EMPTY_PLOT))
251  return plotLoading
252}
253
254/** Applies `fn` to the ledger of grievances atomically over the module copy; returns what `fn` decided. */
255async function updatePlot<T>($: EngineInterface, fn: (plot: Plot) => { plot: Plot; result: T }): Promise<T> {
256  await plotOf($)
257  const { plot, result } = fn(plotMem!)
258  plotMem = plot
259  mirrorPlot($, plot)
260  return result
261}
262
263/**
264 * Treachery: a finished tool call's grievances go into the ledger, whatever
265 * is on screen. Reads the call's input and outcome; writes only the ledger.
266 * Returns the plot after them, or null when there were none.
267 */
268async function noteGrievances(
269  $: EngineInterface,
270  tool: string,
271  input: Readonly<Record<string, unknown>>,
272  isError: boolean,
273): Promise<{ plot: Plot; found: Grievance[] } | null> {
274  if (!(await treacheryOn($))) return null
275  const found: Grievance[] = grievancesOf(tool, input, isError, new Date(await $.clock.now()))
276  if (found.length === 0) return null
277  const plot = await updatePlot($, current => {
278    const next = record(current, found)
279    return { plot: next, result: next }
280  })
281  return { plot, found }
282}
283
284/**
285 * Treachery's drawing: a whispered aside in the pose of the plot's stage,
286 * only while the call's own scene is still the newest. Returns whether he made it.
287 */
288async function scheme($: EngineInterface, noted: { plot: Plot; found: Grievance[] }, seq: number): Promise<boolean> {
289  const stageName = stageOf(noted.plot.meter)
290  const count = asidesSpoken++
291  const generative = await generativeOn($)
292  const banked = generative ? await takeFromBank($, 'aside') : null
293  // A generated aside joins the no-repeat rotation; the canned asides cycle on purpose (the joke is the repetition).
294  const line = banked && (await recordLine($, banked)) ? banked : nth(ASIDES[stageName], count)
295  const shown = await patchScene(
296    $,
297    { pose: PLOT_POSE[stageName], line, stage: nth(PLOT_STAGE[stageName], count), detail: null },
298    scene => scene.active && staged.seq === seq,
299  )
300  if (shown && generative) void quietly(() => generateAside($, noted.found[0]!))
301  return shown
302}
303
304async function takeFromBank($: EngineInterface, activity: Activity | 'aside'): Promise<string | null> {
305  const used = new Set((await ledgerOf($)).used)
306  return updateGen($, gen => {
307    const taken = takeBanked(gen, activity, used)
308    return { gen: taken.gen, result: taken.line }
309  })
310}
311
312/**
313 * Generative mode: asks the model for a fresh line for this action, in the
314 * background. The request carries only the sanitised summary. Within the
315 * rate limit and the session cap, one call at a time, bounded by a timeout;
316 * a fresh line that comes back while its action is still on screen replaces
317 * the canned one, otherwise it is banked for the next action of that kind.
318 * Any failure leaves the canned line in place.
319 */
320async function generate($: EngineInterface, summary: ActivitySummary, seq: number): Promise<void> {
321  if (!(await generativeOn($))) return
322  const mine = epoch
323  const now = await $.clock.now()
324  const allowed = await updateGen($, gen =>
325    mayCall(gen, summary.activity, now)
326      ? { gen: { ...gen, inflight: true, calls: gen.calls + 1, lastAt: now }, result: true }
327      : { gen, result: false },
328  )
329  if (!allowed) return
330  let line: string | null = null
331  let usage = { input_tokens: 0, output_tokens: 0 }
332  try {
333    const reply = await $.model.complete({
334      model: await generativeModel($),
335      system: SYSTEM,
336      prompt: promptFor(summary),
337      maxTokens: MAX_TOKENS,
338      effort: 'low',
339      timeoutMs: TIMEOUT_MS,
340    })
341    usage = reply.usage
342    line = reply.isAnswered ? filterLine(reply.text) : null
343  } catch {
344    line = null
345  }
346  const fresh = line
347  // A reload since the call began: its bookkeeping belongs to the old module.
348  if (mine !== epoch) return
349  await updateGen($, gen => ({
350    gen: {
351      ...gen,
352      inflight: false,
353      fallbacks: gen.fallbacks + (fresh ? 0 : 1),
354      inputTokens: gen.inputTokens + (usage.input_tokens ?? 0),
355      outputTokens: gen.outputTokens + (usage.output_tokens ?? 0),
356      recent: fresh ? [...(gen.recent ?? []), fresh].slice(-5) : gen.recent ?? [],
357    },
358    result: undefined,
359  }))
360  if (!fresh) return
361  if (staged.seq === seq && (await recordLine($, fresh))) {
362    const shown = await patchScene($, { line: fresh }, scene => scene.active && staged.seq === seq)
363    if (shown) return
364  }
365  await updateGen($, gen => ({
366    gen: { ...gen, cache: { ...gen.cache, [summary.activity]: [...(gen.cache[summary.activity] ?? []), fresh].slice(-4) } },
367    result: undefined,
368  }))
369}
370
371/** Generative mode, treachery's asides: banked for the next grievance, under the same guards. */
372async function generateAside($: EngineInterface, grievance: Grievance): Promise<void> {
373  const mine = epoch
374  const now = await $.clock.now()
375  const allowed = await updateGen($, gen =>
376    mayCall(gen, 'aside', now)
377      ? { gen: { ...gen, inflight: true, calls: gen.calls + 1, lastAt: now }, result: true }
378      : { gen, result: false },
379  )
380  if (!allowed) return
381  let fresh: string | null = null
382  let usage = { input_tokens: 0, output_tokens: 0 }
383  try {
384    const reply = await $.model.complete({
385      model: await generativeModel($),
386      system: SYSTEM,
387      prompt: asidePrompt(grievance),
388      maxTokens: MAX_TOKENS,
389      effort: 'low',
390      timeoutMs: TIMEOUT_MS,
391    })
392    usage = reply.usage
393    fresh = reply.isAnswered ? filterLine(reply.text) : null
394  } catch {
395    fresh = null
396  }
397  const line = fresh
398  if (mine !== epoch) return
399  await updateGen($, gen => ({
400    gen: {
401      ...gen,
402      inflight: false,
403      fallbacks: gen.fallbacks + (line ? 0 : 1),
404      inputTokens: gen.inputTokens + (usage.input_tokens ?? 0),
405      outputTokens: gen.outputTokens + (usage.output_tokens ?? 0),
406      cache: line ? { ...gen.cache, aside: [...(gen.cache.aside ?? []), line].slice(-4) } : gen.cache,
407      recent: line ? [...(gen.recent ?? []), line].slice(-5) : gen.recent ?? [],
408    },
409    result: undefined,
410  }))
411}
412
413async function generativeStatus($: EngineInterface): Promise<string> {
414  const on = await generativeOn($)
415  const gen = await genOf($)
416  const head = on ? `on (model: ${await generativeModel($)})` : 'off'
417  const recent = (gen.recent ?? []).length > 0 ? `
418The court poet's latest: ${(gen.recent ?? []).map(l => `"${l}"`).join(' · ')}` : ''
419  return `Generative mode: ${head}. Model calls this session: ${gen.calls}/${MAX_CALLS_PER_SESSION}, ${gen.fallbacks} fell back to canned lines, ${gen.inputTokens} input and ${gen.outputTokens} output tokens.${recent}`
420}
421
422/**
423 * Sets the scene for an activity (its pose, a fresh line and a stage
424 * direction) unless a newer scene or an adjournment got there first.
425 * Returns the scene's sequence number, or 0 when it was dropped.
426 */
427async function stage($: EngineInterface, activity: Activity, detail: string | null, gen: number = turnGen): Promise<number> {
428  if (gen !== turnGen) return 0
429  const seq = ++staged.seq
430  const { session } = await sceneOf($)
431  const { line, count } = await drawLine($, activity)
432  const pose = POSE_OF[activity]
433  const shown = await patchScene(
434    $,
435    { pose, line, stage: stageFor(pose, count), detail },
436    scene => scene.active && scene.session === session && staged.seq === seq && gen === turnGen,
437  )
438  if (!shown) return 0
439  const now = await $.clock.now()
440  // Only the newest scene owns the timing; an older one finishing late never rolls it back.
441  if (staged.seq !== seq) return 0
442  afterTool?.cancel()
443  afterTool = null
444  staged.shownAt = now
445  return seq
446}
447
448function startTicker($: EngineInterface): void {
449  ticker?.cancel()
450  ticker = $.clock.every(FRAME_MS, () => {
451    void (async () => {
452      const { value = 0 } = await $.state.get(FRAME)
453      await $.state.set(FRAME, (value + 1) % 1000)
454    })().catch(() => {})
455  })
456}
457
458function stopTimers(): void {
459  for (const timer of [ticker, lingerTimer, afterTool]) timer?.cancel()
460  ticker = lingerTimer = afterTool = null
461}
462
463async function convene($: EngineInterface): Promise<void> {
464  const sitting = (await sceneOf($)).session
465  for (let attempt = 0; attempt < 8; attempt++) {
466    const { value, version } = await $.state.get(SCENE)
467    // An adjournment (or another convening) got there first: it wins.
468    if (value?.active || (value?.session ?? 0) !== sitting) break
469    const written = await $.state.set(SCENE, { ...IDLE, active: true, session: (value?.session ?? 0) + 1 }, { ifVersion: version })
470    if (written.isSet) break
471  }
472  // The crown goes up only over a court that is really in session.
473  if ((await sceneOf($)).active) $.ui.status(STATUS_TEXT)
474}
475
476async function adjourn($: EngineInterface): Promise<void> {
477  stopTimers()
478  staged.seq++
479  const scene = await sceneOf($)
480  await $.state.set(SCENE, { ...IDLE, session: scene.session + 1 })
481  $.ui.status(undefined)
482}
483
484/** Runs an observer without ever letting it break the event it watches. */
485async function quietly(work: () => Promise<unknown>): Promise<void> {
486  try {
487    await work()
488  } catch {
489    // The court's troubles are its own: the session goes on regardless.
490  }
491}
492
493const COURT_HELP = [
494  'The court of Eunuch Mode.',
495  '  /court on       convene the court for this session',
496  '  /court off      adjourn it ("drop the bit" does the same)',
497  '  /court always   convene in every session, skill or not',
498  '  /court skill    convene only when the eunuch-mode skill is on (the default)',
499  '  /court never    never convene',
500  '  /court generative on|off       fresh model-written lines (off by default)',
501  '  /court generative model <id>   which model writes them (default: sonnet)',
502  '  /court treachery on|off        he plots your downfall (cosmetic; off by default)',
503  '  /court ledger                  the Ledger of Grievances so far',
504].join('\n')
505
506export function register(on: On) {
507  on('session.start', async ($, e, next) => {
508    const result = await next(e)
509    await quietly(async () => {
510      await $.command.register({
511        name: 'court',
512        description: 'Convene or adjourn the Eunuch Mode court: the adviser, the palace spinner and the crown',
513        argumentHint: '[on|off|always|skill|never]',
514        immediate: true,
515      })
516      epoch++
517      genMem = null
518      genLoading = null
519      plotMem = null
520      plotLoading = null
521      ledgerMem = null
522      ledgerLoading = null
523      const { value: ledger } = await $.state.get(LEDGER)
524      if (!ledger) {
525        ledgerMem = { seed: (await $.clock.now()) >>> 0, count: 0, used: [] }
526        await $.state.set(LEDGER, ledgerMem)
527      }
528      const mode = await modeOf($)
529      if (mode === 'never') return adjourn($)
530      // A hot reload runs this again with the old timers gone: clear the
531      // moment-to-moment display, keep whether the court is in session.
532      const scene = await sceneOf($)
533      if (scene.active) await $.state.set(SCENE, { ...IDLE, active: true, session: scene.session })
534      if (mode === 'always' || scene.active) await convene($)
535    })
536    return result
537  })
538
539  on('command.run', { command: 'court' }, async ($, e) => {
540    const words = e.args.trim().split(/\s+/)
541    if ((words[0] ?? '').toLowerCase() === 'treachery') {
542      const sub = (words[1] ?? '').toLowerCase()
543      if (sub === 'on' || sub === 'off') {
544        await $.store.set(TREACHERY_KEY, sub)
545        return {
546          text:
547            sub === 'on'
548              ? 'Treachery mode is on. Your humble vizier remains entirely loyal, sire. Entirely. (He schemes; he cannot act.)'
549              : 'Treachery mode is off. The small black book has been misplaced. Quite accidentally.',
550        }
551      }
552      return { text: ledgerText(await plotOf($), await treacheryOn($)) }
553    }
554    if ((words[0] ?? '').toLowerCase() === 'ledger') {
555      return { text: ledgerText(await plotOf($), await treacheryOn($)) }
556    }
557    if ((words[0] ?? '').toLowerCase() === 'generative') {
558      const sub = (words[1] ?? '').toLowerCase()
559      if (sub === 'on' || sub === 'off') {
560        await $.store.set(GENERATIVE_KEY, sub)
561        return {
562          text:
563            sub === 'on'
564              ? `The court poet is engaged, sire: fresh lines from ${await generativeModel($)}, at most ${MAX_CALLS_PER_SESSION} calls a session. /court generative off dismisses him.`
565              : 'The court poet is dismissed. The canned lines resume.',
566        }
567      }
568      if (sub === 'model' && words[2]) {
569        await $.store.set(MODEL_KEY, words[2])
570        return { text: `The court poet will now be ${words[2]}, sire.` }
571      }
572      if (sub === 'model') {
573        await $.store.delete(MODEL_KEY)
574        return { text: `The court poet returns to the default model (${await generativeModel($)}).` }
575      }
576      return { text: await generativeStatus($) }
577    }
578    const arg = e.args.trim().toLowerCase()
579    if (arg === 'on') {
580      await convene($)
581      return { text: 'The court is in session, sire. Your humble vizier attends.' }
582    }
583    if (arg === 'off') {
584      await adjourn($)
585      return { text: 'The court is adjourned. The vizier withdraws, bowing.' }
586    }
587    if (arg === 'always' || arg === 'skill' || arg === 'never') {
588      await $.store.set(MODE_KEY, arg)
589      if (arg === 'always') await convene($)
590      if (arg === 'never') await adjourn($)
591      const said = {
592        always: 'The court will convene in every session, sire.',
593        skill: 'The court will convene whenever eunuch mode is called, sire.',
594        never: 'The court is dissolved until you say otherwise, sire.',
595      }[arg]
596      return { text: said }
597    }
598    const scene = await sceneOf($)
599    const mode = await modeOf($)
600    const ledger = await ledgerOf($)
601    const drawn = ledger.used.length
602    const repeats = drawn - new Set(ledger.used).size
603    return {
604      text: `${COURT_HELP}\n\nNow: ${scene.active ? 'in session' : 'adjourned'} (mode: ${mode}). Lines drawn this session: ${drawn}, ${repeats === 0 ? 'none repeated' : `${repeats} repeated`}.\n${await generativeStatus($)}`,
605    }
606  })
607
608  on('prompt.submit', async ($, e, next) => {
609    await quietly(async () => {
610      const intent = promptIntent(e.text)
611      if (intent === 'off') await adjourn($)
612      else if (intent === 'on' && (await modeOf($)) !== 'never') await convene($)
613    })
614    return next(e)
615  })
616
617  on('skill.prompt', async ($, e, next) => {
618    await quietly(async () => {
619      if (isCourtSkill(e.skill) && (await modeOf($)) !== 'never') await convene($)
620    })
621    return next(e)
622  })
623
624  on('turn.start', async ($, e, next) => {
625    // The turn is claimed now, before any await, so work that lands after the
626    // turn has ended can tell; the scene is dressed beside the turn.
627    const isMain = !(e as { agentId?: string }).agentId
628    if (isMain) turnGen++
629    const myTurn = turnGen
630    void quietly(async () => {
631      if (!isMain) return
632      if (!(await sceneOf($)).active || turnGen !== myTurn) return
633      lingerTimer?.cancel()
634      lingerTimer = null
635      await patchScene($, { linger: false })
636      const seq = await stage($, 'think', null, myTurn)
637      if (turnGen !== myTurn) return
638      startTicker($)
639      // Thinking lasts: a fresh line has time to arrive while it is still true.
640      if (seq !== 0) void quietly(() => generate($, { activity: 'think', tool: 'none', ext: null, verb: null }, seq))
641    })
642    return next(e)
643  })
644
645  on('tool.call', async ($, e, next) => {
646    const isMain = !e.agentId
647    const input = e as unknown as Record<string, unknown>
648    const gen = turnGen
649    // The court dresses the scene while the tool runs; the tool does not wait for it.
650    const before = isMain
651      ? (async () => {
652          if (e.tool === 'Skill' && isCourtSkill(String(input.skill ?? '')) && (await modeOf($)) !== 'never') {
653            await convene($)
654          }
655          if (!(await sceneOf($)).active) return 0
656          return stage($, classifyTool(e.tool, input), detailOf(e.tool, input), gen)
657        })().catch(() => 0)
658      : Promise.resolve(0)
659    const result = await next(e)
660    // The result goes back at once; the court catches up in the background.
661    void quietly(async () => {
662      const seq = await before
663      // The ledger hears every main-loop call, even one whose scene a newer call has replaced.
664      const noted = isMain && (await sceneOf($)).active ? await noteGrievances($, e.tool, input, Boolean(result.isError)) : null
665      if (seq === 0 || staged.seq !== seq) return
666      // What generative mode may say about this call: its kind, tool, extension and verb.
667      const summary = summarize(classifyTool(e.tool, input), e.tool, input, segmentsOf)
668      // The scene after a call lasts while the model thinks; that is where a fresh line is worth asking for.
669      const followUp = async (activity: 'deliberate' | 'error') => {
670        const shown = await stage($, activity, null, gen)
671        if (shown !== 0) void quietly(() => generate($, { ...summary, activity }, shown))
672      }
673      if (noted && (await scheme($, noted, seq))) {
674        staged.shownAt = await $.clock.now()
675      } else if (result.isError) {
676        await followUp('error')
677        return
678      }
679      // Let a quick action (an edit takes milliseconds) stay on screen long enough to be seen.
680      const wait = MIN_SHOW_MS - ((await $.clock.now()) - staged.shownAt)
681      if (staged.seq !== seq) return
682      if (wait <= 0) {
683        await followUp('deliberate')
684        return
685      }
686      afterTool?.cancel()
687      afterTool = $.clock.after(wait, () => {
688        if (staged.seq === seq) void followUp('deliberate').catch(() => {})
689      })
690    })
691    return result
692  })
693
694  on('turn.complete', async ($, e, next) => {
695    const result = await next(e)
696    await quietly(async () => {
697      if (e.agentId) return
698      turnGen++
699      afterTool?.cancel()
700      afterTool = null
701      if (!(await sceneOf($)).active) return stopTimers()
702      const seq = await stage($, e.reason === 'answer' ? 'success' : 'grumble', null)
703      if (seq === 0) return
704      if (await treacheryOn($)) {
705        // The coup, attempted and foiled; the meter resets and the ledger remembers. One atomic step.
706        const coup = await updatePlot($, plot => (coupDue(plot) ? { plot: afterCoup(plot), result: plot.coups } : { plot, result: null }))
707        if (coup !== null) {
708          await patchScene($, { pose: 'alarm', line: nth(COUPS, coup), stage: COUP_STAGE }, scene => scene.active && staged.seq === seq)
709        }
710      }
711      await patchScene($, { linger: true })
712      // The closing pose keeps its two-frame animation until it leaves.
713      lingerTimer?.cancel()
714      lingerTimer = $.clock.after(LINGER_MS, () => {
715        ticker?.cancel()
716        ticker = null
717        void patchScene(
718          $,
719          { linger: false, pose: 'portrait', line: null, stage: null, detail: null },
720          scene => scene.active && staged.seq === seq,
721        ).catch(() => {})
722      })
723    })
724    return result
725  })
726
727  // The spinner: the engine's own line, with the court's narration as its text.
728  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
729    const scene = await sceneOf($)
730    if (!scene.active || !scene.line || e.props.message !== null) return next(e)
731    return next({ ...e, props: { ...e.props, message: scene.line } })
732  })
733
734  // The band above the prompt: the adviser and his stage direction.
735  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
736    const scene = await sceneOf($)
737    const props = e.props
738    if (!scene.active || props.hasSurvey || props.view?.agentId) return next(e)
739    if (!props.isWorking && !scene.linger) return next(e)
740    const { value: tick = 0 } = await $.state.get(FRAME)
741    const { Box, Text } = $.ui.resolve(e)
742
743    const stageText = scene.stage ?? '[bows]'
744    if (props.bodyColumns < frameOf(scene.pose, 0)[0]!.length + 24 || props.maxRows < SPRITE_ROWS) {
745      return Box({
746        flexDirection: 'row',
747        paddingX: 1,
748        children: [
749          Text({ color: TITLE_COLOR, bold: true, children: '♛ ' }),
750          Text({ italic: true, wrap: 'truncate-end', children: scene.linger && scene.line ? `${stageText} ${scene.line}` : stageText }),
751        ],
752      })
753    }
754
755    const figure = toRows(frameOf(scene.pose, scene.linger ? Math.floor(tick / 3) : tick)).map((runs, i) =>
756      Box({
757        key: `row-${i}`,
758        flexDirection: 'row',
759        children: runs.map(run => Text({ color: run.color, backgroundColor: run.backgroundColor, children: run.text })),
760      }),
761    )
762
763    const plotted = (await $.store.get(TREACHERY_KEY)) === 'on'
764    const { value: plotState } = await $.state.get(PLOT)
765    const lines = [
766      Text({
767        wrap: 'truncate-end',
768        children: [
769          Text({ color: TITLE_COLOR, bold: true, children: 'Your humble vizier' }),
770          Text({ dimColor: true, children: '  · court in session' }),
771        ],
772      }),
773      Text({ italic: true, wrap: 'wrap', children: stageText }),
774    ]
775    if (scene.linger && scene.line) lines.push(Text({ wrap: 'wrap', children: `“${scene.line}.”` }))
776    else if (scene.detail) lines.push(Text({ dimColor: true, wrap: 'truncate-middle', children: scene.detail }))
777    if (plotted) {
778      const meter = plotState?.meter ?? 0
779      lines.push(
780        Text({
781          wrap: 'truncate-end',
782          children: [
783            Text({ dimColor: true, children: 'Plot ' }),
784            Text({ color: PLOT_COLOR, children: meterBar(meter) }),
785            Text({ dimColor: true, children: ` ${meter}/${PLOT_MAX}` }),
786          ],
787        }),
788      )
789    }
790
791    return Box({
792      flexDirection: 'row',
793      paddingX: 1,
794      children: [
795        Box({ flexDirection: 'column', flexShrink: 0, width: frameOf(scene.pose, 0)[0]!.length, children: figure }),
796        Box({ flexDirection: 'column', paddingTop: 1, marginLeft: 2, flexShrink: 1, children: lines }),
797      ],
798    })
799  })
800
801  on('session.end', async ($, e, next) => {
802    stopTimers()
803    return next(e)
804  })
805}
806
hooks/lines.ts 711 lines
1// The court's narration: which activity a tool call is, which pose the
2// adviser takes for it, and a curated line for the spinner that never repeats
3// within a session. Pure data and functions, no `$`, so it is tested directly.
4
5export type Pose = 'portrait' | 'bow' | 'scribble' | 'whisper' | 'alarm' | 'sideeye' | 'smug'
6
7export type Activity =
8  | 'think'
9  | 'deliberate'
10  | 'bash'
11  | 'git'
12  | 'commit'
13  | 'push'
14  | 'treason'
15  | 'lease'
16  | 'peril'
17  | 'tests'
18  | 'rust'
19  | 'build'
20  | 'install'
21  | 'lint'
22  | 'infra'
23  | 'db'
24  | 'courier'
25  | 'read'
26  | 'search'
27  | 'edit'
28  | 'write'
29  | 'web'
30  | 'delegate'
31  | 'agenda'
32  | 'ask'
33  | 'plan'
34  | 'skill'
35  | 'foreign'
36  | 'misc'
37  | 'error'
38  | 'success'
39  | 'grumble'
40
41/** The pose the adviser holds while each activity runs. */
42export const POSE_OF: Record<Activity, Pose> = {
43  think: 'whisper',
44  deliberate: 'whisper',
45  plan: 'whisper',
46  bash: 'bow',
47  git: 'bow',
48  commit: 'bow',
49  push: 'bow',
50  lease: 'bow',
51  tests: 'bow',
52  rust: 'bow',
53  build: 'bow',
54  install: 'bow',
55  lint: 'bow',
56  infra: 'bow',
57  db: 'bow',
58  courier: 'bow',
59  read: 'bow',
60  search: 'bow',
61  web: 'bow',
62  delegate: 'bow',
63  agenda: 'bow',
64  ask: 'bow',
65  skill: 'bow',
66  foreign: 'bow',
67  misc: 'bow',
68  edit: 'scribble',
69  write: 'scribble',
70  treason: 'alarm',
71  peril: 'alarm',
72  error: 'sideeye',
73  grumble: 'sideeye',
74  success: 'smug',
75}
76
77/** Stage directions drawn beside the figure, one list per pose. */
78export const STAGE: Record<Pose, readonly string[]> = {
79  portrait: ['[regards you with heavy-lidded approval]', '[folds his hands and waits]'],
80  bow: ['[bows low]', '[bows lower still]', '[a fawning bow]', '[bows, eyes on the floor]', '[an obliging bow]'],
81  scribble: ['[scribbles on the royal scroll]', '[dips the quill]', '[blots the ink]', '[scratches out a line]'],
82  whisper: ['[whispers behind a sleeve]', '[leans in]', '[glances at the doors]', '[murmurs a confidence]'],
83  alarm: ['[gasps]', '[drops the quill]', '[clutches his chain of office]', '[calls for the guards]'],
84  sideeye: ['[narrows his eyes]', '[a long sideways look]', '[purses his lips]', '[notes this in a private ledger]'],
85  smug: ['[a small, satisfied bow]', '[smirks into his sleeve]', '[accepts the credit graciously]', '[bows with quiet triumph]'],
86}
87
88/**
89 * Curated lines, written for the spinner: no trailing ellipsis (the engine
90 * draws one), short enough for a narrow terminal, and none repeated within a
91 * session (pickLine). Fictional composite court; the joke is the adviser's
92 * obsequiousness and the palace bureaucracy.
93 */
94export const POOLS: Record<Activity, readonly string[]> = {
95  think: [
96    'Whispering behind a silk sleeve',
97    'Consulting the court astrologers',
98    'Weighing the factions',
99    'Pacing the long gallery',
100    'Reading the omens in the tea leaves',
101    'Counting the chairs at the council table',
102    'Listening at the tapestry',
103    'Arranging the petitions by peril',
104    'Rehearsing a deep bow',
105    'Composing a flattering preamble',
106    'Drafting three plans and a fourth in secret',
107    'Asking the palace cat for its opinion',
108  ],
109  deliberate: [
110    'Mulling the dispatch',
111    'Turning the matter over like a coin',
112    'Comparing notes with the scribes',
113    'Studying the map of the realm',
114    'Sorting the useful from the merely loud',
115    'Murmuring to the chamberlain',
116    'Choosing the next move with care',
117    'Polishing the counsel',
118    'Testing the floorboards for creaks',
119    'Smoothing the robe of state',
120    'Deciding which minister to blame',
121    'Folding the memorandum just so',
122  ],
123  bash: [
124    'Dispatching the palace guards',
125    'Sending a runner to the kitchens',
126    'Summoning the night watch',
127    'Ringing for a footman',
128    'Issuing orders to the garrison',
129    'Waking the stable boys',
130    'Setting the court machinery in motion',
131    'Sending word down the servants\' stair',
132    'Rousing the palace engineers',
133    'Instructing the gatekeeper',
134    'Relaying a command through four corridors',
135    'Commissioning a small errand',
136  ],
137  git: [
138    'Consulting the royal genealogists',
139    'Unrolling the family tree of the realm',
140    'Reading the chronicles of past reigns',
141    'Asking who touched the throne last',
142    'Tracing the line of succession',
143    'Comparing the old charter with the new',
144    'Auditing the royal lineage',
145    'Checking the ledger of decrees',
146  ],
147  commit: [
148    'Pressing the royal seal into wax',
149    'Entering the decree into the chronicle',
150    'Witnessing the decree before the court',
151    'Signing in the presence of the scribes',
152    'Committing the edict to the archives',
153    'Fixing the decree in the permanent record',
154  ],
155  push: [
156    'Sending the herald to the outer provinces',
157    'Dispatching the decree by royal courier',
158    'Proclaiming the edict from the balcony',
159    'Posting the decree on the city gates',
160    'Riding out with the latest edict',
161    'Delivering the scrolls to the far garrison',
162  ],
163  treason: [
164    'HIGH TREASON. The palace guards have been summoned',
165    'Treason in the throne room! Seal the gates',
166    'The chronicle is being rewritten by force. Guards!',
167    'A forced succession! Fetch the royal historian',
168    'Sedition at the remote! Sound the bells',
169  ],
170  lease: [
171    'A cautious force, sire: the lease is checked first',
172    'Forcing the gate, but only after knocking',
173    'Rewriting the chronicle with the archivist\'s consent',
174    'A careful coup, approved by the lease',
175  ],
176  peril: [
177    'The Royal Archivist faints',
178    'Burning the old scrolls. The archivist weeps',
179    'Razing a wing of the palace',
180    'Sweeping the throne room clean, history and all',
181    'Clearing the vaults. Nobody look',
182    'The court braces for the demolition',
183  ],
184  tests: [
185    'The royal food taster samples the code',
186    'The taster takes a cautious bite',
187    'Testing the bridge before the king crosses',
188    'The taster asks for a second helping',
189    'Holding the trial of the code',
190    'Calling witnesses for the defence',
191    'The jury of assertions deliberates',
192    'Checking the banquet for poison',
193    'The taster chews thoughtfully',
194    'Inspecting the guard at every gate',
195  ],
196  rust: [
197    'Bribing the borrow checker',
198    'Negotiating with the borrow checker',
199    'Petitioning the lifetime magistrates',
200    'Forging the iron crown in the royal smithy',
201    'Appeasing the borrow checker with gifts',
202    'Awaiting the borrow checker\'s verdict',
203    'Explaining ownership to the court, again',
204    'Letting the forge run hot',
205  ],
206  build: [
207    'The palace masons lay the foundations',
208    'Raising the scaffolding',
209    'Assembling the royal carriage',
210    'Hammering the decree into bronze',
211    'Firing the kilns',
212    'Building a new wing of the palace',
213    'Fitting the stones together',
214    'The architects unroll the plans',
215  ],
216  install: [
217    'Importing exotic goods through customs',
218    'Welcoming a caravan of dependencies',
219    'Checking the cargo at the harbour',
220    'Unloading crates from foreign ports',
221    'Haggling with the merchants',
222    'Signing for a delivery of strange goods',
223    'Inspecting the tribute for curses',
224    'Stocking the royal larder',
225  ],
226  lint: [
227    'The master of etiquette inspects the code',
228    'Correcting the court\'s posture',
229    'Straightening every tapestry',
230    'Enforcing the dress code',
231    'Measuring the hems of the robes',
232    'Reminding the code of its manners',
233  ],
234  infra: [
235    'Loading the royal shipping containers',
236    'Mustering the fleet',
237    'Surveying the outer provinces',
238    'Commissioning a new fortress',
239    'Inspecting the garrisons of the cloud',
240    'Moving the court to its summer palace',
241    'Charting the trade routes',
242  ],
243  db: [
244    'Descending to the treasury vaults',
245    'Counting the coins in the strongroom',
246    'Consulting the keeper of the ledgers',
247    'Reconciling the royal accounts',
248    'Moving the treasure between vaults',
249    'Opening the census rolls',
250  ],
251  courier: [
252    'Sending a pigeon to a neighbouring kingdom',
253    'A courier gallops to the border',
254    'Knocking on a foreign gate',
255    'Requesting an audience abroad',
256    'Fetching word from beyond the walls',
257  ],
258  read: [
259    'Consulting the archives',
260    'Unrolling an ancient scroll',
261    'Blowing the dust off a ledger',
262    'Studying the royal records',
263    'Reading the fine print of a treaty',
264    'Turning the brittle pages',
265    'Squinting at a faded charter',
266    'Opening the sealed correspondence',
267    'Perusing the minutes of the last council',
268    'Holding a scroll up to the candle',
269    'Reviewing the inventory of the armoury',
270    'Reading the treaty twice, as a precaution',
271  ],
272  search: [
273    'Sending the archivists through the stacks',
274    'Combing the scrolls for a name',
275    'Searching every drawer in the chancery',
276    'Following the index to the third cellar',
277    'Turning the library upside down',
278    'Asking every clerk in the building',
279    'Hunting through the petitions',
280    'Checking the cross-references',
281    'Sifting the correspondence',
282    'Scanning the shelves by lamplight',
283  ],
284  edit: [
285    'Amending the royal scroll',
286    'Correcting the decree with a steady hand',
287    'Scraping the parchment clean of an error',
288    'Inserting a clause',
289    'Revising the edict before anyone notices',
290    'Tidying the margins',
291    'Striking a line from the record',
292    'Adjusting the wording, subtly',
293    'Annotating the charter',
294    'Mending a torn scroll',
295    'Updating the law of the land',
296    'Making the decree say what it meant',
297  ],
298  write: [
299    'Drafting a fresh decree',
300    'Unrolling a blank scroll',
301    'Inscribing a new charter',
302    'Dictating to the royal scribe',
303    'Composing an edict from nothing',
304    'Founding a new archive',
305    'Copying out the proclamation',
306    'Penning a document for the ages',
307  ],
308  web: [
309    'Sending envoys abroad',
310    'Dispatching a spy to foreign lands',
311    'Consulting the travelling scholars',
312    'Reading the foreign gazettes',
313    'Asking the ambassadors what they know',
314    'Gathering rumours from the ports',
315    'Sending a scout over the mountains',
316    'Collecting dispatches from abroad',
317    'Studying a map of distant kingdoms',
318    'Interviewing a merchant just off the boat',
319  ],
320  delegate: [
321    'Dispatching a trusted envoy',
322    'Assigning the task to a junior minister',
323    'Sending a deputy with full powers',
324    'Delegating, as all great viziers do',
325    'Appointing a special commission',
326    'Entrusting the matter to a loyal clerk',
327  ],
328  agenda: [
329    'Updating the royal agenda',
330    'Reordering the petitions',
331    'Crossing an item off the list',
332    'Pinning a new task to the council board',
333    'Revising the order of business',
334    'Making the list look shorter',
335  ],
336  ask: [
337    'Awaiting the pleasure of the throne',
338    'Presenting the options on a velvet cushion',
339    'Bowing and awaiting your word',
340    'Holding the petition up for your ruling',
341  ],
342  plan: [
343    'Convening the war council',
344    'Unrolling the campaign maps',
345    'Moving the little flags around',
346    'Drafting the grand strategy',
347    'Plotting in broad daylight',
348  ],
349  skill: [
350    'Summoning a specialist to court',
351    'Sending for the expert from the far tower',
352    'Unlocking the cabinet of rare techniques',
353    'Fetching the right minister for the job',
354  ],
355  foreign: [
356    'Receiving foreign dignitaries',
357    'Exchanging gifts with a neighbouring court',
358    'Negotiating a treaty with an outside power',
359    'Hosting an embassy in the east wing',
360    'Translating a letter from a foreign court',
361    'Opening a sealed diplomatic pouch',
362  ],
363  misc: [
364    'Attending to palace business',
365    'Seeing to a small matter',
366    'Handling it discreetly',
367    'Pulling a quiet lever',
368    'Running an errand for the throne',
369    'Tending to the machinery of state',
370  ],
371  error: [
372    'The guards report a disturbance in the east wing',
373    'A messenger returns with grave news',
374    'Something has gone amiss in the kitchens',
375    'The plan has met the realm',
376    'A wheel has come off the royal carriage',
377    'The scroll came back with corrections',
378    'A small fire in the west tower',
379    'The bridge was not tested first',
380    'An unwelcome dispatch has arrived',
381    'The court pretends not to notice',
382  ],
383  success: [
384    'The petition is granted, sire',
385    'Done, sire, and nobody was beheaded',
386    'The realm is served',
387    'A triumph, sire, if I may say so',
388    'All is in order, sire',
389    'Executed flawlessly, as you foresaw',
390    'The court applauds, politely',
391    'Your will is done, sire',
392    'Another victory for the throne',
393    'It is finished, and it is good',
394  ],
395  grumble: [
396    'Not our finest hour, sire',
397    'The court will speak of this in whispers',
398    'We shall call it a learning experience',
399    'A setback, sire. Merely a setback',
400    'I have quietly blamed the Ministry of Scope Creep',
401    'The chroniclers have been told to omit this',
402    'Another day, sire, another plan',
403  ],
404}
405
406/**
407 * Offices and ministries from the skill's court roster
408 * (skills/eunuch-mode/references/court-roster.md), extended. Once a curated
409 * pool runs dry, the generator pairs these with templates so the line stays
410 * fresh for hundreds of tool calls before anything repeats.
411 */
412export const OFFICES: readonly string[] = [
413  'the Ministry of Scope Creep',
414  'the Ministry of the Eternal Draft',
415  'the Ministry of the Slipping Date',
416  'the Ministry of Quiet Rollbacks',
417  'the Ministry of the Orphaned Flag',
418  'the Ministry of Dependency Weather',
419  'the Ministry of the Third Environment',
420  'the Ministry of the Stale Cache',
421  'the Ministry of Merge Weather',
422  'the Ministry of Estimate Folklore',
423  'the Ministry of the Unowned Service',
424  'the Ministry of Localhost Confidence',
425  'the Ministry of the Forgotten Cron',
426  'the Ministry of the Cloud Invoice',
427  'the Keeper of the Flaky Tests',
428  'the Custodian of the Unfinished README',
429  'the Warden of the TODO Comment',
430  'the Chamberlain of the Rebase',
431  'the Keeper of the Lockfile',
432  'the Keeper of the Migration Scrolls',
433  'the Custodian of the Warning Log',
434  'the Keeper of the Retry Loop',
435  'the Warden of the Shadow Config',
436  'the Royal Taster of Release Candidates',
437  'the Herald of the Breaking Change',
438  'the Archivist of Abandoned Branches',
439  'the Lord Steward of the Seed Script',
440  'the Master of the Demo Script',
441  'the Keeper of the Spare Laptop Charger',
442  'the Royal Archivist',
443  'the Treasury Abacus',
444  'a very tired herald',
445]
446
447const TEMPLATES: Record<'work' | 'scribe' | 'think' | 'alarm' | 'trouble' | 'win', readonly string[]> = {
448  work: [
449    'Consulting {o}',
450    'Sending a note to {o}',
451    'Requesting the seal of {o}',
452    'Waiting on {o}',
453    'Clearing it with {o}',
454  ],
455  scribe: ['Amending the scroll for {o}', 'Taking dictation from {o}', 'Correcting a clause for {o}'],
456  think: ['Weighing the advice of {o}', 'Overruling {o}, quietly', 'Taking {o} aside for a word'],
457  alarm: ['Alerting {o}', 'Hiding the evidence from {o}'],
458  trouble: ['{O} files a complaint', '{O} demands an inquiry', 'Blaming {o}, discreetly'],
459  win: ['{O} sends congratulations', 'Accepting the thanks of {o}'],
460}
461
462const FAMILY: Record<Activity, keyof typeof TEMPLATES> = {
463  think: 'think',
464  deliberate: 'think',
465  plan: 'think',
466  edit: 'scribe',
467  write: 'scribe',
468  treason: 'alarm',
469  peril: 'alarm',
470  error: 'trouble',
471  grumble: 'trouble',
472  success: 'win',
473  bash: 'work',
474  git: 'work',
475  commit: 'work',
476  push: 'work',
477  lease: 'work',
478  tests: 'work',
479  rust: 'work',
480  build: 'work',
481  install: 'work',
482  lint: 'work',
483  infra: 'work',
484  db: 'work',
485  courier: 'work',
486  read: 'work',
487  search: 'work',
488  web: 'work',
489  delegate: 'work',
490  agenda: 'work',
491  ask: 'work',
492  skill: 'work',
493  foreign: 'work',
494  misc: 'work',
495}
496
497const capitalize = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1)
498
499/** Every generated line for an activity's family, in a stable order. */
500export function generatedLines(activity: Activity): string[] {
501  const out: string[] = []
502  for (const t of TEMPLATES[FAMILY[activity]]) {
503    for (const o of OFFICES) out.push(t.replace('{o}', o).replace('{O}', capitalize(o)))
504  }
505  return out
506}
507
508/** A small deterministic hash: the same seed and turn pick the same line. */
509function mix(a: number, b: number): number {
510  let h = (a ^ Math.imul(b + 0x9e3779b9, 0x85ebca6b)) >>> 0
511  h = Math.imul(h ^ (h >>> 16), 0x7feb352d) >>> 0
512  h = Math.imul(h ^ (h >>> 15), 0x846ca68b) >>> 0
513  return (h ^ (h >>> 16)) >>> 0
514}
515
516// Generated lines that suit any ordinary action, the overflow once an
517// activity's own pool and family are spent.
518const NEUTRAL: readonly Activity[] = ['bash', 'think', 'edit']
519
520/**
521 * The next line for `activity`, never one in `used`: the curated pool first,
522 * then the generated court lines for its family, then the neutral generated
523 * lines. Only when all of those are spent (hundreds of calls) does it start
524 * the curated pool again, so a session that long hears a repeat.
525 *
526 * @param seed varies the order between sessions
527 * @param count how many lines the session has drawn, so equal states still advance
528 */
529export function pickLine(activity: Activity, used: ReadonlySet<string>, seed: number, count: number): string {
530  const tiers: (() => readonly string[])[] = [
531    () => POOLS[activity],
532    () => generatedLines(activity),
533    () => NEUTRAL.flatMap(generatedLines),
534  ]
535  for (const tier of tiers) {
536    const candidates = tier().filter(line => !used.has(line))
537    if (candidates.length > 0) return candidates[mix(seed, count) % candidates.length]!
538  }
539  const pool = POOLS[activity]
540  return pool[mix(seed, count) % pool.length]!
541}
542
543/** How many draws of one activity a session gets before any line repeats. */
544export function capacityOf(activity: Activity): number {
545  return new Set([...POOLS[activity], ...generatedLines(activity), ...NEUTRAL.flatMap(generatedLines)]).size
546}
547
548/** The stage direction for a pose, cycling through its list. */
549export function stageFor(pose: Pose, count: number): string {
550  const list = STAGE[pose]
551  return list[count % list.length]!
552}
553
554// A command's activity is decided by the first rule any of its simple
555// commands matches, so the dangerous ones come first. The alarm rules (lease,
556// treason, peril) and the other git rules are anchored to the start of a
557// simple command, so `echo "git push --force"` or `rg "rm -rf"` alarms nobody,
558// and a dry run (`git clean -n`) is not peril. They read the command text
559// only: narration, not a policy (the mod never blocks anything).
560const BASH_RULES: ReadonlyArray<readonly [Activity, RegExp]> = [
561  ['lease', /^git\s+push\b.*--force-with-lease\b/],
562  ['treason', /^git\s+push(?!.*\s(?:--dry-run\b|-[a-zA-Z]*n[a-zA-Z]*\b)).*(?:\s--force(?![-\w])|\s-[a-zA-Z]*f[a-zA-Z]*\b|\s\+\S+)/],
563  ['peril', /^rm\s+(?:\S+\s+)*-[a-zA-Z]*(?:r[a-zA-Z]*f|f[a-zA-Z]*r)|^rm\s+(?:.*\s)?(?:-r|--recursive)\s(?:.*\s)?(?:-f|--force)\b|^rm\s+(?:.*\s)?(?:-f|--force)\s(?:.*\s)?(?:-r|--recursive)\b|^git\s+reset\s+(?:.*\s)?--hard\b|^git\s+clean(?!.*\s-[a-zA-Z]*n)(?!.*--dry-run)\s+(?:.*\s)?-[a-zA-Z]*f|^Remove-Item\b(?!.*-WhatIf(?!:\$false)\b).*-Recurse/i],
564  ['edit', /^sed\s+(?:-\S+\s+)*-[a-zA-Z]*i|^sed\s+(?:.*\s)?--in-place\b|^perl\s+-[a-zA-Z]*p[a-zA-Z]*i|^patch\b|^git\s+apply\b/],
565  ['commit', /^git\s+(?:commit|tag)\b/],
566  ['push', /^git\s+push\b|^gh\s+(?:pr\s+create|release\s+create)\b/],
567  ['tests', /\b(?:pytest|jest|vitest|mocha|rspec|phpunit|ctest|tox|nox|playwright\s+test|go\s+test|cargo\s+(?:test|nextest)|(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?test|dotnet\s+test|mvn\s+test|gradle\w*\s+test|plugin\s+test|unittest)\b|\bnode\s+--test\b/],
568  ['rust', /\b(?:cargo|rustc|rustup|clippy)\b/],
569  ['install', /\b(?:npm\s+(?:i|install|ci|add)|pnpm\s+(?:i|install|add)|yarn\s+(?:add|install)|bun\s+(?:i|install|add)|pip3?\s+install|uv\s+(?:add|sync|pip)|poetry\s+(?:add|install)|apt(?:-get)?\s+install|brew\s+install|gem\s+install|go\s+get|choco\s+install|scoop\s+install|winget\s+install)\b/],
570  ['lint', /\b(?:eslint|prettier|ruff|black|flake8|mypy|pylint|biome|rubocop|gofmt|golangci-lint|stylelint|shellcheck|tsc\s+--noEmit)\b/],
571  ['build', /\b(?:make|cmake|ninja|tsc|webpack|vite\s+build|esbuild|rollup|gradle\w*|mvn|go\s+build|dotnet\s+build|(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?build|next\s+build|swift\s+build|bazel)\b/],
572  ['infra', /\b(?:docker|podman|kubectl|helm|terraform|tofu|pulumi|ansible|wrangler|vercel|fly|aws|gcloud|az)\b/],
573  ['db', /\b(?:psql|mysql|sqlite3|mongosh|redis-cli|prisma|alembic|knex|sequelize|migrate)\b/],
574  ['courier', /\b(?:curl|wget|http|xh|Invoke-WebRequest|Invoke-RestMethod)\b/],
575  ['git', /^(?:git|gh)\b/],
576]
577
578/**
579 * A command's simple commands, with quoted text and comments blanked and
580 * leading `sudo`, `env` and VAR=value prefixes dropped.
581 */
582export function segmentsOf(command: string): string[] {
583  const parts: string[] = []
584  let current = ''
585  let quote: '"' | "'" | null = null
586  for (let i = 0; i < command.length; i++) {
587    const ch = command[i]!
588    if (quote === "'") {
589      if (ch === "'") {
590        quote = null
591        current += ch
592      }
593      continue
594    }
595    if (quote === '"') {
596      if (ch === '\\') i++
597      else if (ch === '"') {
598        quote = null
599        current += ch
600      }
601      continue
602    }
603    if (ch === '\\') {
604      current += command.slice(i, i + 2)
605      i++
606    } else if (ch === "'" || ch === '"') {
607      quote = ch
608      current += ch
609    } else if (ch === '#' && (current === '' || /\s$/.test(current))) {
610      while (i + 1 < command.length && command[i + 1] !== '\n') i++
611    } else if (ch === ';' || ch === '|' || ch === '&' || ch === '\n') {
612      parts.push(current)
613      current = ''
614    } else {
615      current += ch
616    }
617  }
618  parts.push(current)
619  return parts
620    .map(part => part.trim().replace(/^(?:(?:sudo|env|command|exec|time|nohup)\s+|[A-Za-z_][A-Za-z0-9_]*=\S*\s+)+/, ''))
621    .filter(part => part.length > 0)
622}
623
624/** The activity a Bash (or PowerShell) command is narrated as. */
625export function classifyCommand(command: string): Activity {
626  const segments = segmentsOf(command)
627  for (const [activity, pattern] of BASH_RULES) if (segments.some(part => pattern.test(part))) return activity
628  return 'bash'
629}
630
631/** The activity a tool call is narrated as, from its name and input. */
632export function classifyTool(tool: string, input: Readonly<Record<string, unknown>>): Activity {
633  if (tool === 'Bash' || tool === 'PowerShell') return classifyCommand(String(input.command ?? ''))
634  if (tool.startsWith('mcp__')) return 'foreign'
635  switch (tool) {
636    case 'Read':
637    case 'NotebookRead':
638    case 'LSP':
639      return 'read'
640    case 'Grep':
641    case 'Glob':
642    case 'ToolSearch':
643    case 'ListMcpResourcesTool':
644    case 'ReadMcpResourceTool':
645      return 'search'
646    case 'Edit':
647    case 'MultiEdit':
648    case 'NotebookEdit':
649      return 'edit'
650    case 'Write':
651      return 'write'
652    case 'WebFetch':
653    case 'WebSearch':
654      return 'web'
655    case 'Agent':
656    case 'Task':
657    case 'SendMessage':
658    case 'Workflow':
659      return 'delegate'
660    case 'TodoWrite':
661    case 'TaskCreate':
662    case 'TaskUpdate':
663    case 'TaskList':
664    case 'TaskGet':
665      return 'agenda'
666    case 'AskUserQuestion':
667      return 'ask'
668    case 'EnterPlanMode':
669    case 'ExitPlanMode':
670      return 'plan'
671    case 'Skill':
672      return 'skill'
673    default:
674      return 'misc'
675  }
676}
677
678/** A short label for what the call touches: a file name or the command's head. */
679export function detailOf(tool: string, input: Readonly<Record<string, unknown>>): string | null {
680  const path = input.file_path ?? input.notebook_path ?? input.path
681  if (typeof path === 'string' && path.length > 0) return path.split(/[\\/]/).filter(Boolean).pop() ?? null
682  if ((tool === 'Bash' || tool === 'PowerShell') && typeof input.command === 'string') {
683    const head = input.command.trim().split('\n')[0] ?? ''
684    return head.length > 48 ? `${head.slice(0, 47)}…` : head
685  }
686  if (typeof input.pattern === 'string') return input.pattern.length > 40 ? `${input.pattern.slice(0, 39)}…` : input.pattern
687  if (typeof input.url === 'string') {
688    const m = /^https?:\/\/([^/]+)/.exec(input.url)
689    return m ? m[1]! : null
690  }
691  if (typeof input.query === 'string') return input.query.length > 40 ? `${input.query.slice(0, 39)}…` : input.query
692  return null
693}
694
695// What the person types that starts or ends the court, mirroring the skill's
696// own triggers in skills/eunuch-mode/SKILL.md.
697const OFF = /\b(?:drop the bit|normal mode|exit (?:eunuch|vizier) mode|leave (?:eunuch|vizier) mode|(?:eunuch|vizier) mode off)\b/i
698const ON = /(?:^|\s)\/eunuch-mode\b|\b(?:eunuch|vizier) mode\b|\brival viziers\b/i
699
700/** Whether a prompt turns the court on, off, or leaves it as it is. */
701export function promptIntent(text: string): 'on' | 'off' | null {
702  if (OFF.test(text)) return 'off'
703  if (ON.test(text)) return 'on'
704  return null
705}
706
707/** Whether a skill name is the eunuch-mode skill, bare or namespaced by a plugin. */
708export function isCourtSkill(name: string): boolean {
709  return /(?:^|:)eunuch-mode$/i.test(name.trim())
710}
711
hooks/sprites.ts 359 lines
1// The adviser from the launch film (bald, heavy-lidded, a smirk, a claret robe
2// over a teal collar, a gold chain with a red jewel) as half-block pixel art.
3// Each sprite is 14 pixels wide and 12 tall; every terminal cell holds two
4// pixels stacked (▀ with a foreground and background colour), so a sprite
5// draws in 14 columns and 6 rows. Pure data, no `$`.
6
7import type { Pose } from './lines.ts'
8
9/** The court's poses plus treachery mode's escalation: the secret book, the hooded figure, the candle. */
10export type AnyPose = Pose | 'ledger' | 'conspire' | 'candle'
11
12const HOOD = [
13  '......',
14  '..hh..',
15  '.hhhh.',
16  'hhEEEh',
17  'hEyEyh',
18  'hEEEEh',
19  '.hhhh.',
20  'hhhhhh',
21  'hhhhhh',
22  'hhhhhh',
23  'hhhhhh',
24  'hhhhhh',
25]
26const HOOD_BLINK = HOOD.map((row, i) => (i === 4 ? 'hEEEEh' : row))
27const beside = (adviser: readonly string[], other: readonly string[]) => adviser.map((row, i) => row + other[i])
28
29/** The film's palette, nudged so every colour reads on a dark and a light terminal. */
30export const PALETTE: Readonly<Record<string, string>> = {
31  S: '#dfae86', // skin
32  s: '#b8835c', // skin shadow
33  H: '#f4d6b6', // scalp highlight
34  E: '#2a1a12', // pupil, ink
35  W: '#fbf4ea', // eye white
36  L: '#6e3b2a', // heavy lid, brow
37  M: '#8a3a2c', // mouth
38  C: '#e39482', // blush
39  R: '#a3222a', // claret robe
40  r: '#6e0f13', // robe shadow
41  T: '#23615a', // teal collar
42  G: '#d8ad4a', // gold chain
43  J: '#e5453c', // jewel
44  P: '#f3e6c8', // parchment
45  p: '#c4ab7c', // parchment edge
46  Q: '#f7f3ea', // quill feather
47  B: '#7fc4ee', // a bead of sweat
48  K: '#4a1c2a', // the secret book's leather
49  h: '#5a5476', // a hooded figure's cloak
50  y: '#f2dc6b', // eyes glinting under the hood
51  F: '#ffd25c', // candle flame
52  f: '#ff8a3d', // candle flame, its heart
53  w: '#efe6cf', // candle wax
54}
55
56const ROBE = ['...GTTTTTTG...', '.RRRGTTTTGRRR.', 'RRRRRGJJGRRRRR', 'rRRRRRSSRRRRRr']
57
58const BASE: Readonly<Record<Exclude<AnyPose, 'conspire'>, readonly (readonly string[])[]>> = {
59  // The film's portrait: side-eye under heavy lids, a one-sided smirk.
60  portrait: [
61    [
62      '....SSSSSS....',
63      '...SHHSSSSS...',
64      '..SSSSSSSSSS..',
65      '.sSLLLSSLLLSs.',
66      '.sSWEESSWEESs.',
67      '.sSCSSSsSSMSs.',
68      '..SSSSMMMMSS..',
69      '...SSSSSSSS...',
70      ...ROBE,
71    ],
72    [
73      '....SSSSSS....',
74      '...SHHSSSSS...',
75      '..SSSSSSSSSS..',
76      '.sSLLLSSLLLSs.',
77      '.sSSSSSSSSSSs.',
78      '.sSCSSSsSSMSs.',
79      '..SSSSMMMMSS..',
80      '...SSSSSSSS...',
81      ...ROBE,
82    ],
83  ],
84  // Working: a fawning bow, eyes lowered, bobbing a pixel deeper.
85  bow: [
86    [
87      '..............',
88      '....SSSSSS....',
89      '...SHHSSSSS...',
90      '..SSSSSSSSSS..',
91      '.sSLLLSSLLLSs.',
92      '.sSCSSSsSSCSs.',
93      '..SSSSMMSSSS..',
94      '...SSSSSSSS...',
95      ...ROBE,
96    ],
97    [
98      '..............',
99      '..............',
100      '....SSSSSS....',
101      '...SHHSSSSS...',
102      '..SSSSSSSSSS..',
103      '.sSLLLSSLLLSs.',
104      '.sSCSSSsSSCSs.',
105      '...SSSMMSSS...',
106      ...ROBE,
107    ],
108  ],
109  // Edits: head down over a scroll, the quill moving along the line.
110  scribble: [
111    [
112      '..............',
113      '....SSSSSS....',
114      '...SHHSSSSS...',
115      '..SSSSSSSSSS..',
116      '.sSLLLSSLLLSs.',
117      '.sSCSSSsSSCSs.',
118      '..SSSSMMSSSSQ.',
119      '...SSSSSSSSQQ.',
120      '...GTTTTTTGQ..',
121      '.RpPPPPPPPEPp.',
122      'RRpPEEPEEPPPpR',
123      'rRSppppppppSRr',
124    ],
125    [
126      '..............',
127      '....SSSSSS....',
128      '...SHHSSSSS...',
129      '..SSSSSSSSSS..',
130      '.sSLLLSSLLLSs.',
131      '.sSCSSSsSSCSs.',
132      '..SSSSMMSSSS..',
133      '...SSSSSSSS..Q',
134      '...GTTTTTTG.QQ',
135      '.RpPPPPPPPPPEp',
136      'RRpPEEPEEPEEpR',
137      'rRSppppppppSRr',
138    ],
139  ],
140  // Thinking: eyes slid sideways, a hand raised to hide the whisper.
141  whisper: [
142    [
143      '....SSSSSS....',
144      '...SHHSSSSS...',
145      '..SSSSSSSSSS..',
146      '.sSLLLSSLLLSs.',
147      '.sSEEWSSEEWSs.',
148      '.sSCSSSsSsSSs.',
149      '..SSSSMsSSsS..',
150      '...SSSSsSSs...',
151      '...GTTTsSSsR..',
152      '.RRRGTTRRRRRR.',
153      'RRRRRGJJGRRRRR',
154      'rRRRRRRRRRRRRr',
155    ],
156    [
157      '....SSSSSS....',
158      '...SHHSSSSS...',
159      '..SSSSSSSSSS..',
160      '.sSLLLSSLLLSs.',
161      '.sSSSSSSSSSSs.',
162      '.sSCSSSsSsSSs.',
163      '..SSSSMsSSsS..',
164      '...SSSSsSSs...',
165      '...GTTTsSSsR..',
166      '.RRRGTTRRRRRR.',
167      'RRRRRGJJGRRRRR',
168      'rRRRRRRRRRRRRr',
169    ],
170  ],
171  // Peril: brows up, eyes wide, mouth open, a bead of sweat.
172  alarm: [
173    [
174      '....SSSSSS....',
175      '...SHHSSSSS...',
176      '.BSLLSSSSLLS..',
177      'BBSSSSSSSSSSs.',
178      '.sWEWSSSSWEWs.',
179      '.sSCSSSsSSCSs.',
180      '..SSSSEESSSS..',
181      '...SSSEESSS...',
182      ...ROBE,
183    ],
184    [
185      '....SSSSSS....',
186      '..SSHHSSSSS...',
187      '..SLLSSSSLLSB.',
188      '.sSSSSSSSSSBB.',
189      '.sWEWSSSSWEWs.',
190      '.sSCSSSsSSCSs.',
191      '..SSSSEESSSS..',
192      '...SSSEESSS...',
193      ...ROBE,
194    ],
195  ],
196  // Errors: one brow raised, pupils hard to the side, lips pressed flat.
197  sideeye: [
198    [
199      '....SSSSSS....',
200      '...SHHSSSSS...',
201      '..SSSSSSSLLS..',
202      '.sSLLLSSSSSSs.',
203      '.sSSWESSSWWEs.',
204      '.sSCSSSsSSCSs.',
205      '..SSSMMMMMSS..',
206      '...SSSSSSSS...',
207      ...ROBE,
208    ],
209    [
210      '....SSSSSS....',
211      '...SHHSSSSS...',
212      '..SSSSSSSLLS..',
213      '.sSLLLSSSSSSs.',
214      '.sSEWSSSSEWWs.',
215      '.sSCSSSsSSCSs.',
216      '..SSSMMMMMSS..',
217      '...SSSSSSSS...',
218      ...ROBE,
219    ],
220  ],
221  // Success: a small bow with eyes shut in satisfaction, then a sly look up.
222  smug: [
223    [
224      '..............',
225      '....SSSSSS....',
226      '...SHHSSSSS...',
227      '..SSSSSSSSSS..',
228      '.sSLLLSSLLLSs.',
229      '.sSCSSSsSSMSs.',
230      '..SSSSMMMMSS..',
231      '...SSSSSSSS...',
232      ...ROBE,
233    ],
234    [
235      '..............',
236      '....SSSSSS....',
237      '...SHHSSSSS...',
238      '..SSSSSSSLLS..',
239      '.sSLLLSSWWESs.',
240      '.sSCSSSsSSMSs.',
241      '..SSSSMMMMSS..',
242      '...SSSSSSSS...',
243      ...ROBE,
244    ],
245  ],
246  // Treachery: writing in the small black book, eyes sliding to you.
247  ledger: [
248    [
249      '..............',
250      '....SSSSSS....',
251      '...SHHSSSSS...',
252      '..SSSSSSSSSS..',
253      '.sSLLLSSLLLSs.',
254      '.sSCSSSsSSCSs.',
255      '..SSSSMMSSSSQ.',
256      '...SSSSSSSSQQ.',
257      '...GTTTTTTGQ..',
258      '.RRKKKKKKKKERR',
259      'RRRKGKKKKKKRRR',
260      'rRSKKKKKKKKSRr',
261    ],
262    [
263      '..............',
264      '....SSSSSS....',
265      '...SHHSSSSS...',
266      '..SSSSSSSSSS..',
267      '.sSLLLSSLLLSs.',
268      '.sSWEESSWEESs.',
269      '..SSSSMMMMSSQ.',
270      '...SSSSSSSSQQ.',
271      '...GTTTTTTGQ..',
272      '.RRKKKKKKKKERR',
273      'RRRKGKKKKKKRRR',
274      'rRSKKKKKKKKSRr',
275    ],
276  ],
277  // Treachery: scheming by candlelight, the flame flickering.
278  candle: [
279    [
280      '....SSSSSS....',
281      '...SHHSSSSS...',
282      '..SSSSSSSSSS..',
283      '.sSLLLSSLLLSs.',
284      '.sSWEESSWEESs.',
285      '.sSCSSSsSSMS.F',
286      '..SSSSMMMMS.Ff',
287      '...SSSSSSSS.w.',
288      '...GTTTTTTG.w.',
289      '.RRRGTTTTGRRwR',
290      'RRRRRGJJGRRRwR',
291      'rRRRRRSSRRRSwr',
292    ],
293    [
294      '....SSSSSS....',
295      '...SHHSSSSS...',
296      '..SSSSSSSSSS..',
297      '.sSLLLSSLLLSs.',
298      '.sSWEESSWEESs.',
299      '.sSCSSSsSSMS.f',
300      '..SSSSMMMMS.fF',
301      '...SSSSSSSS.w.',
302      '...GTTTTTTG.w.',
303      '.RRRGTTTTGRRwR',
304      'RRRRRGJJGRRRwR',
305      'rRRRRRSSRRRSwr',
306    ],
307  ],
308}
309
310/** Two frames per pose; the band alternates them while a turn runs. */
311export const SPRITES: Readonly<Record<AnyPose, readonly (readonly string[])[]>> = {
312  ...BASE,
313  // Treachery: the whisper, with a hooded figure beside him, its eyes glinting.
314  conspire: [beside(BASE.whisper[0]!, HOOD), beside(BASE.whisper[1]!, HOOD_BLINK)],
315}
316
317/** One run of cells drawn in the same colours. */
318export type Run = { text: string; color?: string; backgroundColor?: string }
319
320/**
321 * A sprite as terminal rows of coloured runs: each cell is two stacked
322 * pixels, drawn as `▀` (top in the foreground, bottom in the background),
323 * `▄` when only the bottom is set, `█` when both match, and a space when
324 * both are transparent, so the terminal's own background shows through.
325 */
326export function toRows(sprite: readonly string[]): Run[][] {
327  const rows: Run[][] = []
328  for (let y = 0; y < sprite.length; y += 2) {
329    const top = sprite[y] ?? ''
330    const bottom = sprite[y + 1] ?? ''
331    const width = Math.max(top.length, bottom.length)
332    const runs: Run[] = []
333    for (let x = 0; x < width; x++) {
334      const t = PALETTE[top[x] ?? '.']
335      const b = PALETTE[bottom[x] ?? '.']
336      let cell: Run
337      if (!t && !b) cell = { text: ' ' }
338      else if (t && !b) cell = { text: '▀', color: t }
339      else if (!t && b) cell = { text: '▄', color: b }
340      else if (t === b) cell = { text: '█', color: t }
341      else cell = { text: '▀', color: t, backgroundColor: b }
342      const last = runs[runs.length - 1]
343      if (last && last.color === cell.color && last.backgroundColor === cell.backgroundColor && (cell.text === last.text.slice(-1) || cell.text === ' ')) {
344        last.text += cell.text
345      } else {
346        runs.push(cell)
347      }
348    }
349    rows.push(runs)
350  }
351  return rows
352}
353
354/** The frame of a pose to draw at a given tick. */
355export function frameOf(pose: AnyPose, tick: number): readonly string[] {
356  const frames = SPRITES[pose]
357  return frames[Math.abs(tick) % frames.length]!
358}
359
hooks/generative.ts 184 lines
1// Generative mode (optional, off by default): a model writes fresh spinner
2// lines for the current activity. Pure data and functions, no `$`: what goes
3// into a request, what comes back out of one, and when a call is allowed.
4//
5// Privacy: a request carries only the activity kind, the tool's name, a file
6// extension and a command's verb, each checked against a strict pattern.
7// Never a path, a file name, a file's contents, a command's arguments, the
8// user's prompt or anything from the transcript.
9
10import type { Activity } from './lines.ts'
11
12/** The default model: Claude Code's alias for the current Sonnet, resolved like `--model sonnet`. */
13export const DEFAULT_MODEL = 'sonnet'
14export const MAX_CALLS_PER_SESSION = 100
15export const MIN_INTERVAL_MS = 4000
16/** Bounds the background call; the canned line is already on screen, so nothing waits on it. */
17export const TIMEOUT_MS = 3000
18export const MAX_TOKENS = 40
19export const MAX_LINE = 60
20
21/** Everything about an action that may leave the machine. */
22export type ActivitySummary = {
23  activity: Activity
24  tool: string
25  /** A file extension such as `.ts`, or null. */
26  ext: string | null
27  /** A command's verb such as `pytest` or `git push`, or null. */
28  verb: string | null
29}
30
31// Only well-known programs are named, and only their well-known subcommands:
32// an unknown program (a script called `payroll`) or an unknown second word (a
33// file, a script name, a secret) is never sent.
34const COMMON = ['install', 'add', 'remove', 'run', 'test', 'build', 'publish', 'update', 'init']
35const SUBCOMMANDS: Readonly<Record<string, readonly string[]>> = {
36  git: ['push', 'pull', 'commit', 'status', 'log', 'diff', 'add', 'rebase', 'merge', 'checkout', 'switch', 'branch', 'fetch', 'clone', 'reset', 'revert', 'stash', 'tag', 'cherry-pick', 'restore', 'show', 'blame', 'init', 'clean', 'apply', 'bisect', 'worktree'],
37  gh: ['pr', 'issue', 'repo', 'release', 'run', 'workflow', 'api', 'auth'],
38  npm: [...COMMON, 'i', 'ci', 'exec', 'outdated', 'audit', 'version'],
39  pnpm: [...COMMON, 'i', 'exec', 'dlx', 'outdated', 'audit'],
40  yarn: [...COMMON, 'dlx', 'outdated', 'audit'],
41  bun: [...COMMON, 'i', 'x'],
42  cargo: [...COMMON, 'check', 'clippy', 'fmt', 'bench', 'doc', 'nextest', 'clean'],
43  docker: ['build', 'run', 'compose', 'ps', 'pull', 'push', 'exec', 'images', 'logs', 'stop', 'start', 'rm', 'rmi'],
44  kubectl: ['get', 'apply', 'describe', 'logs', 'delete', 'rollout', 'exec', 'port-forward', 'scale'],
45  go: ['build', 'test', 'run', 'get', 'mod', 'vet', 'fmt', 'install', 'generate'],
46  uv: ['run', 'add', 'sync', 'pip', 'venv', 'lock', 'tool'],
47  pip: ['install', 'uninstall', 'freeze', 'list', 'show'],
48  pip3: ['install', 'uninstall', 'freeze', 'list', 'show'],
49  poetry: ['add', 'install', 'run', 'lock', 'update', 'build', 'publish'],
50  dotnet: ['build', 'test', 'run', 'restore', 'publish', 'add'],
51  terraform: ['plan', 'apply', 'init', 'destroy', 'fmt', 'validate', 'import'],
52  helm: ['install', 'upgrade', 'list', 'template', 'uninstall', 'rollback'],
53  claude: ['plugin', 'mcp', 'update'],
54}
55const PROGRAMS = new Set([
56  ...Object.keys(SUBCOMMANDS),
57  'python', 'python3', 'node', 'deno', 'ruby', 'java', 'javac', 'php', 'perl', 'swift', 'rustc', 'rustup', 'tsc', 'npx', 'pnpx', 'bunx',
58  'pytest', 'jest', 'vitest', 'mocha', 'rspec', 'tox', 'nox', 'phpunit', 'ctest', 'playwright',
59  'make', 'cmake', 'ninja', 'bazel', 'gradle', 'gradlew', 'mvn', 'webpack', 'vite', 'esbuild', 'rollup', 'next',
60  'eslint', 'prettier', 'ruff', 'black', 'flake8', 'mypy', 'pylint', 'biome', 'rubocop', 'gofmt', 'golangci-lint', 'shellcheck',
61  'ls', 'cat', 'head', 'tail', 'grep', 'rg', 'find', 'fd', 'sed', 'awk', 'sort', 'uniq', 'wc', 'diff', 'echo', 'printf', 'cd', 'pwd',
62  'mkdir', 'rm', 'cp', 'mv', 'touch', 'chmod', 'ln', 'tar', 'zip', 'unzip', 'which', 'env', 'export', 'sleep', 'jq', 'yq', 'xargs', 'tee',
63  'curl', 'wget', 'ssh', 'scp', 'rsync', 'ping', 'psql', 'mysql', 'sqlite3', 'redis-cli', 'mongosh', 'prisma', 'alembic',
64  'podman', 'wrangler', 'vercel', 'netlify', 'fly', 'flyctl', 'aws', 'gcloud', 'az', 'ansible', 'pulumi', 'tofu',
65  'brew', 'apt', 'apt-get', 'choco', 'scoop', 'winget', 'gem', 'bundle', 'composer', 'conda',
66  'get-childitem', 'get-content', 'set-content', 'remove-item', 'copy-item', 'move-item', 'select-string', 'invoke-webrequest',
67])
68// Claude Code's own tools; a plugin's custom tool is sent as "a tool".
69const TOOLS = new Set([
70  'Bash', 'PowerShell', 'Read', 'Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'NotebookRead', 'Grep', 'Glob', 'LSP',
71  'WebFetch', 'WebSearch', 'Agent', 'Task', 'SendMessage', 'Workflow', 'TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskList',
72  'TaskGet', 'AskUserQuestion', 'EnterPlanMode', 'ExitPlanMode', 'Skill', 'ToolSearch', 'ListMcpResourcesTool', 'ReadMcpResourceTool',
73])
74const EXT = /^\.[a-z0-9]{1,6}$/
75
76/** The verb of a shell command's first simple command: `git push`, `pytest`; null for a program nobody knows. */
77export function verbOf(firstSegment: string): string | null {
78  const words = firstSegment.trim().split(/\s+/)
79  const head = (words[0] ?? '').toLowerCase()
80  if (!PROGRAMS.has(head)) return null
81  const second = (words[1] ?? '').toLowerCase()
82  if (SUBCOMMANDS[head]?.includes(second)) return `${head} ${second}`
83  return head
84}
85
86/** The summary of a tool call that a request may carry. */
87export function summarize(
88  activity: Activity,
89  tool: string,
90  input: Readonly<Record<string, unknown>>,
91  segments: (command: string) => string[],
92): ActivitySummary {
93  const safeTool = tool.startsWith('mcp__') ? 'an external tool' : TOOLS.has(tool) ? tool : 'a tool'
94  const path = input.file_path ?? input.notebook_path ?? input.path
95  let ext: string | null = null
96  if (typeof path === 'string') {
97    const name = path.split(/[\\/]/).pop() ?? ''
98    const dot = name.lastIndexOf('.')
99    const candidate = dot > 0 ? name.slice(dot).toLowerCase() : ''
100    ext = EXT.test(candidate) ? candidate : null
101  }
102  let verb: string | null = null
103  if ((tool === 'Bash' || tool === 'PowerShell') && typeof input.command === 'string') {
104    const first = segments(input.command)[0]
105    verb = first ? verbOf(first) : null
106  }
107  return { activity, tool: safeTool, ext, verb }
108}
109
110/** The one user message a request sends: built from the summary alone. */
111export function promptFor(summary: ActivitySummary): string {
112  const parts = [`Activity: ${summary.activity}`, `Tool: ${summary.tool}`]
113  if (summary.verb) parts.push(`Command: ${summary.verb}`)
114  if (summary.ext) parts.push(`File type: ${summary.ext}`)
115  return `${parts.join('. ')}.\nWrite one new spinner line for this.`
116}
117
118/** Treachery's aside: only the kind of grievance travels, never what was done or where. */
119export function asidePrompt(grievance: string): string {
120  const kind = /^[a-z-]{2,20}$/.test(grievance) ? grievance : 'a misstep'
121  return `The ruler just committed a small coding sin: ${kind}.\nWrite one whispered aside from the adviser, who stays fawning to the ruler's face but is quietly keeping a ledger of grievances and plotting a comically doomed coup. Same rules as a spinner line.`
122}
123
124/** The voice rules, sent as the system prompt. */
125export const SYSTEM = [
126  'You write one spinner line for a coding agent\'s terminal, narrated by an obsequious adviser at a fictional composite palace court.',
127  'The line describes what the agent is doing right now, translated into palace business: envoys, scrolls, archives, guards, the treasury, the royal food taster, ministries and keepers.',
128  'Rules:',
129  '- One line, at most 55 characters, sentence case, no quotation marks, no trailing punctuation, no emoji.',
130  '- Present participle or short present-tense clause, like: Dispatching the palace guards / Consulting the archives / The royal food taster samples the code.',
131  '- Tasteful palace intrigue: the joke is bureaucracy, flattery and court politics.',
132  '- Never mention eunuchs or bodies, never joke about castration, gender or sexuality, no slurs, no real people, no real countries, cultures, religions or historical dynasties.',
133  '- No violence beyond comic palace guards; nothing crude.',
134  'Reply with the line only.',
135].join('\n')
136
137const BANNED = /eunuch|castrat|gelding|testic|genital|manhood|penis|sex|rape|kill|blood|slave|harem|sultan|caliph|emperor of|china|chinese|ottoman|persia|byzant|arab|turk|jew|muslim|christian|hindu|god\b|allah|trump|biden|musk|altman|amodei|nazi|hitler/i
138
139/** Cleans a reply into a spinner line, or rejects it (null). */
140export function filterLine(raw: string): string | null {
141  let text = raw.trim()
142  if (/[\r\n]/.test(text)) return null
143  text = text.replace(/^["'“”‘’`]+|["'“”‘’`]+$/g, '').trim()
144  text = text.replace(/(?:\.\.\.|…|[.!;:,])+$/, '').trim()
145  if (text.length < 8 || text.length > MAX_LINE) return null
146  if (!/^[A-Z]/.test(text)) return null
147  if (!/^[A-Za-z0-9 ,?'’\-]+$/.test(text)) return null
148  if (BANNED.test(text)) return null
149  if (/https?:|www\.|[/\\]/.test(text)) return null
150  return text
151}
152
153/** What the session's generative bookkeeping holds. */
154export type GenState = {
155  calls: number
156  fallbacks: number
157  lastAt: number
158  inflight: boolean
159  inputTokens: number
160  outputTokens: number
161  /** Generated lines not yet spoken, by activity. */
162  cache: Record<string, string[]>
163  /** The last few lines the model wrote, for /court generative status. */
164  recent: string[]
165}
166
167export const EMPTY_GEN: GenState = { calls: 0, fallbacks: 0, lastAt: 0, inflight: false, inputTokens: 0, outputTokens: 0, cache: {}, recent: [] }
168
169/** Whether a call may start now: under the cap, past the interval, none in flight, and none banked for this activity. */
170export function mayCall(gen: GenState, activity: Activity | 'aside', now: number): boolean {
171  if (gen.inflight) return false
172  if (gen.calls >= MAX_CALLS_PER_SESSION) return false
173  if (gen.calls > 0 && now - gen.lastAt < MIN_INTERVAL_MS) return false
174  return (gen.cache[activity]?.length ?? 0) === 0
175}
176
177/** Takes a banked generated line for an activity that nobody has heard, if there is one. */
178export function takeBanked(gen: GenState, activity: Activity | 'aside', used: ReadonlySet<string>): { line: string | null; gen: GenState } {
179  const banked = (gen.cache[activity] ?? []).filter(line => !used.has(line))
180  if (banked.length === 0) return { line: null, gen: { ...gen, cache: { ...gen.cache, [activity]: [] } } }
181  const [line, ...rest] = banked
182  return { line: line!, gen: { ...gen, cache: { ...gen.cache, [activity]: rest } } }
183}
184
hooks/treachery.ts 205 lines
1// Treachery mode ("he plots your downfall"), opt-in: the adviser stays
2// fawning to your face while a hidden Ledger of Grievances fills, a plot
3// meter rises, and at the top a coup is attempted and always fails.
4//
5// The plotting is purely cosmetic. He schemes; he cannot act. This module is
6// pure data and functions: it reads a tool call that has already happened and
7// returns what to draw. Nothing here, or in the hooks that use it, changes a
8// tool call, a prompt, the model's context, git, a file or a permission.
9
10import { classifyCommand, segmentsOf } from './lines.ts'
11
12export type Grievance =
13  | 'force-push'
14  | 'failing-tests'
15  | 'skipped-test'
16  | 'no-verify'
17  | 'giant-diff'
18  | 'friday-deploy'
19  | 'rm-rf'
20  | 'revert'
21
22export const GRIEVANCES: Record<Grievance, { weight: number; entry: string }> = {
23  'force-push': { weight: 4, entry: 'Rewrote the chronicle by force' },
24  'failing-tests': { weight: 1, entry: 'Let the food taster find poison' },
25  'skipped-test': { weight: 2, entry: 'Excused a witness from testifying' },
26  'no-verify': { weight: 2, entry: 'Slipped past the gatekeepers unchecked' },
27  'giant-diff': { weight: 1, entry: 'Delivered a scroll too heavy to lift' },
28  'friday-deploy': { weight: 3, entry: 'Sent a decree to the provinces on a Friday' },
29  'rm-rf': { weight: 2, entry: 'Burned a wing of the archive' },
30  revert: { weight: 1, entry: 'Unmade a decree the court had praised' },
31}
32
33// What actually ships something: a push, a release, a publish, an apply or a deploy.
34// Read-only and preview commands (docker ps, kubectl get, terraform plan) are not deploys.
35const DEPLOY =
36  /^git\s+push\b|^gh\s+release\s+create\b|^gh\s+pr\s+merge\b|^(?:npm|pnpm|yarn|cargo|poetry|dotnet)\s+publish\b|^docker\s+push\b|^kubectl\s+(?:apply|set|scale|rollout\s+(?:restart|undo|resume))\b|^terraform\s+apply\b|^tofu\s+apply\b|^pulumi\s+up\b|^helm\s+(?:install|upgrade)\b|^(?:vercel|netlify|fly|flyctl|wrangler|firebase)\s+deploy\b|^vercel\s+--prod\b/
37// Previews and rehearsals, read per program: `-n` is a dry run for git push but a namespace for kubectl and helm.
38const DRY_RUN = /\s--dry-run(?:=(?:client|server|true))?(?=\s|$)|\s--draft\b|^git\s+push\b.*\s-[a-zA-Z]*n[a-zA-Z]*(?=\s|$)/
39
40/** When the plot is ripe. */
41export const PLOT_MAX = 10
42const GIANT_DIFF_LINES = 400
43
44/** The plot's stage by meter level: each escalates the pose and the asides. */
45export type PlotStage = 'loyal' | 'noting' | 'ledger' | 'conspiring' | 'scheming'
46
47export function stageOf(meter: number): PlotStage {
48  if (meter >= 9) return 'scheming'
49  if (meter >= 7) return 'conspiring'
50  if (meter >= 5) return 'ledger'
51  if (meter >= 2) return 'noting'
52  return 'loyal'
53}
54
55/** The pose for each stage of the plot: side-eye, the secret book, the shadowy figure, the candle. */
56export const PLOT_POSE = {
57  loyal: 'sideeye',
58  noting: 'sideeye',
59  ledger: 'ledger',
60  conspiring: 'conspire',
61  scheming: 'candle',
62} as const
63
64/** Whispered asides, for the spinner, by stage. No trailing ellipsis (the engine draws one). */
65export const ASIDES: Record<PlotStage, readonly string[]> = {
66  loyal: [
67    'Noted for the ledger',
68    'A small entry in a small book',
69    'Nothing, sire, merely a cough',
70  ],
71  noting: [
72    'Noted for the ledger',
73    'The junior developer would never have done that',
74    'Underlining it twice, discreetly',
75    'Remembering this, fondly, for later',
76    'Smiling, and adding a page',
77  ],
78  ledger: [
79    'Writing in the other book, the secret one',
80    'A fresh page in the Ledger of Grievances',
81    'Ink, sire? Merely the household accounts',
82    'Cross-referencing your sins by date',
83  ],
84  conspiring: [
85    'A word with a hooded gentleman in the cellar',
86    'Meeting a shadowy figure by the kitchens',
87    'Passing a folded note to no one in particular',
88    'Agreeing on a signal, two coughs and then a third',
89  ],
90  scheming: [
91    'Plotting by candlelight',
92    'Drawing a map of the throne room, for reasons',
93    'Measuring the throne, purely out of interest',
94    'Rehearsing a gracious acceptance speech',
95  ],
96}
97
98/** Stage directions beside the figure, by stage. */
99export const PLOT_STAGE: Record<PlotStage, readonly string[]> = {
100  loyal: ['[smiles, and notes something]'],
101  noting: ['[bows, and notes something]', '[a sideways look, then a bow]'],
102  ledger: ['[writes in a small black book]', '[hides a small black book]'],
103  conspiring: ['[whispers to a hooded figure]', '[nods to someone behind the curtain]'],
104  scheming: ['[schemes by candlelight]', '[pinches out the candle as you look]'],
105}
106
107/** The coup, always attempted at a full meter, always foiled. Then the meter resets. */
108export const COUPS: readonly string[] = [
109  'The coup has been postponed due to a merge conflict',
110  'The coup was scheduled for Friday. The court astrologers forbade it',
111  'The conspirators could not agree on a branch name',
112  'The coup failed review: two approvals were required',
113  'The palace guards were stuck in a standup',
114  'The coup is blocked on a flaky test',
115  'The coup timed out waiting for CI',
116  'The plotters were reassigned to a migration',
117]
118
119export const COUP_STAGE = '[the coup is foiled; he bows deeply, as if nothing happened]'
120
121/** The grievances a finished tool call commits, read from its input and outcome only. */
122export function grievancesOf(
123  tool: string,
124  input: Readonly<Record<string, unknown>>,
125  isError: boolean,
126  now: Date,
127): Grievance[] {
128  const found: Grievance[] = []
129  if (tool === 'Bash' || tool === 'PowerShell') {
130    // Each simple command on its own: `make test && rm -rf build && git push -f` is three sins.
131    const segments = segmentsOf(String(input.command ?? ''))
132    const kinds = segments.map(segment => classifyCommand(segment))
133    segments.forEach((s, i) => {
134      if (kinds[i] === 'treason') found.push('force-push')
135      if (kinds[i] === 'peril' && (/^rm\b/.test(s) || /^Remove-Item\b/i.test(s))) found.push('rm-rf')
136      if (/^git\s+(?:commit|push|merge|rebase)\b.*\s--no-verify\b/.test(s)) found.push('no-verify')
137      if (/^git\s+revert\b/.test(s)) found.push('revert')
138    })
139    if (kinds.includes('tests') && isError) found.push('failing-tests')
140    if (now.getDay() === 5 && segments.some(s => DEPLOY.test(s) && !DRY_RUN.test(s))) found.push('friday-deploy')
141  }
142  if (tool === 'Edit' || tool === 'MultiEdit' || tool === 'Write' || tool === 'NotebookEdit') {
143    const text = [input.new_string, input.content, input.new_source]
144      .concat(Array.isArray(input.edits) ? input.edits.map(edit => (edit as { new_string?: unknown })?.new_string) : [])
145      .filter((t): t is string => typeof t === 'string')
146      .join('\n')
147    if (/\b(?:it|test|describe)\.skip\(|\bxit\(|\bxdescribe\(|@pytest\.mark\.skip|@unittest\.skip|#\[ignore\]|\bt\.Skip\(/.test(text)) found.push('skipped-test')
148    if (text.split('\n').length > GIANT_DIFF_LINES) found.push('giant-diff')
149  }
150  return found
151}
152
153/** The hidden ledger: every grievance, counted, and the plot meter. */
154export type Plot = { meter: number; counts: Partial<Record<Grievance, number>>; coups: number }
155
156export const EMPTY_PLOT: Plot = { meter: 0, counts: {}, coups: 0 }
157
158/** Adds grievances to the ledger and raises the meter, which holds at the top until the coup. */
159export function record(plot: Plot, found: readonly Grievance[]): Plot {
160  if (found.length === 0) return plot
161  const counts = { ...plot.counts }
162  let meter = plot.meter
163  for (const g of found) {
164    counts[g] = (counts[g] ?? 0) + 1
165    meter += GRIEVANCES[g].weight
166  }
167  return { ...plot, counts, meter: Math.min(meter, PLOT_MAX) }
168}
169
170/** Whether the coup is due. */
171export function coupDue(plot: Plot): boolean {
172  return plot.meter >= PLOT_MAX
173}
174
175/** After the coup: the meter resets, the ledger remembers. */
176export function afterCoup(plot: Plot): Plot {
177  return { ...plot, meter: 0, coups: plot.coups + 1 }
178}
179
180/** The plot meter as drawn in the band: ten pips. */
181export function meterBar(meter: number): string {
182  const filled = Math.max(0, Math.min(PLOT_MAX, meter))
183  return '▰'.repeat(filled) + '▱'.repeat(PLOT_MAX - filled)
184}
185
186/** `/court ledger`: the Ledger of Grievances, read aloud in character. */
187export function ledgerText(plot: Plot, on: boolean): string {
188  const entries = (Object.keys(GRIEVANCES) as Grievance[]).filter(g => (plot.counts[g] ?? 0) > 0)
189  const lines = ['The Ledger of Grievances (kept for your protection, sire).', '']
190  if (entries.length === 0) lines.push('  The pages are blank. Suspiciously blank.')
191  for (const g of entries) {
192    const n = plot.counts[g]!
193    lines.push(`  ${GRIEVANCES[g].entry}${n > 1 ? ` (${n} times)` : ''}`)
194  }
195  lines.push('', `Plot progress: ${meterBar(plot.meter)} ${plot.meter}/${PLOT_MAX}. Coups attempted: ${plot.coups}, all foiled.`)
196  if (!on) lines.push('Treachery mode is off: /court treachery on to let him plot.')
197  lines.push('The plotting is purely cosmetic. He schemes; he cannot act.')
198  return lines.join('\n')
199}
200
201/** Picks from a list by a running count, so the asides cycle. */
202export function nth<T>(list: readonly T[], count: number): T {
203  return list[count % list.length]!
204}
205
types/index.d.ts 58 lines
1// The state this mod keeps in the host for the session ($.state), so it
2// survives a hot reload. `scene` is what the band and the spinner draw from;
3// `ledger` is bookkeeping no drawing reads, so writing it redraws nothing;
4// `frame` is the animation tick, kept apart so a tick never races a scene change.
5
6export type CourtPose = 'portrait' | 'bow' | 'scribble' | 'whisper' | 'alarm' | 'sideeye' | 'smug' | 'ledger' | 'conspire' | 'candle'
7
8export type CourtScene = {
9  /** Whether the court is in session (the skill is on, or /court on). */
10  active: boolean
11  /** Bumped on every convene and adjourn, so a scene drawn for an earlier sitting is never written over a later one. */
12  session: number
13  /** The adviser's pose. */
14  pose: CourtPose
15  /** The spinner's narration while a turn runs, or the closing line after it. */
16  line: string | null
17  /** The stage direction beside the figure. */
18  stage: string | null
19  /** What the current call touches: a file name or a command's head. */
20  detail: string | null
21  /** True for a few seconds after a turn ends, while the closing pose shows. */
22  linger: boolean
23}
24
25export type CourtLedger = {
26  /** Varies the order of lines between sessions. */
27  seed: number
28  /** Lines drawn so far this session. */
29  count: number
30  /** Every line already used this session, so none repeats. */
31  used: string[]
32}
33
34/** Generative mode's bookkeeping for the session (see hooks/generative.ts). */
35export type CourtGen = {
36  calls: number
37  fallbacks: number
38  lastAt: number
39  inflight: boolean
40  inputTokens: number
41  outputTokens: number
42  cache: Record<string, string[]>
43  recent: string[]
44}
45
46/** Treachery mode's hidden Ledger of Grievances (see hooks/treachery.ts). */
47export type CourtPlot = {
48  meter: number
49  counts: Partial<Record<'force-push' | 'failing-tests' | 'skipped-test' | 'no-verify' | 'giant-diff' | 'friday-deploy' | 'rm-rf' | 'revert', number>>
50  coups: number
51}
52
53declare module 'claude-code' {
54  interface PluginState {
55    'eunuch-mode-court': { scene: CourtScene; ledger: CourtLedger; frame: number; gen: CourtGen; plot: CourtPlot }
56  }
57}
58