@cat-factory/app 0.151.1 → 0.152.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,81 +1,11 @@
1
1
  import { defineStore } from 'pinia'
2
- import { computed, ref } from 'vue'
3
- import type { AgentKind, Pipeline } from '~/types/domain'
4
- import type { ConsensusStepConfig, StepGating } from '~/types/consensus'
5
- import type { PipelinePurpose, StepOptions, TesterQualityConfig } from '@cat-factory/contracts'
6
- import { companionForProducer, uid } from '~/utils/catalog'
2
+ import { ref } from 'vue'
3
+ import type { Pipeline } from '~/types/domain'
4
+ import type { PipelinePurpose } from '@cat-factory/contracts'
7
5
  import { useUpsertList } from '~/composables/useUpsertList'
8
- import { useWorkspaceStore } from '~/stores/workspace'
9
-
10
- /** A sensible default config when a step is first flipped to consensus in the builder. */
11
- function defaultConsensusConfig(): ConsensusStepConfig {
12
- return {
13
- enabled: true,
14
- strategy: 'specialist-panel',
15
- participants: [
16
- { id: uid('cp'), role: 'Pragmatist', systemFraming: 'Favour the simplest viable approach.' },
17
- {
18
- id: uid('cp'),
19
- role: 'Skeptic',
20
- systemFraming: 'Probe risks, edge cases and failure modes.',
21
- },
22
- ],
23
- }
24
- }
25
-
26
- /**
27
- * The per-step, index-aligned draft arrays. Every one is kept the same length as `draft` and
28
- * spliced/reordered in lockstep (see `insertAt` / `removeFromDraft` / `moveUnit`), so grouping
29
- * their construction keeps that alignment contract — and its documentation — in one place.
30
- */
31
- function createDraftStepState() {
32
- /** The chain currently being assembled in the builder. */
33
- const draft = ref<AgentKind[]>([])
34
- /** Per-step approval gates, kept index-aligned with `draft`. */
35
- const draftGates = ref<boolean[]>([])
36
- /** Per-step enable flags, kept index-aligned with `draft` (false ⇒ skipped at run). */
37
- const draftEnabled = ref<boolean[]>([])
38
- /**
39
- * Per-step companion thresholds, kept index-aligned with `draft`. Not editable in the
40
- * builder UI today, but carried through edits so an existing pipeline's thresholds
41
- * survive a save.
42
- */
43
- const draftThresholds = ref<(number | null)[]>([])
44
- /** Per-step consensus configs, kept index-aligned with `draft` (null ⇒ standard agent). */
45
- const draftConsensus = ref<(ConsensusStepConfig | null)[]>([])
46
- /** Per-step estimate gating, kept index-aligned with `draft` (null ⇒ always run). */
47
- const draftGating = ref<(StepGating | null)[]>([])
48
- /**
49
- * Per-step Follow-up companion toggle, kept index-aligned with `draft`. Only meaningful on
50
- * a `coder` step; `false` disables the companion there (default/true ⇒ enabled).
51
- */
52
- const draftFollowUps = ref<(boolean | null)[]>([])
53
- /**
54
- * Per-step test quality-control companion config, kept index-aligned with `draft`. Only
55
- * meaningful on a Tester step (`tester-api`/`tester-ui`); `null`/absent means "enabled, no
56
- * gating" (the QC companion is on by default), `{ enabled: false }` disables it, and an
57
- * entry with `gating` makes it conditional on the task estimate.
58
- */
59
- const draftTesterQuality = ref<(TesterQualityConfig | null)[]>([])
60
- /**
61
- * Per-step options bag, kept index-aligned with `draft`: the extensible home for new per-step
62
- * parameters (see `StepOptions`). `null`/absent per step ⇒ that step's defaults. Today the only
63
- * field is `autoRecommend` (requirements-review); by convention we store ONLY deviations from a
64
- * default, so an entry exists only when a step opts out of something.
65
- */
66
- const draftStepOptions = ref<(StepOptions | null)[]>([])
67
- return {
68
- draft,
69
- draftGates,
70
- draftEnabled,
71
- draftThresholds,
72
- draftConsensus,
73
- draftGating,
74
- draftFollowUps,
75
- draftTesterQuality,
76
- draftStepOptions,
77
- }
78
- }
6
+ import { createDraftStepState, type PipelinesContext } from '~/stores/pipelines/context'
7
+ import { createPipelineDraftActions } from '~/stores/pipelines/draftActions'
8
+ import { createPipelinePersistence } from '~/stores/pipelines/persistence'
79
9
 
