@cat-factory/app 0.187.0 → 0.189.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +26 -0
  2. package/app/components/consensus/ConsensusSessionWindow.vue +11 -0
  3. package/app/components/palettes/AgentPalette.vue +12 -1
  4. package/app/components/palettes/AgentTierSelect.vue +67 -0
  5. package/app/components/pipeline/PipelineBuilder.vue +166 -92
  6. package/app/components/pipeline/PipelineHealthModal.vue +68 -12
  7. package/app/components/settings/ConsensusGroupsSection.vue +498 -0
  8. package/app/components/settings/ModelConfigurationPanel.vue +34 -2
  9. package/app/composables/api/presets.ts +22 -0
  10. package/app/composables/usePipelineErrorToast.ts +17 -0
  11. package/app/composables/usePipelineHealth.spec.ts +79 -7
  12. package/app/composables/usePipelineHealth.ts +70 -3
  13. package/app/modular/agent-kinds.spec.ts +9 -3
  14. package/app/modular/agent-kinds.ts +4 -0
  15. package/app/stores/agentTier.spec.ts +24 -0
  16. package/app/stores/agentTier.ts +42 -0
  17. package/app/stores/consensusGroups.ts +77 -0
  18. package/app/stores/pipelines/draftActions.ts +11 -113
  19. package/app/stores/pipelines/draftStepConfig.ts +157 -0
  20. package/app/stores/pipelines.ts +21 -3
  21. package/app/stores/workspace/hydrate.ts +7 -1
  22. package/app/types/consensus.ts +3 -0
  23. package/app/types/domain.ts +8 -1
  24. package/app/utils/agentTier.spec.ts +59 -0
  25. package/app/utils/agentTier.ts +49 -0
  26. package/app/utils/catalog.spec.ts +12 -0
  27. package/app/utils/catalog.ts +58 -2
  28. package/i18n/locales/de.json +74 -5
  29. package/i18n/locales/en.json +80 -5
  30. package/i18n/locales/es.json +74 -5
  31. package/i18n/locales/fr.json +74 -5
  32. package/i18n/locales/he.json +74 -5
  33. package/i18n/locales/it.json +74 -5
  34. package/i18n/locales/ja.json +74 -5
  35. package/i18n/locales/pl.json +74 -5
  36. package/i18n/locales/tr.json +74 -5
  37. package/i18n/locales/uk.json +74 -5
  38. package/package.json +2 -2
@@ -27,14 +27,26 @@ function builtin(agentKinds: string[], over: Partial<Pipeline> = {}): Pipeline {
27
27
  }
28
28
  }
29
29
 
