SLOPSHOPPER

pillar-brief-guard

Enforces the pillar-brief rules in any session: no article idea without a parent article, no product selection or article writing without an approved (<= 365…

newguardtoast
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 1 files
hooks/register.ts 647 lines
1import type { Register } from 'claude-code'
2
3// Why this exists (LC-1731, rulings in memories 2ef2354a / 1c7e219e, LC-1726): operator ruling, 2026-10-08,
4// that memories are suggestions sessions ignore. The pillar-brief rules are enforced here, at the
5// moment they matter, in ANY session (not only refresh runs):
6//   1. a new article idea (Jira LCC, label article-idea) names a parent article that EXISTS, so its
7//      brief can inherit from it;
8//   2. product selection and article writing happen only against an APPROVED pillar brief
9//      (public.pillar_briefs, status='approved', approved_at within 365 days);
10//   3. every approval names a configured human approver (userConfig `approvers`, default the operator).
11// Business analogy: the foreman who will not let the crew order materials until the blueprint
12// is signed, and who wants to see whose signature it is.
13//
14// Fail closed, never silently allow. A lookup that cannot run (no Supabase MCP, SQL error) DENIES with the fix:
15// an unattended session cannot answer a question, and a stall is worse than a clear refusal. The only ASK left
16// is an expired brief (> 365 days) after a successful lookup.
17// Triggers are RECOGNISED actions only (tool + argv shapes), never names appearing in text or heredoc bodies.
18// Cost: $0 (one read-only SQL per gated call, no paid API).
19//
20// Not covered (README lists the same): Jira edits that ADD the label later, Webflow MCP
21// writes, other selection writers (write_stage_receipt.py), shell writes built from variables.
22
23const NAME = 'pillar-brief-guard'
24const YEAR_MS = 365 * 86_400_000
25const ALLOW = 'Allow this call'
26const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
27const SLUG = /^[a-z0-9][a-z0-9-]{0,119}$/
28const CREATE_ISSUE = /__createJiraIssue$/
29const DISPATCH = /__actions_run_trigger$/
30const GH_FILE = /__(create_or_update_file|push_files)$/
31const ARTICLE_FILE_SKIP = /^(readme|claude|agents|changelog|license|contributing)\.md$/i
32// An unattended session cannot answer a question, and a stall is worse than a clear refusal
33// (orchestrator ruling, 2026-10-08, after run-state asked in a session with no Supabase MCP).
34// Do NOT promise a server-side fallback here: with no approved brief product_shortlist.py falls
35// back to search demand, so nothing but this guard stops the selection.
36const FIX_LOOKUP = 'Fix: connect the Supabase MCP so the approved pillar brief can be checked; nothing selects products without it.'
37const AFTER_FAIL = 'the guard failed after the call ran; treat its result as unchecked.'
38
39type Opts = { supabaseServer?: string; projectId?: string; approvers?: string | string[] }
40type Verdict = { deny?: string; ask?: string }
41type Target =
42  | { kind: 'run'; id: string }
43  | { kind: 'slug'; slug: string }
44  | { kind: 'articleId'; id: string }
45  | { kind: 'unknown'; why: string }
46
47const STEP_TO_RUN = (slug: string): string =>
48  `Step to run: draft the brief (brief_stage.py draft --article-slug ${slug} [--parent-slug <parent>], LC-1728; ` +
49  `until it lands, pillar_brief.py draft --file <brief.json> --apply), get the operator's approval ` +
50  `(pillar_brief.py approve --brief-id <id> --by "the operator"), then retry.`
51
52// Callout convention (claude-mods/README.md): prefix for the model, toast for the operator.
53function say($: any, text: string): string {
54  const msg = `[mod:${NAME}] ${text}`
55  $.ui.toast(msg)
56  return msg
57}
58
59function arg(e: any, key: string): unknown {
60  return e?.[key] ?? e?.input?.[key]
61}
62
63function asObject(v: unknown): Record<string, unknown> {
64  if (typeof v === 'string') { try { return asObject(JSON.parse(v)) } catch { return {} } }
65  return v && typeof v === 'object' ? (v as Record<string, unknown>) : {}
66}
67
68// ---------------------------------------------------------------- approvers
69
70function approversOf(o: Opts): string[] {
71  const raw = Array.isArray(o.approvers) ? o.approvers : typeof o.approvers === 'string' ? o.approvers.split(',') : ['the operator']
72  const list = raw.map(a => String(a).trim()).filter(Boolean)
73  return list.length ? list : ['the operator']
74}
75
76// The operator approves every brief: the approver must be one of the configured humans, so `claude` or `x` fail.
77// Recorded approvals carry the channel ("operator (chat)", "operator (portal)") and may drop the article, so both
78// sides are compared as a bare name: a leading "the " and a trailing "(...)" are ignored, nothing else is.
79// Briefs approved before LC-1889 were recorded under the owner's first name; they stay valid, so that
80// legacy spelling (lowercase, compared case-insensitively) counts as the same approver as "operator".
81const LEGACY_APPROVER_ALIASES: Record<string, string> = { stan: 'operator' }
82const bareName = (s: string): string => {
83  const n = s.trim().toLowerCase().replace(/\s*\([^)]*\)\s*$/, '').replace(/^the\s+/, '')
84  return LEGACY_APPROVER_ALIASES[n] ?? n
85}
86function approverOk(o: Opts, v: unknown): boolean {
87  return typeof v === 'string' && !!bareName(v) && approversOf(o).some(a => bareName(a) === bareName(v))
88}
89
90const approverDeny = (o: Opts, where: string): string =>
91  `${where} needs a named human approver (${approversOf(o).join(' or ')}): an approval must say who gave it, and the operator approves every brief.`
92
93// ---------------------------------------------------------------- text helpers
94
95// A Jira description may be Markdown or an ADF document; return the plain text lines.
96function plainText(v: unknown): string {
97  if (typeof v === 'string') return v
98  const out: string[] = []
99  const walk = (n: unknown): void => {
100    if (Array.isArray(n)) n.forEach(walk)
101    else if (n && typeof n === 'object') {
102      const o = n as Record<string, unknown>
103      if (typeof o.text === 'string') out.push(o.text)
104      if (o.type === 'paragraph' || o.type === 'hardBreak') out.push('\n')
105      walk(o.content)
106    }
107  }
108  walk(v)
109  return out.join('')
110}
111
112// `Parent article: <slug>` on a line of its own, tolerating Markdown emphasis/backticks/bullets.
113const PARENT = /^[\s>*_`+-]*parent article[\s*_`]*:[\s*_`]*([a-z0-9][a-z0-9-]*)(?=[\s*_`.,;)]|$)/im
114const PLACEHOLDER = /^(none|n-a|na|tbd|todo|unknown|null|nil|slug|parent|xxx+)$/
115
116function parentSlug(description: unknown): string | undefined {
117  const m = PARENT.exec(plainText(description))
118  const slug = m?.[1]?.toLowerCase()
119  return slug && !PLACEHOLDER.test(slug) && SLUG.test(slug) ? slug : undefined
120}
121
122// Minimal base64 -> text (the GitHub contents API takes base64; atob is not guaranteed here).
123const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
124function fromBase64(s: string): string {
125  let out = ''
126  let acc = 0
127  let bits = 0
128  for (const ch of s.replace(/[^A-Za-z0-9+/]/g, '')) {
129    acc = (acc << 6) | B64.indexOf(ch)
130    bits += 6
131    if (bits >= 8) { bits -= 8; out += String.fromCharCode((acc >> bits) & 255); acc &= (1 << bits) - 1 }
132  }
133  return out
134}
135
136// Frontmatter = the block between the opening --- and the FIRST closing ---; a `slug:` line in the
137// body (after the fence) must not name the article. Only a file whose frontmatter carries BOTH
138// slug: and title: is an article (a README, a notes file or a template is not).
139function articleSlug(content: string): string | undefined {
140  content = content.replace(/^\uFEFF/, '') // a byte-order mark must not hide the frontmatter
141  const block = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(content)?.[1] ?? ''
142  const slug = /^slug:[ \t]*["']?([a-z0-9][a-z0-9-]*)/im.exec(block)?.[1]
143  return slug && /^title:[ \t]*\S/im.test(block) ? slug.toLowerCase() : undefined
144}
145
146function articlePath(path: string, repo: string): boolean {
147  const parts = path.split('/')
148  const base = parts[parts.length - 1] ?? ''
149  return (
150    /\.md$/i.test(base) && !ARTICLE_FILE_SKIP.test(base) && parts.includes('articles') &&
151    (parts.includes('bbm-content') || repo === 'bbm-content') &&
152    !parts.some(p => p === '.github' || p === 'node_modules' || p === 'docs')
153  )
154}
155
156// Splits a shell command into segments of tokens (quotes respected; ; & | newline end a
157// segment; heredoc bodies are text and skipped). A token that itself holds whitespace after
158// `-c` / `eval` is re-tokenised one level.
159function segments(cmd: string, depth = 0): string[][] {
160  const segs: string[][] = []
161  let cur: string[] = []
162  let tok = ''
163  let has = false
164  let q = ''
165  const push = (): void => { if (has) cur.push(tok); tok = ''; has = false }
166  const end = (): void => { push(); if (cur.length) segs.push(normSeg(cur)); cur = [] }
167  const here: string[] = [] // heredoc delimiters waiting for their body
168  for (let i = 0; i < cmd.length; i++) {
169    const c = cmd[i]
170    // `# comment` (only at the start of a word, outside quotes): an apostrophe in it must not open a quote.
171    if (!q && c === '#' && !has) { while (i + 1 < cmd.length && cmd[i + 1] !== '\n') i++; continue }
172    // Arithmetic `$((1<<3))` / `((1<<3))` holds `<<` that is not a heredoc: skip it whole.
173    if (!q && (cmd.startsWith('$((', i) || (!has && cur.length === 0 && cmd.startsWith('((', i)))) {
174      let depth = 0
175      let j = i + (cmd[i] === '$' ? 1 : 0)
176      for (; j < cmd.length; j++) {
177        if (cmd[j] === '(') depth++
178        else if (cmd[j] === ')' && --depth === 0) break
179      }
180      tok += cmd.slice(i, j + 1)
181      has = true
182      i = j
183      continue
184    }
185    if (!q && c === '<' && cmd[i + 1] === '<' && cmd[i + 2] !== '<' && cmd[i - 1] !== '<') {
186      const m = /^<<-?\s*(?:'([^']+)'|"([^"]+)"|([A-Za-z0-9_]+))/.exec(cmd.slice(i))
187      if (m) { here.push(m[1] ?? m[2] ?? m[3]); i += m[0].length - 1; continue }
188    }
189    if (!q && c === '\n' && here.length) {
190      end()
191      // The body is text, not commands. If its terminator is never found we do NOT swallow the rest of the
192      // command (that would hide a real command after a fake heredoc): it is parsed as commands instead.
193      for (const word of here.splice(0)) {
194        let j = i + 1
195        let found = false
196        while (j < cmd.length) {
197          let nl = cmd.indexOf('\n', j)
198          if (nl < 0) nl = cmd.length
199          const line = cmd.slice(j, nl).trim()
200          j = nl + 1
201          if (line === word) { found = true; break }
202        }
203        if (found) i = j - 1
204      }
205      continue
206    }
207    if (q) {
208      if (c === q) q = ''
209      else if (c === '\\' && q === '"' && i + 1 < cmd.length) { tok += cmd[++i] }
210      else tok += c
211    } else if (c === '"' || c === "'") { q = c; has = true }
212    else if (c === '\\' && i + 1 < cmd.length) { tok += cmd[++i]; has = true }
213    else if (c === ';' || c === '&' || c === '|' || c === '\n') end()
214    else if (c === ' ' || c === '\t') push()
215    else { tok += c; has = true }
216  }
217  end()
218  if (depth < 8) { // nested `bash -c "bash -c ..."`: bounded, deep enough for any real command
219    for (const s of [...segs]) {
220      s.forEach((t, i) => { if (/\s/.test(t) && (/^-[a-z]*c$/.test(s[i - 1] ?? '') || s[0] === 'eval')) segs.push(...segments(t, depth + 1)) })
221    }
222  }
223  return segs
224}
225
226// Leading shell keywords and grouping (then/do/else/if/while, `(`, `{`, `!`) are not part of the command.
227const SHELL_LEAD = /^(then|do|else|elif|if|while|until|!)$/
228function normSeg(seg: string[]): string[] {
229  const out = [...seg]
230  for (;;) {
231    if (out.length && (SHELL_LEAD.test(out[0]) || /^[({]+$/.test(out[0]))) { out.shift(); continue }
232    if (out.length && /^[({]+./.test(out[0])) { out[0] = out[0].replace(/^[({]+/, ''); continue }
233    // `( cmd --run-id X )` leaves the group's closing bracket glued to the last word.
234    const last = out.length - 1
235    if (last >= 0 && /^[^()]+\)+$/.test(out[last])) out[last] = out[last].replace(/\)+$/, '')
236    return out
237  }
238}
239
240// --flag value | --flag=value. present=false when the flag is absent; value '' when it has none.
241// argparse accepts any unambiguous prefix of a long option (`--run` for `--run-id`), so a long flag
242// also matches by prefix (2+ chars after `--`): a bypass by abbreviation would otherwise be silent.
243const KNOWN_LONG = ['--run-id', '--article', '--article-id', '--article-slug', '--slug', '--plan', '--by', '--output', '--html', '--render']
244function flag(seg: string[], names: string[]): { present: boolean; value: string } {
245  for (let i = 0; i < seg.length; i++) {
246    const t = seg[i]
247    for (const n of names) {
248      if (t === n) return { present: true, value: seg[i + 1] ?? '' }
249      if (t.startsWith(n + '=')) return { present: true, value: t.slice(n.length + 1) }
250      if (n.startsWith('--') && t.startsWith('--')) {
251        const eq = t.indexOf('=')
252        const name = eq < 0 ? t : t.slice(0, eq)
253        if (name.length >= 4 && n.startsWith(name) && !KNOWN_LONG.includes(name)) return { present: true, value: eq < 0 ? seg[i + 1] ?? '' : t.slice(eq + 1) }
254      }
255    }
256  }
257  return { present: false, value: '' }
258}
259
260// Tokens in flag position: the value of a value-taking flag (`--output --selftest`) is not a flag.
261function flagTokens(seg: string[], valueFlags: string[]): string[] {
262  const out: string[] = []
263  for (let i = 0; i < seg.length; i++) {
264    out.push(seg[i])
265    // argparse takes the next word as the value of an abbreviated value flag too (`--out --selftest`).
266    const t = seg[i]
267    if (valueFlags.includes(t) || (t.startsWith('--') && !t.includes('=') && t.length >= 4 && valueFlags.some(f => f.startsWith(t)))) i++
268  }
269  return out
270}
271
272// The command must be EXECUTED: first word, or behind only an interpreter / env assignments / a
273// launcher. `grep product_shortlist.py x`, `cat ...pillar_brief.py` and the like do not count.
274// A script matches as a file (`x/product_shortlist.py`) or as a module (`python -m a.product_shortlist`).
275const LAUNCHER = /^(python[\d.]*|-[A-Za-z]+|[A-Za-z_][A-Za-z0-9_]*=\S*|env|uv|run|poetry|sudo|time|nice|timeout|nohup|exec|command|\d+)$/
276const PILLAR_BRIEF = /(^|[/.])pillar_brief(\.py)?$/
277const SHORTLIST = /(^|[/.])product_shortlist(\.py)?$/
278const ROSTER = /(^|[/.])roster_materialize(\.py)?$/
279const script = (seg: string[], re: RegExp): number => {
280  const i = seg.findIndex(t => re.test(t))
281  return i >= 0 && seg.slice(0, i).every(t => LAUNCHER.test(t)) ? i : -1
282}
283
284// ---------------------------------------------------------------- brief lookup
285
286// A reply that is itself a JSON object carrying a string `result` is unwrapped to that string.
287function unwrapResult(text: string): string {
288  const t = text.trim()
289  if (!t.startsWith('{')) return text
290  try {
291    const o = JSON.parse(t)
292    return o && typeof o === 'object' && typeof o.result === 'string' ? o.result : text
293  } catch {
294    return text
295  }
296}
297
298
299function rowsFromMcp(content: readonly unknown[]): unknown[] {
300  const joined = (content ?? [])
301    .map(b => (b && typeof b === 'object' && 'text' in b ? String((b as { text: unknown }).text) : ''))
302    .join('')
303  // The Supabase MCP may return the whole reply as JSON, {"result":"...<untrusted-data>[{\"id\":...}]..."},
304  // so the rows inside still carry escaped quotes. Unwrap that layer first; anything else stays as text.
305  const text = unwrapResult(joined)
306  const env = /<untrusted-data-[^>]*>([\s\S]*)<\/untrusted-data-[^>]*>/.exec(text)
307  const body = env ? env[1] : text
308  const open = body.indexOf('[')
309  const close = body.lastIndexOf(']')
310  if (open < 0 || close < open) throw new Error('execute_sql returned no row array')
311  const rows = JSON.parse(body.slice(open, close + 1))
312  if (!Array.isArray(rows)) throw new Error('execute_sql rows were not an array')
313  return rows
314}
315
316async function sql($: any, o: Opts, query: string): Promise<any[]> {
317  const res = await $.mcp.call(o.supabaseServer || 'Supabase', 'execute_sql', {
318    project_id: o.projectId || 'tlcbwwgfqnasmiipgmmd',
319    query,
320  })
321  if (res.isError) throw new Error('execute_sql returned an error')
322  return rowsFromMcp(res.content)
323}
324
325// Every id/slug is shape-checked before it reaches SQL; anything else never gets here.
326async function briefVerdict($: any, o: Opts, t: Target, what: string): Promise<Verdict> {
327  try {
328    if (t.kind === 'unknown') {
329      return { deny: `cannot tell which article this ${what} is for (${t.why}), so its pillar brief cannot be checked and the call is refused. Name the article (--run-id, --article or a readable --plan).` }
330    }
331    let where: string
332    let label: string
333    if (t.kind === 'run') {
334      const runs = await sql($, o, `select article_id, article_slug from public.refresh_runs where id = '${t.id}' limit 1`)
335      if (runs.length !== 1 || !UUID.test(String(runs[0].article_id))) {
336        return { deny: `cannot find refresh run ${t.id}, so its article's pillar brief cannot be checked and this ${what} is refused. Check the run id.` }
337      }
338      where = `article_id = '${runs[0].article_id}'`
339      label = String(runs[0].article_slug ?? runs[0].article_id)
340    } else if (t.kind === 'articleId') {
341      where = `article_id = '${t.id}'`
342      label = t.id
343    } else {
344      // Through public.articles, never pillar_briefs.article_slug (a drafting-time snapshot).
345      where = `article_id = (select id from public.articles where slug = '${t.slug}')`
346      label = t.slug
347    }
348    const rows = await sql(
349      $, o,
350      'select id, article_slug, version, approved_at, approved_by from public.pillar_briefs ' +
351        `where ${where} and status = 'approved' order by version desc limit 1`,
352    )
353    if (rows.length === 0) {
354      return { deny: `no approved pillar brief for "${label}". Article selection and writing happen only against an approved brief (public.pillar_briefs, status='approved'), so this ${what} is refused. ${STEP_TO_RUN(label)}` }
355    }
356    const b = rows[0]
357    const at = Date.parse(String(b.approved_at))
358    if (Number.isNaN(at)) return { deny: `the approved brief for "${label}" has no readable approved_at, so its age cannot be checked and this ${what} is refused. Re-approve it with pillar_brief.py approve --by "the operator".` }
359    if (!approverOk(o, b.approved_by)) return { deny: `the approved brief for "${label}" names no configured approver (${approversOf(o).join(' or ')}), and every approval must; this ${what} is refused. Re-approve it with pillar_brief.py approve --by "the operator".` }
360    const age = (await $.clock.now()) - at
361    if (age > YEAR_MS) {
362      const days = Math.floor(age / 86_400_000)
363      return { ask: `the approved brief for "${label}" (v${b.version}, approved ${String(b.approved_at).slice(0, 10)}) is ${days} days old, past the 365-day yearly refresh. Draft a fresh brief first, or confirm this ${what} on the expired one.` }
364    }
365    return {}
366  } catch (err) {
367    return { deny: `the pillar-brief lookup could not run (${err instanceof Error ? err.message : String(err)}), so this ${what} is refused (fail closed). ${FIX_LOOKUP}` }
368  }
369}
370
371// ask: the engine's question dialog; dismissed or nobody to ask (-p run) means refuse.
372async function resolve($: any, v: Verdict): Promise<{ deny: string } | null> {
373  if (v.deny) return { deny: say($, v.deny) }
374  if (v.ask) {
375    const asking = say($, v.ask)
376    let choice = ''
377    try {
378      choice = await $.ui.ask(`${asking} Allow this call?`, [ALLOW, 'Refuse'])
379    } catch {
380      return { deny: `${asking} (no one answered; refused)` }
381    }
382    if (choice !== ALLOW) return { deny: `${asking} (refused)` }
383  }
384  return null
385}
386
387function targetFrom(runId: string, slug: string, articleId: string): Target {
388  if (runId) return UUID.test(runId) ? { kind: 'run', id: runId.toLowerCase() } : { kind: 'unknown', why: `run id "${runId.slice(0, 40)}" is not a UUID` }
389  if (articleId) return UUID.test(articleId) ? { kind: 'articleId', id: articleId.toLowerCase() } : { kind: 'unknown', why: 'article id is not a UUID' }
390  if (slug) return SLUG.test(slug.toLowerCase()) ? { kind: 'slug', slug: slug.toLowerCase() } : { kind: 'unknown', why: `slug "${slug.slice(0, 40)}" is not a valid article slug` }
391  return { kind: 'unknown', why: 'the call names no run, slug or article id' }
392}
393
394// roster_materialize.py apply names only a plan file; the article is inside it.
395async function planTarget($: any, seg: string[]): Promise<Target> {
396  const f = flag(seg, ['--plan']).value
397  if (!f) return { kind: 'unknown', why: 'apply names no --plan file' }
398  try {
399    const plan = asObject(await $.fs.read(f))
400    const a = String(plan.article_id ?? asObject(plan.article).id ?? '')
401    if (UUID.test(a)) return { kind: 'articleId', id: a.toLowerCase() }
402    const s = String(plan.article_slug ?? plan.slug ?? '').toLowerCase()
403    if (SLUG.test(s)) return { kind: 'slug', slug: s }
404    return { kind: 'unknown', why: `${f} carries no article id or slug` }
405  } catch {
406    return { kind: 'unknown', why: `the plan file ${f.slice(0, 80)} is unreadable` }
407  }
408}
409
410// ---------------------------------------------------------------- workflow dispatch (MCP and gh CLI)
411
412// Which guarded workflow a dispatch is. A numeric workflow id cannot be mapped to a file name here,
413// so it counts as a match when its inputs carry step=shortlist / mode=approve.
414function dispatchKind(wf: string, inputs: Record<string, unknown>): 'approve' | 'shortlist' | null {
415  const name = (wf.split('/').pop() ?? '').toLowerCase()
416  const numeric = /^\d+$/.test(name)
417  const step = String(inputs.step ?? '').trim().toLowerCase()
418  const mode = String(inputs.mode ?? '').trim().toLowerCase()
419  if (mode === 'approve' && (numeric || /^run-pillar-brief\.ya?ml$/.test(name))) return 'approve'
420  if (step === 'shortlist' && (numeric || /^run-heading-research\.ya?ml$/.test(name))) return 'shortlist'
421  return null
422}
423
424async function dispatchVerdict($: any, o: Opts, kind: 'approve' | 'shortlist', inputs: Record<string, unknown>): Promise<Verdict> {
425  if (kind === 'approve') {
426    return approverOk(o, inputs.approved_by) ? {} : { deny: approverDeny(o, 'a run-pillar-brief.yml mode=approve dispatch (inputs.approved_by)') }
427  }
428  // apply is a boolean workflow input that defaults to false: unset, '' and false run the step's selftest
429  // only (nothing is selected). true selects. Any other value is out of the enum: deny, never guess.
430  const apply = typeof inputs.apply === 'boolean' ? String(inputs.apply) : String(inputs.apply ?? '').trim().toLowerCase()
431  if (apply === '' || ['false', '0', 'no', 'off', 'n', 'f'].includes(apply)) return {}
432  // Looks true to a human or to a shell-ish coercion (1/yes/on): judge it as a selection.
433  if (!['true', '1', 'yes', 'on', 'y', 't'].includes(apply)) return { deny: `run-heading-research.yml inputs.apply must be true or false (got "${apply.slice(0, 20)}"), so the shortlist dispatch cannot be judged and is refused.` }
434  return briefVerdict($, o, targetFrom(String(inputs.run_id ?? '').trim(), '', ''), 'shortlist dispatch')
435}
436
437// `gh workflow run <wf> [-R repo] [--ref r] -f k=v ...` -> { wf, inputs }; json=true when inputs come from stdin.
438function ghDispatch(seg: string[]): { wf: string; inputs: Record<string, unknown>; json: boolean } | null {
439  const gh = seg.findIndex(t => t === 'gh' || t.endsWith('/gh'))
440  if (gh < 0 || !seg.slice(0, gh).every(t => LAUNCHER.test(t))) return null
441  // gh global flags may precede the subcommand: `gh -R o/r workflow run ...`
442  let k = gh + 1
443  while (k < seg.length && seg[k].startsWith('-')) k += seg[k] === '-R' || seg[k] === '--repo' ? 2 : 1
444  if (seg[k] !== 'workflow' || seg[k + 1] !== 'run') return null
445  const inputs: Record<string, unknown> = {}
446  let wf = ''
447  let json = false
448  const rest = seg.slice(k + 2)
449  for (let i = 0; i < rest.length; i++) {
450    const t = rest[i]
451    let kv: string | undefined
452    if (t === '-f' || t === '-F' || t === '--field' || t === '--raw-field') kv = rest[++i] ?? ''
453    else kv = /^--(?:field|raw-field)=(.*)$/.exec(t)?.[1] ?? /^-[fF](.+)$/.exec(t)?.[1]
454    if (kv !== undefined) {
455      const eq = kv.indexOf('=')
456      if (eq > 0) inputs[kv.slice(0, eq)] = kv.slice(eq + 1)
457    } else if (t === '--json') json = true
458    else if (['-R', '--repo', '-r', '--ref'].includes(t)) i++
459    else if (!t.startsWith('-') && !wf) wf = t
460  }
461  return wf ? { wf, inputs, json } : null
462}
463
464// ---------------------------------------------------------------- the hooks
465
466export const register: Register = (on, options) => {
467  const o = (options ?? {}) as Opts
468
469  // Rule 1: article ideas name an EXISTING parent -------------------------------------------
470  on('tool.call', { tool: CREATE_ISSUE }, async ($, e: any, next) => {
471    const rawFields = arg(e, 'additional_fields')
472    const fields = asObject(rawFields)
473    // additional_fields that cannot be read hides both the labels and the project: fail closed.
474    let unreadable = false
475    if (rawFields !== undefined && rawFields !== null && rawFields !== '') {
476      let parsed: unknown = rawFields
477      if (typeof rawFields === 'string') { try { parsed = JSON.parse(rawFields) } catch { parsed = undefined } }
478      if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) unreadable = true
479    }
480    const projects = [arg(e, 'projectKey'), asObject(fields.project).key].map(k => String(k ?? '').toUpperCase())
481    if (!projects.includes('LCC') && !(unreadable && !arg(e, 'projectKey'))) return next(e)
482    // Labels in every shape the tool could take (array, comma/space string, {name} objects, top level or
483    // additional_fields). A shape we cannot read is treated as an article idea: fail closed.
484    const raw = [fields.labels, arg(e, 'labels')].filter(v => v !== undefined && v !== null)
485    const labels: string[] = []
486    for (const v of raw) {
487      const items = Array.isArray(v) ? v : typeof v === 'string' ? v.split(/[\s,]+/) : (unreadable = true, [])
488      for (const it of items) {
489        const name = typeof it === 'string' ? it : typeof asObject(it).name === 'string' ? String(asObject(it).name) : (unreadable = true, '')
490        if (name) labels.push(name.trim().toLowerCase())
491      }
492    }
493    if (!unreadable && !labels.includes('article-idea')) return next(e)
494
495    const parent = parentSlug(arg(e, 'description'))
496    if (!parent) {
497      return {
498        deny: say(
499          $,
500          'an article-idea ticket must name its parent article so its pillar brief can inherit from it. ' +
501            'Add a line `Parent article: <slug>` to the description (the slug of the existing article this idea grows from) and retry.',
502        ),
503      }
504    }
505    try {
506      const found = await sql($, o, `select id from public.articles where slug = '${parent}' limit 1`)
507      if (found.length === 0) {
508        return { deny: say($, `parent article "${parent}" does not exist in public.articles, so a brief cannot inherit from it. Use the slug of an existing article.`) }
509      }
510    } catch (err) {
511      return { deny: say($, `could not check that parent article "${parent}" exists (${err instanceof Error ? err.message : String(err)}), so this call is refused (fail closed). ${FIX_LOOKUP}`) }
512    }
513    const ran: any = await next(e)
514    if (ran.deny !== undefined || ran.isError) return ran
515    return {
516      ...ran,
517      context: [
518        ...(ran.context ?? []),
519        say(
520          $,
521          `article idea created under parent "${parent}". Next: draft the brief from the parent ` +
522            `(brief_stage.py draft --article-slug <new> --parent-slug ${parent}, LC-1728), get the operator's approval, ` +
523            `and only then write or pick products.`,
524        ),
525      ],
526    }
527  }).catch(($, e, next) =>
528    next.called ? { deny: say($, AFTER_FAIL) } : { deny: say($, 'the article-idea check failed, so this call is refused rather than unchecked.') },
529  )
530
531  // Rules 2(a) and 3(b): workflow dispatch through the GitHub MCP --------------------------
532  on('tool.call', { tool: DISPATCH }, async ($, e: any, next) => {
533    if (String(arg(e, 'method')) !== 'run_workflow') return next(e)
534    const rawInputs = arg(e, 'inputs')
535    const inputs = asObject(rawInputs)
536    // Inputs that are present but not an object (or not JSON) hide step/mode: for a guarded workflow
537    // (or a numeric id we cannot map to a file) that is a deny, never "not a shortlist".
538    const wfName = (String(arg(e, 'workflow_id') ?? '').split('/').pop() ?? '').toLowerCase()
539    if (/^(\d+|run-pillar-brief\.ya?ml|run-heading-research\.ya?ml)$/.test(wfName) && rawInputs !== undefined && rawInputs !== null && rawInputs !== '') {
540      let parsed: unknown = rawInputs
541      if (typeof rawInputs === 'string') { try { parsed = JSON.parse(rawInputs) } catch { parsed = undefined } }
542      if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
543        return { deny: say($, `the inputs of this ${wfName} dispatch cannot be read (not an object), so step/mode/apply/approved_by cannot be judged and the call is refused. Pass inputs as a JSON object.`) }
544      }
545    }
546    const kind = dispatchKind(String(arg(e, 'workflow_id') ?? ''), inputs)
547    if (kind) {
548      const stop = await resolve($, await dispatchVerdict($, o, kind, inputs))
549      if (stop) return stop
550    }
551    return next(e)
552  }).catch(($, e, next) =>
553    next.called ? { deny: say($, AFTER_FAIL) } : { deny: say($, 'the workflow-dispatch check failed, so this call is refused rather than unchecked.') },
554  )
555
556  // Rules 2(b), 3(a) and 3(b): Bash ---------------------------------------------------------
557  on('tool.call', { tool: 'Bash' }, async ($, e: any, next) => {
558    const cmd = String(arg(e, 'command') ?? '')
559    const segs = cmd ? segments(cmd) : []
560    for (const seg of segs) {
561      const pb = script(seg, PILLAR_BRIEF)
562      if (pb >= 0 && seg[pb + 1] === 'approve' && !approverOk(o, flag(seg, ['--by']).value)) {
563        return { deny: say($, approverDeny(o, 'pillar_brief.py approve --by')) }
564      }
565      const gh = ghDispatch(seg)
566      if (gh) {
567        const guarded = /^(run-pillar-brief|run-heading-research)\.ya?ml$/.test((gh.wf.split('/').pop() ?? '').toLowerCase()) || /^\d+$/.test(gh.wf)
568        if (gh.json && guarded) {
569          return { deny: say($, 'gh workflow run with --json hides its inputs from the pillar-brief guard; pass them as -f key=value (step, run_id, apply, mode, approved_by).') }
570        }
571        const kind = dispatchKind(gh.wf, gh.inputs)
572        if (kind) {
573          const stop = await resolve($, await dispatchVerdict($, o, kind, gh.inputs))
574          if (stop) return stop
575        }
576      }
577    }
578    for (const seg of segs) {
579      let target: Target | undefined
580      let what = ''
581      const sl = script(seg, SHORTLIST)
582      const rm = script(seg, ROSTER)
583      if (sl >= 0 && !flagTokens(seg.slice(sl + 1), ['--run-id', '--output', '--html', '--render']).some(t => ['--selftest', '--render', '--dry-run', '-h', '--help'].includes(t.split('=')[0]))) {
584        // Only an invocation that SELECTS (it carries a target) is a selection; no target is not one.
585        const run = flag(seg, ['--run-id'])
586        const art = flag(seg, ['--article', '--article-slug', '--slug'])
587        const aid = flag(seg, ['--article-id'])
588        if (run.present || art.present || aid.present) {
589          what = 'product_shortlist.py run'
590          target = targetFrom(run.value, art.value, aid.value)
591        }
592      } else if (rm >= 0 && seg[rm + 1] === 'apply') {
593        what = 'roster_materialize.py apply'
594        target = await planTarget($, seg)
595      }
596      if (target) {
597        const stop = await resolve($, await briefVerdict($, o, target, what))
598        if (stop) return stop
599      }
600    }
601    return next(e)
602  }).catch(($, e, next) =>
603    next.called ? { deny: say($, AFTER_FAIL) } : { deny: say($, 'the Bash check failed, so this call is refused rather than unchecked.') },
604  )
605
606  // Rule 2(c): writing a NEW article markdown (Write tool) ----------------------------------
607  on('tool.call', { tool: /^(Write|Edit)$/ }, async ($, e: any, next) => {
608    const path = String(arg(e, 'file_path') ?? '')
609    if (!articlePath(path, '') || !path.split('/').includes('bbm-content')) return next(e)
610    let exists = false
611    try { exists = await $.fs.exists(path) } catch { exists = false } // unknown counts as new: fail closed
612    if (exists) return next(e)
613    const slug = articleSlug(String(arg(e, 'content') ?? ''))
614    if (!slug) return next(e) // not an article (no slug: + title: frontmatter)
615    const stop = await resolve($, await briefVerdict($, o, targetFrom('', slug, ''), 'new article file'))
616    return stop ?? next(e)
617  }).catch(($, e, next) =>
618    next.called ? { deny: say($, AFTER_FAIL) } : { deny: say($, 'the article-file check failed, so this call is refused rather than unchecked.') },
619  )
620
621  // Rule 2(c) via the GitHub MCP: create_or_update_file (no sha = a new file) and push_files --------
622  on('tool.call', { tool: GH_FILE }, async ($, e: any, next) => {
623    const repo = String(arg(e, 'repo') ?? '')
624    const files: { path: string; text: string }[] = []
625    if (String(e.tool).endsWith('push_files')) {
626      let list = arg(e, 'files')
627      if (typeof list === 'string') { try { list = JSON.parse(list) } catch { list = undefined } }
628      if (Array.isArray(list)) for (const f of list) files.push({ path: String(asObject(f).path ?? ''), text: String(asObject(f).content ?? '') })
629    } else if (arg(e, 'sha') === undefined || arg(e, 'sha') === null || arg(e, 'sha') === '') {
630      files.push({ path: String(arg(e, 'path') ?? ''), text: fromBase64(String(arg(e, 'content') ?? '')) })
631    }
632    const slugs = new Set<string>()
633    for (const f of files) {
634      if (!articlePath(f.path, repo)) continue
635      const slug = articleSlug(f.text)
636      if (slug) slugs.add(slug)
637    }
638    for (const slug of slugs) {
639      const stop = await resolve($, await briefVerdict($, o, targetFrom('', slug, ''), 'new article file (GitHub MCP)'))
640      if (stop) return stop
641    }
642    return next(e)
643  }).catch(($, e, next) =>
644    next.called ? { deny: say($, AFTER_FAIL) } : { deny: say($, 'the GitHub file check failed, so this call is refused rather than unchecked.') },
645  )
646}
647