@cat-factory/app 0.147.7 → 0.149.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.
Files changed (40) hide show
  1. package/app/components/board/AddTaskModal.vue +146 -3
  2. package/app/components/board/nodes/TaskCard.vue +29 -1
  3. package/app/components/fragments/FragmentLibraryManager.vue +188 -18
  4. package/app/components/panels/AgentStepDetail.vue +11 -0
  5. package/app/components/panels/StepEffortReport.vue +86 -0
  6. package/app/components/panels/StepFragmentAdherence.vue +85 -0
  7. package/app/components/prReview/PrReviewWindow.vue +8 -0
  8. package/app/components/settings/WorkspaceSettingsPanel.vue +14 -8
  9. package/app/composables/api/fragments.ts +17 -0
  10. package/app/docs/consumer-extensions.md +41 -8
  11. package/app/modular/agent-kinds.spec.ts +1 -39
  12. package/app/modular/agent-kinds.ts +7 -58
  13. package/app/modular/capabilities.spec.ts +88 -0
  14. package/app/modular/capabilities.ts +77 -0
  15. package/app/modular/nav-contributions.spec.ts +2 -0
  16. package/app/modular/registry.ts +8 -1
  17. package/app/modular/slots.ts +13 -1
  18. package/app/modular/task-types.ts +27 -0
  19. package/app/plugins/modular.client.ts +8 -3
  20. package/app/stores/agents.spec.ts +13 -9
  21. package/app/stores/agents.ts +16 -17
  22. package/app/stores/fragmentLibrary.ts +16 -0
  23. package/app/stores/taskTypes.spec.ts +100 -0
  24. package/app/stores/taskTypes.ts +88 -0
  25. package/app/stores/workspace.ts +14 -4
  26. package/app/types/domain.ts +23 -0
  27. package/app/types/execution.ts +3 -0
  28. package/app/types/fragments.ts +2 -0
  29. package/app/utils/catalog.ts +107 -0
  30. package/i18n/locales/de.json +23 -4
  31. package/i18n/locales/en.json +23 -4
  32. package/i18n/locales/es.json +23 -4
  33. package/i18n/locales/fr.json +23 -4
  34. package/i18n/locales/he.json +23 -4
  35. package/i18n/locales/it.json +23 -4
  36. package/i18n/locales/ja.json +23 -4
  37. package/i18n/locales/pl.json +23 -4
  38. package/i18n/locales/tr.json +23 -4
  39. package/i18n/locales/uk.json +23 -4
  40. package/package.json +2 -2
