@cat-factory/app 0.186.0 → 0.188.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 (34) hide show
  1. package/README.md +26 -0
  2. package/app/components/consensus/ConsensusSessionWindow.vue +11 -0
  3. package/app/components/fragments/FragmentLibraryManager.vue +39 -2
  4. package/app/components/palettes/AgentPalette.vue +12 -1
  5. package/app/components/palettes/AgentTierSelect.vue +67 -0
  6. package/app/components/pipeline/PipelineBuilder.vue +166 -92
  7. package/app/components/settings/ConsensusGroupsSection.vue +498 -0
  8. package/app/components/settings/ModelConfigurationPanel.vue +34 -2
  9. package/app/composables/api/presets.ts +22 -0
  10. package/app/modular/agent-kinds.spec.ts +9 -3
  11. package/app/modular/agent-kinds.ts +4 -0
  12. package/app/stores/agentTier.spec.ts +24 -0
  13. package/app/stores/agentTier.ts +42 -0
  14. package/app/stores/consensusGroups.ts +77 -0
  15. package/app/stores/pipelines/draftActions.ts +11 -113
  16. package/app/stores/pipelines/draftStepConfig.ts +157 -0
  17. package/app/stores/workspace/hydrate.ts +2 -0
  18. package/app/types/consensus.ts +3 -0
  19. package/app/types/domain.ts +8 -1
  20. package/app/utils/agentTier.spec.ts +59 -0
  21. package/app/utils/agentTier.ts +49 -0
  22. package/app/utils/catalog.spec.ts +12 -0
  23. package/app/utils/catalog.ts +58 -2
  24. package/i18n/locales/de.json +61 -2
  25. package/i18n/locales/en.json +68 -2
  26. package/i18n/locales/es.json +61 -2
  27. package/i18n/locales/fr.json +61 -2
  28. package/i18n/locales/he.json +61 -2
  29. package/i18n/locales/it.json +61 -2
  30. package/i18n/locales/ja.json +61 -2
  31. package/i18n/locales/pl.json +61 -2
  32. package/i18n/locales/tr.json +61 -2
  33. package/i18n/locales/uk.json +61 -2
  34. package/package.json +2 -2
