@cat-factory/app 0.133.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,98 +1,60 @@
1
1
  <script setup lang="ts">
2
- // Universal dedicated-result-view host. An agent archetype can declare a `resultView`
3
- // id (see `~/utils/catalog`); when a step of that kind is opened, `ui.resultView` is set
4
- // and this host renders the matching registered window instead of the generic
5
- // `AgentStepDetail` prose panel. Adding a bespoke visualization for a new agent is just:
6
- // 1. declare `resultView: '<id>'` on its archetype, and
7
- // 2. register `'<id>': <Component>` below.
8
- // No caller changes every board/inspector entry point already routes through
9
- // `ui` dispatch. Each registered window builds on `useResultView(viewId, { onOpen })`,
10
- // which owns the seam contract (open/blockId/close + Escape + load-on-open) so a new
11
- // window can't reintroduce the route-dependent empty-state bug by forgetting to fetch
12
- // on mount — declare an `onOpen` loader and it fires on every open.
13
- import { computed, type Component } from 'vue'
14
- import RequirementsReviewWindow from '~/components/requirements/RequirementsReviewWindow.vue'
15
- import ClarityReviewWindow from '~/components/clarity/ClarityReviewWindow.vue'
16
- import BrainstormWindow from '~/components/brainstorm/BrainstormWindow.vue'
17
- import TestReportWindow from '~/components/testing/TestReportWindow.vue'
18
- import HumanTestWindow from '~/components/humanTest/HumanTestWindow.vue'
19
- import VisualConfirmationWindow from '~/components/visualConfirm/VisualConfirmationWindow.vue'
20
- import GateResultView from '~/components/gates/GateResultView.vue'
21
- import ConsensusSessionWindow from '~/components/consensus/ConsensusSessionWindow.vue'
22
- import GenericStructuredResultView from '~/components/panels/GenericStructuredResultView.vue'
23
- import ServiceSpecWindow from '~/components/spec/ServiceSpecWindow.vue'
24
- import FollowUpWindow from '~/components/followUp/FollowUpWindow.vue'
25
- import ForkDecisionWindow from '~/components/forkDecision/ForkDecisionWindow.vue'
26
- import PrReviewWindow from '~/components/prReview/PrReviewWindow.vue'
27
- import MergerResultView from '~/components/panels/MergerResultView.vue'
28
- import InitiativeTrackerWindow from '~/components/initiative/InitiativeTrackerWindow.vue'
29
- import InitiativePlanningWindow from '~/components/initiative/InitiativePlanningWindow.vue'
30
- import DocInterviewWindow from '~/components/docs/DocInterviewWindow.vue'
31
- import RalphLoopResultView from '~/components/ralph/RalphLoopResultView.vue'
2
+ // Universal dedicated-result-view host (slice 2 of the modular-vue adoption
3
+ // docs/initiatives/modular-vue-adoption.md). An agent archetype can declare a
4
+ // `resultView` id (see `~/utils/catalog`); when a step of that kind is opened,
5
+ // `ui.resultView` is set and this host mounts the matching registered window
6
+ // instead of the generic `AgentStepDetail` prose panel.
7
+ //
8
+ // The registry is no longer a hardcoded `Record` here every built-in window is
9
+ // contributed to the modular `resultViews` slot (`~/modular/result-views`), and
10
+ // a consumer deployment adds its OWN window to the SAME slot from a
11
+ // `registerAppModule` module. This host reads the merged slot reactively via
12
+ // `useReactiveSlots` and indexes it with `resolveComponentRegistry`; the kind's
13
+ // (or custom kind's) `resultView` id selects the component. Adding a bespoke
14
+ // visualization for a new agent is: declare `resultView: '<id>'` on its
15
+ // archetype + contribute `{ id: '<id>', component }` to the `resultViews` slot.
16
+ //
17
+ // Each registered window builds on `useResultView(viewId, { onOpen })`, which
18
+ // owns the seam contract (open/blockId/close + Escape + load-on-open) so a new
19
+ // window can't reintroduce the route-dependent empty-state bug by forgetting to
20
+ // fetch on mount.
21
+ import { computed, watchEffect, type Component } from 'vue'
22
+ import { useReactiveSlots } from '@modular-vue/runtime'
23
+ import { pairById, resolveComponentRegistry } from '@modular-vue/core'
24
+ import type { AppSlots } from '~/modular/slots'
32
25
 
