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…

Read-only public mirror of the Claude Code mods (plugins) that Brand Bridge Media sessions run. The source of truth is claude-mods/ in the private bbm-infrastructure repo; every merge there that touches a mod overwrites this repo with a fresh snapshot. Edits made here are lost on the next sync.
Pull requests are not accepted. One opened by anyone but the owner is closed automatically and the owner is notified.
Install (what the cloud environment setup script runs):
claude plugin marketplace add brand-bridge-media/bbm-mods
claude plugin install fleet-tracker@bbm-mods --scope userhooks/register.ts 556 lines1import 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}
556hooks/gate.ts 159 lines1// 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