@cat-factory/app 0.254.2 → 0.255.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.
package/README.md CHANGED
@@ -271,12 +271,20 @@ The builder's palette narrows on two axes, and both controls sit on one row abov
271
271
  (`PipelinePurposeSelect` above `AgentTierSelect`), each with its own "n hidden" hint so neither
272
272
  narrowing reads as an empty catalog. The tier says how deep to look; the **purpose** says what
273
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
274
+ drops the categories that purpose has no use for. It reaches past the palette: the saved-pipeline
275
+ library in the builder's third column lists the pipelines built for the purpose being edited, so
276
+ one dial narrows both ends of the slideover. The purpose is not a view preference either: it is
275
277
  saved on the pipeline and also decides which task pickers offer it, which is why the control
276
278
  writes through to the draft while the tier writes to its own store.
277
279
 
278
- Purpose is filtered by two predicates in `@cat-factory/contracts`, and the difference between
279
- them is the point:
280
+ `Pipeline.purpose` is MANDATORY, so there is no unclassified state for any of these surfaces to
281
+ invent a policy for: a new draft starts at `build` (what an unclassified pipeline always behaved
282
+ as) and the dial only moves it. What each surface still has to read carefully is a purpose the
283
+ BUNDLE cannot name, which the persisted, closed vocabulary makes reachable in both directions (a
284
+ browser older than a new member, a row older than a retired one).
285
+
286
+ Purpose is filtered by three predicates in `@cat-factory/contracts`, and the difference between
287
+ the first two is the point:
280
288
 
281
289
  - `purposeSuggestsAgentCategory` is **relevance**: what the palette OFFERS. Opinionated (a
282
290
  review pipeline designs nothing; a planning pipeline has no pull request to gate), because a
@@ -284,14 +292,28 @@ them is the point:
284
292
  - `purposeAllowsAgentCategory` is **compatibility**: what the builder will SAVE. It states only
285
293
  what is contradictory (a pipeline that writes no code carrying an implementation step) and
286
294
  drives the draft's conflict warning.
295
+ - `pipelineMatchesPurpose` is **membership**: which SAVED pipelines the builder's library lists,
296
+ reduced with the label and archive dials in `utils/pipelineLibrary.ts`. The one dial in that
297
+ column whose control is elsewhere, so it is the one that owes a "n hidden" hint. It may be
298
+ exact where the pickers' `pipelineAllowedForTaskType` is permissive, because it narrows a list
299
+ somebody is BROWSING rather than one they are about to run from: two known purposes never mix,
300
+ while a pipeline whose classifier this build cannot NAME is listed at every purpose rather than
301
+ vanishing from the editor that has to fix it.
302
+
303
+ The library's purpose is a BROWSING dial of its own, defaulting to the draft's and relaxed by the
304
+ hint itself ("show every purpose"). Reading the draft directly is the trap: it is an authoring
305
+ field with no "off" setting, so a hint that only NAMES the absence would send the reader to a
306
+ control whose every setting narrows and whose every change is saved. A dial that hides rows owes
307
+ both a count and a way back, and the way back may not be an edit.
287
308
 
288
309
  Relevance is a subset of compatibility, asserted over the whole grid in `pipeline.spec.ts`. Keep
289
310
  it that way: the palette may hide what the save gate tolerates, so tightening the relevance table
290
311
  never turns a stored pipeline into one its own editor refuses, but offering a kind the save gate
291
312
  then rejects would be a dead end with the refusal arriving after the work.
292
313
 
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
314
+ **Each hint counts what relaxing THAT dial alone would reveal**, which is why each reduction is one
315
+ function (`utils/agentPalette.ts` for the catalog, `utils/pipelineLibrary.ts` for the library)
316
+ rather than chained filters at the call site. Chaining them
295
317
  and subtracting the lengths gives the second dial an honest count and hands the first one the whole
296
318
  rest of the catalog: at the default `basic` tier a `planning` pipeline claimed thirteen kinds hidden
297
319
  for its purpose when switching back to Build revealed three, the other ten being tier-hidden either
@@ -17,8 +17,9 @@ defineEmits<{
17
17
  }>()
18
18
  // The purpose of the pipeline being built, edited right here by the control above the catalog.
19
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.
21
- const props = defineProps<{ purpose?: PipelinePurpose | null }>()
20
+ // designs nothing and builds nothing, see `purposeSuggestsAgentCategory`). Required, like the
21
+ // field it edits: a draft is classified from the moment it exists.
22
+ const props = defineProps<{ purpose: PipelinePurpose }>()
22
23
 
23
24
  // The post-release-health gate is only meaningful — and only accepted by the backend —
24
25
  // with an observability integration connected, so it appears in the palette ONLY then.
@@ -13,8 +13,8 @@ import { isPipelinePurpose, PIPELINE_PURPOSES, type PipelinePurpose } from '@cat
13
13
  const { t } = useI18n()
14
14
 
15
15
  const props = defineProps<{
16
- /** The draft's purpose. `null` = unclassified, which narrows nothing. */
17
- purpose?: PipelinePurpose | null
16
+ /** The draft's purpose. Required: every pipeline carries one, the builder's default being `build`. */
17
+ purpose: PipelinePurpose
18
18
  /** How many agent kinds the current purpose hides. Renders the "n hidden" hint when > 0. */
19
19
  hiddenCount?: number
20
20
  }>()
@@ -31,19 +31,15 @@ const PURPOSE_LABELS = computed<Record<PipelinePurpose, string>>(() => ({
31
31
  planning: t('pipeline.builder.purposeOption.planning'),
32
32
  }))
