@cat-factory/app 0.267.0 → 0.268.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.
@@ -1,325 +1,116 @@
1
1
  <script setup lang="ts">
2
- // Workspace settings: the merge-threshold preset library a task picks its
3
- // auto-merge policy from (the `merger` step compares a PR's assessment against the
4
- // resolved preset). Full CRUD over the riskPolicies store — the same library the
5
- // task inspector's "Merge policy" dropdown selects from. Exactly one preset is the
6
- // default; it cannot be deleted or un-defaulted (the backend enforces this too).
7
- import { computed, reactive, ref, watch } from 'vue'
8
- import type {
9
- ClassRulesByRole,
10
- DryRunRoles,
11
- MergeClassRules,
12
- RiskPolicy,
13
- RequirementConcernLevel,
14
- SubmissionClassesByRole,
15
- } from '~/types/merge'
16
- import type { StepGating } from '@cat-factory/contracts'
17
- import {
18
- RISK_POLICY_AXES,
19
- RISK_POLICY_CEILING_FIELD,
20
- type RiskPolicyAxis,
21
- } from '~/utils/riskPolicy'
22
- import MergeClassRulesEditor from '~/components/settings/MergeClassRulesEditor.vue'
23
- import MergeRolePolicyEditor from '~/components/settings/MergeRolePolicyEditor.vue'
2
+ // Workspace settings: the risk policy library a task picks its auto-merge policy from (the `merger`
3
+ // step compares a PR's assessment against the resolved policy).
4
+ //
5
+ // Since ADR 0055 the library has two tiers. The board's OWN policies are fully editable here; the
6
+ // ones it INHERITS from its account are read-only, with the two actions a board has over a row it
7
+ // does not own (clone it in, or hide it). A third section lists what is currently hidden, because a
8
+ // hidden policy is by construction absent from the library above and would otherwise be a one-way
9
+ // door.
10
+ import { computed, onMounted, ref } from 'vue'
11
+ import type { RiskPolicyLibraryEntry, UpdateRiskPolicyInput } from '~/types/merge'
12
+ import { riskPolicyCopyName } from '~/utils/riskPolicy'
13
+ import RiskPolicyCreateForm from '~/components/settings/RiskPolicyCreateForm.vue'
14
+ import RiskPolicyEditorRow from '~/components/settings/RiskPolicyEditorRow.vue'
15
+ import RiskPolicyInheritedRow from '~/components/settings/RiskPolicyInheritedRow.vue'
24
16
 
25
17
  const { t } = useI18n()
26
18
 
27
- // Both axis groups below iterate the SHARED presentation order rather than hard-coding one,
28
- // so this editor can't drift from the order the picker's preview and the inspector's summary
29
- // line use (it used to read complexity-first while they read risk-first).
30
- const CEILING_LABEL_KEYS: Record<RiskPolicyAxis, string> = {
31
- risk: 'settings.riskPolicy.field.maxRisk',
32
- impact: 'settings.riskPolicy.field.maxImpact',
33
- complexity: 'settings.riskPolicy.field.maxComplexity',
34
- }
35
-
36
- // The fork-decision group is the same three axes read as FLOORS (how big an estimate has to
37
- // be before the coder stops to propose implementations), so it shares the order but not the
38
- // fields.
39
- const FORK_FLOOR_FIELD: Record<
40
- RiskPolicyAxis,
41
- 'forkMinRisk' | 'forkMinImpact' | 'forkMinComplexity'
42
- > = {
43
- risk: 'forkMinRisk',
44
- impact: 'forkMinImpact',
45
- complexity: 'forkMinComplexity',
46
- }
47
- const FORK_FLOOR_LABEL_KEYS: Record<RiskPolicyAxis, string> = {
48
- risk: 'settings.riskPolicy.forkDecision.minRisk',
49
- impact: 'settings.riskPolicy.forkDecision.minImpact',
50
- complexity: 'settings.riskPolicy.forkDecision.minComplexity',
51
- }
52
-
53
- // Per-concern-level label. An exhaustive Record keyed off the union (a missing member fails
54
- // the typecheck); each value is a LITERAL catalog key so the typed-message-keys check sees
55
- // it. Leaf keys mirror the enum value verbatim.
56
- const CONCERN_LABEL_KEYS: Record<RequirementConcernLevel, string> = {
57
- none: 'settings.riskPolicy.concern.none',
58
- low: 'settings.riskPolicy.concern.low',
59
- medium: 'settings.riskPolicy.concern.medium',
60
- high: 'settings.riskPolicy.concern.high',
61
- }
62
-
63
- // Concern-level options for the requirements auto-pass threshold (none < low < medium < high).
64
- const CONCERN_LEVELS = computed<{ value: RequirementConcernLevel; label: string }[]>(() => [
65
- { value: 'none', label: t(CONCERN_LABEL_KEYS.none) },
66
- { value: 'low', label: t(CONCERN_LABEL_KEYS.low) },
67
- { value: 'medium', label: t(CONCERN_LABEL_KEYS.medium) },
68
- { value: 'high', label: t(CONCERN_LABEL_KEYS.high) },
69
- ])
70
-
71
19
  const store = useRiskPoliciesStore()
