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…

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 userhooks/register.ts 407 lines1import 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}
407hooks/py.ts 87 lines1// 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