@cat-factory/app 0.109.4 → 0.110.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.
Files changed (36) hide show
  1. package/app/components/board/AddTaskModal.vue +15 -15
  2. package/app/components/panels/inspector/TaskRunSettings.vue +12 -12
  3. package/app/components/pipeline/PipelineBuilder.vue +22 -0
  4. package/app/components/requirements/RequirementsReviewWindow.vue +300 -203
  5. package/app/components/settings/{MergePresetHealthModal.vue → RiskPolicyHealthModal.vue} +19 -19
  6. package/app/components/settings/{MergeThresholdsPanel.vue → RiskPolicyPanel.vue} +46 -46
  7. package/app/components/settings/UsageSettings.vue +142 -0
  8. package/app/components/settings/WorkspaceSettingsPanel.vue +14 -2
  9. package/app/composables/api/board.ts +1 -1
  10. package/app/composables/api/execution.ts +5 -0
  11. package/app/composables/api/presets.ts +18 -18
  12. package/app/composables/api/reviews.ts +7 -6
  13. package/app/composables/{useMergePresetHealth.ts → useRiskPolicyHealth.ts} +10 -10
  14. package/app/pages/index.vue +9 -9
  15. package/app/stores/board.ts +2 -2
  16. package/app/stores/pipelines.ts +41 -1
  17. package/app/stores/requirements.ts +9 -13
  18. package/app/stores/{mergePresets.ts → riskPolicies.ts} +13 -13
  19. package/app/stores/ui.ts +17 -17
  20. package/app/stores/usage.ts +60 -0
  21. package/app/stores/workspace.spec.ts +1 -1
  22. package/app/stores/workspace.ts +4 -4
  23. package/app/types/merge.ts +3 -3
  24. package/app/types/requirements.ts +1 -0
  25. package/app/utils/{mergePreset.ts → riskPolicy.ts} +4 -4
  26. package/i18n/locales/de.json +52 -27
  27. package/i18n/locales/en.json +52 -27
  28. package/i18n/locales/es.json +52 -27
  29. package/i18n/locales/fr.json +53 -28
  30. package/i18n/locales/he.json +53 -28
  31. package/i18n/locales/it.json +53 -28
  32. package/i18n/locales/ja.json +52 -27
  33. package/i18n/locales/pl.json +53 -28
  34. package/i18n/locales/tr.json +52 -27
  35. package/i18n/locales/uk.json +53 -28
  36. package/package.json +2 -2
@@ -1,20 +1,20 @@
1
1
  <script setup lang="ts">
2
2
  // Startup advisory for built-in merge presets that drifted from the catalog. Opened once per
3
- // session from the board page when `useMergePresetHealth` reports any issue. Lists:
3
+ // session from the board page when `useRiskPolicyHealth` reports any issue. Lists:
4
4
  // • new built-in presets the workspace doesn't have yet (ADD them);
5
5
  // • built-ins with a newer catalog version available (RESEED to adopt it).
6
6
  // Both fixes are the same reseed call (it creates or updates by catalog id). Detection is
7
- // client-side (see useMergePresetHealth); the actions hit the mergePresets store.
7
+ // client-side (see useRiskPolicyHealth); the actions hit the riskPolicies store.
8
8
  const { t } = useI18n()
9
9
  const ui = useUiStore()
10
- const presets = useMergePresetsStore()
11
- const { newPresets, outdated, hasIssues } = useMergePresetHealth()
10
+ const presets = useRiskPoliciesStore()
11
+ const { newPresets, outdated, hasIssues } = useRiskPolicyHealth()
12
12
  const toast = useToast()
13
13
 
14
14
  const open = computed({
15
- get: () => ui.mergePresetHealthOpen,
15
+ get: () => ui.riskPolicyHealthOpen,
16
16
  set: (v: boolean) => {
17
- if (!v) ui.closeMergePresetHealth()
17
+ if (!v) ui.closeRiskPolicyHealth()
18
18
  },
19
19
  })
20
20
 