72
20
  const toast = useToast()
73
21
  const { present } = usePipelineErrorToast()
74
22
  const { confirm } = useConfirm()
75
23
 
76
- // Local editable copy per preset, kept in sync with the store. Percentages are
77
- // edited 0..100 and stored 0..1.
78
- interface Draft {
79
- name: string
80
- maxComplexity: number
81
- maxRisk: number
82
- maxImpact: number
83
- ciMaxAttempts: number
84
- maxRequirementIterations: number
85
- maxRequirementConcernAllowed: RequirementConcernLevel
86
- autoMergeEnabled: boolean
87
- // Whether a run under this policy answers the parks its own automatic loops raise when they give
88
- // up, rather than stopping for a person. Edited as a switch because the vocabulary is two-valued
89
- // and the OFF state is the historical behaviour.
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
95
- // Per-change-class auto-merge rules. An OMITTED class means "use the score ceilings above",
96
- // so `{}` is the identity — the editor stores `thresholds` as an omission for that reason.
97
- classRules: MergeClassRules
98
- // The ROLE layer over those rules: per-role narrowing (narrow-only, so `{}` is the identity)
99
- // and the roles whose runs are sandboxed. Both replace the stored value wholesale on save,
100
- // which is why clearing one in the editor is a plain omission.
101
- classRulesByRole: ClassRulesByRole
102
- dryRunRoles: DryRunRoles
103
- // Which classes each role may LAND at all. A role with no entry is unrestricted, so `{}` is the
104
- // identity; an entry with no classes is the different policy that the role lands nothing.
105
- submissionClassesByRole: SubmissionClassesByRole
106
- // Implementation-fork decision gating (edited 0..100, stored 0..1); disabled ⇒ off in `auto`.
107
- forkEnabled: boolean
108
- forkMinComplexity: number
109
- forkMinRisk: number
110
- forkMinImpact: number
111
- forkOnMissing: 'run' | 'skip'
112
- }
113
- const drafts = reactive<Record<string, Draft>>({})
114
-
115
- // On-missing-estimate options for the fork gating group (fail toward asking / skipping).
116
- const ON_MISSING_OPTIONS = computed<{ value: 'run' | 'skip'; label: string }[]>(() => [
117
- { value: 'run', label: t('settings.riskPolicy.forkDecision.onMissing.run') },
118
- { value: 'skip', label: t('settings.riskPolicy.forkDecision.onMissing.skip') },
119
- ])
120
-
121
- /** Build the `StepGating` payload for the fork-decision gate from a draft (or null when off). */
122
- function forkGating(d: Draft): StepGating {
123
- return {
124
- enabled: d.forkEnabled,
125
- minComplexity: d.forkMinComplexity / 100,
126
- minRisk: d.forkMinRisk / 100,
127
- minImpact: d.forkMinImpact / 100,
128
- onMissingEstimate: d.forkOnMissing,
129
- }
130
- }
131
-
132
- function toDraft(p: RiskPolicy): Draft {
133
- return {
134
- name: p.name,
135
- maxComplexity: Math.round(p.maxComplexity * 100),
136
- maxRisk: Math.round(p.maxRisk * 100),
137
- maxImpact: Math.round(p.maxImpact * 100),
138
- ciMaxAttempts: p.ciMaxAttempts,
139
- maxRequirementIterations: p.maxRequirementIterations,
140
- maxRequirementConcernAllowed: p.maxRequirementConcernAllowed,
141
- autoMergeEnabled: p.autoMergeEnabled,
142
- unattended: p.autonomy === 'unattended',
143
- minAutoAnswerConfidence: Math.round(p.minAutoAnswerConfidence * 100),
144
- classRules: { ...p.classRules },
145
- classRulesByRole: { ...p.classRulesByRole },
146
- dryRunRoles: [...p.dryRunRoles],
147
- submissionClassesByRole: { ...p.submissionClassesByRole },
148
- forkEnabled: p.forkDecision?.enabled ?? false,
149
- forkMinComplexity: Math.round((p.forkDecision?.minComplexity ?? 0.5) * 100),
150
- forkMinRisk: Math.round((p.forkDecision?.minRisk ?? 0.4) * 100),
151
- forkMinImpact: Math.round((p.forkDecision?.minImpact ?? 0.4) * 100),
152
- forkOnMissing: p.forkDecision?.onMissingEstimate ?? 'run',
153
- }
154
- }
155
-
156
- watch(
157
- () => store.presets,
158
- (presets) => {
159
- for (const p of presets) if (!drafts[p.id]) drafts[p.id] = toDraft(p)
160
- for (const id of Object.keys(drafts)) if (!presets.some((p) => p.id === id)) delete drafts[id]
161
- },
162
- { immediate: true, deep: false },
163
- )
164
-
165
24
  /**
166
25
  * Which single control is mid-request, keyed `<policyId>[:<action>]`.
167
26
  *
168
- * Per ACTION and not merely per policy, because a row now carries two independent promote
169
- * buttons: keyed by policy alone, promoting the in-app default spun the unattended button too and
170
- * told the operator a change they had not asked for was in flight.
27
+ * Per ACTION and not merely per policy, because a row carries several independent buttons: keyed by
28
+ * policy alone, promoting the in-app default spun the unattended button too and told the operator a
29
+ * change they had not asked for was in flight.
171
30
  */
