@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,42 @@
1
+ import { defineStore } from 'pinia'
2
+ import { computed, ref } from 'vue'
3
+ import type { AgentTier } from '~/types/domain'
4
+ import { DEFAULT_AGENT_TIER_LEVEL, isAgentTier } from '~/utils/agentTier'
5
+
6
+ /**
7
+ * How deep into the agent catalog the two catalog surfaces reach — the pipeline builder's
8
+ * palette and the model preset's per-agent override list.
9
+ *
10
+ * ONE persisted preference shared by both, not a per-surface one: the two are halves of the
11
+ * same job (pick the agents a pipeline runs, then pick what each of them runs on), so a user
12
+ * who widened the palette to reach a specialist kind expects to find that same kind when they
13
+ * go to pin its model. Persisted like the interface mode's own preference, so the choice
14
+ * survives a reload.
15
+ *
16
+ * Distinct from `uiMode` (the SPA-wide basic/advanced interface tier) on purpose — see
17
+ * `utils/agentTier.ts` for why the axes stay separate.
18
+ */
19
+ export const useAgentTierStore = defineStore(
20
+ 'agentTier',
21
+ () => {
22
+ /** The selected level, persisted. Seeded to the everyday-loop default. */
23
+ const storedTier = ref<AgentTier>(DEFAULT_AGENT_TIER_LEVEL)
24
+
25
+ // The restored value is untrusted input (a blob written by an older build, or hand-edited),
26
+ // and a catalog filtered on an unknown level would show nothing at all — so fall back
27
+ // rather than trust it, exactly as `uiMode` does with its persisted mode.
28
+ const tier = computed<AgentTier>(() =>
29
+ isAgentTier(storedTier.value) ? storedTier.value : DEFAULT_AGENT_TIER_LEVEL,
30
+ )
31
+
32
+ /** Whether the widest level is selected, i.e. the whole catalog is showing. */
33
+ const showsAll = computed(() => tier.value === 'advanced')
34
+
35
+ function setTier(next: AgentTier) {
36
+ storedTier.value = next
37
+ }
38
+
39
+ return { tier, showsAll, storedTier, setTier }
40
+ },
41
+ { persist: { pick: ['storedTier'] } },
42
+ )
@@ -0,0 +1,77 @@
1
+ import { defineStore } from 'pinia'
2
+ import { computed, ref } from 'vue'
3
+ import type {
4
+ ConsensusGroup,
5
+ CreateConsensusGroupInput,
6
+ UpdateConsensusGroupInput,
7
+ } from '~/types/consensus'
8
+ import { useWorkspaceStore } from '~/stores/workspace'
9
+
10
+ /**
11
+ * The workspace's consensus-GROUP library: the reusable, estimate-gated review panels a pipeline
12
+ * step escalates to. Hydrated from the workspace snapshot (like the model presets) and managed
13
+ * from the Model Configuration settings screen; the pipeline builder reads it to offer a step's
14
+ * tier set.
15
+ *
16
+ * A workspace that has authored no group simply has nothing to escalate to, and every consensus
17
+ * step runs the inline participants written on it — so an empty library is a valid, quiet state,
18
+ * not an error.
19
+ */
20
+ export const useConsensusGroupsStore = defineStore('consensusGroups', () => {
21
+ const api = useApi()
22
+
23
+ const groups = ref<ConsensusGroup[]>([])
24
+
25
+ function hydrate(list: ConsensusGroup[]) {
26
+ groups.value = [...list].sort((a, b) => a.createdAt - b.createdAt)
27
+ }
28
+
29
+ /** Whether the workspace has any tier to escalate to (drives the builder's empty state). */
30
+ const hasGroups = computed(() => groups.value.length > 0)
31
+
32
+ /** Resolve ids to groups, dropping any the library no longer holds. */
33
+ function resolve(ids: readonly string[] | undefined): ConsensusGroup[] {
34
+ if (!ids?.length) return []
35
+ return ids
36
+ .map((id) => groups.value.find((g) => g.id === id))
37
+ .filter((g): g is ConsensusGroup => !!g)
38
+ }
39
+
40
+ /**
41
+ * The estimate bar a group sets, as a display number: the highest threshold it names, or null
42
+ * when it is ungated (the unconditional floor tier). Mirrors kernel's `consensusGroupBar`,
43
+ * which returns -1 for the same case — the sentinel exists so an ungated group SORTS below
44
+ * every gated one, which is a ranking concern the UI doesn't share.
45
+ */
46
+ function barFor(group: ConsensusGroup): number | null {
47
+ if (!group.gating.enabled) return null
48
+ const thresholds = [
49
+ group.gating.minComplexity,
50
+ group.gating.minRisk,
51
+ group.gating.minImpact,
52
+ ].filter((t): t is number => t !== undefined)
53
+ return thresholds.length ? Math.max(...thresholds) : null
54
+ }
55
+
56
+ async function create(input: CreateConsensusGroupInput) {
57
+ const ws = useWorkspaceStore()
58
+ const created = await api.createConsensusGroup(ws.requireId(), input)
59
+ await ws.refresh()
60
+ return created
61
+ }
62
+
63
+ async function update(groupId: string, patch: UpdateConsensusGroupInput) {
64
+ const ws = useWorkspaceStore()
65
+ const updated = await api.updateConsensusGroup(ws.requireId(), groupId, patch)
66
+ await ws.refresh()
67
+ return updated
68
+ }
69
+
70
+ async function remove(groupId: string) {
71
+ const ws = useWorkspaceStore()
72
+ await api.deleteConsensusGroup(ws.requireId(), groupId)
73
+ await ws.refresh()
74
+ }
75
+
76
+ return { groups, hasGroups, hydrate, resolve, barFor, create, update, remove }
77
+ })
@@ -1,15 +1,18 @@
1
1
  import { computed } from 'vue'