@@ -0,0 +1,85 @@
1
+ <script setup lang="ts">
2
+ // A code/PR review step's best-practice adherence report, surfaced in run details: for each
3
+ // best-practice standard (prompt fragment) folded into the reviewer's prompt, how well the reviewed
4
+ // change adheres (1..10) and the issues that standard surfaced. Recorded on the step
5
+ // (`step.fragmentAdherence`) by the engine from the review agent's output. Rendered only when the
6
+ // reviewer reported at least one standard; when none were reachable the reviewer says so in its
7
+ // summary instead (so there is nothing to show here).
8
+ import type { FragmentAdherence } from '~/types/execution'
9
+
10
+ const props = defineProps<{ items: FragmentAdherence }>()
11
+ const { t } = useI18n()
12
+
13
+ /** Rating band → bar colour: poor adherence (rose) → partial (amber) → strong (emerald). */
14
+ function ratingClass(rating: number): string {
15
+ return rating >= 8 ? 'bg-emerald-400' : rating >= 5 ? 'bg-amber-400' : 'bg-rose-400'
16
+ }
17
+ function ratingPct(rating: number): number {
18
+ return Math.min(100, Math.max(0, (rating / 10) * 100))
19
+ }
20
+ /** The label the reviewer was asked to cite the standard by: its title, else its id. */
21
+ function label(item: FragmentAdherence[number]): string {
22
+ return item.title?.trim() || item.fragmentId || t('panels.stepDetail.adherence.unnamed')
23
+ }
24
+ </script>
25
+
26
+ <template>
27
+ <section
28
+ v-if="items.length"
29
+ data-testid="step-fragment-adherence"
30
+ class="scroll-mt-4 rounded-xl border border-slate-800 bg-slate-900/50 p-4"
31
+ >
32
+ <div
33
+ class="mb-2 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-400"
34
+ >
35
+ <UIcon name="i-lucide-clipboard-check" class="h-3.5 w-3.5" />
36
+ <span>{{ t('panels.stepDetail.adherence.heading') }}</span>
37
+ </div>
38
+
39
+ <div class="space-y-2.5">
40
+ <article
41
+ v-for="(item, i) in items"
42
+ :key="item.fragmentId ?? i"
43
+ data-testid="step-fragment-adherence-item"
44
+ class="rounded-lg border border-slate-800 bg-slate-900/60 px-3 py-2"
45
+ >
46
+ <div class="flex items-center gap-2">
47
+ <span class="min-w-0 flex-1 truncate text-[13px] font-medium text-slate-100">{{
48
+ label(item)
49
+ }}</span>
50
+ <div class="h-1.5 w-16 shrink-0 overflow-hidden rounded-full bg-slate-700/60">
51
+ <div
52
+ class="h-full rounded-full"
53
+ :class="ratingClass(item.rating)"
54
+ :style="{ width: `${ratingPct(item.rating)}%` }"
55
+ />
56
+ </div>
57
+ <span class="shrink-0 text-[12px] font-medium text-slate-200">
58
+ {{ t('panels.stepDetail.adherence.outOfTen', { value: item.rating }) }}
59
+ </span>
60
+ </div>
61
+ <p
62
+ v-if="item.assessment"
63
+ class="mt-1 whitespace-pre-wrap text-[12px] leading-relaxed text-slate-300"
64
+ >
65
+ {{ item.assessment }}
66
+ </p>
67
+ <div v-if="item.relatedFindings.length" class="mt-1.5">
68
+ <p class="text-[10px] font-semibold uppercase tracking-wide text-slate-500">
69
+ {{ t('panels.stepDetail.adherence.relatedFindings') }}
70
+ </p>
71
+ <ul class="mt-0.5 space-y-0.5">
72
+ <li
73
+ v-for="(finding, fi) in item.relatedFindings"
74
+ :key="fi"
75
+ class="flex items-start gap-1.5 text-[11px] text-slate-400"
76
+ >
77
+ <UIcon name="i-lucide-dot" class="mt-0.5 h-3.5 w-3.5 shrink-0" />
78
+ <span>{{ finding }}</span>
79
+ </li>
80
+ </ul>
81
+ </div>
82
+ </article>
83
+ </div>
84
+ </section>
85
+ </template>
@@ -466,6 +466,14 @@ async function onDismiss(id: string): Promise<void> {
466
466
  {{ state.summary }}
467
467
  </p>
468
468
 
469
+ <!-- Best-practice adherence: per standard folded into the reviewer's prompt, a 1..10
470
+ rating of how well the PR adheres + the issues that standard surfaced. -->
471
+ <StepFragmentAdherence
472
+ v-if="step?.fragmentAdherence?.length"
473
+ :items="step.fragmentAdherence"
474
+ class="mb-3"
475
+ />
476
+
469
477
  <!-- A clean PR / resolved review with no findings. -->
470
478
  <div
471
479
  v-if="findings.length === 0"
@@ -8,7 +8,7 @@
8
8
  // The latter three are body-only section components rendered in tabs here (no longer
9
9
  // standalone modals).
10
10
  import { reactive, ref, watch } from 'vue'
11
- import type { CreateTaskType, TaskLimitMode } from '~/types/domain'
11
+ import type { TaskLimitMode } from '~/types/domain'
12
12
  import RiskPolicyPanel from '~/components/settings/RiskPolicyPanel.vue'
13
13
  import IssueTrackerPanel from '~/components/settings/IssueTrackerPanel.vue'
14
14
  import ServiceFragmentDefaultsPanel from '~/components/settings/ServiceFragmentDefaultsPanel.vue'
@@ -103,12 +103,18 @@ const tabsUi = {
103
103
  indicator: 'hidden',
104
104
  }
105
105
 
106
- const TASK_TYPES: CreateTaskType[] = ['feature', 'bug', 'document', 'spike']
106
+ // The finite BUILT-IN create-task types this per-type running-task-limit UI configures.
107
+ // `CreateTaskType` widened to an open `string` (to admit consumer-registered CUSTOM task types),
108
+ // so the config surface pins the built-in set it renders inputs for — a custom type buckets on its
109
+ // own id server-side (`RunAdmission`) and is not configured here. Keying the records below off this
110
+ // finite union (not the open `CreateTaskType`) keeps them exhaustive + undefined-free.
111
+ type LimitTaskType = 'feature' | 'bug' | 'document' | 'spike' | 'review' | 'ralph'
112
+ const TASK_TYPES: LimitTaskType[] = ['feature', 'bug', 'document', 'spike']
107
113
 
108
114
  // Per-task-type label for the "Max {type} tasks" inputs. An exhaustive Record keyed off
109
- // the CreateTaskType union (a missing member fails the typecheck); each value is a LITERAL
110
- // catalog key so the typed-message-keys check sees it. Leaf keys mirror the enum verbatim.
111
- const TASK_TYPE_KEYS: Record<CreateTaskType, string> = {
115
+ // the finite {@link LimitTaskType} union (a missing member fails the typecheck); each value is a
116
+ // LITERAL catalog key so the typed-message-keys check sees it. Leaf keys mirror the enum verbatim.
117
+ const TASK_TYPE_KEYS: Record<LimitTaskType, string> = {
112
118
  feature: 'settings.workspaceSettings.taskTypes.feature',
113
119
  bug: 'settings.workspaceSettings.taskTypes.bug',
114
120
  document: 'settings.workspaceSettings.taskTypes.document',
@@ -124,7 +130,7 @@ const MODES = computed<{ value: TaskLimitMode; label: string }[]>(() => [
124
130
  ])
125
131
 
126
132
  /** The localized "Max {type} tasks" label for a per-type running-task limit input. */
127
- function maxTaskTypeLabel(type: CreateTaskType): string {
133
+ function maxTaskTypeLabel(type: LimitTaskType): string {
128
134
  const key = TASK_TYPE_KEYS[type]
129
135
  const typeLabel = te(key) ? t(key) : type
130
136
  return t('settings.workspaceSettings.taskLimit.maxPerType', { type: typeLabel })
@@ -135,7 +141,7 @@ const draft = reactive({
135
141
  waitingEscalationMinutes: 120,
136
142
  taskLimitMode: 'off' as TaskLimitMode,
137
143
  taskLimitShared: 5 as number,
138
- perType: {} as Record<CreateTaskType, number>,
144
+ perType: {} as Record<LimitTaskType, number>,
139
145
  storeAgentContext: true,
140
146
  artifactRetentionDays: 14,
141
147
  kaizenEnabled: true,
@@ -173,7 +179,7 @@ async function save() {
173
179
  acc[t] = draft.perType[t]
174
180
  return acc
175
181
  },
176
- {} as Record<CreateTaskType, number>,
182
+ {} as Record<LimitTaskType, number>,
177
183
  )
178
184
  : null,
179
185
  storeAgentContext: draft.storeAgentContext,
@@ -3,6 +3,7 @@ import {
3
3
  createPromptFragmentContract,
4
4
  deletePromptFragmentContract,
5
5
  fragmentSourceStatusContract,
6
+ generatePromptFragmentTitleContract,
6
7
  linkFragmentSourceContract,
7
8
  listFragmentCatalogContract,
8
9
  listFragmentSourcesContract,
@@ -17,6 +18,7 @@ import type {
17
18
  CreateDocumentFragmentInput,
18
19
  CreatePromptFragmentInput,
19
20
  FragmentOwnerKind,
21
+ GenerateFragmentTitleInput,
20
22
  LinkFragmentSourceInput,
21
23
  UpdatePromptFragmentInput,
22
24
  } from '~/types/domain'
@@ -58,6 +60,21 @@ export function fragmentsApi({ send, ws, scope }: ApiContext) {
58
60
  pathParams: { fragmentId },
59
61
  }),
60
62
 
63
+ // Auto-generate a title for a hand-authored fragment from its content (an inline LLM call).
64
+ // At the account scope the backend resolves the model against `viaWorkspaceId`'s scope;
65
+ // ignored at the workspace scope (which uses the addressed workspace).
66
+ generateFragmentTitle: (
67
+ kind: FragmentOwnerKind,
68
+ id: string,
69
+ body: GenerateFragmentTitleInput,
70
+ viaWorkspaceId?: string,
71
+ ) =>
72
+ send(generatePromptFragmentTitleContract, {
73
+ pathPrefix: scope(kind, id),
74
+ queryParams: { viaWorkspaceId },
75
+ body,
76
+ }),
77
+
61
78
  // Link an external document (Confluence/Notion/GitHub) as a living fragment.
62
79
  createDocumentFragment: (
63
80
  kind: FragmentOwnerKind,
@@ -62,14 +62,15 @@ export default defineNuxtPlugin(() => {
62
62
 
63
63
  ## The landed seams
64
64
 
65
- | Seam | Slot key | Entry shape | Host |
66
- | ----------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------- |
67
- | Run-detail windows | `resultViews` | `{ id: '<ns>:<name>', component }` | `StepResultViewHost` via `dispatchStepView` |
68
- | Agent kinds (palette data) | `agentKinds` | `{ kind, container, presentation: { label, icon, color, description, category?, resultView? } }` | agents store merge → `agentKindMeta` |
69
- | Sidebar / command-palette / toolbar | `nav` | `{ id, labelKey, icon, surfaces, gate?, run, sidebar?, command?, toolbar? }` | the three shells via `useNavContributions` |
70
- | Inspector body panels | `inspectorPanels` | `{ id, component, when(block), order }` (`PanelEntry<Block>`) | `<PanelsOutlet>` in `InspectorPanel` |
71
- | Multi-step wizards | (journeys) | `registerJourney` + step modules | `<JourneyHost>` / `<JourneyOutlet>` |
72
- | Locale strings | (i18n) | `i18n/locales/*.json` in the deployment | `@nuxtjs/i18n` layer deep-merge |
65
+ | Seam | Slot key | Entry shape | Host |
66
+ | ----------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------- |
67
+ | Run-detail windows | `resultViews` | `{ id: '<ns>:<name>', component }` | `StepResultViewHost` via `dispatchStepView` |
68
+ | Agent kinds (palette data) | `agentKinds` | `{ kind, container, presentation: { label, icon, color, description, category?, resultView? } }` | agents store merge → `agentKindMeta` |
69
+ | Custom task types | `taskTypes` | `{ taskType: '<ns>:<name>', presentation, fields?, defaultPipelineId?, formPanel? }` | `AddTaskModal` picker/fields + `TaskCard` badge (via `taskTypeMeta`) |
70
+ | Sidebar / command-palette / toolbar | `nav` | `{ id, labelKey, icon, surfaces, gate?, run, sidebar?, command?, toolbar? }` | the three shells via `useNavContributions` |
71
+ | Inspector body panels | `inspectorPanels` | `{ id, component, when(block), order }` (`PanelEntry<Block>`) | `<PanelsOutlet>` in `InspectorPanel` |
72
+ | Multi-step wizards | (journeys) | `registerJourney` + step modules | `<JourneyHost>` / `<JourneyOutlet>` |
73
+ | Locale strings | (i18n) | `i18n/locales/*.json` in the deployment | `@nuxtjs/i18n` layer deep-merge |
73
74
 
74
75
  ### Run-detail windows (`resultViews` + `agentKinds`)
75
76
 
@@ -102,6 +103,38 @@ show the panel, and `order` places it among the built-ins. Your panel component
102
103
  selected block via `usePanelSubject<Block>()` (`@modular-vue/core`). `when` must tolerate a
103
104
  nullish subject (the boot-time validation resolve passes `null`).
104
105
 
106
+ ### Custom task types (`taskTypes`)
107
+
108
+ Model a proprietary work item — an "incident", "pentest", "compliance-audit" — as a first-class
109
+ task type, the create-task twin of an agent kind. Contribute `{ taskType: '<ns>:<name>',
110
+ presentation: { label, icon, color, description }, fields?, defaultPipelineId?, formPanel? }` to
111
+ the `taskTypes` slot (see `acme:incident` in the example module). The SPA merges it into the
112
+ create-task picker and the card-badge catalog:
113
+
114
+ - **`presentation`** drives the create-task picker entry and the `TaskCard` type badge (resolved
115
+ through the pure `taskTypeMeta` read-model — the `agentKindMeta` twin). An UNREGISTERED
116
+ namespaced type (a stale row after your extension is removed) degrades to the `feature`
117
+ presentation, so a leftover string never breaks a card.
118
+ - **`fields`** are descriptor-driven create-form inputs (`text` / `textarea` / `number` /
119
+ `select`); their values land in the task's sparse `taskTypeFields.custom` bag (no migration).
120
+ - **`formPanel`** optionally names a bespoke create-form section component you contribute to the
121
+ `taskTypeFormPanels` slot (paired by that id, like `resultViews`); shown INSTEAD of `fields`. An
122
+ unpaired id degrades to the descriptor fields.
123
+ - **`defaultPipelineId`** pre-selects the type's pipeline in the picker.
124
+
125
+ The **same type can be delivered from the backend** instead of code-shipped: register it on the
126
+ deployment's app-owned `TaskTypeRegistry` and it arrives in the workspace snapshot's
127
+ `customTaskTypes`, folded into the SAME merged catalog (data over the wire, never components). The
128
+ widened `taskType` contract (`<built-in> | <ns>:<name>`) accepts the namespaced id everywhere, so a
129
+ task created with it round-trips with zero host edits.
130
+
131
+ > **Validation.** A BACKEND-registered task type is checked at boot by `validateRegistrations`
132
+ > (namespaced id, well-formed `formPanel`, a `defaultPipelineId` that resolves to a real pipeline).
133
+ > A CODE-shipped `taskTypes` entry is trusted and **not** validated (like a code-shipped agent kind):
134
+ > a malformed `taskType`/`formPanel` id or a `defaultPipelineId` naming no real pipeline fails
135
+ > silently — the type just won't pre-select a pipeline and an unpaired `formPanel` degrades to the
136
+ > descriptor `fields`. Prefer backend registration when you want the fail-fast guardrail.
137
+
105
138
  ## Reuse the shared building blocks — don't reinvent them
106
139
 
107
140
  The layer ships window/inspector primitives you compose instead of hand-rolling chrome or
@@ -1,10 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import {
3
- buildAgentCapabilitiesManifest,
4
- capabilitiesManifestVersion,
5
- customKindToArchetype,
6
- WORKSPACE_CAPABILITIES_MANIFEST_ID,
7
- } from './agent-kinds'
2
+ import { customKindToArchetype } from './agent-kinds'
8
3
  import type { AgentKind, CustomAgentKind } from '~/types/domain'
9
4
 
10
5
  const kind = (
@@ -22,39 +17,6 @@ const kind = (
22
17
  },
23
18
  })
24
19
 
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
20
  describe('customKindToArchetype', () => {
59
21
  it('projects presentation onto the display archetype', () => {
60
22
  expect(customKindToArchetype(kind())).toEqual({
@@ -1,67 +1,16 @@
1
- import type { RemoteModuleManifest } from '@modular-vue/core'
2
1
  import type { AgentArchetype, CustomAgentKind } from '~/types/domain'
3
- import type { AppSlots } from './slots'
4
2
 
5
3
  /**
6
- * Custom agent kinds as remote capability manifests (slice 2 of the modular-vue
7
- * adoption — docs/initiatives/modular-vue-adoption.md).
4
+ * Custom agent-kind projection (slice 2 of the modular-vue adoption —
5
+ * docs/initiatives/modular-vue-adoption.md).
8
6
  *
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.
7
+ * A deployment's BACKEND-registered agent kinds arrive in the workspace snapshot as
8
+ * `customAgentKinds` (wire data), folded into the shared per-workspace capability manifest
9
+ * (see `./capabilities.ts`, generalized to carry custom TASK types too). CODE-shipped consumer
10
+ * kinds instead enter via the static `agentKinds` slot (a `registerAppModule` module); the agents
11
+ * store merges both. This module holds only the wire→display projection they share.
18
12
  */
19
13
 
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
14
  /**
66
15
  * Project a wire `CustomAgentKind` onto the frontend's display `AgentArchetype`
67
16
  * (icon/label/color/description + optional category/resultView). The inverse of
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ buildWorkspaceCapabilitiesManifest,
4
+ WORKSPACE_CAPABILITIES_MANIFEST_ID,
5
+ workspaceCapabilitiesVersion,
6
+ } from './capabilities'
7
+ import type { AgentKind, CustomAgentKind, CustomTaskType } from '~/types/domain'
8
+
9
+ const kind = (
10
+ over: Partial<CustomAgentKind['presentation']> = {},
11
+ kindId = 'acme-audit',
12
+ ): CustomAgentKind => ({
13
+ kind: kindId as AgentKind,
14
+ container: true,
15
+ presentation: {
16
+ label: 'Audit',
17
+ icon: 'i-lucide-shield',
18
+ color: '#fff',
19
+ description: 'd',
20
+ ...over,
21
+ },
22
+ })
23
+
24
+ const taskType = (
25
+ over: Partial<CustomTaskType['presentation']> = {},
26
+ id = 'acme:incident',
27
+ ): CustomTaskType => ({
28
+ taskType: id,
29
+ presentation: {
30
+ label: 'Incident',
31
+ icon: 'i-lucide-siren',
32
+ color: '#ef4444',
33
+ description: 'd',
34
+ ...over,
35
+ },
36
+ })
37
+
38
+ describe('buildWorkspaceCapabilitiesManifest', () => {
39
+ it('models the snapshot capabilities as one manifest carrying BOTH slots', () => {
40
+ const kinds = [kind()]
41
+ const taskTypes = [taskType()]
42
+ const manifest = buildWorkspaceCapabilitiesManifest(kinds, taskTypes)
43
+ expect(manifest.id).toBe(WORKSPACE_CAPABILITIES_MANIFEST_ID)
44
+ expect(manifest.slots?.agentKinds).toEqual(kinds)
45
+ expect(manifest.slots?.taskTypes).toEqual(taskTypes)
46
+ })
47
+
48
+ it('copies the inputs (no aliasing of the caller arrays)', () => {
49
+ const kinds = [kind()]
50
+ const taskTypes = [taskType()]
51
+ const manifest = buildWorkspaceCapabilitiesManifest(kinds, taskTypes)
52
+ kinds.push(kind())
53
+ taskTypes.push(taskType())
54
+ expect(manifest.slots?.agentKinds).toHaveLength(1)
55
+ expect(manifest.slots?.taskTypes).toHaveLength(1)
56
+ })
57
+
58
+ it('derives an identical version for identical content (so an unchanged re-hydrate no-ops)', () => {
59
+ expect(buildWorkspaceCapabilitiesManifest([kind()], [taskType()]).version).toBe(
60
+ buildWorkspaceCapabilitiesManifest([kind()], [taskType()]).version,
61
+ )
62
+ })
63
+
64
+ it('changes the version when an agent-kind display/pairing field or the kind set differs', () => {
65
+ const base = workspaceCapabilitiesVersion([kind()], [])
66
+ expect(workspaceCapabilitiesVersion([kind({ label: 'Renamed' })], [])).not.toBe(base)
67
+ expect(workspaceCapabilitiesVersion([kind({ resultView: 'acme:audit' })], [])).not.toBe(base)
68
+ expect(workspaceCapabilitiesVersion([kind({}, 'acme-other')], [])).not.toBe(base)
69
+ expect(workspaceCapabilitiesVersion([kind(), kind({}, 'acme-two')], [])).not.toBe(base)
70
+ expect(workspaceCapabilitiesVersion([], [])).not.toBe(base)
71
+ })
72
+
73
+ it('changes the version when a task-type field, its fields, or the set differs', () => {
74
+ const base = workspaceCapabilitiesVersion([], [taskType()])
75
+ expect(workspaceCapabilitiesVersion([], [taskType({ label: 'Renamed' })])).not.toBe(base)
76
+ expect(workspaceCapabilitiesVersion([], [taskType({}, 'acme:other')])).not.toBe(base)
77
+ expect(
78
+ workspaceCapabilitiesVersion([], [{ ...taskType(), defaultPipelineId: 'pl_review' }]),
79
+ ).not.toBe(base)
80
+ expect(
81
+ workspaceCapabilitiesVersion(
82
+ [],
83
+ [{ ...taskType(), fields: [{ key: 'sev', label: 'Severity', type: 'text' }] }],
84
+ ),
85
+ ).not.toBe(base)
86
+ expect(workspaceCapabilitiesVersion([], [])).not.toBe(base)
87
+ })
88
+ })
@@ -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
+ }