@cat-factory/app 0.266.1 → 0.267.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.
@@ -429,6 +429,9 @@ function defaultPipelineIdFor(type: TaskTypeChoice): string {
429
429
  const preset =
430
430
  custom?.defaultPipelineId ??
431
431
  DEFAULT_PIPELINE_FOR_TYPE[type] ??
432
+ // The workspace's own declared in-app default, ahead of the interface-mode rung, so this form
433
+ // and the task card's plain Start still cannot disagree (see `declaredDefaultId`).
434
+ pipelines.declaredDefaultId('interactive') ??
432
435
  defaultBuildPipelineId(uiMode.isAdvanced)
433
436
  return pipelines.pipelines.some((p) => p.id === preset) ? preset : ''
434
437
  }
@@ -92,7 +92,13 @@ const defaultPipeline = computed<{ id: string; name: string } | undefined>(() =>
92
92
  }
93
93
  )
94
94
  }
95
- return pipelines.getPipeline(defaultBuildPipelineId(uiMode.isAdvanced)) ?? pipelines.pipelines[0]
95
+ // No pin: the workspace's own DECLARED in-app default outranks the interface-mode rung, because
96
+ // an operator who named one said something a tier cannot overrule.
97
+ const declared = pipelines.declaredDefaultId('interactive')
98
+ return (
99
+ pipelines.getPipeline(declared ?? defaultBuildPipelineId(uiMode.isAdvanced)) ??
100
+ pipelines.pipelines[0]
101
+ )
96
102
  })
97
103
 
98
104
  /** The PR the implementer agent opened for this task, if any. */
@@ -399,45 +399,10 @@ const library = computed(() =>
399
399
  }),
400
400
  )
401
401
  const visiblePipelines = computed(() => library.value.offered)
402
- async function toggleArchive(p: Pipeline) {
403
- try {
404
- if (p.archived) await pipelines.unarchive(p.id)
405
- else await pipelines.archive(p.id)
406
- } catch {
407
- toast.add({ title: t('pipeline.builder.toast.updateFailed'), color: 'error' })
408
- }
409
- }
410
-
411
- /** Load a custom pipeline into the draft for in-place editing. */
412
- function edit(p: Pipeline) {
413
- pipelines.loadForEdit(p)
414
- }
415
-
416
- const { confirm } = useConfirm()
417
- async function removePipeline(p: Pipeline) {
418
- const ok = await confirm({
419
- title: t('pipeline.builder.confirmDeletePipeline.title'),
420
- description: t('pipeline.builder.confirmDeletePipeline.body', { name: p.name }),
421
- variant: 'destructive',
422
- confirmLabel: t('common.delete'),
423
- icon: 'i-lucide-trash-2',
424
- })
425
- if (ok) void pipelines.removePipeline(p.id)
426
- }
427
-
428
- /** Clone any pipeline (incl. a read-only built-in) into an editable copy, then edit it. */
429
- async function clone(p: Pipeline) {
430
- try {
431
- const copy = await pipelines.clonePipeline(p.id)
432
- toast.add({
433
- title: t('pipeline.builder.toast.cloned', { name: p.name, copy: copy.name }),
434
- color: 'success',
435
- icon: 'i-lucide-copy',
436
- })
437
- } catch {
438
- toast.add({ title: t('pipeline.builder.toast.cloneFailed'), color: 'error' })
439
- }
440
- }
402
+ // The library ROW's actions (archive, the two scope defaults, edit, clone, delete) — one cohesive
403
+ // group, extracted so this component stays inside its size budget. See
404
+ // `usePipelineLibraryActions` for why they belong together.
405
+ const { toggleArchive, toggleDefault, edit, removePipeline, clone } = usePipelineLibraryActions()
441
406
  </script>
442
407
 
443
408
  <template>
