@cat-factory/app 0.223.0 → 0.224.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,9 @@
1
1
  import { ref, computed, nextTick } from 'vue'
2
+ import { refuseGateResolution, UNATTRIBUTED_GATE_ACTOR } from '@cat-factory/contracts'
3
+ import type { GateActor, GateApprovalRefusal } from '@cat-factory/contracts'
2
4
  import type { PipelineStep } from '~/types/execution'
3
5
  import { useProseComments } from '~/composables/useProseComments'
6
+ import { useWorkspaceAccess } from '~/composables/useWorkspaceAccess'
4
7
 
5
8
  /**
6
9
  * The GitHub-style approval/review state machine for a pending gate step. When the
@@ -23,6 +26,8 @@ export function useStepApproval(opts: {
23
26
  close: () => void
24
27
  }) {
25
28
  const execution = useExecutionStore()
29
+ const auth = useAuthStore()
30
+ const access = useWorkspaceAccess()
26
31
 
27
32
  const feedback = ref('')
28
33
  const submitting = ref(false)
@@ -60,12 +65,71 @@ export function useStepApproval(opts: {
60
65
  () => !!feedback.value.trim() || reviewComments.value.length > 0,
61
66
  )
62
67
 
68
+ // ---- The gate's own POLICY, as the pipeline step configured it -------------------------
69
+ //
70
+ // Read from the SAME `@cat-factory/contracts` rules the engine enforces, never a local
71
+ // reimplementation: a button enabled by a second copy of the rule is a request the server
72
+ // refuses, and the person pressing it has no way to tell which of the two is right.
73
+
74
+ const approval = computed(() => opts.step()?.approval ?? null)
75
+
76
+ /**
77
+ * The gate's quorum, or null when it needs the usual single approval. Non-null is what makes
78
+ * the rail say "1 of 2 approvals" — otherwise an approve that correctly leaves the run parked
79
+ * looks exactly like one that failed.
80
+ */
81
+ const quorum = computed(() => {
82
+ const required = approval.value?.requiredApprovals ?? 1
83
+ if (required <= 1) return null
84
+ return { required, recorded: approval.value?.approvals?.length ?? 0 }
85
+ })
86
+
87
+ /** Whether the viewer's own approval is already counted (so the rail can say so). */
88
+ const viewerHasApproved = computed(() => {
89
+ const userId = auth.user?.id
90
+ return !!userId && !!approval.value?.approvals?.some((a) => a.actorId === userId)
91
+ })
92
+
93
+ /**
94
+ * Whether the viewer's approval would be the one that CLEARS the gate. Always true without a
95
+ * quorum; under one it folds the viewer in the way the server does, so a re-approval by someone
96
+ * already counted does not read as a new vote.
97
+ *
98
+ * This is what decides whether "approve with corrections" is offered: a quorum votes on ONE
99
+ * artifact, so an edit that does not clear the gate would rewrite the proposal under the people
100
+ * already counted toward it and the ones still to come. The server refuses that
101
+ * (`proposal_not_editable_until_quorum`); hiding the affordance is what stops a reviewer typing
102
+ * a correction into a dead end, the same disposition as `outputIsRendered`.
103
+ */
104
+ const approvalWouldClearGate = computed(() => {
105
+ const q = quorum.value
106
+ if (!q) return true
107
+ return (viewerHasApproved.value ? q.recorded : q.recorded + 1) >= q.required
108
+ })
109
+
110
+ /**
111
+ * Why the viewer may not resolve this gate, or null when they may. Drives the disabled state of
112
+ * all three verbs, since the policy governs every resolution and not just approve.
113
+ *
114
+ * With auth off there is no signed-in user, and the actor is `unattributed` — exactly what the
115
+ * server will decide with, so the rail refuses ahead of it rather than offering a button whose
116
+ * request comes back 403.
117
+ */
118
+ const refusal = computed<GateApprovalRefusal | null>(() => {
119
+ if (!approval.value) return null
120
+ const user = auth.user
121
+ const actor: GateActor = user
122
+ ? { id: user.id, kind: 'user', role: access.role.value }
123
+ : { id: UNATTRIBUTED_GATE_ACTOR, kind: 'unattributed', role: access.role.value }
124
+ return refuseGateResolution(approval.value.approverPolicy, actor)
125
+ })
126
+
63
127
  // Plain approve: accept the agent's proposal verbatim and advance. Every action below