33
26
  const ui = useUiStore()
27
+ const agents = useAgentsStore()
28
+ const slots = useReactiveSlots<AppSlots>()
34
29
 
35
- const STEP_RESULT_VIEWS: Record<string, Component> = {
36
- 'requirements-review': RequirementsReviewWindow,
37
- 'clarity-review': ClarityReviewWindow,
38
- // Shared by both brainstorm stages (requirements + architecture); the window reads the stage.
39
- brainstorm: BrainstormWindow,
40
- tester: TestReportWindow,
41
- // The human-testing gate: env URL + confirm / request-fix / pull-main / recreate / destroy.
42
- 'human-test': HumanTestWindow,
43
- // The visual-confirmation gate: actual-vs-reference screenshot gallery + approve / request-fix.
44
- 'visual-confirm': VisualConfirmationWindow,
45
- // Shared by both polling gates (`ci` + `conflicts`); the window branches on agentKind.
46
- gate: GateResultView,
47
- // Opened for any step that ran the consensus mechanism (routed in `ui.dispatchStepView`).
48
- 'consensus-session': ConsensusSessionWindow,
49
- // Default dedicated view for a registered CUSTOM kind's structured (`custom`) output —
50
- // a read-only JSON viewer, so a proprietary agent ships a result view with no bespoke code.
51
- 'generic-structured': GenericStructuredResultView,
52
- // The service's prescriptive spec tree (+ Gherkin), opened from the inspector's "View
53
- // Requirements" button. Not a pipeline-step view — opened directly via `ui.openServiceSpec`.
54
- 'service-spec': ServiceSpecWindow,
55
- // The future-looking Follow-up companion: the Coder's surfaced loose ends / questions.
56
- // Opened directly via `ui.openFollowUps` (the blinking chip + the `followup_pending` card).
57
- 'follow-ups': FollowUpWindow,
58
- // The implementation-fork decision: the proposer's materially different approaches + the
59
- // human's pick / custom approach. Opened directly via `ui.openForkDecision` (the pipeline
60
- // chip, the inspector button, and the `fork_decision_pending` card).
61
- 'fork-decision': ForkDecisionWindow,
62
- // The PR deep-review: the reviewer's sliced, prioritized findings + the human's multi-select.
63
- // Opened as the `pr-reviewer` step's result view and via the `pr_review_ready` inbox card.
64
- 'pr-review': PrReviewWindow,
65
- // The merger's verdict: the PR's complexity/risk/impact scores + the engine's auto-merge
66
- // or awaiting-review decision (and why), instead of the agent's raw JSON.
67
- merger: MergerResultView,
68
- // The initiative tracker: phases, per-item status + PR links, decisions, deviations,
69
- // caveats. Opened from the initiative card / inspector (`ui.openInitiativeTracker`) and
70
- // as the planner step's result view.
71
- 'initiative-tracker': InitiativeTrackerWindow,
72
- 'initiative-planning': InitiativePlanningWindow,
73
- // The interactive document-interview gate (WS5): the interviewer's clarifying questions +
74
- // answer / continue / proceed, opened as the `doc-interviewer` step's result view.
75
- 'doc-interview': DocInterviewWindow,
76
- // The Ralph loop: the persistent retry-until-done iteration history + the programmatic
77
- // validation command + its latest exit code / output. Opened as the `ralph` step's view.
78
- 'ralph-loop': RalphLoopResultView,
79
- }
30
+ // Index the merged `resultViews` slot into an id → component registry. Duplicate
31
+ // ids throw by default (`resolveComponentRegistry`) — two modules claiming the
32
+ // same view id is a wiring bug, not a silent last-wins. Recomputed if a consumer
33
+ // module's contributions change (they don't after boot, but the read is cheap).
34
+ const registry = computed(() => resolveComponentRegistry(slots.value.resultViews ?? []))
80
35
 