@@ -0,0 +1,498 @@
1
+ <script setup lang="ts">
2
+ // The workspace's CONSENSUS-GROUP library, rendered as a section of the Model Configuration
3
+ // screen. A group is a reusable review panel — participants (role + perspective framing +
4
+ // model), the strategy that runs them, a synthesizer — plus the ESTIMATE BAR a task must clear
5
+ // to earn it. A pipeline step names a SET of groups and the engine runs the most demanding tier
6
+ // the task clears, so the library is where "which models review our risky work" is decided once
7
+ // instead of per pipeline.
8
+ //
9
+ // It lives beside the model presets rather than in its own destination because it answers the
10
+ // same question those do — which models do the work — and splitting them is what sends people
11
+ // hunting through Integrations for a model setting.
12
+ import { computed, ref } from 'vue'
13
+ import type { ConsensusGroup, ConsensusStrategy } from '~/types/consensus'
14
+ import { isSelectable } from '~/stores/models'
15
+
16
+ const { t } = useI18n()
17
+ const groups = useConsensusGroupsStore()
18
+ const models = useModelsStore()
19
+ const creds = useVendorCredentialsStore()
20
+ const uiMode = useUiModeStore()
21
+ const toast = useToast()
22
+ const { confirm } = useConfirm()
23
+
24
+ /**
25
+ * Advanced-tier authoring that REVEALS itself once the workspace has a library. Hiding a
26
+ * populated library in basic mode would leave a basic-mode user looking at tier chips in the
27
+ * pipeline builder with no way to find out what they contain — the same failure the override
28
+ * rules exist to prevent.
29
+ */
30
+ const visible = computed(() => uiMode.isAdvanced || groups.hasGroups)
31
+
32
+ const STRATEGIES = computed<{ value: ConsensusStrategy; label: string }[]>(() => [
33
+ { value: 'specialist-panel', label: t('pipeline.builder.strategyOption.specialist-panel') },
34
+ { value: 'debate', label: t('pipeline.builder.strategyOption.debate') },
35
+ { value: 'ranked-voting', label: t('pipeline.builder.strategyOption.ranked-voting') },
36
+ ])
37
+
38
+ interface EditorState {
39
+ id?: string
40
+ name: string
41
+ description: string
42
+ strategy: ConsensusStrategy
43
+ participants: { id: string; role: string; systemFraming?: string; modelId?: string }[]
44
+ synthesizerModelId: string
45
+ rounds: number | null
46
+ gated: boolean
47
+ minComplexity: number | null
48
+ minRisk: number | null
49
+ minImpact: number | null
50
+ }
51
+
52
+ const editor = ref<EditorState | null>(null)
53
+ const busy = ref(false)
54
+
55
+ function uid(prefix: string) {
56
+ return `${prefix}_${Math.random().toString(36).slice(2, 9)}`
57
+ }
58
+
59
+ /** A fresh group starts as a gated two-model panel — the shape the feature is for. */
60
+ function startCreate() {
61
+ editor.value = {
62
+ name: '',
63
+ description: '',
64
+ strategy: 'specialist-panel',
65
+ participants: [
66
+ { id: uid('cnp'), role: 'Pragmatist', systemFraming: 'Favour the simplest viable approach.' },
67
+ {
68
+ id: uid('cnp'),
69
+ role: 'Skeptic',
70
+ systemFraming: 'Probe risks, edge cases and failure modes.',
71
+ },
72
+ ],
73
+ synthesizerModelId: '',
74
+ rounds: null,
75
+ gated: true,
76
+ minComplexity: null,
77
+ minRisk: 0.7,
78
+ minImpact: null,
79
+ }
80
+ }
81
+
82
+ function startEdit(group: ConsensusGroup) {
83
+ editor.value = {
84
+ id: group.id,
85
+ name: group.name,
86
+ description: group.description ?? '',
87
+ strategy: group.strategy,
88
+ participants: group.participants.map((p) => ({ ...p })),
89
+ synthesizerModelId: group.synthesizerModelId ?? '',
90
+ rounds: group.rounds ?? null,
91
+ gated: group.gating.enabled,
92
+ minComplexity: group.gating.minComplexity ?? null,
93
+ minRisk: group.gating.minRisk ?? null,
94
+ minImpact: group.gating.minImpact ?? null,
95
+ }
96
+ }
97
+
98
+ function addParticipant() {
99
+ editor.value?.participants.push({ id: uid('cnp'), role: '' })
100
+ }
101
+
102
+ function removeParticipant(index: number) {
103
+ editor.value?.participants.splice(index, 1)
104
+ }
105
+
106
+ /** The bar a stored group sets, for the list rows. */
107
+ function barLabel(group: ConsensusGroup): string {
108
+ const bar = groups.barFor(group)
109
+ return bar === null
110
+ ? t('settings.consensusGroups.list.always')
111
+ : t('settings.consensusGroups.list.bar', { bar })
112
+ }
113
+
114
+ const selectableModelIds = computed(() => {
115
+ const configured = creds.configuredVendors
116
+ return models.models
117
+ .filter((m) => isSelectable(m, configured))
118
+ .map((m) => ({ id: m.id, label: m.label }))
119
+ })
120
+
121
+ /**
122
+ * A threshold field's value as the contract wants it, or undefined when the author left it blank.
123
+ *
124
+ * `v-model.number` does NOT yield null for an emptied input: Vue's coercion returns the RAW value
125
+ * when it cannot parse a number, so clearing a box a user had typed in leaves `''` behind. Read
126
+ * back with a `!== null` test that empty string passes every guard, reaches the wire as
127
+ * `minRisk: ''`, and comes back a 422 behind a generic "could not save" toast. Anything that is
128
+ * not a finite number is therefore treated as absent, at the one place the value crosses out of
129
+ * the editor.
130
+ */
131
+ function threshold(value: unknown): number | undefined {
132
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined
133
+ }
134
+
135
+ /** The thresholds the author actually set, in contract shape. */
136
+ function thresholds(e: EditorState) {
137
+ return {
138
+ ...(threshold(e.minComplexity) !== undefined
139
+ ? { minComplexity: threshold(e.minComplexity) }
140
+ : {}),
141
+ ...(threshold(e.minRisk) !== undefined ? { minRisk: threshold(e.minRisk) } : {}),
142
+ ...(threshold(e.minImpact) !== undefined ? { minImpact: threshold(e.minImpact) } : {}),
143
+ }
144
+ }
145
+
146
+ /**
147
+ * The gating payload. A gated group with no threshold is refused by the backend (it could never
148
+ * be selected), so surface that here rather than as a save failure.
149
+ */
150
+ function gatingPayload(e: EditorState) {
151
+ if (!e.gated) return { enabled: false as const, onMissingEstimate: 'consensus' as const }
152
+ return {
153
+ enabled: true as const,
154
+ ...thresholds(e),
155
+ onMissingEstimate: 'consensus' as const,
156
+ }
157
+ }
158
+
159
+ const gatingIncomplete = computed(
160
+ () => !!editor.value?.gated && Object.keys(thresholds(editor.value)).length === 0,
161
+ )
162
+
163
+ async function save() {
164
+ const e = editor.value
165
+ if (!e) return
166
+ if (!e.name.trim() || gatingIncomplete.value) return
167
+ const body = {
168
+ name: e.name.trim(),
169
+ ...(e.description.trim() ? { description: e.description.trim() } : {}),
170
+ strategy: e.strategy,
171
+ participants: e.participants.map((p) => ({
172
+ id: p.id,
173
+ role: p.role.trim() || t('settings.consensusGroups.editor.unnamedRole'),
174
+ ...(p.systemFraming?.trim() ? { systemFraming: p.systemFraming.trim() } : {}),
175
+ ...(p.modelId?.trim() ? { modelId: p.modelId.trim() } : {}),
176
+ })),
177
+ ...(e.synthesizerModelId.trim() ? { synthesizerModelId: e.synthesizerModelId.trim() } : {}),
178
+ ...(e.rounds !== null ? { rounds: e.rounds } : {}),
179
+ gating: gatingPayload(e),
180
+ }
181
+ busy.value = true
182
+ try {
183
+ if (e.id) await groups.update(e.id, body)
184
+ else await groups.create(body)
185
+ editor.value = null
186
+ } catch (err) {
187
+ fail(t('settings.consensusGroups.toast.saveFailed'), err)
188
+ } finally {
189
+ busy.value = false
190
+ }
191
+ }
192
+
193
+ async function remove(group: ConsensusGroup) {
194
+ const ok = await confirm({
195
+ title: t('settings.consensusGroups.confirmDelete.title'),
196
+ description: t('settings.consensusGroups.confirmDelete.body', { name: group.name }),
197
+ variant: 'destructive',
198
+ confirmLabel: t('common.delete'),
199
+ icon: 'i-lucide-trash-2',
200
+ })
201
+ if (!ok) return
202
+ busy.value = true
203
+ try {
204
+ await groups.remove(group.id)
205
+ } catch (err) {
206
+ fail(t('settings.consensusGroups.toast.deleteFailed'), err)
207
+ } finally {
208
+ busy.value = false
209
+ }
210
+ }
211
+
212
+ function fail(title: string, e: unknown) {
213
+ toast.add({
214
+ title,
215
+ description: e instanceof Error ? e.message : String(e),
216
+ icon: 'i-lucide-triangle-alert',
217
+ color: 'error',
218
+ })
219
+ }
220
+ </script>
221
+
222
+ <template>
223
+ <section v-if="visible" class="space-y-3 border-t border-slate-800 pt-5">
224
+ <div class="flex items-start justify-between gap-3">
225
+ <div class="min-w-0">
226
+ <h2 class="text-sm font-semibold text-slate-100">
227
+ {{ t('settings.consensusGroups.title') }}
228
+ </h2>
229
+ <p class="mt-1 text-xs leading-relaxed text-slate-500">
230
+ {{ t('settings.consensusGroups.intro') }}
231
+ </p>
232
+ </div>
233
+ <UButton
234
+ v-if="!editor"
235
+ icon="i-lucide-plus"
236
+ color="neutral"
237
+ variant="soft"
238
+ size="sm"
239
+ class="shrink-0"
240
+ data-testid="consensus-group-new"
241
+ @click="startCreate"
242
+ >
243
+ {{ t('settings.consensusGroups.new') }}
244
+ </UButton>
245
+ </div>
246
+
247
+ <!-- ===== list ===== -->
248
+ <template v-if="!editor">
249
+ <div v-if="groups.groups.length" class="space-y-2">
250
+ <div
251
+ v-for="g in groups.groups"
252
+ :key="g.id"
253
+ class="rounded-xl border border-slate-800 bg-slate-900/50 p-3"
254
+ data-testid="consensus-group-row"
255
+ >
256
+ <div class="flex items-center gap-2">
257
+ <span class="truncate text-sm font-semibold text-slate-100">{{ g.name }}</span>
258
+ <UBadge color="neutral" variant="subtle" size="xs">{{ barLabel(g) }}</UBadge>
259
+ <div class="ms-auto flex items-center gap-1">
260
+ <UButton
261
+ size="xs"
262
+ variant="ghost"
263
+ color="neutral"
264
+ icon="i-lucide-pencil"
265
+ :title="t('settings.consensusGroups.list.editTitle')"
266
+ @click="startEdit(g)"
267
+ />
268
+ <UButton
269
+ size="xs"
270
+ variant="ghost"
271
+ color="error"
272
+ icon="i-lucide-trash-2"
273
+ :loading="busy"
274
+ :title="t('settings.consensusGroups.list.deleteTitle')"
275
+ @click="remove(g)"
276
+ />
277
+ </div>
278
+ </div>
279
+ <p v-if="g.description" class="mt-1 text-[11px] text-slate-400">{{ g.description }}</p>
280
+ <div class="mt-1.5 text-[11px] text-slate-400">
281
+ {{ t(`pipeline.builder.strategyOption.${g.strategy}`) }}
282
+ ·
283
+ {{
284
+ t(
285
+ 'settings.consensusGroups.list.participantCount',
286
+ { count: g.participants.length },
287
+ g.participants.length,
288
+ )
289
+ }}
290
+ </div>
291
+ </div>
292
+ </div>
293
+ <p v-else class="py-4 text-center text-sm text-slate-500">
294
+ {{ t('settings.consensusGroups.list.empty') }}
295
+ </p>
296
+ </template>
297
+
298
+ <!-- ===== editor ===== -->
299
+ <div v-else class="space-y-4 rounded-xl border border-slate-800 bg-slate-900/50 p-4">
300
+ <div class="grid gap-3 sm:grid-cols-2">
301
+ <div>
302
+ <label
303
+ class="mb-1 block text-[11px] font-semibold uppercase tracking-wide text-slate-400"
304
+ >
305
+ {{ t('settings.consensusGroups.editor.nameLabel') }}
306
+ </label>
307
+ <UInput
308
+ v-model="editor.name"
309
+ size="sm"
310
+ class="w-full"
311
+ :placeholder="t('settings.consensusGroups.editor.namePlaceholder')"
312
+ />
313
+ </div>
314
+ <div>
315
+ <label
316
+ class="mb-1 block text-[11px] font-semibold uppercase tracking-wide text-slate-400"
317
+ >
318
+ {{ t('settings.consensusGroups.editor.strategyLabel') }}
319
+ </label>
320
+ <select
321
+ v-model="editor.strategy"
322
+ class="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1.5 text-sm text-slate-100"
323
+ >
324
+ <option v-for="s in STRATEGIES" :key="s.value" :value="s.value">{{ s.label }}</option>
325
+ </select>
326
+ </div>
327
+ </div>
328
+
329
+ <div>
330
+ <label class="mb-1 block text-[11px] font-semibold uppercase tracking-wide text-slate-400">
331
+ {{ t('settings.consensusGroups.editor.descriptionLabel') }}
332
+ </label>
333
+ <UInput
334
+ v-model="editor.description"
335
+ size="sm"
336
+ class="w-full"
337
+ :placeholder="t('settings.consensusGroups.editor.descriptionPlaceholder')"
338
+ />
339
+ </div>
340
+
341
+ <!-- participants -->
342
+ <div class="space-y-2">
343
+ <div class="flex items-center justify-between">
344
+ <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
345
+ {{ t('settings.consensusGroups.editor.participantsLabel') }}
346
+ </span>
347
+ <UButton
348
+ icon="i-lucide-plus"
349
+ color="neutral"
350
+ variant="ghost"
351
+ size="xs"
352
+ :label="t('settings.consensusGroups.editor.addParticipant')"
353
+ @click="addParticipant"
354
+ />
355
+ </div>
356
+ <p class="text-[11px] text-slate-500">
357
+ {{ t('settings.consensusGroups.editor.participantsHint') }}
358
+ </p>
359
+ <div
360
+ v-for="(p, index) in editor.participants"
361
+ :key="p.id"
362
+ class="flex flex-wrap items-center gap-1.5"
363
+ >
364
+ <UInput
365
+ v-model="p.role"
366
+ size="xs"
367
+ class="w-32"
368
+ :placeholder="t('pipeline.builder.rolePlaceholder')"
369
+ />
370
+ <UInput
371
+ v-model="p.systemFraming"
372
+ size="xs"
373
+ class="min-w-40 flex-1"
374
+ :placeholder="t('settings.consensusGroups.editor.framingPlaceholder')"
375
+ />
376
+ <select
377
+ v-model="p.modelId"
378
+ class="w-44 rounded border border-slate-700 bg-slate-900 px-1.5 py-1 text-xs text-slate-300"
379
+ >
380
+ <option :value="undefined">{{ t('settings.consensusGroups.editor.stepModel') }}</option>
381
+ <option v-for="m in selectableModelIds" :key="m.id" :value="m.id">{{ m.label }}</option>
382
+ </select>
383
+ <UButton
384
+ icon="i-lucide-x"
385
+ color="error"
386
+ variant="ghost"
387
+ size="xs"
388
+ :disabled="editor.participants.length <= 2"
389
+ :title="t('pipeline.builder.removeParticipant')"
390
+ @click="removeParticipant(index)"
391
+ />
392
+ </div>
393
+ </div>
394
+
395
+ <div class="grid gap-3 sm:grid-cols-2">
396
+ <div>
397
+ <label
398
+ class="mb-1 block text-[11px] font-semibold uppercase tracking-wide text-slate-400"
399
+ >
400
+ {{ t('settings.consensusGroups.editor.synthesizerLabel') }}
401
+ </label>
402
+ <select
403
+ v-model="editor.synthesizerModelId"
404
+ class="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1.5 text-sm text-slate-100"
405
+ >
406
+ <option value="">{{ t('settings.consensusGroups.editor.stepModel') }}</option>
407
+ <option v-for="m in selectableModelIds" :key="m.id" :value="m.id">{{ m.label }}</option>
408
+ </select>
409
+ </div>
410
+ <div v-if="editor.strategy === 'debate'">
411
+ <label
412
+ class="mb-1 block text-[11px] font-semibold uppercase tracking-wide text-slate-400"
413
+ >
414
+ {{ t('pipeline.builder.rounds') }}
415
+ </label>
416
+ <UInput v-model.number="editor.rounds" type="number" min="1" max="5" size="sm" />
417
+ </div>
418
+ </div>
419
+
420
+ <!-- the estimate bar -->
421
+ <div class="space-y-2 rounded-lg border border-slate-800 bg-slate-950/40 p-3">
422
+ <label class="flex items-center gap-2 text-xs text-slate-300">
423
+ <input v-model="editor.gated" type="checkbox" class="accent-emerald-500" />
424
+ {{ t('settings.consensusGroups.editor.gatedLabel') }}
425
+ </label>
426
+ <p class="text-[11px] text-slate-500">
427
+ {{ t('settings.consensusGroups.editor.gatedHint') }}
428
+ </p>
429
+ <div v-if="editor.gated" class="flex flex-wrap items-center gap-3 text-xs">
430
+ <label class="flex items-center gap-1.5 text-slate-400">
431
+ {{ t('pipeline.builder.riskThreshold') }}
432
+ <UInput
433
+ v-model.number="editor.minRisk"
434
+ type="number"
435
+ min="0"
436
+ max="1"
437
+ step="0.1"
438
+ size="xs"
439
+ class="w-20"
440
+ />
441
+ </label>
442
+ <label class="flex items-center gap-1.5 text-slate-400">
443
+ {{ t('pipeline.builder.impactThreshold') }}
444
+ <UInput
445
+ v-model.number="editor.minImpact"
446
+ type="number"
447
+ min="0"
448
+ max="1"
449
+ step="0.1"
450
+ size="xs"
451
+ class="w-20"
452
+ />
453
+ </label>
454
+ <label class="flex items-center gap-1.5 text-slate-400">
455
+ {{ t('settings.consensusGroups.editor.complexityThreshold') }}
456
+ <UInput
457
+ v-model.number="editor.minComplexity"
458
+ type="number"
459
+ min="0"
460
+ max="1"
461
+ step="0.1"
462
+ size="xs"
463
+ class="w-20"
464
+ />
465
+ </label>
466
+ </div>
467
+ <p v-if="gatingIncomplete" class="text-[11px] text-amber-400">
468
+ {{ t('settings.consensusGroups.editor.gatingIncomplete') }}
469
+ </p>
470
+ </div>
471
+
472
+ <div class="flex items-center justify-end gap-2">
473
+ <UButton
474
+ color="neutral"
475
+ variant="ghost"
476
+ size="sm"
477
+ @click="
478
+ () => {
479
+ editor = null
480
+ }
481
+ "
482
+ >
483
+ {{ t('settings.consensusGroups.editor.cancel') }}
484
+ </UButton>
485
+ <UButton
486
+ color="primary"
487
+ size="sm"
488
+ :loading="busy"
489
+ :disabled="!editor.name.trim() || gatingIncomplete"
490
+ data-testid="consensus-group-save"
491
+ @click="save"
492
+ >
493
+ {{ t('settings.consensusGroups.editor.save') }}
494
+ </UButton>
495
+ </div>
496
+ </div>
497
+ </section>
498
+ </template>
@@ -14,14 +14,18 @@ import { computed, ref, watch } from 'vue'
14
14
  import { onKeyStroke } from '@vueuse/core'