2
2
  import type { AgentKind, Pipeline } from '~/types/domain'
3
- import type { ConsensusStepConfig } from '~/types/consensus'
4
- import type { StepOptions } from '@cat-factory/contracts'
5
3
  import { companionForProducer } from '~/utils/catalog'
6
- import { defaultConsensusConfig, type PipelinesContext } from './context'
4
+ import type { PipelinesContext } from './context'
5
+ import { createPipelineStepConfigActions } from './draftStepConfig'
7
6
 
8
7
  /**
9
- * The pipeline-builder draft manipulation: inserting/removing/reordering steps and toggling each
10
- * step's per-step config, plus `clearDraft` / `loadForEdit`. Extracted from the store setup into a
11
- * factory closing over the shared {@link PipelinesContext} so behaviour is byte-identical to the
12
- * former in-closure functions — the split is purely to keep the setup within the size budget.
8
+ * The pipeline-builder draft's STRUCTURE: inserting/removing/reordering steps, the companion
9
+ * folding that turns the flat arrays into renderable units, and `clearDraft` / `loadForEdit`.
10
+ * Extracted from the store setup into a factory closing over the shared {@link PipelinesContext}.
11
+ *
12
+ * The per-step CONFIG toggles (consensus + its group tiers, gates, companions, step options) are
13
+ * the sibling factory in `./draftStepConfig` — a different concern on the same context, and the
14
+ * seam this file was split along when it outgrew the per-function budget. Both are spread into
15
+ * the store, so callers see one flat API exactly as before.
13
16
  */
