@cat-factory/app 0.256.3 → 0.258.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 (59) hide show
  1. package/app/components/binaryCandidates/BinaryCandidatesWindow.vue +284 -0
  2. package/app/components/binaryOutput/BinaryOutputReport.vue +75 -0
  3. package/app/components/board/AddTaskModal.vue +38 -12
  4. package/app/components/board/RecurringPipelineModal.vue +24 -12
  5. package/app/components/board/nodes/TaskCard.vue +34 -7
  6. package/app/components/forkDecision/ForkDecisionWindow.vue +3 -1
  7. package/app/components/panels/AgentStepDetail.vue +42 -20
  8. package/app/components/panels/InspectorPanel.vue +39 -0
  9. package/app/components/panels/ResultWindowShell.logic.spec.ts +4 -0
  10. package/app/components/panels/inspector/TaskExecution.vue +34 -34
  11. package/app/components/pipeline/BinaryOutputStepPicker.logic.spec.ts +119 -1
  12. package/app/components/pipeline/BinaryOutputStepPicker.logic.ts +115 -1
  13. package/app/components/pipeline/BinaryOutputStepPicker.vue +463 -1
  14. package/app/components/pipeline/PipelineBuilder.vue +44 -0
  15. package/app/components/pipeline/PipelinePreview.vue +20 -1
  16. package/app/components/pipeline/PipelineProgress.vue +50 -0
  17. package/app/composables/api/binaryCandidates.ts +36 -0
  18. package/app/composables/api/execution.ts +19 -0
  19. package/app/composables/useApi.ts +2 -0
  20. package/app/composables/usePipelineHealth.spec.ts +42 -5
  21. package/app/composables/usePipelineHealth.ts +109 -48
  22. package/app/modular/agent-kinds.ts +5 -0
  23. package/app/modular/result-views.ts +4 -0
  24. package/app/stores/binaryCandidates.ts +89 -0
  25. package/app/stores/environmentWizard/context.ts +0 -2
  26. package/app/stores/environmentWizard/flow.ts +11 -6
  27. package/app/stores/environmentWizard.ts +12 -11
  28. package/app/stores/execution/commands.ts +26 -1
  29. package/app/stores/pipelines/draftActions.ts +2 -0
  30. package/app/stores/pipelines/draftStepConfig.ts +4 -161
  31. package/app/stores/pipelines/draftStepOptions.ts +204 -0
  32. package/app/stores/ui/resultViews.ts +8 -6
  33. package/app/stores/ui/runStepOpeners.ts +23 -1
  34. package/app/types/domain.ts +6 -0
  35. package/app/types/execution.ts +5 -0
  36. package/app/utils/agentPalette.spec.ts +26 -0
  37. package/app/utils/agentPalette.ts +9 -4
  38. package/app/utils/binaryCandidates.spec.ts +110 -0
  39. package/app/utils/binaryCandidates.ts +126 -0
  40. package/app/utils/binaryOutput.spec.ts +122 -1
  41. package/app/utils/binaryOutput.ts +149 -2
  42. package/app/utils/catalog.spec.ts +24 -0
  43. package/app/utils/catalog.ts +21 -4
  44. package/app/utils/pipeline.spec.ts +35 -3
  45. package/app/utils/pipeline.ts +54 -3
  46. package/app/utils/pipelineRender.spec.ts +122 -1
  47. package/app/utils/pipelineRender.ts +113 -2
  48. package/i18n/locales/de.json +107 -3
  49. package/i18n/locales/en.json +107 -3
  50. package/i18n/locales/es.json +107 -3
  51. package/i18n/locales/fr.json +107 -3
  52. package/i18n/locales/he.json +107 -3
  53. package/i18n/locales/it.json +107 -3
  54. package/i18n/locales/ja.json +107 -3
  55. package/i18n/locales/pl.json +107 -3
  56. package/i18n/locales/tr.json +107 -3
  57. package/i18n/locales/uk.json +107 -3
  58. package/i18n/plural-forms.spec.ts +15 -0
  59. package/package.json +2 -2
@@ -11,6 +11,8 @@ import {
11
11
  FAILED_STEP_META,
12
12
  containerPhaseLabel,
13
13
  dedicatedParkView,
14
+ REDIRECT_PARK_PRESENTATION,
15
+ stepSkipReasonKey,
14
16
  } from '~/utils/pipelineRender'
15
17
  import { prReviewPhase } from '~/utils/prReviewProgress'
16
18
  import StepMetricsBar from '~/components/observability/StepMetricsBar.vue'
@@ -90,6 +92,17 @@ function prReviewAwaiting(step: PipelineStep): boolean {
90
92
  return step.prReview?.status === 'awaiting_selection'
91
93
  }