@@ -29,7 +29,7 @@ async function reseed(id: string) {
29
29
  await presets.reseed(id)
30
30
  } catch (e) {
31
31
  toast.add({
32
- title: t('mergePreset.health.toast.reseedFailed'),
32
+ title: t('riskPolicy.health.toast.reseedFailed'),
33
33
  description: e instanceof Error ? e.message : String(e),
34
34
  icon: 'i-lucide-triangle-alert',
35
35
  color: 'error',
@@ -53,11 +53,11 @@ const reseedableCount = computed(
53
53
  </script>
54
54
 
55
55
  <template>
56
- <UModal v-model:open="open" :title="t('mergePreset.health.title')" :ui="{ content: 'max-w-2xl' }">
56
+ <UModal v-model:open="open" :title="t('riskPolicy.health.title')" :ui="{ content: 'max-w-2xl' }">
57
57
  <template #body>
58
58
  <div v-if="!hasIssues" class="py-6 text-center text-sm text-slate-400">
59
59
  <UIcon name="i-lucide-check-circle-2" class="mx-auto mb-2 h-8 w-8 text-emerald-400" />
60
- {{ t('mergePreset.health.allValid') }}
60
+ {{ t('riskPolicy.health.allValid') }}
61
61
  </div>
62
62
 
63
63
  <div v-else class="space-y-5">
@@ -66,10 +66,10 @@ const reseedableCount = computed(
66
66
  <div class="flex items-center gap-2">
67
67
  <UIcon name="i-lucide-sparkles" class="h-4 w-4 text-emerald-400" />
68
68
  <h3 class="text-sm font-semibold text-slate-200">
69
- {{ t('mergePreset.health.newHeading') }}
69
+ {{ t('riskPolicy.health.newHeading') }}
70
70
  </h3>
71
71
  </div>
72
- <p class="text-[11px] text-slate-500">{{ t('mergePreset.health.newDescription') }}</p>
72
+ <p class="text-[11px] text-slate-500">{{ t('riskPolicy.health.newDescription') }}</p>
73
73
  <ul class="space-y-2">
74
74
  <li
75
75
  v-for="i in newPresets"
@@ -90,7 +90,7 @@ const reseedableCount = computed(
90
90
  :disabled="anyBusy"
91
91
  @click="reseed(i.id)"
92
92
  >
93
- {{ t('mergePreset.health.add') }}
93
+ {{ t('riskPolicy.health.add') }}
94
94
  </UButton>
95
95
  </li>
96
96
  </ul>
@@ -101,10 +101,10 @@ const reseedableCount = computed(
101
101
  <div class="flex items-center gap-2">
102
102
  <UIcon name="i-lucide-arrow-up-circle" class="h-4 w-4 text-amber-400" />
103
103
  <h3 class="text-sm font-semibold text-slate-200">
104
- {{ t('mergePreset.health.updatesHeading') }}
104
+ {{ t('riskPolicy.health.updatesHeading') }}
105
105
  </h3>
106
106
  </div>
107
- <p class="text-[11px] text-slate-500">{{ t('mergePreset.health.updatesDescription') }}</p>
107
+ <p class="text-[11px] text-slate-500">{{ t('riskPolicy.health.updatesDescription') }}</p>
108
108
  <ul class="space-y-2">
109
109
  <li
110
110
  v-for="i in outdated"
@@ -115,7 +115,7 @@ const reseedableCount = computed(
115
115
  <span class="truncate text-sm font-medium text-slate-100">{{ i.name }}</span>
116
116
  <p class="text-[11px] text-amber-400/80">
117
117
  {{
118
- t('mergePreset.health.versionAvailable', {
118
+ t('riskPolicy.health.versionAvailable', {
119
119
  from: i.fromVersion ?? 0,
120
120
  to: i.toVersion ?? 0,
121
121
  })
@@ -131,7 +131,7 @@ const reseedableCount = computed(
131
131
  :disabled="anyBusy"
132
132
  @click="reseed(i.id)"
133
133
  >
134
- {{ t('mergePreset.health.reseed') }}
134
+ {{ t('riskPolicy.health.reseed') }}
135
135
  </UButton>
136
136
  </li>
137
137
  </ul>
@@ -149,16 +149,16 @@ const reseedableCount = computed(
149
149
  :loading="anyBusy"
150
150
  @click="reseedAll"
151
151
  >
152
- {{ t('mergePreset.health.reseedAll', { count: reseedableCount }) }}
152
+ {{ t('riskPolicy.health.reseedAll', { count: reseedableCount }) }}
153
153
  </UButton>
154
154
  <span v-else />
155
155
  <UButton
156
156
  color="neutral"
157
157
  variant="ghost"
158
158
  :disabled="anyBusy"
159
- @click="ui.closeMergePresetHealth()"
159
+ @click="ui.closeRiskPolicyHealth()"
160
160
  >
161
- {{ hasIssues ? t('mergePreset.health.dismiss') : t('mergePreset.health.done') }}
161
+ {{ hasIssues ? t('riskPolicy.health.dismiss') : t('riskPolicy.health.done') }}
162
162
  </UButton>
163
163
  </div>
164
164
  </template>
@@ -1,11 +1,11 @@
1
1
  <script setup lang="ts">
2
2
  // Workspace settings: the merge-threshold preset library a task picks its
3
3
  // auto-merge policy from (the `merger` step compares a PR's assessment against the
4
- // resolved preset). Full CRUD over the mergePresets store — the same library the
4
+ // resolved preset). Full CRUD over the riskPolicies store — the same library the
5
5
  // task inspector's "Merge policy" dropdown selects from. Exactly one preset is the
6
6
  // default; it cannot be deleted or un-defaulted (the backend enforces this too).
7
7
  import { computed, reactive, ref, watch } from 'vue'
8
- import type { MergeThresholdPreset, RequirementConcernLevel } from '~/types/merge'
8
+ import type { RiskPolicy, RequirementConcernLevel } from '~/types/merge'
9
9
 
10
10
  const { t } = useI18n()
11
11
 
@@ -13,10 +13,10 @@ const { t } = useI18n()
13
13
  // the typecheck); each value is a LITERAL catalog key so the typed-message-keys check sees
14
14
  // it. Leaf keys mirror the enum value verbatim.
15
15
  const CONCERN_LABEL_KEYS: Record<RequirementConcernLevel, string> = {
16
- none: 'settings.mergeThresholds.concern.none',
17
- low: 'settings.mergeThresholds.concern.low',
18
- medium: 'settings.mergeThresholds.concern.medium',
19
- high: 'settings.mergeThresholds.concern.high',
16
+ none: 'settings.riskPolicy.concern.none',
17
+ low: 'settings.riskPolicy.concern.low',
18
+ medium: 'settings.riskPolicy.concern.medium',
19
+ high: 'settings.riskPolicy.concern.high',
20
20
  }
21
21
 
22
22
  // Concern-level options for the requirements auto-pass threshold (none < low < medium < high).
@@ -27,7 +27,7 @@ const CONCERN_LEVELS = computed<{ value: RequirementConcernLevel; label: string
27
27
  { value: 'high', label: t(CONCERN_LABEL_KEYS.high) },
28
28
  ])
29
29
 
30
- const store = useMergePresetsStore()
30
+ const store = useRiskPoliciesStore()
31
31
  const toast = useToast()
32
32
  const { confirm } = useConfirm()
33
33
 
@@ -45,7 +45,7 @@ interface Draft {
45
45
  }
46
46
  const drafts = reactive<Record<string, Draft>>({})
47
47
 
48
- function toDraft(p: MergeThresholdPreset): Draft {
48
+ function toDraft(p: RiskPolicy): Draft {
49
49
  return {
50
50
  name: p.name,
51
51
  maxComplexity: Math.round(p.maxComplexity * 100),
@@ -78,7 +78,7 @@ function notifyError(title: string, e: unknown) {
78
78
  })
79
79
  }
80
80
 
81
- async function save(p: MergeThresholdPreset) {
81
+ async function save(p: RiskPolicy) {
82
82
  const d = drafts[p.id]
83
83
  if (!d) return
84
84
  busy.value = p.id
@@ -94,32 +94,32 @@ async function save(p: MergeThresholdPreset) {
94
94
  autoMergeEnabled: d.autoMergeEnabled,
95
95
  })
96
96
  toast.add({
97
- title: t('settings.mergeThresholds.toast.saved'),
97
+ title: t('settings.riskPolicy.toast.saved'),
98
98
  icon: 'i-lucide-check',
99
99
  color: 'success',
100
100
  })
101
101
  } catch (e) {
102
- notifyError(t('settings.mergeThresholds.toast.saveFailed'), e)
102
+ notifyError(t('settings.riskPolicy.toast.saveFailed'), e)
103
103
  } finally {
104
104
  busy.value = null
105
105
  }
106
106
  }
107
107
 
108
- async function makeDefault(p: MergeThresholdPreset) {
108
+ async function makeDefault(p: RiskPolicy) {
109
109
  busy.value = p.id
110
110
  try {
111
111
  await store.update(p.id, { isDefault: true })
112
112
  } catch (e) {
113
- notifyError(t('settings.mergeThresholds.toast.defaultFailed'), e)
113
+ notifyError(t('settings.riskPolicy.toast.defaultFailed'), e)
114
114
  } finally {
115
115
  busy.value = null
116
116
  }
117
117
  }
118
118
 
119
- async function remove(p: MergeThresholdPreset) {
119
+ async function remove(p: RiskPolicy) {
120
120
  const ok = await confirm({
121
- title: t('settings.mergeThresholds.confirmDelete.title'),
122
- description: t('settings.mergeThresholds.confirmDelete.body', { name: p.name }),
121
+ title: t('settings.riskPolicy.confirmDelete.title'),
122
+ description: t('settings.riskPolicy.confirmDelete.body', { name: p.name }),
123
123
  variant: 'destructive',
124
124
  confirmLabel: t('common.delete'),
125
125
  icon: 'i-lucide-trash-2',
@@ -129,7 +129,7 @@ async function remove(p: MergeThresholdPreset) {
129
129
  try {
130
130
  await store.remove(p.id)
131
131
  } catch (e) {
132
- notifyError(t('settings.mergeThresholds.toast.deleteFailed'), e)
132
+ notifyError(t('settings.riskPolicy.toast.deleteFailed'), e)
133
133
  } finally {
134
134
  busy.value = null
135
135
  }
@@ -165,12 +165,12 @@ async function create() {
165
165
  draft.name = ''
166
166
  draft.autoMergeEnabled = true
167
167
  toast.add({
168
- title: t('settings.mergeThresholds.toast.created'),
168
+ title: t('settings.riskPolicy.toast.created'),
169
169
  icon: 'i-lucide-check',
170
170
  color: 'success',
171
171
  })
172
172
  } catch (e) {
173
- notifyError(t('settings.mergeThresholds.toast.createFailed'), e)
173
+ notifyError(t('settings.riskPolicy.toast.createFailed'), e)
174
174
  } finally {
175
175
  creating.value = false
176
176
  }
@@ -180,13 +180,13 @@ async function create() {
180
180
  <template>
181
181
  <div class="space-y-4">
182
182
  <i18n-t
183
- keypath="settings.mergeThresholds.intro"
183
+ keypath="settings.riskPolicy.intro"
184
184
  tag="p"
185
185
  class="text-xs text-slate-400"
186
186
  scope="global"
187
187
  >
188
188
  <template #merger>
189
- <span class="text-slate-300">{{ t('settings.mergeThresholds.mergerAgent') }}</span>
189
+ <span class="text-slate-300">{{ t('settings.riskPolicy.mergerAgent') }}</span>
190
190
  </template>
191
191
  </i18n-t>
192
192
 
@@ -200,10 +200,10 @@ async function create() {
200
200
  v-model="drafts[p.id]!.name"
201
201
  size="sm"
202
202
  class="flex-1"
203
- :placeholder="t('settings.mergeThresholds.presetNamePlaceholder')"
203
+ :placeholder="t('settings.riskPolicy.presetNamePlaceholder')"
204
204
  />
205
205
  <UBadge v-if="p.isDefault" color="primary" variant="subtle" size="sm">
206
- {{ t('settings.mergeThresholds.default') }}
206
+ {{ t('settings.riskPolicy.default') }}
207
207
  </UBadge>
208
208
  <UButton
209
209
  v-else
@@ -214,7 +214,7 @@ async function create() {
214
214
  :loading="busy === p.id"
215
215
  @click="makeDefault(p)"
216
216
  >
217
- {{ t('settings.mergeThresholds.makeDefault') }}
217
+ {{ t('settings.riskPolicy.makeDefault') }}
218
218
  </UButton>
219
219
  <UButton
220
220
  color="error"
@@ -224,8 +224,8 @@ async function create() {
224
224
  :disabled="p.isDefault || busy === p.id"
225
225
  :title="
226
226
  p.isDefault
227
- ? t('settings.mergeThresholds.deleteDefaultBlocked')
228
- : t('settings.mergeThresholds.deletePreset')
227
+ ? t('settings.riskPolicy.deleteDefaultBlocked')
228
+ : t('settings.riskPolicy.deletePreset')
229
229
  "
230
230
  @click="remove(p)"
231
231
  />
@@ -234,7 +234,7 @@ async function create() {
234
234
  <div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
235
235
  <label class="block">
236
236
  <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
237
- {{ t('settings.mergeThresholds.field.maxComplexity') }}
237
+ {{ t('settings.riskPolicy.field.maxComplexity') }}
238
238
  </span>
239
239
  <UInput
240
240
  v-model.number="drafts[p.id]!.maxComplexity"
@@ -246,7 +246,7 @@ async function create() {
246
246
  </label>
247
247
  <label class="block">
248
248
  <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
249
- {{ t('settings.mergeThresholds.field.maxRisk') }}
249
+ {{ t('settings.riskPolicy.field.maxRisk') }}
250
250
  </span>
251
251
  <UInput
252
252
  v-model.number="drafts[p.id]!.maxRisk"
@@ -258,7 +258,7 @@ async function create() {
258
258
  </label>
259
259
  <label class="block">
260
260
  <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
261
- {{ t('settings.mergeThresholds.field.maxImpact') }}
261
+ {{ t('settings.riskPolicy.field.maxImpact') }}
262
262
  </span>
263
263
  <UInput
264
264
  v-model.number="drafts[p.id]!.maxImpact"
@@ -270,7 +270,7 @@ async function create() {
270
270
  </label>
271
271
  <label class="block">
272
272
  <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
273
- {{ t('settings.mergeThresholds.field.ciMaxAttempts') }}
273
+ {{ t('settings.riskPolicy.field.ciMaxAttempts') }}
274
274
  </span>
275
275
  <UInput
276
276
  v-model.number="drafts[p.id]!.ciMaxAttempts"
@@ -282,7 +282,7 @@ async function create() {
282
282
  </label>
283
283
  <label class="block">
284
284
  <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
285
- {{ t('settings.mergeThresholds.field.maxRequirementIterations') }}
285
+ {{ t('settings.riskPolicy.field.maxRequirementIterations') }}
286
286
  </span>
287
287
  <UInput
288
288
  v-model.number="drafts[p.id]!.maxRequirementIterations"
@@ -294,7 +294,7 @@ async function create() {
294
294
  </label>
295
295
  <label class="block">
296
296
  <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
297
- {{ t('settings.mergeThresholds.field.maxRequirementConcernAllowed') }}
297
+ {{ t('settings.riskPolicy.field.maxRequirementConcernAllowed') }}
298
298
  </span>
299
299
  <USelect
300
300
  v-model="drafts[p.id]!.maxRequirementConcernAllowed"
@@ -309,11 +309,11 @@ async function create() {
309
309
  <USwitch
310
310
  v-model="drafts[p.id]!.autoMergeEnabled"
311
311
  size="sm"
312
- :label="t('settings.mergeThresholds.field.autoMerge')"
312
+ :label="t('settings.riskPolicy.field.autoMerge')"
313
313
  :description="
314
314
  drafts[p.id]!.autoMergeEnabled
315
- ? t('settings.mergeThresholds.autoMergeOnHint')
316
- : t('settings.mergeThresholds.autoMergeOffHint')
315
+ ? t('settings.riskPolicy.autoMergeOnHint')
316
+ : t('settings.riskPolicy.autoMergeOffHint')
317
317
  "
318
318
  />
319
319
  <UButton
@@ -332,22 +332,22 @@ async function create() {
332
332
  <!-- create -->
333
333
  <div class="rounded-lg border border-dashed border-slate-700 p-3">
334
334
  <p class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
335
- {{ t('settings.mergeThresholds.newPreset') }}
335
+ {{ t('settings.riskPolicy.newPreset') }}
336
336
  </p>
337
337
  <div class="flex flex-wrap items-end gap-3">
338
338
  <label class="block min-w-40 flex-1">
339
339
  <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
340
- {{ t('settings.mergeThresholds.create.name') }}
340
+ {{ t('settings.riskPolicy.create.name') }}
341
341
  </span>
342
342
  <UInput
343
343
  v-model="draft.name"
344
344
  size="sm"
345
- :placeholder="t('settings.mergeThresholds.create.namePlaceholder')"
345
+ :placeholder="t('settings.riskPolicy.create.namePlaceholder')"
346
346
  />
347
347
  </label>
348
348
  <label class="block w-20">
349
349
  <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
350
- {{ t('settings.mergeThresholds.create.complexity') }}
350
+ {{ t('settings.riskPolicy.create.complexity') }}
351
351
  </span>
352
352
  <UInput
353
353
  v-model.number="draft.maxComplexity"
@@ -359,25 +359,25 @@ async function create() {
359
359
  </label>
360
360
  <label class="block w-20">
361
361
  <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
362
- {{ t('settings.mergeThresholds.create.risk') }}
362
+ {{ t('settings.riskPolicy.create.risk') }}
363
363
  </span>
364
364
  <UInput v-model.number="draft.maxRisk" type="number" :min="0" :max="100" size="sm" />
365
365
  </label>
366
366
  <label class="block w-20">
367
367
  <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
368
- {{ t('settings.mergeThresholds.create.impact') }}
368
+ {{ t('settings.riskPolicy.create.impact') }}
369
369
  </span>
370
370
  <UInput v-model.number="draft.maxImpact" type="number" :min="0" :max="100" size="sm" />
371
371
  </label>
372
372
  <label class="block w-20">
373
373
  <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
374
- {{ t('settings.mergeThresholds.create.ciFix') }}
374
+ {{ t('settings.riskPolicy.create.ciFix') }}
375
375
  </span>
376
376
  <UInput v-model.number="draft.ciMaxAttempts" type="number" :min="0" :max="50" size="sm" />
377
377
  </label>
378
378
  <label class="block w-20">
379
379
  <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
380
- {{ t('settings.mergeThresholds.create.reqIter') }}
380
+ {{ t('settings.riskPolicy.create.reqIter') }}
381
381
  </span>
382
382
  <UInput
383
383
  v-model.number="draft.maxRequirementIterations"
@@ -389,7 +389,7 @@ async function create() {
389
389
  </label>
390
390
  <label class="block w-32">
391
391
  <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
392
- {{ t('settings.mergeThresholds.create.autoPass') }}
392
+ {{ t('settings.riskPolicy.create.autoPass') }}
393
393
  </span>
394
394
  <USelect
395
395
  v-model="draft.maxRequirementConcernAllowed"
@@ -401,7 +401,7 @@ async function create() {
401
401
  <USwitch
402
402
  v-model="draft.autoMergeEnabled"
403
403
  size="sm"
404
- :label="t('settings.mergeThresholds.field.autoMerge')"
404
+ :label="t('settings.riskPolicy.field.autoMerge')"
405
405
  />
406
406
  <UButton
407
407
  color="primary"
@@ -411,7 +411,7 @@ async function create() {
411
411
  :disabled="!draft.name.trim()"
412
412
  @click="create"
413
413
  >
414
- {{ t('settings.mergeThresholds.add') }}
414
+ {{ t('settings.riskPolicy.add') }}
415
415
  </UButton>
416
416
  </div>
417
417
  </div>
@@ -0,0 +1,142 @@
1
+ <script setup lang="ts">
2
+ // The Usage report: token usage this billing period, broken down into flat-rate
3
+ // subscription harness usage (Claude Code / Codex / GLM / pooled Kimi & DeepSeek) and
4
+ // metered API/proxy calls. Reporting only — the spend budget (Budget tab) still counts
5
+ // only the metered rows. See docs/initiatives/usage-and-quota-tracking.md.
6
+ import { computed, watch } from 'vue'
7
+ import type { UsageBreakdownRow } from '@cat-factory/contracts'
8
+
9
+ const { t, n, d } = useI18n()
10
+ const usage = useUsageStore()
11
+ const workspace = useWorkspaceStore()
12
+
13
+ // Load (and reload) whenever the active workspace resolves/changes.
14
+ watch(
15
+ () => workspace.workspaceId,
16
+ (ws) => {
17
+ if (ws) usage.load(ws)
18
+ },
19
+ { immediate: true },
20
+ )
21
+
22
+ const currency = computed(() => usage.report?.currency ?? 'EUR')
23
+ const money = (value: number) => n(value, { key: 'currency', currency: currency.value })
24
+
25
+ // The largest single-row token total in a section, so the per-row bars are relative to the
26
+ // section's heaviest model (a full bar = the biggest consumer, not an absolute quota).
27
+ function maxTokens(rows: UsageBreakdownRow[]): number {
28
+ return rows.reduce((m, r) => Math.max(m, r.inputTokens + r.outputTokens), 0)
29
+ }
30
+ function pctOf(row: UsageBreakdownRow, rows: UsageBreakdownRow[]): number {
31
+ const max = maxTokens(rows)
32
+ if (max <= 0) return 0
33
+ return Math.round(((row.inputTokens + row.outputTokens) / max) * 100)
34
+ }
35
+
36
+ const hasAny = computed(() => usage.rows.length > 0)
37
+ </script>
38
+
39
+ <template>
40
+ <div class="space-y-6">
41
+ <p class="text-[11px] text-slate-400">
42
+ {{ t('settings.usage.body') }}
43
+ </p>
44
+
45
+ <p v-if="usage.report" class="text-[11px] text-slate-500">
46
+ {{ t('settings.usage.period', { date: d(new Date(usage.report.periodStart), 'short') }) }}
47
+ </p>
48
+
49
+ <p v-if="usage.loading" class="text-[11px] text-slate-500">{{ t('common.loading') }}</p>
50
+ <p v-else-if="usage.error" class="text-[11px] text-rose-400">{{ usage.error }}</p>
51
+ <p v-else-if="!hasAny" class="text-[11px] text-slate-500">{{ t('settings.usage.empty') }}</p>
52
+
53
+ <template v-else>
54
+ <!-- Subscriptions (flat-rate quota harnesses) -->
55
+ <section v-if="usage.subscription.length" class="space-y-3">
56
+ <div class="flex items-baseline justify-between">
57
+ <h3 class="text-sm font-semibold text-slate-200">
58
+ {{ t('settings.usage.subscription') }}
59
+ </h3>
60
+ <span class="text-[11px] text-slate-400">
61
+ {{
62
+ t('settings.usage.tokens', {
63
+ input: formatTokens(usage.subscriptionTotal.inputTokens),
64
+ output: formatTokens(usage.subscriptionTotal.outputTokens),
65
+ })
66
+ }}
67
+ </span>
68
+ </div>
69
+ <p class="text-[10px] text-slate-500">{{ t('settings.usage.illustrative') }}</p>
70
+ <div
71
+ v-for="row in usage.subscription"
72
+ :key="`sub-${row.vendor}-${row.provider}-${row.model}`"
73
+ class="space-y-1"
74
+ :data-testid="'usage-row-subscription'"
75
+ >
76
+ <div class="flex items-center justify-between gap-2 text-[11px]">
77
+ <span class="min-w-0 truncate font-medium text-slate-300">{{ row.model }}</span>
78
+ <span class="shrink-0 rounded bg-slate-700/60 px-1.5 py-0.5 text-[10px] text-slate-300">
79
+ {{ row.vendor ?? row.provider }}
80
+ </span>
81
+ </div>
82
+ <UProgress :model-value="pctOf(row, usage.subscription)" size="xs" color="primary" />
83
+ <div class="flex justify-between text-[10px] text-slate-500">
84
+ <span>
85
+ {{
86
+ t('settings.usage.tokens', {
87
+ input: formatTokens(row.inputTokens),
88
+ output: formatTokens(row.outputTokens),
89
+ })
90
+ }}
91
+ · {{ t('settings.usage.calls', { count: row.calls }) }}
92
+ </span>
93
+ <span>~{{ money(row.costEstimate) }}</span>
94
+ </div>
95
+ </div>
96
+ </section>
97
+
98
+ <!-- Metered API / proxy calls (real spend) -->
99
+ <section v-if="usage.metered.length" class="space-y-3">
100
+ <div class="flex items-baseline justify-between">
101
+ <h3 class="text-sm font-semibold text-slate-200">
102
+ {{ t('settings.usage.metered') }}
103
+ </h3>
104
+ <span class="text-[11px] text-slate-400">
105
+ {{
106
+ t('settings.usage.tokens', {
107
+ input: formatTokens(usage.meteredTotal.inputTokens),
108
+ output: formatTokens(usage.meteredTotal.outputTokens),
109
+ })
110
+ }}
111
+ · {{ money(usage.meteredTotal.costEstimate) }}
112
+ </span>
113
+ </div>
114
+ <div
115
+ v-for="row in usage.metered"
116
+ :key="`met-${row.provider}-${row.model}`"
117
+ class="space-y-1"
118
+ :data-testid="'usage-row-metered'"
119
+ >
120
+ <div class="flex items-center justify-between gap-2 text-[11px]">
121
+ <span class="min-w-0 truncate font-medium text-slate-300">
122
+ {{ row.provider }}:{{ row.model }}
123
+ </span>
124
+ </div>
125
+ <UProgress :model-value="pctOf(row, usage.metered)" size="xs" color="neutral" />
126
+ <div class="flex justify-between text-[10px] text-slate-500">
127
+ <span>
128
+ {{
129
+ t('settings.usage.tokens', {
130
+ input: formatTokens(row.inputTokens),
131
+ output: formatTokens(row.outputTokens),
132
+ })
133
+ }}
134
+ · {{ t('settings.usage.calls', { count: row.calls }) }}
135
+ </span>
136
+ <span>{{ money(row.costEstimate) }}</span>
137
+ </div>
138
+ </div>
139
+ </section>
140
+ </template>
141
+ </div>
142
+ </template>
@@ -9,10 +9,11 @@
9
9
  // standalone modals).
10
10
  import { reactive, ref, watch } from 'vue'
11
11
  import type { CreateTaskType, TaskLimitMode } from '~/types/domain'
12
- import MergeThresholdsPanel from '~/components/settings/MergeThresholdsPanel.vue'
12
+ import RiskPolicyPanel from '~/components/settings/RiskPolicyPanel.vue'
13
13
  import IssueTrackerPanel from '~/components/settings/IssueTrackerPanel.vue'
14
14
  import ServiceFragmentDefaultsPanel from '~/components/settings/ServiceFragmentDefaultsPanel.vue'
15
15
  import BudgetSettings from '~/components/settings/BudgetSettings.vue'
16
+ import UsageSettings from '~/components/settings/UsageSettings.vue'
16
17
  import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
17
18
 
18
19
  const { t, te } = useI18n()
@@ -46,6 +47,12 @@ const tabs = computed(() => [
46
47
  icon: 'i-lucide-wallet',
47
48
  slot: 'budget',
48
49
  },
50
+ {
51
+ value: 'usage',
52
+ label: t('settings.workspaceSettings.tabs.usage'),
53
+ icon: 'i-lucide-bar-chart-3',
54
+ slot: 'usage',
55
+ },
49
56
  {
50
57
  value: 'merge',
51
58
  label: t('settings.workspaceSettings.tabs.merge'),
@@ -331,9 +338,14 @@ async function save() {
331
338
  <BudgetSettings />
332
339
  </template>
333
340
 
341
+ <!-- Usage report (metered + subscription token usage this period) -->
342
+ <template #usage>
343
+ <UsageSettings />
344
+ </template>
345
+
334
346
  <!-- Merge thresholds -->
335
347
  <template #merge>
336
- <MergeThresholdsPanel />
348
+ <RiskPolicyPanel />
337
349
  </template>
338
350
 
339
351
  <!-- Issue tracker -->
@@ -47,7 +47,7 @@ export function boardApi({ send, ws }: ApiContext) {
47
47
  description?: string
48
48
  taskType?: CreateTaskType
49
49
  taskTypeFields?: TaskTypeFields
50
- mergePresetId?: string
50
+ riskPolicyId?: string
51
51
  modelPresetId?: string
52
52
  pipelineId?: string
53
53
  agentConfig?: Record<string, string>
@@ -5,6 +5,7 @@ import {
5
5
  getExecutionAgentContextContract,
6
6
  getExecutionLlmMetricsContract,
7
7
  getExecutionSearchQueriesContract,
8
+ getWorkspaceUsageContract,
8
9
  mergeBlockContract,
9
10
  rejectStepContract,
10
11
  requestStepChangesContract,
@@ -157,5 +158,9 @@ export function executionApi({ send, sendWith, ws, pwHeaders }: ApiContext) {
157
158
  // ---- spend safeguard --------------------------------------------------
158
159
  resumeSpend: (workspaceId: string) =>
159
160
  send(resumeSpendContract, { pathPrefix: ws(workspaceId) }),
161
+
162
+ // ---- usage report -----------------------------------------------------
163
+ getUsage: (workspaceId: string) =>
164
+ send(getWorkspaceUsageContract, { pathPrefix: ws(workspaceId) }),
160
165
  }
161
166
  }