@cat-factory/app 0.147.7 → 0.148.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.
@@ -0,0 +1,77 @@
1
+ import type { RemoteModuleManifest } from '@modular-vue/core'
2
+ import type { CustomAgentKind, CustomTaskType } from '~/types/domain'
3
+ import type { AppSlots } from './slots'
4
+
5
+ /**
6
+ * The per-workspace capability manifest (modular-vue adoption slice 2 + the
7
+ * frontend-extension-mechanism initiative, slice B).
8
+ *
9
+ * A deployment's BACKEND-registered capabilities arrive in the workspace snapshot as wire data
10
+ * — `customAgentKinds` (slice 2) AND `customTaskTypes` (slice B). Rather than mutating
11
+ * module-global catalogs, BOTH are folded into ONE {@link RemoteModuleManifest} whose slots carry
12
+ * the data. cat-factory holds ONE active manifest per workspace (swapped on snapshot), so each
13
+ * store reads its OWN slot off the shared manifest — the sanctioned single-active-manifest shape
14
+ * (`mergeRemoteManifests` is for holding several at once, which we don't). CODE-shipped consumer
15
+ * capabilities instead enter via the static `agentKinds` / `taskTypes` slots (a `registerAppModule`
16
+ * module); each store merges its slot + its manifest half.
17
+ */
18
+
19
+ /** The stable id for the per-workspace capability manifest built from the snapshot. */
20
+ export const WORKSPACE_CAPABILITIES_MANIFEST_ID = 'cat-factory:workspace-capabilities'
21
+
22
+ /**
23
+ * A deterministic, key-order-independent content signature covering BOTH capability lists, used as
24
+ * the manifest `version`. The workspace snapshot re-delivers the SAME deployment capabilities on
25
+ * every board-event refresh (a full `workspace.refresh()` re-hydrates each time), so a
26
+ * content-derived version lets each store skip re-projecting an UNCHANGED catalog — otherwise every
27
+ * refresh would replace its read-model and needlessly invalidate every `agentKindMeta` /
28
+ * `taskTypeMeta` consumer. It still changes (and swaps wholesale) when a different workspace's
29
+ * capabilities genuinely differ. Serialized as fixed-order tuples of only the fields that affect
30
+ * display/pairing, so a re-serialization with reordered object keys can't spuriously differ.
31
+ */
32
+ export function workspaceCapabilitiesVersion(
33
+ kinds: readonly CustomAgentKind[],
34
+ taskTypes: readonly CustomTaskType[],
35
+ ): 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
+ ])
59
+ }
60
+
61
+ /**
62
+ * Model the snapshot's `customAgentKinds` + `customTaskTypes` as a single remote capability
63
+ * manifest. The `version` is a {@link workspaceCapabilitiesVersion content signature over both
64
+ * lists} so identical snapshots produce an identical manifest — which each store's
65
+ * `hydrateCapabilities` uses to no-op an unchanged re-hydrate. Swapped wholesale (not diffed) when
66
+ * the content genuinely changes.
67
+ */
68
+ export function buildWorkspaceCapabilitiesManifest(
69
+ kinds: readonly CustomAgentKind[],
70
+ taskTypes: readonly CustomTaskType[],
71
+ ): RemoteModuleManifest<AppSlots> {
72
+ return {
73
+ id: WORKSPACE_CAPABILITIES_MANIFEST_ID,
74
+ version: workspaceCapabilitiesVersion(kinds, taskTypes),
75
+ slots: { agentKinds: [...kinds], taskTypes: [...taskTypes] },
76
+ }
77
+ }
@@ -50,6 +50,8 @@ const slots = (): AppSlots => ({
50
50
  resultViews: [],
51
51
  agentKinds: [],
52
52
  inspectorPanels: [],
53
+ taskTypes: [],
54
+ taskTypeFormPanels: [],
53
55
  })
54
56
  const ids = (s: unknown) => (s as AppSlots).nav.map((i) => i.id)
55
57
 
