@@ -17,6 +17,7 @@ import process from 'node:process'
1717import {
1818 readLastAssistantText ,
1919 readStdin ,
20+ readUserText ,
2021 stripCodeFences ,
2122 stripQuotedSpans ,
2223} from './transcript.mts'
@@ -90,6 +91,121 @@ export interface ReminderConfig {
9091 readonly stripQuotedSpans ?: boolean | undefined
9192}
9293
94+ /**
95+ * A reminder rule-group for the multiplexed `runStopReminders`. Same shape as
96+ * ReminderConfig minus the process-lifecycle bits (name + per-group env var +
97+ * patterns + hint); `blocking` is intentionally absent — a multiplexed group is
98+ * informational only (mixing block + non-block decisions across groups in one
99+ * process can't emit a single coherent Stop decision).
100+ */
101+ export interface ReminderGroup {
102+ readonly name : string
103+ readonly disabledEnvVar : string
104+ readonly patterns : readonly RuleViolation [ ]
105+ readonly closingHint ?: string | undefined
106+ readonly stripQuotedSpans ?: boolean | undefined
107+ }
108+
109+ /**
110+ * Scan `text` against a pattern list (+ optional extraCheck), returning hits.
111+ * The pure core shared by `runStopReminder` and `runStopReminders`.
112+ */
113+ export async function scanReminderText (
114+ text : string ,
115+ patterns : readonly RuleViolation [ ] ,
116+ extraCheck ?: ReminderConfig [ 'extraCheck' ] ,
117+ ) : Promise < ReminderHit [ ] > {
118+ const hits : ReminderHit [ ] = [ ]
119+ for ( let i = 0 , { length } = patterns ; i < length ; i += 1 ) {
120+ const pattern = patterns [ i ] !
121+ const match = pattern . regex . exec ( text )
122+ if ( ! match ) {
123+ continue
124+ }
125+ hits . push ( {
126+ label : pattern . label ,
127+ why : pattern . why ,
128+ snippet : extractSnippet ( text , match . index , match [ 0 ] . length ) ,
129+ } )
130+ }
131+ if ( extraCheck ) {
132+ try {
133+ const extra = await extraCheck ( text )
134+ for ( let i = 0 , { length } = extra ; i < length ; i += 1 ) {
135+ hits . push ( extra [ i ] ! )
136+ }
137+ } catch {
138+ // Fail-open: a buggy extra-check must not suppress the regex hits.
139+ }
140+ }
141+ return hits
142+ }
143+
144+ /**
145+ * Format the stderr block for one group's hits.
146+ */
147+ export function formatReminderBlock (
148+ name : string ,
149+ hits : readonly ReminderHit [ ] ,
150+ closingHint ?: string | undefined ,
151+ ) : string {
152+ const lines = [
153+ `[${ name } ] Assistant turn matched reminder patterns:` ,
154+ '' ,
155+ ...hits . flatMap ( h => [ ` • "${ h . label } " — ${ h . snippet } ` , ` ${ h . why } ` ] ) ,
156+ ]
157+ if ( closingHint ) {
158+ lines . push ( '' , ` ${ closingHint } ` )
159+ }
160+ lines . push ( '' )
161+ return lines . join ( '\n' )
162+ }
163+
164+ /**
165+ * Run several informational reminder groups in ONE Stop-hook process. Reads
166+ * stdin + the most-recent assistant turn once, then scans each group whose
167+ * `disabledEnvVar` is unset — preserving per-group disabling exactly as if each
168+ * were its own hook. Emits one stderr block per group with hits. Always exits 0.
169+ * Use when merging pure-table reminders to cut process count without losing the
170+ * granular disable env vars.
171+ */
172+ export async function runStopReminders (
173+ groups : readonly ReminderGroup [ ] ,
174+ ) : Promise < void > {
175+ const payloadRaw = await readStdin ( )
176+ let payload : StopPayload
177+ try {
178+ payload = JSON . parse ( payloadRaw ) as StopPayload
179+ } catch {
180+ process . exit ( 0 )
181+ }
182+ const rawText = readLastAssistantText ( payload . transcript_path )
183+ if ( ! rawText ) {
184+ process . exit ( 0 )
185+ }
186+ const fencesStripped = stripCodeFences ( rawText )
187+ const blocks : string [ ] = [ ]
188+ for ( let i = 0 , { length } = groups ; i < length ; i += 1 ) {
189+ const group = groups [ i ] !
190+ if ( process . env [ group . disabledEnvVar ] ) {
191+ continue
192+ }
193+ const text = group . stripQuotedSpans
194+ ? stripQuotedSpans ( fencesStripped )
195+ : fencesStripped
196+ // eslint-disable-next-line no-await-in-loop
197+ const hits = await scanReminderText ( text , group . patterns )
198+ if ( hits . length > 0 ) {
199+ blocks . push ( formatReminderBlock ( group . name , hits , group . closingHint ) )
200+ }
201+ }
202+ if ( blocks . length === 0 ) {
203+ process . exit ( 0 )
204+ }
205+ process . stderr . write ( blocks . join ( '\n' ) + '\n' )
206+ process . exit ( 0 )
207+ }
208+
93209/**
94210 * Run a Stop-hook reminder. Reads stdin, scans the most-recent assistant turn,
95211 * and writes hits to stderr. Always exits 0.
@@ -115,51 +231,13 @@ export async function runStopReminder(config: ReminderConfig): Promise<void> {
115231 ? stripQuotedSpans ( fencesStripped )
116232 : fencesStripped
117233
118- const hits : ReminderHit [ ] = [ ]
119- const { patterns } = config
120- const { length : patternsLength } = patterns
121- for ( let i = 0 ; i < patternsLength ; i += 1 ) {
122- const pattern = patterns [ i ] !
123- const match = pattern . regex . exec ( text )
124- if ( ! match ) {
125- continue
126- }
127- hits . push ( {
128- label : pattern . label ,
129- why : pattern . why ,
130- snippet : extractSnippet ( text , match . index , match [ 0 ] . length ) ,
131- } )
132- }
133-
134- if ( config . extraCheck ) {
135- try {
136- const extra = await config . extraCheck ( text )
137- for (
138- let i = 0 , { length : extraLength } = extra ;
139- i < extraLength ;
140- i += 1
141- ) {
142- hits . push ( extra [ i ] ! )
143- }
144- } catch {
145- // Fail-open: a buggy extra-check must not suppress the regex hits.
146- }
147- }
234+ const hits = await scanReminderText ( text , config . patterns , config . extraCheck )
148235
149236 if ( hits . length === 0 ) {
150237 process . exit ( 0 )
151238 }
152239
153- const lines = [
154- `[${ config . name } ] Assistant turn matched reminder patterns:` ,
155- '' ,
156- ...hits . flatMap ( h => [ ` • "${ h . label } " — ${ h . snippet } ` , ` ${ h . why } ` ] ) ,
157- ]
158- if ( config . closingHint ) {
159- lines . push ( '' , ` ${ config . closingHint } ` )
160- }
161- lines . push ( '' )
162- const message = lines . join ( '\n' )
240+ const message = formatReminderBlock ( config . name , hits , config . closingHint )
163241
164242 // Blocking mode: emit a Stop-hook block decision so Claude must
165243 // continue the turn and address the matched phrase. Suppressed
@@ -176,3 +254,92 @@ export async function runStopReminder(config: ReminderConfig): Promise<void> {
176254 process . stderr . write ( message + '\n' )
177255 process . exit ( 0 )
178256}
257+
258+ /**
259+ * Config for a turn-pair reminder: fires only when the last USER turn matches a
260+ * trigger AND the most-recent ASSISTANT turn matches a deflection. The shape
261+ * shared by answer-passing-questions / answer-status-requests /
262+ * follow-direct-imperative — "user asked X, assistant did Y instead".
263+ */
264+ /**
265+ * A turn-pair matcher. `label` + `why` describe it; matching is by `regex`
266+ * OR — when the rule needs logic a regex can't express (word-count bounds,
267+ * first-word-only, question filtering, like follow-direct-imperative's
268+ * `looksLikeImperative`) — by an explicit `match` predicate. Exactly one of
269+ * `regex` / `match` is consulted (`match` wins when present).
270+ */
271+ export interface TurnPairRule {
272+ readonly label : string
273+ readonly why : string
274+ readonly regex ?: RegExp | undefined
275+ readonly match ?: ( ( text : string ) => boolean ) | undefined
276+ }
277+
278+ export interface TurnPairConfig {
279+ readonly name : string
280+ readonly disabledEnvVar : string
281+ readonly userTriggers : readonly TurnPairRule [ ]
282+ readonly assistantDeflections : readonly TurnPairRule [ ]
283+ readonly closingHint ?: string | undefined
284+ }
285+
286+ function turnPairMatches ( rule : TurnPairRule , text : string ) : boolean {
287+ if ( rule . match ) {
288+ return rule . match ( text )
289+ }
290+ return rule . regex ? rule . regex . test ( text ) : false
291+ }
292+
293+ /**
294+ * Run a turn-pair Stop reminder. Reads the last user turn + the most-recent
295+ * assistant turn (via transcript.mts — no per-hook re-implementation of
296+ * JSONL parsing / role detection / content flattening), and emits a reminder
297+ * only when BOTH a user trigger and an assistant deflection match. Always
298+ * exits 0. The fired message names the matched trigger + deflection so the
299+ * reader sees what pair tripped it.
300+ */
301+ export async function runTurnPairReminder (
302+ config : TurnPairConfig ,
303+ ) : Promise < void > {
304+ const payloadRaw = await readStdin ( )
305+ if ( process . env [ config . disabledEnvVar ] ) {
306+ process . exit ( 0 )
307+ }
308+ let payload : StopPayload
309+ try {
310+ payload = JSON . parse ( payloadRaw ) as StopPayload
311+ } catch {
312+ process . exit ( 0 )
313+ }
314+ const userText = stripCodeFences ( readUserText ( payload . transcript_path , 1 ) )
315+ const assistantText = stripCodeFences (
316+ readLastAssistantText ( payload . transcript_path ) ,
317+ )
318+ if ( ! userText || ! assistantText ) {
319+ process . exit ( 0 )
320+ }
321+ const trigger = config . userTriggers . find ( p => turnPairMatches ( p , userText ) )
322+ if ( ! trigger ) {
323+ process . exit ( 0 )
324+ }
325+ const deflection = config . assistantDeflections . find ( p =>
326+ turnPairMatches ( p , assistantText ) ,
327+ )
328+ if ( ! deflection ) {
329+ process . exit ( 0 )
330+ }
331+ const userPreview = userText . trim ( ) . slice ( 0 , 60 ) . replace ( / \s + / g, ' ' )
332+ const lines = [
333+ `[${ config . name } ] User asked, assistant deflected:` ,
334+ '' ,
335+ ` User trigger: "${ trigger . label } " — "${ userPreview } "` ,
336+ ` Assistant deflection: "${ deflection . label } "` ,
337+ ` ${ deflection . why } ` ,
338+ ]
339+ if ( config . closingHint ) {
340+ lines . push ( '' , ` ${ config . closingHint } ` )
341+ }
342+ lines . push ( '' )
343+ process . stderr . write ( lines . join ( '\n' ) + '\n' )
344+ process . exit ( 0 )
345+ }
0 commit comments