@cat-factory/app 0.255.1 → 0.256.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
@@ -286,9 +286,13 @@ browser older than a new member, a row older than a retired one).
286
286
  Purpose is filtered by three predicates in `@cat-factory/contracts`, and the difference between
287
287
  the first two is the point:
288
288
 
289
- - `purposeSuggestsAgentCategory` is **relevance**: what the palette OFFERS. Opinionated (a
289
+ - `purposeSuggestsAgentKind` is **relevance**: what the palette OFFERS. Opinionated (a
290
290
  review pipeline designs nothing; a planning pipeline has no pull request to gate), because a
291
- wrong guess costs one purpose switch.
291
+ wrong guess costs one purpose switch. It reads the kind's `category` through
292
+ `purposeSuggestsAgentCategory` and then the kind's OWN `presentation.purposes`, and the two
293
+ INTERSECT: a declaration may only hide more, never buy a kind back into a purpose its section
294
+ is not offered to, which is what keeps relevance inside compatibility whatever a deployment
295
+ declares.
292
296
  - `purposeAllowsAgentCategory` is **compatibility**: what the builder will SAVE. It states only
293
297
  what is contradictory (a pipeline that writes no code carrying an implementation step) and
294
298
  drives the draft's conflict warning.
@@ -311,6 +315,19 @@ it that way: the palette may hide what the save gate tolerates, so tightening th
311
315
  never turns a stored pipeline into one its own editor refuses, but offering a kind the save gate
312
316
  then rejects would be a dead end with the refusal arriving after the work.
313
317
 
318
+ **A category is a shelf label, not a statement of what a kind does**, which is why relevance is
319
+ asked of the KIND. Keeping `docs` for a `review` pipeline so the Domain Rules Reviewer survives
320
+ also handed it the two kinds that WRITE documentation into the repo, and `document` and `research`
321
+ had identical rows, so moving the dial between them narrowed nothing at all. A kind that belongs
322
+ to one use-case says so in `presentation.purposes` and leaves a section its siblings stay in; the
323
+ section keeps deciding for every kind that declares nothing, which is the normal case and the one
324
+ a deployment-registered kind falls into for free. Declare it only to opt OUT: it can never widen,
325
+ and a list naming only purposes this build cannot name is read as no declaration at all rather
326
+ than as excluding everything, the same default-open reading the unknown `purpose` gets. An EMPTY
327
+ list is refused at registration instead (`agentPresentationSchema`, and `catalog.spec.ts` for the
328
+ static half valibot never parses): the reader cannot tell one from declaring nothing, so it would
329
+ offer the kind everywhere its section is offered, which is the inverse of what writing it means.
330
+
314
331
  **Each hint counts what relaxing THAT dial alone would reveal**, which is why each reduction is one
315
332
  function (`utils/agentPalette.ts` for the catalog, `utils/pipelineLibrary.ts` for the library)
316
333
  rather than chained filters at the call site. Chaining them
@@ -27,6 +27,9 @@ export function customKindToArchetype(kind: CustomAgentKind): AgentArchetype {
27
27
  color: p.color,
28
28
  description: p.description,
29
29
  ...(p.category ? { category: p.category } : {}),
30
+ // Carried verbatim, INCLUDING a list this build cannot fully name: `purposeSuggestsAgentKind`
31
+ // owns the reading of an unrecognised member, so filtering here would fork that rule.
32
+ ...(p.purposes?.length ? { purposes: p.purposes } : {}),
30
33
  // A kind that declares no tier is left WITHOUT one rather than stamped with the default
31
34
  // here, so the single fallback stays in `agentTierVisibleAt` — filling it in at the
32
35
  // projection would fork the rule the moment the default changes.
@@ -70,6 +70,55 @@ describe('buildWorkspaceCapabilitiesManifest', () => {
70
70
  expect(workspaceCapabilitiesVersion([], [])).not.toBe(base)
71
71
  })
72
72
 
73
+ it('changes the version for EVERY declared field, including the ones nothing renders', () => {
74
+ // The signature covers the whole entry rather than a list of fields somebody kept in step,
75
+ // because an omitted one is not a cosmetic miss: `hydrateCapabilities` no-ops on an unchanged
76
+ // version, so an open tab keeps filtering its palette on the declaration the backend just
77
+ // replaced. Asserted field by field over the ones that steer the builder rather than the
78
+ // label, which is the class the old field list kept missing.
79
+ const base = workspaceCapabilitiesVersion([kind()], [])
80
+ expect(workspaceCapabilitiesVersion([kind({ purposes: ['review'] })], [])).not.toBe(base)
81
+ expect(workspaceCapabilitiesVersion([kind({ category: 'docs' })], [])).not.toBe(base)
82
+ expect(workspaceCapabilitiesVersion([kind({ tier: 'basic' })], [])).not.toBe(base)
83
+ expect(workspaceCapabilitiesVersion([{ ...kind(), container: false }], [])).not.toBe(base)
84
+ expect(workspaceCapabilitiesVersion([{ ...kind(), binaryOutput: true }], [])).not.toBe(base)
85
+ expect(
86
+ workspaceCapabilitiesVersion([{ ...kind(), companionTargets: ['coder' as AgentKind] }], []),
87
+ ).not.toBe(base)
88
+ // And a purposes list is ORDER-bearing content, not a set: two spellings of the same
89
+ // declaration are two declarations, so re-signing is the honest answer over guessing.
90
+ expect(workspaceCapabilitiesVersion([kind({ purposes: ['review', 'build'] })], [])).not.toBe(
91
+ workspaceCapabilitiesVersion([kind({ purposes: ['build', 'review'] })], []),
92
+ )
93
+ })
94
+
95
+ it('ignores the KEY ORDER a snapshot happened to serialize with', () => {
96
+ // The whole point of canonicalizing rather than hashing the raw JSON: a re-serialization
97
+ // that reorders keys is the same catalog, and re-swapping the manifest for it would
98
+ // invalidate every `agentKindMeta` consumer for nothing.
99
+ const ordered: CustomAgentKind = {
100
+ kind: 'acme-audit' as AgentKind,
101
+ container: true,
102
+ presentation: { label: 'Audit', icon: 'i-lucide-shield', color: '#fff', description: 'd' },
103
+ }
104
+ const reordered: CustomAgentKind = {
105
+ presentation: { description: 'd', color: '#fff', icon: 'i-lucide-shield', label: 'Audit' },
106
+ container: true,
107
+ kind: 'acme-audit' as AgentKind,
108
+ }
109
+ expect(workspaceCapabilitiesVersion([reordered], [])).toBe(
110
+ workspaceCapabilitiesVersion([ordered], []),
111
+ )
112
+ })
113
+
114
+ it('reads an explicitly-undefined field as an absent one', () => {
115
+ // A projection that spreads a conditional field (`...(x ? { x } : {})`) and one that assigns
116
+ // `x: undefined` describe the same catalog, so they must not hash differently.
117
+ expect(workspaceCapabilitiesVersion([{ ...kind(), binaryOutput: undefined }], [])).toBe(
118
+ workspaceCapabilitiesVersion([kind()], []),
119
+ )
120
+ })
121
+
73
122
  it('changes the version when a task-type field, its fields, or the set differs', () => {
74
123
  const base = workspaceCapabilitiesVersion([], [taskType()])
75
124
  expect(workspaceCapabilitiesVersion([], [taskType({ label: 'Renamed' })])).not.toBe(base)
@@ -19,43 +19,46 @@ import type { AppSlots } from './slots'
19
19
  /** The stable id for the per-workspace capability manifest built from the snapshot. */
20
20
  export const WORKSPACE_CAPABILITIES_MANIFEST_ID = 'cat-factory:workspace-capabilities'
21
21
 
22
+ /**
23
+ * Every key of `value`, recursively, in sorted order: the canonical form
24
+ * {@link workspaceCapabilitiesVersion} signs. Arrays keep their order (a capability list's order is
25
+ * content: it is the order the palette and the picker render in); objects lose theirs, so a
26
+ * re-serialization with reordered keys can't spuriously differ.
27
+ */
28
+ function canonicalize(value: unknown): unknown {
29
+ if (Array.isArray(value)) return value.map(canonicalize)
30
+ if (value === null || typeof value !== 'object') return value
31
+ return Object.fromEntries(
32
+ Object.entries(value)
33
+ .filter(([, entry]) => entry !== undefined)
34
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
35
+ .map(([key, entry]) => [key, canonicalize(entry)]),
36
+ )
37
+ }
38
+
22
39
  /**
23
40
  * A deterministic, key-order-independent content signature covering BOTH capability lists, used as
24
41
  * the manifest `version`. The workspace snapshot re-delivers the SAME deployment capabilities on
25
42
  * every board-event refresh (a full `workspace.refresh()` re-hydrates each time), so a
26
- * content-derived version lets each store skip re-projecting an UNCHANGED catalog otherwise every
43
+ * content-derived version lets each store skip re-projecting an UNCHANGED catalog: otherwise every
27
44
  * refresh would replace its read-model and needlessly invalidate every `agentKindMeta` /
28
45
  * `taskTypeMeta` consumer. It still changes (and swaps wholesale) when a different workspace's
29
- * capabilities genuinely differ. Serialized as fixed-order tuples of only the fields that affect
30
- * display/pairing, so a re-serialization with reordered object keys can't spuriously differ.
46
+ * capabilities genuinely differ.
47
+ *
48
+ * Signs the WHOLE entry, canonicalized, rather than a hand-listed tuple of the fields that seemed
49
+ * to matter. The two mistakes are not symmetric: folding in a field nothing renders costs one
50
+ * re-projection nobody sees, while OMITTING one costs correctness, because `hydrateCapabilities`
51
+ * short-circuits on an unchanged version and the store then keeps serving the PREVIOUS declaration
52
+ * until something else swaps the manifest. A field list drifts silently into that: it was missing
53
+ * `tier` and `binaryOutput` before `purposes` was added to it, so a backend that re-declared any of
54
+ * the three reached an open tab as the catalog it had replaced. There is nothing volatile in either
55
+ * shape (no timestamps, no ids minted per request), so signing all of it costs nothing.
31
56
  */
32
57
  export function workspaceCapabilitiesVersion(
33
58
  kinds: readonly CustomAgentKind[],
34
59
  taskTypes: readonly CustomTaskType[],
35
60
  ): string {
36
- return JSON.stringify([
37
- kinds.map((k) => [
38
- k.kind,
39
- k.container,
40
- k.presentation.label,
41
- k.presentation.icon,
42
- k.presentation.color,
43
- k.presentation.description,
44
- k.presentation.category ?? null,
45
- k.presentation.resultView ?? null,
46
- ]),
47
- taskTypes.map((t) => [
48
- t.taskType,
49
- t.presentation.label,
50
- t.presentation.icon,
51
- t.presentation.color,
52
- t.presentation.description,
53
- t.defaultPipelineId ?? null,
54
- t.formPanel ?? null,
55
- // The descriptor list affects the create-form, so fold its shape into the signature too.
56
- (t.fields ?? []).map((f) => [f.key, f.type, f.label, f.required ?? false]),
57
- ]),
58
- ])
61
+ return JSON.stringify([canonicalize(kinds), canonicalize(taskTypes)])
59
62
  }
60
63
 
61
64
  /**
@@ -117,7 +117,7 @@ export type {
117
117
  PreviewStatus,
118
118
  } from '@cat-factory/contracts'
119
119
 
120
- import type { AgentCategory, AgentKind, AgentTier } from '@cat-factory/contracts'
120
+ import type { AgentCategory, AgentKind, AgentTier, PipelinePurpose } from '@cat-factory/contracts'
121
121
 
122
122
  // The document-kind list + the per-kind field descriptors are runtime values (used to render
123
123
  // the picker and the conditional per-kind inputs), so they are re-exported as values — the
@@ -135,6 +135,12 @@ export interface AgentArchetype {
135
135
  description: string
136
136
  /** Palette category this archetype is grouped under. Absent ⇒ ungrouped/system kind. */
137
137
  category?: AgentCategory
138
+ /**
139
+ * The pipeline PURPOSES the palette offers this kind to, WITHIN the ones its {@link category}
140
+ * already admits (`purposeSuggestsAgentKind`). Absent ⇒ the category alone decides, which is
141
+ * the normal case: declare this only to opt OUT of a purpose the category would admit.
142
+ */
143
+ purposes?: readonly PipelinePurpose[]
138
144
  /**
139
145
  * How specialist this kind is — the tier the palette / model-preset override list filter on
140
146
  * (`basic` shows only basic kinds, `intermediate` adds those, `advanced` shows everything).
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest'
2
+ import type { PipelinePurpose } from '@cat-factory/contracts'
2
3
  import type { AgentArchetype } from '~/types/domain'
3
4
  import { groupAgentPalette, narrowAgentPalette } from '~/utils/agentPalette'
4
5
 
@@ -6,6 +7,7 @@ const archetype = (
6
7
  kind: string,
7
8
  category?: AgentArchetype['category'],
8
9
  tier?: AgentArchetype['tier'],
10
+ purposes?: AgentArchetype['purposes'],
9
11
  ): AgentArchetype => ({
10
12
  kind: kind as AgentArchetype['kind'],
11
13
  label: kind,
@@ -14,6 +16,7 @@ const archetype = (
14
16
  description: kind,
15
17
  ...(category ? { category } : {}),
16
18
  ...(tier ? { tier } : {}),
19
+ ...(purposes ? { purposes } : {}),
17
20
  })
18
21
 
19
22
  // Spread across both dials on purpose: every combination of relevant/irrelevant to a `planning`
@@ -100,6 +103,33 @@ describe('narrowAgentPalette', () => {
100
103
  expect(hiddenByBoth).toBe(1)
101
104
  })
102
105
 
106
+ it('narrows WITHIN a category when a kind declares the purposes it is for', () => {
107
+ // The reason relevance is asked of the KIND: a category is a shelf label, so `documenter` and
108
+ // `doc-reviewer` sit on the same shelf while only one of them belongs in a pipeline that
109
+ // reviews someone else's pull request. Both dials still apply to the declaring kind.
110
+ const catalog = [
111
+ archetype('documenter', 'docs', 'basic', ['build', 'document']),
112
+ archetype('doc-reviewer', 'docs', 'basic', ['build', 'review']),
113
+ archetype('house-style', 'docs', 'basic'),
114
+ ]
115
+ const offered = (purpose: PipelinePurpose) =>
116
+ narrowAgentPalette(catalog, purpose, 'advanced').offered.map((a) => a.kind)
117
+ expect(offered('review')).toEqual(['doc-reviewer', 'house-style'])
118
+ expect(offered('document')).toEqual(['documenter', 'house-style'])
119
+ // `planning` drops the whole `docs` category, and the declarations do not override that for
120
+ // the kinds that named it: a declared list is the kind's own answer, not an exemption.
121
+ expect(offered('planning')).toEqual([])
122
+ expect(narrowAgentPalette(catalog, 'review', 'advanced').hiddenByPurpose).toBe(1)
123
+ })
124
+
125
+ it('falls back to the category when a declared list names nothing this build knows', () => {
126
+ // The mirror of the unknown-purpose rule: a kind whose entire list was retired has told this
127
+ // build nothing, so its section decides rather than the kind vanishing from every palette.
128
+ const stale = archetype('acme-doc', 'docs', 'basic', ['acme-migration' as never])
129
+ expect(narrowAgentPalette([stale], 'document', 'advanced').offered).toEqual([stale])
130
+ expect(narrowAgentPalette([stale], 'planning', 'advanced').offered).toEqual([])
131
+ })
132
+
103
133
  it('keeps a purpose this build does not recognise from narrowing anything', () => {
104
134
  // A stored `purpose` from a build that shipped a member this one has not (or has retired):
105
135
  // unknown is not a licence to guess, so the palette offers what the tier admits and says so.
@@ -2,7 +2,7 @@ import {
2
2
  type AgentTier,
3
3
  agentTierVisibleAt,
4
4
  type PipelinePurpose,
5
- purposeSuggestsAgentCategory,
5
+ purposeSuggestsAgentKind,
6
6
  } from '@cat-factory/contracts'
7
7
  import type { AgentArchetype } from '~/types/domain'
8
8
 
@@ -36,16 +36,17 @@ export interface NarrowedAgentPalette<T> {
36
36
  * Reduce `archetypes` to what the palette offers at `purpose` + `tier`, with each dial's hint
37
37
  * count (see {@link NarrowedAgentPalette} for what the counts promise).
38
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.
39
+ * Relevance is asked of the KIND (`purposeSuggestsAgentKind`), which reads the kind's own
40
+ * `purposes` when it declares one and falls back to its `category`'s row otherwise, so a kind
41
+ * that belongs to one use-case can leave a section its siblings still belong in. An archetype
42
+ * carrying neither has nothing for the purpose dial to judge and is always relevant; an absent
43
+ * `tier` is `DEFAULT_AGENT_TIER`. All three are how a deployment-registered kind that declares
44
+ * nothing behaves, and none of them is a reason to drop it from the catalog.
42
45
  */
43
- export function narrowAgentPalette<T extends Pick<AgentArchetype, 'tier' | 'category'>>(
44
- archetypes: readonly T[],
45
- purpose: PipelinePurpose,
46
- tier: AgentTier,
47
- ): NarrowedAgentPalette<T> {
48
- const relevant = (a: T) => !a.category || purposeSuggestsAgentCategory(purpose, a.category)
46
+ export function narrowAgentPalette<
47
+ T extends Pick<AgentArchetype, 'tier' | 'category' | 'purposes'>,
48
+ >(archetypes: readonly T[], purpose: PipelinePurpose, tier: AgentTier): NarrowedAgentPalette<T> {
49
+ const relevant = (a: T) => purposeSuggestsAgentKind(purpose, a)
49
50
  const inTier = (a: T) => agentTierVisibleAt(a.tier, tier)
50
51
  return {
51
52
  offered: archetypes.filter((a) => relevant(a) && inTier(a)),
@@ -1,5 +1,7 @@
1
1
  import { describe, it, expect } from 'vitest'
2
+ import { PIPELINE_PURPOSES, purposeAllowsAgentCategory } from '@cat-factory/contracts'
2
3
  import type { AgentKind, BlockStatus, BlockType } from '~/types/domain'
4
+ import { narrowAgentPalette } from '~/utils/agentPalette'
3
5
  import {
4
6
  AGENT_ARCHETYPES,
5
7
  AGENT_BY_KIND,
@@ -85,6 +87,39 @@ describe('catalog', () => {
85
87
  expect(basic).toEqual(expect.arrayContaining(['architect', 'coder', 'tester-api']))
86
88
  })
87
89
 
90
+ it('leaves every purpose a palette to build from, and every declaration saveable', () => {
91
+ // Two properties over the whole grid rather than a pinned count, which every ordinary
92
+ // addition would break without naming anything.
93
+ //
94
+ // A `purposes` declaration only ever HIDES, so the way to get it wrong is to hide too much:
95
+ // a purpose whose palette reduces to nothing is a dial setting with no way forward, and the
96
+ // widest tier is where that has to be checked because the tier hint is the way out of a thin
97
+ // one. And relevance stays a subset of compatibility per KIND, so a declaration can never
98
+ // offer a step the save gate would then refuse.
99
+ for (const purpose of PIPELINE_PURPOSES) {
100
+ const offered = narrowAgentPalette(AGENT_ARCHETYPES, purpose, 'advanced').offered
101
+ expect(offered.length, `${purpose} offers no agent at all`).toBeGreaterThan(0)
102
+ for (const a of offered) {
103
+ expect(
104
+ !a.category || purposeAllowsAgentCategory(purpose, a.category),
105
+ `${a.kind} is offered to a ${purpose} pipeline its step could not be saved in`,
106
+ ).toBe(true)
107
+ }
108
+ }
109
+ })
110
+
111
+ it('never declares an EMPTY `purposes` (which reads as no declaration, not as "nowhere")', () => {
112
+ // `agentPresentationSchema` refuses an empty list at registration, but this catalog is
113
+ // authored in TypeScript and parsed by nothing, so the same guard has to be asserted for the
114
+ // half valibot never sees. Left empty, a kind someone meant to offer NOWHERE is offered
115
+ // everywhere its section is: `purposeSuggestsAgentKind` cannot tell an authored `[]` from a
116
+ // kind that declared nothing at all.
117
+ for (const a of [...AGENT_ARCHETYPES, ...Object.values(SYSTEM_AGENT_META)]) {
118
+ if (!a.purposes) continue
119
+ expect(a.purposes.length, `${a.kind} declares an empty purposes list`).toBeGreaterThan(0)
120
+ }
121
+ })
122
+
88
123
  it('never shadows a companion producer as a system kind', () => {
89
124
  // A companion is never placed directly: the builder renders it as a toggle on its producer
90
125
  // step, so a producer that cannot be placed takes its companion out of the builder with it.
@@ -35,6 +35,10 @@ export const AGENT_ARCHETYPES: AgentArchetype[] = [
35
35
  icon: 'i-lucide-clipboard-check',
36
36
  color: '#f59e0b',
37
37
  category: 'review',
38
+ // Settles the PRODUCT layer before anyone builds, which is every use-case except reviewing
39
+ // someone else's open pull request: there the requirements are already someone's shipped
40
+ // decision and nothing here can change them.
41
+ purposes: ['build', 'document', 'research', 'planning'],
38
42
  description:
39
43
  'Reviews the collected context (description + linked PRDs/RFCs) for gaps, ambiguities, assumptions and risks before the architect starts.',
40
44
  // Opens the dedicated structured review window (answer/dismiss findings → incorporate
@@ -48,6 +52,8 @@ export const AGENT_ARCHETYPES: AgentArchetype[] = [
48
52
  icon: 'i-lucide-bug',
49
53
  color: '#f59e0b',
50
54
  category: 'review',
55
+ // Triages a BUG REPORT for fixability, so it only makes sense where something gets fixed.
56
+ purposes: ['build'],
51
57
  description:
52
58
  'Triages a bug report for fixability — raising questions, gaps and assumptions about the report before anyone starts fixing it.',
53
59
  // Opens the dedicated structured review window (answer/dismiss findings → incorporate
@@ -65,6 +71,9 @@ export const AGENT_ARCHETYPES: AgentArchetype[] = [
65
71
  icon: 'i-lucide-search-code',
66
72
  color: '#38bdf8',
67
73
  category: 'review',
74
+ // Traces a bug to its root cause in the code: a fixing pipeline's opening move, and nothing
75
+ // a document, review, spike or plan has any use for.
76
+ purposes: ['build'],
68
77
  description:
69
78
  'Read-only, multi-repo codebase investigation that traces the bug to its root cause and decides whether the report is fixable as-is or needs the reporter to clarify (no code changes).',
70
79
  resultView: 'generic-structured',
@@ -81,6 +90,9 @@ export const AGENT_ARCHETYPES: AgentArchetype[] = [
81
90
  icon: 'i-lucide-clipboard-check',
82
91
  color: '#6366f1',
83
92
  category: 'review',
93
+ // Reviews an EXISTING open pull request, which is the whole of the review use-case, and is
94
+ // available to a build pipeline that wants a deep pass over the pull request it just opened.
95
+ purposes: ['build', 'review'],
84
96
  description:
85
97
  'Deep, token-bounded review of an open pull request: slices a large diff into cohesive ' +
86
98
  'chunks, reviews each, and returns prioritized findings.',
@@ -148,6 +160,8 @@ export const AGENT_ARCHETYPES: AgentArchetype[] = [
148
160
  icon: 'i-lucide-clipboard-list',
149
161
  color: '#c084fc',
150
162
  category: 'design',
163
+ // Writes the in-repo spec the implementation is then built against.
164
+ purposes: ['build', 'planning'],
151
165
  description:
152
166
  "Aggregates every task's clarified requirements into the service's in-repo specification (spec.json) with full acceptance-scenario coverage, derived into Gherkin.",
153
167
  },
@@ -158,6 +172,9 @@ export const AGENT_ARCHETYPES: AgentArchetype[] = [
158
172
  icon: 'i-lucide-drafting-compass',
159
173
  color: '#a78bfa',
160
174
  category: 'design',
175
+ // Designs the shape of a CODE change, so it belongs wherever code is planned or written and
176
+ // nowhere a document is being authored.
177
+ purposes: ['build', 'research', 'planning'],
161
178
  description: 'Designs the shape of the solution and breaks down the work.',
162
179
  },
163
180
  {
@@ -170,6 +187,8 @@ export const AGENT_ARCHETYPES: AgentArchetype[] = [
170
187
  icon: 'i-lucide-map',
171
188
  color: '#22d3ee',
172
189
  category: 'design',
190
+ // Decomposes a repository into services and modules on the board.
191
+ purposes: ['build', 'planning'],
173
192
  description: 'Maps the repository into the service → modules blueprint.',
174
193
  },
175
194
  {
@@ -310,6 +329,9 @@ export const AGENT_ARCHETYPES: AgentArchetype[] = [
310
329
  icon: 'i-lucide-book-open-text',
311
330
  color: '#818cf8',
312
331
  category: 'docs',
332
+ // WRITES documentation into the repository, which a pipeline that reviews someone else's
333
+ // pull request never does.
334
+ purposes: ['build', 'document'],
313
335
  description: 'Produces docs and usage examples.',
314
336
  },
315
337
  {
@@ -319,6 +341,8 @@ export const AGENT_ARCHETYPES: AgentArchetype[] = [
319
341
  icon: 'i-lucide-scroll-text',
320
342
  color: '#84cc16',
321
343
  category: 'docs',
344
+ // Writes domain-rule docs into the repository (see `documenter`).
345
+ purposes: ['build', 'document'],
322
346
  description:
323
347
  'Reads the implementation and writes/updates business-logic & domain-rule docs in the repo, weaving in linked context documents.',
324
348
  },
@@ -329,6 +353,9 @@ export const AGENT_ARCHETYPES: AgentArchetype[] = [
329
353
  icon: 'i-lucide-shield-alert',
330
354
  color: '#ef4444',
331
355
  category: 'docs',
356
+ // The review activity that groups under Documentation: it reads a change against the
357
+ // documented rules and reports violations, writing nothing.
358
+ purposes: ['build', 'review'],
332
359
  description:
333
360
  'Reviews a change against the documented domain rules and reports violations, undocumented changes and unexpected drift.',
334
361
  },
@@ -8,7 +8,9 @@ import {
8
8
  pipelineAllowedForTaskType,
9
9
  purposeAllowsAgentCategory,
10
10
  purposeSuggestsAgentCategory,
11
+ purposeSuggestsAgentKind,
11
12
  } from '@cat-factory/contracts'
13
+ import type { PipelinePurpose } from '@cat-factory/contracts'
12
14
  import type { Block, Pipeline } from '~/types/domain'
13
15
  import {
14
16
  pipelineAllowedForManualStart,
@@ -216,6 +218,73 @@ describe('purposeSuggestsAgentCategory (builder palette filter)', () => {
216
218
  })
217
219
  })
218
220
 
221
+ describe('purposeSuggestsAgentKind (what the palette actually filters on)', () => {
222
+ it('defers to the category for a kind that declares no purposes of its own', () => {
223
+ // The normal case, and the one that keeps a deployment-registered kind exactly as visible as
224
+ // it was before kinds could speak for themselves.
225
+ for (const purpose of [...PIPELINE_PURPOSES, UNKNOWN_PURPOSE]) {
226
+ for (const category of AGENT_CATEGORIES) {
227
+ expect(purposeSuggestsAgentKind(purpose, { category })).toBe(
228
+ purposeSuggestsAgentCategory(purpose, category),
229
+ )
230
+ }
231
+ expect(purposeSuggestsAgentKind(purpose, {})).toBe(true)
232
+ }
233
+ })
234
+
235
+ it('lets a kind narrow within a category its siblings still belong to', () => {
236
+ // What the category table structurally cannot say: `docs` survives a `review` pipeline so the
237
+ // Domain Rules Reviewer does, which also handed it the two kinds that WRITE documentation.
238
+ const author = { category: 'docs', purposes: ['build', 'document'] } as const
239
+ expect(purposeSuggestsAgentKind('document', author)).toBe(true)
240
+ expect(purposeSuggestsAgentKind('review', author)).toBe(false)
241
+ expect(purposeSuggestsAgentCategory('review', 'docs')).toBe(true)
242
+ })
243
+
244
+ it('is a declaration, not an exemption from the category', () => {
245
+ // A kind cannot buy its way back into a purpose its section is not offered to: `docs` is gone
246
+ // for `planning`, and the palette drops the kind whether or not it named `planning` itself.
247
+ const doc = { category: 'docs', purposes: ['planning'] } as const
248
+ expect(purposeSuggestsAgentKind('planning', doc)).toBe(
249
+ purposeSuggestsAgentCategory('planning', 'docs'),
250
+ )
251
+ })
252
+
253
+ it('reads a list naming nothing this build knows as no declaration at all', () => {
254
+ const stale = { category: 'docs', purposes: [UNKNOWN_PURPOSE] } as const
255
+ for (const purpose of PIPELINE_PURPOSES) {
256
+ expect(purposeSuggestsAgentKind(purpose, stale)).toBe(
257
+ purposeSuggestsAgentCategory(purpose, 'docs'),
258
+ )
259
+ }
260
+ // And a purpose this build cannot name still narrows nothing, declaration or not.
261
+ expect(
262
+ purposeSuggestsAgentKind(UNKNOWN_PURPOSE, { category: 'docs', purposes: ['build'] }),
263
+ ).toBe(true)
264
+ })
265
+
266
+ it('never offers what the save gate would refuse', () => {
267
+ // The same invariant the category predicate carries, restated where the palette now reads it:
268
+ // a kind's own declaration may only ever hide more, so it can never open a hole through which
269
+ // a kind is offered and its step then blocks the save.
270
+ for (const purpose of [...PIPELINE_PURPOSES, UNKNOWN_PURPOSE]) {
271
+ for (const category of AGENT_CATEGORIES) {
272
+ const declarations: (readonly PipelinePurpose[] | undefined)[] = [
273
+ undefined,
274
+ PIPELINE_PURPOSES,
275
+ ['build'],
276
+ [UNKNOWN_PURPOSE],
277
+ ]
278
+ for (const purposes of declarations) {
279
+ if (purposeSuggestsAgentKind(purpose, { category, purposes })) {
280
+ expect(purposeAllowsAgentCategory(purpose, category)).toBe(true)
281
+ }
282
+ }
283
+ }
284
+ }
285
+ })
286
+ })
287
+
219
288
  describe('a purpose or category this build does not recognise', () => {
220
289
  it('is recognised as unknown rather than trusted', () => {
221
290
  for (const purpose of PIPELINE_PURPOSES) expect(isPipelinePurpose(purpose)).toBe(true)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.255.1",
3
+ "version": "0.256.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.282.0"
43
+ "@cat-factory/contracts": "0.283.1"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@toad-contracts/testing": "0.3.2",