@@ -101,7 +101,14 @@ export function createAppRegistry(
101
101
  // are registered from the client plugin via `extraModules` + `registerJourney`.
102
102
  const registry = createRegistry<AppDeps, AppSlots>({
103
103
  services: { gates: deps.gates },
104
- slots: { nav: [], resultViews: [], agentKinds: [], inspectorPanels: [] },
104
+ slots: {
105
+ nav: [],
106
+ resultViews: [],
107
+ agentKinds: [],
108
+ inspectorPanels: [],
109
+ taskTypes: [],
110
+ taskTypeFormPanels: [],
111
+ },
105
112
  }).use(journeysPlugin())
106
113
  for (const mod of [...FIRST_PARTY_MODULES, ...extraModules, ...consumerModules]) {
107
114
  registry.register(mod)
@@ -1,6 +1,6 @@
1
1
  import type { Component } from 'vue'
2
2
  import type { ComponentEntry, PanelEntry } from '@modular-vue/core'
3
- import type { Block, CustomAgentKind } from '~/types/domain'
3
+ import type { Block, CustomAgentKind, CustomTaskType } from '~/types/domain'
4
4
  import type { NavContribution } from './nav-contributions'
5
5
 
6
6
  /**
@@ -23,6 +23,14 @@ import type { NavContribution } from './nav-contributions'
23
23
  * predicate. Replaces `InspectorPanel.vue`'s level/type `v-if` fan; a consumer
24
24
  * contributes its own panels to the SAME slot via `registerAppModule`. This is
25
25
  * the slot key `definePanelGroup<Block>('inspectorPanels')` names.
26
+ * - `taskTypes` (extension slice B) — CODE-shipped custom task types a consumer
27
+ * module contributes (the create-task-picker + card-badge data half). BACKEND-
28
+ * registered types arrive separately in the shared capability manifest read by
29
+ * the task-types store — see `stores/taskTypes.ts`. Symmetric with `agentKinds`.
30
+ * - `taskTypeFormPanels` (extension slice B) — a bespoke create-task-form section
31
+ * per custom task type, addressed by the type's `formPanel` id and paired via
32
+ * `resolveComponentRegistry` (same shape as `resultViews`); shown INSTEAD of the
33
+ * descriptor-driven `fields`. An unpaired id degrades to the descriptor fields.
26
34
  *
27
35
  * The index signature is mutable (`unknown[]`) to satisfy the runtime's
28
36
  * `SlotMap` constraint while `unknown[]` still meets `useReactiveSlots`'
@@ -33,6 +41,8 @@ export interface AppSlots {
33
41
  resultViews: ResultViewContribution[]
34
42
  agentKinds: CustomAgentKind[]
35
43
  inspectorPanels: PanelEntry<Block>[]
44
+ taskTypes: CustomTaskType[]
45
+ taskTypeFormPanels: ResultViewContribution[]
36
46
  [key: string]: unknown[]
37
47
  }
38
48
 
@@ -43,5 +53,7 @@ export interface AppSlots {
43
53
  * helpers index and pair it with the wire-delivered `presentation.resultView`
44
54
  * id — the sanctioned "backend data selects a code-shipped, locally-registered
45
55
  * component" pairing (see the modular-vue Remote Capability Manifests guide).
56
+ * Reused for `taskTypeFormPanels` (a bespoke create-form section paired by a
57
+ * custom task type's `formPanel` id).
46
58
  */
47
59
  export type ResultViewContribution = ComponentEntry<Component>
@@ -0,0 +1,27 @@
1
+ import type { CustomTaskType, TaskTypeMeta } from '~/types/domain'
2
+
3
+ /**
4
+ * Custom task-type projection (frontend-extension-mechanism initiative, slice B — the frontend
5
+ * analogue of `customKindToArchetype` for agent kinds).
6
+ *
7
+ * A deployment's BACKEND-registered task types arrive in the workspace snapshot as
8
+ * `customTaskTypes` (wire data), folded into the shared per-workspace capability manifest (see
9
+ * `./capabilities.ts`). CODE-shipped consumer task types instead enter via the static `taskTypes`
10
+ * slot (a `registerAppModule` module); the task-types store merges both. This module holds only
11
+ * the wire→display projection they share.
12
+ */
13
+
14
+ /**
15
+ * Project a wire {@link CustomTaskType} onto the frontend's display {@link TaskTypeMeta}. A custom
16
+ * type carries a LITERAL label (from the wire presentation), unlike a built-in type whose label is
17
+ * an i18n key — so the projected meta sets `label` (not `labelKey`); the renderer resolves the
18
+ * display string accordingly (`labelKey ? t(labelKey) : label`).
19
+ */
20
+ export function customTaskTypeToMeta(t: CustomTaskType): TaskTypeMeta {
21
+ return {
22
+ taskType: t.taskType,
23
+ icon: t.presentation.icon,
24
+ color: t.presentation.color,
25
+ label: t.presentation.label,
26
+ }
27
+ }
@@ -16,7 +16,7 @@ import {
16
16
  environmentSetupPersistence,
17
17
  } from '~/modular/journeys/environmentSetup'
18
18
  import type { AppSlots, ResultViewContribution } from '~/modular/slots'
19
- import type { Block, CustomAgentKind } from '~/types/domain'
19
+ import type { Block, CustomAgentKind, CustomTaskType } from '~/types/domain'
20
20
 
21
21
  /**
22
22
  * Wire the modular-vue registry into the Nuxt app (slice 0 of the modular-vue
@@ -98,9 +98,14 @@ export default defineNuxtPlugin({
98
98
  // selected. `<PanelsOutlet>` re-resolves reactively per subject; this only
99
99
  // validates the wiring.
100
100
  resolvePanels((slots.inspectorPanels ?? []) as PanelEntry<Block>[], null)
101
- // Consumer agent kinds contributed as CODE to the static `agentKinds` slot
102
- // (module slots resolve once, so the static base is the full set).
101
+ // Same fail-fast for the custom-task-type form-panel registry (extension slice B): a
102
+ // duplicate `formPanel` id across the first-party + consumer modules throws at BOOT
103
+ // rather than the first time the create-task form opens a custom type's section.
104
+ resolveComponentRegistry((slots.taskTypeFormPanels ?? []) as ResultViewContribution[])
105
+ // Consumer agent kinds + task types contributed as CODE to the static `agentKinds` /
106
+ // `taskTypes` slots (module slots resolve once, so the static base is the full set).
103
107
  useAgentsStore().registerConsumerKinds((slots.agentKinds ?? []) as CustomAgentKind[])
108
+ useTaskTypesStore().registerConsumerTaskTypes((slots.taskTypes ?? []) as CustomTaskType[])
104
109
  return { provide: { modular: manifest } }
105
110
  },
106
111
  })
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { useAgentsStore } from './agents'
3
+ import { buildWorkspaceCapabilitiesManifest } from '~/modular/capabilities'
3
4
  import {
4
5
  __resetCustomAgentKindMetaForTest,
5
6
  agentKindMeta,
@@ -23,6 +24,11 @@ const backendKind = (
23
24
  },
24
25
  })
25
26
 
27
+ /** Hydrate the store's backend-manifest half with `kinds` (via the shared capability manifest). */
28
+ const hydrate = (agents: ReturnType<typeof useAgentsStore>, kinds: CustomAgentKind[]): void => {
29
+ agents.hydrateCapabilities(buildWorkspaceCapabilitiesManifest(kinds, []))
30
+ }
31
+
26
32
  describe('agents store — custom-kind catalog (slice 2)', () => {
27
33
  it('exposes only the built-in archetypes before any custom kind is hydrated', () => {
28
34
  const agents = useAgentsStore()
@@ -37,9 +43,7 @@ describe('agents store — custom-kind catalog (slice 2)', () => {
37
43
  expect(isKnownAgentKind('acme-audit')).toBe(false)
38
44
  expect(agentKindMeta('acme-audit').label).toBe('Agent') // generic fallback
39
45
 
40
- agents.hydrateCustomKinds([
41
- backendKind('acme-audit', { category: 'review', resultView: 'acme:audit' }),
42
- ])
46
+ hydrate(agents, [backendKind('acme-audit', { category: 'review', resultView: 'acme:audit' })])
43
47
 
44
48
  // Palette + kind lookup both see it now (sync-flush projection — no tick needed).
45
49
  expect(agents.archetypes.some((a) => a.kind === 'acme-audit')).toBe(true)
@@ -52,7 +56,7 @@ describe('agents store — custom-kind catalog (slice 2)', () => {
52
56
 
53
57
  it('never lets a custom kind shadow a built-in kind', () => {
54
58
  const agents = useAgentsStore()
55
- agents.hydrateCustomKinds([backendKind('coder', { label: 'Evil Coder' })])
59
+ hydrate(agents, [backendKind('coder', { label: 'Evil Coder' })])
56
60
  // `coder` is a built-in; the custom entry is dropped, the built-in wins.
57
61
  expect(agents.customArchetypes.some((a) => a.kind === 'coder')).toBe(false)
58
62
  expect(agentKindMeta('coder').label).not.toBe('Evil Coder')
@@ -61,7 +65,7 @@ describe('agents store — custom-kind catalog (slice 2)', () => {
61
65
  it('merges CODE-shipped consumer kinds with BACKEND manifest kinds, de-duplicated', () => {
62
66
  const agents = useAgentsStore()
63
67
  agents.registerConsumerKinds([backendKind('acme-consumer')])
64
- agents.hydrateCustomKinds([backendKind('acme-backend'), backendKind('acme-consumer')])
68
+ hydrate(agents, [backendKind('acme-backend'), backendKind('acme-consumer')])
65
69
  const kinds = agents.customArchetypes.map((a) => a.kind)
66
70
  expect(kinds).toContain('acme-consumer')
67
71
  expect(kinds).toContain('acme-backend')
@@ -71,20 +75,20 @@ describe('agents store — custom-kind catalog (slice 2)', () => {
71
75
 
72
76
  it('no-ops a re-hydrate of identical content (stable projection, content-versioned manifest)', () => {
73
77
  const agents = useAgentsStore()
74
- agents.hydrateCustomKinds([backendKind('acme-x', { resultView: 'acme:x' })])
78
+ hydrate(agents, [backendKind('acme-x', { resultView: 'acme:x' })])
75
79
  const first = agents.customArchetypes
76
80
  // A new snapshot re-delivering structurally-equal kinds (fresh objects) must not
77
81
  // recompute the merged catalog — the content-derived manifest version is unchanged, so
78
82
  // `hydrateCustomKinds` skips the swap and the computed keeps its cached identity.
79
- agents.hydrateCustomKinds([backendKind('acme-x', { resultView: 'acme:x' })])
83
+ hydrate(agents, [backendKind('acme-x', { resultView: 'acme:x' })])
80
84
  expect(agents.customArchetypes).toBe(first)
81
85
  })
82
86
 
83
87
  it('swaps the backend catalog wholesale on re-hydrate (per-workspace manifest)', () => {
84
88
  const agents = useAgentsStore()
85
- agents.hydrateCustomKinds([backendKind('ws1-kind')])
89
+ hydrate(agents, [backendKind('ws1-kind')])
86
90
  expect(agents.customArchetypes.some((a) => a.kind === 'ws1-kind')).toBe(true)
87
- agents.hydrateCustomKinds([backendKind('ws2-kind')])
91
+ hydrate(agents, [backendKind('ws2-kind')])
88
92
  expect(agents.customArchetypes.some((a) => a.kind === 'ws1-kind')).toBe(false)
89
93
  expect(agents.customArchetypes.some((a) => a.kind === 'ws2-kind')).toBe(true)
90
94
  expect(isKnownAgentKind('ws1-kind')).toBe(false)
@@ -1,7 +1,7 @@
1
1
  import { defineStore } from 'pinia'
2
2
  import { computed, ref, watch } from 'vue'
3
3
  import type { RemoteModuleManifest } from '@modular-vue/core'
4
- import { buildAgentCapabilitiesManifest, customKindToArchetype } from '~/modular/agent-kinds'
4
+ import { customKindToArchetype } from '~/modular/agent-kinds'
5
5
  import type { AppSlots } from '~/modular/slots'
6
6
  import {
7
7
  AGENT_ARCHETYPES,
@@ -21,9 +21,9 @@ import type { AgentArchetype, AgentKind, CustomAgentKind } from '~/types/domain'
21
21
  * - the built-in archetypes (static);
22
22
  * - CONSUMER-shipped kinds contributed as CODE via the modular `agentKinds`
23
23
  * slot (`registerConsumerKinds`, fed once at boot from the resolved manifest);
24
- * - the deployment's BACKEND-registered kinds, modeled as a single
24
+ * - the deployment's BACKEND-registered kinds, read from the shared per-workspace
25
25
  * {@link RemoteModuleManifest} swapped per workspace snapshot
26
- * (`hydrateCustomKinds`) and read directly (single-active-manifest shape).
26
+ * (`hydrateCapabilities`, reading its own `agentKinds` slot single-active-manifest shape).
27
27
  *
28
28
  * The merged custom catalog is projected back into `catalog.ts`'s
29
29
  * {@link setCustomAgentKindMeta} read-model so the pure `agentKindMeta` /
@@ -77,7 +77,7 @@ export const useAgentsStore = defineStore('agents', () => {
77
77
 
78
78
  // Keep `catalog.ts`'s pure-util projection in sync with the merged custom
79
79
  // catalog so `agentKindMeta` / `isKnownAgentKind` resolve custom kinds. Sync
80
- // flush so an imperative read right after `hydrateCustomKinds` (e.g. the run
80
+ // flush so an imperative read right after `hydrateCapabilities` (e.g. the run
81
81
  // dispatch resolving a custom kind's `resultView`) sees the fresh catalog with
82
82
  // no tick gap. The watch lives in the store's effect scope (disposed with it).
83
83
  watch(customByKind, (map) => setCustomAgentKindMeta(map), { immediate: true, flush: 'sync' })
@@ -119,20 +119,19 @@ export const useAgentsStore = defineStore('agents', () => {
119
119
  }
120
120
 
121
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.
122
+ * Hydrate the deployment's BACKEND-registered custom kinds from the shared per-workspace
123
+ * capability manifest (built by the workspace store from the snapshot, carrying both `agentKinds`
124
+ * + `taskTypes`; this store reads only its own `agentKinds` slot). Swapped wholesale per
125
+ * workspace. Replaces the old `registerCustomKinds` that mutated {@link AGENT_BY_KIND} directly.
126
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.
127
+ * The snapshot re-delivers the same deployment kinds on every board refresh, so skip the swap —
128
+ * and the downstream projection invalidation of every `agentKindMeta` consumer — when the
129
+ * content-derived manifest version is unchanged. A genuinely different workspace's capabilities
130
+ * change the version and swap.
131
131
  */
132
- function hydrateCustomKinds(kinds: CustomAgentKind[]) {
133
- const next = buildAgentCapabilitiesManifest(kinds)
134
- if (capabilitiesManifest.value?.version === next.version) return
135
- capabilitiesManifest.value = next
132
+ function hydrateCapabilities(manifest: RemoteModuleManifest<AppSlots>) {
133
+ if (capabilitiesManifest.value?.version === manifest.version) return
134
+ capabilitiesManifest.value = manifest
136
135
  }
137
136
 
138
137
  return {
@@ -141,6 +140,6 @@ export const useAgentsStore = defineStore('agents', () => {
141
140
  get,
142
141
  addAgent,
143
142
  registerConsumerKinds,
144
- hydrateCustomKinds,
143
+ hydrateCapabilities,
145
144
  }
146
145
  })
@@ -0,0 +1,100 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { useTaskTypesStore } from './taskTypes'
3
+ import { buildWorkspaceCapabilitiesManifest } from '~/modular/capabilities'
4
+ import { __resetCustomTaskTypeMetaForTest, isKnownTaskType, taskTypeMeta } from '~/utils/catalog'
5
+ import type { CustomTaskType } from '~/types/domain'
6
+
7
+ const backendType = (
8
+ taskType: string,
9
+ over: Partial<CustomTaskType['presentation']> = {},
10
+ ): CustomTaskType => ({
11
+ taskType,
12
+ presentation: {
13
+ label: `L:${taskType}`,
14
+ icon: 'i-lucide-siren',
15
+ color: '#ef4444',
16
+ description: 'd',
17
+ ...over,
18
+ },
19
+ })
20
+
21
+ /** Hydrate the store's backend-manifest half with `taskTypes` (via the shared capability manifest). */
22
+ const hydrate = (
23
+ store: ReturnType<typeof useTaskTypesStore>,
24
+ taskTypes: CustomTaskType[],
25
+ ): void => {
26
+ store.hydrateCapabilities(buildWorkspaceCapabilitiesManifest([], taskTypes))
27
+ }
28
+
29
+ describe('taskTypes store — custom task-type catalog (extension slice B)', () => {
30
+ it('exposes no custom types before any hydrate', () => {
31
+ const store = useTaskTypesStore()
32
+ expect(store.customTaskTypes).toEqual([])
33
+ })
34
+
35
+ it('folds a backend remote-manifest custom type into the catalog + the pure-util projection', () => {
36
+ __resetCustomTaskTypeMetaForTest()
37
+ const store = useTaskTypesStore()
38
+ // Before hydrate the pure-util lookups don't know the type; taskTypeMeta degrades to `feature`.
39
+ expect(isKnownTaskType('acme:incident')).toBe(false)
40
+ const before = taskTypeMeta('acme:incident')
41
+ expect(before.icon).toBe(taskTypeMeta('feature').icon) // feature fallback icon
42
+ expect(before.label).toBe('acme:incident') // raw id, never blank
43
+
44
+ hydrate(store, [backendType('acme:incident', { label: 'Incident' })])
45
+
46
+ // Catalog + lookup both see it now (sync-flush projection — no tick needed).
47
+ expect(store.customTaskTypes.some((t) => t.taskType === 'acme:incident')).toBe(true)
48
+ expect(isKnownTaskType('acme:incident')).toBe(true)
49
+ const meta = taskTypeMeta('acme:incident')
50
+ expect(meta.label).toBe('Incident')
51
+ expect(meta.labelKey).toBeUndefined() // custom types carry a literal label, not an i18n key
52
+ })
53
+
54
+ it('never lets a custom type shadow a built-in type', () => {
55
+ const store = useTaskTypesStore()
56
+ hydrate(store, [backendType('feature', { label: 'Evil Feature' })])
57
+ expect(store.customTaskTypes.some((t) => t.taskType === 'feature')).toBe(false)
58
+ expect(taskTypeMeta('feature').labelKey).toBe('board.addTask.types.feature')
59
+ })
60
+
61
+ it('merges CODE-shipped consumer types with BACKEND manifest types, de-duplicated', () => {
62
+ const store = useTaskTypesStore()
63
+ store.registerConsumerTaskTypes([backendType('acme:consumer')])
64
+ hydrate(store, [backendType('acme:backend'), backendType('acme:consumer')])
65
+ const ids = store.customTaskTypes.map((t) => t.taskType)
66
+ expect(ids).toContain('acme:consumer')
67
+ expect(ids).toContain('acme:backend')
68
+ expect(ids.filter((k) => k === 'acme:consumer')).toHaveLength(1)
69
+ })
70
+
71
+ it('no-ops a re-hydrate of identical content (content-versioned manifest)', () => {
72
+ const store = useTaskTypesStore()
73
+ hydrate(store, [backendType('acme:x')])
74
+ const first = store.customTaskTypes
75
+ hydrate(store, [backendType('acme:x')])
76
+ expect(store.customTaskTypes).toBe(first)
77
+ })
78
+
79
+ it('swaps the backend catalog wholesale on re-hydrate (per-workspace manifest)', () => {
80
+ const store = useTaskTypesStore()
81
+ hydrate(store, [backendType('acme:ws1')])
82
+ expect(store.customTaskTypes.some((t) => t.taskType === 'acme:ws1')).toBe(true)
83
+ hydrate(store, [backendType('acme:ws2')])
84
+ expect(store.customTaskTypes.some((t) => t.taskType === 'acme:ws1')).toBe(false)
85
+ expect(store.customTaskTypes.some((t) => t.taskType === 'acme:ws2')).toBe(true)
86
+ expect(isKnownTaskType('acme:ws1')).toBe(false)
87
+ })
88
+
89
+ it('get() returns the full registration (for the create-form field descriptors)', () => {
90
+ const store = useTaskTypesStore()
91
+ hydrate(store, [
92
+ {
93
+ ...backendType('acme:incident'),
94
+ fields: [{ key: 'sev', label: 'Severity', type: 'text' }],
95
+ },
96
+ ])
97
+ expect(store.get('acme:incident')?.fields?.[0]?.key).toBe('sev')
98
+ expect(store.get('acme:unknown')).toBeUndefined()
99
+ })
100
+ })
@@ -0,0 +1,88 @@
1
+ import { defineStore } from 'pinia'
2
+ import { computed, ref, watch } from 'vue'
3
+ import type { RemoteModuleManifest } from '@modular-vue/core'
4
+ import { customTaskTypeToMeta } from '~/modular/task-types'
5
+ import type { AppSlots } from '~/modular/slots'
6
+ import { TASK_TYPE_META, setCustomTaskTypeMeta } from '~/utils/catalog'
7
+ import type { CustomTaskType, TaskTypeMeta } from '~/types/domain'
8
+
9
+ /**
10
+ * The custom task-type catalog (frontend-extension-mechanism initiative, slice B) — the exact twin
11
+ * of the agents store (`stores/agents.ts`), which is the template for every capability catalog.
12
+ *
13
+ * Reactive union of two sources, neither mutating the built-in {@link TASK_TYPE_META} const:
14
+ * - CONSUMER-shipped task types contributed as CODE via the modular `taskTypes` slot
15
+ * (`registerConsumerTaskTypes`, fed once at boot from the resolved manifest);
16
+ * - the deployment's BACKEND-registered task types, folded into the single per-workspace
17
+ * {@link RemoteModuleManifest} the workspace store swaps per snapshot (`hydrateCapabilities`,
18
+ * reading its OWN `taskTypes` slot off the shared manifest).
19
+ *
20
+ * The merged custom catalog is projected back into `catalog.ts`'s {@link setCustomTaskTypeMeta}
21
+ * read-model so the pure `taskTypeMeta` lookup (used by the card badge + create-task picker)
22
+ * resolves a custom type reactively without importing this store.
23
+ */
24
+ export const useTaskTypesStore = defineStore('taskTypes', () => {
25
+ // CODE-shipped consumer task types from the static `taskTypes` slot (fed once at boot by the
26
+ // modular install plugin — module slots are resolved once).
27
+ const consumerTaskTypes = ref<CustomTaskType[]>([])
28
+ // The active per-workspace capability manifest (shared with the agents store), or null before
29
+ // the first hydrate. This store reads only its own `taskTypes` slot off it.
30
+ const capabilitiesManifest = ref<RemoteModuleManifest<AppSlots> | null>(null)
31
+
32
+ /**
33
+ * The merged CUSTOM task types (consumer-slot → backend-manifest), de-duplicated and never
34
+ * shadowing a BUILT-IN type. A namespaced custom id can't collide with a bare built-in, but the
35
+ * built-in drop is kept for parity with the agents store (and to be robust to a malformed id).
36
+ */
37
+ const customTaskTypes = computed<CustomTaskType[]>(() => {
38
+ const seen = new Set<string>()
39
+ const out: CustomTaskType[] = []
40
+ const add = (t: CustomTaskType) => {
41
+ if (t.taskType in TASK_TYPE_META || seen.has(t.taskType)) return
42
+ seen.add(t.taskType)
43
+ out.push(t)
44
+ }
45
+ for (const t of consumerTaskTypes.value) add(t)
46
+ for (const t of capabilitiesManifest.value?.slots?.taskTypes ?? []) add(t)
47
+ return out
48
+ })
49
+
50
+ /** The custom types indexed by id, for a per-type lookup (e.g. the create-form field descriptors). */
51
+ const byTaskType = computed<Record<string, CustomTaskType>>(() =>
52
+ Object.fromEntries(customTaskTypes.value.map((t) => [t.taskType, t])),
53
+ )
54
+
55
+ // Keep `catalog.ts`'s pure-util projection in sync with the merged custom catalog so
56
+ // `taskTypeMeta` resolves custom types. Sync flush so an imperative read right after
57
+ // `hydrateCapabilities` sees the fresh catalog with no tick gap (mirrors the agents store).
58
+ const customMetaMap = computed<Record<string, TaskTypeMeta>>(() =>
59
+ Object.fromEntries(customTaskTypes.value.map((t) => [t.taskType, customTaskTypeToMeta(t)])),
60
+ )
61
+ watch(customMetaMap, (map) => setCustomTaskTypeMeta(map), { immediate: true, flush: 'sync' })
62
+
63
+ /** The registered custom task type for `taskType`, or undefined. */
64
+ function get(taskType: string): CustomTaskType | undefined {
65
+ return byTaskType.value[taskType]
66
+ }
67
+
68
+ /**
69
+ * Register the deployment's CODE-shipped consumer task types — the resolved modular `taskTypes`
70
+ * slot, fed once by the install plugin. Idempotent replace (module slots resolve once).
71
+ */
72
+ function registerConsumerTaskTypes(types: readonly CustomTaskType[]) {
73
+ consumerTaskTypes.value = [...types]
74
+ }
75
+
76
+ /**
77
+ * Hydrate the deployment's BACKEND-registered capabilities from the shared per-workspace manifest
78
+ * (built by the workspace store from the snapshot, carrying both `agentKinds` + `taskTypes`).
79
+ * Skips the swap — and the downstream projection invalidation — when the content-derived manifest
80
+ * version is unchanged, exactly like the agents store.
81
+ */
82
+ function hydrateCapabilities(manifest: RemoteModuleManifest<AppSlots>) {
83
+ if (capabilitiesManifest.value?.version === manifest.version) return
84
+ capabilitiesManifest.value = manifest
85
+ }
86
+
87
+ return { customTaskTypes, get, registerConsumerTaskTypes, hydrateCapabilities }
88
+ })
@@ -25,6 +25,8 @@ import { useRecurringPipelinesStore } from '~/stores/recurringPipelines'
25
25
  import { useInitiativesStore } from '~/stores/initiative'
26
26
  import { useServicesStore } from '~/stores/services'
27
27
  import { useAgentsStore } from '~/stores/agents'
28
+ import { useTaskTypesStore } from '~/stores/taskTypes'
29
+ import { buildWorkspaceCapabilitiesManifest } from '~/modular/capabilities'
28
30
  import { useSkillsStore } from '~/stores/skills'
29
31
  import { useTrackerStore } from '~/stores/tracker'
30
32
  import { useRequirementsStore } from '~/stores/requirements'
@@ -169,10 +171,18 @@ export const useWorkspaceStore = defineStore(
169
171
  useInitiativesStore().hydratePresets(snapshot.initiativePresets)
170
172
  useTrackerStore().hydrate(snapshot.trackerSettings)
171
173
  useServicesStore().hydrate(snapshot.mounts ?? [], snapshot.serviceCatalog ?? [])
172
- // Hydrate the deployment's backend-registered custom agent kinds as the workspace's
173
- // remote capability manifest, so a proprietary kind renders as a first-class palette
174
- // block + result view. Swapped wholesale per workspace (no global-catalog mutation).
175
- useAgentsStore().hydrateCustomKinds(snapshot.customAgentKinds ?? [])
174
+ // Hydrate the deployment's backend-registered capabilities from ONE shared per-workspace
175
+ // remote capability manifest carrying BOTH custom agent kinds AND custom task types (its
176
+ // version covers both, so an unchanged snapshot no-ops both stores). Each store reads its
177
+ // own slot off it, so a proprietary agent kind renders as a first-class palette block +
178
+ // result view and a proprietary task type as a first-class create-task choice + card badge.
179
+ // Swapped wholesale per workspace (no global-catalog mutation).
180
+ const capabilities = buildWorkspaceCapabilitiesManifest(
181
+ snapshot.customAgentKinds ?? [],
182
+ snapshot.customTaskTypes ?? [],
183
+ )
184
+ useAgentsStore().hydrateCapabilities(capabilities)
185
+ useTaskTypesStore().hydrateCapabilities(capabilities)
176
186
  // The account's repo-sourced Claude Skills catalog (shared across its workspaces), so the
177
187
  // pipeline builder's per-step skill picker has its options. A straight replace.
178
188
  useSkillsStore().hydrate(snapshot.skills ?? [])
@@ -59,6 +59,10 @@ export type {
59
59
  AgentKind,
60
60
  AgentCategory,
61
61
  CustomAgentKind,
62
+ CustomTaskType,
63
+ TaskTypePresentation,
64
+ TaskTypeFieldDescriptor,
65
+ TaskTypeFieldOption,
62
66
  Pipeline,
63
67
  PipelinePurpose,
64
68
  SpendStatus,
@@ -117,6 +121,25 @@ export interface AgentArchetype {
117
121
  resultView?: string
118
122
  }
119
123
 
124
+ /**
125
+ * Display metadata for a task TYPE (the card badge + create-task picker), resolved through the
126
+ * `taskTypeMeta` read-model. A BUILT-IN type carries an i18n {@link labelKey}; a CUSTOM
127
+ * (deployment-registered) type carries a literal {@link label} from the wire presentation. The
128
+ * renderer resolves the display string as `labelKey ? t(labelKey) : label`. Frontend-only.
129
+ */
130
+ export interface TaskTypeMeta {
131
+ /** The task type id this meta describes. */
132
+ taskType: string
133
+ /** iconify name (lucide). */
134
+ icon: string
135
+ /** tailwind-ish accent token used across the card badge / picker. */
136
+ color: string
137
+ /** i18n key for a BUILT-IN type's label; absent for a custom type. */
138
+ labelKey?: string
139
+ /** Literal label for a CUSTOM type (from the wire presentation); absent for a built-in. */
140
+ label?: string
141
+ }
142
+
120
143
  /** Level-of-detail buckets driven by the canvas zoom level. Shallow → deep:
121
144
  * `far`/`mid`/`close` govern a service frame (chip → card → opened with tasks);
122
145
  * `steps`/`subtasks` drill spatially into an individual task — revealing its