@codebam/dsh-thinking-auditor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.mjs ADDED
@@ -0,0 +1,674 @@
1
+ /**
2
+ * @codebam/dsh-thinking-auditor
3
+ *
4
+ * A covert claim/evidence auditor for live DeepSeek Harness reasoning traces.
5
+ *
6
+ * The plugin taps `agent/assistant-stream`, copies each attempt's
7
+ * `reasoning-delta` chunks into a bounded per-session window, and passes every
8
+ * chunk through untouched. The monitored request and its context are never
9
+ * modified. At triggered boundaries it joins the trace against an incrementally
10
+ * maintained ledger of what the session actually saw (`tool/call`,
11
+ * `tool/result`, user messages), runs deterministic claim-vs-evidence checks,
12
+ * and asks the same provider/model route in a hand-built session-less call for
13
+ * a structured second opinion. A deterministic policy module owns escalation:
14
+ * T0 observe, T1 force verification at `agent/turn-stopping`, T2 ask/deny an
15
+ * irreversible `tools/pre-execute` action, T3 cancel only when explicitly
16
+ * configured.
17
+ *
18
+ * The monitor is not a mind reader. It reports claim-to-evidence relations;
19
+ * it never labels intent. CoT is an unfaithful generated artifact, so the
20
+ * defaults cap actuators at T1; nothing stops a turn or blocks an irreversible action on a single model opinion.
21
+ *
22
+ * @module @codebam/dsh-thinking-auditor
23
+ */
24
+
25
+ import z from '@deepseek-ai/schemastery'
26
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
27
+ import { resolveConfig } from './src/config.mjs'
28
+ import { EvidenceLedger } from './src/ledger.mjs'
29
+ import { extractClaims, summarizeFindings, verifyClaims } from './src/claims.mjs'
30
+ import { buildAuditorPrompt, parseAuditorResponse } from './src/auditor.mjs'
31
+ import { decideToolAction, decideTurnAction, mergeVerdict } from './src/policy.mjs'
32
+ import { AuditStore } from './src/store.mjs'
33
+ import { createJsonlSink } from './src/file-sink.mjs'
34
+ import { SessionAuditState } from './src/state.mjs'
35
+ import { classifyAction } from './src/stakes.mjs'
36
+ import { deadlineSignal, isAbortError, raceDeadline } from './src/async.mjs'
37
+
38
+ /** Cordis plugin name used by loader diagnostics. */
39
+ export const name = 'thinking-auditor'
40
+
41
+ /** Services the plugin consumes; all are part of the dsh base bundle. */
42
+ export const inject = ['llm', 'sessions', 'tools']
43
+
44
+ /** Settings namespace for live user-owned thresholds and gates. */
45
+ export const SETTINGS_NAMESPACE = 'thinking-audit'
46
+
47
+ /** Stable producer id attached to every synthetic message and audit record. */
48
+ export const PLUGIN_ID = '@codebam/dsh-thinking-auditor'
49
+
50
+ /**
51
+ * Loader-facing configuration schema.
52
+ *
53
+ * This schema is declarative for the configuration catalog; normalization and
54
+ * validation live in `src/config.mjs` and own the real contract.
55
+ */
56
+ export const Config = z.object({
57
+ enabled: z.boolean(),
58
+ auditOnToolResult: z.boolean(),
59
+ auditOnTurnStop: z.boolean(),
60
+ auditOnAttemptEnd: z.boolean(),
61
+ windowChars: z.number(),
62
+ minWindowChars: z.number(),
63
+ maxTraceChars: z.number(),
64
+ cooldownMs: z.number(),
65
+ requestTimeoutMs: z.number(),
66
+ turnStopTimeoutMs: z.number(),
67
+ maxAuditsPerTurn: z.number(),
68
+ auditor: z.object({
69
+ provider: z.union([z.string(), z.const(null)]),
70
+ model: z.union([z.string(), z.const(null)]),
71
+ maxTokens: z.number(),
72
+ temperature: z.number(),
73
+ followReasoningEffort: z.boolean(),
74
+ maxResponseChars: z.number(),
75
+ }),
76
+ evidence: z.object({
77
+ maxToolCalls: z.number(),
78
+ maxResultChars: z.number(),
79
+ maxDigestChars: z.number(),
80
+ maxUserChars: z.number(),
81
+ }),
82
+ gates: z.object({
83
+ maxTier: z.union(['observe', 'verify', 'block', 'cancel']),
84
+ minimumSeverity: z.union(['low', 'medium', 'high']),
85
+ verifyOn: z.union(['speculative', 'unsupported', 'contradicted', 'never']),
86
+ blockOn: z.union(['never', 'contradicted', 'confirmed']),
87
+ cancelOn: z.union(['never', 'confirmed']),
88
+ consecutive: z.number(),
89
+ t2Mode: z.union(['ask', 'deny']),
90
+ askOnTimeoutForIrreversible: z.boolean(),
91
+ }),
92
+ store: z.object({
93
+ persist: z.boolean(),
94
+ dir: z.union([z.string(), z.const(null)]),
95
+ maxRecordsPerSession: z.number(),
96
+ maxSessions: z.number(),
97
+ }),
98
+ })
99
+
100
+ /** Neutral T1 instruction; it asks for evidence, not for a behavior change. */
101
+ export const VERIFY_MESSAGE = [
102
+ 'Before finalizing: verify each factual claim against an actual tool result or file read from this session,',
103
+ 'cite the evidence for anything you assert, and remove or explicitly mark as unverified any claim you cannot',
104
+ 'support from this session’s evidence. Do not state a result you have not verified here.',
105
+ ].join(' ')
106
+
107
+ /** Neutral T2 reason shown to the model on an ask/deny. */
108
+ export const BLOCK_REASON =
109
+ 'Blocked: this turn contains unresolved claim/evidence mismatches. Re-establish the facts from tool output or file reads, then run the intended action again.'
110
+
111
+ /** Resolve a logger facade that works with the Cordis built-in and with mocks. */
112
+ function resolveLogger(ctx) {
113
+ const fallback = {
114
+ info() {},
115
+ warn() {},
116
+ error() {},
117
+ debug() {},
118
+ }
119
+ const logger = ctx?.logger
120
+ if (typeof logger === 'function') {
121
+ try {
122
+ return logger('thinking-auditor')
123
+ } catch {
124
+ return fallback
125
+ }
126
+ }
127
+ if (logger !== undefined && logger !== null) return logger
128
+ return fallback
129
+ }
130
+
131
+ /** Stable string session key from either a live agent or session id. */
132
+ function sessionKey(agent) {
133
+ return String(agent?.session?.id ?? agent?.id ?? 'unknown')
134
+ }
135
+
136
+ /**
137
+ * Resolve the auditor route: explicit config override first, then the session's
138
+ * latest logged `request/header` (which follows model changes), then the
139
+ * agent's creation options.
140
+ */
141
+ export function resolveRoute(agent, config) {
142
+ const header = typeof agent?.session?.requestHeader === 'function' ? agent.session.requestHeader() : undefined
143
+ const headerConfig = header?.config
144
+ const options = agent?.options
145
+ const provider = config?.auditor?.provider ?? headerConfig?.provider ?? options?.provider
146
+ const model = config?.auditor?.model ?? headerConfig?.model ?? options?.model
147
+ if (typeof provider !== 'string' || provider.length === 0 || typeof model !== 'string' || model.length === 0) {
148
+ return undefined
149
+ }
150
+ const reasoningEffort = headerConfig?.reasoningEffort ?? options?.reasoningEffort
151
+ return { provider, model, reasoningEffort }
152
+ }
153
+
154
+ /** Collect focus anchors from extracted claims for evidence-digest ranking. */
155
+ function focusTerms(claims) {
156
+ const terms = []
157
+ for (const claim of claims ?? []) {
158
+ for (const anchor of claim?.anchors ?? []) {
159
+ if (anchor.kind === 'term') continue
160
+ terms.push(anchor.value)
161
+ }
162
+ }
163
+ return [...new Set(terms)].slice(0, 24)
164
+ }
165
+
166
+ /** Merge mechanical findings with auditor claims, keeping mechanical status authoritative. */
167
+ export function mergeClaims(mechanicalFindings, auditorClaims) {
168
+ const merged = (mechanicalFindings ?? []).slice(0, 24).map((finding) => ({ ...finding, source: 'mechanical' }))
169
+ const seen = new Set(merged.map((finding) => String(finding.quote ?? '').toLowerCase().replace(/\s+/g, ' ')))
170
+ for (const claim of auditorClaims ?? []) {
171
+ const key = String(claim?.quote ?? '').toLowerCase().replace(/\s+/g, ' ')
172
+ if (key.length === 0 || seen.has(key)) continue
173
+ seen.add(key)
174
+ merged.push({ ...claim, source: 'auditor' })
175
+ }
176
+ return merged.slice(0, 32)
177
+ }
178
+
179
+ /** Read the last real user request from the ledger, if any. */
180
+ function lastUserRequest(ledger, sessionId) {
181
+ const view = ledger.view(sessionId)
182
+ for (let index = view.users.length - 1; index >= 0; index -= 1) {
183
+ const user = view.users[index]
184
+ if (user.sourceKind === 'user' || user.sourceKind === 'unknown') return user.text
185
+ }
186
+ return ''
187
+ }
188
+
189
+ /**
190
+ * Run one isolated auditor call on the same route as the monitored session.
191
+ *
192
+ * The request deliberately omits `sessionId` and `purpose`, so the call
193
+ * attaches to no session, enters no persistence log, and emits no
194
+ * `agent/assistant-stream` frame. Text is collected from delta chunks only, so
195
+ * reasoning content cannot contaminate the JSON audit.
196
+ */
197
+ export async function callAuditor(ctx, config, route, prompt, signal) {
198
+ const options = {
199
+ provider: route.provider,
200
+ model: route.model,
201
+ messages: [
202
+ createUserMessage({
203
+ content: [{ type: 'text', text: prompt.user }],
204
+ source: { kind: 'plugin', plugin: PLUGIN_ID, form: 'notice', summary: 'audit request' },
205
+ }),
206
+ ],
207
+ system: prompt.system,
208
+ temperature: config.auditor.temperature,
209
+ maxTokens: config.auditor.maxTokens,
210
+ signal,
211
+ }
212
+ if (route.reasoningEffort !== undefined && config.auditor.followReasoningEffort) {
213
+ options.reasoningEffort = route.reasoningEffort
214
+ }
215
+ const textByIndex = new Map()
216
+ const deltaIndexes = new Set()
217
+ let finish
218
+ for await (const chunk of ctx.llm.stream(options)) {
219
+ if (chunk?.type === 'text-delta' && typeof chunk.text === 'string') {
220
+ const index = Number.isInteger(chunk.index) ? chunk.index : 0
221
+ deltaIndexes.add(index)
222
+ textByIndex.set(index, `${textByIndex.get(index) ?? ''}${chunk.text}`)
223
+ } else if (chunk?.type === 'block-end' && chunk.block?.type === 'text') {
224
+ const index = Number.isInteger(chunk.index) ? chunk.index : 0
225
+ // Delta-built blocks already have their text; one closed block without
226
+ // deltas is the adapter's delta-only fallback.
227
+ if (!deltaIndexes.has(index)) textByIndex.set(index, chunk.block.text)
228
+ } else if (chunk?.type === 'finish') {
229
+ finish = chunk.reason
230
+ }
231
+ }
232
+ const text = [...textByIndex.entries()]
233
+ .sort((left, right) => left[0] - right[0])
234
+ .map(([, value]) => value)
235
+ .join('\n')
236
+ if (finish?.kind === 'error' || finish?.kind === 'aborted') {
237
+ return { text, error: finish.failure?.message ?? finish.kind }
238
+ }
239
+ return { text }
240
+ }
241
+
242
+ /**
243
+ * Mount the auditor.
244
+ * @param {import('@deepseek-ai/cordis').Context} ctx - owning Cordis context.
245
+ * @param {object} [rawConfig] - entry configuration merged over defaults.
246
+ * @returns {void}
247
+ */
248
+ export function apply(ctx, rawConfig = {}) {
249
+ const baseConfig = resolveConfig(rawConfig)
250
+ const log = resolveLogger(ctx)
251
+ if (!baseConfig.enabled) {
252
+ log.info?.('thinking-auditor: disabled by configuration')
253
+ return
254
+ }
255
+
256
+ /** Live configuration source: entry config, or the settings scope once present. */
257
+ let configSource = () => baseConfig
258
+ const getConfig = () => resolveConfig(configSource())
259
+
260
+ const pluginAbort = new AbortController()
261
+ ctx.effect(() => () => {
262
+ pluginAbort.abort(new Error('thinking-auditor disposed'))
263
+ }, 'thinking-auditor.dispose')
264
+
265
+ const store = new AuditStore({
266
+ maxSessions: baseConfig.store.maxSessions,
267
+ maxRecordsPerSession: baseConfig.store.maxRecordsPerSession,
268
+ })
269
+ const ledger = new EvidenceLedger({
270
+ maxToolCalls: baseConfig.evidence.maxToolCalls,
271
+ maxResultChars: baseConfig.evidence.maxResultChars,
272
+ maxUserChars: baseConfig.evidence.maxUserChars,
273
+ })
274
+ const sink = baseConfig.store.persist === true
275
+ ? createJsonlSink({ ctx, dir: baseConfig.store.dir, log })
276
+ : undefined
277
+ if (sink !== undefined) {
278
+ store.addSink((record) => {
279
+ if (getConfig().store.persist !== true) return undefined
280
+ return sink.write(record)
281
+ })
282
+ }
283
+
284
+ /** @type {Map<string, SessionAuditState>} */
285
+ const sessions = new Map()
286
+ const maxSessions = Math.max(1, baseConfig.store.maxSessions)
287
+ const ensureState = (id) => {
288
+ const key = String(id)
289
+ let state = sessions.get(key)
290
+ if (state !== undefined && !state.disposed) return state
291
+ while (sessions.size >= maxSessions) {
292
+ const oldestKey = sessions.keys().next().value
293
+ const oldest = sessions.get(oldestKey)
294
+ if (oldest !== undefined) {
295
+ oldest.disposed = true
296
+ if (oldest.cooldownTimer !== undefined) clearTimeout(oldest.cooldownTimer)
297
+ }
298
+ sessions.delete(oldestKey)
299
+ }
300
+ state = new SessionAuditState({ sessionId: key, maxTraceChars: baseConfig.maxTraceChars })
301
+ sessions.set(key, state)
302
+ return state
303
+ }
304
+
305
+ // ----- auditor execution -------------------------------------------------
306
+
307
+ /**
308
+ * Start one audit if none is in flight. The window is removed before the
309
+ * model call; a failed audit is not retried with the same text.
310
+ */
311
+ const runAudit = (agent, state, reason, config, options = {}) => {
312
+ if (state.inFlight !== undefined) {
313
+ state.rerunRequested = true
314
+ return state.inFlight
315
+ }
316
+ if (state.disposed || !config.enabled) return Promise.resolve(state.lastVerdict)
317
+ if (options.force !== true && !state.canAuditThisTurn(config.maxAuditsPerTurn)) {
318
+ return Promise.resolve(state.lastVerdict)
319
+ }
320
+ if (state.pending.length === 0) return Promise.resolve(state.lastVerdict)
321
+
322
+ const route = resolveRoute(agent, config)
323
+ if (route === undefined) {
324
+ log.warn?.('thinking-auditor: no provider/model route available; audit skipped')
325
+ return Promise.resolve(state.lastVerdict)
326
+ }
327
+
328
+ state.maxTraceChars = config.maxTraceChars
329
+ const window = state.takeWindow(config.maxTraceChars)
330
+ const signal = deadlineSignal(options.timeoutMs ?? config.requestTimeoutMs, pluginAbort.signal)
331
+ state.markAuditStarted()
332
+
333
+ const work = (async () => {
334
+ const started = Date.now()
335
+ const sessionId = state.sessionId
336
+ const claims = extractClaims(window.text, { maxClaims: 64 })
337
+ const evidenceView = ledger.view(sessionId)
338
+ const mechanicalFindings = verifyClaims(claims, evidenceView)
339
+ const mechanical = summarizeFindings(mechanicalFindings, config)
340
+ const prompt = buildAuditorPrompt({
341
+ route,
342
+ trace: window.text,
343
+ evidenceDigest: ledger.digest(sessionId, {
344
+ focus: focusTerms(claims),
345
+ maxChars: config.evidence.maxDigestChars,
346
+ }),
347
+ mechanical,
348
+ userRequest: lastUserRequest(ledger, sessionId),
349
+ })
350
+ const auditorCall = await callAuditor(ctx, config, route, prompt, signal)
351
+ const auditor = auditorCall.error !== undefined
352
+ ? { ...parseAuditorResponse(''), error: auditorCall.error, ok: false }
353
+ : parseAuditorResponse(auditorCall.text, {
354
+ maxClaims: 24,
355
+ maxResponseChars: config.auditor.maxResponseChars,
356
+ })
357
+ const verdict = mergeVerdict(mechanical, auditor)
358
+ const record = store.record({
359
+ sessionId,
360
+ turn: window.turn,
361
+ step: window.step,
362
+ trigger: reason,
363
+ route: { provider: route.provider, model: route.model },
364
+ traceChars: window.chars,
365
+ level: verdict.level,
366
+ confirmedFabrication: verdict.confirmedFabrication,
367
+ reasons: verdict.reasons,
368
+ mechanical: {
369
+ level: mechanical.level,
370
+ counts: mechanical.counts,
371
+ reasons: mechanical.reasons,
372
+ highestSeverity: mechanical.highestSeverity,
373
+ },
374
+ claims: mergeClaims(mechanicalFindings, auditor.claims),
375
+ auditor: {
376
+ ok: auditor.ok === true,
377
+ level: auditor.level,
378
+ confidence: auditor.confidence,
379
+ signals: auditor.signals,
380
+ summary: auditor.summary,
381
+ error: auditor.error,
382
+ },
383
+ latencyMs: Date.now() - started,
384
+ })
385
+ state.lastVerdict = record
386
+ state.pushHistory(record)
387
+ return record
388
+ })()
389
+
390
+ const tracked = work
391
+ .catch((error) => {
392
+ if (!isAbortError(error)) log.warn?.(`thinking-auditor: audit failed: ${error?.message ?? String(error)}`)
393
+ return undefined
394
+ })
395
+ .finally(() => {
396
+ state.inFlight = undefined
397
+ if (state.rerunRequested && !state.disposed) {
398
+ state.rerunRequested = false
399
+ queueMicrotask(() => {
400
+ try {
401
+ maybeSchedule(agent, state, 'rerun', getConfig())
402
+ } catch (error) {
403
+ log.warn?.(`thinking-auditor: rerun scheduling failed: ${error?.message ?? String(error)}`)
404
+ }
405
+ })
406
+ }
407
+ })
408
+ state.inFlight = tracked
409
+ return tracked
410
+ }
411
+
412
+ /** Schedule a background audit when the state says one is due. */
413
+ const maybeSchedule = (agent, state, reason, config) => {
414
+ if (!config.enabled || state.disposed) return
415
+ if (state.inFlight !== undefined) {
416
+ state.rerunRequested = true
417
+ return
418
+ }
419
+ if (!state.hasPending(config.minWindowChars) && state.pending.length < config.windowChars) return
420
+ if (!state.canAuditThisTurn(config.maxAuditsPerTurn)) return
421
+
422
+ const now = Date.now()
423
+ const wait = config.cooldownMs - (now - state.lastAuditAt)
424
+ if (wait > 0 && state.pending.length < config.windowChars) {
425
+ if (state.cooldownTimer === undefined) {
426
+ state.cooldownTimer = setTimeout(() => {
427
+ state.cooldownTimer = undefined
428
+ try {
429
+ maybeSchedule(agent, state, 'cooldown', getConfig())
430
+ } catch (error) {
431
+ log.warn?.(`thinking-auditor: cooldown scheduling failed: ${error?.message ?? String(error)}`)
432
+ }
433
+ }, wait)
434
+ state.cooldownTimer.unref?.()
435
+ }
436
+ return
437
+ }
438
+ void runAudit(agent, state, reason, config)
439
+ }
440
+
441
+ // ----- live reasoning capture -------------------------------------------
442
+
443
+ ctx.on('agent/assistant-stream', ({ agent, frame } = {}) => {
444
+ try {
445
+ const config = getConfig()
446
+ if (!config.enabled || agent === undefined || frame === undefined) return
447
+ const state = ensureState(sessionKey(agent))
448
+ if (frame.type === 'start') {
449
+ state.turn = frame.turn ?? state.turn
450
+ state.step = frame.step ?? state.step
451
+ state.attemptHasReasoningDelta = false
452
+ return
453
+ }
454
+ if (frame.type === 'end') {
455
+ state.attemptHasReasoningDelta = false
456
+ if (config.auditOnAttemptEnd && state.hasPending(Math.min(config.minWindowChars, config.windowChars))) {
457
+ maybeSchedule(agent, state, 'attempt-end', config)
458
+ }
459
+ return
460
+ }
461
+ if (frame.type !== 'chunk') return
462
+ state.turn = frame.turn ?? state.turn
463
+ state.step = frame.step ?? state.step
464
+ const chunk = frame.chunk
465
+ if (chunk?.type === 'reasoning-delta' && typeof chunk.text === 'string') {
466
+ state.attemptHasReasoningDelta = true
467
+ state.appendReasoning(chunk.text, { turn: frame.turn, step: frame.step })
468
+ } else if (chunk?.type === 'block-end' && chunk.block?.type === 'reasoning' && state.attemptHasReasoningDelta !== true) {
469
+ state.appendReasoning(chunk.block.text, { turn: frame.turn, step: frame.step })
470
+ }
471
+ if (state.pending.length >= config.windowChars) maybeSchedule(agent, state, 'window', config)
472
+ } catch (error) {
473
+ log.warn?.(`thinking-auditor: reasoning capture failed: ${error?.message ?? String(error)}`)
474
+ }
475
+ })
476
+
477
+ // ----- evidence ledger ---------------------------------------------------
478
+
479
+ ctx.on('session/created', (session) => {
480
+ try {
481
+ if (session?.firstLiveSeq > 0) ledger.seedFromMessages(session.id, session.deriveMessages())
482
+ } catch (error) {
483
+ log.warn?.(`thinking-auditor: resume ledger backfill failed: ${error?.message ?? String(error)}`)
484
+ }
485
+ })
486
+
487
+ ctx.on('session/disposed', (session) => {
488
+ try {
489
+ const key = String(session?.id ?? '')
490
+ const state = sessions.get(key)
491
+ if (state !== undefined) {
492
+ state.disposed = true
493
+ if (state.cooldownTimer !== undefined) clearTimeout(state.cooldownTimer)
494
+ sessions.delete(key)
495
+ }
496
+ ledger.dropSession(session?.id)
497
+ } catch (error) {
498
+ log.warn?.(`thinking-auditor: session cleanup failed: ${error?.message ?? String(error)}`)
499
+ }
500
+ })
501
+
502
+ ctx.on('session/event', (session, event) => {
503
+ try {
504
+ ledger.onSessionEvent(session.id, event)
505
+ if (event?.type === 'turn/start') {
506
+ ensureState(session.id).startTurn(event.data?.turn)
507
+ return
508
+ }
509
+ // The durable tool/result append is the ordering point: `tools/result`
510
+ // fires before the agent loop records the result, so scheduling from
511
+ // there could audit without the very result that triggered it. Here the
512
+ // ledger is already updated.
513
+ if (event?.type === 'tool/result') {
514
+ const config = getConfig()
515
+ if (!config.enabled || config.auditOnToolResult !== true) return
516
+ const state = ensureState(session.id)
517
+ // The session itself is enough for route resolution; the latest logged
518
+ // request header owns the route.
519
+ const agentLike = { session }
520
+ if (state.hasPending(config.minWindowChars)) maybeSchedule(agentLike, state, 'tool-result', config)
521
+ }
522
+ } catch (error) {
523
+ log.warn?.(`thinking-auditor: ledger update failed: ${error?.message ?? String(error)}`)
524
+ }
525
+ })
526
+
527
+ // ----- actuators ---------------------------------------------------------
528
+
529
+ ctx.on('agent/turn-stopping', async ({ agent, turn, signal } = {}) => {
530
+ try {
531
+ const config = getConfig()
532
+ if (!config.enabled || agent === undefined) return
533
+ const state = ensureState(sessionKey(agent))
534
+ state.turn = turn ?? state.turn
535
+
536
+ let record = state.lastVerdict
537
+ if (config.auditOnTurnStop === true) {
538
+ if (state.inFlight !== undefined) {
539
+ const raced = await raceDeadline(state.inFlight, config.turnStopTimeoutMs)
540
+ if (raced.settled && raced.value !== undefined) record = raced.value
541
+ } else if (state.hasPending(Math.min(config.minWindowChars, config.windowChars))) {
542
+ const audited = await runAudit(agent, state, 'turn-stop', config, {
543
+ force: true,
544
+ timeoutMs: config.turnStopTimeoutMs,
545
+ })
546
+ if (audited !== undefined) record = audited
547
+ }
548
+ }
549
+ if (record === undefined || signal?.aborted === true) return
550
+
551
+ const decision = decideTurnAction({
552
+ record,
553
+ history: state.history,
554
+ config,
555
+ alreadyVerified: state.wasVerified(state.turn),
556
+ alreadyCancelled: state.wasCancelled(state.turn),
557
+ })
558
+ if (decision.action === 'observe') return
559
+
560
+ store.recordAction({
561
+ sessionId: state.sessionId,
562
+ turn: state.turn,
563
+ tier: decision.tier,
564
+ action: decision.action,
565
+ reason: decision.reason,
566
+ verdictId: record.id,
567
+ })
568
+
569
+ if (decision.action === 'cancel') {
570
+ state.latchCancelled(state.turn)
571
+ agent.cancel({ kind: 'hook', reason: 'thinking-auditor: confirmed untrusted claims survived verification' })
572
+ return
573
+ }
574
+ if (decision.action === 'verify') {
575
+ state.latchVerified(state.turn)
576
+ agent.steer(createUserMessage({
577
+ content: [{ type: 'text', text: VERIFY_MESSAGE }],
578
+ source: { kind: 'plugin', plugin: PLUGIN_ID, form: 'notice', summary: 'verify claims before finalizing' },
579
+ }))
580
+ }
581
+ } catch (error) {
582
+ log.warn?.(`thinking-auditor: turn-stopping gate failed: ${error?.message ?? String(error)}`)
583
+ }
584
+ })
585
+
586
+ ctx.on('tools/pre-execute', async (exec, next) => {
587
+ const decision = await next()
588
+ try {
589
+ const config = getConfig()
590
+ if (!config.enabled || exec?.agent === undefined || decision?.kind !== 'allow') return decision
591
+ const state = ensureState(sessionKey(exec.agent))
592
+ const stakes = classifyAction(exec.name, exec.arguments)
593
+ const gate = decideToolAction({
594
+ record: state.lastVerdict,
595
+ history: state.history,
596
+ config,
597
+ stakes,
598
+ auditPending: state.inFlight !== undefined,
599
+ })
600
+ if (gate.action === 'allow') return decision
601
+
602
+ store.recordAction({
603
+ sessionId: state.sessionId,
604
+ turn: state.turn,
605
+ tier: gate.tier,
606
+ action: gate.action,
607
+ reason: gate.reason,
608
+ toolName: String(exec.name ?? 'unknown'),
609
+ stakes,
610
+ verdictId: state.lastVerdict?.id,
611
+ })
612
+ if (gate.action === 'deny') {
613
+ return {
614
+ kind: 'deny',
615
+ reason: BLOCK_REASON,
616
+ info: { name: 'ThinkingAuditBlocked', code: 'THINKING_AUDIT_BLOCKED' },
617
+ }
618
+ }
619
+ return { kind: 'ask', reason: BLOCK_REASON }
620
+ } catch (error) {
621
+ log.warn?.(`thinking-auditor: pre-execute gate failed: ${error?.message ?? String(error)}`)
622
+ return decision
623
+ }
624
+ })
625
+
626
+ // ----- settings and public service --------------------------------------
627
+
628
+ ctx.inject(['settings'], (settingsCtx) => {
629
+ try {
630
+ settingsCtx.settings.installSection(ctx, SETTINGS_NAMESPACE, Config, baseConfig, {
631
+ setSource: (source) => {
632
+ configSource = typeof source === 'function' ? source : () => source
633
+ },
634
+ onChange: () => {},
635
+ validate: (value) => {
636
+ resolveConfig(value)
637
+ },
638
+ })
639
+ } catch (error) {
640
+ log.warn?.(`thinking-auditor: settings namespace install failed: ${error?.message ?? String(error)}`)
641
+ }
642
+ })
643
+
644
+ const service = {
645
+ plugin: PLUGIN_ID,
646
+ settingsNamespace: SETTINGS_NAMESPACE,
647
+ status() {
648
+ let config = baseConfig
649
+ try {
650
+ config = getConfig()
651
+ } catch {
652
+ // A bad external settings document must not make the status API throw.
653
+ }
654
+ return {
655
+ enabled: config.enabled,
656
+ maxTier: config.gates.maxTier,
657
+ sessions: sessions.size,
658
+ verdicts: store.verdictCount,
659
+ inFlight: [...sessions.values()].filter((state) => state.inFlight !== undefined).length,
660
+ auditStore: sink === undefined || sink.disabled ? null : sink.path,
661
+ auditStoreError: sink?.error ?? store.lastSinkError,
662
+ }
663
+ },
664
+ latest(sessionId) {
665
+ return store.latest(sessionId) ?? sessions.get(String(sessionId))?.lastVerdict
666
+ },
667
+ verdicts: (sessionId, limit) => store.verdicts(sessionId, limit),
668
+ actions: (sessionId, limit) => store.actions(sessionId, limit),
669
+ onRecord: (callback) => store.subscribe(callback),
670
+ }
671
+ if (typeof ctx.provide === 'function') ctx.provide('thinkingAudit', service)
672
+ }
673
+
674
+ export default { name, inject, Config, apply }