iterate-plugin 2.11.0 → 2.12.1

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.
@@ -0,0 +1,475 @@
1
+ /**
2
+ * src/transcript.ts — runtime-observatory transcript builder.
3
+ *
4
+ * Pure, deterministic, memory-bounded accumulator that turns normalized
5
+ * iteration events into a serializable {@link TranscriptManifest} the client
6
+ * renders. This is the data backbone for the observatory UI layer:
7
+ * - F1 — per-reviewer sub-agent message streams (threads per round/dimension).
8
+ * - F2 — per-round convergence series.
9
+ * - F3 — full finding list with file/line location for jump + triage.
10
+ * - F4 — applied-fix records (for diff + rollback).
11
+ * - F5 — checkpoint summary (resume).
12
+ * - F6 — nudge channel (steer the next round).
13
+ * - F7 — append-only decision timeline.
14
+ *
15
+ * It performs NO I/O and NEVER touches the filesystem — persistence lives in
16
+ * the `iterate_transcript` tool. Inputs are defensively normalized so a
17
+ * malformed event can never crash dedupe/sort or leak non-JSON state.
18
+ *
19
+ * Growth is bounded per field (threads/round capped, messages per thread
20
+ * capped, timeline capped) so a very long run cannot blow up memory or the
21
+ * client payload; the newest events win when a cap is hit.
22
+ */
23
+
24
+ import type {
25
+ TranscriptCheckpoint,
26
+ TranscriptEntry,
27
+ TranscriptFinding,
28
+ TranscriptFix,
29
+ TranscriptManifest,
30
+ TranscriptNudge,
31
+ TranscriptRound,
32
+ } from './types.ts'
33
+
34
+ /** Manifest schema version (bump on incompatible shape change). */
35
+ export const TRANSCRIPT_VERSION = 1
36
+
37
+ /** Max threads recorded per round (extra dimensions/retries beyond this drop). */
38
+ const MAX_THREADS_PER_ROUND = 12
39
+
40
+ /** Max narration messages kept per thread (newest wins). */
41
+ const MAX_MESSAGES_PER_THREAD = 40
42
+
43
+ /** Max findings kept per thread. */
44
+ const MAX_FINDINGS_PER_THREAD = 100
45
+
46
+ /** Max global findings kept in the manifest. */
47
+ const MAX_FINDINGS_TOTAL = 2000
48
+
49
+ /** Max timeline entries kept (newest wins). */
50
+ const MAX_TIMELINE = 500
51
+
52
+ /** Thresholds applied when reducing a string list under a cap. */
53
+ function clampStringList(source: string[], cap: number): string[] {
54
+ const out: string[] = []
55
+ for (const item of source) {
56
+ if (typeof item !== 'string') continue
57
+ const trimmed = item.trim()
58
+ if (!trimmed) continue
59
+ out.push(trimmed)
60
+ if (out.length >= cap) break
61
+ }
62
+ return out
63
+ }
64
+
65
+ /** Normalize a single finding, dropping malformed entries. */
66
+ function normalizeFinding(input: unknown): TranscriptFinding | null {
67
+ if (!input || typeof input !== 'object') return null
68
+ const f = input as Record<string, unknown>
69
+ const dimension = typeof f.dimension === 'string' ? f.dimension : ''
70
+ const file = typeof f.file === 'string' ? f.file : ''
71
+ const summary = typeof f.summary === 'string' ? f.summary : ''
72
+ if (!dimension || !file || !summary) return null
73
+ const sev = f.severity
74
+ const severity =
75
+ sev === 'critical' || sev === 'high' || sev === 'medium' || sev === 'low'
76
+ ? sev
77
+ : 'low'
78
+ const line = typeof f.line === 'number' && Number.isFinite(f.line) ? f.line : 0
79
+ return {
80
+ dimension,
81
+ file,
82
+ line,
83
+ severity,
84
+ summary,
85
+ failure_scenario: typeof f.failure_scenario === 'string' ? f.failure_scenario : undefined,
86
+ suggested_fix: typeof f.suggested_fix === 'string' ? f.suggested_fix : undefined,
87
+ is_atomic: typeof f.is_atomic === 'boolean' ? f.is_atomic : undefined,
88
+ acknowledged: typeof f.acknowledged === 'boolean' ? f.acknowledged : undefined,
89
+ }
90
+ }
91
+
92
+ /** Stage an in-progress thread so the builder can append messages/reads/findings. */
93
+ interface LiveThread {
94
+ dimension: string
95
+ attempt: number
96
+ messages: string[]
97
+ readFiles: string[]
98
+ findings: unknown[]
99
+ }
100
+
101
+ /** Stage an in-progress round. */
102
+ interface LiveRound {
103
+ round: number
104
+ threads: LiveThread[]
105
+ }
106
+
107
+ /** Merge a report snapshot's findings/readFiles into a thread by dimension. */
108
+ function mergeReportIntoThread(
109
+ thread: LiveThread,
110
+ findings: readonly unknown[],
111
+ readFiles: readonly unknown[] | undefined,
112
+ ): void {
113
+ for (const raw of findings) {
114
+ const f = normalizeFinding(raw)
115
+ if (f) thread.findings.push(raw)
116
+ }
117
+ for (const r of readFiles ?? []) {
118
+ if (typeof r === 'string') thread.readFiles.push(r)
119
+ }
120
+ }
121
+
122
+ /**
123
+ * The transcript builder. Create one per project run, feed normalized events,
124
+ * then call {@link serialize}. Safe to call from any thread sequentially.
125
+ */
126
+ export class ReviewTranscriptBuilder {
127
+ private readonly project: string
128
+ private readonly mode: 'dry-run' | 'normal' | null
129
+ private readonly approval: 'ask' | 'deny' | 'allow'
130
+ private goal = ''
131
+ private readonly phases: string[] = []
132
+ private round = 0
133
+ private maxRounds = 0
134
+ private active = true
135
+ private readonly rounds: LiveRound[] = []
136
+ private readonly convergence: number[] = []
137
+ private readonly globalFindings: TranscriptFinding[] = []
138
+ private readonly fixes: TranscriptFix[] = []
139
+ private checkpoint: TranscriptCheckpoint | null = null
140
+ private readonly timeline: TranscriptEntry[] = []
141
+ private nudge: TranscriptNudge | null = null
142
+ private updatedAt: string
143
+
144
+ constructor(input: {
145
+ project: string
146
+ mode?: 'dry-run' | 'normal' | null
147
+ approval?: 'ask' | 'deny' | 'allow'
148
+ goal?: string
149
+ maxRounds?: number
150
+ now?: () => string
151
+ }) {
152
+ this.project = input.project || ''
153
+ this.mode =
154
+ input.mode === 'dry-run' || input.mode === 'normal' ? input.mode : null
155
+ this.approval =
156
+ input.approval === 'ask' || input.approval === 'deny' || input.approval === 'allow'
157
+ ? input.approval
158
+ : 'ask'
159
+ this.goal = typeof input.goal === 'string' ? input.goal : ''
160
+ this.maxRounds =
161
+ typeof input.maxRounds === 'number' && Number.isFinite(input.maxRounds) && input.maxRounds >= 0
162
+ ? Math.floor(input.maxRounds)
163
+ : 0
164
+ this.updatedAt = input.now ? input.now() : new Date().toISOString()
165
+ }
166
+
167
+ // ─── Run lifecycle ──────────────────────────────────────────────────────
168
+
169
+ /** Mark the run started (clears the transcript for a fresh session). */
170
+ begin(goal?: string, maxRounds?: number): void {
171
+ if (typeof goal === 'string' && goal) this.goal = goal
172
+ if (typeof maxRounds === 'number' && Number.isFinite(maxRounds) && maxRounds >= 0) {
173
+ this.maxRounds = Math.floor(maxRounds)
174
+ }
175
+ this.active = true
176
+ this.touch()
177
+ }
178
+
179
+ /** Record a workflow phase name (plan / review / fix / validate / report …). */
180
+ phase(name: string): void {
181
+ const n = typeof name === 'string' ? name.trim() : ''
182
+ if (!n) return
183
+ if (this.phases[this.phases.length - 1] !== n) this.phases.push(n)
184
+ this.touch()
185
+ }
186
+
187
+ /** End the run (stops the "active" pulsing in the UI). */
188
+ finish(): void {
189
+ this.active = false
190
+ this.touch()
191
+ }
192
+
193
+ /** Open a review round, capturing the current round index. */
194
+ roundStart(round: number, maxRounds?: number): void {
195
+ const r = typeof round === 'number' && Number.isFinite(round) ? Math.floor(round) : 1
196
+ this.round = r > 0 ? r : 1
197
+ if (typeof maxRounds === 'number' && Number.isFinite(maxRounds) && maxRounds >= 0) {
198
+ this.maxRounds = Math.floor(maxRounds)
199
+ }
200
+ while (this.rounds.length < this.round) {
201
+ this.rounds.push({ round: this.rounds.length + 1, threads: [] })
202
+ }
203
+ this.touch()
204
+ }
205
+
206
+ // ─── Reviewer threads (F1) ──────────────────────────────────────────────
207
+
208
+ /** Start a reviewer sub-agent's thread for the current round. */
209
+ reviewerStart(dimension: string, attempt = 1): void {
210
+ const dim = typeof dimension === 'string' ? dimension.trim() : 'review'
211
+ const att = typeof attempt === 'number' && Number.isFinite(attempt) ? Math.floor(attempt) : 1
212
+ this.roundStart(this.round)
213
+ const live = this.rounds[this.round - 1]! as LiveRound | undefined
214
+ if (live && this.threadCount(live) < MAX_THREADS_PER_ROUND) {
215
+ live.threads.push({
216
+ dimension: dim || 'review',
217
+ attempt: att > 0 ? att : 1,
218
+ messages: [],
219
+ readFiles: [],
220
+ findings: [],
221
+ })
222
+ }
223
+ this.touch()
224
+ }
225
+
226
+ /** Append narration (assistant text) to the current reviewer thread. */
227
+ reviewerMessage(text: string): void {
228
+ if (typeof text !== 'string' || !text.trim()) return
229
+ const thread = this.currentThread()
230
+ if (!thread) return
231
+ thread.messages.push(text)
232
+ if (thread.messages.length > MAX_MESSAGES_PER_THREAD) {
233
+ thread.messages.splice(0, thread.messages.length - MAX_MESSAGES_PER_THREAD)
234
+ }
235
+ this.touch()
236
+ }
237
+
238
+ /** Record files the current reviewer opened (read_file). */
239
+ reviewerRead(files: readonly unknown[]): void {
240
+ const thread = this.currentThread()
241
+ if (!thread) return
242
+ for (const f of files ?? []) {
243
+ if (typeof f === 'string') thread.readFiles.push(f)
244
+ }
245
+ this.touch()
246
+ }
247
+
248
+ /** Record findings the current reviewer produced (both raw and normalized). */
249
+ reviewerFindings(findings: readonly unknown[]): void {
250
+ const thread = this.currentThread()
251
+ if (!thread) return
252
+ if (Array.isArray(findings)) {
253
+ for (const raw of findings) {
254
+ const f = normalizeFinding(raw)
255
+ if (f) {
256
+ thread.findings.push(raw)
257
+ this.globalFindings.push(f)
258
+ }
259
+ }
260
+ }
261
+ this.reevaluateGlobal()
262
+ this.touch()
263
+ }
264
+
265
+ /** Merge a round-level report snapshot (findings + readFiles) into a thread. */
266
+ reviewerSnapshot(dimension: string, findings: readonly unknown[], readFiles?: readonly unknown[]): void {
267
+ this.reviewerStart(dimension)
268
+ const thread = this.currentThread()
269
+ if (!thread) return
270
+ mergeReportIntoThread(thread, findings, readFiles)
271
+ for (const raw of findings) {
272
+ const f = normalizeFinding(raw)
273
+ if (f) this.globalFindings.push(f)
274
+ }
275
+ this.reevaluateGlobal()
276
+ this.touch()
277
+ }
278
+
279
+ // ─── Convergence (F2) ───────────────────────────────────────────────────
280
+
281
+ /** Record a round's new-finding count for the convergence series. */
282
+ snapshotConvergence(round: number, newCount: number): void {
283
+ const r = typeof round === 'number' && Number.isFinite(round) ? Math.floor(round) : 1
284
+ const n = typeof newCount === 'number' && Number.isFinite(newCount) ? newCount : 0
285
+ while (this.convergence.length < r) this.convergence.push(-1)
286
+ this.convergence[r - 1] = Math.floor(n)
287
+ this.touch()
288
+ }
289
+
290
+ // ─── Fixes (F4) ─────────────────────────────────────────────────────────
291
+
292
+ /** Record an applied atomic fix. */
293
+ fix(record: Partial<TranscriptFix>): void {
294
+ if (!record || typeof record !== 'object') return
295
+ const id = typeof record.id === 'string' ? record.id : ''
296
+ const file = typeof record.file === 'string' ? record.file : ''
297
+ if (!id || !file) return
298
+ this.fixes.push({
299
+ id,
300
+ timestamp: typeof record.timestamp === 'string' ? record.timestamp : isoNow(),
301
+ round: typeof record.round === 'number' ? record.round : this.round,
302
+ file,
303
+ summary: typeof record.summary === 'string' ? record.summary : '',
304
+ linesAdded:
305
+ typeof record.linesAdded === 'number' ? Math.floor(record.linesAdded) : 0,
306
+ linesRemoved:
307
+ typeof record.linesRemoved === 'number' ? Math.floor(record.linesRemoved) : 0,
308
+ success: record.success !== false,
309
+ })
310
+ this.touch()
311
+ }
312
+
313
+ /** Flag a fix as rolled back (kept in the list so the UI shows the reversal). */
314
+ markFixRolledBack(id: string): void {
315
+ for (const f of this.fixes) {
316
+ if (f.id === id) f.success = false
317
+ }
318
+ this.touch()
319
+ }
320
+
321
+ // ─── Checkpoint (F5) ────────────────────────────────────────────────────
322
+
323
+ /** Record the current checkpoint summary (null clears it). */
324
+ recordCheckpoint(state: TranscriptCheckpoint | null): void {
325
+ if (!state || typeof state !== 'object') {
326
+ this.checkpoint = null
327
+ this.touch()
328
+ return
329
+ }
330
+ this.checkpoint = {
331
+ mode: state.mode === 'dry-run' || state.mode === 'normal' ? state.mode : 'normal',
332
+ round: typeof state.round === 'number' ? state.round : 0,
333
+ maxRounds: typeof state.maxRounds === 'number' ? state.maxRounds : 0,
334
+ fixedCount: typeof state.fixedCount === 'number' ? state.fixedCount : 0,
335
+ resumeCount: typeof state.resumeCount === 'number' ? state.resumeCount : 0,
336
+ updatedAt: typeof state.updatedAt === 'string' ? state.updatedAt : isoNow(),
337
+ }
338
+ this.touch()
339
+ }
340
+
341
+ // ─── Decision timeline (F7) ─────────────────────────────────────────────
342
+
343
+ /** Append one decision-log entry to the timeline (newest wins under the cap). */
344
+ decision(entry: Partial<TranscriptEntry>): void {
345
+ if (!entry || typeof entry !== 'object') return
346
+ const type = typeof entry.type === 'string' ? entry.type : 'decision'
347
+ this.timeline.push({
348
+ timestamp: typeof entry.timestamp === 'string' ? entry.timestamp : isoNow(),
349
+ round: typeof entry.round === 'number' ? entry.round : this.round,
350
+ type,
351
+ data:
352
+ entry.data && typeof entry.data === 'object'
353
+ ? (entry.data as Record<string, unknown>)
354
+ : {},
355
+ })
356
+ if (this.timeline.length > MAX_TIMELINE) {
357
+ this.timeline.splice(0, this.timeline.length - MAX_TIMELINE)
358
+ }
359
+ this.touch()
360
+ }
361
+
362
+ // ─── Nudge (F6) ─────────────────────────────────────────────────────────
363
+
364
+ /** Write steering text for the next round (null clears it). */
365
+ setNudge(text: string | null): void {
366
+ if (typeof text === 'string' && text.trim()) {
367
+ this.nudge = { timestamp: isoNow(), text: text.trim() }
368
+ } else {
369
+ this.nudge = null
370
+ }
371
+ this.touch()
372
+ }
373
+
374
+ // ─── Serialization ──────────────────────────────────────────────────────
375
+
376
+ /** Produce the current serializable manifest. */
377
+ serialize(): TranscriptManifest {
378
+ const rounds: TranscriptRound[] = this.rounds.map((r, idx) => ({
379
+ round: r.round,
380
+ threads: r.threads.map((t) => ({
381
+ dimension: t.dimension,
382
+ attempt: t.attempt,
383
+ messages: clampStringList(t.messages, MAX_MESSAGES_PER_THREAD),
384
+ readFiles: dedupePaths(t.readFiles),
385
+ findings: t.findings
386
+ .map((x) => normalizeFinding(x))
387
+ .filter((x): x is TranscriptFinding => x !== null),
388
+ })),
389
+ }))
390
+ return {
391
+ version: TRANSCRIPT_VERSION,
392
+ project: this.project,
393
+ updatedAt: this.updatedAt,
394
+ active: this.active,
395
+ mode: this.mode,
396
+ goal: this.goal,
397
+ phases: this.phases,
398
+ round: this.round,
399
+ maxRounds: this.maxRounds,
400
+ rounds,
401
+ convergence: this.convergence,
402
+ findings:
403
+ this.globalFindings.length > MAX_FINDINGS_TOTAL
404
+ ? this.globalFindings.slice(0, MAX_FINDINGS_TOTAL)
405
+ : this.globalFindings,
406
+ fixes: this.fixes,
407
+ checkpoint: this.checkpoint,
408
+ timeline: this.timeline,
409
+ nudge: this.nudge,
410
+ approval: {
411
+ active: this.approval !== 'allow',
412
+ policy: this.approval,
413
+ },
414
+ }
415
+ }
416
+
417
+ // ─── Internals ──────────────────────────────────────────────────────────
418
+
419
+ /** Bump the manifest's updatedAt to reflect a fresh mutation. */
420
+ private touch(): void {
421
+ this.updatedAt = isoNow()
422
+ }
423
+
424
+ /** Current round's most recent thread, if any. */
425
+ private currentThread(): LiveThread | null {
426
+ const live = this.rounds[this.round - 1] as LiveRound | undefined
427
+ if (!live) return null
428
+ const thread = live.threads[live.threads.length - 1]
429
+ return thread ?? null
430
+ }
431
+
432
+ /** Count threads already recorded for a round. */
433
+ private threadCount(live: LiveRound): number {
434
+ return live.threads.length
435
+ }
436
+
437
+ /** Recompute the global finding list from per-thread findings (dedup). */
438
+ private reevaluateGlobal(): void {
439
+ // Rebuild from threads to derive a deterministic global list.
440
+ this.globalFindings.length = 0
441
+ const seen = new Set<string>()
442
+ for (const r of this.rounds) {
443
+ for (const t of r.threads) {
444
+ for (const raw of t.findings) {
445
+ const f = normalizeFinding(raw)
446
+ if (!f) continue
447
+ const key = `${f.file}\u0000${f.line ?? 0}\u0000${f.dimension}\u0000${f.summary}`
448
+ if (seen.has(key)) continue
449
+ seen.add(key)
450
+ this.globalFindings.push(f)
451
+ if (this.globalFindings.length >= MAX_FINDINGS_TOTAL) return
452
+ }
453
+ }
454
+ }
455
+ }
456
+ }
457
+
458
+ /** Dedupe + bound an ordered string list of file paths. */
459
+ function dedupePaths(paths: string[]): string[] {
460
+ const seen = new Set<string>()
461
+ const out: string[] = []
462
+ for (const p of paths) {
463
+ if (typeof p !== 'string' || !p.trim()) continue
464
+ const k = p.trim()
465
+ if (seen.has(k)) continue
466
+ seen.add(k)
467
+ out.push(k)
468
+ }
469
+ return out
470
+ }
471
+
472
+ /** ISO timestamp helper (kept injectable in tests via the builder's now). */
473
+ function isoNow(): string {
474
+ return new Date().toISOString()
475
+ }
package/src/types.ts CHANGED
@@ -22,6 +22,22 @@ export interface IterateConfig {
22
22
  coverage_validation: boolean
23
23
  scope_chunk_size: number
24
24
  }