33
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.
34
+ // The button text. A stored purpose this build has no label for is NAMED as unrecognised and
35
+ // quoted back, rather than left to render as an empty string after the colon: it is the one state
36
+ // the user cannot diagnose from the control, because a purpose this bundle cannot name narrows
37
+ // nothing (see `purposeSuggestsAgentCategory`), so a blank label would read as a pipeline nobody
38
+ // classified while the saved row says otherwise. The menu still lists every member this build has,
39
+ // so naming the value is also the way out of it. Reachable even though the field is mandatory: the
40
+ // value is persisted and this bundle can be older than the member it reads.
44
41
  const current = computed(() => {
45
42
  const purpose = props.purpose
46
- if (!purpose) return t('pipeline.builder.purposePlaceholder')
47
43
  if (!isPipelinePurpose(purpose)) return t('pipeline.builder.purposeUnrecognized', { purpose })
48
44
  return PURPOSE_LABELS.value[purpose]
49
45
  })
@@ -11,6 +11,7 @@ import OutputBudgetInput from '~/components/pipeline/OutputBudgetInput.vue'
11
11
  import BinaryOutputStepPicker from '~/components/pipeline/BinaryOutputStepPicker.vue'
12
12
  import { ESTIMATE_AXES, ESTIMATE_AXIS_FIELD, type EstimateAxis } from '~/utils/estimateGating'
13
13
  import { showOverrideField } from '~/utils/uiMode'
