SLOPSHOPPER

draft-session

v2_s6 Draft session guard: injects the Draft contract from the refresh-agent checkout, runs draft-lint on /draft-done, and blocks the Draft receipt until lint…

newguardcommandtoaststatusprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · draft-session
› 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 › /draft-start ⎿ draft-session: usage: /draft-start <run_id> <article_id> <slug> [v1|v2|standard] (the first three are required; the slug pins ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
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 2 files
hooks/register.ts 556 lines
1import type { Register } from 'claude-code'
2
3import {
4  bashFileWriteViolation,
5  bashPinViolation,
6  bashTouchesArticleFile,
7  classifyReceiptCommand,
8  filePinViolation,
9  isArticlePath,
10  methodRulesApply,
11  isDraftReceiptSql,
12  looksLikeFileWrite,
13  runsDraftWriter,
14  sqlPinViolation,
15  writesArticleTable,
16  type Run,
17} from './gate'
18
19// Draft session guard (LC-1714, epic LC-1074).
20// Why it exists: v2_s6 Draft must run in a subscription Claude session, and its
21// rules are prose. On 2026-08-09 (modular-sofas) 12 of 14 entries said "our tester"
22// although the skill had been read. This mod (1) puts the contract in front of the
23// model, read from the refresh-agent file at load time so it cannot drift, (2) makes
24// /draft-done run draft-lint, and (3) refuses the Draft receipt until lint passes,
25// and refuses writes to any other article. Business analogy: the contract is the
26// recipe card pinned above the stove; draft-lint is the health inspector; the receipt
27// is the "kitchen passed" sticker the inspector must sign before it goes on the door.
28
29// Callout convention (LC-1711): every deny / block / refusal / changed-state message starts with
30// `[mod:draft-session]` (that text reaches the model in the tool result, so it can say who acted) and fires a
31// toast naming the mod and the reason (so the operator sees it too). One helper keeps the two together.
32const MOD = 'draft-session'
33function say($: any, reason: string, timeoutMs?: number): string {
34  const text = `[mod:${MOD}] ${reason.replace(/^draft-session: /, '')}`
35  try {
36    const shown = text.length > 160 ? `${text.slice(0, 157)}...` : text
37    // A blocked contract must stay on screen long enough to be read (timeoutMs), the rest use the default.
38    if (timeoutMs === undefined) $.ui.toast(shown)
39    else $.ui.toast(shown, { timeoutMs })
40  } catch { /* a toast must never be the reason a guard fails */ }
41  return text
42}
43
44const refuse = ($: any, what: string) => ({
45  deny: say($, `${what}. Run \`/draft-done\` (draft-lint must PASS) before the Draft receipt; fix the draft, never the ledger.`),
46})
47
48// Says a re-lock only when one happened (dirty returns '' otherwise).
49const relock = ($: any, note: string) => {
50  if (note) say($, note)
51}
52
53// ---- cloud drafting (LC-1869) -------------------------------------------------------------------
54// A cloud drafting session has no service role, so it can neither run `draft_lint.py --run-id` nor be trusted to
55// write its own passing stage-6 receipt. Two additions, both one-directional:
56//  * `draft_pin` lets the MODEL pin the session (the user-typed /draft-start is a command, which a model cannot
57//    call). A pin only ADDS restrictions, so setting one is safe; changing or clearing one stays human-only.
58//  * with no key, /draft-done and `draft_lint_request` do not run the lint: they hand the session the exact
59//    dispatch of the credentialed `stage-6-draft` gate (run-refresh-stage-gate.yml), which runs draft-lint in CI and
60//    writes the stage-6 receipt from the real result. Nothing here ever sets `lintPassed` on the model's say-so.
61const UUID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
62const SLUG_SHAPE = /^[a-z0-9][a-z0-9-]{0,120}$/
63const GATE_WORKFLOW = 'run-refresh-stage-gate.yml'
64const DISPATCH_TOOL = /^mcp__(github|GitHub_Custom)__actions_run_trigger$/
65
66// $.env.get needs a LITERAL name (the engine lists the variables a module reads), so one tiny reader per variable.
67const clean = (v: unknown): string => String(v ?? '').trim()
68async function envRunId($: any): Promise<string> {
69  try {
70    return clean(await $.env.get('BBM_DRAFT_RUN_ID'))
71  } catch {
72    return ''
73  }
74}
75async function envArticleId($: any): Promise<string> {
76  try {
77    return clean(await $.env.get('BBM_DRAFT_ARTICLE_ID'))
78  } catch {
79    return ''
80  }
81}
82async function hasServiceKey($: any): Promise<boolean> {
83  try {
84    return clean(await $.env.get('SUPABASE_SERVICE_ROLE_KEY')) !== ''
85  } catch {
86    return false
87  }
88}
89
90function ciDispatchText(run: Run): string {
91  const call = {
92    method: 'run_workflow',
93    owner: 'brand-bridge-media',
94    repo: 'bbm-infrastructure',
95    workflow_id: GATE_WORKFLOW,
96    ref: 'main',
97    inputs: { run_id: run.runId, gate: 'stage-6-draft', dry_run: 'false' },
98  }
99  return (
100    `Dispatch the credentialed Draft lint with mcp__github__actions_run_trigger: ${JSON.stringify(call)}\n` +
101    `That workflow runs draft-lint WITH credentials on run ${run.runId} and writes the stage-6 Draft receipt itself ` +
102    `(done only on a complete PASS; a failing lint writes a failing receipt with its reasons). ` +
103    `Post your v2_s6 findings (article_content, product_copy) through the Supabase MCP FIRST: the lint reads them. ` +
104    `Do not write the stage-6 receipt yourself; it stays blocked here. Wait for the run (mcp__github__actions_get / actions_list), ` +
105    `then READ the result back (Supabase MCP, read only): the newest stage-6 entry in refresh_runs.stages for this run must be status done ` +
106    `with a note carrying lint_sha=. If the run fails, fix the draft, re-post findings, dispatch again.`
107  )
108}
109
110// Once pinned, EVERY run of a workflow by this session is held to one exact shape (LC-1869 security review):
111// the service role is injected into run-refresh-stage-gate.yml, so a dispatch of a pushed branch's copy of
112// draft_lint.py / gate_runner.py would run attacker-chosen code with it. Therefore: this repo, ref exactly main,
113// workflow exactly run-refresh-stage-gate.yml (a numeric id or any other workflow is refused outright), gate exactly
114// stage-6-draft, run_id the pinned run. Any other method of the tool (rerun, cancel ...) is refused too: a rerun
115// would execute a run's own ref.
116const GATE_OWNER = 'brand-bridge-media'
117const GATE_REPO = 'bbm-infrastructure'
118const GATE_REF = 'main'
119const GATE_NAME = 'stage-6-draft'
120function dispatchViolation(e: any, run: Run): string | undefined {
121  const method = String(e.method ?? '')
122  if (method !== 'run_workflow')
123    return `while pinned only method run_workflow of this tool is allowed (got ${JSON.stringify(method)})`
124  if (e.owner !== GATE_OWNER || e.repo !== GATE_REPO)
125    return `dispatch targets ${JSON.stringify(`${e.owner}/${e.repo}`)}, only ${GATE_OWNER}/${GATE_REPO} is allowed`
126  if (e.workflow_id !== GATE_WORKFLOW)
127    return `workflow_id ${JSON.stringify(e.workflow_id)} is not allowed, only the exact name ${GATE_WORKFLOW} (a numeric id or another workflow is refused)`
128  if (e.ref !== GATE_REF) return `ref ${JSON.stringify(e.ref)} is not allowed, only ${GATE_REF} (a branch would run its own copy of the lint with the service role)`
129  let inputs: any = e.inputs
130  if (typeof inputs === 'string') {
131    try {
132      inputs = JSON.parse(inputs)
133    } catch {
134      return `could not read the workflow inputs; this session drafts run ${run.runId}`
135    }
136  }
137  if (inputs?.gate !== GATE_NAME) return `gate ${JSON.stringify(inputs?.gate)} is not allowed, only ${GATE_NAME}`
138  if (typeof inputs?.run_id !== 'string') return `${GATE_WORKFLOW} dispatch names run (none), this session drafts run ${run.runId}`
139  const rid = inputs.run_id.toLowerCase()
140  if (rid !== run.runId.toLowerCase()) return `${GATE_WORKFLOW} dispatch names run ${rid || '(none)'}, this session drafts run ${run.runId}`
141  return undefined
142}
143
144// The pin the guards use: the in-memory one, else the env pin. A mod reload wipes the in-memory pin; the env pin is
145// not wiped, so the dispatch guards must not lift with it.
146async function effectivePin($: any, run: Run | null): Promise<Run | null> {
147  if (run) return run
148  const r = (await envRunId($)).toLowerCase()
149  const a = (await envArticleId($)).toLowerCase()
150  if (UUID_SHAPE.test(r) && UUID_SHAPE.test(a)) return { runId: r, articleId: a, slug: '', variant: '' }
151  return null
152}
153
154// Workflow dispatch through the shell: the gh CLI (workflow / run / api, with or without leading flags) and any
155// curl / wget / http call to api.github.com whose path reaches dispatches, actions/workflows or actions/runs. Quoting and
156// backslashes are stripped and whitespace collapsed first, case-insensitive. Best effort (a script that builds the
157// request elsewhere is not seen); defence in depth, see README.
158function shellDispatchViolation(command: string): string | undefined {
159  const c = command.replace(/['"`\\]/g, '').replace(/\s+/g, ' ')
160  if (/\bgh\s+(?:-[-\w]+(?:[ =]\S+)?\s+)*(?:workflow|run|api)\b/i.test(c))
161    return 'the gh CLI (workflow / run / api) is not available to a pinned drafting session; use the mcp__github__actions_run_trigger dispatch from draft_lint_request'
162  if (/\b(?:curl|wget|https?|xh)\b/i.test(c) && /api\.github\.com/i.test(c) && /(?:dispatches|actions\/workflows|actions\/runs)/i.test(c))
163    return 'a direct GitHub API call to dispatches / actions/workflows / actions/runs is not available to a pinned drafting session; use the mcp__github__actions_run_trigger dispatch from draft_lint_request'
164  return undefined
165}
166
167const CONTRACT_FALLBACKS = [
168  '/home/user/bbm-refresh-agent/stages/v2_s6_draft.md',
169  '../bbm-refresh-agent/stages/v2_s6_draft.md',
170]
171
172// A gate that throws before it reached next() denies. After next() was reached the tool already ran, and in a
173// .catch handler `next` is replay-safe (it resolves to what the last call settled to, nothing beneath runs
174// again), so returning next(e) there reports the real outcome instead of a deny for a write that happened.
175const failClosed = ($: any, e: any, next: any) =>
176  next.called ? next(e) : { deny: say($, 'its guard failed; refusing.') }
177
178type Contract = { ok: true; path: string; text: string } | { ok: false; tried: string[]; err: string }
179
180async function loadContract($: any, configured: string): Promise<Contract> {
181  const tried = configured ? [configured] : CONTRACT_FALLBACKS
182  let err = ''
183  for (const p of tried) {
184    try {
185      const text: string = await $.fs.read(p)
186      if (text.trim().length > 0) return { ok: true, path: p, text }
187      err = `${p} is empty`
188    } catch (e) {
189      err = `${p}: ${(e as Error)?.message ?? String(e)}`
190    }
191  }
192  return { ok: false, tried, err }
193}
194
195// draft_lint.PRE_RECEIPT_SCOPE, copied (a mod cannot import the lint).
196const PRE_RECEIPT_SCOPE = 'draft_receipt_complete.status'
197const LINT_REL = 'webflow/bulk-operations/draft-lint/draft_lint.py'
198
199// Same discipline as draft_lint_tool.py: BBM_INFRA_PATH first, never the cwd. When it is unset the
200// session root (the checkout this session runs in) is the fallback. Neither found = a loud error.
201async function resolveLintScript($: any): Promise<{ ok: true; path: string } | { ok: false; err: string }> {
202  const fromEnv = await $.env.get('BBM_INFRA_PATH')
203  const root = fromEnv || (await $.session.root())
204  if (!root) return { ok: false, err: 'BBM_INFRA_PATH is unset and the session has no root; refusing to guess a checkout' }
205  const path = `${String(root).replace(/\/$/, '')}/${LINT_REL}`
206  try {
207    await $.fs.stat(path)
208    return { ok: true, path }
209  } catch {
210    return { ok: false, err: `no draft_lint.py at ${path} (set BBM_INFRA_PATH to a bbm-infrastructure checkout)` }
211  }
212}
213
214// What the lint's own receipt says it examined. A PASS that skipped a check is a partial lint.
215type Verdict = { unlock: boolean; lines: string[] }
216function readReceipt(raw: string, planArticleId: string | undefined, run: Run, planVariant: string | undefined): Verdict {
217  // A plan that names no article is not evidence about THIS article: explicit refusal, not an empty-string mismatch.
218  if (planArticleId === '') return { unlock: false, lines: ['plan.json carries no article_id; receipt stays locked'] }
219  const lines: string[] = []
220  let result: any
221  try {
222    result = JSON.parse(raw)?.result
223  } catch {
224    return { unlock: false, lines: ['lint receipt was unreadable; treating the lint as not run'] }
225  }
226  if (!result || typeof result.passed !== 'boolean') return { unlock: false, lines: ['lint receipt has no verdict; treating the lint as not run'] }
227  if (planArticleId !== undefined && planArticleId.toLowerCase() !== run.articleId.toLowerCase()) {
228    return { unlock: false, lines: [`plan.json is for article ${planArticleId}, not this run's ${run.articleId}; receipt stays locked`] }
229  }
230  if (!result.passed) return { unlock: false, lines }
231  // The lint may skip exactly one check (--pre-receipt). A receipt that scoped out anything else, or a malformed
232  // checks_scoped, is a lint we did not ask for: it never unlocks.
233  const scoped = result.counts?.checks_scoped
234  if (scoped !== undefined && !(Array.isArray(scoped) && scoped.length === 1 && scoped[0] === PRE_RECEIPT_SCOPE))
235    return { unlock: false, lines: [`lint receipt scoped out ${JSON.stringify(scoped)}; only ["${PRE_RECEIPT_SCOPE}"] may be skipped; receipt stays locked`] }
236  if (result.brief_scanned !== true) lines.push('NOT RUN: locked-heading rule (no usable heading brief for this run)')
237  if (methodRulesApply(run.variant, planVariant) && result.counts?.v2_method_rules_run !== true) lines.push('NOT RUN: V2 approved-method rules (template check)')
238  if (result.testers_block_scanned !== true) lines.push('note: testers-block rule did not run (no testers list supplied)')
239  if (result.experts_block_scanned !== true) lines.push('note: experts rule did not run (no experts list supplied)')
240  const blocking = lines.filter(l => l.startsWith('NOT RUN'))
241  return { unlock: blocking.length === 0, lines }
242}
243
244export const register: Register = (on, options) => {
245  const str = (k: string) => String(options[k] ?? '').trim()
246
247  // Module variables are the session's: a reload wipes them, and a wiped lint pass
248  // means "not passed" — the safe direction.
249  let run: Run | null =
250    str('runId') && str('articleId')
251      ? { runId: str('runId'), articleId: str('articleId'), slug: str('slug'), variant: str('templateVariant') }
252      : null
253  let lintPassed = false
254  let lintedPlan = ''
255  let lintNote = 'draft-lint has not been run in this session'
256
257  // Re-locking is a state change the model and the operator should see, but only when a pass was actually lost:
258  // it returns the call-out text to say (or '' when nothing was lost). The engine follows `$` only into
259  // top-level functions, so the caller says it.
260  const dirty = (why: string): string => {
261    const lost = lintPassed
262    if (lost) lintNote = `draft-lint passed earlier, but ${why} since; re-run /draft-done`
263    lintPassed = false
264    return lost ? `Draft receipt re-locked: ${lintNote}` : ''
265  }
266
267  on('session.start', async ($, e, next) => {
268    const started = await next(e)
269    await $.command.register({
270      name: 'draft-start',
271      description: 'Pin this session to a refresh run: /draft-start <run_id> <article_id> <slug> [v1|v2|standard]',
272      argumentHint: '<run_id> <article_id> <slug> [v1|v2|standard]',
273    })
274    await $.command.register({
275      name: 'draft-done',
276      description: 'Run draft-lint (v2_s6_5 checks); passing unlocks the Draft receipt. Optional plan.json for offline lint',
277      argumentHint: '[plan.json]',
278    })
279    await $.tool.register({
280      name: 'draft_pin',
281      description:
282        'Pin this session to a refresh run so the Draft guards apply (writes to other articles and the passing stage-6 receipt are gated). ' +
283        'Call once at the start of a drafting session. Setting a pin only adds restrictions; the same run again is a no-op; ' +
284        'a different run is refused and nothing can unpin (a human does that with /draft-start).',
285      inputSchema: {
286        type: 'object',
287        properties: {
288          run_id: { type: 'string', description: 'refresh_runs.id (uuid)' },
289          article_id: { type: 'string', description: 'articles.id (uuid) of the run' },
290          slug: { type: 'string', description: 'Optional article slug; pins file edits to that article' },
291        },
292        required: ['run_id', 'article_id'],
293      },
294    })
295    await $.tool.register({
296      name: 'draft_lint_request',
297      description:
298        'Ask for the credentialed Draft lint. Returns the exact GitHub workflow dispatch that runs draft-lint in CI and writes the stage-6 receipt. ' +
299        'Changes nothing and unlocks nothing; the receipt is written only by that workflow, from the real lint result.',
300      inputSchema: { type: 'object', properties: {} },
301    })
302    if (!run) {
303      // Optional env pin (the orchestrator brief can set it). Both must be present and uuid-shaped; anything else pins nothing.
304      const envRun = await envRunId($)
305      const envArt = await envArticleId($)
306      if (envRun && envArt) {
307        if (UUID_SHAPE.test(envRun) && UUID_SHAPE.test(envArt)) {
308          run = { runId: envRun.toLowerCase(), articleId: envArt.toLowerCase(), slug: '', variant: '' }
309          lintPassed = false
310          lintNote = 'draft-lint has not been run for this run'
311          say($, `session pinned to run ${run.runId} (article ${run.articleId}) from BBM_DRAFT_RUN_ID / BBM_DRAFT_ARTICLE_ID`)
312        } else {
313          say($, 'BBM_DRAFT_RUN_ID / BBM_DRAFT_ARTICLE_ID are set but not uuids: this session is NOT pinned. Call draft_pin.')
314        }
315      }
316    }
317    if (run) $.ui.status(`drafting run ${run.runId.slice(0, 8)}`)
318    return started
319  })
320
321  // Model-callable pin. The only state change: unpinned -> pinned (or add a slug to a pin with none). Never unpin, never re-pin.
322  on('tool.call', { tool: 'mcp__draft-session__draft_pin' }, async ($, e: any) => {
323    const runId = String(e.run_id ?? '').trim().toLowerCase()
324    const articleId = String(e.article_id ?? '').trim().toLowerCase()
325    const slug = String(e.slug ?? '').trim()
326    if (!UUID_SHAPE.test(runId) || !UUID_SHAPE.test(articleId))
327      return { deny: say($, 'draft_pin REFUSED: run_id and article_id must both be uuids (refresh_runs.id and articles.id). Nothing was pinned.') }
328    if (slug && !SLUG_SHAPE.test(slug)) return { deny: say($, `draft_pin REFUSED: slug ${JSON.stringify(slug)} is not a lowercase slug. Nothing was pinned.`) }
329    // An env pin outlives a mod reload (the in-memory pin does not): when BBM_DRAFT_RUN_ID is set it is the only run
330    // this session may be pinned to, even if the in-memory state is empty.
331    const envRun = (await envRunId($)).toLowerCase()
332    const envArt = (await envArticleId($)).toLowerCase()
333    if (envRun && envRun !== runId)
334      return { deny: say($, `draft_pin REFUSED: BBM_DRAFT_RUN_ID pins this session to run ${envRun}, not ${runId}. Nothing was pinned.`) }
335    if (envArt && envArt !== articleId)
336      return { deny: say($, `draft_pin REFUSED: BBM_DRAFT_ARTICLE_ID pins this session to article ${envArt}, not ${articleId}. Nothing was pinned.`) }
337    if (run) {
338      if (run.runId.toLowerCase() !== runId || run.articleId.toLowerCase() !== articleId)
339        return { deny: say($, `draft_pin REFUSED: this session is already pinned to run ${run.runId} (article ${run.articleId}). A pin is never changed from inside the session; a human re-pins with /draft-start.`) }
340      if (slug && run.slug && slug !== run.slug)
341        return { deny: say($, `draft_pin REFUSED: the pin already names slug ${run.slug}, not ${slug}.`) }
342      if (slug && !run.slug) {
343        run = { ...run, slug }
344        return { result: say($, `pin extended: file edits are now pinned to slug ${slug} (run ${run.runId} unchanged).`) }
345      }
346      return { result: say($, `already pinned to run ${run.runId}; no change.`) }
347    }
348    run = { runId, articleId, slug, variant: '' }
349    lintPassed = false
350    lintedPlan = ''
351    lintNote = 'draft-lint has not been run for this run'
352    $.ui.status(`drafting run ${runId.slice(0, 8)}`)
353    const c = await loadContract($, str('contractPath'))
354    const head = say($, `session pinned to run ${runId} (article ${articleId}); writes to other articles and the Draft receipt are now gated. The pin cannot be removed from inside the session.`)
355    return {
356      result: c.ok
357        ? `${head}\nContract: read stages/v2_s6_draft.md (${c.path}, ${c.text.length} chars); it is injected from the next turn.`
358        : `${head}\n[mod:${MOD}] The contract is UNREADABLE (${c.err}); do not draft from memory.`,
359    }
360  })
361
362  // Read-only: hands back the dispatch. It cannot unlock anything.
363  on('tool.call', { tool: 'mcp__draft-session__draft_lint_request' }, async ($, e: any) => {
364    if (!run) return { deny: say($, 'draft_lint_request REFUSED: this session is not pinned to a run. Call draft_pin {run_id, article_id} first.') }
365    lintPassed = false
366    lintNote = 'credentialed draft-lint requested; the stage-6 receipt is written by CI only'
367    return { result: `${say($, 'the credentialed draft-lint runs in CI; the Draft receipt stays blocked here until CI has written it.')}\n${ciDispatchText(run)}` }
368  })
369
370  // A pinned session may dispatch the stage gate for its own run only.
371  on('tool.call', { tool: DISPATCH_TOOL }, async ($, e: any, next) => {
372    const pin = await effectivePin($, run)
373    if (!pin) return next(e)
374    const bad = dispatchViolation(e, pin)
375    if (bad) return { deny: say($, `${bad}.`) }
376    return next(e)
377  }).catch(failClosed)
378
379  // The same ban for the shell routes to a workflow run (gh, direct API calls). Registered before the Bash gates below.
380  on('tool.call', { tool: 'Bash' }, async ($, e: any, next) => {
381    if (!(await effectivePin($, run))) return next(e)
382    const bad = shellDispatchViolation(String(e.command ?? ''))
383    if (bad) return { deny: say($, `${bad}.`) }
384    return next(e)
385  }).catch(failClosed)
386
387  on('command.run', { command: 'draft-start' }, async ($, e) => {
388    const [runId, articleId, slug, variant] = e.args.trim().split(/\s+/)
389    // The slug is required: it is the only pin that can tell this article's files from another's.
390    if (!runId || !articleId || !slug) return { text: 'usage: /draft-start <run_id> <article_id> <slug> [v1|v2|standard] (the first three are required; the slug pins file edits; an unstated template is treated as v2, the strict side)' }
391    run = { runId, articleId, slug, variant: variant ?? '' }
392    lintPassed = false
393    lintNote = 'draft-lint has not been run for this run'
394    $.ui.status(`drafting run ${runId.slice(0, 8)}`)
395    const c = await loadContract($, str('contractPath'))
396    // One say() per outcome: its return value is both the toast and the headline the model reads, so the
397    // two cannot diverge. An unreadable contract is a block, so it toasts the block reason, not just the pin.
398    const headline = c.ok
399      ? say($, `session pinned to run ${runId} (article ${articleId}); writes to other articles and the Draft receipt are now gated`)
400      : say($, `session pinned to run ${runId}, but the CONTRACT IS UNREADABLE: ${c.err}. The contract was NOT injected and /draft-done will refuse. Do not draft from memory.`)
401    return {
402      text: c.ok
403        ? `${headline}\nContract: ${c.path} (${c.text.length} chars) is injected into the system prompt from the next turn.`
404        : headline,
405      context: [
406        `${headline} Read stages/v2_s6_draft.md in full before drafting if it is not in your system prompt.`,
407      ],
408    }
409  })
410
411  on('command.run', { command: 'draft-done' }, async ($, e) => {
412    if (!run) return { text: 'draft-session is not pinned to a run. /draft-start <run_id> <article_id> first.' }
413    lintPassed = false
414    const c = await loadContract($, str('contractPath'))
415    if (!c.ok) {
416      lintNote = 'contract unreadable'
417      return { text: say($, `REFUSED: contract unreadable (${c.err}). Fix the checkout path (userConfig contractPath) and retry.`) }
418    }
419    const plan = e.args.trim()
420    const script = await resolveLintScript($)
421    if (!script.ok) {
422      lintNote = 'draft_lint.py not found'
423      return { text: say($, `REFUSED: ${script.err}. The Draft receipt stays locked.`) }
424    }
425    // --pre-receipt (LC-1869): this lint DECIDES the stage-6 receipt, so it skips only the "v2_s6 status is not done"
426    // finding (that status is the outcome). readReceipt refuses any other checks_scoped.
427    // --run-id in BOTH modes: it is what turns on the locked-heading and template checks.
428    let planArticle: string | undefined
429    let planVariant: string | undefined
430    if (plan) {
431      try {
432        const parsed = JSON.parse(await $.fs.read(plan))
433        planArticle = String(parsed?.article_id ?? '')
434        planVariant = parsed?.template_variant ?? undefined
435      } catch (err) {
436        lintNote = 'plan unreadable'
437        return { text: say($, `REFUSED: could not read plan ${plan}: ${(err as Error)?.message ?? String(err)}. The Draft receipt stays locked.`) }
438      }
439    }
440    // No service role = the lint cannot read the run (exit 2). Do not run it to learn that: hand over the CI dispatch.
441    // Nothing is unlocked; the stage-6 receipt is then written by the workflow, from the real result.
442    if (!(await hasServiceKey($))) {
443      lintNote = 'no service-role key in this session; draft-lint runs in CI (stage-6-draft gate)'
444      $.ui.status(`draft-lint via CI · run ${run.runId.slice(0, 8)}`)
445      return { text: `${say($, 'no service-role key in this session, so draft-lint cannot run here. The Draft receipt stays blocked; CI writes it.')}\n${ciDispatchText(run)}` }
446    }
447    // The receipt file is how we learn what the lint actually EXAMINED, not just its exit code. It goes in a directory
448    // THIS call creates (mktemp -d: new, mode 700, unpredictable name), so a stale or planted file cannot stand in for it.
449    let outDir = ''
450    try {
451      const m = await $.process.run(['mktemp', '-d', '-t', 'draft-session-lint-XXXXXXXX'], { timeoutMs: 10_000 })
452      outDir = m.exitCode === 0 ? String(m.stdout).trim() : ''
453    } catch {
454      outDir = ''
455    }
456    if (!outDir.startsWith('/')) {
457      lintNote = 'could not create a private directory for the lint receipt'
458      return { text: say($, 'REFUSED: could not create a private directory for the lint receipt. The Draft receipt stays locked.') }
459    }
460    const outPath = `${outDir}/lint.json`
461    const argv = plan
462      ? ['python3', script.path, '--plan', plan, '--run-id', run.runId, '--pre-receipt', '--output', outPath]
463      : ['python3', script.path, '--article-id', run.articleId, '--run-id', run.runId, '--pre-receipt', '--output', outPath]
464    let out = ''
465    let code: number
466    try {
467      const r = await $.process.run(argv, { timeoutMs: 120_000 })
468      code = r.exitCode
469      out = `${r.stdout}${r.stderr ? `\n${r.stderr}` : ''}`.trim()
470    } catch (err) {
471      code = -1
472      out = `could not run draft-lint: ${(err as Error)?.message ?? String(err)}`
473    }
474    // Only exit 0 can unlock; 1 = findings; 2 or anything else = the lint could not examine the draft.
475    let verdict: Verdict = { unlock: false, lines: [] }
476    if (code === 0) {
477      let raw = ''
478      try {
479        raw = await $.fs.read(outPath)
480      } catch {
481        raw = ''
482      }
483      verdict = readReceipt(raw, planArticle, run, planVariant)
484    } else if (planArticle !== undefined && planArticle.toLowerCase() !== run.articleId.toLowerCase()) {
485      verdict.lines.push(`plan.json is for article ${planArticle}, not this run's ${run.articleId}`)
486    }
487    lintPassed = verdict.unlock
488    lintedPlan = lintPassed ? plan : ''
489    const partial = code === 0 && !verdict.unlock
490    lintNote = lintPassed ? 'draft-lint PASS' : partial ? 'partial lint (checks did not run)' : `draft-lint did not pass (exit ${code})`
491    const head = [out, ...verdict.lines].filter(Boolean).join('\n').split('\n').slice(0, 60).join('\n')
492    $.ui.status(lintPassed ? `draft-lint PASS · run ${run.runId.slice(0, 8)}` : `draft-lint FAIL · run ${run.runId.slice(0, 8)}`)
493    // Unlocking and staying locked are both actions of this mod: each is called out, with the lint's head below it.
494    const headline = lintPassed
495      ? say($, 'draft-lint PASS: the Draft receipt is unlocked until the next draft change.')
496      : partial
497        ? say($, 'PARTIAL LINT, receipt stays locked: the lint exited 0 but skipped checks it needs.')
498        : say($, `draft-lint did NOT pass (exit ${code}). The Draft receipt stays blocked. Fix the draft, then /draft-done again.`)
499    return { text: `${headline}\n${head}` }
500  })
501
502  on('prompt.compose', async ($, e, next) => {
503    const base = await next(e)
504    if (!run) return base
505    const c = await loadContract($, str('contractPath'))
506    const text = c.ok
507      ? `# Draft session — run ${run.runId}, article ${run.articleId}\n` +
508        `You are drafting v2_s6 for this run only. The contract below is read live from ${c.path}; it is the source. ` +
509        `Run /draft-done (draft-lint) before writing the Draft receipt; the receipt is blocked until lint passes.\n\n${c.text}`
510      : `# Draft session — CONTRACT UNAVAILABLE\n` +
511        `The v2_s6 contract could not be read (${c.err}). DO NOT draft from memory or any older copy. ` +
512        `Tell the user plainly that the contract file is missing and stop.`
513    if (!c.ok) say($, 'contract unreadable, drafting is blocked', 15_000)
514    return { sections: [...base.sections, { id: 'draft-session:contract', text, scope: 'session' as const }] }
515  })
516
517  // ---- gates: every one fails CLOSED (a throw while a run is pinned denies) ----
518
519  on('tool.call', { tool: 'Bash' }, ($, e, next) => {
520    if (!run) return next(e)
521    const bad = bashPinViolation(e.command, run) ?? sqlPinViolation(e.command, run)
522    if (bad) return { deny: say($, `${bad}.`) }
523    // psql/curl-style stage-6 writes carry the same SQL as the MCP form: gate them the same way
524    if (isDraftReceiptSql(e.command) && !lintPassed) return refuse($, `Draft receipt blocked: ${lintNote}`)
525    const r = classifyReceiptCommand(e.command, run)
526    if (r.pinViolation) return { deny: say($, `${r.pinViolation}.`) }
527    if (r.gated && !lintPassed) return refuse($, `Draft receipt blocked: ${lintNote}`)
528    const fileBad = bashFileWriteViolation(e.command, run)
529    if (fileBad) return { deny: say($, `${fileBad}.`) }
530    const touchesPlan = lintedPlan !== '' && e.command.includes(lintedPlan)
531    if (runsDraftWriter(e.command) || writesArticleTable(e.command) || (looksLikeFileWrite(e.command) && (bashTouchesArticleFile(e.command) || touchesPlan)))
532      relock($, dirty('a command may have changed the draft'))
533    return next(e)
534  }).catch(failClosed)
535
536  on('tool.call', { tool: /^mcp__.*__(execute_sql|apply_migration)$/ }, ($, e, next) => {
537    if (!run) return next(e)
538    const sql = String((e as any).query ?? '')
539    const bad = sqlPinViolation(sql, run)
540    if (bad) return { deny: say($, `${bad}.`) }
541    if (isDraftReceiptSql(sql) && !lintPassed) return refuse($, `Draft receipt blocked: ${lintNote}`)
542    if (writesArticleTable(sql)) relock($, dirty('the draft was written'))
543    return next(e)
544  }).catch(failClosed)
545
546  on('tool.call', { tool: /^(Edit|Write|NotebookEdit)$/ }, ($, e, next) => {
547    if (!run) return next(e)
548    const path = String((e as any).file_path ?? (e as any).notebook_path ?? '')
549    const bad = filePinViolation(path, run)
550    if (bad) return { deny: say($, `${bad}.`) }
551    // Only the run's own article files (and the plan that was linted) are the draft; scratch and notes edits do not re-lock.
552    if (isArticlePath(path) || (lintedPlan !== '' && path.endsWith(lintedPlan))) relock($, dirty('the draft was edited'))
553    return next(e)
554  }).catch(failClosed)
555}
556
hooks/gate.ts 159 lines
1// Pure decision helpers for the draft-session mod. No engine calls here, so the
2// rules that BLOCK a receipt can be read (and tested) without a session.
3
4// Statuses write_stage_receipt.py treats as "this section is finished".
5// Anything not in IN_PROGRESS_OR_FAILED counts as passing, so an unknown or
6// missing status is gated too (fail closed).
7const NOT_PASSING = new Set(['partial', 'pending', 'in_progress', 'todo', 'fail', 'failed', 'blocked'])
8
9export type Run = { runId: string; articleId: string; slug: string; variant: string }
10
11// The lint receipt does not say whether the V2 approved-method rules APPLIED (a v1/standard receipt is
12// byte-identical by design), so the mod must know the run's template. Unknown = treated as v2: the strict side.
13export const VARIANTS = ['v1', 'v2', 'standard']
14export const methodRulesApply = (pinned: string, planVariant: string | undefined): boolean =>
15  pinned === 'v2' || planVariant === 'v2' || !VARIANTS.includes(pinned)
16
17const flag = (cmd: string, name: string): string | undefined => {
18  const m = new RegExp(`--${name}(?:=|\\s+)(?:"([^"]*)"|'([^']*)'|(\\S+))`).exec(cmd)
19  return m ? (m[1] ?? m[2] ?? m[3]) : undefined
20}
21
22export type ReceiptCall = { gated: boolean; pinViolation?: string }
23
24// Is this Bash command a PASSING Draft (stage 6) receipt write for some run?
25export function classifyReceiptCommand(cmd: string, run: Run): ReceiptCall {
26  if (!/write_stage_receipt\.py/.test(cmd)) return { gated: false }
27  if (/--selftest\b/.test(cmd) || /--dry-run\b/.test(cmd)) return { gated: false }
28  const stage = flag(cmd, 'stage')
29  const status = (flag(cmd, 'status') ?? '').toLowerCase()
30  if (NOT_PASSING.has(status)) return { gated: false }
31  // A readable stage other than 6 is somebody else's receipt; a missing or malformed stage cannot be
32  // told apart from stage 6, so it is gated (fail closed).
33  if (stage !== undefined && /^\d+(\.\d+)?$/.test(stage) && Number(stage) !== 6) return { gated: false }
34  const rid = flag(cmd, 'run-id')
35  const slug = flag(cmd, 'slug')
36  if (rid !== undefined && rid !== run.runId)
37    return { gated: true, pinViolation: `receipt targets run ${rid}, this session drafts run ${run.runId}` }
38  if (slug !== undefined && (!run.slug || slug !== run.slug))
39    return { gated: true, pinViolation: `receipt targets slug ${slug}; pin by --run-id ${run.runId}` }
40  if (rid === undefined && slug === undefined)
41    return { gated: true, pinViolation: `receipt names no run; use --run-id ${run.runId}` }
42  return { gated: true }
43}
44
45const WRITE = /\b(update|insert\s+into|upsert|delete\s+from|merge\s+into)\b/i
46// Tables that hold ONE article's draft; a write to them must name this article.
47const ARTICLE_TABLES = /\b(roundup_slots|article_products|article_product_claims|article_selection_briefs)\b/i
48const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi
49
50const PASSING = ['done', 'completed', 'pass', 'passed', 'verified', 'complete', 'completed_with_override', 'skipped']
51const hasToken = (sql: string, words: readonly string[]) =>
52  words.some(w => new RegExp(`(?<![a-z0-9_])${w}(?![a-z0-9_])`, 'i').test(sql))
53
54// Default-deny, not shape-matching: statement spellings are endless (UPDATE ... SET, INSERT column lists
55// and VALUES, JSON, upper case, unquoted), so this does not try to parse them. Any write to a run table
56// that mentions `status` is gated UNLESS the only status words it carries are not-passing ones.
57// Findings-only writes (no `status` at all) stay open: draft-lint reads those findings.
58export function isDraftReceiptSql(sql: string): boolean {
59  if (!WRITE.test(sql)) return false
60  if (!/\brefresh_run_sections\b|\brefresh_runs\b/i.test(sql)) return false
61  if (!/\bstatus\b/i.test(sql)) return false
62  return hasToken(sql, PASSING) || !hasToken(sql, [...NOT_PASSING])
63}
64
65// Returns a violation message when the SQL writes article-scoped data outside the run's article
66// or touches a different run's rows; undefined when it is fine.
67export function sqlPinViolation(sql: string, run: Run): string | undefined {
68  if (!WRITE.test(sql)) return undefined
69  const ids = (sql.match(UUID) ?? []).map(s => s.toLowerCase())
70  if (ARTICLE_TABLES.test(sql)) {
71    // The pin must be BOUND to the write: an article_id predicate equal to the pinned id (or, for an
72    // INSERT, the id inside the VALUES). A bare UUID in a comment or string must not count.
73    const pin = run.articleId.toLowerCase()
74    const bound = new RegExp(`article_id\\s*(?:=|in\\s*\\()\\s*'${pin}'`, 'i').test(sql)
75    const inserted = /\binsert\s+into\b/i.test(sql) && new RegExp(`values\\s*\\([^)]*'${pin}'`, 'is').test(sql)
76    if (!bound && !inserted) return `write to an article table without an article_id = '${run.articleId}' predicate`
77  }
78  if (/\brefresh_run_sections\b|\brefresh_runs\b/i.test(sql)) {
79    // Same binding rule as the article pin: a run_id (or, for refresh_runs, id) predicate equal to the pin,
80    // or the id inside an INSERT's VALUES. A UUID sitting in a comment or string proves nothing.
81    const rp = run.runId.toLowerCase()
82    const bound = new RegExp(`\\b(?:run_id|id)\\s*(?:=|in\\s*\\()\\s*'${rp}'`, 'i').test(sql)
83    const inserted = /\binsert\s+into\b/i.test(sql) && new RegExp(`values\\s*\\([^)]*'${rp}'`, 'is').test(sql)
84    if (!bound && !inserted) return `write to a run table without a run_id = '${run.runId}' predicate`
85  }
86  const foreign = ids.filter(i => i !== run.articleId.toLowerCase() && i !== run.runId.toLowerCase())
87  if (ARTICLE_TABLES.test(sql) && foreign.length > 0 && /\barticle_id\b/i.test(sql)) {
88    // Another uuid next to article_id = likely another article's slot; slot ids are legal, so only
89    // flag when a different article_id comparison is spelled out.
90    const other = /article_id\s*(?:=|in\s*\()\s*'?([0-9a-f-]{36})/gi
91    let m: RegExpExecArray | null
92    while ((m = other.exec(sql))) if (m[1].toLowerCase() !== run.articleId.toLowerCase())
93      return `write names article_id ${m[1]}, not this run's ${run.articleId}`
94  }
95  return undefined
96}
97
98// Bash: the two draft writers + roster tools must be pointed at this article.
99export function bashPinViolation(cmd: string, run: Run): string | undefined {
100  if (!/(write_key_details|write_article_content|draft_lint)\.py/.test(cmd)) return undefined
101  const aid = flag(cmd, 'article-id')
102  const slug = flag(cmd, 'slug')
103  // The real draft writers (write_article_content.py, write_key_details.py) take --run-id and resolve the
104  // article from the run, so for them the run id IS the pin: it must be present and equal. A writer naming no
105  // run, or a different one, is refused; --slug alone never counts as a pin.
106  if (runsDraftWriter(cmd)) {
107    const rid = flag(cmd, 'run-id')
108    if (rid === undefined) return `draft writer names no run; pass --run-id ${run.runId}`
109    if (rid !== run.runId) return `--run-id ${rid} is not this session's run ${run.runId}`
110  }
111  if (aid !== undefined && aid.toLowerCase() !== run.articleId.toLowerCase())
112    return `--article-id ${aid} is not this run's article ${run.articleId}`
113  if (slug !== undefined && run.slug && slug !== run.slug)
114    return `--slug ${slug} is not this run's article ${run.slug}`
115  return undefined
116}
117
118// Article files live under a content root (bbm-content/ or an articles/ directory) at any depth:
119// bbm-content/best-sofas.md, content/articles/best-sofas/index.md, articles/a/b/best-sofas.mdx.
120export const ARTICLE_FILE = /(^|\/)(bbm-content|articles?)\/(?:.+\/)?[^/]+\.(md|mdx|json)$/i
121export const isArticlePath = (path: string): boolean => ARTICLE_FILE.test(path)
122
123// File edits to an article file: the pin is the slug. No slug pinned = every article file is refused
124// (nothing to match against must not read as "all allowed"); otherwise the filename stem or a whole
125// path segment must equal the slug exactly, so "not-best-sofas.md" does not pass for "best-sofas".
126export function filePinViolation(path: string, run: Run): string | undefined {
127  if (!isArticlePath(path)) return undefined
128  if (!run.slug) return `${path} is an article file but no slug is pinned; /draft-start <run_id> <article_id> <slug>`
129  const parts = path.split('/')
130  const stem = (parts[parts.length - 1] ?? '').replace(/\.[^.]+$/, '')
131  const ok = stem === run.slug || parts.slice(0, -1).includes(run.slug)
132  return ok ? undefined : `${path} is not this run's article (${run.slug})`
133}
134
135// Best effort, not a sandbox: a shell command that writes (cp/mv/install/rsync/tee/sed -i/redirect/heredoc)
136// and NAMES an article file of another article. Anything built from variables or globs is not seen.
137const FILE_TOKEN = /[^\s'"<>|;&=()]+\.(?:md|mdx|json)(?![\w.])/g
138const COPY_LIKE = /\b(cp|mv|install|rsync|ln)\b/
139export function bashFileWriteViolation(cmd: string, run: Run): string | undefined {
140  if (!(looksLikeFileWrite(cmd) || COPY_LIKE.test(cmd))) return undefined
141  for (const tok of cmd.match(FILE_TOKEN) ?? []) {
142    const bad = filePinViolation(tok, run)
143    if (bad) return bad
144  }
145  return undefined
146}
147export const bashTouchesArticleFile = (cmd: string): boolean => (cmd.match(FILE_TOKEN) ?? []).some(isArticlePath)
148
149// A write that changes the draft: any write to an article table, or a draft writer CLI that is not a dry run.
150export const writesArticleTable = (sql: string): boolean => WRITE.test(sql) && ARTICLE_TABLES.test(sql)
151export const runsDraftWriter = (cmd: string): boolean =>
152  /(write_key_details|write_article_content)\.py/.test(cmd) && !/--(dry-run|selftest)\b/.test(cmd)
153
154// Bash that rewrites files in place or redirects output: a draft may have changed (heuristic, see README).
155export const looksLikeFileWrite = (cmd: string): boolean => {
156  const c = cmd.replace(/\d?>&\d|\d?>\s*\/dev\/null/g, '')
157  return /\bsed\s+-[a-z]*i|\btee\b|>>?\s*[^\s&|]|python3?\s+-c\b|<<\s*['"]?\w+/.test(c)
158}
159