SLOPSHOPPER

where-we-stand

When a session starts diagnosing a recurring problem or is about to state a ticket's or article's status, looks the topic up read-only (knowledge, Jira newest…

newguardtoasttoolprocess
A shopper browsing a rack in a slop shop
README

bbm-mods

Read-only public mirror of the Claude Code mods (plugins) that Brand Bridge Media sessions run. The source of truth is claude-mods/ in the private bbm-infrastructure repo; every merge there that touches a mod overwrites this repo with a fresh snapshot. Edits made here are lost on the next sync.

Pull requests are not accepted. One opened by anyone but the owner is closed automatically and the owner is notified.

Install (what the cloud environment setup script runs):

claude plugin marketplace add brand-bridge-media/bbm-mods
claude plugin install fleet-tracker@bbm-mods --scope user
Source 2 files
hooks/register.ts 407 lines
1import type { Register } from 'claude-code'
2
3import type { HookOut } from './py'
4import { parseOut, runArgs, toolPayload } from './py'
5
6// Why (LC-1893; CLAUDE.md section 1): the knowledge existed three times and nothing delivered it at the moment
7// a session was diagnosing or stating a status. This mod is the delivery. It reacts only to RECOGNISED shapes
8// (wws_trigger.py), looks the topic up read-only through the session's own Supabase / GitHub MCP, and injects ONE
9// card. Every decision and every query lives in the .py files; this file only moves bytes and keeps the limits.
10//
11//   * once per topic per session, three cards per session, and never for a topic the session already
12//     read in knowledge_memories / knowledge_articles;
13//   * every source is its own step: one failing becomes NOT CHECKED on the card, never a failed lookup;
14//   * three more ways in, none of them capped by the three-card limit: the session's OWN ticket (read from its
15//     branch name) on the first tool call, the `lookup` tool (on demand), and the gate in ./gate.ts, which sends a
16//     reply that states a status without a card back to this tool;
17//   * the whole lookup is read-only: SELECTs, the pg_net GETs wws builds (Jira), list_edge_functions and
18//     pull_request_read. The Jira credential is built inside the database and never leaves it;
19//   * fails open: any error means no card, never a blocked or slowed tool call beyond the time-box.
20const SCRIPT = 'where-we-stand.py'
21const STORE_KEY = 'where-we-stand.session'
22const MAX_CARDS = 3
23const NUDGE = (why: string) =>
24  `[mod:where-we-stand] The lookup could not read the database from the hook (${why}); no card. Treat every status as NOT CHECKED, or read the source yourself: the Jira issue's newest comment, refresh_runs / articles, pull_request_read.`
25const MAX_PRS = 5
26const MAX_TRIES = 2 // a lookup that produced no card is retried once more, not forever
27const JIRA_WAIT_TRIES = 3
28const REPO = { owner: 'brand-bridge-media', repo: 'bbm-infrastructure' }
29
30type Opts = { supabaseServer?: string; projectId?: string; githubServer?: string }
31type Moot = { verdict?: string; reason?: string; tickets?: Record<string, string> }
32// `at` (epoch ms) and `moot` are what the NEEDS YOU guard (wws_needs_you.py) reads: was there a card THIS turn, and did it find the decision moot.
33type Entry = { topic: string; tickets: string[]; terms: string[]; prs: number[]; at?: number; moot?: Moot }
34type Session = { done: string[]; seen: string[]; cards: number; ledger: Entry[]; tries: Record<string, number>; broken?: boolean; nudged?: boolean; mootBlocks?: number }
35type Made = { text: string; broken: boolean }
36type Topic = { kind: string; topic: string; tickets: string[]; terms: string[] }
37
38// A lookup runs its own MCP calls; this stops them re-triggering the mod that made them.
39let busy = false
40
41// --- $ helpers. They live in this file because the plugin validator follows $ only into functions
42// declared in the hook's own file (claude-code 2.1.292); ./py.ts stays pure.
43async function py($: any, payload: unknown): Promise<any> {
44  try {
45    const { argv, init } = runArgs($.plugin.root, SCRIPT, payload)
46    return parseOut(await $.process.run(argv, init)) as HookOut | undefined
47  } catch {
48    return undefined
49  }
50}
51
52function toast($: any, text: string): void {
53  try { $.ui.toast(text.length > 160 ? text.slice(0, 157) + '...' : text) } catch { /* a toast never changes a result */ }
54}
55
56function textOf(content: readonly unknown[]): string {
57  const joined = (content ?? [])
58    .map(b => (b && typeof b === 'object' && 'text' in b ? String((b as { text: unknown }).text) : ''))
59    .join('')
60  try {
61    const o = JSON.parse(joined)
62    return o && typeof o === 'object' && typeof o.result === 'string' ? o.result : joined
63  } catch {
64    return joined
65  }
66}
67
68// The array inside an execute_sql reply (plain, or wrapped as {"result":"...<untrusted-data>[...]..."}).
69function rowsOf(content: readonly unknown[]): unknown[] {
70  const text = textOf(content)
71  const env = /<untrusted-data-[^>]*>([\s\S]*)<\/untrusted-data-[^>]*>/.exec(text)
72  const body = env ? env[1] : text
73  const open = body.indexOf('[')
74  const close = body.lastIndexOf(']')
75  if (open < 0 || close < open) throw new Error('no row array')
76  const rows = JSON.parse(body.slice(open, close + 1))
77  if (!Array.isArray(rows)) throw new Error('rows were not an array')
78  return rows
79}
80
81// Which MCP server answers. LC-1893 first live use (2026-10-22): a cloud session names its Supabase server by a
82// UUID (`mcp__e7e41b6a-...__execute_sql`), not "Supabase", so `$.mcp.call('Supabase', ...)` found no such server and
83// every source went NOT CHECKED. The name is therefore taken from a tool the session really has; the configured
84// name only wins when that server exists, and is the fallback when the tool list cannot be read.
85const SERVER_TOOL = { supabase: 'execute_sql', github: 'pull_request_read' } as const
86type Kind = keyof typeof SERVER_TOOL
87// Per register() call (its options object), not per module: a long-lived process that loads the mod for a second session
88// must not keep the first session's server names.
89const resolvedBy = new WeakMap<object, Partial<Record<Kind, string>>>()
90function cacheOf(o: Opts): Partial<Record<Kind, string>> {
91  let c = resolvedBy.get(o)
92  if (!c) resolvedBy.set(o, (c = {}))
93  return c
94}
95
96async function serverFor($: any, o: Opts, kind: Kind, configured: string): Promise<string> {
97  const resolved = cacheOf(o)
98  if (resolved[kind]) return resolved[kind] as string
99  try {
100    const all: string[] = ((await $.tool.list()) ?? []).map((t: { name: unknown }) => String(t.name))
101    const suffix = `__${SERVER_TOOL[kind]}`
102    const names = all.filter(n => n.startsWith('mcp__') && n.endsWith(suffix)).map(n => n.slice(5, n.length - suffix.length))
103    // Supabase also has to serve list_edge_functions: prefer a server that offers both.
104    const both = kind === 'supabase' ? names.filter(n => all.includes(`mcp__${n}__list_edge_functions`)) : names
105    const pool = both.length ? both : names
106    const pick = pool.includes(configured) ? configured : pool[0]
107    if (pick) {
108      resolved[kind] = pick
109      return pick
110    }
111  } catch { /* fall back to the configured name */ }
112  return configured
113}
114
115// One MCP call, with the server found as above; a "no such server" failure forgets the cache and looks once more.
116async function mcpCall($: any, o: Opts, kind: Kind, configured: string, tool: string, args: Record<string, unknown>): Promise<any> {
117  try {
118    return await $.mcp.call(await serverFor($, o, kind, configured), tool, args)
119  } catch (e) {
120    const resolved = cacheOf(o)
121    if (!resolved[kind]) throw e
122    delete resolved[kind]
123    return await $.mcp.call(await serverFor($, o, kind, configured), tool, args)
124  }
125}
126
127// {rows} on success, {error} otherwise: the .py parsers turn either into a receipt-ready slice.
128async function sql($: any, o: Opts, query: string): Promise<{ rows?: any[]; error?: string }> {
129  try {
130    const res = await mcpCall($, o, 'supabase', o.supabaseServer || 'Supabase', 'execute_sql', {
131      project_id: o.projectId || 'tlcbwwgfqnasmiipgmmd',
132      query,
133    })
134    if (res.isError) return { error: 'execute_sql returned an error' }
135    return { rows: rowsOf(res.content) as any[] }
136  } catch (e) {
137    return { error: String((e as Error)?.message ?? e).slice(0, 160) }
138  }
139}
140
141async function edgeFunctions($: any, o: Opts): Promise<any[]> {
142  try {
143    const res = await mcpCall($, o, 'supabase', o.supabaseServer || 'Supabase', 'list_edge_functions', { project_id: o.projectId || 'tlcbwwgfqnasmiipgmmd' })
144    if (res.isError) return []
145    return (rowsOf(res.content) as any[]).map(f => ({ slug: f.slug, version: f.version, updated_at: f.updated_at }))
146  } catch {
147    return []
148  }
149}
150
151async function jiraSlice($: any, o: Opts, tickets: string[]): Promise<unknown> {
152  if (!tickets.length) return { ok: true, tickets: [] }
153  const fire = await py($, { cmd: 'sql_jira_fire', tickets })
154  if (!fire?.sql) return (await py($, { cmd: 'parse_jira', error: 'Jira query not built' }))?.evidence
155  const fired = await sql($, o, fire.sql)
156  if (!fired.rows) return (await py($, { cmd: 'parse_jira', error: fired.error }))?.evidence
157  const ids = fired.rows.map((r: any) => r.req).filter((r: unknown) => r !== null && r !== undefined)
158  let got: { rows?: any[]; error?: string } = { rows: [] }
159  for (let i = 0; i < JIRA_WAIT_TRIES; i++) {
160    // pg_sleep through the same MCP: pg_net answers after the statement commits, and hooks have no timer.
161    await sql($, o, 'select pg_sleep(3)')
162    const q = await py($, { cmd: 'sql_jira_collect', req_ids: ids })
163    got = q?.sql ? await sql($, o, q.sql) : { error: 'collect query not built' }
164    if (!got.rows || got.rows.length >= ids.length) break
165  }
166  if (!got.rows) return (await py($, { cmd: 'parse_jira', error: got.error }))?.evidence
167  return (await py($, { cmd: 'parse_jira', tickets, fired: fired.rows, collected: got.rows }))?.evidence
168}
169
170async function githubSlice($: any, o: Opts, prs: number[]): Promise<unknown> {
171  if (!prs.length) return { ok: true, asked: [], prs: [] }
172  const items: { number: number; text?: string; error?: string }[] = []
173  for (const n of prs.slice(0, MAX_PRS)) {
174    try {
175      const res = await mcpCall($, o, 'github', o.githubServer || 'github', 'pull_request_read', { method: 'get', ...REPO, pullNumber: n })
176      items.push(res.isError ? { number: n, error: 'pull_request_read returned an error' } : { number: n, text: textOf(res.content) })
177    } catch (e) {
178      items.push({ number: n, error: String((e as Error)?.message ?? e).slice(0, 80) })
179    }
180  }
181  return (await py($, { cmd: 'parse_prs', asked: prs.slice(0, MAX_PRS), items }))?.evidence
182}
183
184// The moot read has ONE success path. Once an open decision exists, anything other than well-formed evidence marked ok:true is
185// a failed read, whatever went wrong (a Python failure, undefined, {}, null, a wrong shape, a throw): it reaches the card as
186// ok:false, which is NOT CHECKED. Three review rounds found a different fail-open branch each time; this is the one rule.
187const FAILED = (error: string) => ({ ok: false, error })
188const wellFormed = (ev: any) => !!ev && typeof ev === 'object' && ev.ok === true && Array.isArray(ev.articles) && Array.isArray(ev.runs)
189
190async function mootSlice($: any, o: Opts, tickets: string[], jira: unknown): Promise<unknown> {
191  try {
192    const refs = await py($, { cmd: 'moot_refs', jira })
193    if (typeof refs?.needed !== 'boolean') return FAILED('moot_refs returned no answer')
194    if (!refs.needed) return undefined // an explicit "no open decision in any newest comment": nothing to compare
195    const q = await py($, { cmd: 'sql_production', tickets: refs.tickets ?? tickets, terms: [], kind: 'ticket-status', refs: { runs: refs.runs ?? [], slugs: refs.slugs ?? [] } })
196    if (!q?.sql) return FAILED('moot query not built')
197    const r = await sql($, o, q.sql)
198    const ev = (await py($, { cmd: 'parse_production', ...r, asked: q.asked ?? {}, terms: [] }))?.evidence
199    if (wellFormed(ev)) return ev
200    return FAILED(String(ev?.error ?? r.error ?? 'moot evidence missing or malformed').slice(0, 200))
201  } catch (e) {
202    return FAILED(`moot read threw: ${String((e as Error)?.message ?? e)}`.slice(0, 200))
203  }
204}
205
206// The whole lookup. Each source is read and parsed on its own; nothing here decides a verdict.
207async function lookup($: any, o: Opts, t: Topic): Promise<{ card: string; entry?: Entry; broken?: boolean } | undefined> {
208  const k = await py($, { cmd: 'sql_knowledge', terms: t.terms, tickets: t.tickets })
209  const kr = k?.sql ? await sql($, o, k.sql) : { error: 'knowledge query not built' }
210  const knowledge = (await py($, { cmd: 'parse_knowledge', ...kr, terms: t.terms, tickets: t.tickets }))?.evidence
211
212  const p = await py($, { cmd: 'sql_production', terms: t.terms, tickets: t.tickets, kind: t.kind })
213  const pr = p?.sql ? await sql($, o, p.sql) : { error: 'production query not built' }
214  // Knowledge AND production both unreadable: the database is unreachable from this hook, and a card of four NOT CHECKED
215  // receipts says nothing. Say so in one line and stop spending calls (Jira and the function list go through it too).
216  if (kr.error && pr.error) return { card: NUDGE(kr.error), broken: true }
217  const fns = t.terms.length ? await edgeFunctions($, o) : []
218  const production = (await py($, { cmd: 'parse_production', ...pr, asked: p?.asked ?? {}, terms: t.terms, functions_raw: fns }))?.evidence
219
220  const found = (await py($, { cmd: 'discover', tickets: t.tickets, knowledge, production })) ?? { tickets: t.tickets, prs: [] }
221  const jira = await jiraSlice($, o, found.tickets ?? t.tickets)
222  // A second look once the tickets are read: PR numbers in their newest comments are cited evidence too.
223  const again = (await py($, { cmd: 'discover', tickets: t.tickets, knowledge, production, jira })) ?? found
224  const github = await githubSlice($, o, again.prs ?? found.prs ?? [])
225  // LC-1907: a decision the newest comment still calls open is checked against the runs and articles it names (and the runs that
226  // list the ticket), which only a second read of production can answer: the first one ran before any comment was read.
227  const mootProduction = await mootSlice($, o, found.tickets ?? t.tickets, jira)
228
229  const out = await py($, { cmd: 'render', topic: t.topic, terms: t.terms, tickets: found.tickets ?? t.tickets, today: new Date().toISOString().slice(0, 10), evidence: { knowledge, jira, github, production, ...(mootProduction ? { moot_production: mootProduction } : {}) } })
230  if (!out?.card) return undefined
231  // What this card covers, for the gate: the tickets it read, the terms it searched, the PRs it checked.
232  const entry: Entry = { topic: t.topic, tickets: found.tickets ?? t.tickets, terms: t.terms, prs: (again.prs ?? found.prs ?? []).slice(0, MAX_PRS), at: Date.now(), moot: out.moot }
233  return { card: `[mod:where-we-stand] ${out.card}`, entry }
234}
235
236async function load($: any): Promise<Session> {
237  try {
238    const s = (await $.store.get(STORE_KEY)) as Session | undefined
239    return s && Array.isArray(s.done) ? { ...s, ledger: Array.isArray(s.ledger) ? s.ledger : [], tries: s.tries ?? {} } : { done: [], seen: [], cards: 0, ledger: [], tries: {} }
240  } catch {
241    return { done: [], seen: [], cards: 0, ledger: [], tries: {} }
242  }
243}
244
245// A topic is "done" only once a card exists for it; a lookup that returned nothing counts as a try, so a transient
246// MCP failure does not silence the topic for the rest of the session (and a persistent one stops after MAX_TRIES).
247async function mark($: any, key: string, gotCard: boolean): Promise<void> {
248  const s = await load($)
249  if (gotCard) s.done = [...s.done, key].slice(-20)
250  else s.tries = { ...s.tries, [key]: (s.tries[key] ?? 0) + 1 }
251  await save($, s)
252}
253
254// Run a lookup and record it: the ledger is what the gate (./gate.ts) checks a reply against. A broken lookup covers
255// nothing, is not a card (it never counts toward the cap), and switches the gate off until a lookup works again.
256async function produce($: any, o: Opts, t: Topic, counted: boolean): Promise<Made | undefined> {
257  const r = await lookup($, o, t)
258  if (!r) return undefined
259  const s = await load($)
260  s.broken = !!r.broken
261  if (!r.broken && r.entry) {
262    s.ledger = [...s.ledger, r.entry].slice(-20)
263    if (counted) s.cards += 1
264  }
265  await save($, s)
266  toast($, r.broken ? '[mod:where-we-stand] lookup unavailable: database not reachable from the hook' : `[mod:where-we-stand] card for ${t.topic}: ${(/VERDICT: ([A-Z ]+)/.exec(r.card)?.[1] ?? '?').toLowerCase()}`)
267  return { text: r.card, broken: !!r.broken }
268}
269
270// The nudge goes into the model's context once per session; after that a broken lookup stays quiet.
271async function nudgeOnce($: any, m: Made): Promise<string | undefined> {
272  if (!m.broken) return m.text
273  const s = await load($)
274  if (s.nudged) return undefined
275  s.nudged = true
276  await save($, s)
277  return m.text
278}
279
280// The ticket the session's own branch names (lc1893-... -> LC-1893), or undefined.
281async function ownTicket($: any): Promise<string | undefined> {
282  try {
283    const r = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { timeoutMs: 10_000 })
284    if (r?.exitCode !== 0) return undefined
285    const t = await py($, { cmd: 'branch_ticket', branch: String(r.stdout ?? '').trim() })
286    return t?.tickets?.[0]
287  } catch {
288    return undefined
289  }
290}
291
292async function save($: any, s: Session): Promise<void> {
293  try { await $.store.set(STORE_KEY, s) } catch { /* the store is a convenience; without it the limits reset */ }
294}
295
296export const register: Register = (on, options) => {
297  const o = (options ?? {}) as Opts
298
299  // The session's own ticket gets its card on the FIRST tool call, not at session start: $.mcp.call is not available in
300  // classic.SessionStart ("no session is bound in this process", the REPL has not mounted yet), so a start-time lookup
301  // always came back empty (LC-1893, second live finding, 2026-10-22). One try per load of the mod.
302  let ownTried = false
303  on('tool.call', async ($, e: any, next) => {
304    const ran = await next(e)
305    if (ownTried || busy || e.tool === 'mcp__where-we-stand__lookup' || ran.deny !== undefined || ran.isError) return ran
306    ownTried = true
307    busy = true
308    try {
309      const key = await ownTicket($)
310      if (!key) return ran
311      const s = await load($)
312      if (s.done.includes(key) || (s.tries[key] ?? 0) >= MAX_TRIES) return ran
313      const made = await produce($, o, { kind: 'ticket-status', topic: key, tickets: [key], terms: [] }, true)
314      await mark($, key, !!made && !made.broken)
315      const card = made ? await nudgeOnce($, made) : undefined
316      return card ? { ...ran, context: [...(ran.context ?? []), card] } : ran
317    } catch {
318      return ran
319    } finally {
320      busy = false
321    }
322  }).catch(($, e, next) => next(e))
323
324  // On demand: the model (or the gate's block message) asks for a card by name.
325  on('session.start', async ($, e, next) => {
326    try {
327      await $.tool.register({
328        name: 'lookup',
329        description:
330          'Where-we-stand card for a ticket, article, job or error: what we know (memories, articles), the ticket status with its NEWEST comment and date, and what GitHub and production say, with every conflict named. ' +
331          'Call it BEFORE stating a ticket status or saying something is published, merged, deployed or live.',
332        inputSchema: {
333          type: 'object',
334          properties: {
335            tickets: { type: 'array', items: { type: 'string' }, description: 'Jira keys, e.g. LC-1345' },
336            terms: { type: 'array', items: { type: 'string' }, description: 'lowercase slugs, job names or error classes, e.g. most-comfortable-couches, catalog-ingest' },
337            kind: { type: 'string', enum: ['ticket-status', 'diagnosis', 'article-status'], description: 'article-status when a term is an article slug or run id' },
338          },
339        },
340      })
341    } catch { /* without the tool the triggers and the gate still work */ }
342    return next(e)
343  }).catch(($, e, next) => next(e))
344
345  on('tool.call', { tool: 'mcp__where-we-stand__lookup' }, async ($, e: any) => {
346    const t = await py($, { cmd: 'normalize', tickets: e.tickets, terms: e.terms, kind: e.kind })
347    if (!t?.topic) return { result: '[mod:where-we-stand] Give at least one valid Jira key (tickets) or slug / job name (terms).' }
348    busy = true
349    try {
350      const made = await produce($, o, t, false)
351      return { result: made?.text ?? '[mod:where-we-stand] The lookup could not run; treat every status as NOT CHECKED.' }
352    } finally {
353      busy = false
354    }
355  }).catch(($, e, next) => next(e))
356  on('tool.call', { tool: /^mcp__.*__(execute_sql|getJiraIssue|fetchJiraIssue)$/ }, async ($, e, next) => {
357    const ran = await next(e)
358    if (busy || ran.deny !== undefined || ran.isError) return ran
359    busy = true
360    try {
361      const t = await py($, { cmd: 'trigger', ...toolPayload(e as any) })
362      if (!t || (!t.kind && !t.seen)) return ran
363      const s = await load($)
364      if (t.seen) {
365        s.seen = [...new Set([...s.seen, ...t.seen])].slice(-40)
366        await save($, s)
367        return ran
368      }
369      const names: string[] = [...t.tickets, ...t.terms]
370      const key = [...names].sort().join('|')
371      if (s.cards >= MAX_CARDS || s.done.includes(key) || (s.tries[key] ?? 0) >= MAX_TRIES || names.every(n => s.seen.includes(n))) return ran
372      const made = await produce($, o, t, true)
373      await mark($, key, !!made && !made.broken)
374      const card = made ? await nudgeOnce($, made) : undefined
375      if (!card) return ran
376      return { ...ran, context: [...(ran.context ?? []), card] }
377    } catch {
378      return ran
379    } finally {
380      busy = false
381    }
382  // Fail open: a bug in the mod must never stop a tool call or a turn.
383  }).catch(($, e, next) => next(e))
384
385  // The backstop (LC-1893): a reply that states a status or says something is published / merged / deployed / live,
386  // with no card in this session for that subject, is sent back ONCE (Claude Code sets stop_hook_active on the stop
387  // after a block, and wws_gate.py always passes then, so it cannot loop). The NEEDS YOU guard (wws_needs_you.py, LC-1907) runs in
388  // the same stop: a reply that opens with the banner and puts a decision_moot ticket to the operator. It lives in this plugin because the
389  // cards it checks against are in this plugin's store, and a plugin's store is private to it.
390  on('classic.Stop', async ($, e, next) => {
391    const s = await load($)
392    // While the lookup is broken it cannot produce the card the gate would demand: sending a reply back for it is noise.
393    if (s.broken) return next(e)
394    const v = await py($, { cmd: 'gate', ...(e as any), cards: s.ledger, moot_blocks: s.mootBlocks ?? 0 })
395    if (typeof v?.block === 'string' && v.block) {
396      // The NEEDS YOU guard (LC-1907) blocks a moot ask again on the re-send; this count is what stops that after MAX_REPEAT.
397      if (v.block.includes('NEEDS YOU guard')) {
398        s.mootBlocks = (s.mootBlocks ?? 0) + 1
399        await save($, s)
400      }
401      toast($, v.block)
402      return { block: v.block }
403    }
404    return next(e)
405  }).catch(($, e, next) => next(e))
406}
407
hooks/py.ts 87 lines
1// Shared by every policy mod that wraps a Python check (LCH-528). A mod cannot import a
2// sibling, so each carries a byte-identical copy; claude-mods.yml fails if the copies drift.
3//
4// Why Python under a mod: the checks were built and mutation-tested as stdlib Python
5// (classic hook in, classic hook out). The mod is how they reach every repo's sessions;
6// the .py file stays the single place the rule's logic lives, with its own --selftest.
7//
8// PURE BY CONSTRAINT: the plugin validator (claude-code 2.1.292, the version CI pins) follows
9// `$` only into functions declared in the hook's own file. So nothing here takes `$`; each
10// register.ts keeps three small `$` helpers (runCheck, preToolUse, blockOf) over these.
11
12export type HookOut = {
13  hookSpecificOutput?: {
14    permissionDecision?: string
15    permissionDecisionReason?: string
16    additionalContext?: string
17  }
18  decision?: string
19  reason?: string
20}
21
22// tool.call envelope fields that are the engine's, not the tool's arguments.
23const RESERVED = new Set(['tool', 'tool_use_id', 'agentId', 'requestMeta'])
24
25/** The classic hook payload's tool fields, rebuilt from a tool.call envelope. */
26export function toolPayload(e: Record<string, unknown>): { tool_name: string; tool_input: Record<string, unknown> } {
27  const tool_input: Record<string, unknown> = {}
28  for (const [k, v] of Object.entries(e)) if (!RESERVED.has(k)) tool_input[k] = v
29  return { tool_name: String(e.tool ?? ''), tool_input }
30}
31
32/** argv and init for `$.process.run`: hooks/<script> with the payload on stdin. */
33export function runArgs(root: string, script: string, payload: unknown): { argv: string[]; init: { stdin: string; timeoutMs: number } } {
34  return { argv: ['python3', '-I', `${root}/hooks/${script}`], init: { stdin: JSON.stringify(payload), timeoutMs: 10_000 } }
35}
36
37/**
38 * The check's JSON output, or undefined on a non-zero exit, silence or junk. The checks fail
39 * open (a guard that fails shut blocks every call and gets removed), as they did as hooks.
40 */
41export function parseOut(r: { exitCode?: number; stdout?: unknown }): HookOut | undefined {
42  const out = String(r?.stdout ?? '').trim()
43  if (r?.exitCode !== 0 || !out) return undefined
44  try {
45    const v = JSON.parse(out)
46    return v && typeof v === 'object' ? (v as HookOut) : undefined
47  } catch {
48    return undefined
49  }
50}
51
52/**
53 * A classic.PreToolUse answer from a check's output. A check that only warns
54 * (additionalContext, no decision) lets the call run with its note.
55 */
56export function decisionOf(v: HookOut | undefined):
57  { deny: string } | { ask: string } | { additionalContext: string[] } | undefined {
58  const h = v?.hookSpecificOutput
59  const reason = String(h?.permissionDecisionReason ?? '')
60  if (h?.permissionDecision === 'deny' && reason) return { deny: reason }
61  if (h?.permissionDecision === 'ask' && reason) return { ask: reason }
62  const note = contextOf(v)
63  return note ? { additionalContext: [note] } : undefined
64}
65
66/** The check's additionalContext, if it gave one. */
67export function contextOf(v: HookOut | undefined): string | undefined {
68  const c = v?.hookSpecificOutput?.additionalContext
69  return typeof c === 'string' && c ? c : undefined
70}
71
72/** A Stop block reason, if the check asked to block the turn's end. */
73export function blockReason(v: HookOut | undefined): string | undefined {
74  return v?.decision === 'block' && typeof v.reason === 'string' && v.reason ? v.reason : undefined
75}
76
77/** The text a tool call's result carries, for checks that read it (PostToolUse-style). */
78export function resultText(ran: { text?: unknown; result?: unknown }): string {
79  if (typeof ran.text === 'string') return ran.text
80  try { return JSON.stringify(ran.result ?? '') } catch { return '' }
81}
82
83/** A toast's text: the first line, capped. */
84export function toastText(text: string): string {
85  return text.split('\n')[0]!.slice(0, 200)
86}
87