81
36
  const active = computed<Component | null>(() => {
82
37
  const view = ui.resultView?.view
83
38
  if (!view) return null
84
- const component = STEP_RESULT_VIEWS[view] ?? null
85
- // An archetype declared a `resultView` id with no registered component — this used to
86
- // silently fall back to the prose panel. The backend now validates `resultView` against the
87
- // canonical id set, so this should only fire mid-development of a new view; make it loud.
88
- if (!component && import.meta.dev) {
89
- console.warn(
90
- `[StepResultViewHost] no component registered for resultView "${view}". ` +
91
- `Add it to STEP_RESULT_VIEWS (and to RESULT_VIEW_IDS in @cat-factory/contracts).`,
92
- )
93
- }
94
- return component
39
+ return registry.value.get(view) ?? null
95
40
  })
41
+
42
+ // Dev guard: surface any CUSTOM kind whose declared `resultView` id resolves to
43
+ // no registered component (a dangling reference — e.g. a backend kind naming a
44
+ // consumer view this build doesn't ship). `pairById`'s `missing` bucket makes
45
+ // the degradation explicit instead of a silent fall-through to the prose panel.
46
+ if (import.meta.dev) {
47
+ watchEffect(() => {
48
+ const { missing } = pairById(agents.customArchetypes, registry.value, (a) => a.resultView)
49
+ if (missing.length) {
50
+ console.warn(
51
+ `[StepResultViewHost] custom agent kind(s) reference unregistered resultView id(s): ` +
52
+ missing.map((m) => `${m.item.kind}→${m.id}`).join(', ') +
53
+ `. Register a component for the id in a resultViews-slot module.`,
54
+ )
55
+ }
56
+ })
57
+ }
96
58
  </script>
97
59
 
98
60
  <template>