@@ -1260,6 +1225,32 @@ async function clone(p: Pipeline) {
1260
1225
  >
1261
1226
  {{ t('pipeline.builder.defaultBadge') }}
1262
1227
  </UBadge>
1228
+ <!-- Which scope this rung is the default for. Shown at BOTH interface tiers even
1229
+ though the controls below are advanced-only: the control is an override, the
1230
+ resulting default is a decision, and a decision nobody can see is the
1231
+ concealed-setting failure. -->
1232
+ <UBadge
1233
+ v-if="p.isDefault"
1234
+ color="primary"
1235
+ variant="subtle"
1236
+ size="xs"
1237
+ class="shrink-0"
1238
+ :title="t('pipeline.builder.scopeDefault.interactiveHint')"
1239
+ data-testid="pipeline-interactive-default"
1240
+ >
1241
+ {{ t('pipeline.builder.scopeDefault.interactive') }}
1242
+ </UBadge>
1243
+ <UBadge
1244
+ v-if="p.isUnattendedDefault"
1245
+ color="info"
1246
+ variant="subtle"
1247
+ size="xs"
1248
+ class="shrink-0"
1249
+ :title="t('pipeline.builder.scopeDefault.unattendedHint')"
1250
+ data-testid="pipeline-unattended-default"
1251
+ >
1252
+ {{ t('pipeline.builder.scopeDefault.unattended') }}
1253
+ </UBadge>
1263
1254
  <span class="shrink-0 text-[10px] text-slate-500">
1264
1255
  {{
1265
1256
  t(
@@ -1273,6 +1264,38 @@ async function clone(p: Pipeline) {
1273
1264
  <div
1274
1265
  class="flex shrink-0 items-center opacity-0 transition group-hover:opacity-100"
1275
1266
  >
1267
+ <!-- The two DEFAULT claims, advanced-tier (see `toggleDefault`). An archived
1268
+ pipeline is not offered either: the backend refuses a hidden row as a
1269
+ default, and a control that can only fail is worse than no control. Safe
1270
+ to hide rather than a way to strand a claim, because the same rule refuses
1271
+ ARCHIVING a row that still holds one: a hidden row never holds a default,
1272
+ so there is never one here to release. -->
1273
+ <template v-if="uiMode.isAdvanced && !p.archived && !p.internal">
1274
+ <UButton
1275
+ :icon="p.isDefault ? 'i-lucide-star' : 'i-lucide-star-off'"
1276
+ :color="p.isDefault ? 'primary' : 'neutral'"
1277
+ variant="ghost"
1278
+ size="xs"
1279
+ :title="
1280
+ p.isDefault
1281
+ ? t('pipeline.builder.scopeDefault.releaseInteractive')
1282
+ : t('pipeline.builder.scopeDefault.claimInteractive')
1283
+ "
1284
+ @click="toggleDefault(p, 'interactive')"
1285
+ />
1286
+ <UButton
1287
+ :icon="p.isUnattendedDefault ? 'i-lucide-bot' : 'i-lucide-bot-off'"
1288
+ :color="p.isUnattendedDefault ? 'info' : 'neutral'"
1289
+ variant="ghost"
1290
+ size="xs"
1291
+ :title="
1292
+ p.isUnattendedDefault
1293
+ ? t('pipeline.builder.scopeDefault.releaseUnattended')
1294
+ : t('pipeline.builder.scopeDefault.claimUnattended')
1295
+ "
1296
+ @click="toggleDefault(p, 'unattended')"
1297
+ />
1298
+ </template>
1276
1299
  <!-- Archive/unarchive: organize the library without deleting. Works on
1277
1300
  built-ins too (view metadata, not structure). -->
1278
1301
  <UButton
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
  import {
3
3
  findingAttention,
4
+ findingClass,
4
5
  orderFindings,
5
6
  reconcileFindingOrder,
6
7
  type FindingRecommendationState,
@@ -21,6 +22,7 @@ const item = (
21
22
  id: string,
22
23
  status: RequirementReviewItem['status'],
23
24
  severity: RequirementReviewItem['severity'] = 'medium',
25
+ autoAnswerable?: boolean,
24
26
  ): RequirementReviewItem => ({
25
27
  id,
26
28
  category: 'question',
@@ -29,6 +31,7 @@ const item = (
29
31
  detail: `detail for ${id}`,
30
32
  status,
31
33
  reply: null,
34
+ ...(autoAnswerable === undefined ? {} : { autoAnswerable }),
32
35
  createdAt: 0,
33
36
  updatedAt: 0,
34
37
  })
@@ -98,11 +101,37 @@ describe('orderFindings', () => {
98
101
  expect(order(items).map((entry) => entry.id)).toEqual(['low-open', 'high-handled'])
99
102
  })
100
103
 
101
- it('tags each entry with the bucket its position came from', () => {
104
+ it('tags each entry with the buckets its position came from', () => {
102
105
  const items = [item('a', 'open'), item('b', 'dismissed')]
103
106
  expect(order(items)).toEqual([
104
- { id: 'a', attention: 'action' },
105
- { id: 'b', attention: 'settled' },
107
+ { id: 'a', attention: 'action', group: 'judgement' },
108
+ { id: 'b', attention: 'settled', group: 'judgement' },
109
+ ])
110
+ })
111
+
112
+ // The GROUP is the primary key, ahead of attention: the two groups have different audiences, so
113
+ // each is its own list ordered by what is left in it, rather than one list interleaving work the
114
+ // reader owns with work the platform may already have answered.
115
+ it('puts the judgement group ahead of the practice group, settled or not', () => {
116
+ const items = [
117
+ item('practice-open', 'open', 'high', true),
118
+ item('judgement-settled', 'answered', 'low', false),
119
+ ]
120
+ expect(order(items).map((entry) => entry.id)).toEqual(['judgement-settled', 'practice-open'])
121
+ })
122
+
123
+ it('keeps attention ordering INSIDE each group', () => {
124
+ const items = [
125
+ item('practice-settled', 'answered', 'high', true),
126
+ item('practice-open', 'open', 'low', true),
127
+ item('judgement-settled', 'answered', 'high', false),
128
+ item('judgement-open', 'open', 'low', false),
129
+ ]
130
+ expect(order(items).map((entry) => entry.id)).toEqual([
131
+ 'judgement-open',
132
+ 'judgement-settled',
133
+ 'practice-open',
134
+ 'practice-settled',
106
135
  ])
107
136
  })
108
137
 
@@ -111,10 +140,23 @@ describe('orderFindings', () => {
111
140
  })
112
141
  })
113
142
 
143
+ describe('findingClass', () => {
144
+ it('reads the reviewer classification', () => {
145
+ expect(findingClass({ autoAnswerable: true })).toBe('practice')
146
+ expect(findingClass({ autoAnswerable: false })).toBe('judgement')
147
+ })
148
+
149
+ // An unclassified finding (a reviewer pass predating the flag, or a garbled reply) lands in the
150
+ // group that asks a person, matching how the contract and the engine both read it.
151
+ it('reads an unclassified finding as needing a person', () => {
152
+ expect(findingClass({})).toBe('judgement')
153
+ })
154
+ })
155
+
114
156
  describe('reconcileFindingOrder', () => {
115
157
  const desired: OrderedFinding[] = [
116
- { id: 'a', attention: 'action' },
117
- { id: 'b', attention: 'settled' },
158
+ { id: 'a', attention: 'action', group: 'judgement' },
159
+ { id: 'b', attention: 'settled', group: 'judgement' },
118
160
  ]
119
161
 
120
162
  it('falls back to the computed order when nothing is pinned', () => {
@@ -124,21 +166,21 @@ describe('reconcileFindingOrder', () => {
124
166
  it('holds the pinned order while it covers the same findings', () => {
125
167
  // `b` has since been answered and would now sink, but the pin keeps the list still.
126
168
  const pinned: OrderedFinding[] = [
127
- { id: 'b', attention: 'action' },
128
- { id: 'a', attention: 'action' },
169
+ { id: 'b', attention: 'action', group: 'judgement' },
170
+ { id: 'a', attention: 'action', group: 'judgement' },
129
171
  ]
130
172
  expect(reconcileFindingOrder(desired, pinned)).toBe(pinned)
131
173
  })
132
174
 
133
175
  it('drops a pin that no longer covers every finding, so a new one can never be hidden', () => {
134
- const pinned: OrderedFinding[] = [{ id: 'a', attention: 'action' }]
176
+ const pinned: OrderedFinding[] = [{ id: 'a', attention: 'action', group: 'judgement' }]
135
177
  expect(reconcileFindingOrder(desired, pinned)).toEqual(desired)
136
178
  })
137
179
 
138
180
  it('drops a pin naming a finding the review no longer has', () => {
139
181
  const pinned: OrderedFinding[] = [
140
- { id: 'a', attention: 'action' },
141
- { id: 'gone', attention: 'action' },
182
+ { id: 'a', attention: 'action', group: 'judgement' },
183
+ { id: 'gone', attention: 'action', group: 'judgement' },
142
184
  ]
143
185
  expect(reconcileFindingOrder(desired, pinned)).toEqual(desired)
144
186
  })
@@ -23,6 +23,32 @@ import type { RequirementReviewItem, ReviewItemSeverity } from '~/types/requirem
23
23
  */
24
24
  export type FindingAttention = 'action' | 'waiting' | 'settled'
25
25
 
26
+ /**
27
+ * WHO can answer a finding, which is the reviewer's own `autoAnswerable` judgement read as the two
28
+ * groups it sorts findings into.
29
+ *
30
+ * - `judgement` — it takes a business / product / domain decision, or information the reviewer was
31
+ * not given. Only a person can settle it, and a run nobody is watching parks on it.
32
+ * - `practice` — a confident answer follows from universal best practice, from the idiomatic
33
+ * approach of a stack the work already uses, or from the context already provided. The
34
+ * Requirement Writer pre-answers these, and an unattended run may adopt a sufficiently confident
35
+ * suggestion without waiting (see `reviewSettledForUnattended`).
36
+ *
37
+ * The two are the PRIMARY grouping in the window, ahead of how much attention a finding wants,
38
+ * because they are answered by different people rather than at different times: the value of
39
+ * seeing them apart is knowing which of them is yours.
40
+ */
41
+ export type FindingClass = 'judgement' | 'practice'
42
+
43
+ /**
44
+ * Which group a finding is in. ABSENT `autoAnswerable` reads as `judgement`, matching the contract's
45
+ * own reading of an unclassified finding (a pass that predates the flag, or a garbled reply): the
46
+ * safe direction is the one that asks a person.
47
+ */
48
+ export function findingClass(item: Pick<RequirementReviewItem, 'autoAnswerable'>): FindingClass {
49
+ return item.autoAnswerable === true ? 'practice' : 'judgement'
50
+ }
51
+
26
52
  /** What the review's recommendation collection currently holds for one finding. */
27
53
  export interface FindingRecommendationState {
28
54
  /** A requested suggestion is still being generated by the Writer. */
@@ -31,13 +57,17 @@ export interface FindingRecommendationState {
31
57
  ready: boolean
32
58
  }
33
59
 
34
- /** One finding in the rendered order, tagged with the bucket its position came from. */
60
+ /** One finding in the rendered order, tagged with the buckets its position came from. */
35
61
  export interface OrderedFinding {
36
62
  id: string
37
63
  attention: FindingAttention
64
+ group: FindingClass
38
65
  }
39
66
 
40
67
  const ATTENTION_RANK: Record<FindingAttention, number> = { action: 0, waiting: 1, settled: 2 }
68
+ // `judgement` first: it is the group that always needs the person reading this, whatever state its
69
+ // findings are in, and the practice group is the one the platform may have already answered.
70
+ const CLASS_RANK: Record<FindingClass, number> = { judgement: 0, practice: 1 }
41
71
  const SEVERITY_RANK: Record<ReviewItemSeverity, number> = { high: 0, medium: 1, low: 2 }
42
72
 
43
73
  /**
@@ -60,10 +90,16 @@ export function findingAttention(
60
90
  }
61
91
 
62
92
  /**
63
- * The findings in the order the window should render them: unreacted-to first, then whatever the
64
- * Writer is still working on, then everything already handled. Severity stays the secondary key
65
- * (the order the window has always used within a bucket), and the reviewer's own ordering breaks
66
- * the remaining ties so the sort is total and stable.
93
+ * The findings in the order the window should render them: the two GROUPS first (the ones only a
94
+ * person can decide, then the ones practice can answer), and within each of them unreacted-to
95
+ * first, then whatever the Writer is still working on, then everything already handled. Severity
96
+ * stays the next key (the order the window has always used within a bucket), and the reviewer's own
97
+ * ordering breaks the remaining ties so the sort is total and stable.
98
+ *
99
+ * Group ahead of attention, which is a change of primary key rather than an extra tiebreak: a
100
+ * settled judgement finding now sorts above an open practice one. That is the intended reading —
101
+ * the groups are two lists with different audiences, and each is internally ordered by what is
102
+ * left to do in it, rather than one list interleaving work the reader owns with work they do not.
67
103
  */
68
104
  export function orderFindings(
69
105
  items: readonly RequirementReviewItem[],
@@ -73,16 +109,18 @@ export function orderFindings(
73
109
  .map((item, index) => ({
74
110
  id: item.id,
75
111
  attention: findingAttention(item, recommendationFor(item)),
112
+ group: findingClass(item),
76
113
  severity: item.severity,
77
114
  index,
78
115
  }))
79
116
  .sort(
80
117
  (a, b) =>
118
+ CLASS_RANK[a.group] - CLASS_RANK[b.group] ||
81
119
  ATTENTION_RANK[a.attention] - ATTENTION_RANK[b.attention] ||
82
120
  SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] ||
83
121
  a.index - b.index,
84
122
  )
85
- .map(({ id, attention }) => ({ id, attention }))
123
+ .map(({ id, attention, group }) => ({ id, attention, group }))
86
124
  }
87
125
 
88
126
  /**
@@ -14,8 +14,11 @@ import {
14
14
  orderFindings,
15
15
  reconcileFindingOrder,
16
16
  type FindingAttention,
17
+ type FindingClass,
17
18
  type OrderedFinding,
18
19
  } from './RequirementsReviewWindow.logic'
20
+ import { recommendationConfidenceBand } from '@cat-factory/contracts'
21
+ import type { RecommendationConfidenceBand } from '@cat-factory/contracts'
19
22
  import type {
20
23
  RecommendationSource,
21
24
  RequirementRecommendation,
@@ -191,6 +194,49 @@ const STATUS_LABELS = computed<Record<ReviewItemStatus, string>>(() => ({
191
194
  recommend_requested: t('requirements.itemStatus.recommend_requested'),
192
195
  }))
193
196
 
197
+ // The two GROUPS the reviewer sorts its findings into, which is the window's top-level structure:
198
+ // what only this person can decide, and what practice can answer. Each section says what its group
199
+ // IS rather than only naming it, because the distinction is what tells the reader which half of the
200
+ // list is theirs — and, on an unwatched run, which half the platform may answer without them.
201
+ const CLASS_LABELS = computed<Record<FindingClass, string>>(() => ({
202
+ judgement: t('requirements.findingClass.judgement'),
203
+ practice: t('requirements.findingClass.practice'),
204
+ }))
205
+ const CLASS_HINTS = computed<Record<FindingClass, string>>(() => ({
206
+ judgement: t('requirements.findingClass.judgementHint'),
207
+ practice: t('requirements.findingClass.practiceHint'),
208
+ }))
209
+ const CLASS_LABEL_COLOR = {
210
+ judgement: 'text-amber-300',
211
+ practice: 'text-sky-300',
212
+ } as const satisfies Record<FindingClass, string>
213
+
214
+ // How sure the Writer says it is. Shown on every suggestion, because the confidence is what an
215
+ // unattended run compares against its policy floor: a reader deciding whether to keep a
216
+ // pre-filled answer is looking at the same number the platform used to decide not to ask them.
217
+ // A suggestion the Writer did not grade renders NO badge rather than a "low" one — unreported and
218
+ // unsure are different facts (see `recommendationConfidenceBand`).
219
+ const CONFIDENCE_COLOR = {
220
+ high: 'success',
221
+ medium: 'warning',
222
+ low: 'error',
223
+ } as const satisfies Record<RecommendationConfidenceBand, string>
224
+ const CONFIDENCE_LABELS = computed<Record<RecommendationConfidenceBand, string>>(() => ({
225
+ high: t('requirements.confidence.high'),
226
+ medium: t('requirements.confidence.medium'),
227
+ low: t('requirements.confidence.low'),
228
+ }))
229
+ /** The band a recommendation's grade falls in, or null when it reported none. */
230
+ function confidenceBandOf(
231
+ rec: RequirementRecommendation | undefined,
232
+ ): RecommendationConfidenceBand | null {
233
+ return rec ? recommendationConfidenceBand(rec.confidence) : null
234
+ }
235
+ /** The grade as a percentage for the badge's tooltip, or null when ungraded. */
236
+ function confidencePercent(rec: RequirementRecommendation | undefined): string | null {
237
+ return rec?.confidence == null ? null : `${Math.round(rec.confidence * 100)}%`
238
+ }
239
+
194
240
  // Answers auto-save: there is no explicit "save" button. The textarea is pre-seeded with
195
241
  // the recorded reply (see the watch below); editing and blurring persists it. Persist only
196
242
  // when the trimmed draft actually differs from what's already recorded, so blurring an
@@ -365,24 +411,37 @@ watch(
365
411
  },
366
412
  { immediate: true },
367
413
  )
368
- const orderedFindings = computed<{ item: RequirementReviewItem; attention: FindingAttention }[]>(
369
- () => {
370
- const byId = new Map((review.value?.items ?? []).map((item) => [item.id, item]))
371
- return reconcileFindingOrder(desiredOrder.value, pinnedOrder.value).flatMap((entry) => {
372
- const item = byId.get(entry.id)
373
- return item ? [{ item, attention: entry.attention }] : []
374
- })
375
- },
376
- )
377
- // Label the buckets only once the list actually spans more than one on a fresh review every
378
- // finding is outstanding, and a lone "Needs your reaction" heading over all of them is noise.
379
- const attentionGroupsShown = computed(
380
- () => new Set(orderedFindings.value.map((entry) => entry.attention)).size > 1,
381
- )
414
+ const orderedFindings = computed<
415
+ { item: RequirementReviewItem; attention: FindingAttention; group: FindingClass }[]
416
+ >(() => {
417
+ const byId = new Map((review.value?.items ?? []).map((item) => [item.id, item]))
418
+ return reconcileFindingOrder(desiredOrder.value, pinnedOrder.value).flatMap((entry) => {
419
+ const item = byId.get(entry.id)
420
+ return item ? [{ item, attention: entry.attention, group: entry.group }] : []
421
+ })
422
+ })
423
+ // The GROUP heading is shown whenever the list spans both groups, which is the point of the split:
424
+ // a reader has to be able to see where "yours to decide" ends. A review entirely in one group needs
425
+ // no heading, because then the whole list is that group.
426
+ function startsFindingGroup(index: number): boolean {
427
+ const entries = orderedFindings.value
428
+ if (new Set(entries.map((entry) => entry.group)).size < 2) return false
429
+ return index === 0 || entries[index - 1]?.group !== entries[index]?.group
430
+ }
431
+ // The attention sub-heading is shown only inside a group that spans more than one bucket, so the
432
+ // two levels of heading cannot both appear on a review where they would say the same thing (a fresh
433
+ // review is all outstanding; a pre-answered practice group is all settled).
382
434
  function startsAttentionGroup(index: number): boolean {
383
- if (!attentionGroupsShown.value) return false
384
435
  const entries = orderedFindings.value
385
- return index === 0 || entries[index - 1]?.attention !== entries[index]?.attention
436
+ const here = entries[index]
437
+ if (!here) return false
438
+ if (entries.filter((entry) => entry.group === here.group).length < 2) return false
439
+ const spansBuckets =
440
+ new Set(entries.filter((entry) => entry.group === here.group).map((entry) => entry.attention))
441
+ .size > 1
442
+ if (!spansBuckets) return false
443
+ const previous = entries[index - 1]
444
+ return !previous || previous.group !== here.group || previous.attention !== here.attention
386
445
  }
387
446
  const ATTENTION_LABELS = computed<Record<FindingAttention, string>>(() => ({
388
447
  action: t('requirements.group.action'),
@@ -740,7 +799,20 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
740
799
  @focusin="onFindingFocusIn"
741
800
  @focusout="onFindingFocusOut"
742
801
  >
743
- <template v-for="({ item, attention }, index) in orderedFindings" :key="item.id">
802
+ <template v-for="({ item, attention, group }, index) in orderedFindings" :key="item.id">
803
+ <div v-if="startsFindingGroup(index)" class="pt-2" data-testid="requirements-group">
804
+ <div class="flex items-center gap-2">
805
+ <span
806
+ class="text-xs font-semibold uppercase tracking-wide"
807
+ :class="CLASS_LABEL_COLOR[group]"
808
+ :data-finding-group="group"
809
+ >
810
+ {{ CLASS_LABELS[group] }}
811
+ </span>
812
+ <span class="h-px flex-1 bg-slate-700" />
813
+ </div>
814
+ <p class="mt-0.5 text-[11px] text-slate-500">{{ CLASS_HINTS[group] }}</p>
815
+ </div>
744
816
  <div v-if="startsAttentionGroup(index)" class="flex items-center gap-2 pt-1">
745
817
  <span
746
818
  class="text-[11px] font-semibold uppercase tracking-wide"
@@ -869,6 +941,20 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
869
941
  >
870
942
  {{ GROUNDING_LABELS[autoDefaults.get(item.id)!.groundedIn!] }}
871
943
  </UBadge>
944
+ <!-- The Writer's own grade, which is a different question from where the
945
+ answer came from: it is what an unwatched run compares against its
946
+ policy floor, so it is also what tells a reader how hard this
947
+ pre-filled answer was to be sure of. -->
948
+ <UBadge
949
+ v-if="confidenceBandOf(autoDefaults.get(item.id))"
950
+ size="xs"
951
+ variant="outline"
952
+ :color="CONFIDENCE_COLOR[confidenceBandOf(autoDefaults.get(item.id))!]"
953
+ :title="confidencePercent(autoDefaults.get(item.id)) ?? undefined"
954
+ data-testid="requirements-confidence"
955
+ >
956
+ {{ CONFIDENCE_LABELS[confidenceBandOf(autoDefaults.get(item.id))!] }}
957
+ </UBadge>
872
958
  </div>
873
959
  <UTextarea
874
960
  v-model="drafts[item.id]"
@@ -927,6 +1013,17 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
927
1013
  >
928
1014
  {{ GROUNDING_LABELS[rec.groundedIn] }}
929
1015
  </UBadge>
1016
+ <UBadge
1017
+ v-if="confidenceBandOf(rec)"
1018
+ size="xs"
1019
+ variant="outline"
1020
+ class="ms-1.5"
1021
+ :color="CONFIDENCE_COLOR[confidenceBandOf(rec)!]"
1022
+ :title="confidencePercent(rec) ?? undefined"
1023
+ data-testid="requirements-confidence"
1024
+ >
1025
+ {{ CONFIDENCE_LABELS[confidenceBandOf(rec)!] }}
1026
+ </UBadge>
930
1027
  <!-- The Writer's suggested answer — agent prose, so it takes the
931
1028
  measure like the finding's own question above it. -->
932
1029
  <p class="mt-1 max-w-3xl whitespace-pre-line text-sm text-slate-300">
@@ -88,6 +88,10 @@ interface Draft {
88
88
  // up, rather than stopping for a person. Edited as a switch because the vocabulary is two-valued
89
89
  // and the OFF state is the historical behaviour.
90
90
  unattended: boolean
91
+ // The confidence floor an unattended run's auto-answered requirements finding must clear, as a
92
+ // PERCENT (the numbers above are edited the same way). Only read while `unattended` is on, which
93
+ // is why the field is rendered inside that block rather than beside the other budgets.
94
+ minAutoAnswerConfidence: number
91
95
  // Per-change-class auto-merge rules. An OMITTED class means "use the score ceilings above",
92
96
  // so `{}` is the identity — the editor stores `thresholds` as an omission for that reason.
93
97
  classRules: MergeClassRules
@@ -136,6 +140,7 @@ function toDraft(p: RiskPolicy): Draft {
136
140
  maxRequirementConcernAllowed: p.maxRequirementConcernAllowed,
137
141
  autoMergeEnabled: p.autoMergeEnabled,
138
142
  unattended: p.autonomy === 'unattended',
143
+ minAutoAnswerConfidence: Math.round(p.minAutoAnswerConfidence * 100),
139
144
  classRules: { ...p.classRules },
140
145
  classRulesByRole: { ...p.classRulesByRole },
141
146
  dryRunRoles: [...p.dryRunRoles],
@@ -196,6 +201,7 @@ async function save(p: RiskPolicy) {
196
201
  maxRequirementConcernAllowed: d.maxRequirementConcernAllowed,
197
202
  autoMergeEnabled: d.autoMergeEnabled,
198
203
  autonomy: d.unattended ? 'unattended' : 'attended',
204
+ minAutoAnswerConfidence: d.minAutoAnswerConfidence / 100,
199
205
  classRules: d.classRules,
200
206
  classRulesByRole: d.classRulesByRole,
201
207
  dryRunRoles: d.dryRunRoles,
@@ -277,6 +283,7 @@ const draft = reactive<Draft>({
277
283
  // A new policy parks on its own caps, matching every built-in but the unattended default: a
278
284
  // licence to answer them is a posture somebody grants, never one a blank form assumes.
279
285
  unattended: false,
286
+ minAutoAnswerConfidence: 80,
280
287
  // The create row authors the numbers only. Class and role rules start at their identity and
281
288
  // are edited on the saved preset, where each rule can be shown beside the base rule (and the
282
289
  // track record) it narrows — neither reads as anything on a policy that does not exist yet.
@@ -305,6 +312,7 @@ async function create() {
305
312
  maxRequirementConcernAllowed: draft.maxRequirementConcernAllowed,
306
313
  autoMergeEnabled: draft.autoMergeEnabled,
307
314
  autonomy: draft.unattended ? 'unattended' : 'attended',
315
+ minAutoAnswerConfidence: draft.minAutoAnswerConfidence / 100,
308
316
  classRules: draft.classRules,
309
317
  forkDecision: forkGating(draft),
310
318
  })
@@ -520,6 +528,25 @@ async function create() {
520
528
  : t('settings.riskPolicy.autonomy.attendedHint')
521
529
  "
522
530
  />
531
+ <!-- Shown only while the posture is on, because that is the only state that reads it: a
532
+ floor on an attended policy would be a control over a decision this policy never makes.
533
+ It is not hidden as an "advanced override" — it is inert, which is a different thing. -->
534
+ <label v-if="drafts[p.id]!.unattended" class="mt-3 block">
535
+ <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
536
+ {{ t('settings.riskPolicy.autoAnswer.label') }}
537
+ </span>
538
+ <UInput
539
+ v-model.number="drafts[p.id]!.minAutoAnswerConfidence"
540
+ type="number"
541
+ min="0"
542
+ max="100"
543
+ size="sm"
544
+ data-testid="risk-policy-auto-answer-floor"
545
+ />
546
+ <span class="mt-1 block text-[11px] text-slate-500">
547
+ {{ t('settings.riskPolicy.autoAnswer.hint') }}
548
+ </span>
549
+ </label>
523
550
  </div>
524
551
 
525
552
  <div class="mt-3 flex items-center justify-between gap-3">