30
- /** Seed the store with pipelines + their current catalog versions, then scan. */
31
- function scan(pipelines: Pipeline[], versions: Record<string, number> = {}) {
30
+ /**
31
+ * Seed the store with pipelines + their current catalog versions (and any RETIREMENTS), then scan.
32
+ * A retired id is dropped from the derived catalog versions, mirroring the backend where the two
33
+ * sets are disjoint by construction — a test that seeded both would assert against a snapshot the
34
+ * facade cannot produce.
35
+ */
36
+ function scan(
37
+ pipelines: Pipeline[],
38
+ versions: Record<string, number> = {},
39
+ retired: { id: string; replacedBy?: string }[] = [],
40
+ ) {
32
41
  const store = usePipelinesStore()
33
- const catalogVersions = {
34
- ...Object.fromEntries(pipelines.filter((p) => p.builtin).map((p) => [p.id, p.version ?? 0])),
35
- ...versions,
36
- }
37
- store.hydrate(pipelines, catalogVersions)
42
+ const retiredIds = new Set(retired.map((r) => r.id))
43
+ const catalogVersions = Object.fromEntries(
44
+ Object.entries({
45
+ ...Object.fromEntries(pipelines.filter((p) => p.builtin).map((p) => [p.id, p.version ?? 0])),
46
+ ...versions,
47
+ }).filter(([id]) => !retiredIds.has(id)),
48
+ )
49
+ store.hydrate(pipelines, catalogVersions, retired)
38
50
  return usePipelineHealth()
39
51
  }
40
52
 
@@ -164,4 +176,64 @@ describe('usePipelineHealth', () => {
164
176
  expect(newPipelines.value).toHaveLength(0)
165
177
  expect(hasIssues.value).toBe(false)
166
178
  })
179
+ it('reports a stored built-in the catalog retired, with no reseed offer', () => {
180
+ const stale = builtin(['coder', 'reviewer'], { id: 'pl_gone', name: 'Old flow', version: 1 })
181
+ const { retired, invalid, outdated, newPipelines, hasIssues } = scan([stale], {}, [
182
+ { id: 'pl_gone' },
183
+ ])
184
+ expect(retired.value).toHaveLength(1)
185
+ expect(retired.value[0]!.pipeline.id).toBe('pl_gone')
186
+ expect(retired.value[0]!.replacement).toBeUndefined()
187
+ expect(hasIssues.value).toBe(true)
188
+ // Retirement is answered by a REMOVAL, so the pipeline must appear in no reseed-shaped list.
189
+ expect(invalid.value).toHaveLength(0)
190
+ expect(outdated.value).toHaveLength(0)
191
+ expect(newPipelines.value).toHaveLength(0)
192
+ })
193
+
194
+ it('resolves a retirement replacement to the stored pipeline it names', () => {
195
+ const stale = builtin(['coder'], { id: 'pl_gone', name: 'Old flow', version: 1 })
196
+ const live = builtin(['coder', 'reviewer'], { id: 'pl_simple', name: 'Simple', version: 1 })
197
+ const { retired } = scan([stale, live], {}, [{ id: 'pl_gone', replacedBy: 'pl_simple' }])
198
+ expect(retired.value[0]!.replacement).toEqual({ id: 'pl_simple', name: 'Simple' })
199
+ })
200
+
201
+ it('names a replacement that is in the catalog but NOT yet stored on this board', () => {
202
+ // The canonical retirement: an old flow superseded by a NEWLY SHIPPED built-in. The replacement
203
+ // is in `catalogVersions` with no row until someone adds it — it is simultaneously a
204
+ // `newPipelines` entry — so resolving only against stored pipelines silently dropped the
205
+ // "Use X instead" sentence in exactly the case `replacedBy` exists to serve.
206
+ const stale = builtin(['coder'], { id: 'pl_gone', name: 'Old flow', version: 1 })
207
+ const { retired, newPipelines } = scan([stale], { pl_bug_triage: 1 }, [
208
+ { id: 'pl_gone', replacedBy: 'pl_bug_triage' },
209
+ ])
210
+ expect(newPipelines.value.map((p) => p.id)).toContain('pl_bug_triage')
211
+ expect(retired.value[0]!.replacement).toEqual({ id: 'pl_bug_triage', name: 'bug triage' })
212
+ })
213
+
214
+ it('leaves the replacement unnamed when the id resolves nowhere', () => {
215
+ // A SPA running against a newer backend can be handed a `replacedBy` it knows nothing about.
216
+ // The advisory falls back to the un-named copy rather than inventing a name for it.
217
+ const stale = builtin(['coder'], { id: 'pl_gone', name: 'Old flow', version: 1 })
218
+ const { retired } = scan([stale], {}, [{ id: 'pl_gone', replacedBy: 'pl_from_the_future' }])
219
+ expect(retired.value[0]!.replacement).toBeUndefined()
220
+ })
221
+
222
+ it('keeps an INVALID retired built-in out of the invalid list (its Reseed could only fail)', () => {
223
+ // The regression this pins: a retired pipeline that also references an unknown kind used to
224
+ // land in `invalid` with a built-in Reseed button, and reseed 422s for an id the catalog no
225
+ // longer defines — an advisory offering a fix that cannot work.
226
+ const broken = builtin(['coder', 'bogus-kind'], { id: 'pl_gone', version: 1 })
227
+ const { invalid, retired } = scan([broken], {}, [{ id: 'pl_gone' }])
228
+ expect(invalid.value).toHaveLength(0)
229
+ expect(retired.value).toHaveLength(1)
230
+ })
231
+
232
+ it('ignores a retirement for a pipeline this workspace never stored', () => {
233
+ // Nothing to clean up: the board was created after the withdrawal, so it was never seeded.
234
+ const stored = builtin(['coder', 'reviewer'], { id: 'pl_full', version: 1 })
235
+ const { retired, hasIssues } = scan([stored], { pl_full: 1 }, [{ id: 'pl_gone' }])
236
+ expect(retired.value).toHaveLength(0)
237
+ expect(hasIssues.value).toBe(false)
238
+ })
167
239
  })
