SLOPSHOPPER

recurring-successor-guard

Refuses closing a recurring Jira ticket (label `recurring` or a 'next due' line) until the session created its successor ticket or scheduled task.

newguardtoastprocess
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 3 files
hooks/register.ts 66 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Row } from '../types'
5import type { HookOut } from './py'
6import { blockReason, contextOf, decisionOf, parseOut, resultText, runArgs, toastText, toolPayload } from './py'
7
8// Why (LCH-528, operator rule: recurring work gets its successor scheduled before it is closed): closing a
9// recurring ticket with no successor is how a monthly check silently stops. A PreToolUse hook gets
10// no transcript, so this mod records the calls the check reads (the ticket, its transitions, any
11// successor) and hands them over as transcript rows. Logic and tests: ./recurring-successor-guard.py.
12const RECORD = /^(mcp__.*__(getJiraIssue|getTransitionsForJiraIssue|createJiraIssue|create_trigger|send_later)|CronCreate)$/
13const MAX_ROWS = 200
14const MAX_TEXT = 20_000
15const rows = atom({ plugin: 'recurring-successor-guard', key: 'rows' } as const, [] as Row[])
16
17// --- $ helpers. They live in this file because the plugin validator follows $ only into functions
18// declared in the hook's own file (claude-code 2.1.292); ./py.ts stays pure.
19async function runCheck($: any, script: string, payload: unknown): Promise<HookOut | undefined> {
20  try {
21    const { argv, init } = runArgs($.plugin.root, script, payload)
22    return parseOut(await $.process.run(argv, init))
23  } catch {
24    return undefined
25  }
26}
27
28function toast($: any, text: string): void {
29  try { $.ui.toast(toastText(text)) } catch { /* a toast never changes a verdict */ }
30}
31
32// Deny and ask are toasted for the operator; a warning is not.
33function preToolUse($: any, v: HookOut | undefined) {
34  const d = decisionOf(v)
35  if (d && 'deny' in d) toast($, d.deny)
36  if (d && 'ask' in d) toast($, d.ask)
37  return d
38}
39
40function blockOf($: any, v: HookOut | undefined): string | undefined {
41  const r = blockReason(v)
42  if (r) toast($, r)
43  return r
44}
45
46export const register: Register = on => {
47  on('tool.call', { tool: RECORD }, async ($, e, next) => {
48    const ran = await next(e)
49    if (ran.deny !== undefined || ran.isError) return ran
50    const { tool_name, tool_input } = toolPayload(e as any)
51    const id = String((e as any).tool_use_id ?? '')
52    await update($, rows, l => [...l,
53      { type: 'assistant', message: { content: [{ type: 'tool_use', id, name: tool_name, input: tool_input }] } },
54      { type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: id, content: resultText(ran).slice(0, MAX_TEXT) }] } },
55    ].slice(-MAX_ROWS))
56    return ran
57  // A recording failure must never change the call's outcome.
58  }).catch(($, e, next) => next(e))
59
60  on('classic.PreToolUse', { tool: /^mcp__.*__transitionJiraIssue$/ }, async ($, e, next) =>
61    preToolUse($, await runCheck($, 'recurring-successor-guard.py',
62      { ...toolPayload(e as any), transcript_rows: await read($, rows) })) ?? next(e),
63  // Fail open: a bug in the mod must never stop a tool call or a turn (runCheck already fails open).
64  ).catch(($, e, next) => next(e))
65}
66
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
types/index.d.ts 8 lines
1export type Row = Record<string, unknown>
2
3declare module 'claude-code' {
4  interface PluginState {
5    'recurring-successor-guard': { rows: Row[] }
6  }
7}
8