SLOPSHOPPER

session-title-nudge

Puts the session-naming convention (`<TICKET> — <summary>`) in front of the model at session start, and a rename reminder when createJiraIssue mints a key.

newguardprocess
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 54 lines
1import type { Register } from 'claude-code'
2
3import type { HookOut } from './py'
4import { blockReason, contextOf, decisionOf, parseOut, resultText, runArgs, toastText, toolPayload } from './py'
5
6// Why (LCH-528, operator preference: name chats by ticket): a hook cannot read the title, so it puts
7// the rule in front of the model at the two moments it matters: the start, and the moment a ticket
8// key first exists. Text and tests: ./session-title-nudge.py.
9// --- $ helpers. They live in this file because the plugin validator follows $ only into functions
10// declared in the hook's own file (claude-code 2.1.292); ./py.ts stays pure.
11async function runCheck($: any, script: string, payload: unknown): Promise<HookOut | undefined> {
12  try {
13    const { argv, init } = runArgs($.plugin.root, script, payload)
14    return parseOut(await $.process.run(argv, init))
15  } catch {
16    return undefined
17  }
18}
19
20function toast($: any, text: string): void {
21  try { $.ui.toast(toastText(text)) } catch { /* a toast never changes a verdict */ }
22}
23
24// Deny and ask are toasted for the operator; a warning is not.
25function preToolUse($: any, v: HookOut | undefined) {
26  const d = decisionOf(v)
27  if (d && 'deny' in d) toast($, d.deny)
28  if (d && 'ask' in d) toast($, d.ask)
29  return d
30}
31
32function blockOf($: any, v: HookOut | undefined): string | undefined {
33  const r = blockReason(v)
34  if (r) toast($, r)
35  return r
36}
37
38export const register: Register = on => {
39  on('classic.SessionStart', async ($, e, next) => {
40    const ran = await next(e)
41    const note = contextOf(await runCheck($, 'session-title-nudge.py', { hook_event_name: 'SessionStart' }))
42    return note ? { ...ran, additionalContext: [...(ran.additionalContext ?? []), note] } : ran
43  }).catch(($, e, next) => next(e))
44
45  on('tool.call', { tool: /^mcp__.*__createJiraIssue$/ }, async ($, e, next) => {
46    const ran = await next(e)
47    if (ran.deny !== undefined || ran.isError) return ran
48    const note = contextOf(await runCheck($, 'session-title-nudge.py',
49      { hook_event_name: 'PostToolUse', tool_name: String((e as any).tool), tool_response: resultText(ran) }))
50    return note ? { ...ran, context: [...(ran.context ?? []), note] } : ran
51  // Fail open: a bug in the mod must never stop a tool call or a turn (runCheck already fails open).
52  }).catch(($, e, next) => next(e))
53}
54
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