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…

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.tsx 291 lines1import { 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}
291hooks/logic.ts 528 lines1import 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()
528types/index.d.ts 34 lines1export 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