92
94
 
95
+ /**
96
+ * Whether a binary-output step is parked awaiting a human candidate choice. Asked of the shared
97
+ * park recognizer rather than re-derived from `step.binaryCandidates`, so this chip and the
98
+ * generic approval gate below (which suppresses itself for exactly the parks that recognizer
99
+ * names) can never disagree about who owns the park. That disagreement is what leaves a parked
100
+ * run showing no action at all.
101
+ */
102
+ function candidatesAwaiting(step: PipelineStep): boolean {
103
+ return dedicatedParkView(step, props.instance) === 'binary-candidates'
104
+ }
105
+
93
106
  /**
94
107
  * Whether a `pr-reviewer` step has a LIVE phase to surface (slicing / reviewing / … ). Drives
95
108
  * showing the phase badge in place of the generic subtask count header; a terminal (done/skipped)
@@ -547,6 +560,18 @@ const ITEM_ICON: Record<string, string> = {
547
560
  {{ t('pipeline.progress.clickToRead') }}
548
561
  </p>
549
562
 
563
+ <!-- Why a skipped step did not run. A skipped step finishes `done` with no output, so
564
+ without this line it is indistinguishable from one that ran and said nothing —
565
+ which reads as a tester that silently did its job. -->
566
+ <p
567
+ v-if="stepSkipReasonKey(s)"
568
+ class="mt-2 flex items-center gap-1 text-[11px] text-slate-500"
569
+ data-testid="step-skip-reason"
570
+ >
571
+ <UIcon name="i-lucide-skip-forward" class="h-3 w-3 shrink-0" />
572
+ {{ t(stepSkipReasonKey(s)!) }}
573
+ </p>
574
+
550
575
  <!-- Conditionally-run companion (today the Tester's fixer): a distinct
551
576
  sub-node marked possible / running / completed / skipped. -->
552
577
  <div
@@ -665,6 +690,31 @@ const ITEM_ICON: Record<string, string> = {
665
690
  </span>
666
691
  </button>
667
692
 
693
+ <!-- A generating step parked between its candidate pass and its delivering pass: a
694
+ purpose-built chip opening the comparison window, ahead of the generic approval
695
+ gate (mirrors the fork-decision and pr-review chips above). Without it the step
696
+ shows no action at all, because the generic gate below is suppressed for every
697
+ park a dedicated window owns. -->
698
+ <button
699
+ v-if="candidatesAwaiting(s)"
700
+ type="button"
701
+ data-testid="binary-candidates-open"
702
+ class="mt-3 flex w-full items-center gap-2 rounded-lg border border-dashed border-cyan-500/50 bg-cyan-500/10 px-2.5 py-1.5 text-start transition followup-blink hover:border-cyan-400/60"
703
+ @click="ui.openBinaryCandidates(instance.id, i)"
704
+ >
705
+ <span
706
+ class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border border-cyan-500/40 bg-cyan-500/15"
707
+ >
708
+ <UIcon
709
+ :name="REDIRECT_PARK_PRESENTATION['binary-candidates'].icon"
710
+ class="h-3 w-3 text-cyan-300"
711
+ />
712
+ </span>
713
+ <span class="min-w-0 flex-1 truncate text-[12px] text-slate-300">
714
+ {{ t('pipeline.progress.binaryCandidates.choose') }}
715
+ </span>
716
+ </button>
717
+
668
718
  <!-- reviewer gate folding/re-reviewing in the background: a working indicator,
669
719
  NOT a "Review & approve" gate (the human is summoned only if needed) -->
670
720
  <div
@@ -0,0 +1,36 @@
1
+ import {
2
+ getBinaryCandidatesContract,
3
+ keepBinaryCandidatesContract,
4
+ type KeepBinaryCandidatesInput,
5
+ } from '@cat-factory/contracts'
6
+ import type { ApiContext } from './context'
7
+
8
+ /**
9
+ * Generated-candidate comparison. A binary-output step configured to COMPARE generates a
10
+ * candidate from each of its selected integrations, stages them through the step's storage
11
+ * service, and parks. These endpoints read the staged candidates and record which of them
12
+ * survive (and under which alternate ids); keeping re-runs the step to deliver exactly those.
13
+ * The read returns null when no step carries candidate state.
14
+ */
15
+ export function binaryCandidatesApi({ send, ws }: ApiContext) {
16
+ return {
17
+ // The live candidate state for a run (null when no step carries one).
18
+ getBinaryCandidates: (workspaceId: string, executionId: string) =>
19
+ send(getBinaryCandidatesContract, {
20
+ pathPrefix: ws(workspaceId),
21
+ pathParams: { executionId },
22
+ }),
23
+
24
+ // Keep the chosen candidates and discard the rest.
25
+ keepBinaryCandidates: (
26
+ workspaceId: string,
27
+ executionId: string,
28
+ body: KeepBinaryCandidatesInput,
29
+ ) =>
30
+ send(keepBinaryCandidatesContract, {
31
+ pathPrefix: ws(workspaceId),
32
+ pathParams: { executionId },
33
+ body,
34
+ }),
35
+ }
36
+ }
@@ -15,6 +15,7 @@ import {
15
15
  resolveStepExceededContract,
16
16
  restartExecutionContract,
17
17
  resumeSpendContract,
18
+ startAgentKindExecutionContract,
18
19
  startExecutionContract,
19
20
  } from '@cat-factory/contracts'
