@cat-factory/app 0.267.0 → 0.268.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.
@@ -0,0 +1,120 @@
1
+ <script setup lang="ts">
2
+ // Account-tier risk policies (ADR 0055): the merge postures an org authors once, which every board
3
+ // in the account inherits read-only and may clone or hide. A body-only section rendered in the "Risk
4
+ // policies" tab of AccountSettingsPanel.
5
+ //
6
+ // The SAME editor rows the board panel uses, with the default-claim controls off: which policy
7
+ // governs a task that pinned none is a per-board question, so an account row holds no default and a
8
+ // promote button here would be a control over a decision this tier never makes.
9
+ import { computed, onMounted, ref, watch } from 'vue'
10
+ import type {
11
+ CreateRiskPolicyInput,
12
+ RiskPolicyLibraryEntry,
13
+ UpdateRiskPolicyInput,
14
+ } from '~/types/merge'
15
+ import RiskPolicyCreateForm from '~/components/settings/RiskPolicyCreateForm.vue'
16
+ import RiskPolicyEditorRow from '~/components/settings/RiskPolicyEditorRow.vue'
17
+
18
+ const props = defineProps<{ accountId: string }>()
19
+
20
+ const { t } = useI18n()
21
+ const store = useAccountRiskPoliciesStore()
22
+ const toast = useToast()
23
+ const { present } = usePipelineErrorToast()
24
+ const { confirm } = useConfirm()
25
+
26
+ const busy = ref<string | null>(null)
27
+ const creating = ref(false)
28
+
29
+ const policies = computed<RiskPolicyLibraryEntry[]>(() => store.policies(props.accountId))
30
+
31
+ onMounted(() => void load())
32
+ // Switching accounts re-reads rather than showing the previous account's library.
33
+ watch(
34
+ () => props.accountId,
35
+ () => void load(),
36
+ )
37
+
38
+ async function load() {
39
+ try {
40
+ await store.load(props.accountId)
41
+ } catch (e) {
42
+ present(e, 'layout.accountRiskPolicies.loadFailed')
43
+ }
44
+ }
45
+
46
+ async function save(policy: RiskPolicyLibraryEntry, patch: UpdateRiskPolicyInput) {
47
+ busy.value = policy.id
48
+ try {
49
+ await store.update(props.accountId, policy.id, patch)
50
+ toast.add({
51
+ title: t('settings.riskPolicy.toast.saved'),
52
+ icon: 'i-lucide-check',
53
+ color: 'success',
54
+ })
55
+ } catch (e) {
56
+ present(e, 'settings.riskPolicy.toast.saveFailed')
57
+ } finally {
58
+ busy.value = null
59
+ }
60
+ }
61
+
62
+ async function remove(policy: RiskPolicyLibraryEntry) {
63
+ const ok = await confirm({
64
+ title: t('settings.riskPolicy.confirmDelete.title'),
65
+ // Its own copy rather than the board's: withdrawing an account policy changes what EVERY board
66
+ // in the account can pin, and a task that pinned it falls back to its board's own default.
67
+ description: t('layout.accountRiskPolicies.confirmDelete', { name: policy.name }),
68
+ variant: 'destructive',
69
+ confirmLabel: t('common.delete'),
70
+ icon: 'i-lucide-trash-2',
71
+ })
72
+ if (!ok) return
73
+ busy.value = policy.id
74
+ try {
75
+ await store.remove(props.accountId, policy.id)
76
+ } catch (e) {
77
+ present(e, 'settings.riskPolicy.toast.deleteFailed')
78
+ } finally {
79
+ busy.value = null
80
+ }
81
+ }
82
+
83
+ async function create(input: CreateRiskPolicyInput) {
84
+ creating.value = true
85
+ try {
86
+ await store.create(props.accountId, input)
87
+ toast.add({
88
+ title: t('settings.riskPolicy.toast.created'),
89
+ icon: 'i-lucide-check',
90
+ color: 'success',
91
+ })
92
+ } catch (e) {
93
+ present(e, 'settings.riskPolicy.toast.createFailed')
94
+ } finally {
95
+ creating.value = false
96
+ }
97
+ }
98
+ </script>
99
+
100
+ <template>
101
+ <div class="space-y-4 text-sm" data-testid="account-risk-policy-panel">
102
+ <p class="text-[11px] text-slate-400">{{ t('layout.accountRiskPolicies.intro') }}</p>
103
+
104
+ <p v-if="!store.loading && policies.length === 0" class="text-[11px] text-slate-500">
105
+ {{ t('layout.accountRiskPolicies.empty') }}
106
+ </p>
107
+
108
+ <RiskPolicyEditorRow
109
+ v-for="policy in policies"
110
+ :key="policy.id"
111
+ :policy="policy"
112
+ :busy="busy"
113
+ :show-defaults="false"
114
+ @save="save(policy, $event)"
115
+ @remove="remove(policy)"
116
+ />
117
+
118
+ <RiskPolicyCreateForm :busy="creating" @create="create($event)" />
119
+ </div>
120
+ </template>
@@ -2,14 +2,15 @@
2
2
  // Account settings — a single tabbed modal for the per-account configuration, distinct
