SLOPSHOPPER

run-state

Band above the prompt with the refresh run's stage, next READY section, Pass A age and missing receipts; plus a guard that refuses receipt writes for sections…

newbandguardcommandtoastprocess
v0.1.0no licenseupdated 2026-10-23brand-bridge-media/bbm-mods/claude-mods/run-state
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · run-state
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /run-state ⎿ run-state: run-state: plan unreadable (no read path worked; tried Supabase MCP server "Supabase": undefined is not an obj plan unreadable (no read path worked; tried Supabase MCP server "Supabase": undefined is not an object (evaluating 'content.map'); PostgREST: SUPABASE_URL...); receipt writes will ask ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
plan unreadable (no read path worked; tried Supabase MCP server "Supabase": undefined is not an object (evaluating 'content.map'); PostgREST: SUPABASE_URL...); receipt writes will ask
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.tsx 291 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { RunView } from '../types'
5import {
6  LIST_ACTIVE_SQL,
7  PY_HELPER,
8  REST_ACTIVE_QS,
9  SQL_TOOL,
10  UUID,
11  bandText,
12  decide,
13  mayWriteRunState,
14  pinClause,
15  sameRun,
16  receiptWritesInBash,
17  receiptWritesInSql,
18  restRowQs,
19  restUrl,
20  rowSql,
21  rowsFromMcp,
22  rowsFromRest,
23  viewFromHelper,
24  worst,
25} from './logic'
26import type { WriteTarget } from './logic'
27
28// run-state (LC-1715, epic LC-1074). Two jobs on one source of truth:
29//   1. a band above the prompt: run, stage, next READY section, Pass A age, missing receipts;
30//   2. a guard that refuses a receipt write for a section the conductor has not marked READY,
31//      and ASKS (never allows) when it cannot read the plan.
32// Business analogy: the band is the job board on the wall; the guard is the foreman who will
33// not let a worker sign off a job whose predecessor is not finished.
34//
35// Plan source: refresh_conductor.py cannot run live in a cloud session (no SUPABASE_* in the
36// shell), so the mod reads the run row itself (Supabase MCP, else PostgREST when SUPABASE_URL and
37// SUPABASE_SERVICE_ROLE_KEY are set) and gives it to the conductor's own evaluate() (see PY_HELPER in logic.ts). Re-implementing the DAG would drift.
38
39const REFRESH_MS = 120_000
40const ALLOW = 'Allow this write'
41
42const view = atom({ plugin: 'run-state', key: 'view' } as const, null as RunView | null)
43const pin = atom({ plugin: 'run-state', key: 'pin' } as const, '')
44const error = atom({ plugin: 'run-state', key: 'error' } as const, '')
45const idleAt = atom({ plugin: 'run-state', key: 'idleAt' } as const, 0)
46
47type Opts = { supabaseServer: string; projectId: string; conductorPath: string }
48
49// One read, two ways in (LC-1711). A cloud build session has no Supabase MCP server, so the MCP is
50// tried first (it is how the mod always worked) and PostgREST with SUPABASE_URL +
51// SUPABASE_SERVICE_ROLE_KEY second, the way the bulk-operations tools read refresh_runs. Every path
52// that fails is named in the error, because that text is what the "ask" shows a person: they can
53// see what was tried instead of guessing. The key never appears in a message.
54// `active`: this is the "which runs are active" list. Over PostgREST an empty answer cannot be told
55// from a key that simply cannot see the table (row-level security answers [] , not an error), so it is
56// a failed path there, never "no active run" (which would switch the guard off).
57type Q = { sql: string; rest: string; active?: boolean }
58
59const msg = (err: unknown): string => (err instanceof Error ? err.message : String(err)).replace(/^run-state: /, '')
60
61async function viaMcp($: any, o: Opts, q: Q): Promise<unknown[]> {
62  const res = await $.mcp.call(o.supabaseServer, 'execute_sql', { project_id: o.projectId, query: q.sql })
63  if (res.isError) throw new Error('execute_sql returned an error')
64  return rowsFromMcp(res.content)
65}
66
67async function viaRest($: any, q: Q): Promise<unknown[]> {
68  const base = await $.env.get('SUPABASE_URL')
69  const key = await $.env.get('SUPABASE_SERVICE_ROLE_KEY')
70  if (!base || !key) {
71    throw new Error(`${[!base && 'SUPABASE_URL', !key && 'SUPABASE_SERVICE_ROLE_KEY'].filter(Boolean).join(' and ')} not set`)
72  }
73  const res = await $.http.fetch(restUrl(base, q.rest), {
74    headers: { apikey: key, Authorization: `Bearer ${key}`, Accept: 'application/json' },
75  })
76  if (!res.ok) throw new Error(`HTTP ${res.status}`)
77  const rows = rowsFromRest(res.text)
78  if (q.active && rows.length === 0) {
79    throw new Error('returned no active runs (an empty list cannot be told from a key that cannot see refresh_runs), so it is not trusted as "no active run"')
80  }
81  return rows
82}
83
84async function sql($: any, o: Opts, q: Q): Promise<unknown[]> {
85  const tried: string[] = []
86  try {
87    return await viaMcp($, o, q)
88  } catch (err) {
89    tried.push(`Supabase MCP server "${o.supabaseServer}": ${msg(err)}`)
90  }
91  try {
92    return await viaRest($, q)
93  } catch (err) {
94    tried.push(`PostgREST: ${msg(err)}`)
95  }
96  throw new Error(`no read path worked; tried ${tried.join('; ')}`)
97}
98
99// Reads one run's row and asks the conductor what is READY. `which` is a run id or slug; absent,
100// it is the pin, else the only active run. Returns null when there is NO active run (and nothing
101// was asked for); throws with a reason when any step fails, which callers turn into "ask".
102async function loadView($: any, o: Opts, which?: string): Promise<RunView | null> {
103  const wanted = which ?? (await read($, pin))
104  let q: Q
105  if (wanted) {
106    const clause = pinClause(wanted)
107    const rest = restRowQs(wanted)
108    if (!clause || !rest) throw new Error(`"${wanted}" is not a run id or slug`)
109    q = { sql: rowSql(clause), rest }
110  } else {
111    const active = (await sql($, o, { sql: LIST_ACTIVE_SQL, rest: REST_ACTIVE_QS, active: true })) as { id: string; article_slug: string }[]
112    if (active.length === 0) return null
113    if (active.length > 1) {
114      throw new Error(`${active.length} active runs (${active.map(r => r.article_slug).join(', ')}); pin one with /run-state <slug>`)
115    }
116    // Same shape gate as a pin: an id that is not a UUID never reaches SQL.
117    if (!UUID.test(String(active[0].id))) throw new Error('active run id is not a UUID')
118    const id = String(active[0].id)
119    q = { sql: rowSql(`id = '${id}'`), rest: restRowQs(id) as string }
120  }
121  const rows = await sql($, o, q)
122  if (rows.length !== 1) throw new Error(`run not found for ${wanted || q.sql}`)
123  const ran = await $.process.run(['python3', '-I', '-c', PY_HELPER, o.conductorPath], {
124    stdin: JSON.stringify(rows[0]),
125    timeoutMs: 30_000,
126  })
127  if (ran.exitCode !== 0) throw new Error(`conductor failed: ${String(ran.stderr).trim().slice(-200) || `exit ${ran.exitCode}`}`)
128  return viewFromHelper(ran.stdout, await $.clock.now())
129}
130
131type Read = { v: RunView | null; idle: boolean }
132
133// Never throws. Stores the view, or why there is none. `idle` = no active run: not an error, the
134// guard is inert and the band is blank. A fresh idle result is reused for REFRESH_MS unless forced.
135async function refresh($: any, o: Opts, force = false): Promise<Read> {
136  try {
137    const now = await $.clock.now()
138    const stamp = await read($, idleAt)
139    if (!force && stamp && now - stamp < REFRESH_MS && (await read($, pin)) === '') return { v: null, idle: true }
140    const v = await loadView($, o)
141    await update($, error, () => '')
142    await update($, view, () => v)
143    await update($, idleAt, () => (v ? 0 : now))
144    return { v, idle: v === null }
145  } catch (err) {
146    await update($, error, () => (err instanceof Error ? err.message : String(err)))
147    await update($, idleAt, () => 0)
148    return { v: null, idle: false }
149  }
150}
151
152// Callout convention (LC-1711): every deny / ask / changed-state message starts with `[mod:run-state]`
153// (that text reaches the model in the tool result, so it can say who acted) and fires a toast naming
154// the mod and the reason (so the operator sees it too). One helper keeps the two together.
155const MOD = 'run-state'
156function say($: any, reason: string): string {
157  const text = `[mod:${MOD}] ${reason.replace(/^run-state: /, '')}`
158  try {
159    $.ui.toast(text.length > 160 ? `${text.slice(0, 157)}...` : text)
160  } catch { /* a toast must never be the reason a guard fails */ }
161  return text
162}
163
164export const register: Register = (on, options) => {
165  const o = options as unknown as Opts
166  let lastTry = 0
167
168  on('session.start', async ($, e, next) => {
169    await $.command.register({
170      name: 'run-state',
171      description: 'Pin the refresh run the band and guard follow: /run-state <slug|run_id|clear>',
172    })
173    void refresh($, o, true)
174    return next(e)
175  })
176
177  on('command.run', { command: 'run-state' }, async ($, e) => {
178    const arg = e.args.trim()
179    if (arg === 'clear') {
180      await update($, pin, () => '')
181      say($, 'run pin cleared; the band and guard follow the only active run')
182    } else if (arg !== '') {
183      if (!pinClause(arg)) return { text: say($, `"${arg}" is not a run id or article slug; the pin is unchanged.`) }
184      await update($, pin, () => arg)
185      say($, `pinned to ${arg}; the band and receipt guard now follow it`)
186    }
187    const r = await refresh($, o, true)
188    if (r.idle) return { text: 'run-state: no active refresh run.' }
189    if (!r.v) return { text: `run-state: plan unreadable (${await read($, error)}). Receipt writes will ask.` }
190    return { text: bandText(r.v, await $.clock.now()) }
191  })
192
193  // The guard, and the band's after-every-write refresh, in one hook so a write is judged against
194  // a plan read just before it and the band is re-read just after it. Everything before next(e) is
195  // inside one try (any surprise there refuses the write); the refresh after it is in its own, so
196  // no error can send control back through next(e) and run a write twice. Hence no .catch here.
197  on('tool.call', async ($, e, next) => {
198    const tool = String(e.tool)
199    const command = tool === 'Bash' ? String((e as { command?: unknown }).command ?? '') : ''
200    const query = SQL_TOOL.test(tool) ? String((e as { query?: unknown }).query ?? '') : ''
201    const bashTargets = command ? receiptWritesInBash(command) : []
202    const sqlSuspect = query !== '' && mayWriteRunState(tool, query)
203    if (bashTargets.length === 0 && !sqlSuspect) return next(e)
204
205    try {
206      const def = await refresh($, o)
207      // No active run: nothing to enforce, unless the write NAMES a run. A named run is judged
208      // against its own plan below, so an empty active list never waves a named write through.
209      if (def.idle) {
210        const named = [...bashTargets, ...(sqlSuspect ? receiptWritesInSql(tool, query, []) : [])].some(t => t.run)
211        if (!named) return next(e)
212      }
213
214      let concern: string | null = null
215      if (!def.v && !def.idle) {
216        // Fail closed: an unreadable plan means "a person decides", not "go ahead".
217        concern = `run-state: cannot read the refresh plan (${await read($, error)}); confirm this receipt write.`
218      } else {
219        const targets: WriteTarget[] = [
220          ...bashTargets,
221          ...(sqlSuspect ? receiptWritesInSql(tool, query, def.v ? def.v.table.map(t => t.id) : []) : []),
222        ]
223        // A write that names a run is judged against THAT run's plan, not the pinned / only active one.
224        const plans = new Map<string, RunView | null>()
225        const decisions = []
226        for (const t of targets) {
227          if (def.idle && !t.run) continue // idle: only writes that name a run are gated
228          let v: RunView | null = def.v
229          if (t.run && (!v || !sameRun(v, t.run))) {
230            if (!plans.has(t.run)) {
231              try { plans.set(t.run, await loadView($, o, t.run)) } catch { plans.set(t.run, null) }
232            }
233            v = plans.get(t.run) ?? null
234            if (!v) {
235              concern = `run-state: cannot read the plan for run ${t.run} that this write names; confirm this receipt write.`
236              continue
237            }
238          }
239          decisions.push(decide(v as RunView, t))
240        }
241        const d = decisions.length ? worst(decisions) : null
242        if (d?.action === 'deny') return { deny: say($, d.reason) }
243        if (d?.action === 'ask' && concern === null) concern = d.reason
244      }
245      if (concern !== null) {
246        // The engine's own question dialog. Dismissed, or nobody to ask (-p run): refuse.
247        const asking = say($, concern)
248        let choice = ''
249        try {
250          choice = await $.ui.ask(`${asking} Allow this write?`, [ALLOW, 'Refuse'])
251        } catch {
252          return { deny: `${asking} (no one answered; refused)` }
253        }
254        if (choice !== ALLOW) return { deny: `${asking} (refused)` }
255      }
256    } catch (err) {
257      return { deny: say($, `guard failed (${err instanceof Error ? err.message : String(err)}); refused.`) }
258    }
259
260    const ran = await next(e)
261    if (ran.deny === undefined) {
262      try { await refresh($, o, true) } catch { /* the band stays as it was; the write already ran */ }
263    }
264    return ran
265  })
266
267  // Receipts also land from workflows and other sessions; keep the band honest between writes,
268  // at most once per window (a persistent error or an idle result must not query every turn).
269  on('turn.complete', async ($, e, next) => {
270    const now = await $.clock.now()
271    if (now - lastTry >= REFRESH_MS) {
272      lastTry = now
273      void refresh($, o)
274    }
275    return next(e)
276  }).catch(($, e, next) => next(e))
277
278  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
279    const v = await read($, view)
280    const why = await read($, error)
281    if (e.props.hasSurvey || (!v && !why)) return next(e)
282    const { Box, Text } = $.ui.resolve(e)
283    return (
284      <Box>
285        {v ? <Text dimColor>{bandText(v, await $.clock.now())}</Text> : null}
286        {why ? <Text color="yellow">{v ? ' · ' : ''}plan unreadable ({why.length > 140 ? `${why.slice(0, 137)}...` : why}); receipt writes will ask</Text> : null}
287      </Box>
288    )
289  }).catch(($, e, next) => next(e))
290}
291
hooks/logic.ts 528 lines
1import type { RunView } from '../types'
2
3// Pure logic for the run-state mod, kept apart from register.tsx so tests can
4// call it directly. Business analogy: the conductor is the foreman's job board;
5// this file reads the board and decides whether a worker may sign off a job.
6
7// Runs refresh_conductor.py's own DAG on a refresh_runs row fed on stdin. The
8// conductor's live mode needs SUPABASE_* in the shell, which a cloud session
9// does not have (LC-1715 step 0); --snapshot-style input does work, so the mod
10// reads the row through the Supabase MCP and hands it over here. argv[1] = path.
11export const PY_HELPER = [
12  'import importlib.util, json, sys',
13  'spec = importlib.util.spec_from_file_location("rc", sys.argv[1])',
14  'm = importlib.util.module_from_spec(spec)',
15  'sys.modules["rc"] = m',
16  'spec.loader.exec_module(m)',
17  'raw = m.snapshot_row(json.loads(sys.stdin.read()))',
18  'snap = m.RunSnapshot.model_validate(m.normalise_row(raw))',
19  'plan = m.evaluate(snap)',
20  'table = m.SECTIONS_V2 if snap.pipeline_version == 2 else m.SECTIONS',
21  'prov = raw.get("selection_provenance") or {}',
22  'audit = (prov.get("technical_audit") or {}) if isinstance(prov, dict) else {}',
23  'pa = audit.get("pass_a") if isinstance(audit, dict) else None',
24  'print(json.dumps({',
25  '  "plan": json.loads(plan.model_dump_json()),',
26  '  "table": [{"id": s.id, "stage": s.stage, "part": s.part} for s in table],',
27  '  "pass_a": {"verdict": pa.get("verdict"), "at": pa.get("at")} if isinstance(pa, dict) else None,',
28  '}))',
29].join('\n')
30
31// Only these two shapes ever reach SQL, so a pin can never smuggle a quote in.
32export const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
33const SLUG = /^[a-z0-9][a-z0-9-]{0,120}$/i
34
35export const LIST_ACTIVE_SQL =
36  "select id, article_slug, status from refresh_runs where status in ('in_progress','awaiting_approval') order by created_at desc limit 5"
37
38export function pinClause(pin: string): string | null {
39  if (UUID.test(pin)) return `id = '${pin.toLowerCase()}'`
40  if (SLUG.test(pin)) return `article_slug = '${pin.toLowerCase()}'`
41  return null
42}
43
44// The same two reads over PostgREST (LC-1711): a cloud build session has no Supabase MCP server, but
45// the bulk-operations tools (refresh_conductor.fetch_run) read refresh_runs with SUPABASE_URL +
46// SUPABASE_SERVICE_ROLE_KEY, so the mod can too. Same shape gate as pinClause: only a UUID or a slug
47// reaches the URL, and the value is encoded anyway.
48const RUN_COLS = 'id,article_slug,status,stages,selection_provenance,created_at,pipeline_version'
49export const REST_ACTIVE_QS =
50  'select=id,article_slug,status&status=in.(in_progress,awaiting_approval)&order=created_at.desc&limit=5'
51
52export function restRowQs(pin: string): string | null {
53  const col = UUID.test(pin) ? 'id' : SLUG.test(pin) ? 'article_slug' : null
54  return col ? `select=${RUN_COLS}&${col}=eq.${encodeURIComponent(pin.toLowerCase())}&order=created_at.desc&limit=1` : null
55}
56
57export function restUrl(base: string, qs: string): string {
58  return `${base.trim().replace(/\/+$/, '')}/rest/v1/refresh_runs?${qs}`
59}
60
61export function rowsFromRest(text: string): unknown[] {
62  const rows = JSON.parse(text)
63  if (!Array.isArray(rows)) throw new Error('PostgREST rows were not an array')
64  return rows
65}
66
67export const rowSql = (where: string): string =>
68  'select id, article_slug, status, stages, selection_provenance, created_at, pipeline_version ' +
69  `from refresh_runs where ${where} order by created_at desc limit 1`
70
71// A reply that is itself a JSON object carrying a string `result` is unwrapped to that string.
72function unwrapResult(text: string): string {
73  const t = text.trim()
74  if (!t.startsWith('{')) return text
75  try {
76    const o = JSON.parse(t)
77    return o && typeof o === 'object' && typeof o.result === 'string' ? o.result : text
78  } catch {
79    return text
80  }
81}
82
83
84// execute_sql wraps its rows in an "untrusted-data" envelope; take the JSON array inside.
85export function rowsFromMcp(content: readonly unknown[]): unknown[] {
86  const joined = content
87    .map(b => (b && typeof b === 'object' && 'text' in b ? String((b as { text: unknown }).text) : ''))
88    .join('')
89  // The Supabase MCP may return the whole reply as JSON, {"result":"...<untrusted-data>[{\"id\":...}]..."},
90  // so the rows inside still carry escaped quotes. Unwrap that layer first; anything else stays as text.
91  const text = unwrapResult(joined)
92  // The rows sit inside the server's untrusted-data envelope; prose outside it may hold brackets.
93  const env = /<untrusted-data-[^>]*>([\s\S]*)<\/untrusted-data-[^>]*>/.exec(text)
94  const body = env ? env[1] : text
95  const open = body.indexOf('[')
96  const close = body.lastIndexOf(']')
97  if (open < 0 || close < open) throw new Error('execute_sql returned no row array')
98  const rows = JSON.parse(body.slice(open, close + 1))
99  if (!Array.isArray(rows)) throw new Error('execute_sql rows were not an array')
100  return rows
101}
102
103type Plan = {
104  run_id: string | null
105  slug: string | null
106  run_status: string | null
107  pipeline_version: number
108  sections: { id: string; label: string; state: string; unmet: string[] }[]
109}
110
111export function viewFromHelper(out: string, now: number): RunView {
112  const o = JSON.parse(out) as { plan: Plan; table: RunView['table']; pass_a: RunView['passA'] }
113  if (!o.plan || !Array.isArray(o.plan.sections) || o.plan.sections.length === 0 || !o.plan.run_id) {
114    throw new Error('conductor returned an empty plan')
115  }
116  return {
117    runId: o.plan.run_id,
118    slug: o.plan.slug ?? '?',
119    status: o.plan.run_status ?? '?',
120    pipelineVersion: o.plan.pipeline_version,
121    sections: o.plan.sections.map(s => ({ id: s.id, label: s.label, state: s.state, unmet: s.unmet })),
122    table: o.table,
123    passA: o.pass_a,
124    fetchedAt: now,
125  }
126}
127
128// ---------------------------------------------------------------- the band
129
130const ago = (iso: string | null | undefined, now: number): string => {
131  const t = iso ? Date.parse(iso) : NaN
132  if (Number.isNaN(t)) return 'unknown age'
133  const m = Math.max(0, Math.round((now - t) / 60_000))
134  if (m < 90) return `${m}m ago`
135  const h = Math.round(m / 60)
136  return h < 48 ? `${h}h ago` : `${Math.round(h / 24)}d ago`
137}
138
139export function bandText(view: RunView, now: number): string {
140  const open = view.sections.filter(s => s.state !== 'DONE')
141  if (open.length === 0) return `run ${view.runId.slice(0, 8)} ${view.slug} · all sections DONE`
142  const stageOf = new Map(view.table.map(t => [t.id, t.stage]))
143  const stage = Math.min(...open.map(s => stageOf.get(s.id) ?? Infinity))
144  const ready = open.filter(s => s.state === 'READY').map(s => s.id)
145  const stan = open.filter(s => s.state === 'WAITING_STAN').map(s => s.id)
146  const next = ready[0] ?? (stan.length ? `${stan[0]} (waiting on a human)` : 'none (nothing READY)')
147  const pass = view.passA ? `${view.passA.verdict ?? '?'} ${ago(view.passA.at, now)}` : 'none'
148  const missing = open.slice(0, 4).map(s => s.id).join(', ')
149  const more = open.length > 4 ? ` +${open.length - 4}` : ''
150  return (
151    `run ${view.runId.slice(0, 8)} ${view.slug} · stage ${Number.isFinite(stage) ? stage : '?'}` +
152    ` · next READY: ${next} · Pass A: ${pass} · missing ${open.length}: ${missing}${more}`
153  )
154}
155
156// ---------------------------------------------------------------- write detection
157
158export type WriteTarget = {
159  // human description for the reason text
160  what: string
161  ids: string[]
162  pairs: { stage: number; part: string | null }[]
163  passes: ('a' | 'b')[]
164  // The run the write itself names (--run-id / --slug, or id / article_slug in the SQL), lowercased.
165  // Absent = the write names none, so the pinned / only active run is the one to judge against.
166  run?: string
167}
168
169const KNOWN_WRITERS = ['write_stage_receipt.py', 'article_audit.py']
170
171// PLAIN-COMMAND-BEGIN (keep identical in run-state/hooks/logic.ts and refresh-run-guard/hooks/plain.ts; ci_gates.py parity enforces it)
172// ---------------------------------------------------------------- what counts as "plain"
173//
174// Things are exempt from the receipt guard (LC-1711): a --help / --dry-run / --selftest run of the
175// writer, an echo/printf/cat/tee that only prints or writes text, and the body of a quoted-terminator
176// heredoc that cat/tee writes to a file (receipt code and its tests name the scripts constantly).
177// Each exemption is a claim about what the shell will NOT run, and every shell parser that tries to
178// prove that grows holes (escapes, comments, expansions, quoting forms). So there is no parser to
179// get wrong: an exemption applies ONLY when the whole command is plain AND the segment's first word
180// is on a short allow-list (the writer itself, or echo / printf / cat / tee). Anything else is judged
181// exactly as if no exemption existed. Plain is FAIL CLOSED on every character the code does not model.
182//
183// The text outside a cat/tee quoted-terminator heredoc body (the body is raw data, `'EOF'` stops the
184// shell expanding it) is NOT plain if it contains any of:
185//   `#` (comment)  `\` (escape)  `${`  `$'`  `$"`  a backtick  `$(`  `>(`  `<(`
186//   a single `|`   a single `&`   an unclosed quote   a heredoc that was not dropped
187//   eval / source / alias / unalias / function / a leading `.` / `name()` / a `PATH=` assignment
188//   bash sh zsh python python3 node with -c, `-` or any `<` after it (code read from stdin)
189// A command with a comment or a backslash simply gets no exemption: a few false positives are fine.
190// What is left is a command with no escapes, comments or expansions, where quotes just pair up, so
191// the one pass below (quotes only) is exact for it.
192
193type ShellScan = { closed: boolean; heredocs: number; drops: { from: number; to: number }[] }
194
195const HEREDOC_OP = /^(-?)\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\2/
196
197// One pass over the command with the quote state carried across lines. Heredoc bodies are raw text,
198// so they are skipped (never scanned for quotes). Only an operator outside quotes counts as a
199// heredoc. Anything that would make this pass inexact (an escape, a comment) also makes the command
200// not plain, so a wrong drop can never survive into a plain verdict.
201function scanShell(command: string): ShellScan {
202  const lines = command.split('\n')
203  const scan: ShellScan = { closed: true, heredocs: 0, drops: [] }
204  let q = ''
205  for (let i = 0; i < lines.length; i++) {
206    const line = lines[i]
207    let pending: { term: string; dash: boolean; quoted: boolean; consumer: string } | null = null
208    for (let j = 0; j < line.length; j++) {
209      const c = line[j]
210      if (q) { if (c === q) q = ''; continue }
211      if (c === "'" || c === '"') { q = c; continue }
212      if (c === '<' && line[j + 1] === '<') {
213        if (line[j + 2] === '<') { j += 2; continue } // a here-string, not a heredoc
214        const m = HEREDOC_OP.exec(line.slice(j + 2))
215        if (m && !pending) {
216          const before = line.slice(0, j).split(/&&|\|\||;|\|/).pop() ?? ''
217          const word = (before.trim().split(/\s+/)[0] ?? '').split('/').pop() ?? ''
218          pending = { term: m[3], dash: m[1] === '-', quoted: m[2] !== '', consumer: word }
219          scan.heredocs += 1
220        }
221        j += 1
222      }
223    }
224    if (pending) {
225      const p = pending
226      const end = lines.findIndex((l, k) => k > i && (p.dash ? l.replace(/^\t+/, '') : l).trimEnd() === p.term)
227      if (end < 0) continue // unterminated: nothing is skipped or dropped, the rest is scanned as commands
228      if (p.quoted && (p.consumer === 'cat' || p.consumer === 'tee')) scan.drops.push({ from: i + 1, to: end })
229      i = end // the body is raw text for the scan whether or not it is dropped
230    }
231  }
232  scan.closed = q === ''
233  return scan
234}
235
236// The command with every cat/tee quoted-terminator heredoc body removed.
237function dropHeredocBodies(command: string, scan: ShellScan): string {
238  if (scan.drops.length === 0) return command
239  const skip = new Set<number>()
240  for (const d of scan.drops) for (let k = d.from; k <= d.to; k++) skip.add(k)
241  return command.split('\n').filter((_, k) => !skip.has(k)).join('\n')
242}
243
244function looksPlain(text: string, scan: ShellScan): boolean {
245  if (!scan.closed) return false
246  if (scan.heredocs !== scan.drops.length) return false // a heredoc whose body is still in the command
247  if (/[#\\`]|\$[{('"]|[<>]\(/.test(text)) return false // comment, escape, backtick, ${ $( $' $", >( <(
248  if (/(^|[^|])\|([^|]|$)/.test(text)) return false // a single | (|| is fine)
249  if (/(^|[^&>])&([^&>]|$)/.test(text.replace(/[0-9]?>&[0-9]/g, ''))) return false // a single & (not && or fd dup)
250  if (/\b(eval|source|alias|unalias|function)\b/.test(text)) return false
251  if (/(^|[;&|\n(])\s*\.\s/.test(text)) return false
252  if (/[\w.-]+\s*\(\s*\)/.test(text)) return false // name() { ... }
253  if (/\bPATH=/.test(text)) return false
254  // An interpreter word (also as /bin/bash), not a file name that merely ends in .sh.
255  if (/(^|[\s;&|(/])(bash|sh|zsh|python3?|node)(?=\s|$)[^;&|\n]*(\s-\w*c\b|\s-(\s|$)|<)/.test(text)) return false
256  return true
257}
258
259// The command text to scan, and whether exemptions may apply to it.
260export function plainView(command: string): { text: string; plain: boolean } {
261  const scan = scanShell(command)
262  const dropped = dropHeredocBodies(command, scan)
263  return { text: dropped, plain: looksPlain(dropped, scan) }
264}
265// PLAIN-COMMAND-END
266
267// `;`, `&`, `|` and newline inside quotes are text, not separators: mask them so a quoted payload
268// is not cut into fake commands. Only called on a plain command: every quote is closed and there is
269// no backslash, comment or expansion, so quotes simply pair up.
270function maskQuotedSeparators(command: string): string {
271  let q = ''
272  let out = ''
273  for (let i = 0; i < command.length; i++) {
274    const c = command[i]
275    if (q === "'") { if (c === "'") q = ''; out += /[;&|\n]/.test(c) ? '\u0001' : c; continue }
276    if (q === '"') {
277      if (c === '"') q = ''
278      out += /[;&|\n]/.test(c) ? '\u0001' : c
279      continue
280    }
281    if (c === "'" || c === '"') q = c
282    out += c
283  }
284  return out
285}
286
287const SPLIT = /&&|\|\||;|\n/
288
289// The writer itself: [VAR=x ...] [python3 [-flags]] path/to/<writer>.py
290const WRITER_FIRST = /^(?:\w+=\S+\s+)*(?:python3?\s+(?:-[A-Za-z]+\s+)*)?\S*(?:write_stage_receipt|article_audit)\.py(?=\s|$)/
291
292// Quoted text and redirect operators with their targets are not flags: `W 2> --dry-run` and
293// `W > --help` write to a file called that, they do not ask for a dry run.
294const flagText = (seg: string): string =>
295  seg
296    .replace(/"[^"]*"|'[^']*'/g, '""')
297    .replace(/(?:\d*|&)>{1,2}\s*&?\S+|\d*<\s*&?\S+/g, ' ')
298
299// `--help` / `-h`: usage only, nothing is written.
300const hasHelpFlag = (seg: string): boolean => /(^|\s)(--help|-h)(\s|$)/.test(flagText(seg))
301// Whole flags only: `--dry-runner` or `--dry-run-log` is not a dry run, so it is still judged.
302const hasDryRunFlag = (seg: string): boolean => /(^|\s)--(dry-run|selftest)(\s|$)/.test(flagText(seg))
303
304// ---- the exemption is all-or-nothing (LC-1736). A heredoc body or an echo payload written to a FILE is data
305// only if nothing later runs that file, and "nothing later runs it" cannot be proved by naming the ways to
306// run a file (`sh f`, `./f`, `timeout 5 sh f`, `( sh f )`, `if true; then sh f; fi`, `x="sh f"; $x`, `python3 p.py`
307// ...). So: the command is exempt only when it is plain AND EVERY step is one of a short allow-list. Any other
308// step and the whole command is judged, exactly as if no exemption existed (bodies and payloads included).
309//   - print only: echo / printf / cat that writes nothing (no `>`)
310//   - a file write: a plain cat / tee heredoc, echo / printf / cat redirected to a file, when no TARGET is risky
311//   - the writer's own --help / --dry-run / --selftest run
312//   - steps that cannot run a file that was just written (LC-1739, cut to the minimum after review): git add|status|diff,
313//     ls, grep, wc, claude plugin test|validate (prefix forms, rest must be inert), and two EXACT forms below:
314//     `git commit -m` with a single-quoted or one-word message, `mkdir -p` with a plain path.
315//     NOT git log, git push, gh, bun/npm test, make, head, tail, sed, test, chmod, mv: they ask, as on main.
316const BENIGN_STEP = [
317  /^git\s+(?:add|status|diff)(?:\s|$)/,
318  /^(?:ls|grep|wc)(?:\s|$)/,
319  /^claude\s+plugin\s+(?:test|validate)(?:\s|$)/,
320]
321// Exact forms, whole step: no editor (a message is given), no flag that writes a file or sets a mode.
322const COMMIT_MSG = String.raw`(?:'[^']*'|[A-Za-z0-9._:-]+)`
323const GIT_COMMIT = new RegExp(String.raw`^git\s+commit(?:\s+-a)?\s+-m\s+${COMMIT_MSG}$|^git\s+commit\s+-am\s+${COMMIT_MSG}$`)
324// A plain path has no shell character and no `.git` component.
325const plainPath = (p: string): boolean => /^[A-Za-z0-9._/-]+$/.test(p) && !p.split('/').includes('.git')
326const dirStep = (step: string): boolean => {
327  // No `cd`: a write after it has an unknown real directory, so its target could not be checked (`cd ../.config/git`
328  // then `cat > config` is the global git config). `mkdir -p` moves nothing, so later targets keep their real paths.
329  const m = /^mkdir\s+-p\s+(\S+)$/.exec(step)
330  return m !== null && plainPath(m[1])
331}
332// A write TARGET that could change what a later benign git command or test run executes: a `.git` path COMPONENT
333// (a hook, `.git/config`, `info/attributes`), the global/XDG git config, a variable, or anything with a glob or a
334// quote in it (`.g*t/config`, `".g"it/config` name a path the shell assembles later). `.gitignore`, `.gitkeep` and
335// `.github/` are ordinary files and are not matched. Only targets are tested, never the text being written.
336// `git commit` is allow-listed, so a file named like a git hook (core.hooksPath can point anywhere) or sitting in a
337// hooks dir is risky, and a target with a group or redirect character (`>(sh)`) is a process substitution.
338const RISKY_TARGET = /\$|[*?['"()<>]|\.config\/git|(?:^|\/)(?:applypatch-msg|pre-applypatch|post-applypatch|pre-commit|pre-merge-commit|prepare-commit-msg|commit-msg|post-commit|pre-rebase|post-checkout|post-merge|pre-push|pre-receive|update|proc-receive|post-receive|post-update|reference-transaction|push-to-checkout|pre-auto-gc|post-rewrite|sendemail-validate|fsmonitor-watchman|p4-[a-z-]+|post-index-change)$|(?:^|\/)hooks(?:\/|$)|(?:^|\/)\.git(?:\/|$|config$|attributes$|modules$)/
339const PRINT_WORD = /^(?:echo|printf|cat|tee)(?=\s|$)/
340
341// Where an echo / printf / cat / tee step writes: each redirect target, and tee's file arguments.
342function writeTargets(step: string): string[] {
343  // fd copies and heredoc ops are not targets; `>|` is the clobber form of `>`.
344  const bare = step.replace(/[0-9]?>&[0-9]/g, '').replace(/<<-?\s*['"]?\w+['"]?/g, ' ').replace(/>\|/g, '>')
345  const out: string[] = []
346  for (const m of bare.matchAll(/>{1,2}\s*(\S+)/g)) out.push(m[1])
347  if (/^tee\b/.test(bare)) {
348    for (const t of bare.replace(/^tee\b/, '').replace(/>{1,2}\s*\S+/g, ' ').split(/\s+/)) {
349      if (t && !t.startsWith('-')) out.push(t)
350    }
351  }
352  return out
353}
354
355function stepIsExempt(step: string): boolean {
356  if (PRINT_WORD.test(step)) {
357    // Quoted text is the payload and is data, so it is blanked whole; what is left (the redirect operators and heredoc
358    // markers aside) must be inert too, so this step does not lean on the outer plain test alone.
359    if (/\$[({]|`/.test(step)) return false // a substitution is code even inside double quotes
360    const s = step.replace(/[0-9]?>&[0-9]/g, '').replace(/<<-?\s*['"]?\w+['"]?/g, ' ').replace(/"[^"]*"|'[^']*'/g, '""').replace(/>>?\|?/g, ' ')
361    return !/[$`|;&<>(){}\\]/.test(s) && writeTargets(step).every(t => !RISKY_TARGET.test(t))
362  }
363  if (WRITER_FIRST.test(step)) return hasDryRunFlag(step) || hasHelpFlag(step)
364  // The allow-list entries match a command PREFIX, so the rest of the step must be inert too: after quoted text is
365  // blanked it may hold no redirect, group, expansion or separator (`head a > .git/hooks/pre-commit` would write a
366  // hook through an otherwise harmless `head`; `head $(sh f.sh)` would run it). `2>&1` is a descriptor copy.
367  const rest = step.replace(/[0-9]?>&[0-9]/g, '').replace(/"[^"$`]*"|'[^']*'/g, '""')
368  // The exact forms carry their own character class; a flag that writes a file or sets a mode is named in any form
369  // (`git diff --output=f` writes f, `git add --chmod=+x f` sets the exec bit).
370  if (/--output|--chmod/.test(step)) return false
371  if (GIT_COMMIT.test(step) || dirStep(step)) return true
372  // The prefix forms: the flags that write are named above, so `=` (`grep --include=f.sh`) is fine.
373  if (/[$`|;&<>(){}\\]/.test(rest)) return false
374  return BENIGN_STEP.some(re => re.test(step))
375}
376
377// The steps to judge: none when the whole command is exempt, else the command split plainly (no exemption at all).
378function segments(command: string): string[] {
379  const view = plainView(command)
380  if (view.plain) {
381    const steps = maskQuotedSeparators(view.text)
382      .split(SPLIT)
383      .map(s => s.trim())
384      .filter(Boolean)
385      .map(s => s.replace(/\u0001/g, ';'))
386    if (steps.every(stepIsExempt)) return []
387  }
388  return command.split(SPLIT).map(s => s.trim()).filter(Boolean)
389}
390
391const runInBash = (seg: string): string | undefined => {
392  const id = /--run-id[ =]+["']?([0-9a-f-]{36})\b/i.exec(seg)
393  if (id) return id[1].toLowerCase()
394  const slug = /--slug[ =]+["']?([a-z0-9][a-z0-9-]*)/i.exec(seg)
395  return slug ? slug[1].toLowerCase() : undefined
396}
397
398export function receiptWritesInBash(command: string): WriteTarget[] {
399  const out: WriteTarget[] = []
400  for (const seg of segments(command)) {
401    if (!KNOWN_WRITERS.some(w => seg.includes(w))) continue
402    if (seg.includes('write_stage_receipt.py')) {
403      const stage = /--stage[ =]+([0-9]+(?:\.[0-9]+)?)/.exec(seg)
404      const part = /--part[ =]+["']?([A-Za-z0-9_]+)/.exec(seg)
405      out.push({
406        what: `write_stage_receipt stage ${stage?.[1] ?? '?'}${part ? ` part ${part[1]}` : ''}`,
407        ids: [],
408        pairs: stage ? [{ stage: Number(stage[1]), part: part ? part[1] : null }] : [],
409        passes: [],
410        run: runInBash(seg),
411      })
412    } else if (/--write-receipt/.test(seg)) {
413      const p = /--pass[ =]+["']?(a|b|both)\b/i.exec(seg)
414      const which = (p?.[1] ?? '').toLowerCase()
415      out.push({
416        what: `article-audit receipt (pass ${which || '?'})`,
417        ids: [],
418        pairs: [],
419        passes: which === 'both' ? ['a', 'b'] : which === 'a' || which === 'b' ? [which] : [],
420        run: runInBash(seg),
421      })
422    }
423  }
424  return out
425}
426
427export const SQL_TOOL = /(^|__)(execute_sql|apply_migration)$/
428// Any statement that can change rows, not just the common four (COPY / MERGE / TRUNCATE also write).
429const WRITE_VERB = /\b(insert\s+into|update|upsert|delete\s+from|copy|merge\s+into|truncate)\b/i
430
431export function receiptWritesInSql(tool: string, query: string, knownIds: string[]): WriteTarget[] {
432  if (!SQL_TOOL.test(tool)) return []
433  const out: WriteTarget[] = []
434  for (const seg of query.split(';')) {
435    if (!WRITE_VERB.test(seg)) continue
436    const touchesSections = /\brefresh_run_sections\b/i.test(seg)
437    const touchesReceipts =
438      /\brefresh_runs\b/i.test(seg) && /\b(stages|selection_provenance)\b/i.test(seg)
439    if (!touchesSections && !touchesReceipts) continue
440    const ids = knownIds.filter(id => new RegExp(`(^|[^A-Za-z0-9_])${id}([^A-Za-z0-9_]|$)`).test(seg))
441    const pairs: WriteTarget['pairs'] = []
442    for (const obj of seg.match(/\{[^{}]*\}/g) ?? []) {
443      const st = /"stage"\s*:\s*"?([0-9]+(?:\.[0-9]+)?)/.exec(obj)
444      if (!st) continue
445      const pt = /"part"\s*:\s*"([^"]+)"/.exec(obj)
446      pairs.push({ stage: Number(st[1]), part: pt ? pt[1] : null })
447    }
448    const passes: ('a' | 'b')[] = []
449    if (/pass_a/i.test(seg)) passes.push('a')
450    if (/pass_b/i.test(seg)) passes.push('b')
451    const rid = /\b(?:id|run_id)\s*=\s*'([0-9a-f-]{36})'/i.exec(seg)
452    const rslug = /\barticle_slug\s*=\s*'([a-z0-9][a-z0-9-]*)'/i.exec(seg)
453    const run = rid ? rid[1].toLowerCase() : rslug ? rslug[1].toLowerCase() : undefined
454    out.push({ what: 'SQL write to refresh run state', ids, pairs, passes, run })
455  }
456  return out
457}
458
459// ---------------------------------------------------------------- the decision
460
461export type Decision = { action: 'allow' | 'ask' | 'deny'; reason: string }
462
463const near = (a: number, b: number): boolean => Math.abs(a - b) < 1e-9
464
465function sectionIdsFor(view: RunView, t: WriteTarget): string[] {
466  const ids = new Set(t.ids)
467  for (const { stage, part } of t.pairs) {
468    const sameStage = view.table.filter(r => near(r.stage, stage))
469    // A whole-stage receipt belongs to the stage's own (part-less) section when
470    // there is one; otherwise it stands in for every part (conductor RECEIPT CONTRACT).
471    const hit = part === null
472      ? (sameStage.some(r => r.part === null) ? sameStage.filter(r => r.part === null) : sameStage)
473      : sameStage.filter(r => r.part === part)
474    for (const r of hit) ids.add(r.id)
475  }
476  for (const p of t.passes) {
477    const v2 = view.table.some(r => r.id === 'v2_s9c6')
478    ids.add(p === 'a' ? (v2 ? 'v2_s9c6' : 's7_5c6') : v2 ? 'v2_s11c5' : 's9c5')
479  }
480  return [...ids]
481}
482
483export function decide(view: RunView, t: WriteTarget): Decision {
484  const ids = sectionIdsFor(view, t)
485  if (ids.length === 0) {
486    return { action: 'ask', reason: `run-state: ${t.what} does not map to any section of run ${view.slug}; confirm it.` }
487  }
488  const byId = new Map(view.sections.map(s => [s.id, s]))
489  const verdicts = ids.map(id => ({ id, s: byId.get(id) }))
490  const unknown = verdicts.find(v => !v.s)
491  if (unknown) {
492    return { action: 'ask', reason: `run-state: ${unknown.id} is not in the conductor's plan for ${view.slug}; confirm it.` }
493  }
494  // Worst section wins: one BLOCKED id in an aggregate write is enough to refuse it. Only BLOCKED
495  // (unmet dependencies) denies. A human barrier (WAITING_STAN) asks, because recording its receipt
496  // after the person has answered is exactly the legitimate write.
497  const blocked = verdicts.find(v => v.s!.state === 'BLOCKED')
498  if (blocked) {
499    return { action: 'deny', reason: `run-state: ${t.what} refused: ${blocked.id} is not READY; it is BLOCKED by ${blocked.s!.unmet.join(', ') || 'unmet blockers'}. Run the conductor again and take a READY section.` }
500  }
501  const barrier = verdicts.find(v => v.s!.state === 'WAITING_STAN')
502  if (barrier) {
503    return { action: 'ask', reason: `run-state: ${barrier.id} is a human barrier (waiting on the operator or the content manager) on ${view.slug}; confirm the person has answered.` }
504  }
505  const redo = verdicts.find(v => v.s!.state === 'DONE' || v.s!.state === 'IN_PROGRESS')
506  if (redo) {
507    return { action: 'ask', reason: `run-state: ${redo.id} is already ${redo.s!.state} on ${view.slug}; this write supersedes its receipt. Confirm.` }
508  }
509  // Allow-list, not fall-through: only READY, or FAILED (the conductor lists a failed gate
510  // under LAUNCH NOW as a re-run). Any other state (a future or malformed one) asks.
511  const odd = verdicts.find(v => v.s!.state !== 'READY' && v.s!.state !== 'FAILED')
512  if (odd) {
513    return { action: 'ask', reason: `run-state: ${odd.id} has unrecognised state "${odd.s!.state}" on ${view.slug}; confirm.` }
514  }
515  return { action: 'allow', reason: `run-state: ${ids.join(', ')} ${verdicts.map(v => v.s!.state).join('/')} on ${view.slug}` }
516}
517
518const rank = { allow: 0, ask: 1, deny: 2 } as const
519export const worst = (ds: Decision[]): Decision => ds.reduce((a, b) => (rank[b.action] > rank[a.action] ? b : a))
520
521// Cheap pre-check so ordinary SQL (the common case) never triggers a plan fetch.
522export const mayWriteRunState = (tool: string, query: string): boolean =>
523  receiptWritesInSql(tool, query, []).length > 0
524
525// The write names a run: is it the one the view is of? (a run id or an article slug)
526export const sameRun = (view: RunView, run: string): boolean =>
527  run === view.runId.toLowerCase() || run === view.slug.toLowerCase()
528
types/index.d.ts 34 lines
1export type RunSection = {
2  id: string
3  label: string
4  state: string
5  unmet: string[]
6}
7
8export type RunView = {
9  runId: string
10  slug: string
11  status: string
12  pipelineVersion: number
13  sections: RunSection[]
14  // stage/part per section id, so a receipt written as (stage, part) maps to a section
15  table: { id: string; stage: number; part: string | null }[]
16  passA: { verdict: string | null; at: string | null } | null
17  fetchedAt: number
18}
19
20declare module 'claude-code' {
21  interface PluginState {
22    'run-state': {
23      // `/run-state <slug|run_id>` pin; '' = latest active run (ambiguous if several)
24      pin: string
25      view: RunView | null
26      // why the plan could not be read; the guard asks while this is set
27      error: string
28      // Clock time of the last check that found NO active run (0 = not idle). While fresh, the
29      // guard is inert and nothing is queried, so turn.complete does not hit the database each turn.
30      idleAt: number
31    }
32  }
33}
34