20
21
  import type { RequestStepChangesInput, RunMode } from '@cat-factory/contracts'
@@ -41,6 +42,24 @@ export function executionApi({ send, sendWith, ws, pwHeaders }: ApiContext) {
41
42
  body,
42
43
  }),
43
44
 
45
+ /**
46
+ * Start ONE agent kind against a block — a run with no pipeline behind it (the service
47
+ * frame's "Map service" action, the environment wizard's deep analysis). Gated on the
48
+ * personal password exactly as a pipeline start is: the kind leases a personal subscription
49
+ * the same way a pipeline step does.
50
+ */
51
+ startAgentKindExecution: (
52
+ workspaceId: string,
53
+ blockId: string,
54
+ agentKind: string,
55
+ password?: string,
56
+ ) =>
57
+ sendWith(pwHeaders(password), startAgentKindExecutionContract, {
58
+ pathPrefix: ws(workspaceId),
59
+ pathParams: { blockId },
60
+ body: { agentKind },
61
+ }),
62
+
44
63
  cancelExecution: (workspaceId: string, blockId: string) =>
45
64
  send(cancelExecutionContract, { pathPrefix: ws(workspaceId), pathParams: { blockId } }),
46
65
 
@@ -13,6 +13,7 @@ import { boardApi } from './api/board'
13
13
  import { documentsApi } from './api/documents'
14
14
  import { executionApi } from './api/execution'
15
15
  import { followUpsApi } from './api/followUps'
16
+ import { binaryCandidatesApi } from './api/binaryCandidates'
16
17
  import { forkDecisionApi } from './api/forkDecision'
17
18
  import { inputGateApi } from './api/inputGate'
18
19
  import { judgeApi } from './api/judge'
