@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
@@ -7,6 +7,8 @@ import {
7
7
  type PreflightResult,
8
8
  type ProvisioningRecommendation,
9
9
  type StackRecipe,
10
+ ENVIRONMENT_ANALYST_AGENT_KIND,
11
+ adHocPipelineIdFor,
10
12
  analystRecipeDraftSchema,
11
13
  mergeAnalystRecipeDraft,
12
14
  } from '@cat-factory/contracts'
@@ -15,7 +17,6 @@ import { useBoardStore } from '~/stores/board'
15
17
  import { useExecutionStore } from '~/stores/execution'
16
18
  import { useGitHubStore } from '~/stores/github'
17
19
  import { useInfraConfigStore } from '~/stores/infraConfig'
18
- import { usePipelinesStore } from '~/stores/pipelines'
19
20
  import { usePreflightsStore } from '~/stores/preflights'
20
21
  import { useServicesStore } from '~/stores/services'
21
22
  import type { WizardContext } from '~/stores/environmentWizard/context'
@@ -43,10 +44,11 @@ import { createSaveActions } from '~/stores/environmentWizard/save'
43
44
  // recipe / save) that close over the shared reactive {@link WizardContext} assembled here — a
44
45
  // size-only extraction following the `board` store idiom, behaviour is unchanged.
45
46
 
46
- /** The seeded analyst-only pipeline the "run deep analysis" trigger starts against the frame. */
47
- const ANALYSIS_PIPELINE_ID = 'pl_environment_analysis'
48
- /** The analyst agent kind whose `result.custom` carries the drafted recipe. */
49
- const ANALYST_AGENT_KIND = 'environment-analyst'
47
+ // The "run deep analysis" trigger starts the analyst agent as a SINGLE-KIND run — one step, no
48
+ // pipeline and reads the drafted recipe off that step's `result.custom`. Both the kind and the
49
+ // id its run reports come from the shared contract, so the wizard cannot go looking for a run
50
+ // under a name the backend stopped using.
51
+ const ANALYSIS_PIPELINE_ID = adHocPipelineIdFor(ENVIRONMENT_ANALYST_AGENT_KIND)
50
52
 
51
53
  /** The analyst run's lifecycle as the wizard surfaces it. */
52
54
  export type AnalysisStatus = 'idle' | 'running' | 'ready' | 'failed'