3
3
  // from Workspace settings. Hosts the team panel (members + roles, invitations, email
4
4
  // sender, account-wide API keys; org-scoped, with a create-org CTA on a personal account)
5
- // the account-tier prompt-fragment library, the repo-sourced skills, and the foundational-service
6
- // catalog (all available for every account type).
5
+ // the account-tier prompt-fragment library, the repo-sourced skills, the foundational-service
6
+ // catalog and the account-tier risk policies (all available for every account type).
7
7
  // Opened from the SideBar Configuration section, the account switcher and the command
8
8
  // bar; bound to the `ui` store so any surface can open it and deep-link to a tab.
9
9
  import AccountTeamSettings from '~/components/layout/AccountTeamSettings.vue'
10
10
  import AccountFragmentSettings from '~/components/layout/AccountFragmentSettings.vue'
11
11
  import AccountSkillSettings from '~/components/layout/AccountSkillSettings.vue'
12
12
  import AccountFoundationalSettings from '~/components/layout/AccountFoundationalSettings.vue'
13
+ import AccountRiskPolicySettings from '~/components/layout/AccountRiskPolicySettings.vue'
13
14
 
14
15
  const { t } = useI18n()
15
16
  const ui = useUiStore()
@@ -47,6 +48,12 @@ const tabs = computed(() => [
47
48
  icon: 'i-lucide-boxes',
48
49
  slot: 'foundational',
49
50
  },
51
+ {
52
+ value: 'riskPolicies',
53
+ label: t('settings.account.tabs.riskPolicies'),
54
+ icon: 'i-lucide-shield-check',
55
+ slot: 'riskPolicies',
56
+ },
50
57
  ])
51
58
  </script>
52
59
 
