@cat-factory/app 0.223.0 → 0.224.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.
@@ -64,6 +64,10 @@ const ISSUE_KEYS = {
64
64
  title: 'inputGate.issue.success_criteria_missing.title',
65
65
  hint: 'inputGate.issue.success_criteria_missing.hint',
66
66
  },
67
+ required_field_missing: {
68
+ title: 'inputGate.issue.required_field_missing.title',
69
+ hint: 'inputGate.issue.required_field_missing.hint',
70
+ },
67
71
  } as const satisfies Record<InputGateIssueCode, { title: string; hint: string }>
68
72
 
69
73
  /**
@@ -87,16 +91,30 @@ const TONE_COPY: Record<InputGateTone, { title: string; body: string }> = {
87
91
  }
88
92
  const copy = computed(() => TONE_COPY[props.tone])
89
93
 
94
+ /**
95
+ * The interpolation a finding's copy is rendered with. Only `required_field_missing` carries a
96
+ * `field`, and its copy is the one line that cannot be written without knowing which input is
97
+ * missing: a deployment registers its own task types, so the platform has no vocabulary for
98
+ * "the incident's severity" and names the field instead. The label is deployment-supplied
99
+ * English, exactly as a custom agent kind's is.
100
+ *
101
+ * A finding whose `field` is somehow absent still renders: the copy falls back to naming no
102
+ * field rather than printing `undefined` into a sentence a human is meant to act on.
103
+ */
104
+ function issueValues(issue: InputGateIssue): Record<string, string> {
105
+ return { field: issue.field?.label ?? t('inputGate.issue.required_field_missing.unnamedField') }
106
+ }
107
+
90
108
  /** A finding's translated title, falling back to the generic line for a retired code. */