15
15
  import type { AgentKind } from '~/types/domain'
16
16
  import type { ModelPreset } from '~/types/model-presets'
17
+ import AgentTierSelect from '~/components/palettes/AgentTierSelect.vue'
18
+ import { filterByAgentTierKeeping } from '~/utils/agentTier'
17
19
  import { MODEL_CONFIGURABLE_SYSTEM_KINDS } from '~/utils/catalog'
18
20
  import { cachingLabel, contextLabel, costLabel, displayFlavor, isSelectable } from '~/stores/models'
21
+ import ConsensusGroupsSection from '~/components/settings/ConsensusGroupsSection.vue'
19
22
 
20
23
  const { t } = useI18n()
21
24
  const ui = useUiStore()
22
25
  const models = useModelsStore()
23
26
  const presets = useModelPresetsStore()
24
27
  const agents = useAgentsStore()
28
+ const agentTier = useAgentTierStore()
25
29
  const creds = useVendorCredentialsStore()
26
30
  const workspace = useWorkspaceStore()
27
31
  const toast = useToast()
@@ -49,14 +53,36 @@ const filter = ref('')
49
53
  // (spec-writer, merger, the fixers/resolver). The pure gates run no model, so they
50
54
  // stay out — exactly the set the per-agent override list should cover.
