@cat-factory/app 0.132.0 → 0.134.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.
@@ -1,7 +1,9 @@
1
- import { defineModule } from '@modular-vue/core'
2
1
  import type { AnyModuleDescriptor } from '@modular-vue/core'
3
2
  import { createRegistry } from '@modular-vue/runtime'
4
3
  import type { ModuleRegistry } from '@modular-vue/runtime'
4
+ import { navigationModule } from '~/modular/nav-contributions'
5
+ import type { NavGates } from '~/modular/nav-contributions'
6
+ import type { AppSlots } from '~/modular/slots'
5
7
 
6
8
  /**
7
9
  * modular-vue registry for the `@cat-factory/app` layer (slice 0 of the
@@ -13,21 +15,29 @@ import type { ModuleRegistry } from '@modular-vue/runtime'
13
15
  * contributes its own, all through the same seam. The registry is resolved and
14
16
  * installed by `app/plugins/modular.client.ts`.
15
17
  *
16
- * Slice 0 wires the plumbing behind ZERO behaviour change: the one first-party
17
- * module carries no navigation / slots / component, so nothing new renders.
18
- * Later slices convert real areas (navigation, result views, wizards, inspector
19
- * panels) into modules registered here.
18
+ * Slice 1 registers the first real feature module: `cat-factory:navigation`
19
+ * contributes the whole nav/command catalog to the `nav` slot, gated reactively
20
+ * by a `gates` service + `navSlotFilter` and rendered by `SideBar`, `CommandBar`,
21
+ * and `BoardToolbar` via `useReactiveSlots`. Later slices add result views,
22
+ * wizards, and inspector panels as further modules registered here.
20
23
  */
21
24
 
22
25
  /**
23
- * First-party modules the layer always registers. Kept tiny on purpose for
24
- * slice 0 a single descriptor with no contributions, present only to prove the
25
- * registration pipeline end to end and give the seam a stable anchor. Real
26
- * feature modules land here as later slices convert each area.
26
+ * The layer's shared-dependency shape (grows as later slices wire more deps).
27
+ * A `type` (not `interface`) so it satisfies the registry's
28
+ * `Record<string, any>` dependency constraint an interface lacks the implicit
29
+ * index signature a type-literal has.
27
30
  */
28
- const FIRST_PARTY_MODULES: readonly AnyModuleDescriptor[] = [
29
- defineModule({ id: 'cat-factory:core', version: '1.0.0' }),
30
- ]
31
+ export type AppDeps = {
32
+ /** Reactive RBAC/availability gates the nav `slotFilter` reads. */
33
+ gates: NavGates
34
+ }
35
+
36
+ /**
37
+ * First-party modules the layer always registers. Real feature modules land
38
+ * here as each area is converted; slice 1 adds the navigation catalog.
39
+ */
40
+ const FIRST_PARTY_MODULES: readonly AnyModuleDescriptor[] = [navigationModule]
31
41
 
32
42
  /**
33
43
  * Consumer-contributed modules, collected before the layer resolves its
@@ -61,14 +71,33 @@ export function __resetConsumerModulesForTest(): void {
61
71
  }
62
72
 
63
73
  /**
64
- * Build a fresh registry with the first-party modules plus everything a consumer
65
- * contributed via {@link registerAppModule}. A new registry per call because
66
- * `resolve()` / `resolveManifest()` are single-commit; the install plugin calls
67
- * this exactly once at client startup (`ssr: false`, so a singleton app).
74
+ * Build a fresh registry with the first-party modules, any `extraModules` the
75
+ * caller supplies, plus everything a consumer contributed via
76
+ * {@link registerAppModule}. A new registry per call because `resolve()` /
77
+ * `resolveManifest()` are single-commit; the install plugin calls this exactly
78
+ * once at client startup (`ssr: false`, so a singleton app).
79
+ *
80
+ * `deps.gates` is the reactive gate service the nav `slotFilter` reads; it's
81
+ * built in the install plugin (Vue context) and registered as a `service` so
82
+ * `useReactiveSlots` tracks it. Each slot default is seeded empty so the key
83
+ * always exists even before any module (or with only consumer modules)
84
+ * contributes to it.
85
+ *
86
+ * `extraModules` is how the client plugin registers first-party modules that
87
+ * carry Vue components (the `resultViews` registry): keeping them out of
88
+ * `FIRST_PARTY_MODULES` keeps this module's static import graph — which the
89
+ * unit tests exercise — free of `.vue` files (the vitest config has no SFC
90
+ * transform).
68
91
  */