@@ -57,7 +59,6 @@ export const useEnvironmentWizardStore = defineStore('environmentWizard', () =>
57
59
  const services = useServicesStore()
58
60
  const infra = useInfraConfigStore()
59
61
  const execution = useExecutionStore()
60
- const pipelines = usePipelinesStore()
61
62
  const preflights = usePreflightsStore()
62
63
 
63
64
  // ---- Target frame -------------------------------------------------------
@@ -124,9 +125,10 @@ export const useEnvironmentWizardStore = defineStore('environmentWizard', () =>
124
125
 
125
126
  const hasRepo = computed(() => repoContext.value !== undefined)
126
127
 
127
- /** The seeded analyst pipeline, when present in the workspace (else deep analysis is unavailable). */
128
- const analysisPipeline = computed(() => pipelines.getPipeline(ANALYSIS_PIPELINE_ID))
129
- const canAnalyze = computed(() => hasRepo.value && analysisPipeline.value !== undefined)
128
+ // Deep analysis needs only a repo to read: the agent is started by KIND, so there is no
129
+ // catalog row for the workspace to be missing (which is what the old `pl_environment_analysis`
130
+ // lookup guarded against).
131
+ const canAnalyze = computed(() => hasRepo.value)
130
132
 
131
133
  /** The analyst run for this frame (newest matching instance), read live from the execution store.
132
134
  * Filters the full instance list (not the collapsing `getByBlock`, which returns a single run per
@@ -153,7 +155,7 @@ export const useEnvironmentWizardStore = defineStore('environmentWizard', () =>
153
155
  const analystDraft = computed<AnalystRecipeDraft | null>(() => {
154
156
  const run = analystRun.value
155
157
  if (!run) return null
156
- const analystStep = run.steps.find((s) => s.agentKind === ANALYST_AGENT_KIND)
158
+ const analystStep = run.steps.find((s) => s.agentKind === ENVIRONMENT_ANALYST_AGENT_KIND)
157
159
  if (!analystStep || analystStep.state !== 'done' || analystStep.custom === undefined)
158
160
  return null
159
161
  const parsed = v.safeParse(analystRecipeDraftSchema, analystStep.custom)
@@ -207,7 +209,6 @@ export const useEnvironmentWizardStore = defineStore('environmentWizard', () =>
207
209
  trialError,
208
210
  trialStarted,
209
211
  repoContext,
210
- analysisPipeline,
211
212
  merged,
212
213
  }
213
214
  const flow = createFlowActions(context)
@@ -41,10 +41,15 @@ export function createExecutionCommands(ctx: ExecutionCommandContext) {
41
41
  * and nothing merges. It is a request, not a decision — the task's merge preset can sandbox a
42
42
  * role's runs whatever they asked for, so what the run got is read back off the run's own
43
43
  * `mode`, never assumed from what was sent here.
44
+ *
45
+ * Takes the id + name it actually sends and reports, not a whole {@link Pipeline}: a caller may
46
+ * legitimately hold neither, because a task can be pinned to an INTERNAL pipeline that the
47
+ * library withholds from every picker. Demanding the row there would force the caller to either
48
+ * fabricate one or silently start something else.
44
49
  */
45
50
  async function start(
46
51
  blockId: string,
47
- pipeline: Pipeline,
52
+ pipeline: Pick<Pipeline, 'id' | 'name'>,
48
53
  options?: { mode?: RunMode },
49
54
  ): Promise<boolean> {
50
55
  const ws = useWorkspaceStore()
@@ -71,6 +76,25 @@ export function createExecutionCommands(ctx: ExecutionCommandContext) {
71
76
  }
72
77
  }
73
78
 
79
+ /**
80
+ * Start ONE agent kind against a block. The single-kind counterpart of {@link start}: same
81
+ * credential gate, same snapshot refresh, same false-on-refusal contract — only the thing being
82
+ * started is an agent rather than a pipeline.
83
+ */
84
+ async function startAgentKind(blockId: string, agentKind: string): Promise<boolean> {
85
+ const ws = useWorkspaceStore()
86
+ const personal = usePersonalSubscriptionsStore()
87
+ try {
88
+ return await personal.withCredential(async (password) => {
89
+ await api.startAgentKindExecution(ws.requireId(), blockId, agentKind, password)
90
+ await ws.refresh()
91
+ })
92
+ } catch (e) {
93
+ runErrors.present(e, 'errors.action.startFailed')
94
+ return false
95
+ }
96
+ }
97
+
74
98
  // Interacting with a running individual-usage run (resolve/approve/request-changes) advances
75
99
  // + re-dispatches the run, so the server re-mints its short-TTL activation from the personal
76
100
  // password first. It rides the cached password transparently, and — like start/retry — is
@@ -237,6 +261,7 @@ export function createExecutionCommands(ctx: ExecutionCommandContext) {
237
261
 
238
262
  return {
239
263
  start,
264
+ startAgentKind,
240
265
  resolveDecision,
241
266
  approveStep,
242
267
  requestStepChanges,
@@ -4,6 +4,7 @@ import { companionForProducer } from '~/utils/catalog'
4
4
  import type { PipelinesContext } from './context'
5
5
  import { createPipelineGateConfigActions } from './draftGateConfig'
6
6
  import { createPipelineStepConfigActions } from './draftStepConfig'
7
+ import { createPipelineStepOptionActions } from './draftStepOptions'
7
8
 
8
9
  /**
9
10
  * The pipeline-builder draft's STRUCTURE: inserting/removing/reordering steps, the companion
@@ -193,6 +194,7 @@ export function createPipelineDraftActions(ctx: PipelinesContext) {
193
194
 
194
195
  return {
195
196
  ...createPipelineStepConfigActions(ctx),
197
+ ...createPipelineStepOptionActions(ctx),
196
198
  ...createPipelineGateConfigActions(ctx),
197
199
  addToDraft,
198
200
  removeFromDraft,
@@ -1,41 +1,19 @@
1
- import type { BinaryOutputConfig, StepOptions } from '@cat-factory/contracts'
2
1
  import type { ConsensusStepConfig } from '~/types/consensus'
3
2
  import { defaultConsensusConfig, type PipelinesContext } from './context'
4
3
 
5
4
  /**
6
5
  * The pipeline-builder draft's PER-STEP CONFIG toggles: consensus (inline panel and the workspace
7
6
  * consensus-GROUP tier set), the human approval gate, the estimate gate on a companion step, the
8
- * follow-up and test-QC companions, the per-step enable flag, and the `StepOptions` bag
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). The step's GATE
11
- * configuration is the sibling `./draftGateConfig`, which explains why it is not here.
7
+ * follow-up and test-QC companions, and the per-step enable flag every toggle that writes a
8
+ * PARALLEL ARRAY. The `StepOptions` bag accessors are the sibling `./draftStepOptions`, and the
9
+ * step's GATE configuration `./draftGateConfig`; each is split along the state it writes rather
10
+ * than an arbitrary line count.
12
11
  *
13
12
  * Split out of `./draftActions`, which owns the draft's STRUCTURE (insert / remove / reorder /
14
13
  * units). Every function here reads and writes one of the parallel per-step arrays at an index and
15
14
  * touches nothing else, which is what makes the two independent; both are spread into the store,
16
15
  * so the store's API is unchanged.
17
16
  */
18
- /**
19
- * Write ONE field of the step's `StepOptions` bag at `index`, merging into whatever else that step
20
- * carries and normalizing an emptied bag back to `null`. `undefined` CLEARS the field.
21
- *
22
- * Every per-step option below goes through this rather than repeating the clone/assign/normalize
23
- * dance, because the two halves that are easy to get wrong are shared by all of them: replacing
24
- * the bag loses the neighbouring options a step may also carry, and leaving a `{}` behind makes a
25
- * step that is back on every default persist a shape it never had.
26
- */
27
- function patchStepOption<K extends keyof StepOptions>(
28
- draftStepOptions: PipelinesContext['draftStepOptions'],
29
- index: number,
30
- key: K,
31
- value: StepOptions[K] | undefined,
32
- ) {
33
- const next: StepOptions = { ...draftStepOptions.value[index] }
34
- if (value === undefined) delete next[key]
35
- else next[key] = value
36
- draftStepOptions.value[index] = Object.keys(next).length ? next : null
37
- }
38
-
39
17
  export function createPipelineStepConfigActions(ctx: PipelinesContext) {
40
18
  const {
41
19
  draftGates,
@@ -44,7 +22,6 @@ export function createPipelineStepConfigActions(ctx: PipelinesContext) {
44
22
  draftGating,
45
23
  draftFollowUps,
46
24
  draftTesterQuality,
47
- draftStepOptions,
48
25
  } = ctx
49
26
 
50
27
  /** Toggle estimate gating on/off for the (companion) step at `index`. */
@@ -128,128 +105,6 @@ export function createPipelineStepConfigActions(ctx: PipelinesContext) {
128
105
  draftEnabled.value[index] = draftEnabled.value[index] === false
129
106
  }
130
107
 
131
- /** Whether auto-recommendation is on for the draft (requirements-review) step at `index`. */
132
- function draftAutoRecommendEnabled(index: number): boolean {
133
- return draftStepOptions.value[index]?.autoRecommend !== false
134
- }
135
-
136
- /**
137
- * Toggle the requirements-review auto-recommendation on the draft step at `index`. It is on by
138
- * default, so we store ONLY the opt-out (`{ autoRecommend: false }`); toggling back drops the
139
- * flag. Merges with any other future StepOptions fields rather than clobbering the whole bag.
140
- */
141
- function toggleDraftAutoRecommend(index: number) {
142
- const off = draftAutoRecommendEnabled(index) ? false : undefined
143
- patchStepOption(draftStepOptions, index, 'autoRecommend', off)
144
- }
145
-
146
- /**
147
- * Whether the draft `deployer` step at `index` declares that its environments outlive the run
148
- * (its `stepOptions.retainEnvironment`). OFF by default: the everyday shape is a run that
149
- * reclaims what it stood up, and the save boundary refuses a Deployer that does neither.
150
- */
151
- function draftRetainEnvironment(index: number): boolean {
152
- return draftStepOptions.value[index]?.retainEnvironment === true
153
- }
154
-
155
- /**
156
- * Toggle the retain declaration on the draft `deployer` step at `index`. It is off by default,
157
- * so we store ONLY the opt-in (the mirror of `toggleDraftAutoRecommend`, which stores only the
158
- * opt-out); toggling back drops the flag and, if the bag empties, the whole entry.
159
- */
160
- function toggleDraftRetainEnvironment(index: number) {
161
- const on = draftRetainEnvironment(index) ? undefined : true
162
- patchStepOption(draftStepOptions, index, 'retainEnvironment', on)
163
- }
164
-
165
- /** The skill picked for the draft `skill` step at `index` (its `stepOptions.skillId`). */
166
- function draftSkillId(index: number): string | undefined {
167
- return draftStepOptions.value[index]?.skillId
168
- }
169
-
170
- /**
171
- * Set (or clear) the picked skill on the draft `skill` step at `index`. Merges into the
172
- * step's `StepOptions` bag rather than clobbering it; clearing drops the field and, if the
173
- * bag empties, the whole entry (so it normalizes away like the other options).
174
- */
175
- function setDraftSkillId(index: number, skillId: string | undefined) {
176
- patchStepOption(draftStepOptions, index, 'skillId', skillId || undefined)
177
- }
178
-
179
- /**
180
- * The agent-kind VARIANT picked for the draft step at `index` (its
181
- * `stepOptions.agentVariantId`), or undefined when it runs the kind's shipped prompt.
182
- */
183
- function draftAgentVariantId(index: number): string | undefined {
184
- return draftStepOptions.value[index]?.agentVariantId
185
- }
186
-
187
- /**
188
- * Set (or clear) the picked variant on the draft step at `index`. Merges into the step's
189
- * `StepOptions` bag rather than clobbering it; clearing drops the field and, if the bag
190
- * empties, the whole entry — exactly like the other options here, so a step back on the
191
- * shipped prompt persists nothing.
192
- */
193
- function setDraftAgentVariantId(index: number, agentVariantId: string | undefined) {
194
- patchStepOption(draftStepOptions, index, 'agentVariantId', agentVariantId || undefined)
195
- }
196
-
197
- /**
198
- * The binary-output SELECTION on the draft step at `index` (its `stepOptions.binaryOutput`) —
199
- * the foundational storage service a generator kind's artifacts are stored through, plus any
200
- * services consulted for the generation's scope. Undefined on every step of every stock
201
- * pipeline; required on a step whose kind carries the `binary-output` trait.
202
- */
203
- function draftBinaryOutput(index: number): BinaryOutputConfig | undefined {
204
- return draftStepOptions.value[index]?.binaryOutput
205
- }
206
-
207
- /**
208
- * Set (or clear) the binary-output selection on the draft step at `index`. Merges into the
209
- * step's `StepOptions` bag rather than clobbering it; clearing drops the field and, if the
210
- * bag empties, the whole entry — exactly like the other options here, so a step that never
211
- * used it persists the shape it always did.
212
- *
213
- * An EMPTY `contextServiceIds` is dropped rather than stored, for the reason the consensus
214
- * tier set drops its own empty array: the field's absence means "no scope service was
215
- * selected", while `[]` reads as "context was considered and rejected" — a different claim,
216
- * and one the brief renderer would repeat to the agent. `generatorIds` and `modalities` take
217
- * the same treatment for the same reason: an absent `generatorIds` means the step generates
218
- * through whatever its agent already has, and an absent `modalities` imposes no delivery
219
- * requirement — both of which the brief STATES, so persisting `[]` would have it state the
220
- * wrong thing.
221
- */
222
- function setDraftBinaryOutput(index: number, config: BinaryOutputConfig | undefined) {
223
- const { storageServiceId, contextServiceIds, generatorIds, modalities } = config ?? {}
224
- const selection = storageServiceId
225
- ? {
226
- storageServiceId,
227
- ...(contextServiceIds?.length ? { contextServiceIds } : {}),
228
- ...(generatorIds?.length ? { generatorIds } : {}),
229
- ...(modalities?.length ? { modalities } : {}),
230
- }
231
- : undefined
232
- patchStepOption(draftStepOptions, index, 'binaryOutput', selection)
233
- }
234
-
235
- /**
236
- * The output-token ceiling pinned on the draft step at `index`, or undefined when the step
237
- * inherits (the workspace's per-kind setting, else the deployment default).
238
- */
239
- function draftMaxOutputTokens(index: number): number | undefined {
240
- return draftStepOptions.value[index]?.maxOutputTokens
241
- }
242
-
243
- /**
244
- * Set (or clear) this step's own output-token ceiling. Merges into the step's `StepOptions`
245
- * bag rather than clobbering it; clearing drops the field and, if the bag empties, the whole
246
- * entry — so a step back on the inherited budget persists no options at all, exactly like the
247
- * other fields here.
248
- */
249
- function setDraftMaxOutputTokens(index: number, maxOutputTokens: number | undefined) {
250
- patchStepOption(draftStepOptions, index, 'maxOutputTokens', maxOutputTokens ?? undefined)
251
- }
252
-
253
108
  return {
254
109
  toggleDraftGating,
255
110
  toggleDraftConsensus,
@@ -260,17 +115,5 @@ export function createPipelineStepConfigActions(ctx: PipelinesContext) {
260
115
  toggleDraftTesterQuality,
261
116
  toggleDraftTesterQualityGating,
262
117
  toggleDraftEnabled,
263
- draftAutoRecommendEnabled,
264
- toggleDraftAutoRecommend,
265
- draftRetainEnvironment,
266
- toggleDraftRetainEnvironment,
267
- draftSkillId,
268
- setDraftSkillId,
269
- draftAgentVariantId,
270
- setDraftAgentVariantId,
271
- draftBinaryOutput,
272
- setDraftBinaryOutput,
273
- draftMaxOutputTokens,
274
- setDraftMaxOutputTokens,
275
118
  }
276
119
  }
@@ -0,0 +1,204 @@
1
+ import type { BinaryOutputConfig, StepOptions, StepServiceScope } from '@cat-factory/contracts'
2
+ import type { PipelinesContext } from './context'
3
+
4
+ /**
5
+ * The pipeline-builder draft's `StepOptions` BAG accessors: every per-step parameter that lives in
6
+ * the one extensible options object rather than in a parallel array of its own — the requirements
7
+ * auto-recommendation, the Deployer's retain declaration, the step's run CONDITION, the picked
8
+ * skill, the picked agent-kind variant, the binary-output selection, and the per-step output-token
9
+ * ceiling.
10
+ *
11
+ * Its own module for the reason `./draftGateConfig` is: the sibling `./draftStepConfig` owns the
12
+ * per-step TOGGLES that each write a parallel array, while every function here reads and writes
13
+ * one FIELD of one bag through {@link patchStepOption}. The split is along that seam rather than
14
+ * an arbitrary line count, so a new per-step knob has an obvious home — and since the bag is the
15
+ * declared home for every NEW knob, this is the half that keeps growing.
16
+ */
17
+
18
+ /**
19
+ * Write ONE field of the step's `StepOptions` bag at `index`, merging into whatever else that step
20
+ * carries and normalizing an emptied bag back to `null`. `undefined` CLEARS the field.
21
+ *
22
+ * Every per-step option below goes through this rather than repeating the clone/assign/normalize
23
+ * dance, because the two halves that are easy to get wrong are shared by all of them: replacing
24
+ * the bag loses the neighbouring options a step may also carry, and leaving a `{}` behind makes a
25
+ * step that is back on every default persist a shape it never had.
26
+ */
27
+ function patchStepOption<K extends keyof StepOptions>(
28
+ draftStepOptions: PipelinesContext['draftStepOptions'],
29
+ index: number,
30
+ key: K,
31
+ value: StepOptions[K] | undefined,
32
+ ) {
33
+ const next: StepOptions = { ...draftStepOptions.value[index] }
34
+ if (value === undefined) delete next[key]
35
+ else next[key] = value
36
+ draftStepOptions.value[index] = Object.keys(next).length ? next : null
37
+ }
38
+
39
+ export function createPipelineStepOptionActions(ctx: PipelinesContext) {
40
+ const { draftStepOptions } = ctx
41
+
42
+ /** Whether auto-recommendation is on for the draft (requirements-review) step at `index`. */
43
+ function draftAutoRecommendEnabled(index: number): boolean {
44
+ return draftStepOptions.value[index]?.autoRecommend !== false
45
+ }
46
+
47
+ /**
48
+ * Toggle the requirements-review auto-recommendation on the draft step at `index`. It is on by
49
+ * default, so we store ONLY the opt-out (`{ autoRecommend: false }`); toggling back drops the
50
+ * flag. Merges with any other future StepOptions fields rather than clobbering the whole bag.
51
+ */
52
+ function toggleDraftAutoRecommend(index: number) {
53
+ const off = draftAutoRecommendEnabled(index) ? false : undefined
54
+ patchStepOption(draftStepOptions, index, 'autoRecommend', off)
55
+ }
56
+
57
+ /**
58
+ * Whether the draft `deployer` step at `index` declares that its environments outlive the run
59
+ * (its `stepOptions.retainEnvironment`). OFF by default: the everyday shape is a run that
60
+ * reclaims what it stood up, and the save boundary refuses a Deployer that does neither.
61
+ */
62
+ function draftRetainEnvironment(index: number): boolean {
63
+ return draftStepOptions.value[index]?.retainEnvironment === true
64
+ }
65
+
66
+ /**
67
+ * Toggle the retain declaration on the draft `deployer` step at `index`. It is off by default,
68
+ * so we store ONLY the opt-in (the mirror of `toggleDraftAutoRecommend`, which stores only the
69
+ * opt-out); toggling back drops the flag and, if the bag empties, the whole entry.
70
+ */
71
+ function toggleDraftRetainEnvironment(index: number) {
72
+ const on = draftRetainEnvironment(index) ? undefined : true
73
+ patchStepOption(draftStepOptions, index, 'retainEnvironment', on)
74
+ }
75
+
76
+ /**
77
+ * The RUN CONDITION on the draft step at `index` — the service scope it applies to, or
78
+ * `undefined` when it runs on every task (`stepOptions.condition`).
79
+ */
80
+ function draftStepCondition(index: number): StepServiceScope | undefined {
81
+ return draftStepOptions.value[index]?.condition?.serviceScope
82
+ }
83
+
84
+ /**
85
+ * Cycle the draft step's run condition: unconditional → frontend-only → backend-only →
86
+ * unconditional.
87
+ *
88
+ * A CYCLE rather than a picker because the states are three and mutually exclusive, and the
89
+ * control has to live in a dense per-step icon row beside eight others. It cycles back to
90
+ * unconditional deliberately: a condition arrives on a step by CLONING a built-in (the tester
91
+ * pair), so the state a user most needs to reach from here is the one that clears it.
92
+ */
93
+ function cycleDraftStepCondition(index: number) {
94
+ const current = draftStepCondition(index)
95
+ const next: StepServiceScope | undefined =
96
+ current === undefined ? 'frontend' : current === 'frontend' ? 'backend' : undefined
97
+ patchStepOption(draftStepOptions, index, 'condition', next ? { serviceScope: next } : undefined)
98
+ }
99
+
100
+ /** The skill picked for the draft `skill` step at `index` (its `stepOptions.skillId`). */
101
+ function draftSkillId(index: number): string | undefined {
102
+ return draftStepOptions.value[index]?.skillId
103
+ }
104
+
105
+ /**
106
+ * Set (or clear) the picked skill on the draft `skill` step at `index`. Merges into the
107
+ * step's `StepOptions` bag rather than clobbering it; clearing drops the field and, if the
108
+ * bag empties, the whole entry (so it normalizes away like the other options).
109
+ */
110
+ function setDraftSkillId(index: number, skillId: string | undefined) {
111
+ patchStepOption(draftStepOptions, index, 'skillId', skillId || undefined)
112
+ }
113
+
114
+ /**
115
+ * The agent-kind VARIANT picked for the draft step at `index` (its
116
+ * `stepOptions.agentVariantId`), or undefined when it runs the kind's shipped prompt.
117
+ */
118
+ function draftAgentVariantId(index: number): string | undefined {
119
+ return draftStepOptions.value[index]?.agentVariantId
120
+ }
121
+
122
+ /**
123
+ * Set (or clear) the picked variant on the draft step at `index`. Merges into the step's
124
+ * `StepOptions` bag rather than clobbering it; clearing drops the field and, if the bag
125
+ * empties, the whole entry — exactly like the other options here, so a step back on the
126
+ * shipped prompt persists nothing.
127
+ */
128
+ function setDraftAgentVariantId(index: number, agentVariantId: string | undefined) {
129
+ patchStepOption(draftStepOptions, index, 'agentVariantId', agentVariantId || undefined)
130
+ }
131
+
132
+ /**
133
+ * The binary-output SELECTION on the draft step at `index` (its `stepOptions.binaryOutput`) —
134
+ * the foundational storage service a generator kind's artifacts are stored through, plus any
135
+ * services consulted for the generation's scope. Undefined on every step of every stock
136
+ * pipeline; required on a step whose kind carries the `binary-output` trait.
137
+ */
138
+ function draftBinaryOutput(index: number): BinaryOutputConfig | undefined {
139
+ return draftStepOptions.value[index]?.binaryOutput
140
+ }
141
+
142
+ /**
143
+ * Set (or clear) the binary-output selection on the draft step at `index`. Merges into the
144
+ * step's `StepOptions` bag rather than clobbering it; clearing drops the field and, if the
145
+ * bag empties, the whole entry — exactly like the other options here, so a step that never
146
+ * used it persists the shape it always did.
147
+ *
148
+ * An EMPTY `contextServiceIds` is dropped rather than stored, for the reason the consensus
149
+ * tier set drops its own empty array: the field's absence means "no scope service was
150
+ * selected", while `[]` reads as "context was considered and rejected" — a different claim,
151
+ * and one the brief renderer would repeat to the agent. `generatorIds` and `modalities` take
152
+ * the same treatment for the same reason: an absent `generatorIds` means the step generates
153
+ * through whatever its agent already has, and an absent `modalities` imposes no delivery
154
+ * requirement — both of which the brief STATES, so persisting `[]` would have it state the
155
+ * wrong thing.
156
+ */
157
+ function setDraftBinaryOutput(index: number, config: BinaryOutputConfig | undefined) {
158
+ const { storageServiceId, contextServiceIds, generatorIds, modalities } = config ?? {}
159
+ const selection = storageServiceId
160
+ ? {
161
+ storageServiceId,
162
+ ...(contextServiceIds?.length ? { contextServiceIds } : {}),
163
+ ...(generatorIds?.length ? { generatorIds } : {}),
164
+ ...(modalities?.length ? { modalities } : {}),
165
+ }
166
+ : undefined
167
+ patchStepOption(draftStepOptions, index, 'binaryOutput', selection)
168
+ }
169
+
170
+ /**
171
+ * The output-token ceiling pinned on the draft step at `index`, or undefined when the step
172
+ * inherits (the workspace's per-kind setting, else the deployment default).
173
+ */
174
+ function draftMaxOutputTokens(index: number): number | undefined {
175
+ return draftStepOptions.value[index]?.maxOutputTokens
176
+ }
177
+
178
+ /**
179
+ * Set (or clear) this step's own output-token ceiling. Merges into the step's `StepOptions`
180
+ * bag rather than clobbering it; clearing drops the field and, if the bag empties, the whole
181
+ * entry — so a step back on the inherited budget persists no options at all, exactly like the
182
+ * other fields here.
183
+ */
184
+ function setDraftMaxOutputTokens(index: number, maxOutputTokens: number | undefined) {
185
+ patchStepOption(draftStepOptions, index, 'maxOutputTokens', maxOutputTokens ?? undefined)
186
+ }
187
+
188
+ return {
189
+ draftAutoRecommendEnabled,
190
+ toggleDraftAutoRecommend,
191
+ draftRetainEnvironment,
192
+ toggleDraftRetainEnvironment,
193
+ draftStepCondition,
194
+ cycleDraftStepCondition,
195
+ draftSkillId,
196
+ setDraftSkillId,
197
+ draftAgentVariantId,
198
+ setDraftAgentVariantId,
199
+ draftBinaryOutput,
200
+ setDraftBinaryOutput,
201
+ draftMaxOutputTokens,
202
+ setDraftMaxOutputTokens,
203
+ }
204
+ }
@@ -164,12 +164,13 @@ export function createUiResultViews() {
164
164
  // The run-scoped openers (a caller that knows only the RUN, so the step index has to be
165
165
  // resolved) live in a sibling module: they share one shape and one hazard, and lifting them out
166
166
  // keeps this factory inside its per-function line budget. Their two seams are bound here.
167
- const { openFollowUps, openForkDecision, openPrReview, openTestEvidence } = createRunStepOpeners({
168
- dispatchStepView: (instanceId, stepIndex) => dispatchStepView(instanceId, stepIndex),
169
- setResultView: (view, instance, stepIndex) => {
170
- resultView.value = { view, blockId: instance.blockId, instanceId: instance.id, stepIndex }
171
- },
172
- })
167
+ const { openFollowUps, openForkDecision, openBinaryCandidates, openPrReview, openTestEvidence } =
168
+ createRunStepOpeners({
169
+ dispatchStepView: (instanceId, stepIndex) => dispatchStepView(instanceId, stepIndex),
170
+ setResultView: (view, instance, stepIndex) => {
171
+ resultView.value = { view, blockId: instance.blockId, instanceId: instance.id, stepIndex }
172
+ },
173
+ })
173
174
 
174
175
  function closeResultView() {
175
176
  resultView.value = null
@@ -209,6 +210,7 @@ export function createUiResultViews() {
209
210
  openInitiativePlanning,
210
211
  openFollowUps,
211
212
  openForkDecision,
213
+ openBinaryCandidates,
212
214
  openPrReview,
213
215
  openTestEvidence,
214
216
  openOutcome,
@@ -93,6 +93,28 @@ export function createRunStepOpeners(deps: RunStepOpenerDeps) {
93
93
  )
94
94
  }
95
95
 
96
+ // Open the generated-candidate comparison window for a run's binary-output step (from the
97
+ // pipeline chip / inspector rail / step overlay). Resolves the step index from the run when not
98
+ // given, preferring the step parked awaiting a choice.
99
+ //
100
+ // Resolved by the CANDIDATE STATE rather than by an agent kind, unlike its neighbours: any kind
101
+ // carrying the `binary-output` trait can run a comparison, and a deployment's own kinds are
102
+ // exactly the ones a hard-coded kind list here would never name.
103
+ function openBinaryCandidates(instanceId: string, stepIndex: number | null = null) {
104
+ withStep(
105
+ instanceId,
106
+ stepIndex,
107
+ (instance) => {
108
+ const awaiting = indexOf(instance, (s) => s.binaryCandidates?.status === 'awaiting_choice')
109
+ if (awaiting >= 0) return awaiting
110
+ const current = instance.steps[instance.currentStep]
111
+ if (current?.binaryCandidates) return instance.currentStep
112
+ return indexOf(instance, (s) => !!s.binaryCandidates)
113
+ },
114
+ (instance, idx) => deps.setResultView('binary-candidates', instance, idx),
115
+ )
116
+ }
117
+
96
118
  // Open the PR deep-review window for a run's `pr-reviewer` step (from the `pr_review_ready`
97
119
  // notification / the step). Resolves the step index from the run when not given, preferring
98
120
  // the step parked awaiting a finding selection.
@@ -139,5 +161,5 @@ export function createRunStepOpeners(deps: RunStepOpenerDeps) {
139
161
  )
140
162
  }
141
163
 
142
- return { openFollowUps, openForkDecision, openPrReview, openTestEvidence }
164
+ return { openFollowUps, openForkDecision, openBinaryCandidates, openPrReview, openTestEvidence }
143
165
  }
@@ -164,6 +164,12 @@ export interface AgentArchetype {
164
164
  * which is every built-in kind.
165
165
  */
166
166
  binaryOutput?: boolean
167
+ /**
168
+ * The platform dispatches this kind for a flow of its own, so the builder palette never offers
169
+ * it as a placeable block (`narrowAgentPalette` drops it). It still resolves through
170
+ * `agentKindMeta`, because a run of it has to RENDER. Absent ⇒ an ordinary palette block.
171
+ */
172
+ internal?: boolean
167
173
  }
168
174
 
169
175
  /**
@@ -95,6 +95,11 @@ export type {
95
95
  BinaryOutputArtifact,
96
96
  BinaryOutputConfig,
97
97
  BinaryOutputReport,
98
+ // The candidate-comparison set on a step whose selection declares a `comparison`: the staged
99
+ // candidates, the live park state, and the human's keep/discard decision.
100
+ BinaryCandidate,
101
+ BinaryCandidateChoice,
102
+ BinaryCandidateStepState,
98
103
  TesterStepState,
99
104
  HumanTestEnvironment,
100
105
  RunEnvironment,
@@ -33,6 +33,32 @@ const CATALOG: AgentArchetype[] = [
33
33
  archetype('acme-auditor'),
34
34
  ]
35
35
 
36
+ describe('narrowAgentPalette — internal kinds', () => {
37
+ // An INTERNAL kind is one the platform dispatches for a flow of its own (the environment
38
+ // analyst, which hands its draft to the setup wizard). It is not hidden by a dial, it is not a
39
+ // palette block at all.
40
+ const internal: AgentArchetype = {
41
+ ...archetype('environment-analyst', 'design', 'basic'),
42
+ internal: true,
43
+ }
44
+ const catalog = [archetype('architect', 'design', 'basic'), internal]
45
+
46
+ it('never offers one, at any purpose or tier', () => {
47
+ for (const tier of ['basic', 'intermediate', 'advanced'] as const) {
48
+ const { offered } = narrowAgentPalette(catalog, 'build', tier)
49
+ expect(offered.map((a) => a.kind)).toEqual(['architect'])
50
+ }
51
+ })
52
+
53
+ it('counts one against NEITHER dial, so no hint promises a control that would reveal it', () => {
54
+ // The whole point of the counts is "relax THIS dial and you get n more". An internal kind is
55
+ // revealed by neither, so counting it would send a reader chasing a control that cannot help.
56
+ const { hiddenByPurpose, hiddenByTier } = narrowAgentPalette(catalog, 'review', 'basic')
57
+ expect(hiddenByPurpose).toBe(1) // the architect alone: a review pipeline designs nothing
58
+ expect(hiddenByTier).toBe(0)
59
+ })
60
+ })
61
+
36
62
  describe('narrowAgentPalette', () => {
37
63
  it('narrows nothing for a build pipeline at the widest tier', () => {
38
64
  const { offered, hiddenByPurpose, hiddenByTier } = narrowAgentPalette(