@@ -7,7 +7,7 @@ import { usePipelinesStore } from '~/stores/pipelines'
7
7
  /** Estimate-gating consults a `task-estimator` step (mirrors the backend constant). */
8
8
  const TASK_ESTIMATOR_KIND = 'task-estimator'
9
9
 
10
- export type PipelineProblemType = 'unknown-kind' | 'shape' | 'outdated'
10
+ export type PipelineProblemType = 'unknown-kind' | 'shape' | 'outdated' | 'retired'
11
11
 
12
12
  export interface PipelineProblem {
13
13
  type: PipelineProblemType
@@ -23,6 +23,30 @@ export interface PipelineHealth {
23
23
  outdated: boolean
24
24
  }
25
25
 
26
+ /**
27
+ * A stored built-in that has been WITHDRAWN from the catalog — no longer relevant, and removable
28
+ * (the one case where deleting a built-in is allowed).
29
+ *
30
+ * It is a list of its own rather than a {@link PipelineProblem} on {@link PipelineHealth} because
31
+ * every problem there is answered by a RESEED, and a retired pipeline has no catalog definition
32
+ * left to reseed from. Keeping it separate is what guarantees the advisory can never offer both
33
+ * fixes for one row — a retired pipeline is skipped by the health scan entirely.
34
+ */
35
+ export interface RetiredPipelineHealth {
36
+ pipeline: Pipeline
37
+ /**
38
+ * The live pipeline that supersedes it, when the catalog names one — resolved to a display name
39
+ * so the advisory can write "Use {name} instead".
40
+ *
41
+ * Deliberately NOT a {@link Pipeline}: the replacement usually is NOT one this workspace stores.
42
+ * The canonical retirement is "old flow superseded by a NEWLY SHIPPED built-in", and a new
43
+ * built-in lives in `catalogVersions` with no row until someone reseeds it — it is literally a
44
+ * {@link NewPipeline} at that moment. Typing this as a stored `Pipeline` made the replacement
45
+ * unresolvable in exactly the case `replacedBy` exists for, silently dropping the sentence.
46
+ */
47
+ replacement?: { id: string; name: string }
48
+ }
49
+
26
50
  /** A brand-new built-in pipeline that appeared in the catalog but isn't in the workspace yet. */
27
51
  export interface NewPipeline {
28
52
  /** The catalog (built-in) id — what the reseed endpoint is keyed by (it creates the row). */
@@ -117,9 +141,18 @@ function shapeProblem(p: Pipeline): string | null {
117
141
  export function usePipelineHealth() {
118
142
  const store = usePipelinesStore()
119
143
 
144
+ /** Catalog ids the backend reports as withdrawn, indexed to their (optional) replacement id. */
145
+ const retiredIds = computed(
146
+ () => new Map(store.retiredPipelines.map((p) => [p.id, p.replacedBy])),
147
+ )
148
+
120
149
  const health = computed<PipelineHealth[]>(() => {
121
150
  const out: PipelineHealth[] = []
122
151
  for (const pipeline of store.pipelines) {
152
+ // A retired pipeline is reported by `retired` below, never here: every problem this scan
153
+ // raises is answered by a reseed, and there is no catalog definition left to reseed from.
154
+ // (An invalid retired pipeline would otherwise get a Reseed button that can only 422.)
155
+ if (retiredIds.value.has(pipeline.id)) continue
123
156
  const problems: PipelineProblem[] = []
124
157
 
125
158
  const unknown = [...new Set(pipeline.agentKinds.filter((k) => !isKnownAgentKind(k)))]
@@ -158,11 +191,45 @@ export function usePipelineHealth() {
158
191
  .map((id) => ({ id, name: builtinPipelineName(id) }))
159
192
  })
160
193
 
194
+ // Retired built-ins this workspace still stores: the ones seeded before the withdrawal. A
195
+ // retirement the board never had a row for is nothing to report — there is no cleanup to do.
196
+ const retired = computed<RetiredPipelineHealth[]>(() =>
197
+ store.pipelines
198
+ .filter((p) => retiredIds.value.has(p.id))
199
+ .map((pipeline) => {
200
+ const replacementId = retiredIds.value.get(pipeline.id)
201
+ const replacement = replacementId ? resolveReplacement(replacementId) : undefined
202
+ return { pipeline, ...(replacement ? { replacement } : {}) }
203
+ }),
204
+ )
205
+
206
+ /**
207
+ * Name the pipeline a retirement points at. Two sources, in order, because a replacement is a
208
+ * LIVE catalog id and a live catalog id may or may not have been seeded into this workspace yet:
209
+ * the stored row's authored name when there is one, else the catalog-derived name — the same
210
+ * `builtinPipelineName` fallback `newPipelines` uses for exactly this "in the catalog, no row
211
+ * yet" state. Reading only the store would blank the replacement on the most common retirement
212
+ * (superseded by a newly shipped built-in, which by definition has no row until it is added).
213
+ *
214
+ * An id in neither returns undefined and the advisory falls back to the un-named copy: the
215
+ * backend guards `replacedBy` against naming a non-existent pipeline, but a SPA running against
216
+ * a newer backend can still be handed one it doesn't know, and inventing a name for it would be
217
+ * worse than saying nothing.
218
+ */
219
+ function resolveReplacement(id: string): { id: string; name: string } | undefined {
220
+ const stored = store.getPipeline(id)
221
+ if (stored) return { id, name: stored.name }
222
+ if (id in store.catalogVersions) return { id, name: builtinPipelineName(id) }
223
+ return undefined
224
+ }
225
+
161
226
  // An invalid built-in is reseeded (not deleted) and that also clears any "outdated" flag, so
162
227
  // exclude it from the outdated list to avoid offering the same fix twice.
163
228
  const invalid = computed(() => health.value.filter((h) => h.invalid))
164
229
  const outdated = computed(() => health.value.filter((h) => h.outdated && !h.invalid))
165
- const hasIssues = computed(() => health.value.length > 0 || newPipelines.value.length > 0)
230
+ const hasIssues = computed(
231
+ () => health.value.length > 0 || newPipelines.value.length > 0 || retired.value.length > 0,
232
+ )
166
233
 
167
- return { health, invalid, outdated, newPipelines, hasIssues }
234
+ return { health, invalid, outdated, newPipelines, retired, hasIssues }
168
235
  }
@@ -28,15 +28,21 @@ describe('customKindToArchetype', () => {
28
28
  })
29
29
  })
30
30
 
31
- it('carries category and resultView through when present', () => {
32
- const a = customKindToArchetype(kind({ category: 'review', resultView: 'acme:audit' }))
31
+ it('carries category, tier and resultView through when present', () => {
32
+ const a = customKindToArchetype(
33
+ kind({ category: 'review', tier: 'basic', resultView: 'acme:audit' }),
34
+ )
33
35
  expect(a.category).toBe('review')
36
+ expect(a.tier).toBe('basic')
34
37
  expect(a.resultView).toBe('acme:audit')
35
38
  })
36
39
 
37
- it('omits category/resultView when absent (no undefined keys)', () => {
40
+ it('omits category/tier/resultView when absent (no undefined keys)', () => {
38
41
  const a = customKindToArchetype(kind())
39
42
  expect('category' in a).toBe(false)
43
+ // Left UNSET rather than stamped with the default, so the fallback stays in one place
44
+ // (`agentTierVisibleAt`) instead of being forked into this projection.
45
+ expect('tier' in a).toBe(false)
40
46
  expect('resultView' in a).toBe(false)
41
47
  })
42
48
  })
@@ -27,6 +27,10 @@ export function customKindToArchetype(kind: CustomAgentKind): AgentArchetype {
27
27
  color: p.color,
28
28
  description: p.description,
29
29
  ...(p.category ? { category: p.category } : {}),
30
+ // A kind that declares no tier is left WITHOUT one rather than stamped with the default
31
+ // here, so the single fallback stays in `agentTierVisibleAt` — filling it in at the
32
+ // projection would fork the rule the moment the default changes.
33
+ ...(p.tier ? { tier: p.tier } : {}),
30
34
  ...(p.resultView ? { resultView: p.resultView } : {}),
31
35
  }
32
36
  }
@@ -0,0 +1,24 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import type { AgentTier } from '~/types/domain'
3
+ import { useAgentTierStore } from '~/stores/agentTier'
4
+
5
+ describe('agentTier store', () => {
6
+ it('opens on the everyday-loop tier and records a widening', () => {
7
+ const store = useAgentTierStore()
8
+ expect(store.tier).toBe('basic')
9
+ expect(store.showsAll).toBe(false)
10
+
11
+ store.setTier('advanced')
12
+ expect(store.tier).toBe('advanced')
13
+ // The widest level is the "show everything" setting the surfaces advertise.
14
+ expect(store.showsAll).toBe(true)
15
+ })
16
+
17
+ it('falls back to the default when the persisted value is not a known tier', () => {
18
+ const store = useAgentTierStore()
19
+ // What a blob written by an older build (or hand-edited) looks like coming back in. A
20
+ // catalog filtered on an unknown level would list nothing at all, so this must not pass through.
21
+ store.storedTier = 'expert' as AgentTier
22
+ expect(store.tier).toBe('basic')
23
+ })
24
+ })
@@ -0,0 +1,42 @@
1
+ import { defineStore } from 'pinia'
2
+ import { computed, ref } from 'vue'
3
+ import type { AgentTier } from '~/types/domain'
4
+ import { DEFAULT_AGENT_TIER_LEVEL, isAgentTier } from '~/utils/agentTier'
5
+
6
+ /**
7
+ * How deep into the agent catalog the two catalog surfaces reach — the pipeline builder's
8
+ * palette and the model preset's per-agent override list.
9
+ *
10
+ * ONE persisted preference shared by both, not a per-surface one: the two are halves of the
11
+ * same job (pick the agents a pipeline runs, then pick what each of them runs on), so a user
12
+ * who widened the palette to reach a specialist kind expects to find that same kind when they
13
+ * go to pin its model. Persisted like the interface mode's own preference, so the choice
14
+ * survives a reload.
15
+ *
16
+ * Distinct from `uiMode` (the SPA-wide basic/advanced interface tier) on purpose — see
17
+ * `utils/agentTier.ts` for why the axes stay separate.
18
+ */
19
+ export const useAgentTierStore = defineStore(
20
+ 'agentTier',
21
+ () => {
22
+ /** The selected level, persisted. Seeded to the everyday-loop default. */
23
+ const storedTier = ref<AgentTier>(DEFAULT_AGENT_TIER_LEVEL)
24
+
25
+ // The restored value is untrusted input (a blob written by an older build, or hand-edited),
26
+ // and a catalog filtered on an unknown level would show nothing at all — so fall back
27
+ // rather than trust it, exactly as `uiMode` does with its persisted mode.
28
+ const tier = computed<AgentTier>(() =>
29
+ isAgentTier(storedTier.value) ? storedTier.value : DEFAULT_AGENT_TIER_LEVEL,
30
+ )
31
+
32
+ /** Whether the widest level is selected, i.e. the whole catalog is showing. */
33
+ const showsAll = computed(() => tier.value === 'advanced')
34
+
35
+ function setTier(next: AgentTier) {
36
+ storedTier.value = next
37
+ }
38
+
39
+ return { tier, showsAll, storedTier, setTier }
40
+ },
41
+ { persist: { pick: ['storedTier'] } },
42
+ )
@@ -0,0 +1,77 @@
1
+ import { defineStore } from 'pinia'
2
+ import { computed, ref } from 'vue'
3
+ import type {
4
+ ConsensusGroup,
5
+ CreateConsensusGroupInput,
6
+ UpdateConsensusGroupInput,
7
+ } from '~/types/consensus'
8
+ import { useWorkspaceStore } from '~/stores/workspace'
9
+
10
+ /**
11
+ * The workspace's consensus-GROUP library: the reusable, estimate-gated review panels a pipeline
12
+ * step escalates to. Hydrated from the workspace snapshot (like the model presets) and managed
13
+ * from the Model Configuration settings screen; the pipeline builder reads it to offer a step's
14
+ * tier set.
15
+ *
16
+ * A workspace that has authored no group simply has nothing to escalate to, and every consensus
17
+ * step runs the inline participants written on it — so an empty library is a valid, quiet state,
18
+ * not an error.
19
+ */
20
+ export const useConsensusGroupsStore = defineStore('consensusGroups', () => {
21
+ const api = useApi()
22
+
23
+ const groups = ref<ConsensusGroup[]>([])
24
+
25
+ function hydrate(list: ConsensusGroup[]) {
26
+ groups.value = [...list].sort((a, b) => a.createdAt - b.createdAt)
27
+ }
28
+
29
+ /** Whether the workspace has any tier to escalate to (drives the builder's empty state). */
30
+ const hasGroups = computed(() => groups.value.length > 0)
31
+
32
+ /** Resolve ids to groups, dropping any the library no longer holds. */
33
+ function resolve(ids: readonly string[] | undefined): ConsensusGroup[] {
34
+ if (!ids?.length) return []
35
+ return ids
36
+ .map((id) => groups.value.find((g) => g.id === id))
37
+ .filter((g): g is ConsensusGroup => !!g)
38
+ }
39
+
40
+ /**
41
+ * The estimate bar a group sets, as a display number: the highest threshold it names, or null
42
+ * when it is ungated (the unconditional floor tier). Mirrors kernel's `consensusGroupBar`,
43
+ * which returns -1 for the same case — the sentinel exists so an ungated group SORTS below
44
+ * every gated one, which is a ranking concern the UI doesn't share.
45
+ */
46
+ function barFor(group: ConsensusGroup): number | null {
47
+ if (!group.gating.enabled) return null
48
+ const thresholds = [
49
+ group.gating.minComplexity,
50
+ group.gating.minRisk,
51
+ group.gating.minImpact,
52
+ ].filter((t): t is number => t !== undefined)
53
+ return thresholds.length ? Math.max(...thresholds) : null
54
+ }
55
+
56
+ async function create(input: CreateConsensusGroupInput) {
57
+ const ws = useWorkspaceStore()
58
+ const created = await api.createConsensusGroup(ws.requireId(), input)
59
+ await ws.refresh()
60
+ return created
61
+ }
62
+
63
+ async function update(groupId: string, patch: UpdateConsensusGroupInput) {
64
+ const ws = useWorkspaceStore()
65
+ const updated = await api.updateConsensusGroup(ws.requireId(), groupId, patch)
66
+ await ws.refresh()
67
+ return updated
68
+ }
69
+
70
+ async function remove(groupId: string) {
71
+ const ws = useWorkspaceStore()
72
+ await api.deleteConsensusGroup(ws.requireId(), groupId)
73
+ await ws.refresh()
74
+ }
75
+
76
+ return { groups, hasGroups, hydrate, resolve, barFor, create, update, remove }
77
+ })
@@ -1,15 +1,18 @@
1
1
  import { computed } from 'vue'
2
2
  import type { AgentKind, Pipeline } from '~/types/domain'
3
- import type { ConsensusStepConfig } from '~/types/consensus'
4
- import type { StepOptions } from '@cat-factory/contracts'
5
3
  import { companionForProducer } from '~/utils/catalog'
6
- import { defaultConsensusConfig, type PipelinesContext } from './context'
4
+ import type { PipelinesContext } from './context'
5
+ import { createPipelineStepConfigActions } from './draftStepConfig'
7
6
 
8
7
  /**
9
- * The pipeline-builder draft manipulation: inserting/removing/reordering steps and toggling each
10
- * step's per-step config, plus `clearDraft` / `loadForEdit`. Extracted from the store setup into a
11
- * factory closing over the shared {@link PipelinesContext} so behaviour is byte-identical to the
12
- * former in-closure functions — the split is purely to keep the setup within the size budget.
8
+ * The pipeline-builder draft's STRUCTURE: inserting/removing/reordering steps, the companion
9
+ * folding that turns the flat arrays into renderable units, and `clearDraft` / `loadForEdit`.
10
+ * Extracted from the store setup into a factory closing over the shared {@link PipelinesContext}.
11
+ *
12
+ * The per-step CONFIG toggles (consensus + its group tiers, gates, companions, step options) are
13
+ * the sibling factory in `./draftStepConfig` — a different concern on the same context, and the
14
+ * seam this file was split along when it outgrew the per-function budget. Both are spread into
15
+ * the store, so callers see one flat API exactly as before.
13
16
  */
14
17
  export function createPipelineDraftActions(ctx: PipelinesContext) {
15
18
  const {
@@ -97,13 +100,6 @@ export function createPipelineDraftActions(ctx: PipelinesContext) {
97
100
  else insertAt(index + 1, companion)
98
101
  }
99
102
 
100
- /** Toggle estimate gating on/off for the (companion) step at `index`. */
101
- function toggleDraftGating(index: number) {
102
- draftGating.value[index] = draftGating.value[index]?.enabled
103
- ? null
104
- : { enabled: true, minRisk: 0.5, minImpact: 0.5, onMissingEstimate: 'run' }
105
- }
106
-
107
103
  /**
108
104
  * The draft as a list of "units" for rendering: each step is one unit, EXCEPT a companion
109
105
  * that sits immediately after its producer — that companion is folded into the producer's
@@ -157,93 +153,6 @@ export function createPipelineDraftActions(ctx: PipelinesContext) {
157
153
  draftStepOptions.value = reorder(draftStepOptions.value)
158
154
  }
159
155
 
160
- /** Toggle the consensus mechanism on the draft step at `index` (default config / off). */
161
- function toggleDraftConsensus(index: number) {
162
- draftConsensus.value[index] = draftConsensus.value[index] ? null : defaultConsensusConfig()
163
- }
164
-
165
- /** Replace the consensus config of the draft step at `index` (builder editor edits). */
166
- function setDraftConsensus(index: number, config: ConsensusStepConfig | null) {
167
- draftConsensus.value[index] = config
168
- }
169
-
170
- /** Toggle the approval gate on the draft step at `index`. */
171
- function toggleDraftGate(index: number) {
172
- draftGates.value[index] = !draftGates.value[index]
173
- }
174
-
175
- /** Toggle the Follow-up companion on the draft (coder) step at `index` (default on → off). */
176
- function toggleDraftFollowUps(index: number) {
177
- // Default (null/true) is enabled, so the first toggle disables it (false); toggle back to null.
178
- draftFollowUps.value[index] = draftFollowUps.value[index] === false ? null : false
179
- }
180
-
181
- /**
182
- * Toggle the test quality-control companion on the draft (Tester) step at `index`. The
183
- * companion is enabled by default (a `null` entry), so the first toggle disables it
184
- * (`{ enabled: false }`, dropping any gating) and the next restores the default.
185
- */
186
- function toggleDraftTesterQuality(index: number) {
187
- draftTesterQuality.value[index] =
188
- draftTesterQuality.value[index]?.enabled === false ? null : { enabled: false }
189
- }
190
-
191
- /**
192
- * Toggle estimate gating on/off for the QC companion on the draft (Tester) step at `index`.
193
- * A no-op while the companion is disabled (nothing to gate). Enabling gating pins the config
194
- * to `{ enabled: true, gating }` so the thresholds are editable; disabling drops back to the
195
- * default `null` (enabled, ungated).
196
- */
197
- function toggleDraftTesterQualityGating(index: number) {
198
- const cur = draftTesterQuality.value[index]
199
- if (cur?.enabled === false) return
200
- draftTesterQuality.value[index] = cur?.gating?.enabled
201
- ? null
202
- : {
203
- enabled: true,
204
- gating: { enabled: true, minRisk: 0.5, minImpact: 0.5, onMissingEstimate: 'run' },
205
- }
206
- }
207
-
208
- /** Enable/disable the draft step at `index` without removing it. */
209
- function toggleDraftEnabled(index: number) {
210
- draftEnabled.value[index] = draftEnabled.value[index] === false
211
- }
212
-
213
- /** Whether auto-recommendation is on for the draft (requirements-review) step at `index`. */
214
- function draftAutoRecommendEnabled(index: number): boolean {
215
- return draftStepOptions.value[index]?.autoRecommend !== false
216
- }
217
-
218
- /**
219
- * Toggle the requirements-review auto-recommendation on the draft step at `index`. It is on by
220
- * default, so we store ONLY the opt-out (`{ autoRecommend: false }`); toggling back drops the
221
- * flag. Merges with any other future StepOptions fields rather than clobbering the whole bag.
222
- */
223
- function toggleDraftAutoRecommend(index: number) {
224
- const next: StepOptions = { ...draftStepOptions.value[index] }
225
- if (draftAutoRecommendEnabled(index)) next.autoRecommend = false
226
- else delete next.autoRecommend
227
- draftStepOptions.value[index] = Object.keys(next).length ? next : null
228
- }
229
-
230
- /** The skill picked for the draft `skill` step at `index` (its `stepOptions.skillId`). */
231
- function draftSkillId(index: number): string | undefined {
232
- return draftStepOptions.value[index]?.skillId
233
- }
234
-
235
- /**
236
- * Set (or clear) the picked skill on the draft `skill` step at `index`. Merges into the
237
- * step's `StepOptions` bag rather than clobbering it; clearing drops the field and, if the
238
- * bag empties, the whole entry (so it normalizes away like the other options).
239
- */
240
- function setDraftSkillId(index: number, skillId: string | undefined) {
241
- const next: StepOptions = { ...draftStepOptions.value[index] }
242
- if (skillId) next.skillId = skillId
243
- else delete next.skillId
244
- draftStepOptions.value[index] = Object.keys(next).length ? next : null
245
- }
246
-
247
156
  function clearDraft() {
248
157
  draft.value = []
249
158
  draftGates.value = []
@@ -282,25 +191,14 @@ export function createPipelineDraftActions(ctx: PipelinesContext) {
282
191
  }
283
192
 
284
193
  return {
194
+ ...createPipelineStepConfigActions(ctx),
285
195
  addToDraft,
286
196
  removeFromDraft,
287
197
  moveInDraft,
288
198
  hasCompanion,
289
199
  toggleCompanion,
290
- toggleDraftGating,
291
200
  units,
292
201
  moveUnit,
293
- toggleDraftConsensus,
294
- setDraftConsensus,
295
- toggleDraftGate,
296
- toggleDraftFollowUps,
297
- toggleDraftTesterQuality,
298
- toggleDraftTesterQualityGating,
299
- toggleDraftEnabled,
300
- draftAutoRecommendEnabled,
301
- toggleDraftAutoRecommend,
302
- draftSkillId,
303
- setDraftSkillId,
304
202
  clearDraft,
305
203
  loadForEdit,
306
204
  }