@@ -0,0 +1,80 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ buildAgentCapabilitiesManifest,
4
+ capabilitiesManifestVersion,
5
+ customKindToArchetype,
6
+ WORKSPACE_CAPABILITIES_MANIFEST_ID,
7
+ } from './agent-kinds'
8
+ import type { AgentKind, CustomAgentKind } from '~/types/domain'
9
+
10
+ const kind = (
11
+ over: Partial<CustomAgentKind['presentation']> = {},
12
+ kindId = 'acme-audit',
13
+ ): CustomAgentKind => ({
14
+ kind: kindId as AgentKind,
15
+ container: true,
16
+ presentation: {
17
+ label: 'Audit',
18
+ icon: 'i-lucide-shield',
19
+ color: '#fff',
20
+ description: 'd',
21
+ ...over,
22
+ },
23
+ })
24
+
25
+ describe('buildAgentCapabilitiesManifest', () => {
26
+ it('models the snapshot kinds as one remote capability manifest carrying the agentKinds slot', () => {
27
+ const kinds = [kind(), kind()]
28
+ const manifest = buildAgentCapabilitiesManifest(kinds)
29
+ expect(manifest.id).toBe(WORKSPACE_CAPABILITIES_MANIFEST_ID)
30
+ expect(manifest.slots?.agentKinds).toEqual(kinds)
31
+ })
32
+
33
+ it('copies the input (no aliasing of the caller array)', () => {
34
+ const kinds = [kind()]
35
+ const manifest = buildAgentCapabilitiesManifest(kinds)
36
+ kinds.push(kind())
37
+ expect(manifest.slots?.agentKinds).toHaveLength(1)
38
+ })
39
+
40
+ it('derives an identical version for identical content (so an unchanged re-hydrate no-ops)', () => {
41
+ // A fresh, structurally-equal kinds array (a new snapshot re-delivering the same kinds)
42
+ // must produce the same version — that's what lets `hydrateCustomKinds` skip the swap.
43
+ expect(buildAgentCapabilitiesManifest([kind()]).version).toBe(
44
+ buildAgentCapabilitiesManifest([kind()]).version,
45
+ )
46
+ })
47
+
48
+ it('changes the version when a display/pairing field or the kind set differs', () => {
49
+ const base = capabilitiesManifestVersion([kind()])
50
+ expect(capabilitiesManifestVersion([kind({ label: 'Renamed' })])).not.toBe(base)
51
+ expect(capabilitiesManifestVersion([kind({ resultView: 'acme:audit' })])).not.toBe(base)
52
+ expect(capabilitiesManifestVersion([kind({}, 'acme-other')])).not.toBe(base)
53
+ expect(capabilitiesManifestVersion([kind(), kind({}, 'acme-two')])).not.toBe(base)
54
+ expect(capabilitiesManifestVersion([])).not.toBe(base)
55
+ })
56
+ })
57
+
58
+ describe('customKindToArchetype', () => {
59
+ it('projects presentation onto the display archetype', () => {
60
+ expect(customKindToArchetype(kind())).toEqual({
61
+ kind: 'acme-audit',
62
+ label: 'Audit',
63
+ icon: 'i-lucide-shield',
64
+ color: '#fff',
65
+ description: 'd',
66
+ })
67
+ })
68
+
69
+ it('carries category and resultView through when present', () => {
70
+ const a = customKindToArchetype(kind({ category: 'review', resultView: 'acme:audit' }))
71
+ expect(a.category).toBe('review')
72
+ expect(a.resultView).toBe('acme:audit')
73
+ })
74
+
75
+ it('omits category/resultView when absent (no undefined keys)', () => {
76
+ const a = customKindToArchetype(kind())
77
+ expect('category' in a).toBe(false)
78
+ expect('resultView' in a).toBe(false)
79
+ })
80
+ })
@@ -0,0 +1,83 @@
1
+ import type { RemoteModuleManifest } from '@modular-vue/core'
2
+ import type { AgentArchetype, CustomAgentKind } from '~/types/domain'
3
+ import type { AppSlots } from './slots'
4
+
5
+ /**
6
+ * Custom agent kinds as remote capability manifests (slice 2 of the modular-vue
7
+ * adoption — docs/initiatives/modular-vue-adoption.md).
8
+ *
9
+ * A deployment's BACKEND-registered agent kinds arrive in the workspace snapshot
10
+ * as `customAgentKinds` (wire data). Rather than mutating a module-global
11
+ * catalog, they're modeled as a single {@link RemoteModuleManifest} whose
12
+ * `agentKinds` slot carries the data. cat-factory holds ONE active manifest per
13
+ * workspace (swapped on snapshot), so the agents store reads its `.slots`
14
+ * directly — the sanctioned single-active-manifest shape (the merge-many
15
+ * `mergeRemoteManifests` helper is for holding several manifests at once, which
16
+ * we don't). CODE-shipped consumer kinds instead enter via the static
17
+ * `agentKinds` slot (a `registerAppModule` module); the store merges both.
18
+ */
19
+
20
+ /** The stable id for the per-workspace capability manifest built from the snapshot. */
21
+ export const WORKSPACE_CAPABILITIES_MANIFEST_ID = 'cat-factory:workspace-capabilities'
22
+
23
+ /**
24
+ * A deterministic, key-order-independent content signature of the custom kinds, used as the
25
+ * manifest `version`. The workspace snapshot re-delivers the SAME deployment-registered kinds on
26
+ * every board-event refresh (a full `workspace.refresh()` runs `hydrateCustomKinds` each time), so
27
+ * a content-derived version lets the store skip re-projecting an UNCHANGED catalog — otherwise
28
+ * every refresh would replace the `customAgentKindMeta` read-model and needlessly invalidate the
29
+ * ~17 `agentKindMeta` / `isKnownAgentKind` consumers. It still changes (and swaps wholesale) when a
30
+ * different workspace's kinds actually differ. Serialized as fixed-order tuples of only the fields
31
+ * that affect display/pairing, so a re-serialization with reordered object keys can't spuriously
32
+ * differ.
33
+ */
34
+ export function capabilitiesManifestVersion(kinds: readonly CustomAgentKind[]): string {
35
+ return JSON.stringify(
36
+ kinds.map((k) => [
37
+ k.kind,
38
+ k.container,
39
+ k.presentation.label,
40
+ k.presentation.icon,
41
+ k.presentation.color,
42
+ k.presentation.description,
43
+ k.presentation.category ?? null,
44
+ k.presentation.resultView ?? null,
45
+ ]),
46
+ )
47
+ }
48
+
49
+ /**
50
+ * Model the snapshot's `customAgentKinds` as a single remote capability manifest. The `version` is
51
+ * a {@link capabilitiesManifestVersion content signature} so identical snapshots produce an
52
+ * identical manifest — which `useAgentsStore().hydrateCustomKinds` uses to no-op an unchanged
53
+ * re-hydrate. Swapped wholesale (not diffed) when the content genuinely changes.
54
+ */
55
+ export function buildAgentCapabilitiesManifest(
56
+ kinds: readonly CustomAgentKind[],
57
+ ): RemoteModuleManifest<AppSlots> {
58
+ return {
59
+ id: WORKSPACE_CAPABILITIES_MANIFEST_ID,
60
+ version: capabilitiesManifestVersion(kinds),
61
+ slots: { agentKinds: [...kinds] },
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Project a wire `CustomAgentKind` onto the frontend's display `AgentArchetype`
67
+ * (icon/label/color/description + optional category/resultView). The inverse of
68
+ * the backend `agentPresentationSchema` — the SAME mapping the removed
69
+ * `registerCustomKinds` did inline, now pure and shared by the consumer-slot and
70
+ * backend-manifest paths.
71
+ */
72
+ export function customKindToArchetype(kind: CustomAgentKind): AgentArchetype {
73
+ const { presentation: p } = kind
74
+ return {
75
+ kind: kind.kind,
76
+ label: p.label,
77
+ icon: p.icon,
78
+ color: p.color,
79
+ description: p.description,
80
+ ...(p.category ? { category: p.category } : {}),
81
+ ...(p.resultView ? { resultView: p.resultView } : {}),
82
+ }
83
+ }
@@ -45,7 +45,7 @@ const ALL_GATES: NavGates = {
45
45
  isAccountAdmin: true,
46
46
  }
47
47
 
48
- const slots = (): AppSlots => ({ nav: [...NAV_CONTRIBUTIONS] })
48
+ const slots = (): AppSlots => ({ nav: [...NAV_CONTRIBUTIONS], resultViews: [], agentKinds: [] })
49
49
  const ids = (s: unknown) => (s as AppSlots).nav.map((i) => i.id)
50
50
 
51
51
  describe('navSlotFilter', () => {
@@ -1,4 +1,11 @@
1
1
  import { defineModule } from '@modular-vue/core'
2
+ import type { AppSlots } from './slots'
3
+
4
+ // Re-exported for the slice-1 importers that reach `AppSlots` through this
5
+ // module (`useNavContributions`, `registry`, the nav specs); its canonical home
6
+ // is now `./slots`, where all slot keys are aggregated (slice 2 added
7
+ // `resultViews` / `agentKinds`).
8
+ export type { AppSlots } from './slots'
2
9
 
3
10
  /**
4
11
  * The single nav/command catalog for the layer (slice 1 of the modular-vue
@@ -121,17 +128,6 @@ export interface NavContribution {
121
128
  toolbar?: { order: number }
122
129
  }
123
130
 
124
- /**
125
- * The layer's slot map. Consumer modules contribute to the same `nav` key. The
126
- * index signature is mutable (`unknown[]`) to satisfy the runtime's `SlotMap`
127
- * constraint; `unknown[]` still satisfies `useReactiveSlots`' `readonly unknown[]`
128
- * bound, so both the install and the read side accept it.
129
- */
130
- export interface AppSlots {
131
- nav: NavContribution[]
132
- [key: string]: unknown[]
133
- }
134
-
135
131
  const S = (...s: NavSurface[]) => s as readonly NavSurface[]
136
132
 
137
133
  /**
@@ -41,6 +41,44 @@ describe('app modular registry', () => {
41
41
  expect(() => createAppRegistry({ gates: NO_GATES }).resolveManifest()).toThrow(/duplicate/i)
42
42
  })
43
43
 
44
+ it('merges consumer-contributed result-view + agent-kind slots (slice-2 extensibility)', () => {
45
+ // A deployment ships its OWN dedicated window AND its custom agent kind through the
46
+ // same slots the first-party modules use — no layer fork. A fake component (plain
47
+ // object) stands in for the SFC so this stays a pure registry test.
48
+ const fakeComponent = { name: 'AcmeReportWindow' }
49
+ registerAppModule(
50
+ defineModule({
51
+ id: 'consumer:acme',
52
+ version: '1.0.0',
53
+ slots: {
54
+ resultViews: [{ id: 'acme:report', component: fakeComponent }],
55
+ agentKinds: [
56
+ {
57
+ kind: 'acme-audit',
58
+ container: true,
59
+ presentation: {
60
+ label: 'Acme Audit',
61
+ icon: 'i-lucide-shield',
62
+ color: '#fff',
63
+ description: 'd',
64
+ resultView: 'acme:report',
65
+ },
66
+ },
67
+ ],
68
+ },
69
+ }),
70
+ )
71
+
72
+ const slots = createAppRegistry({ gates: NO_GATES }).resolveManifest().slots as {
73
+ resultViews: { id: string }[]
74
+ agentKinds: { kind: string; presentation: { resultView?: string } }[]
75
+ }
76
+ expect(slots.resultViews.map((v) => v.id)).toContain('acme:report')
77
+ const audit = slots.agentKinds.find((k) => k.kind === 'acme-audit')
78
+ // The custom kind's resultView id pairs against the consumer's own registered component.
79
+ expect(audit?.presentation.resultView).toBe('acme:report')
80
+ })
81
+
44
82
  it('merges a consumer-contributed nav item into the resolved nav slot', () => {
45
83
  // A deployment extending the layer contributes its own nav destination to the
46
84
  // SAME `nav` slot the first-party module fills — it then renders in every shell
@@ -2,7 +2,8 @@ import type { AnyModuleDescriptor } from '@modular-vue/core'
2
2
  import { createRegistry } from '@modular-vue/runtime'
3
3
  import type { ModuleRegistry } from '@modular-vue/runtime'
4
4
  import { navigationModule } from '~/modular/nav-contributions'
5
- import type { AppSlots, NavGates } from '~/modular/nav-contributions'
5
+ import type { NavGates } from '~/modular/nav-contributions'
6
+ import type { AppSlots } from '~/modular/slots'
6
7
 
7
8
  /**
8
9
  * modular-vue registry for the `@cat-factory/app` layer (slice 0 of the
@@ -70,23 +71,33 @@ export function __resetConsumerModulesForTest(): void {
70
71
  }
71
72
 
72
73
  /**
73
- * Build a fresh registry with the first-party modules plus everything a consumer
74
- * contributed via {@link registerAppModule}. A new registry per call because
75
- * `resolve()` / `resolveManifest()` are single-commit; the install plugin calls
76
- * 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).
77
79
  *
78
80
  * `deps.gates` is the reactive gate service the nav `slotFilter` reads; it's
79
81
  * built in the install plugin (Vue context) and registered as a `service` so
80
- * `useReactiveSlots` tracks it. The `nav` slot default is seeded empty so the
81
- * key always exists even before any module (or with only consumer modules)
82
- * contributes.
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).
83
91
  */
84
- export function createAppRegistry(deps: AppDeps): ModuleRegistry<AppDeps, AppSlots> {
92
+ export function createAppRegistry(
93
+ deps: AppDeps,
94
+ extraModules: readonly AnyModuleDescriptor[] = [],
95
+ ): ModuleRegistry<AppDeps, AppSlots> {
85
96
  const registry = createRegistry<AppDeps, AppSlots>({
86
97
  services: { gates: deps.gates },
87
- slots: { nav: [] },
98
+ slots: { nav: [], resultViews: [], agentKinds: [] },
88
99
  })
89
- for (const mod of [...FIRST_PARTY_MODULES, ...consumerModules]) {
100
+ for (const mod of [...FIRST_PARTY_MODULES, ...extraModules, ...consumerModules]) {
90
101
  registry.register(mod)
91
102
  }
92
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,7 +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'
3
4
  import { navSlotFilter } from '~/modular/nav-contributions'
4
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'
5
9
 
6
10
  /**
7
11
  * Wire the modular-vue registry into the Nuxt app (slice 0 of the modular-vue
@@ -26,15 +30,34 @@ import { createNavGates } from '~/modular/nav-gates'
26
30
  * plugin), which the registry registers as a `service` and `navSlotFilter` reads
27
31
  * per item. The shells consume the gated `nav` slot through `useReactiveSlots`,
28
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`.
29
41
  */
30
42
  export default defineNuxtPlugin({
31
43
  name: 'cat-factory:modular',
32
44
  enforce: 'post',
33
45
  setup(nuxtApp) {
34
- const registry = createAppRegistry({ gates: createNavGates() })
46
+ const registry = createAppRegistry({ gates: createNavGates() }, [resultViewsModule])
35
47
  const manifest = installModularApp({ vueApp: nuxtApp.vueApp, $router: useRouter() }, registry, {
36
48
  slotFilter: navSlotFilter,
37
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[])
38
61
  return { provide: { modular: manifest } }
39
62
  },
40
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
  }
@@ -1,3 +1,4 @@
1
+ import { shallowRef } from 'vue'
1
2
  import type {
2
3
  AgentArchetype,
3
4
  AgentCategory,
@@ -304,10 +305,44 @@ export function isProducerCompanion(kind: string): boolean {
304
305
  return COMPANION_KINDS.has(kind)
305
306
  }
306
307
 
307
- export const AGENT_BY_KIND: Record<AgentKind, AgentArchetype> = Object.fromEntries(
308
- [...AGENT_ARCHETYPES, ...COMPANION_ARCHETYPES].map((a) => [a.kind, a]),
308
+ /**
309
+ * The BUILT-IN palette + companion catalog, keyed by kind. Frozen and never
310
+ * mutated: custom (deployment/consumer) kinds no longer reach into this const —
311
+ * they flow through the modular `agentKinds` slot / the workspace-capability
312
+ * remote manifest and land in {@link customAgentKindMeta} instead (slice 2 of
313
+ * the modular-vue adoption). Freezing turns any stray write into a loud runtime
314
+ * error rather than silently conflating built-ins with custom kinds.
315
+ */
316
+ export const AGENT_BY_KIND: Record<AgentKind, AgentArchetype> = Object.freeze(
317
+ Object.fromEntries([...AGENT_ARCHETYPES, ...COMPANION_ARCHETYPES].map((a) => [a.kind, a])),
309
318
  ) as Record<AgentKind, AgentArchetype>
310
319
 
320
+ /**
321
+ * Reactive read-model of the deployment's CUSTOM agent kinds (consumer-slot +
322
+ * backend remote-manifest), kept in sync by the agents store from the resolved
323
+ * modular `agentKinds` slot. The slot/manifest is the source of truth; this
324
+ * `shallowRef` is its synchronous projection so the pure kind-meta lookups below
325
+ * ({@link agentKindMeta} / {@link isKnownAgentKind}) resolve a custom kind — and
326
+ * re-render when the catalog changes — WITHOUT importing the store (which would
327
+ * be circular) or mutating {@link AGENT_BY_KIND}. Empty until the store first
328
+ * populates it, so an unknown custom kind degrades to the generic fallback,
329
+ * exactly as before registration.
330
+ */
331
+ const customAgentKindMeta = shallowRef<Record<string, AgentArchetype>>({})
332
+
333
+ /**
334
+ * Replace the custom-kind projection (called only by the agents store, whenever
335
+ * its merged custom catalog changes). Whole-map replace, not per-key mutation.
336
+ */
337
+ export function setCustomAgentKindMeta(map: Record<string, AgentArchetype>): void {
338
+ customAgentKindMeta.value = map
339
+ }
340
+
341
+ /** Test-only: clear the custom-kind projection so a spec starts from built-ins only. */
342
+ export function __resetCustomAgentKindMetaForTest(): void {
343
+ customAgentKindMeta.value = {}
344
+ }
345
+
311
346
  /**
312
347
  * Agent kinds eligible for the optional consensus mechanism (the pipeline builder shows an
313
348
  * "Enable Consensus" toggle for these). Mirrors the backend default-eligible set assigned by
@@ -596,30 +631,36 @@ const FALLBACK_AGENT_META: Omit<AgentArchetype, 'kind'> = {
596
631
  }
597
632
 
598
633
  /**
599
- * Resolve display metadata for ANY agent kind — a palette archetype (incl. custom
600
- * agents registered into {@link AGENT_BY_KIND}), an engine system kind, or an
601
- * unknown one ALWAYS returning a usable icon/label/color. This is the single
602
- * lookup every pipeline / run renderer should use so a kind missing from the
603
- * archetype map (e.g. `ci`/`merger`/`blueprints` in a seeded pipeline) can never
604
- * blow up a component with an undefined access.
634
+ * Resolve display metadata for ANY agent kind — a built-in palette archetype
635
+ * ({@link AGENT_BY_KIND}), an engine system kind ({@link SYSTEM_AGENT_META}), a
636
+ * deployment CUSTOM kind (projected from the modular `agentKinds` slot into
637
+ * {@link customAgentKindMeta} by the agents store), or an unknown one ALWAYS
638
+ * returning a usable icon/label/color. This is the single lookup every pipeline
639
+ * / run renderer should use so a kind missing from the archetype map (e.g.
640
+ * `ci`/`merger`/`blueprints` in a seeded pipeline) can never blow up a component
641
+ * with an undefined access. Reading `customAgentKindMeta` reactively means a
642
+ * component computed re-runs when the custom catalog changes.
605
643
  */
606
644
  export function agentKindMeta(kind: string): AgentArchetype {
607
645
  return (
608
646
  AGENT_BY_KIND[kind as AgentKind] ??
609
- SYSTEM_AGENT_META[kind] ?? { kind: kind as AgentKind, ...FALLBACK_AGENT_META }
647
+ SYSTEM_AGENT_META[kind] ??
648
+ customAgentKindMeta.value[kind] ?? { kind: kind as AgentKind, ...FALLBACK_AGENT_META }
610
649
  )
611
650
  }
612
651
 
613
652
  /**
614
- * Whether an agent kind is actually known to this build — a palette archetype or companion
615
- * ({@link AGENT_BY_KIND}, which deployment custom kinds are merged into via
616
- * `useAgentsStore().registerCustomKinds`), or an engine system/gate kind
617
- * ({@link SYSTEM_AGENT_META}). Unlike {@link agentKindMeta} (which always returns a usable
618
- * fallback so renderers never crash), this returns `false` for an unknown kind — used to flag
619
- * a pipeline that references a nonexistent agent. Call AFTER custom kinds are registered.
653
+ * Whether an agent kind is actually known to this build — a built-in palette
654
+ * archetype or companion ({@link AGENT_BY_KIND}), an engine system/gate kind
655
+ * ({@link SYSTEM_AGENT_META}), or a deployment CUSTOM kind projected from the
656
+ * modular `agentKinds` slot ({@link customAgentKindMeta}). Unlike
657
+ * {@link agentKindMeta} (which always returns a usable fallback so renderers
658
+ * never crash), this returns `false` for an unknown kind used to flag a
659
+ * pipeline that references a nonexistent agent. Call AFTER the workspace
660
+ * snapshot has hydrated the custom kinds.
620
661
  */
621
662
  export function isKnownAgentKind(kind: string): boolean {
622
- return kind in AGENT_BY_KIND || kind in SYSTEM_AGENT_META
663
+ return kind in AGENT_BY_KIND || kind in SYSTEM_AGENT_META || kind in customAgentKindMeta.value
623
664
  }
624
665
 
625
666
  type BlockTypeMeta = { label: string; icon: string; accent: string }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.133.0",
3
+ "version": "0.134.0",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -18,8 +18,8 @@
18
18
  "access": "public"
19
19
  },
20
20
  "dependencies": {
21
- "@modular-frontend/core": "^0.1.0",
22
- "@modular-vue/core": "^1.0.1",
21
+ "@modular-frontend/core": "^0.2.0",
22
+ "@modular-vue/core": "^1.1.0",
23
23
  "@modular-vue/nuxt": "^0.1.1",
24
24
  "@modular-vue/runtime": "^1.1.0",
25
25
  "@modular-vue/vue": "^1.1.0",
@@ -39,7 +39,7 @@
39
39
  "valibot": "^1.4.2",
40
40
  "vue": "3.5.40",
41
41
  "wretch": "^3.0.9",
42
- "@cat-factory/contracts": "0.147.1"
42
+ "@cat-factory/contracts": "0.148.0"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@toad-contracts/testing": "0.3.2",