172
31
  const busy = ref<string | null>(null)
32
+ const creating = ref(false)
33
+
34
+ const own = computed(() => store.presets.filter((p) => p.tier === 'workspace'))
35
+ const inherited = computed(() => store.presets.filter((p) => p.tier === 'account'))
173
36
 
174
37
  /**
175
- * Why the delete button is disabled, naming the flag that actually blocks it.
38
+ * Whether the hidden-policy read FAILED, as opposed to answering nothing.
176
39
  *
177
- * The two are promoted by DIFFERENT buttons, so collapsing them into one "promote another preset
178
- * first" message sends an operator to re-point the in-app default and come back to a delete that
179
- * is still refused, with nothing on screen saying why.
40
+ * The two must not render the same. An empty list means the board hides nothing; a failed read means
41
+ * it may be hiding policies this panel cannot show, and silence there would leave an operator who
42
+ * just hid one looking for it in a section that claims to be complete. Not a toast, because this
43
+ * fires on open and only powers an undo affordance: a line in the section itself is the honest place.
180
44
  */
181
- function deleteBlockedReason(p: RiskPolicy): string {
182
- if (p.isDefault && p.isUnattendedDefault)
183
- return t('settings.riskPolicy.deleteBothDefaultsBlocked')
184
- if (p.isDefault) return t('settings.riskPolicy.deleteDefaultBlocked')
185
- if (p.isUnattendedDefault) return t('settings.riskPolicy.deleteUnattendedDefaultBlocked')
186
- return t('settings.riskPolicy.deletePreset')
187
- }
45
+ const suppressionsFailed = ref(false)
188
46
 
189
- async function save(p: RiskPolicy) {
190
- const d = drafts[p.id]
191
- if (!d) return
192
- busy.value = p.id
47
+ // What the board hides is not in the snapshot (a hidden policy is absent from the library by
48
+ // construction), so it is read when the panel opens.
49
+ onMounted(() => {
50
+ void store
51
+ .loadSuppressions()
52
+ .then(() => (suppressionsFailed.value = false))
53
+ .catch(() => (suppressionsFailed.value = true))
54
+ })
55
+
56
+ /** Run one guarded action under a busy key, reporting a failure through the shared funnel. */
57
+ async function run(key: string, titleKey: string, action: () => Promise<unknown>) {
58
+ busy.value = key
193
59
  try {
194
- await store.update(p.id, {
195
- name: d.name.trim() || p.name,
196
- maxComplexity: d.maxComplexity / 100,
197
- maxRisk: d.maxRisk / 100,
198
- maxImpact: d.maxImpact / 100,
199
- ciMaxAttempts: d.ciMaxAttempts,
200
- maxRequirementIterations: d.maxRequirementIterations,
201
- maxRequirementConcernAllowed: d.maxRequirementConcernAllowed,
202
- autoMergeEnabled: d.autoMergeEnabled,
203
- autonomy: d.unattended ? 'unattended' : 'attended',
204
- minAutoAnswerConfidence: d.minAutoAnswerConfidence / 100,
205
- classRules: d.classRules,
206
- classRulesByRole: d.classRulesByRole,
207
- dryRunRoles: d.dryRunRoles,
208
- submissionClassesByRole: d.submissionClassesByRole,
209
- forkDecision: forkGating(d),
210
- })
60
+ await action()
61
+ } catch (e) {
62
+ present(e, titleKey)
63
+ } finally {
64
+ busy.value = null
65
+ }
66
+ }
67
+
68
+ async function save(policy: RiskPolicyLibraryEntry, patch: UpdateRiskPolicyInput) {
69
+ await run(policy.id, 'settings.riskPolicy.toast.saveFailed', async () => {
70
+ await store.update(policy.id, patch)
211
71
  toast.add({
212
72
  title: t('settings.riskPolicy.toast.saved'),
213
73
  icon: 'i-lucide-check',
214
74
  color: 'success',
215
75
  })
216
- } catch (e) {
217
- present(e, 'settings.riskPolicy.toast.saveFailed')
218
- } finally {
219
- busy.value = null
220
- }
76
+ })
221
77
  }
