@cat-factory/app 0.187.0 → 0.189.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 (38) hide show
  1. package/README.md +26 -0
  2. package/app/components/consensus/ConsensusSessionWindow.vue +11 -0
  3. package/app/components/palettes/AgentPalette.vue +12 -1
  4. package/app/components/palettes/AgentTierSelect.vue +67 -0
  5. package/app/components/pipeline/PipelineBuilder.vue +166 -92
  6. package/app/components/pipeline/PipelineHealthModal.vue +68 -12
  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/composables/usePipelineErrorToast.ts +17 -0
  11. package/app/composables/usePipelineHealth.spec.ts +79 -7
  12. package/app/composables/usePipelineHealth.ts +70 -3
  13. package/app/modular/agent-kinds.spec.ts +9 -3
  14. package/app/modular/agent-kinds.ts +4 -0
  15. package/app/stores/agentTier.spec.ts +24 -0
  16. package/app/stores/agentTier.ts +42 -0
  17. package/app/stores/consensusGroups.ts +77 -0
  18. package/app/stores/pipelines/draftActions.ts +11 -113
  19. package/app/stores/pipelines/draftStepConfig.ts +157 -0
  20. package/app/stores/pipelines.ts +21 -3
  21. package/app/stores/workspace/hydrate.ts +7 -1
  22. package/app/types/consensus.ts +3 -0
  23. package/app/types/domain.ts +8 -1
  24. package/app/utils/agentTier.spec.ts +59 -0
  25. package/app/utils/agentTier.ts +49 -0
  26. package/app/utils/catalog.spec.ts +12 -0
  27. package/app/utils/catalog.ts +58 -2
  28. package/i18n/locales/de.json +74 -5
  29. package/i18n/locales/en.json +80 -5
  30. package/i18n/locales/es.json +74 -5
  31. package/i18n/locales/fr.json +74 -5
  32. package/i18n/locales/he.json +74 -5
  33. package/i18n/locales/it.json +74 -5
  34. package/i18n/locales/ja.json +74 -5
  35. package/i18n/locales/pl.json +74 -5
  36. package/i18n/locales/tr.json +74 -5
  37. package/i18n/locales/uk.json +74 -5
  38. package/package.json +2 -2
@@ -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
+ }
@@ -1,7 +1,7 @@
1
1
  import { defineStore } from 'pinia'
2
2
  import { ref } from 'vue'
3
3
  import type { Pipeline } from '~/types/domain'
4
- import type { PipelinePurpose } from '@cat-factory/contracts'
4
+ import type { PipelinePurpose, RetiredPipelineWire } from '@cat-factory/contracts'
5
5
  import { useUpsertList } from '~/composables/useUpsertList'
6
6
  import { createDraftStepState, type PipelinesContext } from '~/stores/pipelines/context'
7
7
  import { createPipelineDraftActions } from '~/stores/pipelines/draftActions'
@@ -32,6 +32,13 @@ export const usePipelinesStore = defineStore('pipelines', () => {
32
32
  * a newer definition available (see `usePipelineHealth`).
33
33
  */
34
34
  const catalogVersions = ref<Record<string, number>>({})
35
+ /**
36
+ * Built-in pipelines WITHDRAWN from the catalog (`retiredPipelines()`), from the workspace
37
+ * snapshot. A stored pipeline whose id appears here is no longer relevant and can be REMOVED —
38
+ * the opposite of a reseed, and the only case where deleting a built-in is allowed (see
39
+ * `usePipelineHealth`). Disjoint from {@link catalogVersions} by construction.
40
+ */
41
+ const retiredPipelines = ref<RetiredPipelineWire[]>([])
35
42
 
36
43
  // The per-step, index-aligned draft arrays (kept in lockstep — see `createDraftStepState`).
37
44
  const {
@@ -60,10 +67,20 @@ export const usePipelinesStore = defineStore('pipelines', () => {
60
67
  /** The id of the pipeline being edited, or null when assembling a brand-new one. */
61
68
  const editingId = ref<string | null>(null)
62
69
 
63
- /** Replace the cached pipelines (and the current built-in catalog versions) from a snapshot. */
64
- function hydrate(next: Pipeline[], versions?: Record<string, number>) {
70
+ /**
71
+ * Replace the cached pipelines (and the current built-in catalog versions + retirements) from a
72
+ * snapshot. `retired` is applied even when EMPTY, unlike `versions`: an absent list means the
73
+ * facade shipped no retirements, and carrying the previous board's forward would offer a delete
74
+ * for a pipeline this deployment still ships.
75
+ */
76
+ function hydrate(
77
+ next: Pipeline[],
78
+ versions?: Record<string, number>,
79
+ retired?: RetiredPipelineWire[],
80
+ ) {
65
81
  pipelines.value = next
66
82
  if (versions) catalogVersions.value = versions
83
+ retiredPipelines.value = retired ?? []
67
84
  }
68
85
 
69
86
  function getPipeline(id: string) {
@@ -99,6 +116,7 @@ export const usePipelinesStore = defineStore('pipelines', () => {
99
116
  return {
100
117
  pipelines,
101
118
  catalogVersions,
119
+ retiredPipelines,
102
120
  draft,
103
121
  draftGates,
104
122
  draftEnabled,
@@ -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'
@@ -61,7 +62,11 @@ export function applySnapshotToStores(snapshot: WorkspaceSnapshot, boardSince?:
61
62
  useUserSettingsStore().hydrate(snapshot.userSettings ?? null)
62
63
  useBoardStore().hydrate(snapshot.blocks, boardSince)
63
64
  useBoardStore().hydrateArchived(snapshot.archivedServices ?? [])
64
- usePipelinesStore().hydrate(snapshot.pipelines, snapshot.pipelineCatalogVersions)
65
+ usePipelinesStore().hydrate(
66
+ snapshot.pipelines,
67
+ snapshot.pipelineCatalogVersions,
68
+ snapshot.retiredPipelines,
69
+ )
65
70
  useExecutionStore().hydrate(snapshot.executions, snapshot.workspace.id)
66
71
  useAgentRunsStore().hydrate(snapshot.bootstrapJobs ?? [], snapshot.workspace.id)
67
72
  useAgentRunsStore().hydrateEnvConfigRepair(snapshot.envConfigRepairJobs ?? [])
@@ -72,6 +77,7 @@ export function applySnapshotToStores(snapshot: WorkspaceSnapshot, boardSince?:
72
77
  useWorkspaceSettingsStore().hydrate(snapshot.settings)
73
78
  useAgentConfigStore().hydrate(snapshot.agentConfigCatalog ?? [])
74
79
  useModelPresetsStore().hydrate(snapshot.modelPresets ?? [], snapshot.modelPresetCatalogVersions)
80
+ useConsensusGroupsStore().hydrate(snapshot.consensusGroups ?? [])
75
81
  useServiceFragmentDefaultsStore().hydrate(snapshot.serviceFragmentDefaults?.fragmentIds)
76
82
  useRecurringPipelinesStore().hydrate(snapshot.recurringPipelines ?? [])
77
83
  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) {