@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.
- package/app/components/panels/StepResultViewHost.vue +48 -86
- package/app/modular/agent-kinds.spec.ts +80 -0
- package/app/modular/agent-kinds.ts +83 -0
- package/app/modular/nav-contributions.spec.ts +1 -1
- package/app/modular/nav-contributions.ts +7 -11
- package/app/modular/registry.spec.ts +38 -0
- package/app/modular/registry.ts +22 -11
- package/app/modular/result-views.ts +104 -0
- package/app/modular/slots.ts +40 -0
- package/app/plugins/modular.client.ts +24 -1
- package/app/stores/agents.spec.ts +100 -0
- package/app/stores/agents.ts +115 -34
- package/app/stores/workspace.ts +4 -3
- package/app/types/domain.ts +4 -3
- package/app/utils/catalog.ts +57 -16
- package/package.json +4 -4
|
@@ -1,98 +1,60 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
// Universal dedicated-result-view host
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// `
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
import
|
|
22
|
-
import
|
|
23
|
-
import
|
|
24
|
-
import
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
package/app/modular/registry.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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.
|
|
81
|
-
*
|
|
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(
|
|
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
|
+
})
|
package/app/stores/agents.ts
CHANGED
|
@@ -1,21 +1,96 @@
|
|
|
1
1
|
import { defineStore } from 'pinia'
|
|
2
|
-
import { ref } from 'vue'
|
|
3
|
-
import {
|
|
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
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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
|
|
48
|
-
|
|
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
|
-
|
|
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
|
})
|
package/app/stores/workspace.ts
CHANGED
|
@@ -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
|
-
//
|
|
172
|
-
// proprietary kind renders as a first-class
|
|
173
|
-
|
|
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 ?? [])
|
package/app/types/domain.ts
CHANGED
|
@@ -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
|
|
112
|
-
* (`
|
|
113
|
-
*
|
|
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
|
}
|
package/app/utils/catalog.ts
CHANGED
|
@@ -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
|
-
|
|
308
|
-
|
|
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
|
|
600
|
-
*
|
|
601
|
-
*
|
|
602
|
-
*
|
|
603
|
-
*
|
|
604
|
-
*
|
|
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] ??
|
|
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
|
|
615
|
-
* ({@link AGENT_BY_KIND},
|
|
616
|
-
*
|
|
617
|
-
* ({@link
|
|
618
|
-
*
|
|
619
|
-
*
|
|
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.
|
|
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.
|
|
22
|
-
"@modular-vue/core": "^1.0
|
|
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.
|
|
42
|
+
"@cat-factory/contracts": "0.148.0"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@toad-contracts/testing": "0.3.2",
|