222
78
 
223
- async function makeDefault(p: RiskPolicy) {
224
- busy.value = `${p.id}:default`
225
- try {
226
- await store.update(p.id, { isDefault: true })
227
- } catch (e) {
228
- present(e, 'settings.riskPolicy.toast.defaultFailed')
229
- } finally {
230
- busy.value = null
231
- }
79
+ function makeDefault(policy: RiskPolicyLibraryEntry) {
80
+ return run(`${policy.id}:default`, 'settings.riskPolicy.toast.defaultFailed', () =>
81
+ store.update(policy.id, { isDefault: true }),
82
+ )
232
83
  }
233
84
 
234
85
  /**
235
- * Promote this policy to the UNATTENDED default: the one a task that pins none resolves when
236
- * nothing is watching the run (a start over the public API, a tracker dispatch, a schedule fire).
86
+ * Promote this policy to the UNATTENDED default: the one a task that pins none resolves when nothing
87
+ * is watching the run (a start over the public API, a tracker dispatch, a schedule fire).
237
88
  *
238
89
  * Its own action rather than a second meaning for the button above, because the two defaults are
239
- * independent: a board can run one posture in the app and another for the work it never sees, and
240
- * flagging one policy both ways is a deliberate choice rather than the only option.
90
+ * independent: a board can run one posture in the app and another for the work it never sees.
241
91
  */
242
- async function makeUnattendedDefault(p: RiskPolicy) {
243
- busy.value = `${p.id}:unattended`
244
- try {
245
- await store.update(p.id, { isUnattendedDefault: true })
246
- } catch (e) {
247
- present(e, 'settings.riskPolicy.toast.defaultFailed')
248
- } finally {
249
- busy.value = null
250
- }
92
+ function makeUnattendedDefault(policy: RiskPolicyLibraryEntry) {
93
+ return run(`${policy.id}:unattended`, 'settings.riskPolicy.toast.defaultFailed', () =>
94
+ store.update(policy.id, { isUnattendedDefault: true }),
95
+ )
251
96
  }
252
97
 
253
- async function remove(p: RiskPolicy) {
98
+ async function remove(policy: RiskPolicyLibraryEntry) {
254
99
  const ok = await confirm({
255
100
  title: t('settings.riskPolicy.confirmDelete.title'),
256
- description: t('settings.riskPolicy.confirmDelete.body', { name: p.name }),
101
+ description: t('settings.riskPolicy.confirmDelete.body', { name: policy.name }),
257
102
  variant: 'destructive',
258
103
  confirmLabel: t('common.delete'),
259
104
  icon: 'i-lucide-trash-2',
260
105
  })
261
106
  if (!ok) return
262
- busy.value = p.id
263
- try {
264
- await store.remove(p.id)
265
- } catch (e) {
266
- present(e, 'settings.riskPolicy.toast.deleteFailed')
267
- } finally {
268
- busy.value = null
269
- }
107
+ await run(policy.id, 'settings.riskPolicy.toast.deleteFailed', () => store.remove(policy.id))
270
108
  }
271
109
 
272
- // ---- create form ----------------------------------------------------------
273
- const creating = ref(false)
274
- const draft = reactive<Draft>({
275
- name: '',
276
- maxComplexity: 50,
277
- maxRisk: 40,
278
- maxImpact: 50,
279
- ciMaxAttempts: 10,
280
- maxRequirementIterations: 6,
281
- maxRequirementConcernAllowed: 'none',
282
- autoMergeEnabled: true,
283
- // A new policy parks on its own caps, matching every built-in but the unattended default: a
284
- // licence to answer them is a posture somebody grants, never one a blank form assumes.
285
- unattended: false,
286
- minAutoAnswerConfidence: 80,
287
- // The create row authors the numbers only. Class and role rules start at their identity and
288
- // are edited on the saved preset, where each rule can be shown beside the base rule (and the
289
- // track record) it narrows — neither reads as anything on a policy that does not exist yet.
290
- classRules: {},
291
- classRulesByRole: {},
292
- dryRunRoles: [],
293
- submissionClassesByRole: {},
294
- forkEnabled: false,
295
- forkMinComplexity: 50,
296
- forkMinRisk: 40,
297
- forkMinImpact: 40,
298
- forkOnMissing: 'run',
299
- })
300
-
301
- async function create() {
302
- if (!draft.name.trim()) return
110
+ async function create(input: Parameters<typeof store.create>[0]) {
303
111
  creating.value = true
304
112
  try {
305
- await store.create({
306
- name: draft.name.trim(),
307
- maxComplexity: draft.maxComplexity / 100,
308
- maxRisk: draft.maxRisk / 100,
309
- maxImpact: draft.maxImpact / 100,
310
- ciMaxAttempts: draft.ciMaxAttempts,
311
- maxRequirementIterations: draft.maxRequirementIterations,
312
- maxRequirementConcernAllowed: draft.maxRequirementConcernAllowed,
313
- autoMergeEnabled: draft.autoMergeEnabled,
314
- autonomy: draft.unattended ? 'unattended' : 'attended',
315
- minAutoAnswerConfidence: draft.minAutoAnswerConfidence / 100,
316
- classRules: draft.classRules,
317
- forkDecision: forkGating(draft),
318
- })
319
- draft.name = ''
320
- draft.autoMergeEnabled = true
321
- draft.unattended = false
322
- draft.classRules = {}
113
+ await store.create(input)
323
114
  toast.add({
324
115
  title: t('settings.riskPolicy.toast.created'),
325
116
  icon: 'i-lucide-check',
@@ -331,6 +122,50 @@ async function create() {
331
122
  creating.value = false
332
123
  }
333
124
  }
125
+
126
+ /**
127
+ * Clone an inherited policy into the board. The copy's NAME is composed here because the backend
128
+ * does not localize prose: it defaults to the source's name, and this states in the reader's own
129
+ * language that the row is a copy. `riskPolicyCopyName` holds it inside the contract's length limit,
130
+ * which the marker would otherwise push a long source name past.
131
+ */
132
+ function clone(policy: RiskPolicyLibraryEntry) {
133
+ return run(`${policy.id}:clone`, 'settings.riskPolicy.toast.cloneFailed', async () => {
134
+ const name = riskPolicyCopyName(policy.name, (source) =>
135
+ t('settings.riskPolicy.inherited.copyName', { name: source }),
136
+ )
137
+ await store.clone(policy.id, name)
138
+ toast.add({
139
+ title: t('settings.riskPolicy.toast.cloned'),
140
+ icon: 'i-lucide-check',
141
+ color: 'success',
142
+ })
143
+ })
144
+ }
145
+
146
+ /**
147
+ * Hide an inherited policy. Confirmed, because a task that already pinned it falls back to the
148
+ * board's default for its scope — the same thing a deleted local policy does — and that is a change
149
+ * of merge posture on existing work rather than a change to a list.
150
+ */
151
+ async function hide(policy: RiskPolicyLibraryEntry) {
152
+ const ok = await confirm({
153
+ title: t('settings.riskPolicy.confirmHide.title'),
154
+ description: t('settings.riskPolicy.confirmHide.body', { name: policy.name }),
155
+ confirmLabel: t('settings.riskPolicy.inherited.hide'),
156
+ icon: 'i-lucide-eye-off',
157
+ })
158
+ if (!ok) return
159
+ await run(`${policy.id}:hide`, 'settings.riskPolicy.toast.hideFailed', () =>
160
+ store.hide(policy.id),
161
+ )
162
+ }
163
+
164
+ function unhide(presetId: string) {
165
+ return run(`${presetId}:unhide`, 'settings.riskPolicy.toast.unhideFailed', () =>
166
+ store.unhide(presetId),
167
+ )
168
+ }
334
169
  </script>
335
170
 
336
171
  <template>
@@ -346,340 +181,78 @@ async function create() {
346
181
  </template>
347
182
  </i18n-t>
348
183
 
349
- <div
350
- v-for="p in store.presets"
351
- :key="p.id"
352
- class="rounded-lg border border-slate-700 bg-slate-800/40 p-3"
353
- data-testid="risk-policy-row"
354
- :data-policy-id="p.id"
355
- >
356
- <div class="mb-3 flex items-center gap-2">
357
- <UInput
358
- v-model="drafts[p.id]!.name"
359
- size="sm"
360
- class="flex-1"
361
- :placeholder="t('settings.riskPolicy.presetNamePlaceholder')"
362
- />
363
- <UBadge v-if="p.isUnattendedDefault" color="info" variant="subtle" size="sm">
364
- {{ t('settings.riskPolicy.unattendedDefault') }}
365
- </UBadge>
366
- <!--
367
- Visibly labelled, like its `makeDefault` sibling below. `title` is a tooltip, NOT an
368
- accessible name: icon-only, this was announced as an unlabelled button, and a sighted
369
- user had to hover a bare glyph to discover it re-points which policy governs every
370
- unwatched run. The short text is the accessible name (so it is not one of those buttons
371
- whose spoken name and printed label disagree) and the longer `title` still explains
372
- which runs those are. `busy` is compared to a per-BUTTON key so promoting one default
373
- does not spin the other's button too.
374
- -->
375
- <UButton
376
- v-else
377
- color="neutral"
378
- variant="ghost"
379
- size="xs"
380
- icon="i-lucide-bot"
381
- :loading="busy === `${p.id}:unattended`"
382
- :title="t('settings.riskPolicy.makeUnattendedDefault')"
383
- @click="makeUnattendedDefault(p)"
384
- >
385
- {{ t('settings.riskPolicy.makeUnattendedDefaultShort') }}
386
- </UButton>
387
- <UBadge v-if="p.isDefault" color="primary" variant="subtle" size="sm">
388
- {{ t('settings.riskPolicy.default') }}
389
- </UBadge>
390
- <UButton
391
- v-else
392
- color="neutral"
393
- variant="ghost"
394
- size="xs"
395
- icon="i-lucide-star"
396
- :loading="busy === `${p.id}:default`"
397
- @click="makeDefault(p)"
398
- >
399
- {{ t('settings.riskPolicy.makeDefault') }}
400
- </UButton>
401
- <UButton
402
- color="error"
403
- variant="ghost"
404
- size="xs"
405
- icon="i-lucide-trash-2"
406
- :disabled="p.isDefault || p.isUnattendedDefault || busy?.startsWith(p.id)"
407
- :title="deleteBlockedReason(p)"
408
- @click="remove(p)"
409
- />
410
- </div>
411
-
412
- <div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
413
- <label v-for="axis in RISK_POLICY_AXES" :key="axis" class="block">
414
- <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
415
- {{ t(CEILING_LABEL_KEYS[axis]) }}
416
- </span>
417
- <UInput
418
- v-model.number="drafts[p.id]![RISK_POLICY_CEILING_FIELD[axis]]"
419
- type="number"
420
- :min="0"
421
- :max="100"
422
- size="sm"
423
- />
424
- </label>
425
- <label class="block">
426
- <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
427
- {{ t('settings.riskPolicy.field.ciMaxAttempts') }}
428
- </span>
429
- <UInput
430
- v-model.number="drafts[p.id]!.ciMaxAttempts"
431
- type="number"
432
- :min="0"
433
- :max="50"
434
- size="sm"
435
- />
436
- </label>
437
- <label class="block">
438
- <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
439
- {{ t('settings.riskPolicy.field.maxRequirementIterations') }}
440
- </span>
441
- <UInput
442
- v-model.number="drafts[p.id]!.maxRequirementIterations"
443
- type="number"
444
- :min="1"
445
- :max="20"
446
- size="sm"
447
- />
448
- </label>
449
- <label class="block">
450
- <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
451
- {{ t('settings.riskPolicy.field.maxRequirementConcernAllowed') }}
452
- </span>
453
- <USelect
454
- v-model="drafts[p.id]!.maxRequirementConcernAllowed"
455
- :items="CONCERN_LEVELS"
456
- value-key="value"
457
- size="sm"
458
- />
459
- </label>
460
- </div>
461
-
462
- <!-- Per-change-class auto-merge rules, each shown beside that class's accumulated track
463
- record — the number that justifies widening the rule. -->
464
- <div class="mt-3 rounded-md border border-slate-800 bg-slate-900/40 p-3">
465
- <MergeClassRulesEditor
466
- v-model="drafts[p.id]!.classRules"
467
- :auto-merge-enabled="drafts[p.id]!.autoMergeEnabled"
468
- :disabled="busy === p.id"
469
- />
470
- </div>
471
-
472
- <!-- The role layer over those rules: what a run may do depending on WHO started it, up to
473
- and including a full sandbox. Directly under the base rules it narrows, since a role
474
- rule is only readable against the rule it applies to. -->
475
- <div class="mt-3 rounded-md border border-slate-800 bg-slate-900/40 p-3">
476
- <MergeRolePolicyEditor
477
- v-model:class-rules-by-role="drafts[p.id]!.classRulesByRole"
478
- v-model:dry-run-roles="drafts[p.id]!.dryRunRoles"
479
- v-model:submission-classes-by-role="drafts[p.id]!.submissionClassesByRole"
480
- :class-rules="drafts[p.id]!.classRules"
481
- :auto-merge-enabled="drafts[p.id]!.autoMergeEnabled"
482
- :disabled="busy === p.id"
483
- />
484
- </div>
485
-
486
- <!-- Implementation-fork decision gate: propose materially different approaches before the
487
- Coder writes code (in `auto` tri-state, gated on the task estimate). -->
488
- <div class="mt-3 rounded-md border border-slate-800 bg-slate-900/40 p-3">
489
- <USwitch
490
- v-model="drafts[p.id]!.forkEnabled"
491
- size="sm"
492
- :label="t('settings.riskPolicy.forkDecision.label')"
493
- :description="t('settings.riskPolicy.forkDecision.hint')"
494
- />
495
- <div v-if="drafts[p.id]!.forkEnabled" class="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-4">
496
- <label v-for="axis in RISK_POLICY_AXES" :key="axis" class="block">
497
- <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
498
- {{ t(FORK_FLOOR_LABEL_KEYS[axis]) }}
499
- </span>
500
- <UInput
501
- v-model.number="drafts[p.id]![FORK_FLOOR_FIELD[axis]]"
502
- type="number"
503
- size="sm"
504
- :min="0"
505
- :max="100"
506
- />
507
- </label>
508
- <label class="block">
509
- <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
510
- {{ t('settings.riskPolicy.forkDecision.onMissingLabel') }}
511
- </span>
512
- <USelect v-model="drafts[p.id]!.forkOnMissing" :items="ON_MISSING_OPTIONS" size="sm" />
513
- </label>
514
- </div>
515
- </div>
184
+ <!-- Inherited FIRST: they are the org's posture, and a board reading its own list wants to see
185
+ what it is working from before what it has changed. -->
186
+ <section v-if="inherited.length > 0" class="space-y-2">
187
+ <p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
188
+ {{ t('settings.riskPolicy.inherited.heading') }}
189
+ </p>
190
+ <p class="text-[11px] text-slate-500">{{ t('settings.riskPolicy.inherited.hint') }}</p>
191
+ <RiskPolicyInheritedRow
192
+ v-for="policy in inherited"
193
+ :key="policy.id"
194
+ :policy="policy"
195
+ :busy="busy"
196
+ @clone="clone(policy)"
197
+ @hide="hide(policy)"
198
+ />
199
+ </section>
516
200
 
517
- <!-- The autonomy posture: whether the parks the engine's own quality loops raise when they
518
- give up wait for a person, or are answered on the record so the run finishes. Never
519
- touches a gate the PIPELINE asked for. -->
520
- <div class="mt-3 rounded-md border border-slate-800 bg-slate-900/40 p-3">
521
- <USwitch
522
- v-model="drafts[p.id]!.unattended"
523
- size="sm"
524
- :label="t('settings.riskPolicy.autonomy.label')"
525
- :description="
526
- drafts[p.id]!.unattended
527
- ? t('settings.riskPolicy.autonomy.unattendedHint')
528
- : t('settings.riskPolicy.autonomy.attendedHint')
529
- "
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>
550
- </div>
201
+ <section class="space-y-4">
202
+ <p
203
+ v-if="inherited.length > 0"
204
+ class="text-[11px] font-semibold uppercase tracking-wide text-slate-400"
205
+ >
206
+ {{ t('settings.riskPolicy.own.heading') }}
207
+ </p>
208
+ <RiskPolicyEditorRow
209
+ v-for="policy in own"
210
+ :key="policy.id"
211
+ :policy="policy"
212
+ :busy="busy"
213
+ :show-defaults="true"
214
+ @save="save(policy, $event)"
215
+ @promote="makeDefault(policy)"
216
+ @promote-unattended="makeUnattendedDefault(policy)"
217
+ @remove="remove(policy)"
218
+ />
219
+ </section>
551
220
 
552
- <div class="mt-3 flex items-center justify-between gap-3">
553
- <USwitch
554
- v-model="drafts[p.id]!.autoMergeEnabled"
555
- size="sm"
556
- :label="t('settings.riskPolicy.field.autoMerge')"
557
- :description="
558
- drafts[p.id]!.autoMergeEnabled
559
- ? t('settings.riskPolicy.autoMergeOnHint')
560
- : t('settings.riskPolicy.autoMergeOffHint')
561
- "
562
- />
221
+ <!-- What this board is hiding. Rendered only when there IS something, and each entry says
222
+ whether it still shadows an account policy: one whose policy the account has since withdrawn
223
+ withholds nothing, and reading it as a live opt-out would misstate what the board is doing. -->
224
+ <section v-if="store.suppressions.length > 0 || suppressionsFailed" class="space-y-2">
225
+ <p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
226
+ {{ t('settings.riskPolicy.hidden.heading') }}
227
+ </p>
228
+ <p v-if="suppressionsFailed" class="text-[11px] text-amber-400">
229
+ {{ t('settings.riskPolicy.hidden.loadFailed') }}
230
+ </p>
231
+ <div
232
+ v-for="entry in store.suppressions"
233
+ :key="entry.id"
234
+ class="flex items-center gap-2 rounded-md border border-slate-800 bg-slate-900/40 px-3 py-2"
235
+ data-testid="risk-policy-hidden-row"
236
+ :data-policy-id="entry.id"
237
+ >
238
+ <span class="flex-1 truncate text-[12px] text-slate-300">{{ entry.name }}</span>
239
+ <span v-if="!entry.inherited" class="text-[11px] text-slate-500">
240
+ {{ t('settings.riskPolicy.hidden.withdrawn') }}
241
+ </span>
563
242
  <UButton
564
- color="primary"
565
- variant="soft"
243
+ color="neutral"
244
+ variant="ghost"
566
245
  size="xs"
567
- icon="i-lucide-save"
568
- :loading="busy === p.id"
569
- @click="save(p)"
246
+ icon="i-lucide-eye"
247
+ :loading="busy === `${entry.id}:unhide`"
248
+ data-testid="risk-policy-unhide"
249
+ @click="unhide(entry.id)"
570
250
  >
571
- {{ t('common.save') }}
251
+ {{ t('settings.riskPolicy.hidden.restore') }}
572
252
  </UButton>
573
253
  </div>
574
- </div>
254
+ </section>
575
255
 
576
- <!-- create -->
577
- <div class="rounded-lg border border-dashed border-slate-700 p-3">
578
- <p class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
579
- {{ t('settings.riskPolicy.newPreset') }}
580
- </p>
581
- <div class="flex flex-wrap items-end gap-3">
582
- <label class="block min-w-40 flex-1">
583
- <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
584
- {{ t('settings.riskPolicy.create.name') }}
585
- </span>
586
- <UInput
587
- v-model="draft.name"
588
- size="sm"
589
- :placeholder="t('settings.riskPolicy.create.namePlaceholder')"
590
- data-testid="risk-policy-create-name"
591
- />
592
- </label>
593
- <label class="block w-20">
594
- <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
595
- {{ t('settings.riskPolicy.create.complexity') }}
596
- </span>
597
- <UInput
598
- v-model.number="draft.maxComplexity"
599
- type="number"
600
- :min="0"
601
- :max="100"
602
- size="sm"
603
- data-testid="risk-policy-create-complexity"
604
- />
605
- </label>
606
- <label class="block w-20">
607
- <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
608
- {{ t('settings.riskPolicy.create.risk') }}
609
- </span>
610
- <UInput
611
- v-model.number="draft.maxRisk"
612
- type="number"
613
- :min="0"
614
- :max="100"
615
- size="sm"
616
- data-testid="risk-policy-create-risk"
617
- />
618
- </label>
619
- <label class="block w-20">
620
- <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
621
- {{ t('settings.riskPolicy.create.impact') }}
622
- </span>
623
- <UInput
624
- v-model.number="draft.maxImpact"
625
- type="number"
626
- :min="0"
627
- :max="100"
628
- size="sm"
629
- data-testid="risk-policy-create-impact"
630
- />
631
- </label>
632
- <label class="block w-20">
633
- <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
634
- {{ t('settings.riskPolicy.create.ciFix') }}
635
- </span>
636
- <UInput v-model.number="draft.ciMaxAttempts" type="number" :min="0" :max="50" size="sm" />
637
- </label>
638
- <label class="block w-20">
639
- <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
640
- {{ t('settings.riskPolicy.create.reqIter') }}
641
- </span>
642
- <UInput
643
- v-model.number="draft.maxRequirementIterations"
644
- type="number"
645
- :min="1"
646
- :max="20"
647
- size="sm"
648
- />
649
- </label>
650
- <label class="block w-32">
651
- <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
652
- {{ t('settings.riskPolicy.create.autoPass') }}
653
- </span>
654
- <USelect
655
- v-model="draft.maxRequirementConcernAllowed"
656
- :items="CONCERN_LEVELS"
657
- value-key="value"
658
- size="sm"
659
- />
660
- </label>
661
- <USwitch
662
- v-model="draft.autoMergeEnabled"
663
- size="sm"
664
- :label="t('settings.riskPolicy.field.autoMerge')"
665
- />
666
- <USwitch
667
- v-model="draft.forkEnabled"
668
- size="sm"
669
- :label="t('settings.riskPolicy.forkDecision.label')"
670
- />
671
- <UButton
672
- color="primary"
673
- size="sm"
674
- icon="i-lucide-plus"
675
- :loading="creating"
676
- :disabled="!draft.name.trim()"
677
- data-testid="risk-policy-create-submit"
678
- @click="create"
679
- >
680
- {{ t('settings.riskPolicy.add') }}
681
- </UButton>
682
- </div>
683
- </div>
256
+ <RiskPolicyCreateForm :busy="creating" @create="create($event)" />
684
257
  </div>
685
258
  </template>