@cat-factory/app 0.253.2 → 0.254.1

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.
package/README.md CHANGED
@@ -265,6 +265,55 @@ picking what each of them runs on are halves of the same job.
265
265
  whatever the tier: the same rule `showOverrideField` states for a single field: a row the
266
266
  user can neither read nor clear is worse than a longer list.
267
267
 
268
+ ### The palette's second dial: the pipeline's purpose
269
+
270
+ The builder's palette narrows on two axes, and both controls sit on one row above the catalog
271
+ (`PipelinePurposeSelect` above `AgentTierSelect`), each with its own "n hidden" hint so neither
272
+ narrowing reads as an empty catalog. The tier says how deep to look; the **purpose** says what
273
+ the pipeline is for (`build` / `document` / `review` / `research` / `planning`), and the palette
274
+ drops the categories that purpose has no use for. The purpose is not a view preference: it is
275
+ saved on the pipeline and also decides which task pickers offer it, which is why the control
276
+ writes through to the draft while the tier writes to its own store.
277
+
278
+ Purpose is filtered by two predicates in `@cat-factory/contracts`, and the difference between
279
+ them is the point:
280
+
281
+ - `purposeSuggestsAgentCategory` is **relevance**: what the palette OFFERS. Opinionated (a
282
+ review pipeline designs nothing; a planning pipeline has no pull request to gate), because a
283
+ wrong guess costs one purpose switch.
284
+ - `purposeAllowsAgentCategory` is **compatibility**: what the builder will SAVE. It states only
285
+ what is contradictory (a pipeline that writes no code carrying an implementation step) and
286
+ drives the draft's conflict warning.
287
+
288
+ Relevance is a subset of compatibility, asserted over the whole grid in `pipeline.spec.ts`. Keep
289
+ it that way: the palette may hide what the save gate tolerates, so tightening the relevance table
290
+ never turns a stored pipeline into one its own editor refuses, but offering a kind the save gate
291
+ then rejects would be a dead end with the refusal arriving after the work.
292
+
293
+ **Each hint counts what relaxing THAT dial alone would reveal**, which is why the reduction is one
294
+ function (`utils/agentPalette.ts`) rather than two chained filters at the call site. Chaining them
295
+ and subtracting the lengths gives the second dial an honest count and hands the first one the whole
296
+ rest of the catalog: at the default `basic` tier a `planning` pipeline claimed thirteen kinds hidden
297
+ for its purpose when switching back to Build revealed three, the other ten being tier-hidden either
298
+ way. So a kind BOTH dials hide is counted by neither, correctly, and a new dial measures itself
299
+ against what the others already admit rather than against the raw catalog.
300
+
301
+ **A purpose or category this build does not recognise narrows nothing.** Both are closed
302
+ vocabularies and both are persisted, so a reader is total against the type and partial against the
303
+ data: a `Pipeline.purpose` outlives the build that wrote it, and a `presentation.category` arrives
304
+ in the snapshot from a kind a deployment registered. Narrow with the schema-derived
305
+ `isPipelinePurpose` / `isAgentCategory` before indexing anything by one, never with an optional
306
+ call, so adding a member still fails the build. The two predicates read the unknown value through
307
+ one helper because they have to agree about it: one narrowing by a purpose the other no longer
308
+ recognises is exactly the subset violation above. On the control itself an unrecognised purpose is
309
+ NAMED and quoted back rather than left to render blank, which would read as a pipeline nobody
310
+ classified while the saved row says otherwise.
311
+
312
+ Offering such a kind is only half of keeping it: `groupAgentPalette` puts whatever no section
313
+ CLAIMED into the trailing custom bucket, derived from the sections rather than from an absent
314
+ `category`. A kind whose category has no section matches neither test, so filtering on the absent
315
+ one alone deleted it from a palette its own save gate accepts.
316
+
268
317
  ## In-app tutorial tours
269
318
 
270
319
  On first launch (once the board is up and no other startup advisory is open) the app asks
@@ -1,53 +1,46 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from 'vue'
3
3
  import { useLocalStorage } from '@vueuse/core'
4
- import { purposeAllowsAgentCategory } from '@cat-factory/contracts'
5
4
  import type { AgentKind, PipelinePurpose } from '~/types/domain'
6
5
  import AgentTierSelect from '~/components/palettes/AgentTierSelect.vue'
7
- import { filterByAgentTier } from '~/utils/agentTier'
6
+ import PipelinePurposeSelect from '~/components/palettes/PipelinePurposeSelect.vue'
7
+ import { groupAgentPalette, narrowAgentPalette } from '~/utils/agentPalette'
8
8
  import { AGENT_CATEGORIES, OBSERVABILITY_GATE_ARCHETYPE } from '~/utils/catalog'
9
9
 
10
10
  const { t } = useI18n()
11
11
  const agents = useAgentsStore()
12
12
  const agentTier = useAgentTierStore()
13
13
  const releaseHealth = useReleaseHealthStore()
14
- defineEmits<{ (e: 'add', kind: AgentKind): void }>()
15
- // The purpose of the pipeline being built. When set to a non-`build` classifier, the
16
- // Implementation (`build`) and Testing (`test`) categories are hidden — such a pipeline writes
17
- // no product code and runs no tests (see `purposeAllowsAgentCategory`). `null`/`build` shows all.
14
+ defineEmits<{
15
+ (e: 'add', kind: AgentKind): void
16
+ (e: 'update:purpose', purpose: PipelinePurpose): void
17
+ }>()
18
+ // The purpose of the pipeline being built, edited right here by the control above the catalog.
19
+ // It narrows the palette to the categories that purpose has any use for (a review pipeline
20
+ // designs nothing and builds nothing, see `purposeSuggestsAgentCategory`); `null` shows all.
18
21
  const props = defineProps<{ purpose?: PipelinePurpose | null }>()
19
22
 
20
23
  // The post-release-health gate is only meaningful — and only accepted by the backend —
21
24
  // with an observability integration connected, so it appears in the palette ONLY then.
22
- const offered = computed(() => {
23
- const all = releaseHealth.connection.connected
25
+ const connected = computed(() =>
26
+ releaseHealth.connection.connected
24
27
  ? [...agents.archetypes, OBSERVABILITY_GATE_ARCHETYPE]
25
- : agents.archetypes
26
- // Hide the categories the pipeline's purpose doesn't build from (an uncategorized custom kind
27
- // has no category to gate, so it always shows).
28
- return all.filter((a) => !a.category || purposeAllowsAgentCategory(props.purpose, a.category))
29
- })
28
+ : agents.archetypes,
29
+ )
30
30
 