80
10
  /**
81
11
  * Saved, reusable pipelines (the pipeline palette) plus the in-progress draft
@@ -83,6 +13,11 @@ function createDraftStepState() {
83
13
  * the draft is transient client state. The draft doubles as the EDIT surface: a
84
14
  * custom pipeline can be loaded into it (`loadForEdit`) and saved back in place,
85
15
  * while a built-in is cloned first (`clonePipeline`) into an editable copy.
16
+ *
17
+ * The draft manipulation + persistence operations live in cohesive factories
18
+ * ({@link createPipelineDraftActions} / {@link createPipelinePersistence}, under
19
+ * `stores/pipelines/`) that close over the shared state assembled here — a size-only
20
+ * split mirroring `stores/board/`, not a new seam.
86
21
  */
87
22
  export const usePipelinesStore = defineStore('pipelines', () => {
88
23
  const api = useApi()
@@ -135,360 +70,31 @@ export const usePipelinesStore = defineStore('pipelines', () => {
135
70
  return pipelines.value.find((p) => p.id === id)
136
71
  }
137
72
 
138
- /** Insert a step (with its default per-step config) at `index`, keeping arrays aligned. */
139
- function insertAt(index: number, kind: AgentKind) {
140
- draft.value.splice(index, 0, kind)
141
- draftGates.value.splice(index, 0, false)
142
- draftEnabled.value.splice(index, 0, true)
143
- draftThresholds.value.splice(index, 0, null)
144
- draftConsensus.value.splice(index, 0, null)
145
- draftGating.value.splice(index, 0, null)
146
- draftFollowUps.value.splice(index, 0, null)
147
- draftTesterQuality.value.splice(index, 0, null)
148
- draftStepOptions.value.splice(index, 0, null)
149
- }
150
-
151
- function addToDraft(kind: AgentKind) {
152
- insertAt(draft.value.length, kind)
153
- }
154
-
155
- function removeFromDraft(index: number) {
156
- draft.value.splice(index, 1)
157
- draftGates.value.splice(index, 1)
158
- draftEnabled.value.splice(index, 1)
159
- draftThresholds.value.splice(index, 1)
160
- draftConsensus.value.splice(index, 1)
161
- draftGating.value.splice(index, 1)
162
- draftFollowUps.value.splice(index, 1)
163
- draftTesterQuality.value.splice(index, 1)
164
- draftStepOptions.value.splice(index, 1)
165
- }
166
-
167
- function moveInDraft(from: number, to: number) {
168
- if (to < 0 || to >= draft.value.length) return
169
- const [item] = draft.value.splice(from, 1)
170
- if (item) draft.value.splice(to, 0, item)
171
- const [gate] = draftGates.value.splice(from, 1)
172
- draftGates.value.splice(to, 0, gate ?? false)
173
- const [on] = draftEnabled.value.splice(from, 1)
174
- draftEnabled.value.splice(to, 0, on ?? true)
175
- const [th] = draftThresholds.value.splice(from, 1)
176
- draftThresholds.value.splice(to, 0, th ?? null)
177
- const [cons] = draftConsensus.value.splice(from, 1)
178
- draftConsensus.value.splice(to, 0, cons ?? null)
179
- const [gat] = draftGating.value.splice(from, 1)
180
- draftGating.value.splice(to, 0, gat ?? null)
181
- const [fu] = draftFollowUps.value.splice(from, 1)
182
- draftFollowUps.value.splice(to, 0, fu ?? null)
183
- const [tq] = draftTesterQuality.value.splice(from, 1)
184
- draftTesterQuality.value.splice(to, 0, tq ?? null)
185
- const [so] = draftStepOptions.value.splice(from, 1)
186
- draftStepOptions.value.splice(to, 0, so ?? null)
187
- }
188
-
189
- /** Whether the producer step at `index` currently has its companion attached after it. */
190
- function hasCompanion(index: number): boolean {
191
- const companion = companionForProducer(draft.value[index] ?? '')
192
- return companion !== undefined && draft.value[index + 1] === companion
193
- }
194
-
195
- /**
196
- * Toggle the dependent companion on the producer step at `index`: insert it immediately
197
- * after (turn on) or remove it (turn off). A no-op for a kind that has no companion.
198
- */
199
- function toggleCompanion(index: number) {
200
- const companion = companionForProducer(draft.value[index] ?? '')
201
- if (!companion) return
202
- if (draft.value[index + 1] === companion) removeFromDraft(index + 1)
203
- else insertAt(index + 1, companion)
204
- }
205
-
206
- /** Toggle estimate gating on/off for the (companion) step at `index`. */
207
- function toggleDraftGating(index: number) {
208
- draftGating.value[index] = draftGating.value[index]?.enabled
209
- ? null
210
- : { enabled: true, minRisk: 0.5, minImpact: 0.5, onMissingEstimate: 'run' }
211
- }
212
-
213
- /**
214
- * The draft as a list of "units" for rendering: each step is one unit, EXCEPT a companion
215
- * that sits immediately after its producer — that companion is folded into the producer's
216
- * unit (`companionIndex`) and surfaced as a toggle on it, not a standalone row. The backend
217
- * now REJECTS a companion that is not immediately after its producer (strict adjacency in
218
- * `validatePipelineShape`), so a saved pipeline never has one — but a stray companion that
219
- * still shows up in the draft (e.g. a pre-existing pipeline saved before adjacency was
220
- * enforced) is emitted as its own standalone unit so it stays visible and removable/
221
- * reorderable into a valid shape rather than being silently dropped — and, crucially, so
222
- * every `draft` index belongs to exactly one unit, which is what lets {@link moveUnit}
223
- * reorder by unit boundaries without ever dropping a step.
224
- * `index`/`companionIndex` are positions in the raw `draft` arrays.
225
- */
226
- const units = computed(() => {
227
- const out: { index: number; kind: AgentKind; companionIndex: number | null }[] = []
228
- let folded = -1 // draft index already consumed as the previous unit's adjacent companion
229
- for (let i = 0; i < draft.value.length; i++) {
230
- const kind = draft.value[i]
231
- if (kind === undefined || i === folded) continue
232
- const companion = companionForProducer(kind)
233
- const companionIndex = companion && draft.value[i + 1] === companion ? i + 1 : null
234
- if (companionIndex !== null) folded = companionIndex
235
- out.push({ index: i, kind, companionIndex })
236
- }
237
- return out
238
- })
239
-
240
- /**
241
- * Move the unit at visible position `from` to `to`, carrying its attached companion. Rebuilds
242
- * every parallel array by the SAME unit boundaries so they stay index-aligned.
243
- */
244
- function moveUnit(from: number, to: number) {
245
- const u = units.value
246
- if (to < 0 || to >= u.length || from === to) return
247
- const reorder = <T>(arr: T[]): T[] => {
248
- const chunks = u.map((unit) =>
249
- arr.slice(unit.index, unit.index + (unit.companionIndex !== null ? 2 : 1)),
250
- )
251
- const [moved] = chunks.splice(from, 1)
252
- if (moved) chunks.splice(to, 0, moved)
253
- return chunks.flat()
254
- }
255
- draft.value = reorder(draft.value)
256
- draftGates.value = reorder(draftGates.value)
257
- draftEnabled.value = reorder(draftEnabled.value)
258
- draftThresholds.value = reorder(draftThresholds.value)
259
- draftConsensus.value = reorder(draftConsensus.value)
260
- draftGating.value = reorder(draftGating.value)
261
- draftFollowUps.value = reorder(draftFollowUps.value)
262
- draftTesterQuality.value = reorder(draftTesterQuality.value)
263
- draftStepOptions.value = reorder(draftStepOptions.value)
264
- }
265
-
266
- /** Toggle the consensus mechanism on the draft step at `index` (default config / off). */
267
- function toggleDraftConsensus(index: number) {
268
- draftConsensus.value[index] = draftConsensus.value[index] ? null : defaultConsensusConfig()
269
- }
270
-
271
- /** Replace the consensus config of the draft step at `index` (builder editor edits). */
272
- function setDraftConsensus(index: number, config: ConsensusStepConfig | null) {
273
- draftConsensus.value[index] = config
274
- }
275
-
276
- /** Toggle the approval gate on the draft step at `index`. */
277
- function toggleDraftGate(index: number) {
278
- draftGates.value[index] = !draftGates.value[index]
279
- }
280
-
281
- /** Toggle the Follow-up companion on the draft (coder) step at `index` (default on → off). */
282
- function toggleDraftFollowUps(index: number) {
283
- // Default (null/true) is enabled, so the first toggle disables it (false); toggle back to null.
284
- draftFollowUps.value[index] = draftFollowUps.value[index] === false ? null : false
285
- }
286
-
287
- /**
288
- * Toggle the test quality-control companion on the draft (Tester) step at `index`. The
289
- * companion is enabled by default (a `null` entry), so the first toggle disables it
290
- * (`{ enabled: false }`, dropping any gating) and the next restores the default.
291
- */
292
- function toggleDraftTesterQuality(index: number) {
293
- draftTesterQuality.value[index] =
294
- draftTesterQuality.value[index]?.enabled === false ? null : { enabled: false }
295
- }
296
-
297
- /**
298
- * Toggle estimate gating on/off for the QC companion on the draft (Tester) step at `index`.
299
- * A no-op while the companion is disabled (nothing to gate). Enabling gating pins the config
300
- * to `{ enabled: true, gating }` so the thresholds are editable; disabling drops back to the
301
- * default `null` (enabled, ungated).
302
- */
303
- function toggleDraftTesterQualityGating(index: number) {
304
- const cur = draftTesterQuality.value[index]
305
- if (cur?.enabled === false) return
306
- draftTesterQuality.value[index] = cur?.gating?.enabled
307
- ? null
308
- : {
309
- enabled: true,
310
- gating: { enabled: true, minRisk: 0.5, minImpact: 0.5, onMissingEstimate: 'run' },
311
- }
312
- }
313
-
314
- /** Enable/disable the draft step at `index` without removing it. */
315
- function toggleDraftEnabled(index: number) {
316
- draftEnabled.value[index] = draftEnabled.value[index] === false
317
- }
318
-
319
- /** Whether auto-recommendation is on for the draft (requirements-review) step at `index`. */
320
- function draftAutoRecommendEnabled(index: number): boolean {
321
- return draftStepOptions.value[index]?.autoRecommend !== false
322
- }
323
-
324
- /**
325
- * Toggle the requirements-review auto-recommendation on the draft step at `index`. It is on by
326
- * default, so we store ONLY the opt-out (`{ autoRecommend: false }`); toggling back drops the
327
- * flag. Merges with any other future StepOptions fields rather than clobbering the whole bag.
328
- */
329
- function toggleDraftAutoRecommend(index: number) {
330
- const next: StepOptions = { ...draftStepOptions.value[index] }
331
- if (draftAutoRecommendEnabled(index)) next.autoRecommend = false
332
- else delete next.autoRecommend
333
- draftStepOptions.value[index] = Object.keys(next).length ? next : null
334
- }
335
-
336
- /** The skill picked for the draft `skill` step at `index` (its `stepOptions.skillId`). */
337
- function draftSkillId(index: number): string | undefined {
338
- return draftStepOptions.value[index]?.skillId
339
- }
340
-
341
- /**
342
- * Set (or clear) the picked skill on the draft `skill` step at `index`. Merges into the
343
- * step's `StepOptions` bag rather than clobbering it; clearing drops the field and, if the
344
- * bag empties, the whole entry (so it normalizes away like the other options).
345
- */
346
- function setDraftSkillId(index: number, skillId: string | undefined) {
347
- const next: StepOptions = { ...draftStepOptions.value[index] }
348
- if (skillId) next.skillId = skillId
349
- else delete next.skillId
350
- draftStepOptions.value[index] = Object.keys(next).length ? next : null
351
- }
352
-
353
- function clearDraft() {
354
- draft.value = []
355
- draftGates.value = []
356
- draftEnabled.value = []
357
- draftThresholds.value = []
358
- draftConsensus.value = []
359
- draftGating.value = []
360
- draftFollowUps.value = []
361
- draftTesterQuality.value = []
362
- draftStepOptions.value = []
363
- draftLabels.value = []
364
- draftPurpose.value = null
365
- draftName.value = 'New pipeline'
366
- draftDescription.value = ''
367
- editingId.value = null
368
- }
369
-
370
- /** Load an existing (custom) pipeline into the draft so it can be edited in place. */
371
- function loadForEdit(pipeline: Pipeline) {
372
- draft.value = [...pipeline.agentKinds]
373
- draftGates.value = pipeline.agentKinds.map((_, i) => pipeline.gates?.[i] ?? false)
374
- draftEnabled.value = pipeline.agentKinds.map((_, i) => pipeline.enabled?.[i] ?? true)
375
- draftThresholds.value = pipeline.agentKinds.map((_, i) => pipeline.thresholds?.[i] ?? null)
376
- draftConsensus.value = pipeline.agentKinds.map((_, i) => pipeline.consensus?.[i] ?? null)
377
- draftGating.value = pipeline.agentKinds.map((_, i) => pipeline.gating?.[i] ?? null)
378
- draftFollowUps.value = pipeline.agentKinds.map((_, i) => pipeline.followUps?.[i] ?? null)
379
- draftTesterQuality.value = pipeline.agentKinds.map(
380
- (_, i) => pipeline.testerQuality?.[i] ?? null,
381
- )
382
- draftStepOptions.value = pipeline.agentKinds.map((_, i) => pipeline.stepOptions?.[i] ?? null)
383
- draftLabels.value = [...(pipeline.labels ?? [])]
384
- draftPurpose.value = pipeline.purpose ?? null
385
- draftName.value = pipeline.name
386
- draftDescription.value = pipeline.description ?? ''
387
- editingId.value = pipeline.id
388
- }
389
-
390
- /** The optional arrays to send, omitting the ones that are at their defaults. */
391
- function draftPayload() {
392
- return {
393
- name: draftName.value.trim() || 'Untitled pipeline',
394
- // ALWAYS send description (like stepOptions) so an update can CLEAR it — an omitted field
395
- // reads as "keep existing", so toggling the last of the text away must send the empty string.
396
- // The backend trims + drops a blank one, so this is a no-op on create / an empty description.
397
- description: draftDescription.value.trim(),
398
- agentKinds: [...draft.value],
399
- // Only send gates when at least one step is gated.
400
- ...(draftGates.value.some(Boolean) ? { gates: [...draftGates.value] } : {}),
401
- // Only send enabled when at least one step is disabled (default is all-on).
402
- ...(draftEnabled.value.some((e) => e === false) ? { enabled: [...draftEnabled.value] } : {}),
403
- // Only send thresholds when at least one step pins an explicit value.
404
- ...(draftThresholds.value.some((t) => t != null)
405
- ? { thresholds: [...draftThresholds.value] }
406
- : {}),
407
- // Only send consensus when at least one step is consensus-enabled.
408
- ...(draftConsensus.value.some((c) => c?.enabled)
409
- ? { consensus: [...draftConsensus.value] }
410
- : {}),
411
- // Only send gating when at least one step has gating enabled.
412
- ...(draftGating.value.some((g) => g?.enabled) ? { gating: [...draftGating.value] } : {}),
413
- // Only send followUps when at least one step disables it (default is on, so only the
414
- // explicit `false` opt-outs are worth persisting).
415
- ...(draftFollowUps.value.some((f) => f === false)
416
- ? { followUps: [...draftFollowUps.value] }
417
- : {}),
418
- // Only send testerQuality when at least one Tester step deviates from the default
419
- // (companion disabled, or an estimate gate configured) — the default (null/enabled,
420
- // ungated) is not worth persisting.
421
- ...(draftTesterQuality.value.some((q) => q?.enabled === false || q?.gating?.enabled)
422
- ? { testerQuality: [...draftTesterQuality.value] }
423
- : {}),
424
- // ALWAYS send stepOptions, unlike the legacy per-step arrays above. Those omit-when-default,
425
- // which means an update can never CLEAR them (an omitted field reads as "keep existing"), so
426
- // toggling the last opt-out back to its default on a saved pipeline would silently not
427
- // persist. Sending the aligned array always lets `update` overwrite; the backend normalizes
428
- // an all-default array away (stores nothing), so this is a no-op on create / all-default.
429
- stepOptions: draftStepOptions.value.map((o) => o ?? null),
430
- // Only send labels when there are any.
431
- ...(draftLabels.value.length ? { labels: [...draftLabels.value] } : {}),
432
- // Only send purpose when the pipeline is classified (null ⇒ leave unclassified). Like the
433
- // legacy per-step arrays, an omitted `purpose` on update reads as "keep existing"; clearing
434
- // a classification back to unclassified is not a supported edit (every built-in ships one).
435
- ...(draftPurpose.value ? { purpose: draftPurpose.value } : {}),
436
- }
437
- }
438
-
439
- /** Persist the draft: update the pipeline being edited, else create a new one. */
440
- async function saveDraft(): Promise<Pipeline | null> {
441
- if (draft.value.length === 0) return null
442
- const wsId = useWorkspaceStore().requireId()
443
- const payload = draftPayload()
444
- if (editingId.value) {
445
- const updated = await api.updatePipeline(wsId, editingId.value, payload)
446
- upsertPipeline(updated)
447
- clearDraft()
448
- return updated
449
- }
450
- const pipeline = await api.createPipeline(wsId, payload)
451
- upsertPipeline(pipeline)
452
- clearDraft()
453
- return pipeline
454
- }
455
-
456
- /** Clone any pipeline (built-in or custom) into an editable copy, ready to edit. */
457
- async function clonePipeline(id: string): Promise<Pipeline> {
458
- const clone = await api.clonePipeline(useWorkspaceStore().requireId(), id)
459
- upsertPipeline(clone)
460
- loadForEdit(clone)
461
- return clone
462
- }
463
-
464
- async function removePipeline(id: string) {
465
- await api.removePipeline(useWorkspaceStore().requireId(), id)
466
- dropPipeline(id)
467
- if (editingId.value === id) clearDraft()
468
- }
469
-
470
- /**
471
- * Reseed a built-in pipeline from the backend's current catalog definition: restores its
472
- * canonical structure + version (adopting an update, or repairing a drifted/invalid copy)
473
- * while preserving its labels/archive state. Replaces the pipeline in the cache.
474
- */
475
- async function reseed(id: string): Promise<Pipeline> {
476
- const updated = await api.reseedPipeline(useWorkspaceStore().requireId(), id)
477
- upsertPipeline(updated)
478
- if (editingId.value === id) clearDraft()
479
- return updated
480
- }
481
-
482
- /** Set a pipeline's organizational metadata (labels / archive). Works on built-ins too. */
483
- async function organize(id: string, body: { labels?: string[]; archived?: boolean }) {
484
- const updated = await api.organizePipeline(useWorkspaceStore().requireId(), id, body)
485
- upsertPipeline(updated)
486
- return updated
73
+ // The draft manipulation + persistence operations, split into cohesive factories sharing the
74
+ // state above (a size-only extraction behaviour is identical to the former in-closure
75
+ // functions). Persistence drives the draft-lifecycle helpers (`clearDraft`/`loadForEdit`).
76
+ const context: PipelinesContext = {
77
+ api,
78
+ pipelines,
79
+ upsertPipeline,
80
+ dropPipeline,
81
+ draft,
82
+ draftGates,
83
+ draftEnabled,
84
+ draftThresholds,
85
+ draftConsensus,
86
+ draftGating,
87
+ draftFollowUps,
88
+ draftTesterQuality,
89
+ draftStepOptions,
90
+ draftLabels,
91
+ draftPurpose,
92
+ draftName,
93
+ draftDescription,
94
+ editingId,
487
95
  }
488
-
489
- const archive = (id: string) => organize(id, { archived: true })
490
- const unarchive = (id: string) => organize(id, { archived: false })
491
- const setLabels = (id: string, labels: string[]) => organize(id, { labels })
96
+ const draftActions = createPipelineDraftActions(context)
97
+ const persistence = createPipelinePersistence(context, draftActions)
492
98
 
493
99
  return {
494
100
  pipelines,
@@ -507,36 +113,9 @@ export const usePipelinesStore = defineStore('pipelines', () => {
507
113
  draftName,
508
114
  draftDescription,
509
115
  editingId,
510
- units,
511
116
  hydrate,
512
117
  getPipeline,
513
- addToDraft,
514
- removeFromDraft,
515
- moveInDraft,
516
- moveUnit,
517
- hasCompanion,
518
- toggleCompanion,
519
- toggleDraftGating,
520
- toggleDraftGate,
521
- toggleDraftFollowUps,
522
- toggleDraftTesterQuality,
523
- toggleDraftTesterQualityGating,
524
- draftAutoRecommendEnabled,
525
- toggleDraftAutoRecommend,
526
- draftSkillId,
527
- setDraftSkillId,
528
- toggleDraftEnabled,
529
- toggleDraftConsensus,
530
- setDraftConsensus,
531
- clearDraft,
532
- loadForEdit,
533
- saveDraft,
534
- clonePipeline,
535
- removePipeline,
536
- reseed,
537
- organize,
538
- archive,
539
- unarchive,
540
- setLabels,
118
+ ...draftActions,
119
+ ...persistence,
541
120
  }
542
121
  })
@@ -0,0 +1,30 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { effortBand, effortHint } from './effort'
3
+
4
+ describe('effortBand', () => {
5
+ it('bands the 1..10 self-rating at the shared thresholds', () => {
6
+ expect(effortBand(1)).toBe('easy')
7
+ expect(effortBand(4)).toBe('easy')
8
+ expect(effortBand(5)).toBe('moderate')
9
+ expect(effortBand(7)).toBe('moderate')
10
+ expect(effortBand(8)).toBe('hard')
11
+ expect(effortBand(10)).toBe('hard')
12
+ })
13
+ })
14
+
15
+ describe('effortHint', () => {
16
+ it('prefers what held the agent back over the work summary', () => {
17
+ expect(
18
+ effortHint({
19
+ difficulty: 7,
20
+ summary: 'Refactored the parser.',
21
+ reducedEffectiveness: 'Flaky test tooling.',
22
+ }),
23
+ ).toBe('Flaky test tooling.')
24
+ })
25
+
26
+ it('falls back to the summary, then to nothing', () => {
27
+ expect(effortHint({ difficulty: 3, summary: 'Straightforward.' })).toBe('Straightforward.')
28
+ expect(effortHint({ difficulty: 3 })).toBeNull()
29
+ })
30
+ })
@@ -0,0 +1,29 @@
1
+ import type { AgentEffortReport } from '~/types/execution'
2
+
3
+ /**
4
+ * Presentation helpers for a container agent's effort self-assessment
5
+ * (`PipelineStep.effortReport` — how hard the work was, what reduced its effectiveness,
6
+ * the obstacles it hit). Shared by the two surfaces that render it: the full card in the
7
+ * generic step-detail panel (`StepEffortReport.vue`) and the collapsible footer every
8
+ * dedicated result window gets from `ResultWindowShell.vue`. The band thresholds live
9
+ * here so the two can't drift into disagreeing about what counts as a hard run.
10
+ */
11
+
12
+ /** The difficulty band a 1..10 self-rating falls into. */
13
+ export type EffortBand = 'easy' | 'moderate' | 'hard'
14
+
15
+ /** Band a report's difficulty: 1-4 easy, 5-7 moderate, 8-10 hard. */
16
+ export function effortBand(difficulty: number): EffortBand {
17
+ if (difficulty >= 8) return 'hard'
18
+ if (difficulty >= 5) return 'moderate'
19
+ return 'easy'
20
+ }
21
+
22
+ /**
23
+ * The one-line gist for a collapsed footer row: what held the agent back, else its
24
+ * summary of the work. Null when the agent rated the run but wrote no prose, so the
25
+ * row falls back to the difficulty chip alone.
26
+ */
27
+ export function effortHint(report: AgentEffortReport): string | null {
28
+ return report.reducedEffectiveness ?? report.summary ?? null
29
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.151.1",
3
+ "version": "0.152.1",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",