51
55
  const configurableKinds = computed(() => [...agents.archetypes, ...MODEL_CONFIGURABLE_SYSTEM_KINDS])
56
+
57
+ // Narrowed to the selected agent tier, EXCEPT that a kind the preset being edited already
58
+ // pins a model for is always kept: that override may have been written by a teammate, by the
59
+ // API, or by this user at a wider tier, and a row hidden here is one they can neither read
60
+ // nor clear. Same rule the interface mode's `showOverrideField` states for a single field.
61
+ const tieredKinds = computed(() =>
62
+ filterByAgentTierKeeping(
63
+ configurableKinds.value,
64
+ agentTier.tier,
65
+ (a) => editor.value?.overrides[a.kind] !== undefined,
66
+ ),
67
+ )
68
+
52
69
  const filteredKinds = computed(() => {
53
70
  const q = filter.value.trim().toLowerCase()
54
- if (!q) return configurableKinds.value
71
+ if (!q) return tieredKinds.value
72
+ // A typed query searches the WHOLE catalog, not the tiered slice: naming an agent is a
73
+ // stronger statement of intent than the tier default, and "No agents match" for a kind the
74
+ // user can spell would read as "this deployment doesn't have it".
55
75
  return configurableKinds.value.filter(
56
76
  (a) => a.label.toLowerCase().includes(q) || String(a.kind).toLowerCase().includes(q),
57
77
  )
58
78
  })
