@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.
- package/app/components/board/AddTaskModal.vue +3 -0
- package/app/components/board/nodes/TaskCard.vue +7 -1
- package/app/components/pipeline/PipelineBuilder.vue +62 -39
- package/app/components/requirements/RequirementsReviewWindow.logic.spec.ts +52 -10
- package/app/components/requirements/RequirementsReviewWindow.logic.ts +44 -6
- package/app/components/requirements/RequirementsReviewWindow.vue +114 -17
- package/app/components/settings/RiskPolicyPanel.vue +27 -0
- package/app/composables/usePipelineLibraryActions.ts +82 -0
- package/app/stores/pipelines/persistence.ts +32 -2
- package/app/stores/pipelines.ts +25 -1
- package/i18n/locales/de.json +26 -1
- package/i18n/locales/en.json +26 -1
- package/i18n/locales/es.json +26 -1
- package/i18n/locales/fr.json +26 -1
- package/i18n/locales/he.json +26 -1
- package/i18n/locales/it.json +26 -1
- package/i18n/locales/ja.json +26 -1
- package/i18n/locales/pl.json +26 -1
- package/i18n/locales/tr.json +26 -1
- package/i18n/locales/uk.json +26 -1
- package/package.json +2 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
|
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
|
|
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:
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* the
|
|
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<
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
)
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
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">
|