64
128
  // closes the overlay ONLY when the command actually ran — a server refusal (surfaced as
65
129
  // a toast by the store) or a cancelled credential prompt keeps the review open.
66
130
  async function approve() {
67
131
  const id = opts.approvalId()
68
- if (!opts.instanceId() || !id || submitting.value) return
132
+ if (!opts.instanceId() || !id || submitting.value || refusal.value) return
69
133
  submitting.value = true
70
134
  try {
71
135
  if (await execution.approveStep(opts.instanceId()!, id)) opts.close()
@@ -88,7 +152,9 @@ export function useStepApproval(opts: {
88
152
  }
89
153
  async function approveWithEdits() {
90
154
  const id = opts.approvalId()
91
- if (!opts.instanceId() || !id || submitting.value) return
155
+ if (!opts.instanceId() || !id || submitting.value || refusal.value) return
156
+ // The server refuses an edit that does not clear the gate; never send one.
157
+ if (!approvalWouldClearGate.value) return
92
158
  submitting.value = true
93
159
  try {
94
160
  if (await execution.approveStep(opts.instanceId()!, id, draftProposal.value)) opts.close()
@@ -99,6 +165,7 @@ export function useStepApproval(opts: {
99
165
  async function requestChanges() {
100
166
  const id = opts.approvalId()
101
167
  if (!opts.instanceId() || !id || submitting.value || !canRequestChanges.value) return
168
+ if (refusal.value) return
102
169
  submitting.value = true
103
170
  try {
104
171
  const ok = await execution.requestStepChanges(opts.instanceId()!, id, {
@@ -118,7 +185,7 @@ export function useStepApproval(opts: {
118
185
  }
119
186
  async function reject() {
120
187
  const id = opts.approvalId()
121
- if (!opts.instanceId() || !id || submitting.value) return
188
+ if (!opts.instanceId() || !id || submitting.value || refusal.value) return
122
189
  submitting.value = true
123
190
  try {
124
191
  if (await execution.rejectStep(opts.instanceId()!, id, feedback.value.trim() || undefined)) {
@@ -159,6 +226,10 @@ export function useStepApproval(opts: {
159
226
  draftProposal,
160
227
  rejectArmed,
161
228
  canRequestChanges,
229
+ quorum,
230
+ viewerHasApproved,
231
+ approvalWouldClearGate,
232
+ refusal,
162
233
  syncHighlights,
163
234
  onProseClick,
164
235
  addDraftComment,
@@ -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]
@@ -2,6 +2,7 @@ import { computed } from 'vue'
2
2
  import type { AgentKind, Pipeline } from '~/types/domain'
3
3
  import { companionForProducer } from '~/utils/catalog'
4
4
  import type { PipelinesContext } from './context'
5
+ import { createPipelineGateConfigActions } from './draftGateConfig'
5
6
  import { createPipelineStepConfigActions } from './draftStepConfig'
6
7
 
7
8
  /**
@@ -192,6 +193,7 @@ export function createPipelineDraftActions(ctx: PipelinesContext) {
192
193
 
193
194
  return {
194
195
  ...createPipelineStepConfigActions(ctx),
196
+ ...createPipelineGateConfigActions(ctx),
195
197
  addToDraft,
196
198
  removeFromDraft,
197
199
  moveInDraft,
@@ -0,0 +1,55 @@
1
+ import type { StepGateConfig, StepOptions } from '@cat-factory/contracts'
2
+ import { hasApproverPolicy } from '@cat-factory/contracts'
3
+ import type { PipelinesContext } from './context'
4
+
5
+ /**
6
+ * The pipeline-builder draft's per-step GATE configuration: who may clear a step's human approval
7
+ * gate, how many of them must, and the parameters the step's registered gate declares.
8
+ *
9
+ * Its own module rather than another pair of accessors on `./draftStepConfig`, which owns the flat
10
+ * per-step options (a skill id, a variant id, a token ceiling — each one field, read and written
11
+ * whole). Gate config is the one nested value there: it MERGES a patch across two independently
12
+ * edited halves and normalizes at three levels on the way out, which is a different enough shape to
13
+ * be worth reading on its own. (It also put that file over the per-function line budget, which is
14
+ * the ratchet doing its job rather than a number to raise.)
15
+ */
16
+ export function createPipelineGateConfigActions(ctx: PipelinesContext) {
17
+ const { draftStepOptions } = ctx
18
+
19
+ /**
20
+ * The gate configuration on the draft step at `index`, or undefined when the step takes every
21
+ * default (one approval from anyone entitled to write, and the gate's shipped knobs).
22
+ */
23
+ function draftGateConfig(index: number): StepGateConfig | undefined {
24
+ return draftStepOptions.value[index]?.gateConfig
25
+ }
26
+
27
+ /**
28
+ * Merge a PATCH into the draft step's gate configuration. A patch rather than a whole-value set
29
+ * because the builder edits the two halves through different controls: a whole-value write from
30
+ * the approver fields would drop the gate's own parameters, and vice versa.
31
+ *
32
+ * Normalizes downward at every level: a field set back to its default is deleted, an emptied
33
+ * `gateConfig` is dropped, and an emptied options bag becomes `null`. So a step returned to the
34
+ * defaults persists nothing at all — the same rule the other per-step options follow, and what
35
+ * keeps an all-default pipeline from growing a `step_options` array it does not need.
36
+ *
37
+ * An empty approver policy is dropped rather than stored, and that one is not just tidiness:
38
+ * `{ roles: [] }` names nobody, and a policy naming nobody would refuse every approver and park
39
+ * the run forever. "No rule" has to persist as an ABSENT rule.
40
+ */
41
+ function patchDraftGateConfig(index: number, patch: Partial<StepGateConfig>) {
42
+ const gateConfig: StepGateConfig = { ...draftGateConfig(index), ...patch }
43
+ if (!hasApproverPolicy(gateConfig.approvers)) delete gateConfig.approvers
44
+ if (gateConfig.minApprovals !== undefined && gateConfig.minApprovals <= 1) {
45
+ delete gateConfig.minApprovals
46
+ }
47
+ if (gateConfig.fields && Object.keys(gateConfig.fields).length === 0) delete gateConfig.fields
48
+ const next: StepOptions = { ...draftStepOptions.value[index] }
49
+ if (Object.keys(gateConfig).length) next.gateConfig = gateConfig
50
+ else delete next.gateConfig
51
+ draftStepOptions.value[index] = Object.keys(next).length ? next : null
52
+ }
53
+
54
+ return { draftGateConfig, patchDraftGateConfig }
55
+ }
@@ -7,7 +7,8 @@ import { defaultConsensusConfig, type PipelinesContext } from './context'
7
7
  * consensus-GROUP tier set), the human approval gate, the estimate gate on a companion step, the
8
8
  * follow-up and test-QC companions, the per-step enable flag, and the `StepOptions` bag
9
9
  * (requirements auto-recommendation, the picked skill, the picked agent-kind variant, the
10
- * per-step output-token ceiling, the binary-output storage/context selection).
10
+ * per-step output-token ceiling, the binary-output storage/context selection). The step's GATE
11
+ * configuration is the sibling `./draftGateConfig`, which explains why it is not here.
11
12
  *
12
13
  * Split out of `./draftActions`, which owns the draft's STRUCTURE (insert / remove / reorder /
13
14
  * units). Every function here reads and writes one of the parallel per-step arrays at an index and
@@ -1,7 +1,7 @@
1
1
  import { defineStore } from 'pinia'
2
2
  import { ref } from 'vue'
3
3
  import type { Pipeline } from '~/types/domain'
4
- import type { PipelinePurpose, RetiredPipelineWire } from '@cat-factory/contracts'
4
+ import type { GateConfigForm, PipelinePurpose, RetiredPipelineWire } from '@cat-factory/contracts'
5
5
  import { useUpsertList } from '~/composables/useUpsertList'
6
6
  import { createDraftStepState, type PipelinesContext } from '~/stores/pipelines/context'
7
7
  import { createPipelineDraftActions } from '~/stores/pipelines/draftActions'
@@ -39,6 +39,13 @@ export const usePipelinesStore = defineStore('pipelines', () => {
39
39
  * `usePipelineHealth`). Disjoint from {@link catalogVersions} by construction.
40
40
  */
41
41
  const retiredPipelines = ref<RetiredPipelineWire[]>([])
42
+ /**
43
+ * The per-step parameters each registered GATE declares, projected from the deployment's gate
44
+ * registry onto the board snapshot. The builder renders a gated step's own config form from
45
+ * these, so what it can save is exactly what run admission validates. Empty on a deployment
46
+ * whose gates declare nothing.
47
+ */
48
+ const gateConfigForms = ref<GateConfigForm[]>([])
42
49
 
43
50
  // The per-step, index-aligned draft arrays (kept in lockstep — see `createDraftStepState`).
44
51
  const {
@@ -83,6 +90,16 @@ export const usePipelinesStore = defineStore('pipelines', () => {
83
90
  retiredPipelines.value = retired ?? []
84
91
  }
85
92
 
93
+ /**
94
+ * Replace the registered gates' declared config forms from a snapshot. Applied even when EMPTY,
95
+ * for the reason `retired` is: carrying the previous board's forward would offer a form for a
96
+ * parameter this deployment's gates do not declare, and a value saved through it would then be
97
+ * refused at save by the very registry that never declared it.
98
+ */
99
+ function hydrateGateConfigForms(forms: GateConfigForm[]) {
100
+ gateConfigForms.value = forms
101
+ }
102
+
86
103
  function getPipeline(id: string) {
87
104
  return pipelines.value.find((p) => p.id === id)
88
105
  }
@@ -117,6 +134,7 @@ export const usePipelinesStore = defineStore('pipelines', () => {
117
134
  pipelines,
118
135
  catalogVersions,
119
136
  retiredPipelines,
137
+ gateConfigForms,
120
138
  draft,
121
139
  draftGates,
122
140
  draftEnabled,
@@ -132,6 +150,7 @@ export const usePipelinesStore = defineStore('pipelines', () => {
132
150
  draftDescription,
133
151
  editingId,
134
152
  hydrate,
153
+ hydrateGateConfigForms,
135
154
  getPipeline,
136
155
  ...draftActions,
137
156
  ...persistence,
@@ -114,6 +114,9 @@ export function applySnapshotToStores(snapshot: WorkspaceSnapshot, boardSince?:
114
114
  snapshot.binaryGeneratorsUnavailable === true,
115
115
  )
116
116
  useTaskTypesStore().hydrateCapabilities(capabilities)
117
+ // The per-step parameters each registered gate declares, so a gated step's config form in the
118
+ // builder comes from the gate's own registration rather than a form hard-coded per gate.
119
+ usePipelinesStore().hydrateGateConfigForms(snapshot.gateConfigForms ?? [])
117
120
  // The account's repo-sourced Claude Skills catalog (shared across its workspaces), so the
118
121
  // pipeline builder's per-step skill picker has its options. A straight replace.
119
122
  useSkillsStore().hydrate(snapshot.skills ?? [])
@@ -73,6 +73,11 @@ export type {
73
73
  DescriptorField,
74
74
  DescriptorFieldValue,
75
75
  DescriptorFieldValues,
76
+ // Per-step GATE configuration: who may resolve a human approval gate, how many of them, and
77
+ // the parameters the step's registered gate declares (`contracts/src/gate-config.ts`).
78
+ GateApproverPolicy,
79
+ GateConfigForm,
80
+ StepGateConfig,
76
81
  Pipeline,
77
82
  PipelinePurpose,
78
83
  SpendStatus,
@@ -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.",
@@ -1979,7 +1985,12 @@
1979
1985
  "notRun": "nicht ausgeführt",
1980
1986
  "attempts": "Versuch {attempts} von {maxAttempts}",
1981
1987
  "omittedTestPaths": "{count} deklarierte Testdatei wurde vor dem Nachweis verworfen, daher wurde der Stand vor dem Fix aus einer unvollständigen Reproduktion aufgebaut. | {count} deklarierte Testdateien wurden vor dem Nachweis verworfen, daher wurde der Stand vor dem Fix aus einer unvollständigen Reproduktion aufgebaut."
1982
- }
1988
+ },
1989
+ "quorumProgress": "{recorded} von {required} Freigaben",
1990
+ "quorumYours": "(deine ist gezählt)",
1991
+ "quorumEditLocked": "Korrekturen sind möglich, sobald deine Freigabe dieses Gate auflöst. Bis dahin unverändert freigeben oder Änderungen anfordern.",
1992
+ "notAnApprover": "Dieses Gate benennt, wer es auflösen darf, und du gehörst nicht dazu.",
1993
+ "approverIdentityRequired": "Dieses Gate benennt, wer es auflösen darf; eine dieser Personen muss dafür angemeldet sein."
1983
1994
  },
1984
1995
  "inspector": {
1985
1996
  "frameStatus": {
@@ -4123,6 +4134,17 @@
4123
4134
  "hint": "Der Höchstumfang einer einzelnen Antwort dieses Agenten. Leer lassen heißt vom nächsthöheren Level erben, nicht das Limit aufheben. Der Wert eines Pipeline-Schritts sticht die Vorgabe des Workspace.",
4124
4135
  "inherits": "Übernommen",
4125
4136
  "inheritsValue": "Übernommen ({tokens})"
4137
+ },
4138
+ "gateConfig": {
4139
+ "approversLabel": "Wer freigeben darf",
4140
+ "roleAdmin": "Administratoren",
4141
+ "roleMember": "Mitglieder",
4142
+ "namedApproversLabel": "Benannte Freigebende",
4143
+ "namedApproversPlaceholder": "Alle mit Schreibrecht",
4144
+ "requiredApprovalsLabel": "Erforderliche Freigaben",
4145
+ "requiredApprovalsHint": "Jeweils von einer anderen Person, bevor der Lauf fortgesetzt wird.",
4146
+ "anyoneHint": "Alle, die dieses Board bearbeiten dürfen, können diesen Schritt freigeben.",
4147
+ "gateParametersLabel": "Gate-Einstellungen"
4126
4148
  }
4127
4149
  },
4128
4150
  "agentPrompt": {
@@ -5561,6 +5583,11 @@
5561
5583
  "title": "Keine Erfolgskriterien",
5562
5584
  "hint": "Nenne die Frage, die der Spike beantwortet, oder wie ein gutes Ergebnis aussieht, damit die Zeitbox ein Ziel hat."
5563
5585
  },
5586
+ "required_field_missing": {
5587
+ "title": "Fehlt: {field}",
5588
+ "hint": "Dieser Aufgabentyp verlangt „{field}“, die Aufgabe beantwortet es aber nicht. Trage es an der Aufgabe nach und prüfe erneut.",
5589
+ "unnamedField": "ein Pflichtfeld"
5590
+ },
5564
5591
  "unknown": {
5565
5592
  "title": "Unbekannter Befund",
5566
5593
  "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 +5941,7 @@
5914
5941
  "operation": {
5915
5942
  "provision": "Hochfahren",
5916
5943
  "teardown": "Abbauen",
5944
+ "teardown-verify": "Abbau-Prüfung",
5917
5945
  "status": "Statusprüfung",
5918
5946
  "dispatch": "Hochfahren",
5919
5947
  "release": "Abbauen",