59
79
 
80
+ // Nothing is being held back while a search is running (it spans every kind), so the hint
81
+ // reports 0 rather than a count the visible list contradicts.
82
+ const hiddenByTier = computed(() =>
83
+ filter.value.trim() ? 0 : configurableKinds.value.length - tieredKinds.value.length,
84
+ )
85
+
60
86
  watch(
61
87
  open,
62
88
  (isOpen) => {
@@ -405,6 +431,11 @@ function fail(title: string, e: unknown) {
405
431
  {{ t('settings.modelConfiguration.list.empty') }}
406
432
  </p>
407
433
  </div>
434
+
435
+ <!-- The consensus-GROUP library: which model PANELS review the workspace's heavier
436
+ tasks, and at what estimate bar. Beside the presets because both answer "which
437
+ models do the work"; its own component so this screen keeps its size budget. -->
438
+ <ConsensusGroupsSection />
408
439
  </template>
409
440
 
410
441
  <!-- ===== editor view ===== -->
@@ -453,10 +484,11 @@ function fail(title: string, e: unknown) {
453
484
  </div>
454
485
 
455
486
  <div>
456
- <div class="mb-1 flex items-center justify-between">
487
+ <div class="mb-1 flex items-start justify-between gap-3">
457
488
  <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
458
489
  {{ t('settings.modelConfiguration.editor.perAgentOverrides') }}
459
490
  </span>
491
+ <AgentTierSelect class="w-56 shrink-0" :hidden-count="hiddenByTier" />
460
492
  </div>
461
493
  <UInput
462
494
  v-model="filter"
@@ -2,6 +2,10 @@ import {
2
2
  createRiskPolicyContract,
3
3
  listMergeClassRollupsContract,
4
4
  tagMergeReviewEffortContract,
5
+ createConsensusGroupContract,
6
+ deleteConsensusGroupContract,
7
+ listConsensusGroupsContract,
8
+ updateConsensusGroupContract,
5
9
  createModelPresetContract,
6
10
  deleteRiskPolicyContract,
7
11
  deleteModelPresetContract,
@@ -14,6 +18,7 @@ import {
14
18
  } from '@cat-factory/contracts'
15
19
  import type { ReviewEffort, UpdateRiskPolicyInput } from '~/types/merge'
16
20
  import type { CreateModelPresetInput, UpdateModelPresetInput } from '~/types/model-presets'
21
+ import type { CreateConsensusGroupInput, UpdateConsensusGroupInput } from '~/types/consensus'
17
22
  import type { SendParams } from './client'
18
23
  import type { ApiContext } from './context'
19
24
 
@@ -86,5 +91,22 @@ export function presetsApi({ send, ws }: ApiContext) {
86
91
  // a drifted one, or materialise a new built-in that appeared). Custom presets reject this.
87
92
  reseedModelPreset: (workspaceId: string, presetId: string) =>
88
93
  send(reseedModelPresetContract, { pathPrefix: ws(workspaceId), pathParams: { presetId } }),
94
+
95
+ // ---- consensus groups (the estimate-gated review panels a step escalates to) ----
96
+ listConsensusGroups: (workspaceId: string) =>
97
+ send(listConsensusGroupsContract, { pathPrefix: ws(workspaceId) }),
98
+
99
+ createConsensusGroup: (workspaceId: string, body: CreateConsensusGroupInput) =>
100
+ send(createConsensusGroupContract, { pathPrefix: ws(workspaceId), body }),
101
+
102
+ updateConsensusGroup: (workspaceId: string, groupId: string, body: UpdateConsensusGroupInput) =>
103
+ send(updateConsensusGroupContract, {
104
+ pathPrefix: ws(workspaceId),
105
+ pathParams: { groupId },
106
+ body,
107
+ }),
108
+
109
+ deleteConsensusGroup: (workspaceId: string, groupId: string) =>
110
+ send(deleteConsensusGroupContract, { pathPrefix: ws(workspaceId), pathParams: { groupId } }),
89
111
  }
90
112
  }
@@ -28,15 +28,21 @@ describe('customKindToArchetype', () => {
28
28
  })
29
29
  })
30
30
 
31
- it('carries category and resultView through when present', () => {
32
- const a = customKindToArchetype(kind({ category: 'review', resultView: 'acme:audit' }))
31
+ it('carries category, tier and resultView through when present', () => {
32
+ const a = customKindToArchetype(
33
+ kind({ category: 'review', tier: 'basic', resultView: 'acme:audit' }),
34
+ )
33
35
  expect(a.category).toBe('review')
36
+ expect(a.tier).toBe('basic')
34
37
  expect(a.resultView).toBe('acme:audit')
35
38
  })
36
39
 
37
- it('omits category/resultView when absent (no undefined keys)', () => {
40
+ it('omits category/tier/resultView when absent (no undefined keys)', () => {
38
41
  const a = customKindToArchetype(kind())
39
42
  expect('category' in a).toBe(false)
43
+ // Left UNSET rather than stamped with the default, so the fallback stays in one place
44
+ // (`agentTierVisibleAt`) instead of being forked into this projection.
45
+ expect('tier' in a).toBe(false)
40
46
  expect('resultView' in a).toBe(false)
41
47
  })
42
48
  })
@@ -27,6 +27,10 @@ export function customKindToArchetype(kind: CustomAgentKind): AgentArchetype {
27
27
  color: p.color,
28
28
  description: p.description,
29
29
  ...(p.category ? { category: p.category } : {}),
30
+ // A kind that declares no tier is left WITHOUT one rather than stamped with the default
31
+ // here, so the single fallback stays in `agentTierVisibleAt` — filling it in at the
32
+ // projection would fork the rule the moment the default changes.
33
+ ...(p.tier ? { tier: p.tier } : {}),
30
34
  ...(p.resultView ? { resultView: p.resultView } : {}),
31
35
  }
32
36
  }
@@ -0,0 +1,24 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import type { AgentTier } from '~/types/domain'
3
+ import { useAgentTierStore } from '~/stores/agentTier'
4
+
5
+ describe('agentTier store', () => {
6
+ it('opens on the everyday-loop tier and records a widening', () => {
7
+ const store = useAgentTierStore()
8
+ expect(store.tier).toBe('basic')
9
+ expect(store.showsAll).toBe(false)
10
+
11
+ store.setTier('advanced')
12
+ expect(store.tier).toBe('advanced')
13
+ // The widest level is the "show everything" setting the surfaces advertise.
14
+ expect(store.showsAll).toBe(true)
15
+ })
16
+
17
+ it('falls back to the default when the persisted value is not a known tier', () => {
18
+ const store = useAgentTierStore()
19
+ // What a blob written by an older build (or hand-edited) looks like coming back in. A
20
+ // catalog filtered on an unknown level would list nothing at all, so this must not pass through.
21
+ store.storedTier = 'expert' as AgentTier
22
+ expect(store.tier).toBe('basic')
23
+ })
24
+ })