14
17
  export function createPipelineDraftActions(ctx: PipelinesContext) {
15
18
  const {
@@ -97,13 +100,6 @@ export function createPipelineDraftActions(ctx: PipelinesContext) {
97
100
  else insertAt(index + 1, companion)
98
101
  }
99
102
 
100
- /** Toggle estimate gating on/off for the (companion) step at `index`. */
101
- function toggleDraftGating(index: number) {
102
- draftGating.value[index] = draftGating.value[index]?.enabled
103
- ? null
104
- : { enabled: true, minRisk: 0.5, minImpact: 0.5, onMissingEstimate: 'run' }
105
- }
106
-
107
103
  /**
108
104
  * The draft as a list of "units" for rendering: each step is one unit, EXCEPT a companion
109
105
  * that sits immediately after its producer — that companion is folded into the producer's
@@ -157,93 +153,6 @@ export function createPipelineDraftActions(ctx: PipelinesContext) {
157
153
  draftStepOptions.value = reorder(draftStepOptions.value)
158
154
  }
159
155
 
160
- /** Toggle the consensus mechanism on the draft step at `index` (default config / off). */
161
- function toggleDraftConsensus(index: number) {
162
- draftConsensus.value[index] = draftConsensus.value[index] ? null : defaultConsensusConfig()
163
- }
164
-
165
- /** Replace the consensus config of the draft step at `index` (builder editor edits). */
166
- function setDraftConsensus(index: number, config: ConsensusStepConfig | null) {
167
- draftConsensus.value[index] = config
168
- }
169
-
170
- /** Toggle the approval gate on the draft step at `index`. */
171
- function toggleDraftGate(index: number) {
172
- draftGates.value[index] = !draftGates.value[index]
173
- }
174
-
175
- /** Toggle the Follow-up companion on the draft (coder) step at `index` (default on → off). */
176
- function toggleDraftFollowUps(index: number) {
177
- // Default (null/true) is enabled, so the first toggle disables it (false); toggle back to null.
178
- draftFollowUps.value[index] = draftFollowUps.value[index] === false ? null : false
179
- }
180
-
181
- /**
182
- * Toggle the test quality-control companion on the draft (Tester) step at `index`. The
183
- * companion is enabled by default (a `null` entry), so the first toggle disables it
184
- * (`{ enabled: false }`, dropping any gating) and the next restores the default.
185
- */
186
- function toggleDraftTesterQuality(index: number) {
187
- draftTesterQuality.value[index] =
188
- draftTesterQuality.value[index]?.enabled === false ? null : { enabled: false }
189
- }
190
-
191
- /**
192
- * Toggle estimate gating on/off for the QC companion on the draft (Tester) step at `index`.
193
- * A no-op while the companion is disabled (nothing to gate). Enabling gating pins the config
194
- * to `{ enabled: true, gating }` so the thresholds are editable; disabling drops back to the
195
- * default `null` (enabled, ungated).
196
- */
197
- function toggleDraftTesterQualityGating(index: number) {
198
- const cur = draftTesterQuality.value[index]
199
- if (cur?.enabled === false) return
200
- draftTesterQuality.value[index] = cur?.gating?.enabled
201
- ? null
202
- : {
203
- enabled: true,
204
- gating: { enabled: true, minRisk: 0.5, minImpact: 0.5, onMissingEstimate: 'run' },
205
- }
206
- }
207
-
208
- /** Enable/disable the draft step at `index` without removing it. */
209
- function toggleDraftEnabled(index: number) {
210
- draftEnabled.value[index] = draftEnabled.value[index] === false
211
- }
212
-
213
- /** Whether auto-recommendation is on for the draft (requirements-review) step at `index`. */
214
- function draftAutoRecommendEnabled(index: number): boolean {
215
- return draftStepOptions.value[index]?.autoRecommend !== false
216
- }
217
-
218
- /**
219
- * Toggle the requirements-review auto-recommendation on the draft step at `index`. It is on by
220
- * default, so we store ONLY the opt-out (`{ autoRecommend: false }`); toggling back drops the
221
- * flag. Merges with any other future StepOptions fields rather than clobbering the whole bag.
222
- */
223
- function toggleDraftAutoRecommend(index: number) {
224
- const next: StepOptions = { ...draftStepOptions.value[index] }
225
- if (draftAutoRecommendEnabled(index)) next.autoRecommend = false
226
- else delete next.autoRecommend
227
- draftStepOptions.value[index] = Object.keys(next).length ? next : null
228
- }
229
-
230
- /** The skill picked for the draft `skill` step at `index` (its `stepOptions.skillId`). */
231
- function draftSkillId(index: number): string | undefined {
232
- return draftStepOptions.value[index]?.skillId
233
- }
234
-
235
- /**
236
- * Set (or clear) the picked skill on the draft `skill` step at `index`. Merges into the
237
- * step's `StepOptions` bag rather than clobbering it; clearing drops the field and, if the
238
- * bag empties, the whole entry (so it normalizes away like the other options).
239
- */
240
- function setDraftSkillId(index: number, skillId: string | undefined) {
241
- const next: StepOptions = { ...draftStepOptions.value[index] }
242
- if (skillId) next.skillId = skillId
243
- else delete next.skillId
244
- draftStepOptions.value[index] = Object.keys(next).length ? next : null
245
- }
246
-
247
156
  function clearDraft() {
248
157
  draft.value = []
249
158
  draftGates.value = []
@@ -282,25 +191,14 @@ export function createPipelineDraftActions(ctx: PipelinesContext) {
282
191
  }
283
192
 
284
193
  return {
194
+ ...createPipelineStepConfigActions(ctx),
285
195
  addToDraft,
286
196
  removeFromDraft,
287
197
  moveInDraft,
288
198
  hasCompanion,
289
199
  toggleCompanion,
290
- toggleDraftGating,
291
200
  units,
292
201
  moveUnit,
293
- toggleDraftConsensus,
294
- setDraftConsensus,
295
- toggleDraftGate,
296
- toggleDraftFollowUps,
297
- toggleDraftTesterQuality,
298
- toggleDraftTesterQualityGating,
299
- toggleDraftEnabled,
300
- draftAutoRecommendEnabled,
301
- toggleDraftAutoRecommend,
302
- draftSkillId,
303
- setDraftSkillId,
304
202
  clearDraft,
305
203
  loadForEdit,
306
204
  }
@@ -0,0 +1,157 @@
1
+ import type { StepOptions } from '@cat-factory/contracts'
2
+ import type { ConsensusStepConfig } from '~/types/consensus'
3
+ import { defaultConsensusConfig, type PipelinesContext } from './context'
4
+
5
+ /**
6
+ * The pipeline-builder draft's PER-STEP CONFIG toggles: consensus (inline panel and the workspace
7
+ * consensus-GROUP tier set), the human approval gate, the estimate gate on a companion step, the
8
+ * follow-up and test-QC companions, the per-step enable flag, and the `StepOptions` bag
9
+ * (requirements auto-recommendation, the picked skill).
10
+ *
11
+ * Split out of `./draftActions`, which owns the draft's STRUCTURE (insert / remove / reorder /
12
+ * units). Every function here reads and writes one of the parallel per-step arrays at an index and
13
+ * touches nothing else, which is what makes the two independent; both are spread into the store,
14
+ * so the store's API is unchanged.
15
+ */
16
+ export function createPipelineStepConfigActions(ctx: PipelinesContext) {
17
+ const {
18
+ draftGates,
19
+ draftEnabled,
20
+ draftConsensus,
21
+ draftGating,
22
+ draftFollowUps,
23
+ draftTesterQuality,
24
+ draftStepOptions,
25
+ } = ctx
26
+
27
+ /** Toggle estimate gating on/off for the (companion) step at `index`. */
28
+ function toggleDraftGating(index: number) {
29
+ draftGating.value[index] = draftGating.value[index]?.enabled
30
+ ? null
31
+ : { enabled: true, minRisk: 0.5, minImpact: 0.5, onMissingEstimate: 'run' }
32
+ }
33
+
34
+ /** Toggle the consensus mechanism on the draft step at `index` (default config / off). */
35
+ function toggleDraftConsensus(index: number) {
36
+ draftConsensus.value[index] = draftConsensus.value[index] ? null : defaultConsensusConfig()
37
+ }
38
+
39
+ /** Replace the consensus config of the draft step at `index` (builder editor edits). */
40
+ function setDraftConsensus(index: number, config: ConsensusStepConfig | null) {
41
+ draftConsensus.value[index] = config
42
+ }
43
+
44
+ /**
45
+ * Add/remove a workspace consensus GROUP from the draft step's tier set. The array is a SET,
46
+ * not a precedence list — the engine ranks candidates by the bar each group sets — so this
47
+ * appends without ceremony.
48
+ *
49
+ * An empty tier set falls back to the step's inline participants; a non-empty one takes over,
50
+ * which is why the builder shows only one of the two editors at a time.
51
+ */
52
+ function toggleDraftConsensusGroup(index: number, groupId: string) {
53
+ const config = draftConsensus.value[index]
54
+ if (!config) return
55
+ const current = config.groupIds ?? []
56
+ const next = current.includes(groupId)
57
+ ? current.filter((id) => id !== groupId)
58
+ : [...current, groupId]
59
+ // Drop the key entirely when the set empties, so a step that never used tiers persists the
60
+ // same shape it always did rather than an empty array that reads as "tiered, but none".
61
+ if (next.length) config.groupIds = next
62
+ else delete config.groupIds
63
+ }
64
+
65
+ /** Toggle the approval gate on the draft step at `index`. */
66
+ function toggleDraftGate(index: number) {
67
+ draftGates.value[index] = !draftGates.value[index]
68
+ }
69
+
70
+ /** Toggle the Follow-up companion on the draft (coder) step at `index` (default on → off). */
71
+ function toggleDraftFollowUps(index: number) {
72
+ // Default (null/true) is enabled, so the first toggle disables it (false); toggle back to null.
73
+ draftFollowUps.value[index] = draftFollowUps.value[index] === false ? null : false
74
+ }
75
+
76
+ /**
77
+ * Toggle the test quality-control companion on the draft (Tester) step at `index`. The
78
+ * companion is enabled by default (a `null` entry), so the first toggle disables it
79
+ * (`{ enabled: false }`, dropping any gating) and the next restores the default.
80
+ */
81
+ function toggleDraftTesterQuality(index: number) {
82
+ draftTesterQuality.value[index] =
83
+ draftTesterQuality.value[index]?.enabled === false ? null : { enabled: false }
84
+ }
85
+
86
+ /**
87
+ * Toggle estimate gating on/off for the QC companion on the draft (Tester) step at `index`.
88
+ * A no-op while the companion is disabled (nothing to gate). Enabling gating pins the config
89
+ * to `{ enabled: true, gating }` so the thresholds are editable; disabling drops back to the
90
+ * default `null` (enabled, ungated).
91
+ */
92
+ function toggleDraftTesterQualityGating(index: number) {
93
+ const cur = draftTesterQuality.value[index]
94
+ if (cur?.enabled === false) return
95
+ draftTesterQuality.value[index] = cur?.gating?.enabled
96
+ ? null
97
+ : {
98
+ enabled: true,
99
+ gating: { enabled: true, minRisk: 0.5, minImpact: 0.5, onMissingEstimate: 'run' },
100
+ }
101
+ }
102
+
103
+ /** Enable/disable the draft step at `index` without removing it. */
104
+ function toggleDraftEnabled(index: number) {
105
+ draftEnabled.value[index] = draftEnabled.value[index] === false
106
+ }
107
+
108
+ /** Whether auto-recommendation is on for the draft (requirements-review) step at `index`. */
109
+ function draftAutoRecommendEnabled(index: number): boolean {
110
+ return draftStepOptions.value[index]?.autoRecommend !== false
111
+ }
112
+
113
+ /**
114
+ * Toggle the requirements-review auto-recommendation on the draft step at `index`. It is on by
115
+ * default, so we store ONLY the opt-out (`{ autoRecommend: false }`); toggling back drops the
116
+ * flag. Merges with any other future StepOptions fields rather than clobbering the whole bag.
117
+ */
118
+ function toggleDraftAutoRecommend(index: number) {
119
+ const next: StepOptions = { ...draftStepOptions.value[index] }
120
+ if (draftAutoRecommendEnabled(index)) next.autoRecommend = false
121
+ else delete next.autoRecommend
122
+ draftStepOptions.value[index] = Object.keys(next).length ? next : null
123
+ }
124
+
125
+ /** The skill picked for the draft `skill` step at `index` (its `stepOptions.skillId`). */
126
+ function draftSkillId(index: number): string | undefined {
127
+ return draftStepOptions.value[index]?.skillId
128
+ }
129
+
130
+ /**
131
+ * Set (or clear) the picked skill on the draft `skill` step at `index`. Merges into the
132
+ * step's `StepOptions` bag rather than clobbering it; clearing drops the field and, if the
133
+ * bag empties, the whole entry (so it normalizes away like the other options).
134
+ */
135
+ function setDraftSkillId(index: number, skillId: string | undefined) {
136
+ const next: StepOptions = { ...draftStepOptions.value[index] }
137
+ if (skillId) next.skillId = skillId
138
+ else delete next.skillId
139
+ draftStepOptions.value[index] = Object.keys(next).length ? next : null
140
+ }
141
+
142
+ return {
143
+ toggleDraftGating,
144
+ toggleDraftConsensus,
145
+ setDraftConsensus,
146
+ toggleDraftConsensusGroup,
147
+ toggleDraftGate,
148
+ toggleDraftFollowUps,
149
+ toggleDraftTesterQuality,
150
+ toggleDraftTesterQualityGating,
151
+ toggleDraftEnabled,
152
+ draftAutoRecommendEnabled,
153
+ toggleDraftAutoRecommend,
154
+ draftSkillId,
155
+ setDraftSkillId,
156
+ }
157
+ }
@@ -12,6 +12,7 @@ import { useFragmentsStore } from '~/stores/fragments'
12
12
  import { useGitHubStore } from '~/stores/github'
13
13
  import { useInitiativesStore } from '~/stores/initiative'
14
14
  import { useModelPresetsStore } from '~/stores/modelPresets'
15
+ import { useConsensusGroupsStore } from '~/stores/consensusGroups'
15
16
  import { useNotificationsStore } from '~/stores/notifications'
16
17
  import { usePipelinesStore } from '~/stores/pipelines'
17
18
  import { useProviderConnectionsStore } from '~/stores/providerConnections'
@@ -72,6 +73,7 @@ export function applySnapshotToStores(snapshot: WorkspaceSnapshot, boardSince?:
72
73
  useWorkspaceSettingsStore().hydrate(snapshot.settings)
73
74
  useAgentConfigStore().hydrate(snapshot.agentConfigCatalog ?? [])
74
75
  useModelPresetsStore().hydrate(snapshot.modelPresets ?? [], snapshot.modelPresetCatalogVersions)
76
+ useConsensusGroupsStore().hydrate(snapshot.consensusGroups ?? [])
75
77
  useServiceFragmentDefaultsStore().hydrate(snapshot.serviceFragmentDefaults?.fragmentIds)
76
78
  useRecurringPipelinesStore().hydrate(snapshot.recurringPipelines ?? [])
77
79
  useInitiativesStore().hydrate(snapshot.initiatives)
@@ -15,4 +15,7 @@ export type {
15
15
  ConsensusRound,
16
16
  ConsensusSessionStatus,
17
17
  ConsensusSession,
18
+ ConsensusGroup,
19
+ CreateConsensusGroupInput,
20
+ UpdateConsensusGroupInput,
18
21
  } from '@cat-factory/contracts'
@@ -60,6 +60,7 @@ export type {
60
60
  TestScreenshot,
61
61
  AgentKind,
62
62
  AgentCategory,
63
+ AgentTier,
63
64
  CustomAgentKind,
64
65
  CustomTaskType,
65
66
  TaskTypePresentation,
@@ -96,7 +97,7 @@ export type {
96
97
  PreviewStatus,
97
98
  } from '@cat-factory/contracts'
98
99
 
99
- import type { AgentCategory, AgentKind } from '@cat-factory/contracts'
100
+ import type { AgentCategory, AgentKind, AgentTier } from '@cat-factory/contracts'
100
101
 
101
102
  // The document-kind list + the per-kind field descriptors are runtime values (used to render
102
103
  // the picker and the conditional per-kind inputs), so they are re-exported as values — the
@@ -114,6 +115,12 @@ export interface AgentArchetype {
114
115
  description: string
115
116
  /** Palette category this archetype is grouped under. Absent ⇒ ungrouped/system kind. */
116
117
  category?: AgentCategory
118
+ /**
119
+ * How specialist this kind is — the tier the palette / model-preset override list filter on
120
+ * (`basic` shows only basic kinds, `intermediate` adds those, `advanced` shows everything).
121
+ * Absent ⇒ `DEFAULT_AGENT_TIER`, which is how an unclassified deployment kind behaves.
122
+ */
123
+ tier?: AgentTier
117
124
  /**
118
125
  * Optional id of a DEDICATED result window this agent's step opens instead of the
119
126
  * generic prose step-detail panel. Resolved through the modular `resultViews` slot
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { AgentArchetype } from '~/types/domain'
3
+ import { filterByAgentTier, filterByAgentTierKeeping, isAgentTier } from '~/utils/agentTier'
4
+
5
+ const archetype = (kind: string, tier?: AgentArchetype['tier']): AgentArchetype => ({
6
+ kind: kind as AgentArchetype['kind'],
7
+ label: kind,
8
+ icon: 'i-lucide-bot',
9
+ color: '#fff',
10
+ description: kind,
11
+ ...(tier ? { tier } : {}),
12
+ })
13
+
14
+ const CATALOG: AgentArchetype[] = [
15
+ archetype('coder', 'basic'),
16
+ archetype('researcher', 'intermediate'),
17
+ archetype('mocker', 'advanced'),
18
+ // A deployment-registered kind that declared no tier.
19
+ archetype('acme-auditor'),
20
+ ]
21
+
22
+ describe('filterByAgentTier', () => {
23
+ it('shows only the basic kinds at the default level', () => {
24
+ expect(filterByAgentTier(CATALOG, 'basic').map((a) => a.kind)).toEqual(['coder'])
25
+ })
26
+
27
+ it('accumulates the levels, with advanced showing the whole catalog', () => {
28
+ expect(filterByAgentTier(CATALOG, 'intermediate').map((a) => a.kind)).toEqual([
29
+ 'coder',
30
+ 'researcher',
31
+ // An undeclared tier defaults to intermediate, so it appears here.
32
+ 'acme-auditor',
33
+ ])
34
+ expect(filterByAgentTier(CATALOG, 'advanced')).toHaveLength(CATALOG.length)
35
+ })
36
+ })
37
+
38
+ describe('filterByAgentTierKeeping', () => {
39
+ it('keeps a pinned kind the tier would otherwise hide, in catalog order', () => {
40
+ const kept = filterByAgentTierKeeping(CATALOG, 'basic', (a) => a.kind === 'mocker')
41
+ expect(kept.map((a) => a.kind)).toEqual(['coder', 'mocker'])
42
+ })
43
+
44
+ it('does not duplicate a pinned kind the tier already shows', () => {
45
+ const kept = filterByAgentTierKeeping(CATALOG, 'advanced', () => true)
46
+ expect(kept.map((a) => a.kind)).toEqual(CATALOG.map((a) => a.kind))
47
+ })
48
+ })
49
+
50
+ describe('isAgentTier', () => {
51
+ it('accepts the known tiers and rejects anything else', () => {
52
+ expect(isAgentTier('basic')).toBe(true)
53
+ expect(isAgentTier('advanced')).toBe(true)
54
+ // A persisted blob from an older build, or a hand-edited one.
55
+ expect(isAgentTier('expert')).toBe(false)
56
+ expect(isAgentTier(undefined)).toBe(false)
57
+ expect(isAgentTier(2)).toBe(false)
58
+ })
59
+ })
@@ -0,0 +1,49 @@
1
+ import { AGENT_TIERS, agentTierVisibleAt, type AgentTier } from '@cat-factory/contracts'
2
+ import type { AgentArchetype } from '~/types/domain'
3
+
4
+ /**
5
+ * Agent-tier filtering for the two catalog surfaces — the pipeline builder's palette and the
6
+ * model preset's per-agent override list.
7
+ *
8
+ * The tier vocabulary, the default and the cumulative predicate live in `@cat-factory/contracts`
9
+ * (beside `purposeAllowsAgentCategory`) so a deployment-registered kind's declared tier and the
10
+ * SPA's own built-ins are read by ONE rule. This module holds only the frontend-shaped helpers
11
+ * built on it.
12
+ *
13
+ * Deliberately NOT the interface mode (`utils/uiMode.ts`). That tier says which SURFACES the
14
+ * whole SPA offers; this one says how deep into the agent catalog a given surface reaches. They
15
+ * are independent axes — an advanced-mode user still starts on the basic agent tier — and the
16
+ * control for this one is visible in both interface modes, since it is the only way to reach
17
+ * the kinds it hides.
18
+ */
19
+
20
+ /** The default agent tier a surface opens on: the everyday delivery loop, nothing else. */
21
+ export const DEFAULT_AGENT_TIER_LEVEL: AgentTier = 'basic'
22
+
23
+ /** Whether an untrusted (persisted / hand-edited) value is one of the known tiers. */
24
+ export function isAgentTier(value: unknown): value is AgentTier {
25
+ return typeof value === 'string' && (AGENT_TIERS as readonly string[]).includes(value)
26
+ }
27
+
28
+ /** Keep only the archetypes visible at `level` (cumulative — see `agentTierVisibleAt`). */
29
+ export function filterByAgentTier<T extends Pick<AgentArchetype, 'tier'>>(
30
+ archetypes: readonly T[],
31
+ level: AgentTier,
32
+ ): T[] {
33
+ return archetypes.filter((a) => agentTierVisibleAt(a.tier, level))
34
+ }
35
+
36
+ /**
37
+ * Keep the archetypes visible at `level`, PLUS any the caller marks as pinned — the model
38
+ * preset editor's kinds that already carry an override. An entity can hold a setting written
39
+ * by a teammate, by the API, or by this user at a wider tier; hiding the row would leave them
40
+ * unable to see or clear it, which is the same failure the interface mode's `showOverrideField`
41
+ * exists to prevent. Order follows the input, so a pinned kind stays where the catalog puts it.
42
+ */
43
+ export function filterByAgentTierKeeping<T extends Pick<AgentArchetype, 'tier' | 'kind'>>(
44
+ archetypes: readonly T[],
45
+ level: AgentTier,
46
+ isPinned: (archetype: T) => boolean,
47
+ ): T[] {
48
+ return archetypes.filter((a) => agentTierVisibleAt(a.tier, level) || isPinned(a))
49
+ }
@@ -67,6 +67,18 @@ describe('catalog', () => {
67
67
  }
68
68
  })
69
69
 
70
+ it('classifies every built-in kind into a tier', () => {
71
+ // The palette / model-preset surfaces open on `basic`, so a built-in that forgot its tier
72
+ // would silently fall to the DEFAULT (intermediate) and vanish from the default view for
73
+ // no stated reason. Only a deployment-registered kind may leave it to the default.
74
+ for (const a of [...AGENT_ARCHETYPES, ...Object.values(SYSTEM_AGENT_META)]) {
75
+ expect(a.tier, `${a.kind} declares no tier`).toBeDefined()
76
+ }
77
+ // And the everyday delivery loop has to be assemblable without touching the control.
78
+ const basic = AGENT_ARCHETYPES.filter((a) => a.tier === 'basic').map((a) => a.kind)
79
+ expect(basic).toEqual(expect.arrayContaining(['architect', 'coder', 'tester-api']))
80
+ })
81
+
70
82
  it('resolves usable metadata for every kind via agentKindMeta', () => {
71
83
  // Palette archetypes resolve to their own entry.
72
84
  for (const a of AGENT_ARCHETYPES) {