@@ -131,6 +132,7 @@ export function useApi() {
131
132
  ...bugHuntApi(ctx),
132
133
  ...reviewsApi(ctx),
133
134
  ...followUpsApi(ctx),
135
+ ...binaryCandidatesApi(ctx),
134
136
  ...forkDecisionApi(ctx),
135
137
  ...inputGateApi(ctx),
136
138
  ...judgeApi(ctx),
@@ -7,7 +7,7 @@ import { usePipelineHealth } from '~/composables/usePipelineHealth'
7
7
  /**
8
8
  * Guards the startup pipeline-health advisory against the failure that bit the first cut: a
9
9
  * legitimate built-in agent kind missing from the frontend catalog made `isKnownAgentKind`
10
- * return false, so a stock seeded pipeline (`pl_tech_debt`, which uses `analysis` + `tracker`)
10
+ * return false, so a stock seeded pipeline (one using `analysis` + `tracker`)
11
11
  * was reported "invalid" in every workspace with a Reseed action that could never fix it.
12
12
  *
13
13
  * The kind lists below mirror the canonical built-ins in
@@ -94,8 +94,8 @@ describe('isKnownAgentKind', () => {
94
94
  })
95
95
 
96
96
  describe('usePipelineHealth', () => {
97
- it('does not flag the stock tech-debt built-in (analysis + tracker) as invalid', () => {
98
- const techDebt = builtin(
97
+ it('does not flag an audit pipeline (analysis + tracker) as invalid', () => {
98
+ const audit = builtin(
99
99
  [
100
100
  'analysis',
101
101
  'tracker',
@@ -107,9 +107,9 @@ describe('usePipelineHealth', () => {
107
107
  'ci',
108
108
  'merger',
109
109
  ],
110
- { id: 'pl_tech_debt', name: 'Tech debt' },
110
+ { id: 'pl_audit', name: 'Audit and fix' },
111
111
  )
112
- const { hasIssues, invalid, outdated } = scan([techDebt])
112
+ const { hasIssues, invalid, outdated } = scan([audit])
113
113
  expect(hasIssues.value).toBe(false)
114
114
  expect(invalid.value).toHaveLength(0)
115
115
  expect(outdated.value).toHaveLength(0)
@@ -164,6 +164,43 @@ describe('usePipelineHealth', () => {
164
164
  expect(invalid.value[0]!.problems.some((p) => p.type === 'shape')).toBe(true)
165
165
  })
166
166
 
167
+ // The RUN CONDITION is the second skip axis, and the advisory has to mirror it for the same
168
+ // reason it mirrors the estimate gate: a rule the engine enforces at save that this scan calls
169
+ // healthy leaves the author to discover it as a 422.
170
+ it('accepts a run condition on a kind the shared gatable set allows', () => {
171
+ const conditional = builtin(['coder', 'reviewer', 'tester-ui'], {
172
+ stepOptions: [null, null, { condition: { serviceScope: 'frontend' } }],
173
+ })
174
+ expect(scan([conditional]).hasIssues.value).toBe(false)
175
+ })
176
+
177
+ it('flags a run condition on a kind the run structurally needs (merger)', () => {
178
+ const conditionalMerger = builtin(['coder', 'merger'], {
179
+ stepOptions: [null, { condition: { serviceScope: 'frontend' } }],
180
+ })
181
+ const { invalid } = scan([conditionalMerger])
182
+ expect(invalid.value).toHaveLength(1)
183
+ expect(invalid.value[0]!.problems.some((p) => p.type === 'shape')).toBe(true)
184
+ })
185
+
186
+ it('flags a step carrying BOTH a human approval gate and a run condition (shape)', () => {
187
+ const both = builtin(['coder', 'tester-ui'], {
188
+ gates: [false, true],
189
+ stepOptions: [null, { condition: { serviceScope: 'frontend' } }],
190
+ })
191
+ const { invalid } = scan([both])
192
+ expect(invalid.value).toHaveLength(1)
193
+ expect(invalid.value[0]!.problems.some((p) => p.type === 'shape')).toBe(true)
194
+ })
195
+
196
+ it('accepts a run condition BESIDE an estimate gate: the axes compose', () => {
197
+ const both = builtin(['task-estimator', 'coder', 'tester-ui'], {
198
+ gating: [null, null, { enabled: true, minComplexity: 0.4, onMissingEstimate: 'run' }],
199
+ stepOptions: [null, null, { condition: { serviceScope: 'frontend' } }],
200
+ })
201
+ expect(scan([both]).hasIssues.value).toBe(false)
202
+ })
203
+
167
204
  it('flags a step carrying BOTH a human approval gate and an estimate gate (shape)', () => {
168
205
  const both = builtin(['task-estimator', 'architect'], {
169
206
  gates: [false, true],
@@ -80,24 +80,11 @@ function companionTargets(companion: string): string[] {
80
80
  const isEnabledAt = (p: Pipeline, i: number) => p.enabled?.[i] !== false
81
81
 
82
82
  /**
83
- * Client-side mirror of the backend `validatePipelineShape` (companion adjacency + estimate
84
- * gating, over the ENABLED subset), collecting the first problem instead of throwing. Returns a
85
- * human message, or null when the shape is valid. Kept in step with
86
- * `backend/packages/orchestration/src/modules/pipelines/pipelineShape.ts`.
87
- *
88
- * A rule here must be keyed off vocabulary SHARED with that module (`@cat-factory/contracts`)
89
- * wherever one exists, never re-stated locally — see the gating note below for what a drifted copy
90
- * costs. Adding a rule to `assertValidGating` without adding it here is the milder half of the same
91
- * drift: a pipeline the engine refuses at save that this advisory calls healthy.
83
+ * Companion adjacency: an enabled companion's nearest preceding ENABLED step must be a producer it
84
+ * can review. Mirrors `assertValidCompanionPlacement`.
92
85
  */
93
- function shapeProblem(p: Pipeline): string | null {
86
+ function companionProblem(p: Pipeline): string | null {
94
87
  const kinds = p.agentKinds
95
- // No enabled steps ⇒ nothing would run.
96
- if (kinds.length === 0 || !kinds.some((_, i) => isEnabledAt(p, i))) {
97
- return 'No enabled steps — the pipeline has nothing to run.'
98
- }
99
- // Companion adjacency: an enabled companion's nearest preceding enabled step must be a
100
- // producer it can review.
101
88
  for (let i = 0; i < kinds.length; i++) {
102
89
  const kind = kinds[i]
103
90
  if (!kind || !isProducerCompanion(kind) || !isEnabledAt(p, i)) continue
@@ -113,44 +100,118 @@ function shapeProblem(p: Pipeline): string | null {
113
100
  return `Companion '${kind}' must run immediately after an enabled step it can review (${targets.join(', ')}).`
114
101
  }
115
102
  }
116
- // Estimate gating: an enabled gated step must be a GATABLE kind, must not also carry a human
117
- // approval gate, must set ≥1 threshold, and must have an enabled task-estimator earlier in the
118
- // chain. Gatability reads the SHARED `BUILTIN_GATABLE_KINDS` rather than a local rule, because
119
- // this advisory auto-opens a modal over the board: a copy of the rule that drifts behind the
120
- // engine's does not merely warn wrongly, it calls a pipeline the product SHIPS invalid and leaves
121
- // the board unusable. A DEPLOYMENT-registered kind can override gatability for itself through the
122
- // agent-kind registry, which the SPA cannot see, so the two are not perfectly symmetric: such a
123
- // kind is reported here and accepted by the engine. That is the safe direction of the asymmetry —
124
- // a dismissible advisory rather than a refused save and the only one available without shipping
125
- // the registry to the browser.
103
+ return null
104
+ }
105
+
106
+ /**
107
+ * The rule both SKIP AXES share: a step that may be absent from a run must be a kind whose result
108
+ * later steps read as context, and must not also carry a human approval gate (a skip may leave a
109
+ * checkpoint un-reached, never cancel one the author asked for). Returns the problem, or null.
110
+ *
111
+ * Shared by {@link gatingProblem} and {@link conditionProblem} rather than written twice, because
112
+ * the reason is identical and only the axis's name differs — which is exactly how the two would
113
+ * drift apart. `axis` supplies the naming, mirroring `assertValidGating` /
114
+ * `assertValidRunConditions`.
115
+ *
116
+ * Gatability reads the SHARED `BUILTIN_GATABLE_KINDS` rather than a local rule, because this
117
+ * advisory auto-opens a modal over the board: a copy of the rule that drifts behind the engine's
118
+ * does not merely warn wrongly, it calls a pipeline the product SHIPS invalid and leaves the board
119
+ * unusable. A DEPLOYMENT-registered kind can override gatability for itself through the agent-kind
120
+ * registry, which the SPA cannot see, so the two are not perfectly symmetric: such a kind is
121
+ * reported here and accepted by the engine. That is the safe direction of the asymmetry — a
122
+ * dismissible advisory rather than a refused save — and the only one available without shipping the
123
+ * registry to the browser.
124
+ */
125
+ function skipAxisProblem(
126
+ p: Pipeline,
127
+ i: number,
128
+ axis: {
129
+ notGatable: (kind: string | undefined) => string
130
+ withHumanGate: (kind: string) => string
131
+ },
132
+ ): string | null {
133
+ const kind = p.agentKinds[i]
134
+ if (!kind || !isBuiltinGatableKind(kind)) return axis.notGatable(kind)
135
+ if (p.gates?.[i] === true) return axis.withHumanGate(kind)
136
+ return null
137
+ }
138
+
139
+ /**
140
+ * Estimate gating: the shared skip-axis rules, plus the two specific to an estimate — at least one
141
+ * axis threshold (with none the step would ALWAYS skip) and an enabled task-estimator earlier in
142
+ * the chain (or the gate has nothing to consult). Mirrors `assertValidGating`.
143
+ */
144
+ function gatingProblem(p: Pipeline): string | null {
126
145
  const gating = p.gating
127
- if (gating) {
128
- for (let i = 0; i < kinds.length; i++) {
129
- const g = gating[i] as StepGating | null | undefined
130
- if (!g?.enabled || !isEnabledAt(p, i)) continue
131
- const kind = kinds[i]
132
- if (!kind || !isBuiltinGatableKind(kind)) {
133
- return `Step '${kind}' may not be estimate-gated — its output is required by the rest of the run. Only a step whose result later steps read as context (a design, a review, an extra verification pass) may be skipped on the estimate.`
134
- }
135
- // A human approval gate and an estimate gate on the same step contradict: the estimate may
136
- // ADD a human checkpoint but never CANCEL a pause the pipeline author asked for.
137
- if (p.gates?.[i] === true) {
138
- return `Step '${kind}' carries a human approval gate, so it cannot also be estimate-gated — the estimate may add a human checkpoint but never remove one.`
139
- }
140
- if (g.minComplexity === undefined && g.minRisk === undefined && g.minImpact === undefined) {
141
- return `Step '${kind}' is estimate-gated but sets no threshold (complexity / risk / impact).`
142
- }
143
- const hasEstimator = kinds
144
- .slice(0, i)
145
- .some((k, j) => k === TASK_ESTIMATOR_KIND && isEnabledAt(p, j))
146
- if (!hasEstimator) {
147
- return `Step '${kind}' is gated on the estimate but no enabled '${TASK_ESTIMATOR_KIND}' runs before it.`
148
- }
146
+ if (!gating) return null
147
+ const kinds = p.agentKinds
148
+ for (let i = 0; i < kinds.length; i++) {
149
+ const g = gating[i] as StepGating | null | undefined
150
+ if (!g?.enabled || !isEnabledAt(p, i)) continue
151
+ const shared = skipAxisProblem(p, i, {
152
+ notGatable: (kind) =>
153
+ `Step '${kind}' may not be estimate-gated — its output is required by the rest of the run. Only a step whose result later steps read as context (a design, a review, an extra verification pass) may be skipped on the estimate.`,
154
+ withHumanGate: (kind) =>
155
+ `Step '${kind}' carries a human approval gate, so it cannot also be estimate-gated — the estimate may add a human checkpoint but never remove one.`,
156
+ })
157
+ if (shared) return shared
158
+ const kind = kinds[i]
159
+ if (g.minComplexity === undefined && g.minRisk === undefined && g.minImpact === undefined) {
160
+ return `Step '${kind}' is estimate-gated but sets no threshold (complexity / risk / impact).`
161
+ }
162
+ const hasEstimator = kinds
163
+ .slice(0, i)
164
+ .some((k, j) => k === TASK_ESTIMATOR_KIND && isEnabledAt(p, j))
165
+ if (!hasEstimator) {
166
+ return `Step '${kind}' is gated on the estimate but no enabled '${TASK_ESTIMATOR_KIND}' runs before it.`
149
167
  }
150
168
  }
151
169
  return null
152
170
  }
153
171
 
172
+ /**
173
+ * Run conditions: the SECOND skip axis, held to the shared rules and nothing more. A skip is a skip
174
+ * whichever axis caused it, so a condition on a non-gatable kind drops something the run needs.
175
+ * Mirrors `assertValidRunConditions`. A condition BESIDE an estimate gate is deliberately fine.
176
+ */
177
+ function conditionProblem(p: Pipeline): string | null {
178
+ const stepOptions = p.stepOptions
179
+ if (!stepOptions) return null
180
+ for (let i = 0; i < p.agentKinds.length; i++) {
181
+ if (!stepOptions[i]?.condition || !isEnabledAt(p, i)) continue
182
+ const problem = skipAxisProblem(p, i, {
183
+ notGatable: (kind) =>
184
+ `Step '${kind}' may not carry a run condition — its output is required by the rest of the run, so a run outside the condition's scope would silently finish without it.`,
185
+ withHumanGate: (kind) =>
186
+ `Step '${kind}' carries a human approval gate, so it cannot also carry a run condition — a condition may leave a checkpoint un-reached but never remove one.`,
187
+ })
188
+ if (problem) return problem
189
+ }
190
+ return null
191
+ }
192
+
193
+ /**
194
+ * Client-side mirror of the backend `validatePipelineShape` (companion adjacency + both skip axes,
195
+ * over the ENABLED subset), collecting the first problem instead of throwing. Returns a human
196
+ * message, or null when the shape is valid. Kept in step with
197
+ * `backend/packages/orchestration/src/modules/pipelines/pipelineShape.ts`.
198
+ *
199
+ * One delegate per rule, in the order the backend checks them, so adding the next rule is a
200
+ * function beside these rather than another branch inside one that already carries three.
201
+ *
202
+ * A rule here must be keyed off vocabulary SHARED with that module (`@cat-factory/contracts`)
203
+ * wherever one exists, never re-stated locally — see {@link skipAxisProblem} for what a drifted
204
+ * copy costs. Adding a rule to `validatePipelineShape` without adding it here is the milder half of
205
+ * the same drift: a pipeline the engine refuses at save that this advisory calls healthy.
206
+ */
207
+ function shapeProblem(p: Pipeline): string | null {
208
+ // No enabled steps ⇒ nothing would run.
209
+ if (p.agentKinds.length === 0 || !p.agentKinds.some((_, i) => isEnabledAt(p, i))) {
210
+ return 'No enabled steps — the pipeline has nothing to run.'
211
+ }
212
+ return companionProblem(p) ?? gatingProblem(p) ?? conditionProblem(p)
213
+ }
214
+
154
215
  /**
155
216
  * Detect pipelines in an unhealthy state for the startup advisory: those referencing an unknown
156
217
  * agent kind or with an invalid shape (offer to delete a custom one / reseed a built-in), built-ins
@@ -35,6 +35,11 @@ export function customKindToArchetype(kind: CustomAgentKind): AgentArchetype {
35
35
  // projection would fork the rule the moment the default changes.
36
36
  ...(p.tier ? { tier: p.tier } : {}),
37
37
  ...(p.resultView ? { resultView: p.resultView } : {}),
38
+ // The kind is the platform's to dispatch, not a block anyone places. Carried onto the
39
+ // archetype rather than dropped at the projection because the catalog is also the READ MODEL
40
+ // every run view resolves a step's label and icon through: filtering it out here would leave
41
+ // the wizard's own analyst run rendering as an unknown kind.
42
+ ...(p.internal ? { internal: true } : {}),
38
43
  // Not part of `presentation` on the wire — it is a fact about how the kind RUNS, projected
39
44
  // beside `container` — so it is lifted from the entry itself. Carried onto the archetype
40
45
  // because the pipeline builder resolves a step's meta through `agentKindMeta`, not through
@@ -13,6 +13,7 @@ import ConsensusSessionWindow from '~/components/consensus/ConsensusSessionWindo
13
13
  import GenericStructuredResultView from '~/components/panels/GenericStructuredResultView.vue'
14
14
  import ServiceSpecWindow from '~/components/spec/ServiceSpecWindow.vue'
15
15
  import FollowUpWindow from '~/components/followUp/FollowUpWindow.vue'
16
+ import BinaryCandidatesWindow from '~/components/binaryCandidates/BinaryCandidatesWindow.vue'
16
17
  import ForkDecisionWindow from '~/components/forkDecision/ForkDecisionWindow.vue'
17
18
  import PrReviewWindow from '~/components/prReview/PrReviewWindow.vue'
18
19
  import MergerResultView from '~/components/panels/MergerResultView.vue'
@@ -77,6 +78,9 @@ const BUILT_IN_RESULT_VIEWS: Record<ResultViewId, Component> = {
77
78
  'follow-ups': FollowUpWindow,
78
79
  // The implementation-fork decision: the proposer's approaches + the human's pick / custom.
79
80
  'fork-decision': ForkDecisionWindow,
81
+ // The generated-candidate comparison: the candidates a generating step staged, side by side,
82
+ // and the human's keep/discard decision (with the alternate ids they assigned).
83
+ 'binary-candidates': BinaryCandidatesWindow,
80
84
  // The PR deep-review: the reviewer's sliced, prioritized findings + the human's multi-select.
81
85
  'pr-review': PrReviewWindow,
82
86
  // The merger's verdict: PR complexity/risk/impact scores + the engine's decision (and why).
@@ -0,0 +1,89 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref } from 'vue'
3
+ import type { KeepBinaryCandidatesInput } from '@cat-factory/contracts'
4
+ import type { BinaryCandidateStepState } from '~/types/execution'
5
+ import { useApi } from '~/composables/useApi'
6
+ import { useWorkspaceStore } from '~/stores/workspace'
7
+ import { useExecutionStore } from '~/stores/execution'
8
+
9
+ /**
10
+ * The generated-candidate action surface. The live state lives on the run's step
11
+ * (`step.binaryCandidates`) and the execution stream keeps it fresh, so the window reads it
12
+ * straight off the execution store; this store only wraps the `keep` action (plus a warm-up
13
+ * `load`), tracks the in-flight state so the window can disable its controls, and echoes the
14
+ * returned state back so the UI settles without waiting for the stream. Shaped exactly like the
15
+ * fork-decision store, which is the same park one subject over.
16
+ */
17
+ export const useBinaryCandidatesStore = defineStore('binaryCandidates', () => {
18
+ const api = useApi()
19
+ const workspace = useWorkspaceStore()
20
+ const execution = useExecutionStore()
21
+
22
+ /** True while a keep call is in flight (drives the button spinner / disabled state). */
23
+ const keeping = ref(false)
24
+ /** The last error message from an action, surfaced inline; cleared on the next action. */
25
+ const error = ref<string | null>(null)
26
+
27
+ /**
28
+ * Apply an authoritative candidate state to the run's step.
29
+ *
30
+ * A pipeline may carry more than one generating step, so target the step this decision is
31
+ * ABOUT rather than the first that happens to hold candidate state: prefer the one still
32
+ * awaiting a choice, then the current step, and only then any step carrying state. Without
33
+ * that order a run whose earlier generator already settled would have its finished record
34
+ * overwritten by the live one.
35
+ *
36
+ * Only ever called through {@link ExecutionStore.echoAfter}, which drops the echo when the
37
+ * event stream already delivered a newer revision.
38
+ */
39
+ function assign(
40
+ instance: ReturnType<typeof execution.getInstance> & object,
41
+ state: BinaryCandidateStepState,
42
+ ): void {
43
+ const current = instance.steps[instance.currentStep]
44
+ const step =
45
+ instance.steps.find((s) => s.binaryCandidates?.status === 'awaiting_choice') ??
46
+ (current?.binaryCandidates ? current : undefined) ??
47
+ instance.steps.find((s) => s.binaryCandidates)
48
+ if (step) step.binaryCandidates = state
49
+ }
50
+
51
+ /** Warm the live state from the GET (the stream also keeps it fresh). Best-effort. */
52
+ async function load(executionId: string): Promise<void> {
53
+ error.value = null
54
+ try {
55
+ await execution.echoAfter(
56
+ executionId,
57
+ () => api.getBinaryCandidates(workspace.requireId(), executionId),
58
+ (state, instance) => {
59
+ if (state) assign(instance, state as BinaryCandidateStepState)
60
+ },
61
+ )
62
+ } catch (e) {
63
+ error.value = e instanceof Error ? e.message : 'Failed to load'
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Keep the chosen candidates (each with the id it is to be stored under) and discard the rest.
69
+ * The step then re-runs to deliver exactly what survived.
70
+ */
71
+ async function keep(executionId: string, input: KeepBinaryCandidatesInput): Promise<void> {
72
+ error.value = null
73
+ keeping.value = true
74
+ try {
75
+ await execution.echoAfter(
76
+ executionId,
77
+ () => api.keepBinaryCandidates(workspace.requireId(), executionId, input),
78
+ (state, instance) => assign(instance, state as BinaryCandidateStepState),
79
+ )
80
+ } catch (e) {
81
+ error.value = e instanceof Error ? e.message : 'Failed to keep candidates'
82
+ throw e
83
+ } finally {
84
+ keeping.value = false
85
+ }
86
+ }
87
+
88
+ return { keeping, error, load, keep }
89
+ })
@@ -9,7 +9,6 @@ import type { useBoardStore } from '~/stores/board'
9
9
  import type { useExecutionStore } from '~/stores/execution'
10
10
  import type { useGitHubStore } from '~/stores/github'
11
11
  import type { useInfraConfigStore } from '~/stores/infraConfig'
12
- import type { usePipelinesStore } from '~/stores/pipelines'
13
12
  import type { usePreflightsStore } from '~/stores/preflights'
14
13
 
15
14
  /**
@@ -47,7 +46,6 @@ export interface WizardContext {
47
46
  trialStarted: Ref<boolean>
48
47
  // ---- derived the actions read ----
49
48
  repoContext: ComputedRef<{ githubId: number; directory?: string | null } | undefined>
50
- analysisPipeline: ComputedRef<ReturnType<ReturnType<typeof usePipelinesStore>['getPipeline']>>
51
49
  merged: ComputedRef<MergedRecipeDraft | null>
52
50
  }
53
51
 
@@ -1,3 +1,4 @@
1
+ import { ENVIRONMENT_ANALYST_AGENT_KIND } from '@cat-factory/contracts'
1
2
  import type { WizardContext } from './context'
2
3
  import { cloneRecipe } from './context'
3
4
 
@@ -34,7 +35,6 @@ export function createFlowActions(ctx: WizardContext) {
34
35
  trialError,
35
36
  trialStarted,
36
37
  repoContext,
37
- analysisPipeline,
38
38
  merged,
39
39
  } = ctx
40
40
 
@@ -117,18 +117,23 @@ export function createFlowActions(ctx: WizardContext) {
117
117
  if (id) void detect()
118
118
  }
119
119
 
120
- /** Fire the analyst-only pipeline against the frame (mirrors how bootstrap runs pl_blueprint). */
120
+ /**
121
+ * Run the analyst agent against the frame — a SINGLE-KIND run, the same seam the board's
122
+ * "Map service" action uses. `startAgentKind` reports a refusal by returning false (it has
123
+ * already surfaced the reason as a toast), so both halves of "it did not start" land on the
124
+ * wizard's own error state rather than only the thrown one.
125
+ */
121
126
  async function startAnalysis() {
122
127
  const id = frameId.value
123
- const pipeline = analysisPipeline.value
124
- if (!id || !pipeline) {
128
+ if (!id) {
125
129
  analysisError.value = true
126
130
  return
127
131
  }
128
132
  analysisError.value = false
129
133
  try {
130
- await execution.start(id, pipeline)
131
- analysisRequested.value = true
134
+ const started = await execution.startAgentKind(id, ENVIRONMENT_ANALYST_AGENT_KIND)
135
+ if (started) analysisRequested.value = true
136
+ else analysisError.value = true
132
137
  } catch {
133
138
  analysisError.value = true
134
139
  }