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…

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 647 lines1import 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