31
- // Then narrow to the selected tier. Applied AFTER the purpose gate so the "n hidden" hint
32
- // counts only what the TIER is holding back a kind the pipeline's purpose rules out is not
33
- // something a wider tier would reveal, so counting it would send the user chasing a control
34
- // that cannot help them.
35
- const palette = computed(() => filterByAgentTier(offered.value, agentTier.tier))
36
- const hiddenByTier = computed(() => offered.value.length - palette.value.length)
31
+ // Then narrow on the two dials above the catalog, each hint counting what relaxing THAT dial
32
+ // alone would reveal. `narrowAgentPalette` owns that rule so it is unit-testable, and so neither
33
+ // count can be quietly re-derived here as a subtraction that measures the wrong population.
34
+ const narrowed = computed(() => narrowAgentPalette(connected.value, props.purpose, agentTier.tier))
35
+ const palette = computed(() => narrowed.value.offered)
37
36
 
38
- // Group the palette into the ordered catalog categories, plus a trailing "Custom" bucket
39
- // for runtime-added agents that carry no category. Empty groups are dropped.
40
- const groups = computed(() => {
41
- const ordered = AGENT_CATEGORIES.map((cat) => ({
42
- id: cat.id as string,
43
- label: cat.label,
44
- agents: palette.value.filter((a) => a.category === cat.id),
45
- }))
46
- const custom = palette.value.filter((a) => !a.category)
47
- if (custom.length)
48
- ordered.push({ id: 'custom', label: t('palette.customAgents'), agents: custom })
49
- return ordered.filter((g) => g.agents.length)
50
- })
37
+ // Group the palette into the ordered catalog categories, plus a trailing "Custom" bucket for
38
+ // whatever none of them claimed. `groupAgentPalette` owns the placement rule for the same reason
39
+ // `narrowAgentPalette` owns the counting one: it decides what is VISIBLE, and the version living
40
+ // here as an inline computed dropped a kind whose category had no section outright.
41
+ const groups = computed(() =>
42
+ groupAgentPalette(palette.value, AGENT_CATEGORIES, t('palette.customAgents')),
43
+ )
51
44
 
52
45
  // Persist which category sections are collapsed across builder opens.
53
46
  const collapsed = useLocalStorage<string[]>('cf.pipelineBuilder.collapsedAgentCategories', [])
@@ -64,7 +57,19 @@ function toggle(id: string) {
64
57
  <template>
65
58
  <div class="space-y-2">
66
59
  <p class="px-1 text-[11px] text-slate-500">{{ t('palette.hint') }}</p>
67
- <AgentTierSelect :hidden-count="hiddenByTier" />
60
+ <!-- The two catalog dials, one above the other: what this pipeline is for, and how deep into
61
+ the agent catalog to look. Both narrow the sections below, and each states its own count.
62
+ Stacked rather than side by side because the palette column is a third of the slideover:
63
+ two half-width buttons truncate to "Purpose: Doc…", which is the one thing a filter
64
+ control may not do. -->
65
+ <div class="space-y-2">
66
+ <PipelinePurposeSelect
67
+ :purpose="props.purpose"
68
+ :hidden-count="narrowed.hiddenByPurpose"
69
+ @update:purpose="$emit('update:purpose', $event)"
70
+ />
71
+ <AgentTierSelect :hidden-count="narrowed.hiddenByTier" />
72
+ </div>
68
73
  <div class="space-y-2">
69
74
  <section v-for="g in groups" :key="g.id">
70
75
  <button
@@ -0,0 +1,80 @@
1
+ <script setup lang="ts">
2
+ // The pipeline-PURPOSE control: what the pipeline being built exists to do (build, document,
3
+ // review, research, plan). It sits beside `AgentTierSelect` at the top of the agent palette
4
+ // because the two are the same kind of dial (each narrows the catalog below to what is worth
5
+ // offering), and a filter the user cannot see is a catalog that looks incomplete for no reason.
6
+ //
7
+ // Unlike the tier, this is not a view preference: it is saved on the pipeline and also decides
8
+ // which task pickers offer it (`pipelineAllowedForTaskType`). So the control writes through to
9
+ // the draft rather than to a store of its own.
10
+ import { computed } from 'vue'
11
+ import { isPipelinePurpose, PIPELINE_PURPOSES, type PipelinePurpose } from '@cat-factory/contracts'
12
+
13
+ const { t } = useI18n()
14
+
15
+ const props = defineProps<{
16
+ /** The draft's purpose. `null` = unclassified, which narrows nothing. */
17
+ purpose?: PipelinePurpose | null
18
+ /** How many agent kinds the current purpose hides. Renders the "n hidden" hint when > 0. */
19
+ hiddenCount?: number
20
+ }>()
21
+
22
+ const emit = defineEmits<{ (e: 'update:purpose', purpose: PipelinePurpose): void }>()
23
+
24
+ // One STATIC literal `t()` key per purpose (not a key assembled from the loop variable), so the
25
+ // typed-message-keys check covers them, the same shape `AgentTierSelect` uses for its tiers.
26
+ const PURPOSE_LABELS = computed<Record<PipelinePurpose, string>>(() => ({
27
+ build: t('pipeline.builder.purposeOption.build'),
28
+ document: t('pipeline.builder.purposeOption.document'),
29
+ review: t('pipeline.builder.purposeOption.review'),
30
+ research: t('pipeline.builder.purposeOption.research'),
31
+ planning: t('pipeline.builder.purposeOption.planning'),
32
+ }))
33
+
34
+ // The button text: the chosen purpose, or the placeholder while the draft carries none. An
35
+ // unclassified draft reads as an unmade choice rather than as a purpose called "none", because
36
+ // that is what it is: nothing is filtered until one is picked.
37
+ //
38
+ // A stored purpose this build has no label for is NAMED as unrecognised and quoted back, not
39
+ // folded into the placeholder and not left to render as an empty string after the colon. It is
40
+ // the one state the user cannot diagnose from the control: the palette is unfiltered (see
41
+ // `purposeSuggestsAgentCategory`), so a blank label would read as a pipeline nobody classified
42
+ // while the saved row says otherwise. The menu still lists every current member, so naming the
43
+ // value is also the way out of it.
44
+ const current = computed(() => {
45
+ const purpose = props.purpose
46
+ if (!purpose) return t('pipeline.builder.purposePlaceholder')
47
+ if (!isPipelinePurpose(purpose)) return t('pipeline.builder.purposeUnrecognized', { purpose })
48
+ return PURPOSE_LABELS.value[purpose]
49
+ })
50
+
51
+ const items = computed(() => [
52
+ PIPELINE_PURPOSES.map((purpose) => ({
53
+ label: PURPOSE_LABELS.value[purpose],
54
+ icon: purpose === props.purpose ? 'i-lucide-check' : 'i-lucide-target',
55
+ onSelect: () => emit('update:purpose', purpose),
56
+ })),
57
+ ])
58
+ </script>
59
+
60
+ <template>
61
+ <div class="space-y-1">
62
+ <UDropdownMenu :items="items" :ui="{ content: 'z-[60] max-w-72' }">
63
+ <UButton
64
+ size="xs"
65
+ color="neutral"
66
+ variant="soft"
67
+ icon="i-lucide-target"
68
+ trailing-icon="i-lucide-chevron-down"
69
+ class="w-full justify-between"
70
+ :title="t('pipeline.builder.purposeLabel')"
71
+ data-testid="pipeline-purpose-select"
72
+ >
73
+ <span class="truncate"> {{ t('pipeline.builder.purposeLabel') }}: {{ current }} </span>
74
+ </UButton>
75
+ </UDropdownMenu>
76
+ <p v-if="props.hiddenCount" class="px-1 text-[10px] text-slate-500">
77
+ {{ t('palette.purposeHidden', { count: props.hiddenCount }, props.hiddenCount) }}
78
+ </p>
79
+ </div>
80
+ </template>
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, ref, watch } from 'vue'
3
3
  import { purposeAllowsAgentCategory } from '@cat-factory/contracts'