25
+ /**
26
+ * Runtime observatory: live review-transcript capture and the destructive
27
+ * tool approval gate. Absent → defaults (capture on, approval per `policy`).
28
+ */
29
+ observatory?: {
30
+ /** Persist a review transcript to `.iterate/transcript.json` for the client observatory. */
31
+ capture?: boolean
32
+ /**
33
+ * Approval policy for destructive iterate tools (iterate_fix /
34
+ * iterate_rollback / iterate_prune with dryRun:false).
35
+ * - 'ask': prompt the human through the dsh approval service before running.
36
+ * - 'deny': refuse the call outright (fail-closed).
37
+ * - 'allow': always run (debug/trusted). Default 'ask'.
38
+ */
39
+ approval?: 'ask' | 'deny' | 'allow'
40
+ }
25
41
  onboarding?: Record<string, unknown>
26
42
  personalization?: Record<string, unknown>
27
43
  }
@@ -188,4 +204,105 @@ export interface IterationStatus {
188
204
  resumeCount: number
189
205
  checkpoint: IterationCheckpoint | null
190
206
  lastUpdated: string | null
207
+ }
208
+
209
+ /** ─── Runtime observatory (transcript) ───────────────────────────────────── */
210
+
211
+ /** Keyboard-control-free direction to steer the running workflow's next round. */
212
+ export interface TranscriptNudge {
213
+ /** ISO timestamp the nudge was written. */
214
+ timestamp: string
215
+ /** Free-form steering text injected at the front of the next round's prompt. */
216
+ text: string
217
+ }
218
+
219
+ /** One finding rendered for the observatory (location + evidence kept for jump/triage). */
220
+ export interface TranscriptFinding {
221
+ dimension: string
222
+ file: string
223
+ line?: number
224
+ severity: 'critical' | 'high' | 'medium' | 'low'
225
+ summary: string
226
+ failure_scenario?: string
227
+ suggested_fix?: string
228
+ is_atomic?: boolean
229
+ /** True when this finding was already marked known_intentional (filtered from active work). */
230
+ acknowledged?: boolean
231
+ }
232
+
233
+ /** One reviewer sub-agent's visible stream within a round. */
234
+ export interface TranscriptThread {
235
+ /** Target dimension the reviewer was asked to review. */
236
+ dimension: string
237
+ /** 1-based attempt within the round (schema-validation retries bump this). */
238
+ attempt: number
239
+ /** Natural-language narration the reviewer produced (F1 message stream). */
240
+ messages: string[]
241
+ /** Files the reviewer opened with read_file (F1 "what it read"). */
242
+ readFiles: string[]
243
+ /** Findings the reviewer produced in this thread. */
244
+ findings: TranscriptFinding[]
245
+ }
246
+
247
+ /** A review round grouping its reviewer threads. */
248
+ export interface TranscriptRound {
249
+ round: number
250
+ threads: TranscriptThread[]
251
+ }
252
+
253
+ /** A recorded atomic fix shown in the observatory (F4). */
254
+ export interface TranscriptFix {
255
+ id: string
256
+ timestamp: string
257
+ round: number
258
+ file: string
259
+ summary: string
260
+ linesAdded: number
261
+ linesRemoved: number
262
+ success: boolean
263
+ }
264
+
265
+ /** A single decision-log entry in the timeline (F7). */
266
+ export interface TranscriptEntry {
267
+ timestamp: string
268
+ round: number
269
+ type: string
270
+ data: Record<string, unknown>
271
+ }
272
+
273
+ /** Checkpoint summary surfaced for resume actions (F5). */
274
+ export interface TranscriptCheckpoint {
275
+ mode: 'dry-run' | 'normal'
276
+ round: number
277
+ maxRounds: number
278
+ fixedCount: number
279
+ resumeCount: number
280
+ updatedAt: string
281
+ }
282
+
283
+ /**
284
+ * Serializable runtime-observatory manifest the client renders. Every field is
285
+ * `JsonValue`-safe; the builder caps growth so a long run cannot blow up memory.
286
+ */
287
+ export interface TranscriptManifest {
288
+ version: number
289
+ project: string
290
+ updatedAt: string
291
+ active: boolean
292
+ mode: 'dry-run' | 'normal' | null
293
+ goal: string
294
+ phases: string[]
295
+ round: number
296
+ maxRounds: number
297
+ rounds: TranscriptRound[]
298
+ convergence: number[]
299
+ findings: TranscriptFinding[]
300
+ fixes: TranscriptFix[]
301
+ checkpoint: TranscriptCheckpoint | null
302
+ timeline: TranscriptEntry[]
303
+ nudge: TranscriptNudge | null
304
+ approval: {
305
+ active: boolean
306
+ policy: 'ask' | 'deny' | 'allow'
307
+ }
191
308
  }