14
+ import { narrowPipelineLibrary } from '~/utils/pipelineLibrary'
14
15
  import {
15
16
  agentKindMeta,
16
17
  companionForProducer,
@@ -403,14 +404,26 @@ const showArchived = ref(false)
403
404
  const allLabels = computed(() =>
404
405
  [...new Set(pipelines.pipelines.flatMap((p) => p.labels ?? []))].sort(),
405
406
  )
406
- const archivedCount = computed(() => pipelines.pipelines.filter((p) => p.archived).length)
407
- const visiblePipelines = computed(() =>
408
- pipelines.pipelines.filter((p) => {
409
- if (!showArchived.value && p.archived) return false
410
- if (labelFilter.value && !(p.labels ?? []).includes(labelFilter.value)) return false
411
- return true
407
+ // The library is narrowed by the same purpose the palette is: browsing at `review` lists the
408
+ // pipelines built for reviewing, not the whole workspace catalog. `narrowPipelineLibrary` owns the
409
+ // reduction so the rule is unit-testable and each hint counts the population its own control can
410
+ // actually reveal, rather than the rows another dial is hiding either way.
411
+ //
412
+ // It is a BROWSING dial of its own, not a read of `draftPurpose`. The draft's purpose is an
413
+ // authoring field with no "unclassified" setting, so keying the library straight off it would
414
+ // leave the only ways back through the hidden rows a re-classification of the pipeline being
415
+ // edited (which persists) or discarding the draft. This ref is what "relax THIS dial alone" means
416
+ // for the purpose: it defaults to following the draft and the hint below toggles it, changing what
417
+ // is listed and nothing that gets saved.
418
+ const browseEveryPurpose = ref(false)
419
+ const library = computed(() =>
420
+ narrowPipelineLibrary(pipelines.pipelines, {
421
+ purpose: browseEveryPurpose.value ? null : pipelines.draftPurpose,
422
+ label: labelFilter.value,
423
+ showArchived: showArchived.value,
412
424
  }),
413
425
  )
426
+ const visiblePipelines = computed(() => library.value.offered)
414
427
  async function toggleArchive(p: Pipeline) {
415
428
  try {
416
429
  if (p.archived) await pipelines.unarchive(p.id)
@@ -1131,7 +1144,7 @@ async function clone(p: Pipeline) {
1131
1144
  {{ t('pipeline.builder.savedPipelines') }}
1132
1145
  </h3>
1133
1146
  <UButton
1134
- v-if="archivedCount"
1147
+ v-if="library.archivedInScope"
1135
1148
  :icon="showArchived ? 'i-lucide-archive-restore' : 'i-lucide-archive'"
1136
1149
  :color="showArchived ? 'primary' : 'neutral'"
1137
1150
  variant="ghost"
@@ -1145,7 +1158,7 @@ async function clone(p: Pipeline) {
1145
1158
  {{
1146
1159
  showArchived
1147
1160
  ? t('pipeline.builder.hideArchived')
1148
- : t('pipeline.builder.archivedCount', { count: archivedCount })
1161
+ : t('pipeline.builder.archivedCount', { count: library.archivedInScope })
1149
1162
  }}
1150
1163
  </UButton>
1151
1164
  </div>
@@ -1174,6 +1187,40 @@ async function clone(p: Pipeline) {
1174
1187
  </UBadge>
1175
1188
  </div>
1176
1189
 
1190
+ <!-- What the purpose is holding back, and the way past it. Stated here rather than
1191
+ beside the palette's purpose control because this is where the absence is noticed:
1192
+ a narrowed library must never read as the whole one, and a hint that only NAMES an
1193
+ absence sends the reader to the authoring dial one column left, whose every setting
1194
+ narrows and whose every change is saved. -->
1195
+ <div
1196
+ v-if="browseEveryPurpose || library.hiddenByPurpose"
1197
+ class="mb-2 flex shrink-0 flex-wrap items-center gap-x-2 px-1 text-[10px] text-slate-500"
1198
+ >
1199
+ <span>
1200
+ {{
1201
+ browseEveryPurpose
1202
+ ? t('pipeline.builder.everyPurposeListed')
1203
+ : t(
1204
+ 'pipeline.builder.purposeHiddenPipelines',
1205
+ { count: library.hiddenByPurpose },
1206
+ library.hiddenByPurpose,
1207
+ )
1208
+ }}
1209
+ </span>
1210
+ <button
1211
+ type="button"
1212
+ class="underline underline-offset-2 hover:text-slate-300"
1213
+ data-testid="pipeline-library-purpose-toggle"
1214
+ @click="browseEveryPurpose = !browseEveryPurpose"
1215
+ >
1216
+ {{
1217
+ browseEveryPurpose
1218
+ ? t('pipeline.builder.narrowToDraftPurpose')
1219
+ : t('pipeline.builder.listEveryPurpose')
1220
+ }}
1221
+ </button>
1222
+ </div>
1223
+
1177
1224
  <ul class="flex-1 space-y-1.5 pe-1 lg:min-h-0 lg:overflow-y-auto">
1178
1225
  <li
1179
1226
  v-for="p in visiblePipelines"
@@ -20,6 +20,7 @@ function builtin(agentKinds: string[], over: Partial<Pipeline> = {}): Pipeline {
20
20
  return {
21
21
  id: `pl_test_${nextId++}`,
22
22
  name: 'Test',
23
+ purpose: 'build',
23
24
  agentKinds,
24
25
  builtin: true,
25
26
  version: 1,
@@ -96,7 +96,7 @@ export interface PipelinesContext {
96
96
  draftTesterQuality: Ref<(TesterQualityConfig | null)[]>
97
97
  draftStepOptions: Ref<(StepOptions | null)[]>
98
98
  draftLabels: Ref<string[]>
99
- draftPurpose: Ref<PipelinePurpose | null>
99
+ draftPurpose: Ref<PipelinePurpose>
100
100
  draftName: Ref<string>
101
101
  draftDescription: Ref<string>
102
102
  editingId: Ref<string | null>
@@ -165,7 +165,7 @@ export function createPipelineDraftActions(ctx: PipelinesContext) {
165
165
  draftTesterQuality.value = []
166
166
  draftStepOptions.value = []
167
167
  draftLabels.value = []
168
- draftPurpose.value = null
168
+ draftPurpose.value = 'build'
169
169
  draftName.value = 'New pipeline'
170
170
  draftDescription.value = ''
171
171
  editingId.value = null
@@ -185,7 +185,7 @@ export function createPipelineDraftActions(ctx: PipelinesContext) {
185
185
  )
186
186
  draftStepOptions.value = pipeline.agentKinds.map((_, i) => pipeline.stepOptions?.[i] ?? null)
187
187
  draftLabels.value = [...(pipeline.labels ?? [])]
188
- draftPurpose.value = pipeline.purpose ?? null
188
+ draftPurpose.value = pipeline.purpose
189
189
  draftName.value = pipeline.name
190
190
  draftDescription.value = pipeline.description ?? ''
191
191
  editingId.value = pipeline.id
@@ -62,10 +62,10 @@ export function createPipelinePersistence(
62
62
  stepOptions: ctx.draftStepOptions.value.map((o) => o ?? null),
63
63
  // Only send labels when there are any.
64
64
  ...(ctx.draftLabels.value.length ? { labels: [...ctx.draftLabels.value] } : {}),
65
- // Only send purpose when the pipeline is classified (null leave unclassified). Like the
66
- // legacy per-step arrays, an omitted `purpose` on update reads as "keep existing"; clearing
67
- // a classification back to unclassified is not a supported edit (every built-in ships one).
68
- ...(ctx.draftPurpose.value ? { purpose: ctx.draftPurpose.value } : {}),
65
+ // ALWAYS sent, like `stepOptions` above and for the same reason: `purpose` is mandatory on
66
+ // create, and on update an omitted field reads as "keep existing", so a re-classification
67
+ // would silently not persist. There is no "unclassified" to clear back to.
68
+ purpose: ctx.draftPurpose.value,
69
69
  }
70
70
  }
71
71
 
@@ -70,11 +70,15 @@ export const usePipelinesStore = defineStore('pipelines', () => {
70
70
  const draftLabels = ref<string[]>([])
71
71
  /**
72
72
  * The use-case classifier of the pipeline being assembled/edited (`build` / `document` /
73
- * `review` / `research` / `planning`), or null when unclassified. Drives which task pickers
74
- * offer the saved pipeline and which agent kinds the builder palette shows (a non-`build`
75
- * purpose hides the Implementation/Testing kinds).
73
+ * `review` / `research` / `planning`). Drives which task pickers offer the saved pipeline, which
74
+ * agent kinds the builder palette shows (a non-`build` purpose hides the Implementation/Testing
75
+ * kinds) and which saved pipelines the library lists.
76
+ *
77
+ * Never null: `Pipeline.purpose` is mandatory, so a draft is classified from the moment it
78
+ * exists. `build` is the default because it is what an unclassified pipeline has always behaved
79
+ * as, so the dial starts where the old absence pointed rather than at a choice nobody made.
76
80
  */
77
- const draftPurpose = ref<PipelinePurpose | null>(null)
81
+ const draftPurpose = ref<PipelinePurpose>('build')
78
82
  const draftName = ref('New pipeline')
79
83
  /** Prose description for the pipeline being assembled/edited (shown in the pickers). */
80
84
  const draftDescription = ref('')
@@ -31,27 +31,33 @@ const CATALOG: AgentArchetype[] = [
31
31
  ]
32
32
 
33
33
  describe('narrowAgentPalette', () => {
34
- it('narrows nothing for an unclassified pipeline at the widest tier', () => {
35
- const { offered, hiddenByPurpose, hiddenByTier } = narrowAgentPalette(CATALOG, null, 'advanced')
34
+ it('narrows nothing for a build pipeline at the widest tier', () => {
35
+ const { offered, hiddenByPurpose, hiddenByTier } = narrowAgentPalette(
36
+ CATALOG,
37
+ 'build',
38
+ 'advanced',
39
+ )
36
40
  expect(offered.map((a) => a.kind)).toEqual(CATALOG.map((a) => a.kind))
37
41
  expect(hiddenByPurpose).toBe(0)
38
42
  expect(hiddenByTier).toBe(0)
39
43
  })
40
44
 
41
45
  it('accumulates the tiers when the purpose narrows nothing', () => {
42
- expect(narrowAgentPalette(CATALOG, null, 'basic').offered.map((a) => a.kind)).toEqual([
46
+ expect(narrowAgentPalette(CATALOG, 'build', 'basic').offered.map((a) => a.kind)).toEqual([
43
47
  'coder',
44
48
  'architect',
45
49
  'documenter',
46
50
  ])
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)
51
+ expect(narrowAgentPalette(CATALOG, 'build', 'intermediate').offered.map((a) => a.kind)).toEqual(
52
+ [
53
+ 'coder',
54
+ 'architect',
55
+ 'documenter',
56
+ // An undeclared tier defaults to intermediate, so it appears here.
57
+ 'acme-auditor',
58
+ ],
59
+ )
60
+ expect(narrowAgentPalette(CATALOG, 'build', 'basic').hiddenByTier).toBe(3)
55
61
  })
56
62
 
57
63
  it('counts each dial against what the OTHER already admits', () => {
@@ -42,7 +42,7 @@ export interface NarrowedAgentPalette<T> {
42
42
  */
43
43
  export function narrowAgentPalette<T extends Pick<AgentArchetype, 'tier' | 'category'>>(
44
44
  archetypes: readonly T[],
45
- purpose: PipelinePurpose | null | undefined,
45
+ purpose: PipelinePurpose,
46
46
  tier: AgentTier,
47
47
  ): NarrowedAgentPalette<T> {
48
48
  const relevant = (a: T) => !a.category || purposeSuggestsAgentCategory(purpose, a.category)
@@ -74,16 +74,19 @@ describe('pipelineAllowedForTaskType', () => {
74
74
  expect(pipelineAllowedForTaskType(pipeline({ purpose: 'document' }), 'document')).toBe(true)
75
75
  expect(pipelineAllowedForTaskType(pipeline({ purpose: 'build' }), 'document')).toBe(false)
76
76
  expect(pipelineAllowedForTaskType(pipeline({ purpose: 'research' }), 'document')).toBe(false)
77
- // An unclassified pipeline is hidden from a document task (it requires the explicit classifier).
78
- expect(pipelineAllowedForTaskType(pipeline({ purpose: undefined }), 'document')).toBe(false)
77
+ // A classifier this build cannot NAME is hidden too: this narrowing requires the explicit
78
+ // member, because a non-document pipeline on a document task authors no document at all.
79
+ expect(pipelineAllowedForTaskType(pipeline({ purpose: UNKNOWN_PURPOSE }), 'document')).toBe(
80
+ false,
81
+ )
79
82
  })
80
83
 
81
84
  it('a review task offers ONLY review-purpose pipelines', () => {
82
85
  expect(pipelineAllowedForTaskType(pipeline({ purpose: 'review' }), 'review')).toBe(true)
83
86
  expect(pipelineAllowedForTaskType(pipeline({ purpose: 'build' }), 'review')).toBe(false)
84
87
  expect(pipelineAllowedForTaskType(pipeline({ purpose: 'document' }), 'review')).toBe(false)
85
- // An unclassified pipeline is hidden from a review task (it requires the explicit classifier).
86
- expect(pipelineAllowedForTaskType(pipeline({ purpose: undefined }), 'review')).toBe(false)
88
+ // Same disposition as the document task's, for the same reason.
89
+ expect(pipelineAllowedForTaskType(pipeline({ purpose: UNKNOWN_PURPOSE }), 'review')).toBe(false)
87
90
  })
88
91
 
89
92
  it('a programmatic task (feature / bug) hides only what cannot ship code', () => {
@@ -99,26 +102,27 @@ describe('pipelineAllowedForTaskType', () => {
99
102
  }
100
103
  })
101
104
 
102
- it('keeps an UNCLASSIFIED pipeline on a feature / bug task', () => {
103
- // The one place this narrowing runs opposite to the document/review one, and it has to: a
104
- // `purpose` is optional at every write boundary (the builder leaves it unset by default, a
105
- // registered deployment pipeline need not declare one), so requiring it here would hide a
106
- // workspace's own hand-built pipelines from the picker they were built for silently, with
107
- // nothing on screen to explain the absence. Unclassified is not known-wrong for a feature the
108
- // way a document preset is.
105
+ it('keeps a pipeline whose classifier this build cannot name on a feature / bug task', () => {
106
+ // The one place this narrowing runs opposite to the document/review one, and it has to. The
107
+ // value is persisted, so a deployment's own classifier (or one retired since the row was
108
+ // written) reaches a bundle with no member for it — and hiding it would take that pipeline out
109
+ // of the picker people use most, silently, with nothing on screen to explain the absence. It is
110
+ // not known-wrong for a feature the way a document preset is.
109
111
  for (const type of ['feature', 'bug'] as const) {
110
- expect(pipelineAllowedForTaskType(pipeline({ purpose: undefined }), type)).toBe(true)
112
+ expect(pipelineAllowedForTaskType(pipeline({ purpose: UNKNOWN_PURPOSE }), type)).toBe(true)
111
113
  }
112
- // Still hidden from the types whose narrowing DOES demand the explicit classifier.
113
- expect(pipelineAllowedForTaskType(pipeline({ purpose: undefined }), 'document')).toBe(false)
114
- expect(pipelineAllowedForTaskType(pipeline({ purpose: undefined }), 'review')).toBe(false)
114
+ // Still hidden from the types whose narrowing DOES demand the explicit member.
115
+ expect(pipelineAllowedForTaskType(pipeline({ purpose: UNKNOWN_PURPOSE }), 'document')).toBe(
116
+ false,
117
+ )
118
+ expect(pipelineAllowedForTaskType(pipeline({ purpose: UNKNOWN_PURPOSE }), 'review')).toBe(false)
115
119
  })
116
120
 
117
121
  it('an un-narrowed task type stays unrestricted (spike, ralph, custom, undefined)', () => {
118
122
  // A custom (namespaced) deployment type has no purpose mapping we could infer, and `spike` /
119
123
  // `ralph` pin their own default pipeline instead of narrowing the picker.
120
124
  for (const type of ['spike', 'ralph', 'acme:incident', undefined] as const) {
121
- for (const purpose of ['build', 'document', 'review', 'research', undefined] as const) {
125
+ for (const purpose of ['build', 'document', 'review', 'research'] as const) {
122
126
  expect(pipelineAllowedForTaskType(pipeline({ purpose }), type)).toBe(true)
123
127
  }
124
128
  }
@@ -151,8 +155,8 @@ describe('pipelineAllowedForBlockLevel (initiative binding)', () => {
151
155
  })
152
156
 
153
157
  describe('purposeAllowsAgentCategory (builder save gate)', () => {
154
- it('a build (or unclassified) pipeline may use every category', () => {
155
- for (const purpose of ['build', null, undefined] as const) {
158
+ it('a build pipeline, and one whose classifier this build cannot name, may use every category', () => {
159
+ for (const purpose of ['build', UNKNOWN_PURPOSE] as const) {
156
160
  for (const cat of AGENT_CATEGORIES) {
157
161
  expect(purposeAllowsAgentCategory(purpose, cat)).toBe(true)
158
162
  }
@@ -173,8 +177,8 @@ describe('purposeAllowsAgentCategory (builder save gate)', () => {
173
177
  })
174
178
 
175
179
  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) {
180
+ it('offers the whole catalog to a build pipeline, and to one it cannot name', () => {
181
+ for (const purpose of ['build', UNKNOWN_PURPOSE] as const) {
178
182
  for (const cat of AGENT_CATEGORIES) {
179
183
  expect(purposeSuggestsAgentCategory(purpose, cat)).toBe(true)
180
184
  }
@@ -0,0 +1,101 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { PIPELINE_PURPOSES } from '@cat-factory/contracts'
3
+ import type { Pipeline } from '~/types/domain'
4
+ import { narrowPipelineLibrary } from '~/utils/pipelineLibrary'
5
+
6
+ type Row = Pick<Pipeline, 'purpose' | 'labels' | 'archived'> & { id: string }
7
+
8
+ const row = (id: string, purpose: Pipeline['purpose'], extra: Partial<Row> = {}): Row => ({
9
+ id,
10
+ purpose,
11
+ ...extra,
12
+ })
13
+
14
+ // A purpose this build has no member for: what a row saved by a newer build (or before a member
15
+ // was retired) looks like on the way back out of the store. Reachable even though the field is
16
+ // mandatory, which is the whole reason the predicates still narrow the value they are handed.
17
+ const UNKNOWN_PURPOSE = 'acme-migration' as Pipeline['purpose']
18
+
19
+ // Spread across all three dials, so neither count can come out right by coincidence: an archived
20
+ // row and a differently-labelled row are excluded by the OTHER dials either way.
21
+ const LIBRARY: Row[] = [
22
+ row('build-a', 'build'),
23
+ row('build-archived', 'build', { archived: true }),
24
+ row('review-a', 'review'),
25
+ row('review-tagged', 'review', { labels: ['nightly'] }),
26
+ row('review-archived-tagged', 'review', { archived: true, labels: ['nightly'] }),
27
+ row('document-tagged', 'document', { labels: ['nightly'] }),
28
+ ]
29
+
30
+ describe('narrowPipelineLibrary', () => {
31
+ it('lists every purpose when the library is browsed at none', () => {
32
+ // `null` is the library's own relaxation, not an unclassified draft: `Pipeline.purpose` is
33
+ // mandatory, so the reader asking to browse past the purpose is the only way to get here.
34
+ const { offered, hiddenByPurpose } = narrowPipelineLibrary(LIBRARY, {
35
+ purpose: null,
36
+ showArchived: true,
37
+ })
38
+ expect(offered.map((p) => p.id)).toEqual(LIBRARY.map((p) => p.id))
39
+ expect(hiddenByPurpose).toBe(0)
40
+ })
41
+
42
+ it('lists the pipelines built for the purpose being browsed at', () => {
43
+ const { offered, hiddenByPurpose } = narrowPipelineLibrary(LIBRARY, { purpose: 'review' })
44
+ expect(offered.map((p) => p.id)).toEqual(['review-a', 'review-tagged'])
45
+ // `build-archived` is hidden by the archive toggle either way, so the purpose does not claim it.
46
+ expect(hiddenByPurpose).toBe(2)
47
+ })
48
+
49
+ it('measures the purpose count against what the other dials already admit', () => {
50
+ // The rule `narrowAgentPalette` established: each hint promises "relax THIS dial alone and you
51
+ // get n more". Counting over the whole library would name `build-a` here, which the label
52
+ // filter hides at every purpose.
53
+ const { offered, hiddenByPurpose } = narrowPipelineLibrary(LIBRARY, {
54
+ purpose: 'review',
55
+ label: 'nightly',
56
+ })
57
+ expect(offered.map((p) => p.id)).toEqual(['review-tagged'])
58
+ expect(hiddenByPurpose).toBe(1)
59
+ })
60
+
61
+ it('counts the archived rows the OTHER dials admit, not the whole catalog', () => {
62
+ // The count the "Archived (n)" toggle renders. Against the raw catalog it would promise rows
63
+ // the toggle cannot reveal: two pipelines are archived, but only one of them is a `review` one.
64
+ expect(narrowPipelineLibrary(LIBRARY, { purpose: 'review' }).archivedInScope).toBe(1)
65
+ expect(narrowPipelineLibrary(LIBRARY, { purpose: 'planning' }).archivedInScope).toBe(0)
66
+ expect(narrowPipelineLibrary(LIBRARY, { purpose: null }).archivedInScope).toBe(2)
67
+ expect(
68
+ narrowPipelineLibrary(LIBRARY, { purpose: null, label: 'nightly' }).archivedInScope,
69
+ ).toBe(1)
70
+ })
71
+
72
+ it('keeps the archived count steady across the toggle it governs', () => {
73
+ // Unlike `hiddenByPurpose`, this one may NOT drop to zero once the dial is relaxed: it decides
74
+ // whether the toggle is offered at all, so a count that vanished when the toggle worked would
75
+ // strand the archived rows visible with no way to hide them again.
76
+ const shown = narrowPipelineLibrary(LIBRARY, { purpose: 'review', showArchived: true })
77
+ expect(shown.offered.map((p) => p.id)).toEqual([
78
+ 'review-a',
79
+ 'review-tagged',
80
+ 'review-archived-tagged',
81
+ ])
82
+ expect(shown.archivedInScope).toBe(1)
83
+ })
84
+
85
+ it('narrows by neither purpose this build cannot name', () => {
86
+ // Same disposition as the palette's: an unrecognised value is one this build has nothing to
87
+ // narrow BY and nothing to narrow it AGAINST, never a reason to guess a current member. A
88
+ // stored row carrying one stays reachable in the editor that has to fix it.
89
+ const stored = [...LIBRARY, row('acme', UNKNOWN_PURPOSE)]
90
+ for (const purpose of PIPELINE_PURPOSES) {
91
+ expect(narrowPipelineLibrary(stored, { purpose }).offered.some((p) => p.id === 'acme')).toBe(
92
+ true,
93
+ )
94
+ }
95
+ expect(narrowPipelineLibrary(stored, { purpose: UNKNOWN_PURPOSE })).toEqual({
96
+ offered: stored.filter((p) => !p.archived),
97
+ hiddenByPurpose: 0,
98
+ archivedInScope: 2,
99
+ })
100
+ })
101
+ })
@@ -0,0 +1,69 @@
1
+ import { pipelineMatchesPurpose, type PipelinePurpose } from '@cat-factory/contracts'
2
+ import type { Pipeline } from '~/types/domain'
3
+
4
+ /** What the pipeline builder's saved-pipeline library is being browsed at. */
5
+ export interface PipelineLibraryFilters {
6
+ /**
7
+ * The purpose to list, or `null` for "every purpose" — the library's own relaxation, NOT an
8
+ * unclassified draft. `Pipeline.purpose` is mandatory, so the draft always has one; `null` is
9
+ * the reader saying they want to browse past it.
10
+ */
11
+ purpose?: PipelinePurpose | null
12
+ /** The picked organizational label, or `null` for all of them. */
13
+ label?: string | null
14
+ /** Whether archived pipelines are included. */
15
+ showArchived?: boolean
16
+ }
17
+
18
+ /**
19
+ * The saved-pipeline library narrowed to what it lists, plus what each hidden-by-default dial is
20
+ * holding back.
21
+ *
22
+ * Both counts are measured against what the OTHER dials already admit, which is the promise
23
+ * `narrowAgentPalette` established: relax THIS dial alone and you get n more. Counting either over
24
+ * the whole catalog would name rows another filter is hiding either way, sending the reader to a
25
+ * control that cannot produce them.
26
+ *
27
+ * The label chips are the one dial with no count, because they are the one dial that already shows
28
+ * its own selection and its own alternatives.
29
+ */
30
+ export interface NarrowedPipelineLibrary<T> {
31
+ /** The pipelines every dial admits: what the library renders, in input order. */
32
+ offered: T[]
33
+ /** How many more the CURRENT label + archive selection would list at every purpose. */
34
+ hiddenByPurpose: number
35
+ /**
36
+ * How many ARCHIVED pipelines the current purpose + label selection covers.
37
+ *
38
+ * Deliberately independent of `showArchived`, unlike {@link hiddenByPurpose}: this is what the
39
+ * archive toggle governs, and it has to be the same number in both of the toggle's positions or
40
+ * the control that turned archived rows ON would vanish the moment it worked, stranding them
41
+ * visible with no way back. While they are hidden it is also exactly what relaxing that dial
42
+ * alone would add.
43
+ */
44
+ archivedInScope: number
45
+ }
46
+
47
+ /**
48
+ * Reduce `pipelines` to the rows the builder's library lists under `filters`.
49
+ *
50
+ * A pure reduction rather than three predicates inlined in the template for the reason
51
+ * {@link import('./agentPalette').narrowAgentPalette} is one: it decides what is VISIBLE and it
52
+ * owes an honest count of what it hid, and a count re-derived at the call site as a subtraction
53
+ * measures the wrong population. The purpose rule itself lives in `@cat-factory/contracts` beside
54
+ * the palette's and the pickers', so the three cannot read a stored `purpose` in different
55
+ * directions.
56
+ */
57
+ export function narrowPipelineLibrary<T extends Pick<Pipeline, 'purpose' | 'labels' | 'archived'>>(
58
+ pipelines: readonly T[],
59
+ filters: PipelineLibraryFilters,
60
+ ): NarrowedPipelineLibrary<T> {
61
+ const labelled = (p: T) => !filters.label || (p.labels ?? []).includes(filters.label)
62
+ const listed = (p: T) => Boolean(filters.showArchived) || !p.archived
63
+ const suits = (p: T) => pipelineMatchesPurpose(p, filters.purpose)
64
+ return {
65
+ offered: pipelines.filter((p) => labelled(p) && listed(p) && suits(p)),
66
+ hiddenByPurpose: pipelines.filter((p) => labelled(p) && listed(p) && !suits(p)).length,
67
+ archivedInScope: pipelines.filter((p) => labelled(p) && suits(p) && p.archived).length,
68
+ }
69
+ }
@@ -4213,7 +4213,6 @@
4213
4213
  "strategy": "Strategie",
4214
4214
  "rounds": "Runden",
4215
4215
  "purposeLabel": "Zweck",
4216
- "purposePlaceholder": "Zweck auswählen",
4217
4216
  "purposeUnrecognized": "Nicht erkannt ({purpose})",
4218
4217
  "purposeOption": {
4219
4218
  "build": "Entwicklung",
@@ -4232,6 +4231,10 @@
4232
4231
  "removeParticipant": "Teilnehmer entfernen (min. 2)",
4233
4232
  "addParticipant": "Teilnehmer hinzufügen",
4234
4233
  "savedPipelines": "Gespeicherte Pipelines",
4234
+ "purposeHiddenPipelines": "{count} gespeicherte Pipeline ist für diesen Zweck ausgeblendet. | {count} gespeicherte Pipelines sind für diesen Zweck ausgeblendet.",
4235
+ "everyPurposeListed": "Gespeicherte Pipelines aller Zwecke werden aufgelistet.",
4236
+ "listEveryPurpose": "Alle Zwecke anzeigen",
4237
+ "narrowToDraftPurpose": "Auf diesen Zweck eingrenzen",
4235
4238
  "hideArchived": "Archivierte ausblenden",
4236
4239
  "archivedCount": "Archiviert ({count})",
4237
4240
  "allLabels": "Alle",
@@ -4795,7 +4795,6 @@
4795
4795
  "strategy": "Strategy",
4796
4796
  "rounds": "Rounds",
4797
4797
  "purposeLabel": "Purpose",
4798
- "purposePlaceholder": "Select a purpose",
4799
4798
  "purposeUnrecognized": "Unrecognized ({purpose})",
4800
4799
  "@purposeUnrecognized": {
4801
4800
  "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."
@@ -4817,6 +4816,13 @@
4817
4816
  "removeParticipant": "Remove participant (min 2)",
4818
4817
  "addParticipant": "Add participant",
4819
4818
  "savedPipelines": "Saved pipelines",
4819
+ "purposeHiddenPipelines": "{count} saved pipeline hidden for this purpose. | {count} saved pipelines hidden for this purpose.",
4820
+ "everyPurposeListed": "Listing saved pipelines for every purpose.",
4821
+ "listEveryPurpose": "Show every purpose",
4822
+ "narrowToDraftPurpose": "Narrow to this purpose",
4823
+ "@purposeHiddenPipelines": {
4824
+ "description": "Shown above the saved-pipeline list in the pipeline builder: how many saved pipelines are not listed because they were built for a different purpose than the one being edited."
4825
+ },
4820
4826
  "hideArchived": "Hide archived",
4821
4827
  "archivedCount": "Archived ({count})",
4822
4828
  "allLabels": "All",
@@ -4652,7 +4652,6 @@
4652
4652
  "strategy": "Estrategia",
4653
4653
  "rounds": "Rondas",
4654
4654
  "purposeLabel": "Propósito",
4655
- "purposePlaceholder": "Seleccionar un propósito",
4656
4655
  "purposeUnrecognized": "No reconocido ({purpose})",
4657
4656
  "purposeOption": {
4658
4657
  "build": "Desarrollo",
@@ -4671,6 +4670,10 @@
4671
4670
  "removeParticipant": "Quitar participante (mín. 2)",
4672
4671
  "addParticipant": "Añadir participante",
4673
4672
  "savedPipelines": "Pipelines guardados",
4673
+ "purposeHiddenPipelines": "{count} pipeline guardado oculto para este propósito. | {count} pipelines guardados ocultos para este propósito.",
4674
+ "everyPurposeListed": "Se muestran los pipelines guardados de todos los propósitos.",
4675
+ "listEveryPurpose": "Mostrar todos los propósitos",
4676
+ "narrowToDraftPurpose": "Limitar a este propósito",
4674
4677
  "hideArchived": "Ocultar archivados",
4675
4678
  "archivedCount": "Archivados ({count})",
4676
4679
  "allLabels": "Todas",
@@ -4652,7 +4652,6 @@
4652
4652
  "strategy": "Stratégie",
4653
4653
  "rounds": "Tours",
4654
4654
  "purposeLabel": "Objectif",
4655
- "purposePlaceholder": "Sélectionner un objectif",
4656
4655
  "purposeUnrecognized": "Non reconnu ({purpose})",
4657
4656
  "purposeOption": {
4658
4657
  "build": "Développement",
@@ -4671,6 +4670,10 @@
4671
4670
  "removeParticipant": "Retirer le participant (min. 2)",
4672
4671
  "addParticipant": "Ajouter un participant",
4673
4672
  "savedPipelines": "Pipelines enregistrés",
4673
+ "purposeHiddenPipelines": "{count} pipeline enregistré masqué pour cet objectif. | {count} pipelines enregistrés masqués pour cet objectif.",
4674
+ "everyPurposeListed": "Les pipelines enregistrés de tous les objectifs sont affichés.",
4675
+ "listEveryPurpose": "Afficher tous les objectifs",
4676
+ "narrowToDraftPurpose": "Limiter à cet objectif",
4674
4677
  "hideArchived": "Masquer les archivés",
4675
4678
  "archivedCount": "Archivés ({count})",
4676
4679
  "allLabels": "Toutes",
@@ -4652,7 +4652,6 @@
4652
4652
  "strategy": "אסטרטגיה",
4653
4653
  "rounds": "סבבים",
4654
4654
  "purposeLabel": "מטרה",
4655
- "purposePlaceholder": "בחר מטרה",
4656
4655
  "purposeUnrecognized": "לא מזוהה ({purpose})",
4657
4656
  "purposeOption": {
4658
4657
  "build": "פיתוח",
@@ -4671,6 +4670,10 @@
4671
4670
  "removeParticipant": "הסר משתתף (מינימום 2)",
4672
4671
  "addParticipant": "הוסף משתתף",
4673
4672
  "savedPipelines": "צינורות שמורים",
4673
+ "purposeHiddenPipelines": "צינור שמור אחד מוסתר עבור מטרה זו. | שני צינורות שמורים מוסתרים עבור מטרה זו. | {count} צינורות שמורים מוסתרים עבור מטרה זו.",
4674
+ "everyPurposeListed": "מוצגים צינורות שמורים מכל המטרות.",
4675
+ "listEveryPurpose": "הצג את כל המטרות",
4676
+ "narrowToDraftPurpose": "צמצם למטרה זו",
4674
4677
  "hideArchived": "הסתר מאורכבים",
4675
4678
  "archivedCount": "מאורכבים ({count})",
4676
4679
  "allLabels": "הכל",
@@ -4213,7 +4213,6 @@
4213
4213
  "strategy": "Strategia",
4214
4214
  "rounds": "Giri",
4215
4215
  "purposeLabel": "Scopo",
4216
- "purposePlaceholder": "Seleziona uno scopo",
4217
4216
  "purposeUnrecognized": "Non riconosciuto ({purpose})",
4218
4217
  "purposeOption": {
4219
4218
  "build": "Sviluppo",
@@ -4232,6 +4231,10 @@
4232
4231
  "removeParticipant": "Rimuovi partecipante (min 2)",
4233
4232
  "addParticipant": "Aggiungi partecipante",
4234
4233
  "savedPipelines": "Pipeline salvate",
4234
+ "purposeHiddenPipelines": "{count} pipeline salvata nascosta per questo scopo. | {count} pipeline salvate nascoste per questo scopo.",
4235
+ "everyPurposeListed": "Sono elencate le pipeline salvate di ogni scopo.",
4236
+ "listEveryPurpose": "Mostra tutti gli scopi",
4237
+ "narrowToDraftPurpose": "Limita a questo scopo",
4235
4238
  "hideArchived": "Nascondi archiviate",
4236
4239
  "archivedCount": "Archiviate ({count})",
4237
4240
  "allLabels": "Tutte",
@@ -4652,7 +4652,6 @@
4652
4652
  "strategy": "戦略",
4653
4653
  "rounds": "ラウンド",
4654
4654
  "purposeLabel": "目的",
4655
- "purposePlaceholder": "目的を選択",
4656
4655
  "purposeUnrecognized": "認識できません ({purpose})",
4657
4656
  "purposeOption": {
4658
4657
  "build": "開発",
@@ -4671,6 +4670,10 @@
4671
4670
  "removeParticipant": "参加者を削除(最低2)",
4672
4671
  "addParticipant": "参加者を追加",
4673
4672
  "savedPipelines": "保存済みパイプライン",
4673
+ "purposeHiddenPipelines": "この目的では {count} 件の保存済みパイプラインが非表示です。",
4674
+ "everyPurposeListed": "すべての目的の保存済みパイプラインを表示しています。",
4675
+ "listEveryPurpose": "すべての目的を表示",
4676
+ "narrowToDraftPurpose": "この目的に絞り込む",
4674
4677
  "hideArchived": "アーカイブを非表示",
4675
4678
  "archivedCount": "アーカイブ済み({count})",
4676
4679
  "allLabels": "すべて",
@@ -4652,7 +4652,6 @@
4652
4652
  "strategy": "Strategia",
4653
4653
  "rounds": "Rundy",
4654
4654
  "purposeLabel": "Cel",
4655
- "purposePlaceholder": "Wybierz cel",
4656
4655
  "purposeUnrecognized": "Nierozpoznany ({purpose})",
4657
4656
  "purposeOption": {
4658
4657
  "build": "Programowanie",
@@ -4671,6 +4670,10 @@
4671
4670
  "removeParticipant": "Usuń uczestnika (min. 2)",
4672
4671
  "addParticipant": "Dodaj uczestnika",
4673
4672
  "savedPipelines": "Zapisane pipeline'y",
4673
+ "purposeHiddenPipelines": "{count} zapisany pipeline ukryty dla tego celu. | {count} zapisane pipeline'y ukryte dla tego celu. | {count} zapisanych pipeline'ów ukrytych dla tego celu.",
4674
+ "everyPurposeListed": "Wyświetlane są zapisane pipeline'y wszystkich celów.",
4675
+ "listEveryPurpose": "Pokaż wszystkie cele",
4676
+ "narrowToDraftPurpose": "Zawęź do tego celu",
4674
4677
  "hideArchived": "Ukryj zarchiwizowane",
4675
4678
  "archivedCount": "Zarchiwizowane ({count})",
4676
4679
  "allLabels": "Wszystkie",
@@ -4652,7 +4652,6 @@
4652
4652
  "strategy": "Strateji",
4653
4653
  "rounds": "Tur",
4654
4654
  "purposeLabel": "Amaç",
4655
- "purposePlaceholder": "Bir amaç seçin",
4656
4655
  "purposeUnrecognized": "Tanınmayan ({purpose})",
4657
4656
  "purposeOption": {
4658
4657
  "build": "Geliştirme",
@@ -4671,6 +4670,10 @@
4671
4670
  "removeParticipant": "Katılımcıyı kaldır (en az 2)",
4672
4671
  "addParticipant": "Katılımcı ekle",
4673
4672
  "savedPipelines": "Kayıtlı pipeline'lar",
4673
+ "purposeHiddenPipelines": "Bu amaç için {count} kayıtlı pipeline gizli.",
4674
+ "everyPurposeListed": "Tüm amaçlara ait kayıtlı pipeline'lar listeleniyor.",
4675
+ "listEveryPurpose": "Tüm amaçları göster",
4676
+ "narrowToDraftPurpose": "Bu amaca daralt",
4674
4677
  "hideArchived": "Arşivlenenleri gizle",
4675
4678
  "archivedCount": "Arşivlenen ({count})",
4676
4679
  "allLabels": "Tümü",
@@ -4652,7 +4652,6 @@
4652
4652
  "strategy": "Стратегія",
4653
4653
  "rounds": "Раунди",
4654
4654
  "purposeLabel": "Призначення",
4655
- "purposePlaceholder": "Виберіть призначення",
4656
4655
  "purposeUnrecognized": "Нерозпізнано ({purpose})",
4657
4656
  "purposeOption": {
4658
4657
  "build": "Розробка",
@@ -4671,6 +4670,10 @@
4671
4670
  "removeParticipant": "Прибрати учасника (мін. 2)",
4672
4671
  "addParticipant": "Додати учасника",
4673
4672
  "savedPipelines": "Збережені пайплайни",
4673
+ "purposeHiddenPipelines": "{count} збережений пайплайн приховано для цього призначення. | {count} збережені пайплайни приховано для цього призначення. | {count} збережених пайплайнів приховано для цього призначення.",
4674
+ "everyPurposeListed": "Показано збережені пайплайни всіх призначень.",
4675
+ "listEveryPurpose": "Показати всі призначення",
4676
+ "narrowToDraftPurpose": "Звузити до цього призначення",
4674
4677
  "hideArchived": "Сховати архівовані",
4675
4678
  "archivedCount": "Архівовані ({count})",
4676
4679
  "allLabels": "Усі",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.254.2",
3
+ "version": "0.255.0",
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.279.0"
43
+ "@cat-factory/contracts": "0.281.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@toad-contracts/testing": "0.3.2",