4
- import type { AgentKind, Pipeline, PipelinePurpose } from '~/types/domain'
4
+ import type { AgentKind, Pipeline } from '~/types/domain'
5
5
  import AgentPalette from '~/components/palettes/AgentPalette.vue'
6
6
  import AgentKindIcon from '~/components/pipeline/AgentKindIcon.vue'
7
7
  import AgentPromptEditor from '~/components/pipeline/AgentPromptEditor.vue'
@@ -30,18 +30,6 @@ const CONSENSUS_STRATEGIES = computed<{ value: ConsensusStrategy; label: string
30
30
  { value: 'ranked-voting', label: t('pipeline.builder.strategyOption.ranked-voting') },
31
31
  ])
32
32
 
33
- // The use-case classifier options for the pipeline (its `purpose`). Static literal `t()` keys, one
34
- // per `PIPELINE_PURPOSES` member, so the typed-message-keys check sees them (a runtime-built key
35
- // wouldn't be checkable). A non-`build` purpose hides the Implementation/Testing agent kinds in
36
- // the palette below (see `AgentPalette`), and drives which task pickers offer the saved pipeline.
37
- const PURPOSE_OPTIONS = computed<{ value: PipelinePurpose; label: string }[]>(() => [
38
- { value: 'build', label: t('pipeline.builder.purposeOption.build') },
39
- { value: 'document', label: t('pipeline.builder.purposeOption.document') },
40
- { value: 'review', label: t('pipeline.builder.purposeOption.review') },
41
- { value: 'research', label: t('pipeline.builder.purposeOption.research') },
42
- { value: 'planning', label: t('pipeline.builder.purposeOption.planning') },
43
- ])
44
-
45
33
  /** Add a blank participant to the draft step's consensus config. */
46
34
  function addParticipant(i: number) {
47
35
  const cfg = pipelines.draftConsensus[i]
@@ -208,12 +196,16 @@ const skillStepNeedsPick = computed(() =>
208
196
  ),
209
197
  )
210
198
 
