SLOPSHOPPER

session-close

Watches what a session touched and, on /close-out or a wrap-up turn, lists the close-out obligations still unmet. A nudge, never a block, $0.

newbandguardcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-close
› 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 › /close-out ⎿ session-close: Close-out: nothing unmet. Seen: 0 ticket(s), 0 commented, 0 run(s), 0 Webflow write(s), 0 merged PR(s), 0 inge ⎿ session-close: Mod actions this session: none seen ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? 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 3 files
hooks/register.tsx 558 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Ledger } from '../types'
5import { MOD_TOOLS, OWN_MOD, modActionsIn, withModActions } from './modlog'
6
7// Session close-out: the daily ritual that used to drive the end-of-session
8// INGEST was retired 2026-10-06 (knowledge-system SKILL.md:43), so nothing
9// asks a session "did you finish properly?" any more. This mod learns what the
10// session touched from tool calls it actually observed, and on /close-out (or a
11// turn that sounds like wrapping up) lists the obligations still unmet.
12// It is a nudge: it never denies a call, never calls a model ($0), and never
13// touches the operator's two gates (live-site publish, irreversible money/data).
14
15const KEY = 'session-close:ledger'
16// A quiet stretch of turns after which an unmet obligation is worth repeating.
17const NUDGE_EVERY_TURNS = 6
18// "Non-trivial work" for the INGEST obligation: this many work signals.
19const NONTRIVIAL = 2
20
21const blank = (): Ledger => ({
22  turns: 0, lastNudgeTurn: 0, served: [], commented: [], webflowWrites: 0, prsMerged: 0,
23  commits: 0, prsOpened: 0, ingests: 0, runs: {}, published: false,
24  cannibScheduled: false, gscScheduled: false, passA: false, passB: false, modActions: [], lastModListTurn: 0,
25  fleetOpen: [], fleetLoaded: false,
26})
27
28const ledger = atom({ plugin: 'session-close', key: 'ledger' } as const, blank())
29
30type Unmet = { id: string; text: string; rule: string }
31
32// ---------------------------------------------------------------------------
33// Mod actions (LC-1711). Every bbm mod that denies, asks, blocks or changes something starts its
34// message with `[mod:<name>]` and fires a toast with the same text. `$.store` is private to one
35// plugin, so no shared log is possible; instead this mod reads the prefix off what it can SEE:
36// the result or deny text of every tool call that reaches its hook, and every toast any plugin
37// fires. Business analogy: not a shared logbook, a clerk who notes every stamped form that crosses
38// the desk. A mod action it never sees (a hook order that denies beneath it, a toast-less path) is
39// not listed; the list is a floor, not a census.
40// ---------------------------------------------------------------------------
41
42const modActionsLine = (l: Ledger): string =>
43  (l.modActions ?? []).length === 0
44    ? 'Mod actions this session: none seen'
45    : `Mod actions this session: ${(l.modActions ?? []).map(a => `[${a}]`).join(' ')}`
46
47// ---------------------------------------------------------------------------
48// Detection. Everything below is a pure function of (tool name, input, result
49// text) so the same logic is easy to reason about; the hooks only call it.
50// ---------------------------------------------------------------------------
51
52// MCP tools are `mcp__<server>__<tool>` and the server is an opaque id here,
53// so match on the tool name alone.
54const shortName = (tool: string): string => tool.split('__').pop() ?? tool
55const blobOf = (v: unknown): string => {
56  try { return JSON.stringify(v ?? '') } catch { return '' }
57}
58const JIRA_KEY = /\b(LC|BBM|LCH|LCC)-?(\d{1,5})\b/i
59const keyIn = (s: unknown): string | undefined => {
60  const m = JIRA_KEY.exec(String(s ?? ''))
61  return m ? `${m[1].toUpperCase()}-${m[2]}` : undefined
62}
63const add = (list: string[], k: string | undefined): string[] =>
64  k && !list.includes(k) ? [...list, k] : list
65
66const WEBFLOW_TOOL = /^(data_[a-z_]+_tool|asset_tool|designer_tool)$/
67// `publish(?!ed)`: a read like list_published_items is not a write or a publish.
68const WEBFLOW_WRITE = /create|update|delete|publish(?!ed)|set_|bulk|add_/i
69const SCHEDULE_TOOLS = new Set(['send_later', 'create_trigger', 'update_trigger', 'CronCreate', 'ScheduleWakeup'])
70// SQL with comments and string literals blanked (single-quoted, with '' escapes,
71// and $tag$...$tag$), so text inside them can never pass for a statement target.
72// An unterminated literal runs to the end: unscannable text earns no credit.
73function codeOf(q: string, keepLiterals = false): string {
74  let out = ''
75  let i = 0
76  while (i < q.length) {
77    const c = q[i]
78    if (c === '-' && q[i + 1] === '-') {
79      const nl = q.indexOf('\n', i)
80      i = nl < 0 ? q.length : nl
81    } else if (c === '/' && q[i + 1] === '*') {
82      const end = q.indexOf('*/', i + 2)
83      i = end < 0 ? q.length : end + 2
84      out += ' '
85    } else if (c === "'") {
86      let j = i + 1
87      for (; j < q.length; j++) {
88        if (q[j] === "'" && q[j + 1] === "'") j++
89        else if (q[j] === "'") break
90      }
91      // keepLiterals keeps a TERMINATED literal's text (a status value lives in
92      // one) but never an unterminated one.
93      out += keepLiterals && j < q.length ? q.slice(i, j + 1) : "''"
94      i = j + 1
95    } else if (c === '$') {
96      const m = /^\$[A-Za-z_]*\$/.exec(q.slice(i))
97      if (m) {
98        const end = q.indexOf(m[0], i + m[0].length)
99        out += keepLiterals && end >= 0 ? q.slice(i, end + m[0].length) : "''"
100        i = end < 0 ? q.length : end + m[0].length
101      } else {
102        out += c
103        i++
104      }
105    } else {
106      out += c
107      i++
108    }
109  }
110  return out
111}
112const UUID_ONCE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i
113const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi
114
115// What a scheduled-text covers. listicle-refresh Stage 7.5 check 5
116// (SKILL.md:511): "schedule (a) a +2-3 week cannibalization re-check and (b)
117// the 30/60/90 GSC checkpoint ... Jira or scheduled task, never chat."
118const COVERS_CANNIB = /cannibal|\+\s*[24]\s*(?:-\s*\d\s*)?(?:wk|week)|\b[24]\s*(?:-\s*\d\s*)?(?:wk|weeks?)\b/i
119// A bare "checkpoint" is not enough ("security checkpoint reminder" is not GSC).
120const COVERS_GSC = /30\s*[\/,\-–]\s*60\s*[\/,\-–]\s*90|\bgsc\b/i
121
122const actionNames = (i: any): string[] => {
123  const list = Array.isArray(i?.actions) ? i.actions : i?.actions ? [i.actions] : []
124  const names: string[] = []
125  for (const a of list) {
126    if (typeof a === 'string') names.push(a)
127    else if (a && typeof a === 'object') names.push(...Object.keys(a))
128  }
129  if (typeof i?.action === 'string') names.push(i.action)
130  return names
131}
132const hasTiming = (i: any): boolean =>
133  ['delay_minutes', 'at', 'run_once_at', 'cron_expression', 'delaySeconds', 'cron', 'recurring']
134    .some(k => i?.[k] !== undefined && i?.[k] !== null && i?.[k] !== '')
135
136function observe(l: Ledger, tool: string, input: any, resultText: string): Ledger {
137  const name = shortName(tool)
138  const blob = blobOf(input)
139  const next: Ledger = { ...l, runs: { ...l.runs } }
140
141  // Jira: a comment on a ticket (session-handoff SKILL.md:189 treats tickets
142  // moved in the session as the thread's record; the operator's rule is do -> verify
143  // -> update ticket).
144  if (name === 'addCommentToJiraIssue') {
145    const k = keyIn(input?.issueIdOrKey)
146    next.commented = add(next.commented, k)
147    const body = String(input?.commentBody ?? '')
148    // Pass A/B verdicts are recorded on the refresh ticket
149    // (listicle-refresh SKILL.md:515 Pass A, :547 Pass B).
150    // Only an anchored "Pass A: PASS|BLOCK" verdict, and only on a ticket this
151    // session actually worked, so unrelated prose or another ticket cannot
152    // satisfy it. The receipt-script path below stays the primary signal.
153    if (k && l.served.includes(k)) {
154      if (/\bpass\s*a\s*[:\-–]\s*(pass|block)\b/i.test(body)) next.passA = true
155      if (/\bpass\s*b\s*[:\-–]\s*(pass|block)\b/i.test(body)) next.passB = true
156    }
157    if (/next due/i.test(body)) schedule(next, body)
158  }
159  if (name === 'transitionJiraIssue' || name === 'editJiraIssue') {
160    next.served = add(next.served, keyIn(input?.issueIdOrKey))
161    // Jira "next due" write counts as a scheduling write.
162    if (/next due|duedate|due date/i.test(blob)) schedule(next, blob)
163  }
164  if (name === 'createJiraIssue' && /duedate|due date|next due/i.test(blob)) schedule(next, blob)
165
166  // Scheduled-task / send_later / create_trigger: the only places a re-check
167  // may live ("never chat").
168  // A real schedule write carries a timing argument; a bare call with prose
169  // alone is not a scheduled re-check.
170  if (SCHEDULE_TOOLS.has(name) && hasTiming(input)) schedule(next, blob)
171
172  // knowledge-system SKILL.md:154 -- INGEST is an INSERT INTO
173  // public.knowledge_memories; session-handoff SKILL.md:281 "Run the inserts directly."
174  if (name === 'execute_sql') {
175    const q = String(input?.query ?? '')
176    if (/\binsert\s+into\s+(?:public\.)?knowledge_memories\b/i.test(codeOf(q))) next.ingests += 1
177    if (/\brefresh_runs\b/i.test(codeOf(q))) observeRun(next, q, resultText)
178  }
179
180  // Webflow writes and publish. The publish click is the operator's gate: this only
181  // *records* that one happened.
182  if (WEBFLOW_TOOL.test(name)) {
183    // Match action NAMES (the keys of each `actions` entry, or an `action`
184    // string), never the serialized payload: an id like "my_publish_test"
185    // must not read as a publish.
186    const act = actionNames(input).join(' ')
187    if (WEBFLOW_WRITE.test(act)) {
188      next.webflowWrites += 1
189      if (/publish(?!ed)/i.test(act) && !/unpublish/i.test(act)) next.published = true
190    }
191  }
192
193  if (name === 'merge_pull_request') next.prsMerged += 1
194  if (name === 'create_pull_request') {
195    next.prsOpened += 1
196    next.served = add(next.served, keyIn(input?.title) ?? keyIn(input?.head))
197  }
198
199  if (tool === 'Bash') {
200    const cmd = String(input?.command ?? '')
201    if (/\bgit\s+commit\b/.test(cmd)) {
202      next.commits += 1
203      // Commit convention (CLAUDE.md §5): the Jira key leads the first line.
204      next.served = add(next.served, keyIn(cmd))
205    }
206    if (/\bgit\s+(?:checkout\s+-b|switch\s+-c|branch)\b/.test(cmd)) next.served = add(next.served, keyIn(cmd))
207    // listicle-refresh SKILL.md:167/:173 -- the Pass A / Pass B gates write receipts.
208    // Flag order is free: both tokens on the same article_audit.py invocation.
209    if (/article_audit\.py/.test(cmd) && /--write-receipt\b/.test(cmd)) {
210      if (/--pass[\s=]+a\b/.test(cmd)) next.passA = true
211      if (/--pass[\s=]+b\b/.test(cmd)) next.passB = true
212    }
213  }
214  return next
215}
216
217// Prose that says NOT to schedule must not earn credit for scheduling.
218const NEGATED = /\b(?:do not|don't|dont|no need to|never|skip|without|not)\b[^.\n]{0,40}\b(?:schedul\w*|gsc|cannibal\w*|checkpoint\w*|re-?check\w*)/i
219function schedule(l: Ledger, text: string): void {
220  if (NEGATED.test(text)) return
221  if (COVERS_CANNIB.test(text)) l.cannibScheduled = true
222  if (COVERS_GSC.test(text)) l.gscScheduled = true
223}
224
225// refresh_runs: listicle-refresh SKILL.md:543 -- "A run left `in_progress`
226// after publish corrupts the next session's resume". A write sets the state to
227// the status it names; a read counts as in_progress only if the rows that came
228// back say so. The last observation wins.
229function observeRun(l: Ledger, q: string, resultText: string): void {
230  // Only a statement whose TARGET is refresh_runs may move run state; mentioning
231  // the table in a subquery of another table's write, or a look-alike name,
232  // must not. Pass A/B are deliberately NOT read from SQL: a verdict inside a
233  // literal cannot be told from prose, so only the receipt script and an
234  // anchored Jira comment count (see observe()).
235  const code = codeOf(q)
236  const writesRuns = /\b(?:insert\s+into|update)\s+(?:public\.)?refresh_runs\b/i.test(code)
237  // A read counts only when refresh_runs is the statement's FIRST `from` target,
238  // so a subquery inside another table's SELECT cannot move run state.
239  const readsRuns = !writesRuns && /^(?:(?!\bfrom\b)[\s\S])*\bfrom\s+(?:public\.)?refresh_runs\b/i.test(code)
240  if (!writesRuns && !readsRuns) return
241  const OPEN = /^(in_progress|queued|running)$/i
242  const DONE = /^(done|complete|completed|closed|failed|abandoned|skipped)$/i
243  const classify = (v: string): 'in_progress' | 'closed' | undefined =>
244    OPEN.test(v) ? 'in_progress' : DONE.test(v) ? 'closed' : undefined
245  const ids = (code.match(UUID) ?? []).concat(codeOf(q, true).match(UUID) ?? [])
246  const queryIds = [...new Set(ids.map(i => i.toLowerCase()))]
247  if (writesRuns) {
248    // Only an UPDATE's explicit `status = '...'`, read from the query with
249    // comments removed (a commented-out assignment must not count). An INSERT
250    // or any other shape changes nothing; the next read of the row settles it.
251    if (!/\bupdate\b/i.test(code)) return
252    const m = /\bstatus\s*=\s*'(\w+)'/i.exec(codeOf(q, true))
253    const state = m ? classify(m[1]) : undefined
254    if (state) for (const id of queryIds.length ? queryIds : ['(unspecified run)']) l.runs[id] = state
255    return
256  }
257  // Read: pair each id with its own status from the result rows. Rows with no
258  // id of their own apply only when the query named exactly one run.
259  const rows = resultText.match(/\{[^{}]*\}/g) ?? []
260  for (const row of rows) {
261    const st = /"status"\s*:\s*"(\w+)"/i.exec(row)
262    const state = st ? classify(st[1]) : undefined
263    if (!state) continue
264    const rid = UUID_ONCE.exec(row)
265    if (rid) l.runs[rid[0].toLowerCase()] = state
266    else if (queryIds.length === 1) l.runs[queryIds[0]] = state
267  }
268}
269
270// ---------------------------------------------------------------------------
271// Obligations. Each carries the skill line it comes from so the checklist
272// answers "says who?" without opening a skill.
273// ---------------------------------------------------------------------------
274
275function unmet(l: Ledger): Unmet[] {
276  const out: Unmet[] = []
277  const work = l.webflowWrites + l.prsMerged + l.commits + l.prsOpened
278
279  if (work >= NONTRIVIAL && l.ingests === 0) {
280    out.push({
281      id: 'ingest',
282      text: `Knowledge INGEST: ${work} work signals this session, no INSERT into knowledge_memories`,
283      rule: 'knowledge-system SKILL.md:52 (after non-trivial work) + :154',
284    })
285  }
286  for (const k of l.served) {
287    if (!l.commented.includes(k)) {
288      out.push({
289        id: `jira:${k}`,
290        text: `Jira: no comment on ${k}, which this session worked on`,
291        rule: 'session-handoff SKILL.md:189 (tickets moved this session are the record); do -> verify -> update ticket',
292      })
293    }
294  }
295  for (const [id, state] of Object.entries(l.runs)) {
296    if (state === 'in_progress') {
297      out.push({
298        id: `run:${id}`,
299        text: `refresh_runs ${id} last seen in_progress; close it (done) or record why not`,
300        rule: 'listicle-refresh SKILL.md:543 (a run left in_progress corrupts the next resume)',
301      })
302    }
303  }
304  // Standing ruling (the operator, 2026-10-15): Claude closes or archives the sessions it spins up.
305  // Only a fleet-tracker that answered can raise this; "not loaded" is not "no children".
306  if (l.fleetLoaded && (l.fleetOpen ?? []).length > 0) {
307    out.push({
308      id: 'fleet',
309      text: `Fleet: ${l.fleetOpen.length} child session(s) still open: ${l.fleetOpen.join('; ')}. ` +
310        'Close each out: mcp__fleet-tracker__untrack archives a finished one (COMPLETED / REVIEW_READY); one still working ' +
311        'cannot be archived yet, so wait for it, or untrack with keep_reason if it stays open on purpose',
312      rule: 'The operator 2026-10-15 (LC-1835): Claude is responsible for the sessions it spins up and closes or archives them',
313    })
314  }
315  if (l.published) {
316    if (!l.cannibScheduled) {
317      out.push({
318        id: 'cannib',
319        text: 'Refresh reached publish: +2/+4-week cannibalization re-check not scheduled (accepted: send_later / create_trigger / CronCreate with a time, or a Jira next-due/duedate write, whose text names cannibalization or +2/+4 weeks)',
320        rule: 'listicle-refresh SKILL.md:511 (Jira or scheduled task, never chat)',
321      })
322    }
323    if (!l.gscScheduled) {
324      out.push({
325        id: 'gsc',
326        text: 'Refresh reached publish: 30/60/90 GSC checkpoints not scheduled (accepted: send_later / create_trigger / CronCreate with a time, or a Jira next-due/duedate write, whose text names GSC or 30/60/90)',
327        rule: 'listicle-refresh SKILL.md:511',
328      })
329    }
330    if (!l.passA) {
331      out.push({
332        id: 'passA',
333        text: 'Refresh reached publish: no Pass A verdict recorded (receipt script or an anchored Jira verdict comment)',
334        rule: 'listicle-refresh SKILL.md:515 (Pass A recorded in selection_provenance)',
335      })
336    }
337    if (!l.passB) {
338      out.push({
339        id: 'passB',
340        text: 'Refresh reached publish: no Pass B verdict recorded (receipt script or an anchored Jira verdict comment; needed before the run closes)',
341        rule: 'listicle-refresh SKILL.md:547 (no verdict, no close)',
342      })
343    }
344  }
345  return out
346}
347
348function checklist(l: Ledger): string {
349  const list = unmet(l)
350  const seen =
351    `Seen: ${l.served.length} ticket(s), ${l.commented.length} commented, ${Object.keys(l.runs).length} run(s), ` +
352    `${l.webflowWrites} Webflow write(s), ${l.prsMerged} merged PR(s), ${l.ingests} ingest(s)${l.published ? ', published' : ''}, ` +
353    (l.fleetLoaded ? `${(l.fleetOpen ?? []).length} open fleet child(ren)` : 'fleet children NOT CHECKED (fleet-tracker not loaded)')
354  if (list.length === 0) return `Close-out: nothing unmet. ${seen}\n${modActionsLine(l)}`
355  return [
356    `Close-out: ${list.length} unmet`,
357    ...list.map(u => `  [ ] ${u.text}\n      rule: ${u.rule}`),
358    seen,
359    modActionsLine(l),
360    'Nudge only: nothing is blocked. Reads of the live site, Jira and Supabase never count as meeting an obligation, only the writes above do.',
361  ].join('\n')
362}
363
364// ---------------------------------------------------------------------------
365
366// Take a saved ledger field by field, keeping only values of the right type, so
367// a partial or foreign object cannot suppress an obligation.
368function rehydrate(saved: any): Ledger {
369  const out: any = blank()
370  if (!saved || typeof saved !== 'object') return out
371  for (const k of Object.keys(out)) {
372    const v = saved[k]
373    if (Array.isArray(out[k])) out[k] = Array.isArray(v) ? v.filter((x: unknown) => typeof x === 'string') : out[k]
374    else if (typeof out[k] === 'object') {
375      if (v && typeof v === 'object' && !Array.isArray(v)) {
376        out[k] = Object.fromEntries(Object.entries(v).filter(([, st]) => st === 'in_progress' || st === 'closed'))
377      }
378    } else if (typeof v === typeof out[k]) out[k] = v
379  }
380  return out
381}
382
383// Top-level so the engine can follow `$` into it.
384// The ledger is saved WITH the id of the session it belongs to, so a fresh
385// session (new id, e.g. after /clear) never inherits another session's unmet
386// obligations; a restart or hot reload of the same session keeps its own.
387async function mutate($: any, fn: (l: Ledger) => Ledger) {
388  await update($, ledger, fn)
389  await $.store.set(KEY, { ...(await read($, ledger)), sessionId: await sessionIdOf($) })
390}
391
392// '' when the id cannot be read; an empty id never matches on restore.
393async function sessionIdOf($: any): Promise<string> {
394  try {
395    return String((await $.session.id()) ?? '')
396  } catch {
397    return ''
398  }
399}
400
401// Child sessions still open in fleet-tracker (LC-1835). session-close cannot import another plugin, so it
402// asks through the tool surface: fleet-tracker's `open_children` answers `Open fleet children: N` then one
403// `- ` line per session. ANY failure (no such tool because fleet-tracker is not loaded, a deny, an error,
404// a reply without that header) is "not loaded / not checked", recorded as fleetLoaded=false so it can
405// never read as zero children.
406const FLEET_TOOL = 'mcp__fleet-tracker__open_children'
407function parseFleet(out: any): string[] | null {
408  if (!out || out.deny || out.isError) return null
409  const text = typeof out.text === 'string' ? out.text : typeof out.result === 'string' ? out.result : ''
410  // The list, not just the header, must be complete: a header claiming N with any other
411  // number of entries is unreadable, i.e. NOT CHECKED, never "0 open" (CI review, PR #1816).
412  const head = text.match(/^\s*Open fleet children:\s*(\d+)\s*$/m)
413  if (!head) return null
414  const items = [...text.matchAll(/^- (.+)$/gm)].map(m => m[1].trim())
415  return items.length === Number(head[1]) ? items : null
416}
417async function refreshFleet($: any): Promise<void> {
418  let open: string[] | null = null
419  try {
420    open = parseFleet(await $.tool.call({ tool: FLEET_TOOL } as any))
421  } catch {
422    open = null
423  }
424  await mutate($, l => ({ ...l, fleetOpen: open ?? [], fleetLoaded: open !== null }))
425}
426
427// A nudge mod must never be the reason a tool call, a turn or a redraw fails:
428// every hook body runs its own work inside this and then reaches next(e).
429function logFailure($: any, where: string, err: unknown): void {
430  try {
431    $.ui.log(`session-close: ${where} failed (ignored): ${String(err).slice(0, 200)}`, { to: 'debug' })
432  } catch {
433    // Logging is best effort too.
434  }
435}
436
437export const register: Register = on => {
438  on('session.start', async ($, e, next) => {
439    try {
440      // $.state is the session's, $.store outlives it. Restore only a ledger
441      // saved by THIS session (same id); anything else is another session's.
442      const saved = (await $.store.get(KEY)) as (Ledger & { sessionId?: string }) | undefined
443      const sid = await sessionIdOf($)
444      if (saved && sid !== '' && saved.sessionId === sid && (await read($, ledger)).turns === 0) {
445        await update($, ledger, () => rehydrate(saved))
446      }
447      await $.command.register({ name: 'close-out', description: 'List the close-out obligations still unmet' })
448    } catch (err) {
449      logFailure($, 'session.start', err)
450    }
451    return next(e)
452  })
453
454  // Observer only: runs the call, then learns from it. A failed call proves
455  // nothing was written, so it is not counted. Whatever observing does, the
456  // tool's own result goes back untouched.
457  on('tool.call', async ($, e: any, next) => {
458    const out: any = await next(e)
459    // A mod's deny / block / result text carries `[mod:<name>]`: note it even when the call was refused,
460    // because a refusal is exactly the action worth listing.
461    try {
462      const speaks = MOD_TOOLS.test(String(e.tool))
463      const found = modActionsIn([
464        out?.deny,
465        ...(speaks ? [out?.text, out?.result] : []),
466        ...(Array.isArray(out?.context) ? out.context : [out?.context]),
467      ])
468      if (found.length) await mutate($, l => withModActions(l, found))
469    } catch (err) {
470      logFailure($, 'tool.call mod actions', err)
471    }
472    if (out?.deny || out?.isError) return out
473    try {
474      const text = typeof out?.text === 'string' ? out.text : blobOf(out?.result)
475      // The engine spreads a tool's arguments beside `tool`; also accept a nested
476      // `input` object so a differently-shaped event under-reports nothing.
477      const args = e.input && typeof e.input === 'object' ? { ...e, ...e.input } : e
478      // A result whose FIRST line reads as an error is not a confirmed write
479      // (a later line that merely starts with "error" is ordinary output).
480      if (/^\s*(?:error|failed|permission denied)\b/i.test(text)) return out
481      await mutate($, l => observe(l, e.tool, args, text))
482    } catch (err) {
483      logFailure($, 'tool.call', err)
484    }
485    return out
486  })
487
488  // Every mod action fires a toast carrying the same `[mod:<name>]` text, including the ones that
489  // never travel in a tool result (a pin, a /draft-done verdict). Observer only: the toast goes on.
490  on('ui.toast', async ($, e: any, next) => {
491    try {
492      const found = modActionsIn([e.text])
493      if (found.length) await mutate($, l => withModActions(l, found))
494    } catch (err) {
495      logFailure($, 'ui.toast', err)
496    }
497    return next(e)
498  })
499
500  on('command.run', { command: 'close-out' }, async $ => {
501    try {
502      await refreshFleet($).catch(err => logFailure($, 'fleet read', err))
503      return { text: checklist(await read($, ledger)) }
504    } catch (err) {
505      logFailure($, 'command.run', err)
506      return { text: `Close-out: could not read the ledger (${String(err).slice(0, 120)}).` }
507    }
508  })
509
510  // One-line nudge, only when something is unmet, only after the session
511  // touched something, and only on a wrap-up-sounding answer or after a long
512  // stretch of quiet turns. Never a block.
513  on('turn.complete', async ($, e: any, next) => {
514    if (e.agentId) return next(e)
515    try {
516      await mutate($, l => ({ ...l, turns: l.turns + 1 }))
517      await refreshFleet($).catch(err => logFailure($, 'fleet read', err))
518      const l = await read($, ledger)
519      const list = unmet(l)
520      const wrap = /\b(done|merged|shipped|wrapp?(?:ing|ed)(?: it)? up|all set|closing|handoff|READY:|NEEDS STAN:)/i.test(String(e.answer ?? ''))
521      if (list.length === 0) {
522        $.ui.status(undefined)
523        // Nothing unmet, but a wrap-up turn still lists what the mods did this session.
524        if (wrap && (l.modActions ?? []).length > 0 && (l.lastModListTurn ?? 0) !== l.turns) {
525          $.ui.toast(`[mod:${OWN_MOD}] ${modActionsLine(l)}`)
526          await mutate($, x => ({ ...x, lastModListTurn: x.turns }))
527        }
528      } else if (wrap || l.turns - l.lastNudgeTurn >= NUDGE_EVERY_TURNS) {
529        $.ui.status(`close-out: ${list.length} unmet (${list.map(u => u.id.split(':')[0]).join(', ')}) /close-out`)
530        if (wrap) $.ui.toast(`[mod:${OWN_MOD}] ${list.length} close-out item(s) unmet: /close-out. ${modActionsLine(l)}`)
531        await mutate($, x => ({ ...x, lastNudgeTurn: x.turns }))
532      }
533    } catch (err) {
534      logFailure($, 'turn.complete', err)
535    }
536    return next(e)
537  })
538
539  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
540    try {
541      const l = await read($, ledger)
542      const list = unmet(l)
543      if (e.props.hasSurvey || list.length === 0 || l.lastNudgeTurn === 0) return next(e)
544      const { Box, Text } = $.ui.resolve(e)
545      return (
546        <Box>
547          <Text color="warning" wrap="truncate-end">
548            Close-out: {list.length} unmet, /close-out to see them
549          </Text>
550        </Box>
551      )
552    } catch (err) {
553      logFailure($, 'ui.render', err)
554      return next(e)
555    }
556  })
557}
558
hooks/modlog.ts 61 lines
1import type { Ledger } from '../types'
2
3// Pure helpers for the mod-actions list (LC-1711), kept apart from register.tsx so tests can call
4// them directly: a test module cannot fire a toast, so the toast/deny dedupe is proven here.
5
6// Anchored: a real mod message STARTS with its tag. A `[mod:x]` in the middle of a string is quoted
7// content (a diff, a README line, a file being read), not an action, and is never listed.
8const MOD_TAG = /^\s*\[mod:([a-z][a-z0-9-]*)\]\s*([^\n"\\]{0,160})/
9const MOD_ACTIONS_MAX = 30
10export const OWN_MOD = 'session-close'
11// Tools that are themselves a mod's surface: only their result / text is the mod speaking. Every
12// other tool (Read, Bash, any MCP file or search tool) carries other people's text, so only the
13// `deny` and `context` a mod attaches to its call are trusted there. Allow-list, not deny-list:
14// a new content tool must not become a place where quoted `[mod:` text counts.
15export const MOD_TOOLS = /^mcp__fleet-tracker__/
16
17// "name: reason" lines for each string that begins with a mod tag, never this mod's own nudges.
18export function modActionsIn(texts: readonly unknown[]): string[] {
19  const out: string[] = []
20  for (const t of texts) {
21    if (typeof t !== 'string') continue
22    const m = MOD_TAG.exec(t)
23    if (!m || m[1] === OWN_MOD) continue
24    const line = `${m[1]}: ${m[2].trim().replace(/\s+/g, ' ')}`
25    if (!out.includes(line)) out.push(line)
26  }
27  return out
28}
29
30// The same action seen twice: a toast cuts its text at 160 characters ("...") while the deny line
31// carries the full reason, so the two differ only in a truncated tail. Same mod, and equal after
32// normalising or one is the "..."-cut stem of the other.
33const split = (a: string): [string, string] => {
34  const i = a.indexOf(': ')
35  return i < 0 ? [a, ''] : [a.slice(0, i), a.slice(i + 2)]
36}
37const isCut = (r: string): boolean => /(\.{3}|\u2026)$/.test(r)
38const stem = (r: string): string => r.replace(/(\.{3}|\u2026)$/, '').replace(/\s+/g, ' ').trim().toLowerCase()
39export function sameAction(a: string, b: string): boolean {
40  const [ma, ra] = split(a)
41  const [mb, rb] = split(b)
42  if (ma !== mb) return false
43  const sa = stem(ra)
44  const sb = stem(rb)
45  return sa === sb || (isCut(ra) && sb.startsWith(sa)) || (isCut(rb) && sa.startsWith(sb))
46}
47
48// `?? []`: a ledger the session built before this field existed (hot reload keeps $.state) has no modActions.
49export function withModActions(l: Ledger, found: string[]): Ledger {
50  const next = [...(l.modActions ?? [])]
51  let changed = false
52  for (const f of found) {
53    const i = next.findIndex(h => sameAction(h, f))
54    if (i < 0) { next.push(f); changed = true; continue }
55    // Keep the fuller wording: a cut toast is replaced by the full deny line, never the reverse.
56    if (isCut(split(next[i])[1]) && !isCut(split(f)[1])) { next[i] = f; changed = true }
57  }
58  return changed ? { ...l, modActions: next.slice(-MOD_ACTIONS_MAX) } : l
59}
60
61
types/index.d.ts 46 lines
1// What the session has touched, learned only from tool calls actually observed.
2export type Ledger = {
3  turns: number
4  // Turn number of the last nudge, so a nudge repeats only after a quiet stretch.
5  lastNudgeTurn: number
6  // Jira keys the session worked on (transitioned, edited, or named by its branch / commit / PR).
7  served: string[]
8  // Jira keys that got an addCommentToJiraIssue.
9  commented: string[]
10  // Non-trivial-work signals: Webflow writes, merged PRs, commits, PRs opened.
11  webflowWrites: number
12  prsMerged: number
13  commits: number
14  prsOpened: number
15  // INSERTs into knowledge_memories that succeeded.
16  ingests: number
17  // refresh_runs ids the session read or wrote -> last observed state.
18  runs: Record<string, 'in_progress' | 'closed'>
19  // A Webflow publish call went through (the operator's gate; the mod never makes one).
20  published: boolean
21  // Scheduling writes (send_later / create_trigger / CronCreate / Jira "next due"),
22  // split by what the scheduled text covers.
23  cannibScheduled: boolean
24  gscScheduled: boolean
25  // Pass A / Pass B verdicts: an `article_audit.py --write-receipt` run, or an
26  // anchored "Pass A: PASS|BLOCK" Jira comment on a ticket the session worked.
27  // SQL text is never read for verdicts.
28  passA: boolean
29  passB: boolean
30  // `[mod:<name>]` messages seen on tool results and toasts, as "name: reason" (newest last, capped).
31  modActions: string[]
32  // Turn number of the last wrap-up that listed the mod actions with nothing else unmet.
33  lastModListTurn: number
34  // Child cloud sessions fleet-tracker still has open, one "ticket (label) bucket id" per line, as of the
35  // last read (LC-1835). Only trusted while fleetLoaded; an unreadable fleet-tracker leaves this empty
36  // and fleetLoaded false, which is "not checked", never "zero children".
37  fleetOpen: string[]
38  fleetLoaded: boolean
39}
40
41declare module 'claude-code' {
42  interface PluginState {
43    'session-close': { ledger: Ledger }
44  }
45}
46