69
- export function createAppRegistry(): ModuleRegistry<Record<string, unknown>> {
70
- const registry = createRegistry<Record<string, unknown>>({})
71
- for (const mod of [...FIRST_PARTY_MODULES, ...consumerModules]) {
92
+ export function createAppRegistry(
93
+ deps: AppDeps,
94
+ extraModules: readonly AnyModuleDescriptor[] = [],
95
+ ): ModuleRegistry<AppDeps, AppSlots> {
96
+ const registry = createRegistry<AppDeps, AppSlots>({
97
+ services: { gates: deps.gates },
98
+ slots: { nav: [], resultViews: [], agentKinds: [] },
99
+ })
100
+ for (const mod of [...FIRST_PARTY_MODULES, ...extraModules, ...consumerModules]) {
72
101
  registry.register(mod)
73
102
  }
74
103
  return registry
@@ -0,0 +1,104 @@
1
+ import type { Component } from 'vue'
2
+ import { defineModule } from '@modular-vue/core'
3
+ import { RESULT_VIEW_IDS, type ResultViewId } from '@cat-factory/contracts'
4
+ import RequirementsReviewWindow from '~/components/requirements/RequirementsReviewWindow.vue'
5
+ import ClarityReviewWindow from '~/components/clarity/ClarityReviewWindow.vue'
6
+ import BrainstormWindow from '~/components/brainstorm/BrainstormWindow.vue'
7
+ import TestReportWindow from '~/components/testing/TestReportWindow.vue'
8
+ import HumanTestWindow from '~/components/humanTest/HumanTestWindow.vue'
9
+ import VisualConfirmationWindow from '~/components/visualConfirm/VisualConfirmationWindow.vue'
10
+ import GateResultView from '~/components/gates/GateResultView.vue'
11
+ import ConsensusSessionWindow from '~/components/consensus/ConsensusSessionWindow.vue'
12
+ import GenericStructuredResultView from '~/components/panels/GenericStructuredResultView.vue'
13
+ import ServiceSpecWindow from '~/components/spec/ServiceSpecWindow.vue'
14
+ import FollowUpWindow from '~/components/followUp/FollowUpWindow.vue'
15
+ import ForkDecisionWindow from '~/components/forkDecision/ForkDecisionWindow.vue'
16
+ import PrReviewWindow from '~/components/prReview/PrReviewWindow.vue'
17
+ import MergerResultView from '~/components/panels/MergerResultView.vue'
18
+ import InitiativeTrackerWindow from '~/components/initiative/InitiativeTrackerWindow.vue'
19
+ import InitiativePlanningWindow from '~/components/initiative/InitiativePlanningWindow.vue'
20
+ import DocInterviewWindow from '~/components/docs/DocInterviewWindow.vue'
21
+ import RalphLoopResultView from '~/components/ralph/RalphLoopResultView.vue'
22
+ import type { ResultViewContribution } from './slots'
23
+
24
+ /**
25
+ * The first-party result-view registry (slice 2 of the modular-vue adoption —
26
+ * docs/initiatives/modular-vue-adoption.md).
27
+ *
28
+ * Every built-in dedicated result window is contributed as a `ComponentEntry`
29
+ * to the `resultViews` slot instead of living in a hardcoded `Record` in
30
+ * `StepResultViewHost.vue`. The host reads the merged slot via
31
+ * `resolveComponentRegistry` (`@modular-vue/core`) and mounts `get(viewId)`; a
32
+ * step's/kind's `resultView` id (built-in or a custom kind's) selects the entry.
33
+ *
34
+ * A consumer deployment ships its OWN result window by contributing another
35
+ * `ComponentEntry` to the SAME slot from a `registerAppModule` module — it then
36
+ * mounts with zero host edits (the extensibility promise), paired against the
37
+ * kind's `presentation.resultView` id exactly like the built-ins. Consumer ids
38
+ * SHOULD be namespaced (`acme:report`) so they can't collide with a built-in;
39
+ * `resolveComponentRegistry` throws on a duplicate id by default.
40
+ *
41
+ * Because these carry Vue components, this module is registered from the client
42
+ * plugin (`plugins/modular.client.ts`), NOT from `createAppRegistry` — that
43
+ * keeps the pure/unit-tested registry import graph free of `.vue` files (the
44
+ * vitest config has no SFC transform).
45
+ *
46
+ * Built-in coverage is enforced at COMPILE time: {@link BUILT_IN_RESULT_VIEWS} is a
47
+ * `Record<ResultViewId, Component>`, so a built-in added to the contract's
48
+ * `RESULT_VIEW_IDS` picklist without a component here (or a stray id that isn't a
49
+ * built-in) is a `nuxt typecheck` failure — a CI gate — rather than a runtime warning
50
+ * that could ship. Consumer namespaced ids are validated separately by `pairById`.
51
+ */
52
+ const BUILT_IN_RESULT_VIEWS: Record<ResultViewId, Component> = {
53
+ 'requirements-review': RequirementsReviewWindow,
54
+ 'clarity-review': ClarityReviewWindow,
55
+ // Shared by both brainstorm stages (requirements + architecture); the window reads the stage.
56
+ brainstorm: BrainstormWindow,
57
+ tester: TestReportWindow,
58
+ // The human-testing gate: env URL + confirm / request-fix / pull-main / recreate / destroy.
59
+ 'human-test': HumanTestWindow,
60
+ // The visual-confirmation gate: actual-vs-reference screenshot gallery + approve / request-fix.
61
+ 'visual-confirm': VisualConfirmationWindow,
62
+ // Shared by all polling gates (`ci` / `conflicts` / `human-review` / …); the window branches on kind.
63
+ gate: GateResultView,
64
+ // Opened for any step that ran the consensus mechanism (routed in `ui.dispatchStepView`).
65
+ 'consensus-session': ConsensusSessionWindow,
66
+ // Default dedicated view for a registered CUSTOM kind's structured (`custom`) output —
67
+ // a read-only JSON viewer, so a proprietary agent ships a result view with no bespoke code.
68
+ 'generic-structured': GenericStructuredResultView,
69
+ // The service's prescriptive spec tree (+ Gherkin); opened directly via `ui.openServiceSpec`.
70
+ 'service-spec': ServiceSpecWindow,
71
+ // The Follow-up companion: the Coder's surfaced loose ends / questions.
72
+ 'follow-ups': FollowUpWindow,
73
+ // The implementation-fork decision: the proposer's approaches + the human's pick / custom.
74
+ 'fork-decision': ForkDecisionWindow,
75
+ // The PR deep-review: the reviewer's sliced, prioritized findings + the human's multi-select.
76
+ 'pr-review': PrReviewWindow,
77
+ // The merger's verdict: PR complexity/risk/impact scores + the engine's decision (and why).
78
+ merger: MergerResultView,
79
+ // The initiative tracker: phases, per-item status + PR links, decisions, deviations, caveats.
80
+ 'initiative-tracker': InitiativeTrackerWindow,
81
+ 'initiative-planning': InitiativePlanningWindow,
82
+ // The interactive document-interview gate: clarifying questions + answer / continue / proceed.
83
+ 'doc-interview': DocInterviewWindow,
84
+ // The Ralph loop: the retry-until-done iteration history + the validation command + its output.
85
+ 'ralph-loop': RalphLoopResultView,
86
+ }
87
+
88
+ /**
89
+ * The built-in windows as slot entries, derived from `RESULT_VIEW_IDS` so the slot order matches
90
+ * the canonical id order. Exhaustiveness is guaranteed by {@link BUILT_IN_RESULT_VIEWS}'s type.
91
+ */
92
+ export const RESULT_VIEW_CONTRIBUTIONS: readonly ResultViewContribution[] = RESULT_VIEW_IDS.map(
93
+ (id) => ({ id, component: BUILT_IN_RESULT_VIEWS[id] }),
94
+ )
95
+
96
+ /**
97
+ * The first-party result-views module: contributes every built-in window to the
98
+ * `resultViews` slot. Registered from the client plugin (see the note above).
99
+ */
100
+ export const resultViewsModule = defineModule({
101
+ id: 'cat-factory:result-views',
102
+ version: '1.0.0',
103
+ slots: { resultViews: [...RESULT_VIEW_CONTRIBUTIONS] },
104
+ })
@@ -0,0 +1,40 @@
1
+ import type { Component } from 'vue'
2
+ import type { ComponentEntry } from '@modular-vue/core'
3
+ import type { CustomAgentKind } from '~/types/domain'
4
+ import type { NavContribution } from './nav-contributions'
5
+
6
+ /**
7
+ * The layer's aggregated slot map — the single home for every slot key the
8
+ * first-party modules (and consumer deployments) contribute to. Grows one key
9
+ * per converted seam as the modular-vue adoption proceeds
10
+ * (docs/initiatives/modular-vue-adoption.md):
11
+ *
12
+ * - `nav` (slice 1) — the nav/command catalog, rendered by the three shells.
13
+ * - `resultViews` (slice 2) — the id → dedicated result-window registry
14
+ * ({@link ResultViewContribution}), read by `StepResultViewHost` through
15
+ * `resolveComponentRegistry`. First-party AND consumer components enter here.
16
+ * - `agentKinds` (slice 2) — CODE-shipped custom agent kinds a consumer module
17
+ * contributes (the palette/catalog data half). BACKEND-registered kinds
18
+ * arrive separately as a {@link RemoteModuleManifest} read in the agents
19
+ * store — see `stores/agents.ts`.
20
+ *
21
+ * The index signature is mutable (`unknown[]`) to satisfy the runtime's
22
+ * `SlotMap` constraint while `unknown[]` still meets `useReactiveSlots`'
23
+ * `readonly unknown[]` bound (the slice-1 type-friction note).
24
+ */
25
+ export interface AppSlots {
26
+ nav: NavContribution[]
27
+ resultViews: ResultViewContribution[]
28
+ agentKinds: CustomAgentKind[]
29
+ [key: string]: unknown[]
30
+ }
31
+
32
+ /**
33
+ * One dedicated result-view window, addressed by its `resultView` id (the same
34
+ * ids as `@cat-factory/contracts` `RESULT_VIEW_IDS` for the built-ins). A plain
35
+ * `ComponentEntry` so the modular `resolveComponentRegistry` / `pairById`
36
+ * helpers index and pair it with the wire-delivered `presentation.resultView`
37
+ * id — the sanctioned "backend data selects a code-shipped, locally-registered
38
+ * component" pairing (see the modular-vue Remote Capability Manifests guide).
39
+ */
40
+ export type ResultViewContribution = ComponentEntry<Component>
@@ -1,5 +1,11 @@
1
1
  import { installModularApp } from '@modular-vue/nuxt/runtime'
2
+ import { resolveComponentRegistry } from '@modular-vue/core'
2
3
  import { createAppRegistry } from '~/modular/registry'
4
+ import { navSlotFilter } from '~/modular/nav-contributions'
5
+ import { createNavGates } from '~/modular/nav-gates'
6
+ import { resultViewsModule } from '~/modular/result-views'
7
+ import type { AppSlots, ResultViewContribution } from '~/modular/slots'
8
+ import type { CustomAgentKind } from '~/types/domain'
3
9
 
4
10
  /**
5
11
  * Wire the modular-vue registry into the Nuxt app (slice 0 of the modular-vue
@@ -16,17 +22,42 @@ import { createAppRegistry } from '~/modular/registry'
16
22
  *
17
23
  * `ssr: false`, so this runs once on the client and a singleton registry is
18
24
  * fine. `installModularApp` is the router-owning path: it grafts each module's
19
- * routes onto Nuxt's router (none yet — slice 0 modules are non-routed) and
20
- * installs the modular contexts (shared deps, navigation, slots) app-wide. The
21
- * resolved manifest is exposed as `$modular` for later slices; nothing reads it
22
- * yet, which is what keeps this behaviour-neutral.
25
+ * routes onto Nuxt's router (none yet — the nav modules are non-routed) and
26
+ * installs the modular contexts (shared deps, navigation, slots) app-wide.
27
+ *
28
+ * Slice 1 wires the reactive nav gating here: `createNavGates()` builds the
29
+ * reactive `gates` service (Pinia + composables are available in a `post`
30
+ * plugin), which the registry registers as a `service` and `navSlotFilter` reads
31
+ * per item. The shells consume the gated `nav` slot through `useReactiveSlots`,
32
+ * so a permission/connection flip re-gates them with no `recalculateSlots()`.
33
+ *
34
+ * Slice 2 registers the first-party `resultViews` registry here (via
35
+ * `extraModules`, so its Vue-component imports stay out of the unit-tested
36
+ * `registry.ts` import graph), and feeds the resolved static `agentKinds` slot —
37
+ * the deployment's CODE-shipped consumer agent kinds — into the agents store.
38
+ * (Backend-registered kinds arrive later as the per-workspace capability
39
+ * manifest via `hydrateCustomKinds`.) The result-view components themselves are
40
+ * read reactively by `StepResultViewHost` through `useReactiveSlots`.
23
41
  */
24
42
  export default defineNuxtPlugin({
25
43
  name: 'cat-factory:modular',
26
44
  enforce: 'post',
27
45
  setup(nuxtApp) {
28
- const registry = createAppRegistry()
29
- const manifest = installModularApp({ vueApp: nuxtApp.vueApp, $router: useRouter() }, registry)
46
+ const registry = createAppRegistry({ gates: createNavGates() }, [resultViewsModule])
47
+ const manifest = installModularApp({ vueApp: nuxtApp.vueApp, $router: useRouter() }, registry, {
48
+ slotFilter: navSlotFilter,
49
+ })
50
+ const slots = manifest.slots as AppSlots
51
+ // Fail FAST on a result-view wiring bug (a duplicate id across the first-party +
52
+ // consumer `resultViews` modules) at BOOT rather than lazily the first time a result
53
+ // window opens: resolve the merged slot once here. `resolveComponentRegistry` throws on
54
+ // a duplicate id by default, so a misconfigured deployment surfaces at startup with a
55
+ // clear stack. The slot is static after this resolve, so `StepResultViewHost`'s own
56
+ // reactive re-resolve is a cheap memoized read that this has already validated.
57
+ resolveComponentRegistry((slots.resultViews ?? []) as ResultViewContribution[])
58
+ // Consumer agent kinds contributed as CODE to the static `agentKinds` slot
59
+ // (module slots resolve once, so the static base is the full set).
60
+ useAgentsStore().registerConsumerKinds((slots.agentKinds ?? []) as CustomAgentKind[])
30
61
  return { provide: { modular: manifest } }
31
62
  },
32
63
  })
@@ -0,0 +1,100 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { useAgentsStore } from './agents'
3
+ import {
4
+ __resetCustomAgentKindMetaForTest,
5
+ agentKindMeta,
6
+ AGENT_ARCHETYPES,
7
+ isKnownAgentKind,
8
+ } from '~/utils/catalog'
9
+ import type { AgentKind, CustomAgentKind } from '~/types/domain'
10
+
11
+ const backendKind = (
12
+ kind: string,
13
+ over: Partial<CustomAgentKind['presentation']> = {},
14
+ ): CustomAgentKind => ({
15
+ kind: kind as AgentKind,
16
+ container: true,
17
+ presentation: {
18
+ label: `L:${kind}`,
19
+ icon: 'i-lucide-shield',
20
+ color: '#abc',
21
+ description: 'd',
22
+ ...over,
23
+ },
24
+ })
25
+
26
+ describe('agents store — custom-kind catalog (slice 2)', () => {
27
+ it('exposes only the built-in archetypes before any custom kind is hydrated', () => {
28
+ const agents = useAgentsStore()
29
+ expect(agents.archetypes).toHaveLength(AGENT_ARCHETYPES.length)
30
+ expect(agents.customArchetypes).toEqual([])
31
+ })
32
+
33
+ it('folds a backend remote-manifest custom kind into the palette + the pure-util projection', () => {
34
+ __resetCustomAgentKindMetaForTest()
35
+ const agents = useAgentsStore()
36
+ // Before hydrate the pure-util lookups don't know the kind.
37
+ expect(isKnownAgentKind('acme-audit')).toBe(false)
38
+ expect(agentKindMeta('acme-audit').label).toBe('Agent') // generic fallback
39
+
40
+ agents.hydrateCustomKinds([
41
+ backendKind('acme-audit', { category: 'review', resultView: 'acme:audit' }),
42
+ ])
43
+
44
+ // Palette + kind lookup both see it now (sync-flush projection — no tick needed).
45
+ expect(agents.archetypes.some((a) => a.kind === 'acme-audit')).toBe(true)
46
+ expect(isKnownAgentKind('acme-audit')).toBe(true)
47
+ const meta = agentKindMeta('acme-audit')
48
+ expect(meta.label).toBe('L:acme-audit')
49
+ expect(meta.resultView).toBe('acme:audit')
50
+ expect(meta.category).toBe('review')
51
+ })
52
+
53
+ it('never lets a custom kind shadow a built-in kind', () => {
54
+ const agents = useAgentsStore()
55
+ agents.hydrateCustomKinds([backendKind('coder', { label: 'Evil Coder' })])
56
+ // `coder` is a built-in; the custom entry is dropped, the built-in wins.
57
+ expect(agents.customArchetypes.some((a) => a.kind === 'coder')).toBe(false)
58
+ expect(agentKindMeta('coder').label).not.toBe('Evil Coder')
59
+ })
60
+
61
+ it('merges CODE-shipped consumer kinds with BACKEND manifest kinds, de-duplicated', () => {
62
+ const agents = useAgentsStore()
63
+ agents.registerConsumerKinds([backendKind('acme-consumer')])
64
+ agents.hydrateCustomKinds([backendKind('acme-backend'), backendKind('acme-consumer')])
65
+ const kinds = agents.customArchetypes.map((a) => a.kind)
66
+ expect(kinds).toContain('acme-consumer')
67
+ expect(kinds).toContain('acme-backend')
68
+ // consumer + backend both name `acme-consumer`; it appears once (consumer-slot first).
69
+ expect(kinds.filter((k) => k === 'acme-consumer')).toHaveLength(1)
70
+ })
71
+
72
+ it('no-ops a re-hydrate of identical content (stable projection, content-versioned manifest)', () => {
73
+ const agents = useAgentsStore()
74
+ agents.hydrateCustomKinds([backendKind('acme-x', { resultView: 'acme:x' })])
75
+ const first = agents.customArchetypes
76
+ // A new snapshot re-delivering structurally-equal kinds (fresh objects) must not
77
+ // recompute the merged catalog — the content-derived manifest version is unchanged, so
78
+ // `hydrateCustomKinds` skips the swap and the computed keeps its cached identity.
79
+ agents.hydrateCustomKinds([backendKind('acme-x', { resultView: 'acme:x' })])
80
+ expect(agents.customArchetypes).toBe(first)
81
+ })
82
+
83
+ it('swaps the backend catalog wholesale on re-hydrate (per-workspace manifest)', () => {
84
+ const agents = useAgentsStore()
85
+ agents.hydrateCustomKinds([backendKind('ws1-kind')])
86
+ expect(agents.customArchetypes.some((a) => a.kind === 'ws1-kind')).toBe(true)
87
+ agents.hydrateCustomKinds([backendKind('ws2-kind')])
88
+ expect(agents.customArchetypes.some((a) => a.kind === 'ws1-kind')).toBe(false)
89
+ expect(agents.customArchetypes.some((a) => a.kind === 'ws2-kind')).toBe(true)
90
+ expect(isKnownAgentKind('ws1-kind')).toBe(false)
91
+ })
92
+
93
+ it('addAgent registers an in-UI prototype without touching the built-in catalog', () => {
94
+ const agents = useAgentsStore()
95
+ const created = agents.addAgent({ label: 'My Agent' })
96
+ expect(agents.archetypes.some((a) => a.kind === created.kind)).toBe(true)
97
+ expect(created.category).toBeUndefined() // lands in the palette "custom" bucket
98
+ expect(agentKindMeta(created.kind).label).toBe('My Agent')
99
+ })
100
+ })
@@ -1,21 +1,96 @@
1
1
  import { defineStore } from 'pinia'
2
- import { ref } from 'vue'
3
- import { AGENT_ARCHETYPES, AGENT_BY_KIND, uid } from '~/utils/catalog'
2
+ import { computed, ref, watch } from 'vue'
3
+ import type { RemoteModuleManifest } from '@modular-vue/core'
4
+ import { buildAgentCapabilitiesManifest, customKindToArchetype } from '~/modular/agent-kinds'
5
+ import type { AppSlots } from '~/modular/slots'
6
+ import {
7
+ AGENT_ARCHETYPES,
8
+ AGENT_BY_KIND,
9
+ setCustomAgentKindMeta,
10
+ SYSTEM_AGENT_META,
11
+ uid,
12
+ } from '~/utils/catalog'
4
13
  import type { AgentArchetype, AgentKind, CustomAgentKind } from '~/types/domain'
5
14
 
6
15
  /**
7
- * The agent palette. Seeded from the static catalog, but custom agents can be
8
- * added at runtime (they show up in the pipeline builder). Newly created agents
9
- * are also registered into AGENT_BY_KIND so the many components that look an
10
- * agent up by kind keep rendering it correctly.
16
+ * The agent palette catalog (slice 2 of the modular-vue adoption
17
+ * docs/initiatives/modular-vue-adoption.md).
18
+ *
19
+ * Reactive union of three sources, none of which mutates the frozen built-in
20
+ * {@link AGENT_BY_KIND} const any more:
21
+ * - the built-in archetypes (static);
22
+ * - CONSUMER-shipped kinds contributed as CODE via the modular `agentKinds`
23
+ * slot (`registerConsumerKinds`, fed once at boot from the resolved manifest);
24
+ * - the deployment's BACKEND-registered kinds, modeled as a single
25
+ * {@link RemoteModuleManifest} swapped per workspace snapshot
26
+ * (`hydrateCustomKinds`) and read directly (single-active-manifest shape).
27
+ *
28
+ * The merged custom catalog is projected back into `catalog.ts`'s
29
+ * {@link setCustomAgentKindMeta} read-model so the pure `agentKindMeta` /
30
+ * `isKnownAgentKind` lookups (used across ~17 renderers) resolve a custom kind
31
+ * reactively without importing this store.
11
32
  */
12
33
  export const useAgentsStore = defineStore('agents', () => {
13
- const archetypes = ref<AgentArchetype[]>([...AGENT_ARCHETYPES])
34
+ // CODE-shipped consumer kinds from the static `agentKinds` slot (fed once at
35
+ // boot by the modular install plugin — module slots are resolved once).
36
+ const consumerKinds = ref<CustomAgentKind[]>([])
37
+ // The active per-workspace capability manifest built from the snapshot's
38
+ // `customAgentKinds`, or null before the first hydrate.
39
+ const capabilitiesManifest = ref<RemoteModuleManifest<AppSlots> | null>(null)
40
+ // In-UI, client-only prototype agents created via the "add agent" modal.
41
+ const runtimeAgents = ref<AgentArchetype[]>([])
14
42
 
15
- function get(kind: AgentKind) {
16
- return AGENT_BY_KIND[kind]
43
+ /**
44
+ * The merged CUSTOM catalog (consumer-slot → backend-manifest → runtime), each
45
+ * mapped to display metadata, de-duplicated, and never shadowing a built-in or
46
+ * system kind. The old `registerCustomKinds` only guarded `AGENT_BY_KIND`; this
47
+ * intentionally ALSO drops any custom kind colliding with a `SYSTEM_AGENT_META`
48
+ * kind (`ci` / `merger` / `blueprints` / gates …), so a snapshot can't override an
49
+ * engine kind's palette entry either — matching `agentKindMeta`'s precedence
50
+ * (built-in → system → custom), where a colliding custom kind would never win anyway.
51
+ */
52
+ const customArchetypes = computed<AgentArchetype[]>(() => {
53
+ const seen = new Set<string>()
54
+ const out: AgentArchetype[] = []
55
+ const add = (a: AgentArchetype) => {
56
+ if (a.kind in AGENT_BY_KIND || a.kind in SYSTEM_AGENT_META || seen.has(a.kind)) return
57
+ seen.add(a.kind)
58
+ out.push(a)
59
+ }
60
+ for (const k of consumerKinds.value) add(customKindToArchetype(k))
61
+ for (const k of capabilitiesManifest.value?.slots?.agentKinds ?? [])
62
+ add(customKindToArchetype(k))
63
+ for (const a of runtimeAgents.value) add(a)
64
+ return out
65
+ })
66
+
67
+ /** The full palette: built-in archetypes + the merged custom ones. */
68
+ const archetypes = computed<AgentArchetype[]>(() => [
69
+ ...AGENT_ARCHETYPES,
70
+ ...customArchetypes.value,
71
+ ])
72
+
73
+ // Known-kind lookup (built-in ∪ system ∪ custom) for `get`.
74
+ const customByKind = computed<Record<string, AgentArchetype>>(() =>
75
+ Object.fromEntries(customArchetypes.value.map((a) => [a.kind, a])),
76
+ )
77
+
78
+ // Keep `catalog.ts`'s pure-util projection in sync with the merged custom
79
+ // catalog so `agentKindMeta` / `isKnownAgentKind` resolve custom kinds. Sync
80
+ // flush so an imperative read right after `hydrateCustomKinds` (e.g. the run
81
+ // dispatch resolving a custom kind's `resultView`) sees the fresh catalog with
82
+ // no tick gap. The watch lives in the store's effect scope (disposed with it).
83
+ watch(customByKind, (map) => setCustomAgentKindMeta(map), { immediate: true, flush: 'sync' })
84
+
85
+ /** Display metadata for a KNOWN kind (built-in / system / custom), else undefined. */
86
+ function get(kind: AgentKind): AgentArchetype | undefined {
87
+ return AGENT_BY_KIND[kind] ?? SYSTEM_AGENT_META[kind] ?? customByKind.value[kind]
17
88
  }
18
89
 
90
+ /**
91
+ * Add an in-UI prototype agent (the pipeline builder's "add agent" modal).
92
+ * Client-only, so it lives in store state — no backend, no global mutation.
93
+ */
19
94
  function addAgent(input: {
20
95
  label: string
21
96
  description?: string
@@ -30,36 +105,42 @@ export const useAgentsStore = defineStore('agents', () => {
30
105
  icon: input.icon || 'i-lucide-sparkles',
31
106
  color: input.color || '#22d3ee',
32
107
  }
33
- // register for kind-based lookups across the app, then surface in the palette
34
- AGENT_BY_KIND[archetype.kind] = archetype
35
- archetypes.value.push(archetype)
108
+ runtimeAgents.value = [...runtimeAgents.value, archetype]
36
109
  return archetype
37
110
  }
38
111
 
39
112
  /**
40
- * Merge the deployment's registered CUSTOM agent kinds (from the workspace snapshot)
41
- * into the palette catalog: each becomes a first-class palette block + a kind-based
42
- * lookup (so timelines / inspectors render it instead of the generic fallback), and its
43
- * declared `resultView` opens through the same registry the built-ins use. Idempotent
44
- * and built-in-safe — a kind already known (a built-in, or a prior load) is left
45
- * untouched, so a snapshot can't shadow a built-in or duplicate on reload.
113
+ * Register the deployment's CODE-shipped consumer agent kinds the resolved
114
+ * modular `agentKinds` slot, fed once by the install plugin. Idempotent
115
+ * replace (module slots resolve once, so this is called a single time).
46
116
  */
47
- function registerCustomKinds(kinds: CustomAgentKind[]) {
48
- for (const { kind, presentation } of kinds) {
49
- if (AGENT_BY_KIND[kind]) continue
50
- const archetype: AgentArchetype = {
51
- kind,
52
- label: presentation.label,
53
- icon: presentation.icon,
54
- color: presentation.color,
55
- description: presentation.description,
56
- ...(presentation.category ? { category: presentation.category } : {}),
57
- ...(presentation.resultView ? { resultView: presentation.resultView } : {}),
58
- }
59
- AGENT_BY_KIND[kind] = archetype
60
- archetypes.value.push(archetype)
61
- }
117
+ function registerConsumerKinds(kinds: readonly CustomAgentKind[]) {
118
+ consumerKinds.value = [...kinds]
62
119
  }
63
120
 
64
- return { archetypes, get, addAgent, registerCustomKinds }
121
+ /**
122
+ * Hydrate the deployment's BACKEND-registered custom kinds from a workspace
123
+ * snapshot, modeled as a single remote capability manifest (swapped wholesale
124
+ * per workspace). Replaces the old `registerCustomKinds` that mutated
125
+ * {@link AGENT_BY_KIND} directly.
126
+ *
127
+ * The snapshot re-delivers the same deployment kinds on every board refresh, so
128
+ * skip the swap — and the downstream projection invalidation of every
129
+ * `agentKindMeta` consumer — when the content-derived manifest version is
130
+ * unchanged. A genuinely different workspace's kinds change the version and swap.
131
+ */
132
+ function hydrateCustomKinds(kinds: CustomAgentKind[]) {
133
+ const next = buildAgentCapabilitiesManifest(kinds)
134
+ if (capabilitiesManifest.value?.version === next.version) return
135
+ capabilitiesManifest.value = next
136
+ }
137
+
138
+ return {
139
+ archetypes,
140
+ customArchetypes,
141
+ get,
142
+ addAgent,
143
+ registerConsumerKinds,
144
+ hydrateCustomKinds,
145
+ }
65
146
  })
@@ -168,9 +168,10 @@ export const useWorkspaceStore = defineStore(
168
168
  useInitiativesStore().hydratePresets(snapshot.initiativePresets)
169
169
  useTrackerStore().hydrate(snapshot.trackerSettings)
170
170
  useServicesStore().hydrate(snapshot.mounts ?? [], snapshot.serviceCatalog ?? [])
171
- // Merge the deployment's registered custom agent kinds into the palette catalog so a
172
- // proprietary kind renders as a first-class block + result view (idempotent on reload).
173
- useAgentsStore().registerCustomKinds(snapshot.customAgentKinds ?? [])
171
+ // Hydrate the deployment's backend-registered custom agent kinds as the workspace's
172
+ // remote capability manifest, so a proprietary kind renders as a first-class palette
173
+ // block + result view. Swapped wholesale per workspace (no global-catalog mutation).
174
+ useAgentsStore().hydrateCustomKinds(snapshot.customAgentKinds ?? [])
174
175
  // The account's repo-sourced Claude Skills catalog (shared across its workspaces), so the
175
176
  // pipeline builder's per-step skill picker has its options. A straight replace.
176
177
  useSkillsStore().hydrate(snapshot.skills ?? [])
@@ -108,9 +108,10 @@ export interface AgentArchetype {
108
108
  category?: AgentCategory
109
109
  /**
110
110
  * Optional id of a DEDICATED result window this agent's step opens instead of the
111
- * generic prose step-detail panel. Resolved through the result-view registry
112
- * (`STEP_RESULT_VIEWS`) so any agent can declare a bespoke visualization without the
113
- * renderer hardcoding a kind. Absent the generic `AgentStepDetail` panel.
111
+ * generic prose step-detail panel. Resolved through the modular `resultViews` slot
112
+ * registry (`~/modular/result-views`, read by `StepResultViewHost`) so any agent
113
+ * built-in or a consumer's can declare a bespoke visualization without the renderer
114
+ * hardcoding a kind. Absent → the generic `AgentStepDetail` panel.
114
115
  */
115
116
  resultView?: string
116
117
  }