211
- // Steps whose agent category the chosen purpose forbids (a non-`build` purpose writes no code and
212
- // runs no tests, so the Implementation/Testing categories are disallowed see
213
- // `purposeAllowsAgentCategory`). The palette hides those kinds, so this is only reachable by
214
- // switching an existing draft to a non-`build` purpose AFTER such steps were added. The backend has
215
- // no kind→category map to gate on, so the builder is the enforcement point: save is blocked until
216
- // the offending steps are removed (or the purpose set back to Build).
199
+ // Steps whose agent category the chosen purpose CONTRADICTS (a non-`build` purpose writes no code
200
+ // and runs no tests, so the Implementation/Testing categories are disallowed, see
201
+ // `purposeAllowsAgentCategory`). Only reachable by switching an existing draft to a non-`build`
202
+ // purpose AFTER such steps were added, since the palette offers neither. The backend has no
203
+ // kind→category map to gate on, so the builder is the enforcement point: save is blocked until the
204
+ // offending steps are removed (or the purpose set back to Build).
205
+ //
206
+ // Deliberately the compatibility predicate, not the palette's narrower relevance one: a purpose
207
+ // that merely stops SUGGESTING a category must not turn a pipeline somebody already built into
208
+ // one they cannot save.
217
209
  const stepsDisallowedByPurpose = computed(() =>
218
210
  pipelines.draft.filter((kind) => {
219
211
  const category = agentKindMeta(kind).category
@@ -492,7 +484,7 @@ async function clone(p: Pipeline) {
492
484
  </UButton>
493
485
  </div>
494
486
  <div class="flex-1 pe-1 lg:min-h-0 lg:overflow-y-auto">
495
- <AgentPalette :purpose="pipelines.draftPurpose" @add="add" />
487
+ <AgentPalette v-model:purpose="pipelines.draftPurpose" @add="add" />
496
488
  </div>
497
489
  </div>
498
490
 
@@ -524,24 +516,6 @@ async function clone(p: Pipeline) {
524
516
  data-testid="pipeline-builder-name"
525
517
  />
526
518
 
527
- <!-- Purpose: the pipeline's use-case classifier. Drives which task pickers offer it (a
528
- document task offers only `document` pipelines) and narrows the palette below (a
529
- non-build purpose hides the Implementation/Testing kinds). -->
530
- <div class="mb-2 flex items-center gap-2">
531
- <label class="shrink-0 text-[11px] font-medium text-slate-400">
532
- {{ t('pipeline.builder.purposeLabel') }}
533
- </label>
534
- <USelect
535
- :model-value="pipelines.draftPurpose ?? undefined"
536
- :items="PURPOSE_OPTIONS"
537
- value-key="value"
538
- size="sm"
539
- class="min-w-40"
540
- :placeholder="t('pipeline.builder.purposePlaceholder')"
541
- @update:model-value="pipelines.draftPurpose = $event"
542
- />
543
- </div>
544
-
545
519
  <!-- Description: the prose summary shown next to the step list in the pipeline pickers. -->
546
520
  <UTextarea
547
521
  v-model="pipelines.draftDescription"
@@ -0,0 +1,145 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { AgentArchetype } from '~/types/domain'
3
+ import { groupAgentPalette, narrowAgentPalette } from '~/utils/agentPalette'
4
+
5
+ const archetype = (
6
+ kind: string,
7
+ category?: AgentArchetype['category'],
8
+ tier?: AgentArchetype['tier'],
9
+ ): AgentArchetype => ({
10
+ kind: kind as AgentArchetype['kind'],
11
+ label: kind,
12
+ icon: 'i-lucide-bot',
13
+ color: '#fff',
14
+ description: kind,
15
+ ...(category ? { category } : {}),
16
+ ...(tier ? { tier } : {}),
17
+ })
18
+
19
+ // Spread across both dials on purpose: every combination of relevant/irrelevant to a `planning`
20
+ // pipeline (which keeps `review` + `design` and drops `build` / `test` / `docs` / `gates`) and
21
+ // visible/hidden at the `basic` tier, so the two counts cannot both be right by coincidence.
22
+ const CATALOG: AgentArchetype[] = [
23
+ archetype('coder', 'build', 'basic'), // irrelevant, in tier
24
+ archetype('tester', 'test', 'advanced'), // irrelevant AND out of tier
25
+ archetype('architect', 'design', 'basic'), // relevant, in tier
26
+ archetype('researcher', 'design', 'advanced'), // relevant, out of tier
27
+ archetype('documenter', 'docs', 'basic'), // irrelevant, in tier
28
+ // A deployment-registered kind declaring neither: no category to judge (always relevant) and an
29
+ // absent tier, which defaults to `intermediate` and so is out of the basic tier.
30
+ archetype('acme-auditor'),
31
+ ]
32
+
33
+ describe('narrowAgentPalette', () => {
34
+ it('narrows nothing for an unclassified pipeline at the widest tier', () => {
35
+ const { offered, hiddenByPurpose, hiddenByTier } = narrowAgentPalette(CATALOG, null, 'advanced')
36
+ expect(offered.map((a) => a.kind)).toEqual(CATALOG.map((a) => a.kind))
37
+ expect(hiddenByPurpose).toBe(0)
38
+ expect(hiddenByTier).toBe(0)
39
+ })
40
+
41
+ it('accumulates the tiers when the purpose narrows nothing', () => {
42
+ expect(narrowAgentPalette(CATALOG, null, 'basic').offered.map((a) => a.kind)).toEqual([
43
+ 'coder',
44
+ 'architect',
45
+ 'documenter',
46
+ ])
47
+ expect(narrowAgentPalette(CATALOG, null, 'intermediate').offered.map((a) => a.kind)).toEqual([
48
+ 'coder',
49
+ 'architect',
50
+ 'documenter',
51
+ // An undeclared tier defaults to intermediate, so it appears here.
52
+ 'acme-auditor',
53
+ ])
54
+ expect(narrowAgentPalette(CATALOG, null, 'basic').hiddenByTier).toBe(3)
55
+ })
56
+
57
+ it('counts each dial against what the OTHER already admits', () => {
58
+ // The regression this shape exists for: measuring the purpose count over the whole catalog
59
+ // reported 3 here (coder, tester, documenter) while switching back to Build at this tier
60
+ // reveals only 2: `tester` is tier-hidden either way, so naming it under the purpose control
61
+ // sends the reader to a dial that cannot produce it.
62
+ const { offered, hiddenByPurpose, hiddenByTier } = narrowAgentPalette(
63
+ CATALOG,
64
+ 'planning',
65
+ 'basic',
66
+ )
67
+ expect(offered.map((a) => a.kind)).toEqual(['architect'])
68
+ expect(hiddenByPurpose).toBe(2)
69
+ expect(hiddenByTier).toBe(2)
70
+ })
71
+
72
+ it('states the full purpose narrowing once the tier stops hiding anything', () => {
73
+ const { offered, hiddenByPurpose, hiddenByTier } = narrowAgentPalette(
74
+ CATALOG,
75
+ 'planning',
76
+ 'advanced',
77
+ )
78
+ // The uncategorized kind has nothing for the purpose dial to judge, so it stays offered.
79
+ expect(offered.map((a) => a.kind)).toEqual(['architect', 'researcher', 'acme-auditor'])
80
+ expect(hiddenByPurpose).toBe(3)
81
+ expect(hiddenByTier).toBe(0)
82
+ })
83
+
84
+ it('leaves a kind both dials hide out of both counts', () => {
85
+ // Each count promises "relax THIS dial alone and you get n more", and relaxing either alone
86
+ // would not reveal `tester`. So the four buckets partition the catalog with it in none of the
87
+ // three the palette names, which is the property that keeps the two hints from double-counting.
88
+ const { offered, hiddenByPurpose, hiddenByTier } = narrowAgentPalette(
89
+ CATALOG,
90
+ 'planning',
91
+ 'basic',
92
+ )
93
+ const hiddenByBoth = CATALOG.length - offered.length - hiddenByPurpose - hiddenByTier
94
+ expect(hiddenByBoth).toBe(1)
95
+ })
96
+
97
+ it('keeps a purpose this build does not recognise from narrowing anything', () => {
98
+ // A stored `purpose` from a build that shipped a member this one has not (or has retired):
99
+ // unknown is not a licence to guess, so the palette offers what the tier admits and says so.
100
+ const purpose = 'acme-migration' as never
101
+ const { offered, hiddenByPurpose } = narrowAgentPalette(CATALOG, purpose, 'advanced')
102
+ expect(offered.map((a) => a.kind)).toEqual(CATALOG.map((a) => a.kind))
103
+ expect(hiddenByPurpose).toBe(0)
104
+ })
105
+ })
106
+
107
+ // The rendered sections, mirroring `utils/catalog.ts` shape (id + display label).
108
+ const SECTIONS = [
109
+ { id: 'design', label: 'Design & research' },
110
+ { id: 'build', label: 'Implementation' },
111
+ { id: 'test', label: 'Testing' },
112
+ { id: 'docs', label: 'Documentation' },
113
+ ]
114
+
115
+ describe('groupAgentPalette', () => {
116
+ it('fills the sections in their given order and drops the empty ones', () => {
117
+ const groups = groupAgentPalette(CATALOG, SECTIONS, 'Custom agents')
118
+ expect(groups.map((g) => [g.id, g.agents.map((a) => a.kind)])).toEqual([
119
+ ['design', ['architect', 'researcher']],
120
+ ['build', ['coder']],
121
+ ['test', ['tester']],
122
+ ['docs', ['documenter']],
123
+ // The uncategorized kind, in the trailing bucket. `review` and `gates` have no members
124
+ // here, so neither section is rendered at all.
125
+ ['custom', ['acme-auditor']],
126
+ ])
127
+ })
128
+
129
+ it('files a kind whose category has no section under custom rather than deleting it', () => {
130
+ // The regression: every section filter misses it AND so does a bare `!a.category`, so it
131
+ // vanished from a palette whose save gate accepts it. Reachable from both sides (a
132
+ // deployment-registered kind naming a category this build retired, and this list drifting
133
+ // behind the schema), which is why the leftover bucket is derived from the sections.
134
+ const registered = archetype('acme-auditor', 'observability' as never, 'basic')
135
+ const groups = groupAgentPalette([registered], SECTIONS, 'Custom agents')
136
+ expect(groups).toEqual([{ id: 'custom', label: 'Custom agents', agents: [registered] }])
137
+ })
138
+
139
+ it('renders no custom section when every kind was claimed', () => {
140
+ const claimed = CATALOG.filter((a) => a.category)
141
+ const groups = groupAgentPalette(claimed, SECTIONS, 'Custom agents')
142
+ expect(groups.map((g) => g.id)).not.toContain('custom')
143
+ expect(groups.flatMap((g) => g.agents)).toHaveLength(claimed.length)
144
+ })
145
+ })
@@ -0,0 +1,89 @@
1
+ import {
2
+ type AgentTier,
3
+ agentTierVisibleAt,
4
+ type PipelinePurpose,
5
+ purposeSuggestsAgentCategory,
6
+ } from '@cat-factory/contracts'
7
+ import type { AgentArchetype } from '~/types/domain'
8
+
9
+ /**
10
+ * The pipeline builder's palette narrows the agent catalog on TWO dials, and the pair is the
11
+ * reason this reduction is one function rather than two chained filters at the call site.
12
+ *
13
+ * The dials are independent (the pipeline's PURPOSE says which categories the work has any use
14
+ * for, the agent TIER says how deep into the catalog to look), but their hints are not, because
15
+ * each hint is an invitation to reach for that dial. Chaining the filters and subtracting the
16
+ * lengths gives the second dial an honest count and the first one the whole rest of the catalog,
17
+ * so at the default `basic` tier a `planning` pipeline reported thirteen kinds hidden for its
18
+ * purpose when switching back to Build revealed three: the other ten were tier-hidden either way.
19
+ * That is exactly the "chasing a control that cannot help them" the ordering was meant to avoid,
20
+ * just pointed at the other dial.
21
+ *
22
+ * So each count is measured against what the OTHER dial already admits, which makes both of them
23
+ * the same promise: relax THIS dial alone and you get n more. A kind both dials hide is counted
24
+ * by neither, and correctly: relaxing either one alone would not reveal it.
25
+ */
26
+ export interface NarrowedAgentPalette<T> {
27
+ /** The archetypes both dials admit: what the palette renders, in input order. */
28
+ offered: T[]
29
+ /** How many more the CURRENT tier would show if the purpose narrowed nothing. */
30
+ hiddenByPurpose: number
31
+ /** How many more the CURRENT purpose would show at the widest tier. */
32
+ hiddenByTier: number
33
+ }
34
+
35
+ /**
36
+ * Reduce `archetypes` to what the palette offers at `purpose` + `tier`, with each dial's hint
37
+ * count (see {@link NarrowedAgentPalette} for what the counts promise).
38
+ *
39
+ * An archetype carrying no `category` has nothing for the purpose dial to judge, so it is always
40
+ * relevant; an absent `tier` is `DEFAULT_AGENT_TIER`. Both are how a deployment-registered kind
41
+ * that declares neither behaves, and neither is a reason to drop it from the catalog.
42
+ */
43
+ export function narrowAgentPalette<T extends Pick<AgentArchetype, 'tier' | 'category'>>(
44
+ archetypes: readonly T[],
45
+ purpose: PipelinePurpose | null | undefined,
46
+ tier: AgentTier,
47
+ ): NarrowedAgentPalette<T> {
48
+ const relevant = (a: T) => !a.category || purposeSuggestsAgentCategory(purpose, a.category)
49
+ const inTier = (a: T) => agentTierVisibleAt(a.tier, tier)
50
+ return {
51
+ offered: archetypes.filter((a) => relevant(a) && inTier(a)),
52
+ hiddenByPurpose: archetypes.filter((a) => inTier(a) && !relevant(a)).length,
53
+ hiddenByTier: archetypes.filter((a) => relevant(a) && !inTier(a)).length,
54
+ }
55
+ }
56
+
57
+ /** One rendered palette section: an ordered category, or the trailing custom bucket. */
58
+ export interface AgentPaletteGroup<T> {
59
+ id: string
60
+ label: string
61
+ agents: T[]
62
+ }
63
+
64
+ /**
65
+ * Group what the palette offers into `sections` in their given order, with everything left over
66
+ * in a trailing bucket under `customLabel`. Empty sections are dropped.
67
+ *
68
+ * The leftover bucket is what no section CLAIMED, not what carries no `category`, and the two are
69
+ * different populations: an archetype whose category has no section here belongs to neither, so
70
+ * testing for the absent one alone silently DELETED it from the palette. A `presentation.category`
71
+ * comes from a kind a DEPLOYMENT registered and `sections` is the SPA's own mirror of the schema,
72
+ * so the gap opens from either side, and the save gate accepts such a kind either way. Showing it
73
+ * under "custom" says what is true: nothing here knows where to file it.
74
+ */
75
+ export function groupAgentPalette<T extends Pick<AgentArchetype, 'category'>>(
76
+ offered: readonly T[],
77
+ sections: readonly { id: string; label: string }[],
78
+ customLabel: string,
79
+ ): AgentPaletteGroup<T>[] {
80
+ const claimed = new Set<string>(sections.map((s) => s.id))
81
+ const groups: AgentPaletteGroup<T>[] = sections.map((s) => ({
82
+ id: s.id,
83
+ label: s.label,
84
+ agents: offered.filter((a) => a.category === s.id),
85
+ }))
86
+ const custom = offered.filter((a) => !a.category || !claimed.has(a.category))
87
+ if (custom.length) groups.push({ id: 'custom', label: customLabel, agents: custom })
88
+ return groups.filter((g) => g.agents.length)
89
+ }
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import type { AgentArchetype } from '~/types/domain'
3
- import { filterByAgentTier, filterByAgentTierKeeping, isAgentTier } from '~/utils/agentTier'
3
+ import { filterByAgentTierKeeping, isAgentTier } from '~/utils/agentTier'
4
4
 
5
5
  const archetype = (kind: string, tier?: AgentArchetype['tier']): AgentArchetype => ({
6
6
  kind: kind as AgentArchetype['kind'],
@@ -19,22 +19,6 @@ const CATALOG: AgentArchetype[] = [
19
19
  archetype('acme-auditor'),
20
20
  ]
21
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
22
  describe('filterByAgentTierKeeping', () => {
39
23
  it('keeps a pinned kind the tier would otherwise hide, in catalog order', () => {
40
24
  const kept = filterByAgentTierKeeping(CATALOG, 'basic', (a) => a.kind === 'mocker')
@@ -25,14 +25,6 @@ export function isAgentTier(value: unknown): value is AgentTier {
25
25
  return typeof value === 'string' && (AGENT_TIERS as readonly string[]).includes(value)
26
26
  }
27
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
28
  /**
37
29
  * Keep the archetypes visible at `level`, PLUS any the caller marks as pinned — the model
38
30
  * preset editor's kinds that already carry an override. An entity can hold a setting written
@@ -1,8 +1,13 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import {
3
+ agentCategorySchema,
4
+ isAgentCategory,
5
+ isPipelinePurpose,
6
+ PIPELINE_PURPOSES,
3
7
  pipelineAllowedForBlockLevel,
4
8
  pipelineAllowedForTaskType,
5
9
  purposeAllowsAgentCategory,
10
+ purposeSuggestsAgentCategory,
6
11
  } from '@cat-factory/contracts'
7
12
  import type { Block, Pipeline } from '~/types/domain'
8
13
  import {
@@ -12,6 +17,17 @@ import {
12
17
  pipelineGateCount,
13
18
  } from '~/utils/pipeline'
14
19
 
20
+ // The palette categories, read off the schema the predicates themselves are typed against, so a
21
+ // new category joins these sweeps instead of quietly going unasserted behind a hand-listed tuple.
22
+ const AGENT_CATEGORIES = agentCategorySchema.options
23
+
24
+ // A `purpose` typed as a member and only DECLARED to be one: `Pipeline.purpose` is persisted and
25
+ // no boundary re-checks it against the union this build compiled, so a browser on a cached bundle
26
+ // sees a member shipped after it and a retired member goes on living in saved rows. Same story for
27
+ // a `presentation.category`, which arrives in the snapshot from a deployment-registered kind.
28
+ const UNKNOWN_PURPOSE = 'acme-migration' as never
29
+ const UNKNOWN_CATEGORY = 'observability' as never
30
+
15
31
  // A minimal pipeline: only the fields the launch/task-type filters read matter here.
16
32
  function pipeline(over: Partial<Pipeline> = {}): Pipeline {
17
33
  return { id: 'pl_x', name: 'X', agentKinds: ['coder'], ...over } as Pipeline
@@ -134,20 +150,21 @@ describe('pipelineAllowedForBlockLevel (initiative binding)', () => {
134
150
  })
135
151
  })
136
152
 
137
- describe('purposeAllowsAgentCategory (builder palette gate)', () => {
153
+ describe('purposeAllowsAgentCategory (builder save gate)', () => {
138
154
  it('a build (or unclassified) pipeline may use every category', () => {
139
155
  for (const purpose of ['build', null, undefined] as const) {
140
- for (const cat of ['review', 'design', 'build', 'test', 'docs', 'gates'] as const) {
156
+ for (const cat of AGENT_CATEGORIES) {
141
157
  expect(purposeAllowsAgentCategory(purpose, cat)).toBe(true)
142
158
  }
143
159
  }
144
160
  })
145
161
 
146
- it('a non-build pipeline hides the Implementation (build) and Testing (test) categories', () => {
162
+ it('a non-build pipeline refuses the Implementation (build) and Testing (test) categories', () => {
147
163
  for (const purpose of ['document', 'review', 'research', 'planning'] as const) {
148
164
  expect(purposeAllowsAgentCategory(purpose, 'build')).toBe(false)
149
165
  expect(purposeAllowsAgentCategory(purpose, 'test')).toBe(false)
150
- // Non-code categories stay visible.
166
+ // Everything else stays SAVEABLE even where the palette stops offering it (below), so a
167
+ // stored pipeline never becomes unsaveable because the relevance table gained an opinion.
151
168
  expect(purposeAllowsAgentCategory(purpose, 'docs')).toBe(true)
152
169
  expect(purposeAllowsAgentCategory(purpose, 'review')).toBe(true)
153
170
  expect(purposeAllowsAgentCategory(purpose, 'gates')).toBe(true)
@@ -155,6 +172,70 @@ describe('purposeAllowsAgentCategory (builder palette gate)', () => {
155
172
  })
156
173
  })
157
174
 
175
+ describe('purposeSuggestsAgentCategory (builder palette filter)', () => {
176
+ it('offers the whole catalog to a build pipeline and to an unclassified one', () => {
177
+ for (const purpose of ['build', null, undefined] as const) {
178
+ for (const cat of AGENT_CATEGORIES) {
179
+ expect(purposeSuggestsAgentCategory(purpose, cat)).toBe(true)
180
+ }
181
+ }
182
+ })
183
+
184
+ it('narrows each purpose to the categories it has a use for', () => {
185
+ // A PR review designs nothing and builds nothing; the plan an initiative pipeline produces
186
+ // is its own in-repo tracker, with no pull request to gate and no repo docs to write.
187
+ expect(purposeSuggestsAgentCategory('review', 'design')).toBe(false)
188
+ expect(purposeSuggestsAgentCategory('review', 'review')).toBe(true)
189
+ expect(purposeSuggestsAgentCategory('planning', 'gates')).toBe(false)
190
+ expect(purposeSuggestsAgentCategory('planning', 'docs')).toBe(false)
191
+ expect(purposeSuggestsAgentCategory('planning', 'design')).toBe(true)
192
+ // Authoring a document and running a spike are researched and reviewed like any change.
193
+ for (const purpose of ['document', 'research'] as const) {
194
+ expect(purposeSuggestsAgentCategory(purpose, 'design')).toBe(true)
195
+ expect(purposeSuggestsAgentCategory(purpose, 'docs')).toBe(true)
196
+ }
197
+ })
198
+
199
+ it('never offers what the save gate would refuse', () => {
200
+ // The invariant that keeps the two tables honest, over the whole grid rather than the cells
201
+ // that happen to differ today: relevance may hide more than compatibility, never less, or
202
+ // the palette would offer a kind whose step then blocks the save. The unknown purpose rides
203
+ // the same sweep because it is the case where the two could most easily read the same value
204
+ // in opposite directions, one narrowing by it and the other not.
205
+ for (const purpose of [...PIPELINE_PURPOSES, UNKNOWN_PURPOSE]) {
206
+ for (const cat of AGENT_CATEGORIES) {
207
+ if (purposeSuggestsAgentCategory(purpose, cat)) {
208
+ expect(purposeAllowsAgentCategory(purpose, cat)).toBe(true)
209
+ }
210
+ }
211
+ }
212
+ })
213
+ })
214
+
215
+ describe('a purpose or category this build does not recognise', () => {
216
+ it('is recognised as unknown rather than trusted', () => {
217
+ for (const purpose of PIPELINE_PURPOSES) expect(isPipelinePurpose(purpose)).toBe(true)
218
+ for (const cat of AGENT_CATEGORIES) expect(isAgentCategory(cat)).toBe(true)
219
+ expect(isPipelinePurpose('acme-migration')).toBe(false)
220
+ expect(isPipelinePurpose('')).toBe(false)
221
+ expect(isAgentCategory('observability')).toBe(false)
222
+ })
223
+
224
+ it('narrows nothing, in the palette or the save gate', () => {
225
+ // Both predicates index a table by these values, so before the guards an unknown purpose threw
226
+ // inside the palette's computed and white-screened the builder, while an unknown category read
227
+ // as `undefined` and silently dropped a registered kind the save gate would have accepted.
228
+ // Unknown means this build has nothing to narrow by, which is exactly what absent already means.
229
+ for (const cat of AGENT_CATEGORIES) {
230
+ expect(purposeSuggestsAgentCategory(UNKNOWN_PURPOSE, cat)).toBe(true)
231
+ expect(purposeAllowsAgentCategory(UNKNOWN_PURPOSE, cat)).toBe(true)
232
+ }
233
+ for (const purpose of PIPELINE_PURPOSES) {
234
+ expect(purposeSuggestsAgentCategory(purpose, UNKNOWN_CATEGORY)).toBe(true)
235
+ }
236
+ })
237
+ })
238
+
158
239
  describe('pipelineAllowedForManualStart composes the task-type gate', () => {
159
240
  const noFrame = undefined
160
241
  const blocks: Block[] = []
@@ -4214,6 +4214,7 @@
4214
4214
  "rounds": "Runden",
4215
4215
  "purposeLabel": "Zweck",
4216
4216
  "purposePlaceholder": "Zweck auswählen",
4217
+ "purposeUnrecognized": "Nicht erkannt ({purpose})",
4217
4218
  "purposeOption": {
4218
4219
  "build": "Entwicklung",
4219
4220
  "document": "Dokumentation",
@@ -6734,7 +6735,8 @@
6734
6735
  },
6735
6736
  "palette": {
6736
6737
  "hint": "Klicke auf einen Agenten, um ihn an die Pipeline anzuhängen.",
6737
- "customAgents": "Benutzerdefinierte Agenten"
6738
+ "customAgents": "Benutzerdefinierte Agenten",
6739
+ "purposeHidden": "{count} Agent ist für diesen Zweck ausgeblendet. | {count} Agenten sind für diesen Zweck ausgeblendet."
6738
6740
  },
6739
6741
  "forkDecision": {
6740
6742
  "title": "Implementierungsansatz wählen",
@@ -4796,6 +4796,10 @@
4796
4796
  "rounds": "Rounds",
4797
4797
  "purposeLabel": "Purpose",
4798
4798
  "purposePlaceholder": "Select a purpose",
4799
+ "purposeUnrecognized": "Unrecognized ({purpose})",
4800
+ "@purposeUnrecognized": {
4801
+ "description": "Shown on the pipeline-purpose control when the pipeline carries a purpose this build has no label for (a member added by a newer build, or one since retired). The raw stored value is quoted back so it can be recognised and re-picked."
4802
+ },
4799
4803
  "purposeOption": {
4800
4804
  "build": "Build",
4801
4805
  "document": "Documentation",
@@ -5129,7 +5133,11 @@
5129
5133
  },
5130
5134
  "palette": {
5131
5135
  "hint": "Click an agent to append it to the pipeline.",
5132
- "customAgents": "Custom agents"
5136
+ "customAgents": "Custom agents",
5137
+ "purposeHidden": "{count} agent hidden for this purpose. | {count} agents hidden for this purpose.",
5138
+ "@purposeHidden": {
5139
+ "description": "Shown under the pipeline-purpose control in the agent palette: how many agents are not offered because they are irrelevant to the pipeline's purpose (e.g. a coder in a documentation pipeline)."
5140
+ }
5133
5141
  },
5134
5142
  "gates": {
5135
5143
  "subtitle": {
@@ -4653,6 +4653,7 @@
4653
4653
  "rounds": "Rondas",
4654
4654
  "purposeLabel": "Propósito",
4655
4655
  "purposePlaceholder": "Seleccionar un propósito",
4656
+ "purposeUnrecognized": "No reconocido ({purpose})",
4656
4657
  "purposeOption": {
4657
4658
  "build": "Desarrollo",
4658
4659
  "document": "Documentación",
@@ -4903,7 +4904,8 @@
4903
4904
  },
4904
4905
  "palette": {
4905
4906
  "hint": "Haz clic en un agente para añadirlo al pipeline.",
4906
- "customAgents": "Agentes personalizados"
4907
+ "customAgents": "Agentes personalizados",
4908
+ "purposeHidden": "{count} agente oculto para este propósito. | {count} agentes ocultos para este propósito."
4907
4909
  },
4908
4910
  "gates": {
4909
4911
  "subtitle": {
@@ -4653,6 +4653,7 @@
4653
4653
  "rounds": "Tours",
4654
4654
  "purposeLabel": "Objectif",
4655
4655
  "purposePlaceholder": "Sélectionner un objectif",
4656
+ "purposeUnrecognized": "Non reconnu ({purpose})",
4656
4657
  "purposeOption": {
4657
4658
  "build": "Développement",
4658
4659
  "document": "Documentation",
@@ -4903,7 +4904,8 @@
4903
4904
  },
4904
4905
  "palette": {
4905
4906
  "hint": "Cliquez sur un agent pour l'ajouter au pipeline.",
4906
- "customAgents": "Agents personnalisés"
4907
+ "customAgents": "Agents personnalisés",
4908
+ "purposeHidden": "{count} agent masqué pour cet objectif. | {count} agents masqués pour cet objectif."
4907
4909
  },
4908
4910
  "gates": {
4909
4911
  "subtitle": {
@@ -4653,6 +4653,7 @@
4653
4653
  "rounds": "סבבים",
4654
4654
  "purposeLabel": "מטרה",
4655
4655
  "purposePlaceholder": "בחר מטרה",
4656
+ "purposeUnrecognized": "לא מזוהה ({purpose})",
4656
4657
  "purposeOption": {
4657
4658
  "build": "פיתוח",
4658
4659
  "document": "תיעוד",
@@ -4903,7 +4904,8 @@
4903
4904
  },
4904
4905
  "palette": {
4905
4906
  "hint": "לחץ על סוכן כדי להוסיף אותו לצינור.",
4906
- "customAgents": "סוכנים מותאמים אישית"
4907
+ "customAgents": "סוכנים מותאמים אישית",
4908
+ "purposeHidden": "סוכן אחד מוסתר עבור מטרה זו. | שני סוכנים מוסתרים עבור מטרה זו. | {count} סוכנים מוסתרים עבור מטרה זו."
4907
4909
  },
4908
4910
  "gates": {
4909
4911
  "subtitle": {
@@ -4214,6 +4214,7 @@
4214
4214
  "rounds": "Giri",
4215
4215
  "purposeLabel": "Scopo",
4216
4216
  "purposePlaceholder": "Seleziona uno scopo",
4217
+ "purposeUnrecognized": "Non riconosciuto ({purpose})",
4217
4218
  "purposeOption": {
4218
4219
  "build": "Sviluppo",
4219
4220
  "document": "Documentazione",
@@ -6734,7 +6735,8 @@
6734
6735
  },
6735
6736
  "palette": {
6736
6737
  "hint": "Clicca su un agente per aggiungerlo alla pipeline.",
6737
- "customAgents": "Agenti personalizzati"
6738
+ "customAgents": "Agenti personalizzati",
6739
+ "purposeHidden": "{count} agente nascosto per questo scopo. | {count} agenti nascosti per questo scopo."
6738
6740
  },
6739
6741
  "forkDecision": {
6740
6742
  "title": "Scegli un approccio di implementazione",
@@ -4653,6 +4653,7 @@
4653
4653
  "rounds": "ラウンド",
4654
4654
  "purposeLabel": "目的",
4655
4655
  "purposePlaceholder": "目的を選択",
4656
+ "purposeUnrecognized": "認識できません ({purpose})",
4656
4657
  "purposeOption": {
4657
4658
  "build": "開発",
4658
4659
  "document": "ドキュメント",
@@ -4903,7 +4904,8 @@
4903
4904
  },
4904
4905
  "palette": {
4905
4906
  "hint": "エージェントをクリックするとパイプラインに追加されます。",
4906
- "customAgents": "カスタムエージェント"
4907
+ "customAgents": "カスタムエージェント",
4908
+ "purposeHidden": "この目的では {count} 件のエージェントが非表示です。"
4907
4909
  },
4908
4910
  "gates": {
4909
4911
  "subtitle": {
@@ -4653,6 +4653,7 @@
4653
4653
  "rounds": "Rundy",
4654
4654
  "purposeLabel": "Cel",
4655
4655
  "purposePlaceholder": "Wybierz cel",
4656
+ "purposeUnrecognized": "Nierozpoznany ({purpose})",
4656
4657
  "purposeOption": {
4657
4658
  "build": "Programowanie",
4658
4659
  "document": "Dokumentacja",
@@ -4903,7 +4904,8 @@
4903
4904
  },
4904
4905
  "palette": {
4905
4906
  "hint": "Kliknij agenta, aby dodać go do pipeline'u.",
4906
- "customAgents": "Agenci niestandardowi"
4907
+ "customAgents": "Agenci niestandardowi",
4908
+ "purposeHidden": "{count} agent ukryty dla tego celu. | {count} agenci ukryci dla tego celu. | {count} agentów ukrytych dla tego celu."
4907
4909
  },
4908
4910
  "gates": {
4909
4911
  "subtitle": {
@@ -4653,6 +4653,7 @@
4653
4653
  "rounds": "Tur",
4654
4654
  "purposeLabel": "Amaç",
4655
4655
  "purposePlaceholder": "Bir amaç seçin",
4656
+ "purposeUnrecognized": "Tanınmayan ({purpose})",
4656
4657
  "purposeOption": {
4657
4658
  "build": "Geliştirme",
4658
4659
  "document": "Dokümantasyon",
@@ -4903,7 +4904,8 @@
4903
4904
  },
4904
4905
  "palette": {
4905
4906
  "hint": "Bir agent'ı pipeline'a eklemek için tıklayın.",
4906
- "customAgents": "Özel agent'lar"
4907
+ "customAgents": "Özel agent'lar",
4908
+ "purposeHidden": "Bu amaç için {count} ajan gizli."
4907
4909
  },
4908
4910
  "gates": {
4909
4911
  "subtitle": {
@@ -4653,6 +4653,7 @@
4653
4653
  "rounds": "Раунди",
4654
4654
  "purposeLabel": "Призначення",
4655
4655
  "purposePlaceholder": "Виберіть призначення",
4656
+ "purposeUnrecognized": "Нерозпізнано ({purpose})",
4656
4657
  "purposeOption": {
4657
4658
  "build": "Розробка",
4658
4659
  "document": "Документація",
@@ -4903,7 +4904,8 @@
4903
4904
  },
4904
4905
  "palette": {
4905
4906
  "hint": "Натисніть агента, щоб додати його до пайплайну.",
4906
- "customAgents": "Власні агенти"
4907
+ "customAgents": "Власні агенти",
4908
+ "purposeHidden": "{count} агента приховано для цього призначення. | {count} агентів приховано для цього призначення. | {count} агентів приховано для цього призначення."
4907
4909
  },
4908
4910
  "gates": {
4909
4911
  "subtitle": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.253.2",
3
+ "version": "0.254.1",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -40,7 +40,7 @@
40
40
  "valibot": "^1.4.2",
41
41
  "vue": "3.5.40",
42
42
  "wretch": "^3.0.9",
43
- "@cat-factory/contracts": "0.276.0"
43
+ "@cat-factory/contracts": "0.278.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@toad-contracts/testing": "0.3.2",