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.

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