@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 +19 -2
- package/app/modular/agent-kinds.ts +3 -0
- package/app/modular/capabilities.spec.ts +49 -0
- package/app/modular/capabilities.ts +29 -26
- package/app/types/domain.ts +7 -1
- package/app/utils/agentPalette.spec.ts +30 -0
- package/app/utils/agentPalette.ts +11 -10
- package/app/utils/catalog.spec.ts +35 -0
- package/app/utils/catalog.ts +27 -0
- package/app/utils/pipeline.spec.ts +69 -0
- package/package.json +2 -2
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
|
-
- `
|
|
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
|
|
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.
|
|
30
|
-
*
|
|
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
|
/**
|
package/app/types/domain.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* that
|
|
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<
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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.
|
package/app/utils/catalog.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
43
|
+
"@cat-factory/contracts": "0.283.1"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@toad-contracts/testing": "0.3.2",
|