@@ -77,6 +84,14 @@ const tabs = computed(() => [
77
84
  :account-id="accounts.activeAccountId"
78
85
  />
79
86
  </template>
87
+ <template #riskPolicies>
88
+ <!-- Keyed on the account like its siblings: a mid-modal account switch must remount
89
+ against a fresh account-keyed store rather than the stale initial one. -->
90
+ <AccountRiskPolicySettings
91
+ :key="accounts.activeAccountId ?? undefined"
92
+ :account-id="accounts.activeAccountId"
93
+ />
94
+ </template>
80
95
  <template #skills>
81
96
  <!-- Key on the account so a mid-modal account switch remounts against a fresh
82
97
  account-keyed skill-library store rather than the stale initial one. -->
@@ -0,0 +1,164 @@
1
+ <script setup lang="ts">
2
+ // The create row for a new risk policy, shared by the board and account tiers (ADR 0055): the
3
+ // numbers only. Class and role rules start at their identity and are edited on the saved policy,
4
+ // where each rule can be shown beside the base rule (and the track record) it narrows.
5
+ import { computed, reactive } from 'vue'
6
+ import type { CreateRiskPolicyInput, RequirementConcernLevel } from '~/types/merge'
7
+ import {
8
+ CONCERN_LABEL_KEYS,
9
+ CONCERN_LEVELS,
10
+ blankRiskPolicyDraft,
11
+ forkGatingFromDraft,
12
+ } from '~/utils/riskPolicyDraft'
13
+
14
+ const props = defineProps<{ busy: boolean }>()
15
+
16
+ const emit = defineEmits<{ create: [input: CreateRiskPolicyInput] }>()
17
+
18
+ const { t } = useI18n()
19
+
20
+ const draft = reactive(blankRiskPolicyDraft())
21
+
22
+ const concernOptions = computed<{ value: RequirementConcernLevel; label: string }[]>(() =>
23
+ CONCERN_LEVELS.map((value) => ({ value, label: t(CONCERN_LABEL_KEYS[value]) })),
24
+ )
25
+
26
+ const canSubmit = computed(() => draft.name.trim().length > 0 && !props.busy)
27
+
28
+ /**
29
+ * Emit the create body and reset the fields a second policy should not inherit.
30
+ *
31
+ * The numbers are deliberately KEPT: an operator authoring two related policies edits them from the
32
+ * first one's values, and re-typing four ceilings to make a near-identical policy is the friction
33
+ * that leads to cloning by hand. The name is cleared because two policies with one name is the one
34
+ * outcome nothing on screen would explain.
35
+ */
36
+ function submit() {
37
+ if (!canSubmit.value) return
38
+ emit('create', {
39
+ name: draft.name.trim(),
40
+ maxComplexity: draft.maxComplexity / 100,
41
+ maxRisk: draft.maxRisk / 100,
42
+ maxImpact: draft.maxImpact / 100,
43
+ ciMaxAttempts: draft.ciMaxAttempts,
44
+ maxRequirementIterations: draft.maxRequirementIterations,
45
+ maxRequirementConcernAllowed: draft.maxRequirementConcernAllowed,
46
+ autoMergeEnabled: draft.autoMergeEnabled,
47
+ autonomy: draft.unattended ? 'unattended' : 'attended',
48
+ minAutoAnswerConfidence: draft.minAutoAnswerConfidence / 100,
49
+ classRules: draft.classRules,
50
+ forkDecision: forkGatingFromDraft(draft),
51
+ } as CreateRiskPolicyInput)
52
+ draft.name = ''
53
+ }
54
+ </script>
55
+
56
+ <template>
57
+ <div class="rounded-lg border border-dashed border-slate-700 p-3">
58
+ <p class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
59
+ {{ t('settings.riskPolicy.newPreset') }}
60
+ </p>
61
+ <div class="flex flex-wrap items-end gap-3">
62
+ <label class="block min-w-40 flex-1">
63
+ <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
64
+ {{ t('settings.riskPolicy.create.name') }}
65
+ </span>
66
+ <UInput
67
+ v-model="draft.name"
68
+ size="sm"
69
+ :placeholder="t('settings.riskPolicy.create.namePlaceholder')"
70
+ data-testid="risk-policy-create-name"
71
+ />
72
+ </label>
73
+ <label class="block w-20">
74
+ <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
75
+ {{ t('settings.riskPolicy.create.complexity') }}
76
+ </span>
77
+ <UInput
78
+ v-model.number="draft.maxComplexity"
79
+ type="number"
80
+ :min="0"
81
+ :max="100"
82
+ size="sm"
83
+ data-testid="risk-policy-create-complexity"
84
+ />
85
+ </label>
86
+ <label class="block w-20">
87
+ <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
88
+ {{ t('settings.riskPolicy.create.risk') }}
89
+ </span>
90
+ <UInput
91
+ v-model.number="draft.maxRisk"
92
+ type="number"
93
+ :min="0"
94
+ :max="100"
95
+ size="sm"
96
+ data-testid="risk-policy-create-risk"
97
+ />
98
+ </label>
99
+ <label class="block w-20">
100
+ <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
101
+ {{ t('settings.riskPolicy.create.impact') }}
102
+ </span>
103
+ <UInput
104
+ v-model.number="draft.maxImpact"
105
+ type="number"
106
+ :min="0"
107
+ :max="100"
108
+ size="sm"
109
+ data-testid="risk-policy-create-impact"
110
+ />
111
+ </label>
112
+ <label class="block w-20">
113
+ <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
114
+ {{ t('settings.riskPolicy.create.ciFix') }}
115
+ </span>
116
+ <UInput v-model.number="draft.ciMaxAttempts" type="number" :min="0" :max="50" size="sm" />
117
+ </label>
118
+ <label class="block w-20">
119
+ <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
120
+ {{ t('settings.riskPolicy.create.reqIter') }}
121
+ </span>
122
+ <UInput
123
+ v-model.number="draft.maxRequirementIterations"
124
+ type="number"
125
+ :min="1"
126
+ :max="20"
127
+ size="sm"
128
+ />
129
+ </label>
130
+ <label class="block w-32">
131
+ <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
132
+ {{ t('settings.riskPolicy.create.autoPass') }}
133
+ </span>
134
+ <USelect
135
+ v-model="draft.maxRequirementConcernAllowed"
136
+ :items="concernOptions"
137
+ value-key="value"
138
+ size="sm"
139
+ />
140
+ </label>
141
+ <USwitch
142
+ v-model="draft.autoMergeEnabled"
143
+ size="sm"
144
+ :label="t('settings.riskPolicy.field.autoMerge')"
145
+ />
146
+ <USwitch
147
+ v-model="draft.forkEnabled"
148
+ size="sm"
149
+ :label="t('settings.riskPolicy.forkDecision.label')"
150
+ />
151
+ <UButton
152
+ color="primary"
153
+ size="sm"
154
+ icon="i-lucide-plus"
155
+ :loading="busy"
156
+ :disabled="!canSubmit"
157
+ data-testid="risk-policy-create-submit"
158
+ @click="submit"
159
+ >
160
+ {{ t('settings.riskPolicy.add') }}
161
+ </UButton>
162
+ </div>
163
+ </div>
164
+ </template>
@@ -0,0 +1,315 @@
1
+ <script setup lang="ts">
2
+ // ONE editable risk policy: the ceilings, the loop budgets, the per-class and per-role rules, the
3
+ // fork gate and the autonomy posture.
4
+ //
5
+ // Extracted out of `RiskPolicyPanel.vue` because the same form now serves two tiers (a board's own
6
+ // policies and an account's, ADR 0055). It owns only the FORM state and emits intent — the panel
7
+ // that mounts it knows which tier it is writing to, so nothing here has to.
8
+ import { computed, reactive, watch } from 'vue'
9
+ import type {
10
+ RiskPolicyLibraryEntry,
11
+ RequirementConcernLevel,
12
+ UpdateRiskPolicyInput,
13
+ } from '~/types/merge'
14
+ import {
15
+ CEILING_LABEL_KEYS,
16
+ CONCERN_LABEL_KEYS,
17
+ CONCERN_LEVELS,
18
+ FORK_FLOOR_FIELD,
19
+ FORK_FLOOR_LABEL_KEYS,
20
+ riskPolicyPatchFromDraft,
21
+ toRiskPolicyDraft,
22
+ } from '~/utils/riskPolicyDraft'
23
+ import { RISK_POLICY_AXES, RISK_POLICY_CEILING_FIELD } from '~/utils/riskPolicy'
24
+ import MergeClassRulesEditor from '~/components/settings/MergeClassRulesEditor.vue'
25
+ import MergeRolePolicyEditor from '~/components/settings/MergeRolePolicyEditor.vue'
26
+
27
+ const props = defineProps<{
28
+ policy: RiskPolicyLibraryEntry
29
+ /** Which single control is mid-request, keyed `<policyId>[:<action>]` by the owning panel. */
30
+ busy: string | null
31
+ /**
32
+ * Whether this tier carries the two per-scope DEFAULT claims. False at the ACCOUNT tier, which
33
+ * holds none: which policy governs a task that pinned none is a per-board question, so the badges
34
+ * and promote buttons would be controls over a decision this tier never makes.
35
+ */
36
+ showDefaults: boolean
37
+ }>()
38
+
39
+ const emit = defineEmits<{
40
+ save: [patch: UpdateRiskPolicyInput]
41
+ promote: []
42
+ promoteUnattended: []
43
+ remove: []
44
+ }>()
45
+
46
+ const { t } = useI18n()
47
+
48
+ const draft = reactive(toRiskPolicyDraft(props.policy))
49
+
50
+ // Re-seed the form only when this row starts editing a DIFFERENT policy, so recycling the component
51
+ // cannot leave the previous one's numbers in the fields.
52
+ //
53
+ // Keyed on the ID and deliberately not on the row: every store hydrate rebuilds each entry as a new
54
+ // object, and both panels re-read after any write (`ws.refresh()` here, `store.load()` at the account
55
+ // tier), which a coarse `board` realtime event also triggers. Watching the row identity therefore
56
+ // re-seeded this form whenever an UNRELATED policy was saved, silently discarding whatever the
57
+ // operator had typed into this one with nothing on screen saying so.
58
+ watch(
59
+ () => props.policy.id,
60
+ () => Object.assign(draft, toRiskPolicyDraft(props.policy)),
61
+ )
62
+
63
+ const concernOptions = computed<{ value: RequirementConcernLevel; label: string }[]>(() =>
64
+ CONCERN_LEVELS.map((value) => ({ value, label: t(CONCERN_LABEL_KEYS[value]) })),
65
+ )
66
+
67
+ const onMissingOptions = computed<{ value: 'run' | 'skip'; label: string }[]>(() => [
68
+ { value: 'run', label: t('settings.riskPolicy.forkDecision.onMissing.run') },
69
+ { value: 'skip', label: t('settings.riskPolicy.forkDecision.onMissing.skip') },
70
+ ])
71
+
72
+ /**
73
+ * Why the delete button is disabled, naming the flag that actually blocks it.
74
+ *
75
+ * The two defaults are promoted by DIFFERENT buttons, so collapsing them into one "promote another
76
+ * preset first" message sends an operator to re-point the in-app default and come back to a delete
77
+ * that is still refused, with nothing on screen saying why.
78
+ */
79
+ const deleteBlockedReason = computed(() => {
80
+ if (!props.showDefaults) return t('settings.riskPolicy.deletePreset')
81
+ if (props.policy.isDefault && props.policy.isUnattendedDefault)
82
+ return t('settings.riskPolicy.deleteBothDefaultsBlocked')
83
+ if (props.policy.isDefault) return t('settings.riskPolicy.deleteDefaultBlocked')
84
+ if (props.policy.isUnattendedDefault)
85
+ return t('settings.riskPolicy.deleteUnattendedDefaultBlocked')
86
+ return t('settings.riskPolicy.deletePreset')
87
+ })
88
+
89
+ const deleteBlocked = computed(
90
+ () => props.showDefaults && (props.policy.isDefault || props.policy.isUnattendedDefault),
91
+ )
92
+ </script>
93
+
94
+ <template>
95
+ <div
96
+ class="rounded-lg border border-slate-700 bg-slate-800/40 p-3"
97
+ data-testid="risk-policy-row"
98
+ :data-policy-id="policy.id"
99
+ :data-policy-tier="policy.tier"
100
+ >
101
+ <div class="mb-3 flex items-center gap-2">
102
+ <UInput
103
+ v-model="draft.name"
104
+ size="sm"
105
+ class="flex-1"
106
+ :placeholder="t('settings.riskPolicy.presetNamePlaceholder')"
107
+ />
108
+ <template v-if="showDefaults">
109
+ <UBadge v-if="policy.isUnattendedDefault" color="info" variant="subtle" size="sm">
110
+ {{ t('settings.riskPolicy.unattendedDefault') }}
111
+ </UBadge>
112
+ <!--
113
+ Visibly labelled, like its `makeDefault` sibling below. `title` is a tooltip, NOT an
114
+ accessible name: icon-only, this was announced as an unlabelled button, and a sighted
115
+ user had to hover a bare glyph to discover it re-points which policy governs every
116
+ unwatched run. `busy` is compared to a per-BUTTON key so promoting one default does not
117
+ spin the other's button too.
118
+ -->
119
+ <UButton
120
+ v-else
121
+ color="neutral"
122
+ variant="ghost"
123
+ size="xs"
124
+ icon="i-lucide-bot"
125
+ :loading="busy === `${policy.id}:unattended`"
126
+ :title="t('settings.riskPolicy.makeUnattendedDefault')"
127
+ @click="emit('promoteUnattended')"
128
+ >
129
+ {{ t('settings.riskPolicy.makeUnattendedDefaultShort') }}
130
+ </UButton>
131
+ <UBadge v-if="policy.isDefault" color="primary" variant="subtle" size="sm">
132
+ {{ t('settings.riskPolicy.default') }}
133
+ </UBadge>
134
+ <UButton
135
+ v-else
136
+ color="neutral"
137
+ variant="ghost"
138
+ size="xs"
139
+ icon="i-lucide-star"
140
+ :loading="busy === `${policy.id}:default`"
141
+ @click="emit('promote')"
142
+ >
143
+ {{ t('settings.riskPolicy.makeDefault') }}
144
+ </UButton>
145
+ </template>
146
+ <UButton
147
+ color="error"
148
+ variant="ghost"
149
+ size="xs"
150
+ icon="i-lucide-trash-2"
151
+ :disabled="deleteBlocked || busy?.startsWith(policy.id)"
152
+ :title="deleteBlockedReason"
153
+ @click="emit('remove')"
154
+ />
155
+ </div>
156
+
157
+ <div class="grid grid-cols-1 gap-3 sm:grid-cols-4">
158
+ <label v-for="axis in RISK_POLICY_AXES" :key="axis" class="block">
159
+ <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
160
+ {{ t(CEILING_LABEL_KEYS[axis]) }}
161
+ </span>
162
+ <UInput
163
+ v-model.number="draft[RISK_POLICY_CEILING_FIELD[axis]]"
164
+ type="number"
165
+ :min="0"
166
+ :max="100"
167
+ size="sm"
168
+ />
169
+ </label>
170
+ <label class="block">
171
+ <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
172
+ {{ t('settings.riskPolicy.field.ciMaxAttempts') }}
173
+ </span>
174
+ <UInput v-model.number="draft.ciMaxAttempts" type="number" :min="0" :max="50" size="sm" />
175
+ </label>
176
+ <label class="block">
177
+ <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
178
+ {{ t('settings.riskPolicy.field.maxRequirementIterations') }}
179
+ </span>
180
+ <UInput
181
+ v-model.number="draft.maxRequirementIterations"
182
+ type="number"
183
+ :min="1"
184
+ :max="20"
185
+ size="sm"
186
+ />
187
+ </label>
188
+ <label class="block">
189
+ <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
190
+ {{ t('settings.riskPolicy.field.maxRequirementConcernAllowed') }}
191
+ </span>
192
+ <USelect
193
+ v-model="draft.maxRequirementConcernAllowed"
194
+ :items="concernOptions"
195
+ value-key="value"
196
+ size="sm"
197
+ />
198
+ </label>
199
+ </div>
200
+
201
+ <!-- Per-change-class auto-merge rules, each shown beside that class's accumulated track
202
+ record — the number that justifies widening the rule. -->
203
+ <div class="mt-3 rounded-md border border-slate-800 bg-slate-900/40 p-3">
204
+ <MergeClassRulesEditor
205
+ v-model="draft.classRules"
206
+ :auto-merge-enabled="draft.autoMergeEnabled"
207
+ :disabled="busy === policy.id"
208
+ />
209
+ </div>
210
+
211
+ <!-- The role layer over those rules: what a run may do depending on WHO started it, up to and
212
+ including a full sandbox. Directly under the base rules it narrows, since a role rule is
213
+ only readable against the rule it applies to. -->
214
+ <div class="mt-3 rounded-md border border-slate-800 bg-slate-900/40 p-3">
215
+ <MergeRolePolicyEditor
216
+ v-model:class-rules-by-role="draft.classRulesByRole"
217
+ v-model:dry-run-roles="draft.dryRunRoles"
218
+ v-model:submission-classes-by-role="draft.submissionClassesByRole"
219
+ :class-rules="draft.classRules"
220
+ :auto-merge-enabled="draft.autoMergeEnabled"
221
+ :disabled="busy === policy.id"
222
+ />
223
+ </div>
224
+
225
+ <!-- Implementation-fork decision gate: propose materially different approaches before the
226
+ Coder writes code (in `auto` tri-state, gated on the task estimate). -->
227
+ <div class="mt-3 rounded-md border border-slate-800 bg-slate-900/40 p-3">
228
+ <USwitch
229
+ v-model="draft.forkEnabled"
230
+ size="sm"
231
+ :label="t('settings.riskPolicy.forkDecision.label')"
232
+ :description="t('settings.riskPolicy.forkDecision.hint')"
233
+ />
234
+ <div v-if="draft.forkEnabled" class="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-4">
235
+ <label v-for="axis in RISK_POLICY_AXES" :key="axis" class="block">
236
+ <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
237
+ {{ t(FORK_FLOOR_LABEL_KEYS[axis]) }}
238
+ </span>
239
+ <UInput
240
+ v-model.number="draft[FORK_FLOOR_FIELD[axis]]"
241
+ type="number"
242
+ size="sm"
243
+ :min="0"
244
+ :max="100"
245
+ />
246
+ </label>
247
+ <label class="block">
248
+ <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
249
+ {{ t('settings.riskPolicy.forkDecision.onMissingLabel') }}
250
+ </span>
251
+ <USelect v-model="draft.forkOnMissing" :items="onMissingOptions" size="sm" />
252
+ </label>
253
+ </div>
254
+ </div>
255
+
256
+ <!-- The autonomy posture: whether the parks the engine's own quality loops raise when they give
257
+ up wait for a person, or are answered on the record so the run finishes. Never touches a
258
+ gate the PIPELINE asked for. -->
259
+ <div class="mt-3 rounded-md border border-slate-800 bg-slate-900/40 p-3">
260
+ <USwitch
261
+ v-model="draft.unattended"
262
+ size="sm"
263
+ :label="t('settings.riskPolicy.autonomy.label')"
264
+ :description="
265
+ draft.unattended
266
+ ? t('settings.riskPolicy.autonomy.unattendedHint')
267
+ : t('settings.riskPolicy.autonomy.attendedHint')
268
+ "
269
+ />
270
+ <!-- Shown only while the posture is on, because that is the only state that reads it: a floor
271
+ on an attended policy would be a control over a decision this policy never makes. It is
272
+ not hidden as an "advanced override" — it is inert, which is a different thing. -->
273
+ <label v-if="draft.unattended" class="mt-3 block">
274
+ <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
275
+ {{ t('settings.riskPolicy.autoAnswer.label') }}
276
+ </span>
277
+ <UInput
278
+ v-model.number="draft.minAutoAnswerConfidence"
279
+ type="number"
280
+ min="0"
281
+ max="100"
282
+ size="sm"
283
+ data-testid="risk-policy-auto-answer-floor"
284
+ />
285
+ <span class="mt-1 block text-[11px] text-slate-500">
286
+ {{ t('settings.riskPolicy.autoAnswer.hint') }}
287
+ </span>
288
+ </label>
289
+ </div>
290
+
291
+ <div class="mt-3 flex items-center justify-between gap-3">
292
+ <USwitch
293
+ v-model="draft.autoMergeEnabled"
294
+ size="sm"
295
+ :label="t('settings.riskPolicy.field.autoMerge')"
296
+ :description="
297
+ draft.autoMergeEnabled
298
+ ? t('settings.riskPolicy.autoMergeOnHint')
299
+ : t('settings.riskPolicy.autoMergeOffHint')
300
+ "
301
+ />
302
+ <UButton
303
+ color="primary"
304
+ variant="soft"
305
+ size="xs"
306
+ icon="i-lucide-save"
307
+ :loading="busy === policy.id"
308
+ data-testid="risk-policy-save"
309
+ @click="emit('save', riskPolicyPatchFromDraft(draft, policy.name))"
310
+ >
311
+ {{ t('common.save') }}
312
+ </UButton>
313
+ </div>
314
+ </div>
315
+ </template>