91
- function issueTitle(code: InputGateIssueCode): string {
92
- const key = ISSUE_KEYS[code]?.title
93
- return key && te(key) ? t(key) : t('inputGate.issue.unknown.title')
109
+ function issueTitle(issue: InputGateIssue): string {
110
+ const key = ISSUE_KEYS[issue.code]?.title
111
+ return key && te(key) ? t(key, issueValues(issue)) : t('inputGate.issue.unknown.title')
94
112
  }
95
113
 
96
114
  /** A finding's translated remedy hint, on the same fallback. */
97
- function issueHint(code: InputGateIssueCode): string {
98
- const key = ISSUE_KEYS[code]?.hint
99
- return key && te(key) ? t(key) : t('inputGate.issue.unknown.hint')
115
+ function issueHint(issue: InputGateIssue): string {
116
+ const key = ISSUE_KEYS[issue.code]?.hint
117
+ return key && te(key) ? t(key, issueValues(issue)) : t('inputGate.issue.unknown.hint')
100
118
  }
101
119
 
102
120
  async function resolve(choice: 'recheck' | 'proceed') {
@@ -126,7 +144,11 @@ async function resolve(choice: 'recheck' | 'proceed') {
126
144
  <p v-if="!compact" class="text-muted mt-0.5 text-xs">{{ t(copy.body) }}</p>
127
145
 
128
146
  <ul class="mt-2 space-y-1.5">
129
- <li v-for="issue in issues" :key="issue.code" class="flex items-start gap-2 text-xs">
147
+ <li
148
+ v-for="issue in issues"
149
+ :key="`${issue.code}:${issue.field?.key ?? ''}`"
150
+ class="flex items-start gap-2 text-xs"
151
+ >
130
152
  <UBadge
131
153
  :color="issue.severity === 'blocking' ? 'warning' : 'neutral'"
132
154
  variant="subtle"
@@ -139,8 +161,8 @@ async function resolve(choice: 'recheck' | 'proceed') {
139
161
  }}
140
162
  </UBadge>
141
163
  <span class="min-w-0">
142
- <span class="font-medium">{{ issueTitle(issue.code) }}</span>
143
- <span class="text-muted">, {{ issueHint(issue.code) }}</span>
164
+ <span class="font-medium">{{ issueTitle(issue) }}</span>
165
+ <span class="text-muted">, {{ issueHint(issue) }}</span>
144
166
  </span>
145
167
  </li>
146
168
  </ul>
@@ -0,0 +1,115 @@
1
+ <script setup lang="ts">
2
+ // The answers to a CUSTOM task type's own declared fields, editable after creation.
3
+ //
4
+ // Why it exists: the create form is not the only door a task arrives through (the public API, an
5
+ // initiative spawn, a tracker import), and a type's declaration can get STRICTER after a task
6
+ // already exists. The pre-dispatch input gate judges the declaration as it stands now, so it
7
+ // parks runs whose task predates the requirement. Without this panel that park had exactly one
8
+ // exit, a human waiving the gate: `recheck` would re-read the same unanswered bag forever, and
9
+ // the remedy the notice names ("fill it in on the task") would be one nothing offered.
10
+ //
11
+ // Renders through the SAME `DescriptorFields` component the create form uses, against the SAME
12
+ // declaration, validated by the SAME shared rule. A field the form would have hidden by its
13
+ // `showWhen` is hidden here too, so the two doors cannot show a person different questions.
14
+ import { computed, ref, watch } from 'vue'
15
+ import type { DescriptorFieldValues } from '@cat-factory/contracts'
16
+ import { sanitizeDescriptorFields, validateDescriptorFields } from '@cat-factory/contracts'
17
+ import type { Block } from '~/types/domain'
18
+ import DescriptorFields from '~/components/common/DescriptorFields.vue'
19
+ import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
20
+
21
+ const props = defineProps<{ block: Block }>()
22
+
23
+ const board = useBoardStore()
24
+ const taskTypes = useTaskTypesStore()
25
+ const { t } = useI18n()
26
+
27
+ /** The registered type this task is, or undefined for a built-in / unregistered one. */
28
+ const descriptor = computed(() =>
29
+ props.block.taskType
30
+ ? taskTypes.customTaskTypes.find((tt) => tt.taskType === props.block.taskType)
31
+ : undefined,
32
+ )
33
+
34
+ /**
35
+ * The fields to render. A type carrying a bespoke `formPanel` owns its whole bag, so its
36
+ * descriptor fields are not what was collected and editing them here would write values its own
37
+ * form never offered. That is the same stand-down the create door and the input gate take, and
38
+ * all three have to agree or "the declaration" would mean three different things.
39
+ */
40
+ const fields = computed(() => (descriptor.value?.formPanel ? [] : (descriptor.value?.fields ?? [])))
41
+
42
+ const stored = computed<DescriptorFieldValues>(() => props.block.taskTypeFields?.custom ?? {})
43
+
44
+ // Local edit buffer, re-seeded whenever the stored bag changes underneath (a live board push, or
45
+ // switching blocks). Editing writes on commit rather than per keystroke, so a half-typed answer
46
+ // never reaches the row the gate reads.
47
+ const draft = ref<DescriptorFieldValues>({ ...stored.value })
48
+ watch(stored, (next) => {
49
+ draft.value = { ...next }
50
+ })
51
+
52
+ /**
53
+ * The same check the server runs, so the button reflects an invalid form rather than the save
54
+ * failing with a 422. Shared from contracts precisely so the two cannot drift.
55
+ */
56
+ const problems = computed(() => validateDescriptorFields(fields.value, draft.value))
57
+
58
+ const dirty = computed(
59
+ () =>
60
+ JSON.stringify(sanitizeDescriptorFields(fields.value, draft.value)) !==
61
+ JSON.stringify(stored.value),
62
+ )
63
+
64
+ const saving = ref(false)
65
+
66
+ async function save() {
67
+ if (problems.value.length || !dirty.value) return
68
+ saving.value = true
69
+ try {
70
+ await board.updateBlock(props.block.id, {
71
+ customTaskTypeFields: sanitizeDescriptorFields(fields.value, draft.value),
72
+ })
73
+ } finally {
74
+ saving.value = false
75
+ }
76
+ }
77
+
78
+ function revert() {
79
+ draft.value = { ...stored.value }
80
+ }
81
+ </script>
82
+
83
+ <template>
84
+ <InspectorSection
85
+ v-if="fields.length"
86
+ :title="t('inspector.taskTypeFields.title')"
87
+ :hint="t('inspector.taskTypeFields.hint')"
88
+ icon="i-lucide-clipboard-list"
89
+ :count="fields.length"
90
+ >
91
+ <DescriptorFields v-model="draft" :fields="fields" testid-prefix="task-type-field" />
92
+ <div v-if="dirty" class="mt-2 flex items-center gap-2">
93
+ <UButton
94
+ size="xs"
95
+ color="primary"
96
+ variant="soft"
97
+ :loading="saving"
98
+ :disabled="problems.length > 0"
99
+ data-testid="task-type-fields-save"
100
+ @click="save"
101
+ >
102
+ {{ t('inspector.taskTypeFields.save') }}
103
+ </UButton>
104
+ <UButton
105
+ size="xs"
106
+ color="neutral"
107
+ variant="ghost"
108
+ data-testid="task-type-fields-revert"
109
+ @click="revert"
110
+ >
111
+ {{ t('inspector.taskTypeFields.revert') }}
112
+ </UButton>
113
+ </div>
114
+ </InspectorSection>
115
+ </template>
@@ -88,6 +88,7 @@ onBeforeUnmount(() => {
88
88
  const OPERATION_LABEL = computed<Record<ProvisioningOperation, string>>(() => ({
89
89
  provision: t('provisioning.operation.provision'),
90
90
  teardown: t('provisioning.operation.teardown'),
91
+ 'teardown-verify': t('provisioning.operation.teardown-verify'),
91
92
  status: t('provisioning.operation.status'),
92
93
  dispatch: t('provisioning.operation.dispatch'),
93
94
  release: t('provisioning.operation.release'),
@@ -97,6 +97,11 @@ describe('inspector panel group', () => {
97
97
  'task-dependencies',
98
98
  'task-run-settings',
99
99
  'task-agent-config',
100
+ // The custom type's own declared fields sit with the other task INPUTS (what the task is),
101
+ // not under run settings (how it runs). It is gated on being a task alone: the panel hides
102
+ // itself unless the type is one this deployment registered with descriptor fields, which
103
+ // the spec here cannot see and should not try to.
104
+ 'task-type-fields',
100
105
  'task-structure',
101
106
  ])
102
107
  })
@@ -45,6 +45,7 @@ export const INSPECTOR_PANEL_IDS = [
45
45
  'task-dependencies',
46
46
  'task-run-settings',
47
47
  'task-agent-config',
48
+ 'task-type-fields',
48
49
  'task-structure',
49
50
  // service / module body
50
51
  'container-summary',
@@ -134,6 +135,11 @@ export const INSPECTOR_PANEL_SPECS: readonly InspectorPanelSpec[] = [
134
135
  { id: 'task-dependencies', order: 60, when: isTask },
135
136
  { id: 'task-run-settings', order: 70, when: isTask },
136
137
  { id: 'task-agent-config', order: 80, when: isTask },
138
+ // The answers to a CUSTOM task type's declared fields. Gated on being a task alone; the panel
139
+ // itself hides unless the task's type is one this deployment registered WITH descriptor fields,
140
+ // which is the only case there is anything to edit. It sits beside the other task inputs rather
141
+ // than under Run settings: these are what the task IS, not how it runs.
142
+ { id: 'task-type-fields', order: 85, when: isTask },
137
143
  { id: 'task-structure', order: 90, when: isTask },
138
144
  { id: 'container-summary', order: 110, when: isContainer },
139
145
  { id: 'frontend-config', order: 120, when: (b) => isFrame(b) && b.type === 'frontend' },
@@ -21,6 +21,7 @@ import TaskEstimateBadge from '~/components/panels/inspector/TaskEstimateBadge.v
21
21
  import TaskDependencies from '~/components/panels/inspector/TaskDependencies.vue'
22
22
  import TaskRunSettings from '~/components/panels/inspector/TaskRunSettings.vue'
23
23
  import TaskAgentConfig from '~/components/panels/inspector/TaskAgentConfig.vue'
24
+ import TaskTypeFields from '~/components/panels/inspector/TaskTypeFields.vue'
24
25
  import TaskStructure from '~/components/panels/inspector/TaskStructure.vue'
25
26
  import ContainerSummary from '~/components/panels/inspector/ContainerSummary.vue'
26
27
  import FrontendConfig from '~/components/panels/inspector/FrontendConfig.vue'
@@ -75,6 +76,7 @@ const COMPONENTS: Record<InspectorPanelId, Component> = {
75
76
  'task-dependencies': TaskDependencies,
76
77
  'task-run-settings': TaskRunSettings,
77
78
  'task-agent-config': TaskAgentConfig,
79
+ 'task-type-fields': TaskTypeFields,
78
80
  'task-structure': TaskStructure,
79
81
  'container-summary': ContainerSummary,
80
82
  'frontend-config': FrontendConfig,
@@ -7,6 +7,7 @@ import {
7
7
  AGENT_ARCHETYPES,
8
8
  AGENT_BY_KIND,
9
9
  setCustomAgentKindMeta,
10
+ setCustomCompanionTargets,
10
11
  SYSTEM_AGENT_META,
11
12
  uid,
12
13
  } from '~/utils/catalog'
@@ -105,6 +106,28 @@ export const useAgentsStore = defineStore('agents', () => {
105
106
  // no tick gap. The watch lives in the store's effect scope (disposed with it).
106
107
  watch(customByKind, (map) => setCustomAgentKindMeta(map), { immediate: true, flush: 'sync' })
107
108
 
109
+ /**
110
+ * The custom COMPANION pairings (companion kind → the producer kinds it reviews), read off the
111
+ * same custom-kind sources the palette is built from. Kept apart from `customByKind` because a
112
+ * pairing is not display metadata: the builder uses it to decide a kind is a TOGGLE on its
113
+ * producer rather than a placeable block, and `AgentArchetype` has no business carrying it.
114
+ */
115
+ const customCompanions = computed<Record<string, readonly AgentKind[]>>(() => {
116
+ const out: Record<string, readonly AgentKind[]> = {}
117
+ const add = (k: CustomAgentKind) => {
118
+ // A pairing with no targets is not a pairing. Recording it would make the kind vanish from
119
+ // the palette (an `isProducerCompanion` hit) with no producer to hang the toggle on.
120
+ if (k.companionTargets?.length) out[k.kind] = k.companionTargets
121
+ }
122
+ for (const k of consumerKinds.value) add(k)
123
+ for (const k of capabilitiesManifest.value?.slots?.agentKinds ?? []) add(k)
124
+ return out
125
+ })
126
+ watch(customCompanions, (map) => setCustomCompanionTargets(map), {
127
+ immediate: true,
128
+ flush: 'sync',
129
+ })
130
+
108
131
  /** Display metadata for a KNOWN kind (built-in / system / custom), else undefined. */
109
132
  function get(kind: AgentKind): AgentArchetype | undefined {
110
133
  return AGENT_BY_KIND[kind] ?? SYSTEM_AGENT_META[kind] ?? customByKind.value[kind]
@@ -0,0 +1,80 @@
1
+ import { afterEach, describe, expect, it } from 'vitest'
2
+ import type { AgentKind } from '~/types/domain'
3
+ import {
4
+ COMPANION_FOR_PRODUCER,
5
+ __resetCustomCompanionTargetsForTest,
6
+ companionForProducer,
7
+ isProducerCompanion,
8
+ setCustomCompanionTargets,
9
+ } from '~/utils/catalog'
10
+
11
+ // The SPA half of the companion registry. A companion is not a placeable palette block: the
12
+ // builder renders it as an "add companion" toggle ON its producer and inserts it immediately
13
+ // after. These two lookups are what decide that, so a deployment's registered pair is either
14
+ // visible as a toggle or invisible entirely, with nothing in between.
15
+ //
16
+ // The backend half is `extension-registries.companions.test.ts`. Both are needed: the backend
17
+ // enforces the adjacency rule on save, and this decides whether a person can ever express the
18
+ // pairing in the first place.
19
+
20
+ afterEach(() => {
21
+ __resetCustomCompanionTargetsForTest()
22
+ })
23
+
24
+ describe('custom companion pairings in the palette', () => {
25
+ it('has no opinion about a deployment’s pair until the store projects it', () => {
26
+ // The pre-registration state is a real one: the snapshot arrives after first paint, and a
27
+ // custom companion must degrade to an ordinary kind rather than to a broken toggle.
28
+ expect(isProducerCompanion('acme:migration-auditor')).toBe(false)
29
+ expect(companionForProducer('acme:migrator')).toBeUndefined()
30
+ })
31
+
32
+ it('renders a registered pair as a toggle on its producer', () => {
33
+ setCustomCompanionTargets({ 'acme:migration-auditor': ['acme:migrator'] })
34
+ expect(companionForProducer('acme:migrator')).toBe('acme:migration-auditor')
35
+ // ...and the companion itself leaves the palette, which is the other half of "it is a
36
+ // toggle, not a block". Registering only one of these would show the kind twice.
37
+ expect(isProducerCompanion('acme:migration-auditor')).toBe(true)
38
+ // The producer is not a companion just for being reviewed by one.
39
+ expect(isProducerCompanion('acme:migrator')).toBe(false)
40
+ })
41
+
42
+ it('lets one companion review several producers', () => {
43
+ setCustomCompanionTargets({ 'acme:auditor': ['acme:migrator', 'acme:packager'] })
44
+ expect(companionForProducer('acme:migrator')).toBe('acme:auditor')
45
+ expect(companionForProducer('acme:packager')).toBe('acme:auditor')
46
+ })
47
+
48
+ it('never lets a deployment re-point a BUILT-IN producer at its own companion', () => {
49
+ // Built-ins win, matching `agentKindMeta`'s precedence and the backend registry's refusal to
50
+ // shadow a built-in kind. The shipped pairing is what every stock pipeline relies on, so a
51
+ // silent re-point would change what those pipelines do without anyone editing them.
52
+ setCustomCompanionTargets({ 'acme:reviewer': ['coder'] })
53
+ expect(companionForProducer('coder')).toBe(COMPANION_FOR_PRODUCER.coder)
54
+ // The custom kind still leaves the palette: it IS a companion, it just does not get `coder`.
55
+ expect(isProducerCompanion('acme:reviewer')).toBe(true)
56
+ })
57
+
58
+ it('resolves a contested producer deterministically, first registration winning', () => {
59
+ // Only one toggle can hang off a producer, so two companions claiming it is a case with an
60
+ // answer whether or not anyone chose one. Pinning it here is what keeps the answer from
61
+ // being "whichever the object happened to enumerate first".
62
+ const contested: Record<string, readonly AgentKind[]> = {
63
+ 'acme:first': ['acme:migrator'],
64
+ 'acme:second': ['acme:migrator'],
65
+ }
66
+ setCustomCompanionTargets(contested)
67
+ expect(companionForProducer('acme:migrator')).toBe('acme:first')
68
+ expect(isProducerCompanion('acme:second')).toBe(true)
69
+ })
70
+
71
+ it('drops a pairing when the catalog it came from goes away', () => {
72
+ setCustomCompanionTargets({ 'acme:migration-auditor': ['acme:migrator'] })
73
+ expect(companionForProducer('acme:migrator')).toBe('acme:migration-auditor')
74
+ // A workspace switch re-projects an empty catalog. The lookups must follow it rather than
75
+ // keep answering from the old deployment's registrations.
76
+ setCustomCompanionTargets({})
77
+ expect(companionForProducer('acme:migrator')).toBeUndefined()
78
+ expect(isProducerCompanion('acme:migration-auditor')).toBe(false)
79
+ })
80
+ })
@@ -37,6 +37,7 @@ const AGENT_KINDS: AgentKind[] = [
37
37
  'business-reviewer',
38
38
  'human-test',
39
39
  'visual-confirmation',
40
+ 'disposer',
40
41
  ]
41
42
  const BLOCK_TYPES: BlockType[] = [
42
43
  'frontend',
@@ -1,4 +1,4 @@
1
- import { shallowRef } from 'vue'
1
+ import { computed, shallowRef } from 'vue'
2
2
  import type {
3
3
  AgentArchetype,
4
4
  AgentCategory,
@@ -228,6 +228,21 @@ export const AGENT_ARCHETYPES: AgentArchetype[] = [
228
228
  // recreate / destroy) instead of the generic prose step-detail panel.
229
229
  resultView: 'human-test',
230
230
  },
231
+ {
232
+ // The `deployer`'s counterpart at the other end of the environment lifecycle, and a PALETTE
233
+ // block rather than a system kind precisely because deciding WHEN the environment goes away
234
+ // is the point of it: after the automated tester, or after a human has finished with the live
235
+ // URL. Without one, the TTL sweep reclaims environments on a timer long after the run
236
+ // settled, which is a fine backstop and cannot close the run's own teardown proof.
237
+ kind: 'disposer',
238
+ tier: 'intermediate',
239
+ label: 'Disposer',
240
+ icon: 'i-lucide-cloud-off',
241
+ color: '#34d399',
242
+ category: 'test',
243
+ description:
244
+ 'Reclaims the ephemeral environments this run provisioned, and confirms they are actually gone. Place it after the last step that needs the environment.',
245
+ },
231
246
  {
232
247
  kind: 'visual-confirmation',
233
248
  tier: 'advanced',
@@ -322,9 +337,13 @@ export const COMPANION_ARCHETYPES: AgentArchetype[] = [
322
337
  ]
323
338
 
324
339
  /**
325
- * Producer agent kind → its companion agent kind. Mirrors the backend `COMPANIONS` registry
326
- * (`@cat-factory/agents`). The builder shows an "add companion" toggle on a producer step
327
- * found here, and inserts/removes the companion immediately after it.
340
+ * Producer agent kind → its companion agent kind, for the BUILT-IN pairs. Mirrors the backend
341
+ * `COMPANIONS` catalog (`@cat-factory/agents`). The builder shows an "add companion" toggle on
342
+ * a producer step found here, and inserts/removes the companion immediately after it.
343
+ *
344
+ * A DEPLOYMENT's own pair does not live here: it arrives on the snapshot as a custom agent
345
+ * kind carrying `companionTargets`, and is projected into {@link customCompanionTargets} by the
346
+ * agents store. Both are consulted below, built-ins first.
328
347
  */
329
348
  export const COMPANION_FOR_PRODUCER: Record<string, AgentKind> = {
330
349
  coder: 'reviewer',
@@ -335,18 +354,67 @@ export const COMPANION_FOR_PRODUCER: Record<string, AgentKind> = {
335
354
 
336
355
  const COMPANION_KINDS: ReadonlySet<string> = new Set(COMPANION_ARCHETYPES.map((a) => a.kind))
337
356
 
338
- /** The companion kind that depends on a producer kind, or undefined if it has none. */
357
+ /**
358
+ * Reactive read-model of the deployment's CUSTOM companion pairings (companion kind → the
359
+ * producer kinds it reviews), kept in sync by the agents store from the snapshot's
360
+ * `customAgentKinds[].companionTargets`.
361
+ *
362
+ * A `shallowRef` for the same reason {@link customAgentKindMeta} is one: the pure lookups below
363
+ * must resolve a registered companion, and re-render when the catalog changes, without importing
364
+ * the store (circular) or mutating the frozen built-in map. Empty until the store first
365
+ * populates it, so a custom companion degrades to "not a companion" (an ordinary palette block),
366
+ * exactly as before registration.
367
+ */
368
+ const customCompanionTargets = shallowRef<Record<string, readonly AgentKind[]>>({})
369
+
370
+ /**
371
+ * The same projection INVERTED: producer kind → the companion that reviews it, the direction
372
+ * {@link companionForProducer} actually asks in. Derived rather than scanned per call, because
373
+ * that lookup runs once per step of every pipeline the builder renders.
374
+ *
375
+ * Inverting is also where an ambiguity has to be RESOLVED rather than left to iteration order:
376
+ * two registered companions may both claim a producer, and only one toggle can hang off it.
377
+ * First registration wins, stated here once, instead of "whichever `Object.entries` reached
378
+ * first" being the answer at each call site.
379
+ */
380
+ const customCompanionByProducer = computed<Record<string, AgentKind>>(() => {
381
+ const out: Record<string, AgentKind> = {}
382
+ for (const [companion, targets] of Object.entries(customCompanionTargets.value)) {
383
+ for (const producer of targets) if (!(producer in out)) out[producer] = companion
384
+ }
385
+ return out
386
+ })
387
+
388
+ /** Replace the custom companion projection (called only by the agents store). */
389
+ export function setCustomCompanionTargets(map: Record<string, readonly AgentKind[]>): void {
390
+ customCompanionTargets.value = map
391
+ }
392
+
393
+ /** Test-only: clear the custom companion projection so a spec starts from built-ins only. */
394
+ export function __resetCustomCompanionTargetsForTest(): void {
395
+ customCompanionTargets.value = {}
396
+ }
397
+
398
+ /**
399
+ * The companion kind that depends on a producer kind, or undefined if it has none.
400
+ *
401
+ * Built-ins win. A deployment cannot re-point `coder` at its own reviewer by registering one,
402
+ * for the same reason `agentKindMeta`'s precedence puts built-ins first and the backend registry
403
+ * never shadows a built-in kind: the shipped pairing is the one the engine's own pipelines rely
404
+ * on, and a silent re-point would change what every stock pipeline does.
405
+ */
339
406
  export function companionForProducer(kind: string): AgentKind | undefined {
340
- return COMPANION_FOR_PRODUCER[kind]
407
+ return COMPANION_FOR_PRODUCER[kind] ?? customCompanionByProducer.value[kind]
341
408
  }
342
409
 
343
410
  /**
344
411
  * Whether a kind is a dependent producer-companion (reviewer / architect-companion /
345
- * spec-companion) rendered as a toggle on its producer, not a standalone palette block.
346
- * Distinct from `pipelineRender`'s `isCompanionKind`, which also counts the Tester's `fixer`.
412
+ * spec-companion, or a deployment's own): rendered as a toggle on its producer, not a
413
+ * standalone palette block. Distinct from `pipelineRender`'s `isCompanionKind`, which also
414
+ * counts the Tester's `fixer`.
347
415
  */
348
416
  export function isProducerCompanion(kind: string): boolean {
349
- return COMPANION_KINDS.has(kind)
417
+ return COMPANION_KINDS.has(kind) || kind in customCompanionTargets.value
350
418
  }
351
419
 
352
420
  /**
@@ -1533,6 +1533,12 @@
1533
1533
  "inherited": "vom Service geerbt",
1534
1534
  "frozen": "Eingefroren: der Agent hat gestartet"
1535
1535
  },
1536
+ "taskTypeFields": {
1537
+ "title": "Aufgabentyp-Felder",
1538
+ "hint": "Die Antworten, die dieser Aufgabentyp benötigt. Fehlt eine Pflichtangabe, wird ein Lauf vor dem Start angehalten.",
1539
+ "save": "Speichern",
1540
+ "revert": "Zurücksetzen"
1541
+ },
1536
1542
  "dependencies": {
1537
1543
  "title": "Hängt ab von",
1538
1544
  "hint": "Aufgaben, die gemergt sein müssen, bevor diese laufen kann. Das Run-Steuerelement bleibt gesperrt, bis jede Abhängigkeit erledigt ist.",
@@ -5561,6 +5567,11 @@
5561
5567
  "title": "Keine Erfolgskriterien",
5562
5568
  "hint": "Nenne die Frage, die der Spike beantwortet, oder wie ein gutes Ergebnis aussieht, damit die Zeitbox ein Ziel hat."
5563
5569
  },
5570
+ "required_field_missing": {
5571
+ "title": "Fehlt: {field}",
5572
+ "hint": "Dieser Aufgabentyp verlangt „{field}“, die Aufgabe beantwortet es aber nicht. Trage es an der Aufgabe nach und prüfe erneut.",
5573
+ "unnamedField": "ein Pflichtfeld"
5574
+ },
5564
5575
  "unknown": {
5565
5576
  "title": "Unbekannter Befund",
5566
5577
  "hint": "Dieser Lauf hat eine Prüfung erfasst, die diese Version nicht mehr kennt. Sieh die Aufgabe von Hand durch, bevor du fortfährst."
@@ -5914,6 +5925,7 @@
5914
5925
  "operation": {
5915
5926
  "provision": "Hochfahren",
5916
5927
  "teardown": "Abbauen",
5928
+ "teardown-verify": "Abbau-Prüfung",
5917
5929
  "status": "Statusprüfung",
5918
5930
  "dispatch": "Hochfahren",
5919
5931
  "release": "Abbauen",
@@ -1087,6 +1087,12 @@
1087
1087
  "inherited": "inherited from service",
1088
1088
  "frozen": "Frozen: the agent has started"
1089
1089
  },
1090
+ "taskTypeFields": {
1091
+ "title": "Task type fields",
1092
+ "hint": "The answers this task type declares it needs. A required answer that is missing parks a run before it starts.",
1093
+ "save": "Save",
1094
+ "revert": "Revert"
1095
+ },
1090
1096
  "dependencies": {
1091
1097
  "title": "Depends on",
1092
1098
  "hint": "Tasks that must merge before this one can run. The Run control stays locked until every dependency is done.",
@@ -4911,6 +4917,11 @@
4911
4917
  "title": "No success criteria",
4912
4918
  "hint": "State the question the spike answers, or what a good outcome looks like, so the timebox has a target."
4913
4919
  },
4920
+ "required_field_missing": {
4921
+ "title": "Missing: {field}",
4922
+ "hint": "This task type requires “{field}”, and the task does not answer it. Fill it in on the task, then re-check.",
4923
+ "unnamedField": "a required field"
4924
+ },
4914
4925
  "unknown": {
4915
4926
  "title": "Unrecognised finding",
4916
4927
  "hint": "This run recorded a check this version no longer knows about. Review the task by hand before continuing."
@@ -5610,6 +5621,7 @@
5610
5621
  "operation": {
5611
5622
  "provision": "Spin up",
5612
5623
  "teardown": "Tear down",
5624
+ "teardown-verify": "Teardown check",
5613
5625
  "status": "Status check",
5614
5626
  "dispatch": "Spin up",
5615
5627
  "release": "Tear down",
@@ -1003,6 +1003,12 @@
1003
1003
  "inherited": "heredado del servicio",
1004
1004
  "frozen": "Congelado: el agente ya ha comenzado"
1005
1005
  },
1006
+ "taskTypeFields": {
1007
+ "title": "Campos del tipo de tarea",
1008
+ "hint": "Las respuestas que este tipo de tarea declara necesitar. Si falta una respuesta obligatoria, la ejecución se detiene antes de empezar.",
1009
+ "save": "Guardar",
1010
+ "revert": "Descartar"
1011
+ },
1006
1012
  "dependencies": {
1007
1013
  "title": "Depende de",
1008
1014
  "hint": "Tareas que deben fusionarse antes de que esta pueda ejecutarse. El control Ejecutar permanece bloqueado hasta que todas las dependencias estén completas.",
@@ -4703,6 +4709,11 @@
4703
4709
  "title": "Sin criterios de éxito",
4704
4710
  "hint": "Indica la pregunta que responde el spike, o cómo sería un buen resultado, para que el tiempo acotado tenga un objetivo."
4705
4711
  },
4712
+ "required_field_missing": {
4713
+ "title": "Falta: {field}",
4714
+ "hint": "Este tipo de tarea requiere «{field}» y la tarea no lo responde. Complétalo en la tarea y vuelve a comprobar.",
4715
+ "unnamedField": "un campo obligatorio"
4716
+ },
4706
4717
  "unknown": {
4707
4718
  "title": "Hallazgo no reconocido",
4708
4719
  "hint": "Esta ejecución registró una comprobación que esta versión ya no conoce. Revisa la tarea a mano antes de continuar."
@@ -5359,6 +5370,7 @@
5359
5370
  "operation": {
5360
5371
  "provision": "Arrancar",
5361
5372
  "teardown": "Desmontar",
5373
+ "teardown-verify": "Comprobación de desmontaje",
5362
5374
  "status": "Comprobación de estado",
5363
5375
  "dispatch": "Arrancar",
5364
5376
  "release": "Desmontar",
@@ -1003,6 +1003,12 @@
1003
1003
  "inherited": "hérité du service",
1004
1004
  "frozen": "Figé : l'agent a démarré"
1005
1005
  },
1006
+ "taskTypeFields": {
1007
+ "title": "Champs du type de tâche",
1008
+ "hint": "Les réponses que ce type de tâche déclare nécessaires. Une réponse obligatoire manquante met l'exécution en pause avant son démarrage.",
1009
+ "save": "Enregistrer",
1010
+ "revert": "Rétablir"
1011
+ },
1006
1012
  "dependencies": {
1007
1013
  "title": "Dépend de",
1008
1014
  "hint": "Tâches qui doivent être fusionnées avant que celle-ci puisse s'exécuter. Le contrôle Exécuter reste verrouillé tant que toutes les dépendances ne sont pas terminées.",
@@ -4703,6 +4709,11 @@
4703
4709
  "title": "Aucun critère de réussite",
4704
4710
  "hint": "Indiquez la question à laquelle le spike répond, ou ce qu'est un bon résultat, pour donner une cible au temps imparti."
4705
4711
  },
4712
+ "required_field_missing": {
4713
+ "title": "Manquant : {field}",
4714
+ "hint": "Ce type de tâche exige « {field} », et la tâche n’y répond pas. Renseignez-le sur la tâche, puis revérifiez.",
4715
+ "unnamedField": "un champ obligatoire"
4716
+ },
4706
4717
  "unknown": {
4707
4718
  "title": "Constat non reconnu",
4708
4719
  "hint": "Cette exécution a enregistré une vérification que cette version ne connaît plus. Relisez la tâche à la main avant de continuer."
@@ -5359,6 +5370,7 @@
5359
5370
  "operation": {
5360
5371
  "provision": "Démarrer",
5361
5372
  "teardown": "Démanteler",
5373
+ "teardown-verify": "Vérification du démantèlement",
5362
5374
  "status": "Vérification de l'état",
5363
5375
  "dispatch": "Démarrer",
5364
5376
  "release": "Démanteler",
@@ -1003,6 +1003,12 @@
1003
1003
  "inherited": "נורש מהשירות",
1004
1004
  "frozen": "מוקפא: הסוכן כבר התחיל"
1005
1005
  },
1006
+ "taskTypeFields": {
1007
+ "title": "שדות סוג המשימה",
1008
+ "hint": "התשובות שסוג משימה זה מצהיר שהוא זקוק להן. תשובת חובה חסרה עוצרת הרצה לפני שהיא מתחילה.",
1009
+ "save": "שמירה",
1010
+ "revert": "שחזור"
1011
+ },
1006
1012
  "dependencies": {
1007
1013
  "title": "תלוי ב",
1008
1014
  "hint": "משימות שחייבות להתמזג לפני שמשימה זו תוכל לרוץ. פקד ההרצה נשאר נעול עד שכל התלויות הושלמו.",
@@ -4703,6 +4709,11 @@
4703
4709
  "title": "אין קריטריוני הצלחה",
4704
4710
  "hint": "ציינו את השאלה שהבדיקה המקדימה עונה עליה, או איך נראית תוצאה טובה, כדי שלמסגרת הזמן תהיה מטרה."
4705
4711
  },
4712
+ "required_field_missing": {
4713
+ "title": "חסר: {field}",
4714
+ "hint": "סוג משימה זה מחייב את “{field}”, והמשימה אינה ממלאת אותו. מלאו אותו במשימה ובדקו שוב.",
4715
+ "unnamedField": "שדה חובה"
4716
+ },
4706
4717
  "unknown": {
4707
4718
  "title": "ממצא לא מוכר",
4708
4719
  "hint": "ההרצה תיעדה בדיקה שהגרסה הזו כבר לא מכירה. עברו על המשימה ידנית לפני שתמשיכו."
@@ -5359,6 +5370,7 @@
5359
5370
  "operation": {
5360
5371
  "provision": "הקמה",
5361
5372
  "teardown": "פירוק",
5373
+ "teardown-verify": "בדיקת פירוק",
5362
5374
  "status": "בדיקת מצב",
5363
5375
  "dispatch": "הקמה",
5364
5376
  "release": "פירוק",
@@ -1533,6 +1533,12 @@
1533
1533
  "inherited": "ereditato dal servizio",
1534
1534
  "frozen": "Congelato: l'agente e' partito"
1535
1535
  },
1536
+ "taskTypeFields": {
1537
+ "title": "Campi del tipo di attività",
1538
+ "hint": "Le risposte che questo tipo di attività dichiara necessarie. Una risposta obbligatoria mancante mette in pausa l'esecuzione prima che inizi.",
1539
+ "save": "Salva",
1540
+ "revert": "Ripristina"
1541
+ },
1536
1542
  "dependencies": {
1537
1543
  "title": "Dipende da",
1538
1544
  "hint": "Attivita' che devono essere unite prima che questa possa essere eseguita. Il controllo Esegui resta bloccato finche' ogni dipendenza non e' completata.",
@@ -5561,6 +5567,11 @@
5561
5567
  "title": "Nessun criterio di successo",
5562
5568
  "hint": "Indica la domanda a cui risponde lo spike, o come si presenta un buon esito, così il tempo riservato ha un obiettivo."
5563
5569
  },
5570
+ "required_field_missing": {
5571
+ "title": "Manca: {field}",
5572
+ "hint": "Questo tipo di attività richiede «{field}» e l’attività non lo compila. Inseriscilo nell’attività, poi ricontrolla.",
5573
+ "unnamedField": "un campo obbligatorio"
5574
+ },
5564
5575
  "unknown": {
5565
5576
  "title": "Rilievo non riconosciuto",
5566
5577
  "hint": "Questa esecuzione ha registrato un controllo che questa versione non conosce più. Rivedi l'attività a mano prima di continuare."
@@ -5914,6 +5925,7 @@
5914
5925
  "operation": {
5915
5926
  "provision": "Avvio",
5916
5927
  "teardown": "Smantellamento",
5928
+ "teardown-verify": "Verifica dello smantellamento",
5917
5929
  "status": "Controllo dello stato",
5918
5930
  "dispatch": "Avvio",
5919
5931
  "release": "Smantellamento",
@@ -1003,6 +1003,12 @@
1003
1003
  "inherited": "サービスから継承",
1004
1004
  "frozen": "固定: エージェントは開始済みです"
1005
1005
  },
1006
+ "taskTypeFields": {
1007
+ "title": "タスクタイプのフィールド",
1008
+ "hint": "このタスクタイプが必要と宣言している回答です。必須の回答が欠けている場合、実行は開始前に保留されます。",
1009
+ "save": "保存",
1010
+ "revert": "元に戻す"
1011
+ },
1006
1012
  "dependencies": {
1007
1013
  "title": "依存先",
1008
1014
  "hint": "このタスクを実行する前にマージされている必要があるタスク。すべての依存関係が完了するまで実行コントロールはロックされたままです。",
@@ -4703,6 +4709,11 @@
4703
4709
  "title": "成功条件がありません",
4704
4710
  "hint": "スパイクが答える問い、または良い結果の姿を書いて、時間枠に目標を与えてください。"
4705
4711
  },
4712
+ "required_field_missing": {
4713
+ "title": "未入力: {field}",
4714
+ "hint": "このタスク種別は「{field}」を必須としていますが、タスクに入力がありません。タスクで入力してから再確認してください。",
4715
+ "unnamedField": "必須項目"
4716
+ },
4706
4717
  "unknown": {
4707
4718
  "title": "未知の指摘",
4708
4719
  "hint": "この実行には、このバージョンが把握していないチェックが記録されています。続行前にタスクを手動で確認してください。"
@@ -5359,6 +5370,7 @@
5359
5370
  "operation": {
5360
5371
  "provision": "起動",
5361
5372
  "teardown": "破棄",
5373
+ "teardown-verify": "破棄の確認",
5362
5374
  "status": "ステータスチェック",
5363
5375
  "dispatch": "起動",
5364
5376
  "release": "破棄",
@@ -1003,6 +1003,12 @@
1003
1003
  "inherited": "odziedziczone z usługi",
1004
1004
  "frozen": "Zamrożone: agent został uruchomiony"
1005
1005
  },
1006
+ "taskTypeFields": {
1007
+ "title": "Pola typu zadania",
1008
+ "hint": "Odpowiedzi, których wymaga ten typ zadania. Brak wymaganej odpowiedzi wstrzymuje uruchomienie przed startem.",
1009
+ "save": "Zapisz",
1010
+ "revert": "Przywróć"
1011
+ },
1006
1012
  "dependencies": {
1007
1013
  "title": "Zależy od",
1008
1014
  "hint": "Zadania, które muszą zostać scalone, zanim to będzie mogło się uruchomić. Przycisk uruchamiania pozostaje zablokowany, dopóki wszystkie zależności nie będą gotowe.",
@@ -4703,6 +4709,11 @@
4703
4709
  "title": "Brak kryteriów sukcesu",
4704
4710
  "hint": "Podaj pytanie, na które odpowiada spike, albo jak wygląda dobry wynik, żeby wyznaczony czas miał cel."
4705
4711
  },
4712
+ "required_field_missing": {
4713
+ "title": "Brakuje: {field}",
4714
+ "hint": "Ten typ zadania wymaga pola „{field}”, a zadanie go nie wypełnia. Uzupełnij je w zadaniu i sprawdź ponownie.",
4715
+ "unnamedField": "pole wymagane"
4716
+ },
4706
4717
  "unknown": {
4707
4718
  "title": "Nierozpoznane ustalenie",
4708
4719
  "hint": "Ten przebieg zapisał kontrolę, której ta wersja już nie zna. Przejrzyj zadanie ręcznie przed kontynuowaniem."
@@ -5359,6 +5370,7 @@
5359
5370
  "operation": {
5360
5371
  "provision": "Uruchom",
5361
5372
  "teardown": "Zatrzymaj",
5373
+ "teardown-verify": "Sprawdzenie zatrzymania",
5362
5374
  "status": "Sprawdzenie stanu",
5363
5375
  "dispatch": "Uruchom",
5364
5376
  "release": "Zatrzymaj",
@@ -1003,6 +1003,12 @@
1003
1003
  "inherited": "hizmetten devralındı",
1004
1004
  "frozen": "Donduruldu: ajan başladı"
1005
1005
  },
1006
+ "taskTypeFields": {
1007
+ "title": "Görev türü alanları",
1008
+ "hint": "Bu görev türünün gerekli olduğunu bildirdiği yanıtlar. Eksik bir zorunlu yanıt, çalıştırmayı başlamadan önce duraklatır.",
1009
+ "save": "Kaydet",
1010
+ "revert": "Geri al"
1011
+ },
1006
1012
  "dependencies": {
1007
1013
  "title": "Şuna bağlı",
1008
1014
  "hint": "Bu görev çalışmadan önce birleştirilmesi gereken görevler. Tüm bağımlılıklar tamamlanana kadar Çalıştır kontrolü kilitli kalır.",
@@ -4703,6 +4709,11 @@
4703
4709
  "title": "Başarı ölçütü yok",
4704
4710
  "hint": "Spike'ın yanıtladığı soruyu ya da iyi bir sonucun neye benzediğini yazın ki ayrılan sürenin bir hedefi olsun."
4705
4711
  },
4712
+ "required_field_missing": {
4713
+ "title": "Eksik: {field}",
4714
+ "hint": "Bu görev türü “{field}” alanını zorunlu kılıyor, ancak görevde doldurulmamış. Görevde doldurup yeniden denetleyin.",
4715
+ "unnamedField": "zorunlu bir alan"
4716
+ },
4706
4717
  "unknown": {
4707
4718
  "title": "Tanınmayan bulgu",
4708
4719
  "hint": "Bu çalışma, bu sürümün artık bilmediği bir denetimi kaydetmiş. Devam etmeden önce görevi elle gözden geçirin."
@@ -5359,6 +5370,7 @@
5359
5370
  "operation": {
5360
5371
  "provision": "Başlat",
5361
5372
  "teardown": "Kapat",
5373
+ "teardown-verify": "Kapatma kontrolü",
5362
5374
  "status": "Durum kontrolü",
5363
5375
  "dispatch": "Başlat",
5364
5376
  "release": "Kapat",
@@ -1003,6 +1003,12 @@
1003
1003
  "inherited": "успадковано від сервісу",
1004
1004
  "frozen": "Заморожено: агент уже стартував"
1005
1005
  },
1006
+ "taskTypeFields": {
1007
+ "title": "Поля типу завдання",
1008
+ "hint": "Відповіді, які потрібні цьому типу завдання. Відсутня обов'язкова відповідь зупиняє запуск до його початку.",
1009
+ "save": "Зберегти",
1010
+ "revert": "Скасувати зміни"
1011
+ },
1006
1012
  "dependencies": {
1007
1013
  "title": "Залежить від",
1008
1014
  "hint": "Завдання, які мають бути злиті, перш ніж це зможе запуститися. Кнопка запуску лишається заблокованою, доки всі залежності не виконано.",
@@ -4703,6 +4709,11 @@
4703
4709
  "title": "Немає критеріїв успіху",
4704
4710
  "hint": "Вкажіть питання, на яке відповідає spike, або який результат є добрим, щоб відведений час мав ціль."
4705
4711
  },
4712
+ "required_field_missing": {
4713
+ "title": "Бракує: {field}",
4714
+ "hint": "Цей тип завдання вимагає «{field}», але завдання його не заповнює. Заповніть його в завданні й перевірте ще раз.",
4715
+ "unnamedField": "обов'язкове поле"
4716
+ },
4706
4717
  "unknown": {
4707
4718
  "title": "Нерозпізнаний висновок",
4708
4719
  "hint": "Цей запуск зафіксував перевірку, якої ця версія вже не знає. Перегляньте завдання вручну, перш ніж продовжувати."
@@ -5359,6 +5370,7 @@
5359
5370
  "operation": {
5360
5371
  "provision": "Запустити",
5361
5372
  "teardown": "Згорнути",
5373
+ "teardown-verify": "Перевірка згортання",
5362
5374
  "status": "Перевірка стану",
5363
5375
  "dispatch": "Запустити",
5364
5376
  "release": "Згорнути",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.223.0",
3
+ "version": "0.224.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",
@@ -40,7 +40,7 @@
40
40
  "valibot": "^1.4.2",
41
41
  "vue": "3.5.40",
42
42
  "wretch": "^3.0.9",
43
- "@cat-factory/contracts": "0.239.0"
43
+ "@cat-factory/contracts": "0.240.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@toad-contracts/testing": "0.3.2",