@cat-factory/app 0.266.0 → 0.267.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.
@@ -14,8 +14,11 @@ import {
14
14
  orderFindings,
15
15
  reconcileFindingOrder,
16
16
  type FindingAttention,
17
+ type FindingClass,
17
18
  type OrderedFinding,
18
19
  } from './RequirementsReviewWindow.logic'
20
+ import { recommendationConfidenceBand } from '@cat-factory/contracts'
21
+ import type { RecommendationConfidenceBand } from '@cat-factory/contracts'
19
22
  import type {
20
23
  RecommendationSource,
21
24
  RequirementRecommendation,
@@ -191,6 +194,49 @@ const STATUS_LABELS = computed<Record<ReviewItemStatus, string>>(() => ({
191
194
  recommend_requested: t('requirements.itemStatus.recommend_requested'),
192
195
  }))
193
196
 
197
+ // The two GROUPS the reviewer sorts its findings into, which is the window's top-level structure:
198
+ // what only this person can decide, and what practice can answer. Each section says what its group
199
+ // IS rather than only naming it, because the distinction is what tells the reader which half of the
200
+ // list is theirs — and, on an unwatched run, which half the platform may answer without them.
201
+ const CLASS_LABELS = computed<Record<FindingClass, string>>(() => ({
202
+ judgement: t('requirements.findingClass.judgement'),
203
+ practice: t('requirements.findingClass.practice'),
204
+ }))
205
+ const CLASS_HINTS = computed<Record<FindingClass, string>>(() => ({
206
+ judgement: t('requirements.findingClass.judgementHint'),
207
+ practice: t('requirements.findingClass.practiceHint'),
208
+ }))
209
+ const CLASS_LABEL_COLOR = {
210
+ judgement: 'text-amber-300',
211
+ practice: 'text-sky-300',
212
+ } as const satisfies Record<FindingClass, string>
213
+
214
+ // How sure the Writer says it is. Shown on every suggestion, because the confidence is what an
215
+ // unattended run compares against its policy floor: a reader deciding whether to keep a
216
+ // pre-filled answer is looking at the same number the platform used to decide not to ask them.
217
+ // A suggestion the Writer did not grade renders NO badge rather than a "low" one — unreported and
218
+ // unsure are different facts (see `recommendationConfidenceBand`).
219
+ const CONFIDENCE_COLOR = {
220
+ high: 'success',
221
+ medium: 'warning',
222
+ low: 'error',
223
+ } as const satisfies Record<RecommendationConfidenceBand, string>
224
+ const CONFIDENCE_LABELS = computed<Record<RecommendationConfidenceBand, string>>(() => ({
225
+ high: t('requirements.confidence.high'),
226
+ medium: t('requirements.confidence.medium'),
227
+ low: t('requirements.confidence.low'),
228
+ }))
229
+ /** The band a recommendation's grade falls in, or null when it reported none. */
230
+ function confidenceBandOf(
231
+ rec: RequirementRecommendation | undefined,
232
+ ): RecommendationConfidenceBand | null {
233
+ return rec ? recommendationConfidenceBand(rec.confidence) : null
234
+ }
235
+ /** The grade as a percentage for the badge's tooltip, or null when ungraded. */
236
+ function confidencePercent(rec: RequirementRecommendation | undefined): string | null {
237
+ return rec?.confidence == null ? null : `${Math.round(rec.confidence * 100)}%`
238
+ }
239
+
194
240
  // Answers auto-save: there is no explicit "save" button. The textarea is pre-seeded with
195
241
  // the recorded reply (see the watch below); editing and blurring persists it. Persist only
196
242
  // when the trimmed draft actually differs from what's already recorded, so blurring an
@@ -365,24 +411,37 @@ watch(
365
411
  },
366
412
  { immediate: true },
367
413
  )
368
- const orderedFindings = computed<{ item: RequirementReviewItem; attention: FindingAttention }[]>(
369
- () => {
370
- const byId = new Map((review.value?.items ?? []).map((item) => [item.id, item]))
371
- return reconcileFindingOrder(desiredOrder.value, pinnedOrder.value).flatMap((entry) => {
372
- const item = byId.get(entry.id)
373
- return item ? [{ item, attention: entry.attention }] : []
374
- })
375
- },
376
- )
377
- // Label the buckets only once the list actually spans more than one on a fresh review every
378
- // finding is outstanding, and a lone "Needs your reaction" heading over all of them is noise.
379
- const attentionGroupsShown = computed(
380
- () => new Set(orderedFindings.value.map((entry) => entry.attention)).size > 1,
381
- )
414
+ const orderedFindings = computed<
415
+ { item: RequirementReviewItem; attention: FindingAttention; group: FindingClass }[]
416
+ >(() => {
417
+ const byId = new Map((review.value?.items ?? []).map((item) => [item.id, item]))
418
+ return reconcileFindingOrder(desiredOrder.value, pinnedOrder.value).flatMap((entry) => {
419
+ const item = byId.get(entry.id)
420
+ return item ? [{ item, attention: entry.attention, group: entry.group }] : []
421
+ })
422
+ })
423
+ // The GROUP heading is shown whenever the list spans both groups, which is the point of the split:
424
+ // a reader has to be able to see where "yours to decide" ends. A review entirely in one group needs
425
+ // no heading, because then the whole list is that group.
426
+ function startsFindingGroup(index: number): boolean {
427
+ const entries = orderedFindings.value
428
+ if (new Set(entries.map((entry) => entry.group)).size < 2) return false
429
+ return index === 0 || entries[index - 1]?.group !== entries[index]?.group
430
+ }
431
+ // The attention sub-heading is shown only inside a group that spans more than one bucket, so the
432
+ // two levels of heading cannot both appear on a review where they would say the same thing (a fresh
433
+ // review is all outstanding; a pre-answered practice group is all settled).
382
434
  function startsAttentionGroup(index: number): boolean {
383
- if (!attentionGroupsShown.value) return false
384
435
  const entries = orderedFindings.value
385
- return index === 0 || entries[index - 1]?.attention !== entries[index]?.attention
436
+ const here = entries[index]
437
+ if (!here) return false
438
+ if (entries.filter((entry) => entry.group === here.group).length < 2) return false
439
+ const spansBuckets =
440
+ new Set(entries.filter((entry) => entry.group === here.group).map((entry) => entry.attention))
441
+ .size > 1
442
+ if (!spansBuckets) return false
443
+ const previous = entries[index - 1]
444
+ return !previous || previous.group !== here.group || previous.attention !== here.attention
386
445
  }
387
446
  const ATTENTION_LABELS = computed<Record<FindingAttention, string>>(() => ({
388
447
  action: t('requirements.group.action'),
@@ -740,7 +799,20 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
740
799
  @focusin="onFindingFocusIn"
741
800
  @focusout="onFindingFocusOut"
742
801
  >
743
- <template v-for="({ item, attention }, index) in orderedFindings" :key="item.id">
802
+ <template v-for="({ item, attention, group }, index) in orderedFindings" :key="item.id">
803
+ <div v-if="startsFindingGroup(index)" class="pt-2" data-testid="requirements-group">
804
+ <div class="flex items-center gap-2">
805
+ <span
806
+ class="text-xs font-semibold uppercase tracking-wide"
807
+ :class="CLASS_LABEL_COLOR[group]"
808
+ :data-finding-group="group"
809
+ >
810
+ {{ CLASS_LABELS[group] }}
811
+ </span>
812
+ <span class="h-px flex-1 bg-slate-700" />
813
+ </div>
814
+ <p class="mt-0.5 text-[11px] text-slate-500">{{ CLASS_HINTS[group] }}</p>
815
+ </div>
744
816
  <div v-if="startsAttentionGroup(index)" class="flex items-center gap-2 pt-1">
745
817
  <span
746
818
  class="text-[11px] font-semibold uppercase tracking-wide"
@@ -869,6 +941,20 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
869
941
  >
870
942
  {{ GROUNDING_LABELS[autoDefaults.get(item.id)!.groundedIn!] }}
871
943
  </UBadge>
944
+ <!-- The Writer's own grade, which is a different question from where the
945
+ answer came from: it is what an unwatched run compares against its
946
+ policy floor, so it is also what tells a reader how hard this
947
+ pre-filled answer was to be sure of. -->
948
+ <UBadge
949
+ v-if="confidenceBandOf(autoDefaults.get(item.id))"
950
+ size="xs"
951
+ variant="outline"
952
+ :color="CONFIDENCE_COLOR[confidenceBandOf(autoDefaults.get(item.id))!]"
953
+ :title="confidencePercent(autoDefaults.get(item.id)) ?? undefined"
954
+ data-testid="requirements-confidence"
955
+ >
956
+ {{ CONFIDENCE_LABELS[confidenceBandOf(autoDefaults.get(item.id))!] }}
957
+ </UBadge>
872
958
  </div>
873
959
  <UTextarea
874
960
  v-model="drafts[item.id]"
@@ -927,6 +1013,17 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
927
1013
  >
928
1014
  {{ GROUNDING_LABELS[rec.groundedIn] }}
929
1015
  </UBadge>
1016
+ <UBadge
1017
+ v-if="confidenceBandOf(rec)"
1018
+ size="xs"
1019
+ variant="outline"
1020
+ class="ms-1.5"
1021
+ :color="CONFIDENCE_COLOR[confidenceBandOf(rec)!]"
1022
+ :title="confidencePercent(rec) ?? undefined"
1023
+ data-testid="requirements-confidence"
1024
+ >
1025
+ {{ CONFIDENCE_LABELS[confidenceBandOf(rec)!] }}
1026
+ </UBadge>
930
1027
  <!-- The Writer's suggested answer — agent prose, so it takes the
931
1028
  measure like the finding's own question above it. -->
932
1029
  <p class="mt-1 max-w-3xl whitespace-pre-line text-sm text-slate-300">
@@ -88,6 +88,10 @@ interface Draft {
88
88
  // up, rather than stopping for a person. Edited as a switch because the vocabulary is two-valued
89
89
  // and the OFF state is the historical behaviour.
90
90
  unattended: boolean
91
+ // The confidence floor an unattended run's auto-answered requirements finding must clear, as a
92
+ // PERCENT (the numbers above are edited the same way). Only read while `unattended` is on, which
93
+ // is why the field is rendered inside that block rather than beside the other budgets.
94
+ minAutoAnswerConfidence: number
91
95
  // Per-change-class auto-merge rules. An OMITTED class means "use the score ceilings above",
92
96
  // so `{}` is the identity — the editor stores `thresholds` as an omission for that reason.
93
97
  classRules: MergeClassRules
@@ -136,6 +140,7 @@ function toDraft(p: RiskPolicy): Draft {
136
140
  maxRequirementConcernAllowed: p.maxRequirementConcernAllowed,
137
141
  autoMergeEnabled: p.autoMergeEnabled,
138
142
  unattended: p.autonomy === 'unattended',
143
+ minAutoAnswerConfidence: Math.round(p.minAutoAnswerConfidence * 100),
139
144
  classRules: { ...p.classRules },
140
145
  classRulesByRole: { ...p.classRulesByRole },
141
146
  dryRunRoles: [...p.dryRunRoles],
@@ -196,6 +201,7 @@ async function save(p: RiskPolicy) {
196
201
  maxRequirementConcernAllowed: d.maxRequirementConcernAllowed,
197
202
  autoMergeEnabled: d.autoMergeEnabled,
198
203
  autonomy: d.unattended ? 'unattended' : 'attended',
204
+ minAutoAnswerConfidence: d.minAutoAnswerConfidence / 100,
199
205
  classRules: d.classRules,
200
206
  classRulesByRole: d.classRulesByRole,
201
207
  dryRunRoles: d.dryRunRoles,
@@ -277,6 +283,7 @@ const draft = reactive<Draft>({
277
283
  // A new policy parks on its own caps, matching every built-in but the unattended default: a
278
284
  // licence to answer them is a posture somebody grants, never one a blank form assumes.
279
285
  unattended: false,
286
+ minAutoAnswerConfidence: 80,
280
287
  // The create row authors the numbers only. Class and role rules start at their identity and
281
288
  // are edited on the saved preset, where each rule can be shown beside the base rule (and the
282
289
  // track record) it narrows — neither reads as anything on a policy that does not exist yet.
@@ -305,6 +312,7 @@ async function create() {
305
312
  maxRequirementConcernAllowed: draft.maxRequirementConcernAllowed,
306
313
  autoMergeEnabled: draft.autoMergeEnabled,
307
314
  autonomy: draft.unattended ? 'unattended' : 'attended',
315
+ minAutoAnswerConfidence: draft.minAutoAnswerConfidence / 100,
308
316
  classRules: draft.classRules,
309
317
  forkDecision: forkGating(draft),
310
318
  })
@@ -520,6 +528,25 @@ async function create() {
520
528
  : t('settings.riskPolicy.autonomy.attendedHint')
521
529
  "
522
530
  />
531
+ <!-- Shown only while the posture is on, because that is the only state that reads it: a
532
+ floor on an attended policy would be a control over a decision this policy never makes.
533
+ It is not hidden as an "advanced override" — it is inert, which is a different thing. -->
534
+ <label v-if="drafts[p.id]!.unattended" class="mt-3 block">
535
+ <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
536
+ {{ t('settings.riskPolicy.autoAnswer.label') }}
537
+ </span>
538
+ <UInput
539
+ v-model.number="drafts[p.id]!.minAutoAnswerConfidence"
540
+ type="number"
541
+ min="0"
542
+ max="100"
543
+ size="sm"
544
+ data-testid="risk-policy-auto-answer-floor"
545
+ />
546
+ <span class="mt-1 block text-[11px] text-slate-500">
547
+ {{ t('settings.riskPolicy.autoAnswer.hint') }}
548
+ </span>
549
+ </label>
523
550
  </div>
524
551
 
525
552
  <div class="mt-3 flex items-center justify-between gap-3">
@@ -20,6 +20,7 @@ import StepContainerStatus from '~/components/panels/StepContainerStatus.vue'
20
20
  import AttemptEntryHeader from '~/components/panels/AttemptEntryHeader.vue'
21
21
  import EnvironmentStatusPanel from '~/components/environments/EnvironmentStatusPanel.vue'
22
22
  import ProvisioningLogsDrawer from '~/components/provisioning/ProvisioningLogsDrawer.vue'
23
+ import MarkdownProse from '~/components/common/MarkdownProse.vue'
23
24
 
24
25
  const board = useBoardStore()
25
26
  const execution = useExecutionStore()
@@ -489,9 +490,11 @@ const GROUP_STATUS_META: Record<ScenarioGroup['status'], { icon: string; text: s
489
490
  :icon="a.outcome === 'completed' ? 'i-lucide-wrench' : 'i-lucide-circle-x'"
490
491
  :icon-class="a.outcome === 'completed' ? 'text-amber-300' : 'text-rose-400'"
491
492
  />
492
- <p v-if="a.summary" class="mt-1 max-w-3xl text-[12px] leading-snug text-slate-400">
493
- {{ a.summary }}
494
- </p>
493
+ <MarkdownProse
494
+ v-if="a.summary"
495
+ :text="a.summary"
496
+ class="mt-1 max-w-3xl text-[12px] leading-snug text-slate-400"
497
+ />
495
498
  <div v-if="a.concerns && a.concerns.length" class="mt-1.5">
496
499
  <p class="text-[11px] text-slate-500">
497
500
  {{ t('testing.fixerTimeline.addressed') }}
@@ -575,9 +578,11 @@ const GROUP_STATUS_META: Record<ScenarioGroup['status'], { icon: string; text: s
575
578
  d(new Date(vd.at), 'short')
576
579
  }}</span>
577
580
  </div>
578
- <p v-if="vd.feedback" class="mt-1 text-[12px] leading-snug text-slate-400">
579
- {{ vd.feedback }}
580
- </p>
581
+ <MarkdownProse
582
+ v-if="vd.feedback"
583
+ :text="vd.feedback"
584
+ class="mt-1 text-[12px] leading-snug text-slate-400"
585
+ />
581
586
  <div v-if="vd.gaps.length" class="mt-1.5">
582
587
  <p class="text-[11px] text-slate-500">{{ t('testing.quality.gaps') }}</p>
583
588
  <ul class="mt-1 space-y-0.5">
@@ -610,12 +615,11 @@ const GROUP_STATUS_META: Record<ScenarioGroup['status'], { icon: string; text: s
610
615
  <!-- Summary — the tester's own prose, so it takes the reading measure the shell's `full`
611
616
  width obliges (see the `width` prop). The scenario rows and log tails below keep the
612
617
  full span. -->
613
- <p
618
+ <MarkdownProse
614
619
  v-if="report.summary"
620
+ :text="report.summary"
615
621
  class="mb-4 max-w-3xl text-[13px] leading-relaxed text-slate-300"
616
- >
617
- {{ report.summary }}
618
- </p>
622
+ />
619
623
 
620
624
  <h3 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
621
625
  {{ t('testing.scenariosOutcomes') }}
@@ -680,9 +684,11 @@ const GROUP_STATUS_META: Record<ScenarioGroup['status'], { icon: string; text: s
680
684
  />
681
685
  <div class="min-w-0">
682
686
  <span class="text-[13px] text-slate-200">{{ o.name }}</span>
683
- <p v-if="o.detail" class="max-w-3xl text-[12px] leading-snug text-slate-400">
684
- {{ o.detail }}
685
- </p>
687
+ <MarkdownProse
688
+ v-if="o.detail"
689
+ :text="o.detail"
690
+ class="max-w-3xl text-[12px] leading-snug text-slate-400"
691
+ />
686
692
  </div>
687
693
  </div>
688
694
  <p v-if="!g.outcomes.length" class="py-0.5 text-[12px] italic text-slate-500">
@@ -710,9 +716,11 @@ const GROUP_STATUS_META: Record<ScenarioGroup['status'], { icon: string; text: s
710
716
  {{ SEVERITY_LABELS[c.severity] }}
711
717
  </span>
712
718
  </div>
713
- <p v-if="c.detail" class="max-w-3xl text-[12px] leading-snug text-slate-400">
714
- {{ c.detail }}
715
- </p>
719
+ <MarkdownProse
720
+ v-if="c.detail"
721
+ :text="c.detail"
722
+ class="max-w-3xl text-[12px] leading-snug text-slate-400"
723
+ />
716
724
  </div>
717
725
  </div>
718
726
 
@@ -0,0 +1,82 @@
1
+ import type { RunDefaultScope } from '@cat-factory/contracts'
2
+ import type { Pipeline } from '~/types/domain'
3
+ import { usePipelinesStore } from '~/stores/pipelines'
4
+ import { usePipelineErrorToast } from '~/composables/usePipelineErrorToast'
5
+
6
+ /**
7
+ * The actions a row of the saved-pipeline LIBRARY offers: archive, promote to a scope default,
8
+ * edit, clone, delete.
9
+ *
10
+ * Extracted from `PipelineBuilder.vue` so that component stays inside its (shrink-only) size
11
+ * budget. A cohesive seam rather than an arbitrary cut: every one of these takes a library row and
12
+ * nothing else, none of them touches the DRAFT chain the rest of the builder is about, and each
13
+ * reports its own failure — which is what makes them the same kind of thing.
14
+ */
15
+ export function usePipelineLibraryActions() {
16
+ const pipelines = usePipelinesStore()
17
+ const toast = useToast()
18
+ const { t } = useI18n()
19
+ const { present } = usePipelineErrorToast()
20
+ const { confirm } = useConfirm()
21
+
22
+ /** Archive / unarchive: organize the library without deleting. Works on built-ins too. */
23
+ async function toggleArchive(p: Pipeline) {
24
+ try {
25
+ if (p.archived) await pipelines.unarchive(p.id)
26
+ else await pipelines.archive(p.id)
27
+ } catch {
28
+ toast.add({ title: t('pipeline.builder.toast.updateFailed'), color: 'error' })
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Claim (or release) a pipeline as the workspace's default for one resolution scope.
34
+ *
35
+ * Both scopes are ADVANCED-tier controls in the builder, and the reason is the interface-mode rule
36
+ * rather than the feeling of the setting: a workspace that names neither runs exactly what it runs
37
+ * today (the interface-mode rung in the app, the seeded unattended rung headlessly), so hiding the
38
+ * control leaves the same default a basic-tier user would have had. What is NOT hidden is the
39
+ * resulting badge — a default somebody set has to be visible in the library at both tiers, or the
40
+ * hidden control becomes a hidden decision.
41
+ */
42
+ async function toggleDefault(p: Pipeline, scope: RunDefaultScope) {
43
+ const held = scope === 'unattended' ? p.isUnattendedDefault : p.isDefault
44
+ try {
45
+ await pipelines.setDefault(p.id, scope, !held)
46
+ } catch (error) {
47
+ present(error, 'pipeline.builder.toast.updateFailed')
48
+ }
49
+ }
50
+
51
+ /** Load a custom pipeline into the draft for in-place editing. */
52
+ function edit(p: Pipeline) {
53
+ pipelines.loadForEdit(p)
54
+ }
55
+
56
+ async function removePipeline(p: Pipeline) {
57
+ const ok = await confirm({
58
+ title: t('pipeline.builder.confirmDeletePipeline.title'),
59
+ description: t('pipeline.builder.confirmDeletePipeline.body', { name: p.name }),
60
+ variant: 'destructive',
61
+ confirmLabel: t('common.delete'),
62
+ icon: 'i-lucide-trash-2',
63
+ })
64
+ if (ok) void pipelines.removePipeline(p.id)
65
+ }
66
+
67
+ /** Clone any pipeline (incl. a read-only built-in) into an editable copy. */
68
+ async function clone(p: Pipeline) {
69
+ try {
70
+ const copy = await pipelines.clonePipeline(p.id)
71
+ toast.add({
72
+ title: t('pipeline.builder.toast.cloned', { name: p.name, copy: copy.name }),
73
+ color: 'success',
74
+ icon: 'i-lucide-copy',
75
+ })
76
+ } catch {
77
+ toast.add({ title: t('pipeline.builder.toast.cloneFailed'), color: 'error' })
78
+ }
79
+ }
80
+
81
+ return { toggleArchive, toggleDefault, edit, removePipeline, clone }
82
+ }
@@ -112,16 +112,45 @@ export function createPipelinePersistence(
112
112
  return updated
113
113
  }
114
114
 
115
- /** Set a pipeline's organizational metadata (labels / archive). Works on built-ins too. */
116
- async function organize(id: string, body: { labels?: string[]; archived?: boolean }) {
115
+ /**
116
+ * Set a pipeline's organizational metadata (labels / archive / the two default claims). Works on
117
+ * built-ins too, which is the whole reason the default claims live on this call: the rungs a
118
+ * workspace most wants as its defaults are built-in, and a built-in refuses a structural edit.
119
+ *
120
+ * Promoting one row DEMOTES another, and the response names only the winner. So the incumbent is
121
+ * released LOCALLY before the winner is upserted: a targeted edit of the two rows that changed,
122
+ * rather than a full re-read (which this store has no door for — it hydrates from the workspace
123
+ * snapshot) and rather than upserting the winner alone, which would leave two rows claiming the
124
+ * same default on screen until the next snapshot.
125
+ */
126
+ async function organize(
127
+ id: string,
128
+ body: {
129
+ labels?: string[]
130
+ archived?: boolean
131
+ isDefault?: boolean
132
+ isUnattendedDefault?: boolean
133
+ },
134
+ ) {
117
135
  const updated = await api.organizePipeline(useWorkspaceStore().requireId(), id, body)
136
+ if (body.isDefault !== undefined) releaseOtherClaims(id, 'isDefault')
137
+ if (body.isUnattendedDefault !== undefined) releaseOtherClaims(id, 'isUnattendedDefault')
118
138
  upsertPipeline(updated)
119
139
  return updated
120
140
  }
121
141
 
142
+ /** Drop `field` from every row but `id`, mirroring what the store just did server-side. */
143
+ function releaseOtherClaims(id: string, field: 'isDefault' | 'isUnattendedDefault') {
144
+ ctx.pipelines.value = ctx.pipelines.value.map((pipeline) =>
145
+ pipeline.id === id || pipeline[field] !== true ? pipeline : { ...pipeline, [field]: false },
146
+ )
147
+ }
148
+
122
149
  const archive = (id: string) => organize(id, { archived: true })
123
150
  const unarchive = (id: string) => organize(id, { archived: false })
124
151
  const setLabels = (id: string, labels: string[]) => organize(id, { labels })
152
+ const setDefault = (id: string, scope: 'interactive' | 'unattended', claimed: boolean) =>
153
+ organize(id, scope === 'unattended' ? { isUnattendedDefault: claimed } : { isDefault: claimed })
125
154
 
126
155
  return {
127
156
  saveDraft,
@@ -132,5 +161,6 @@ export function createPipelinePersistence(
132
161
  archive,
133
162
  unarchive,
134
163
  setLabels,
164
+ setDefault,
135
165
  }
136
166
  }
@@ -1,7 +1,13 @@
1
1
  import { defineStore } from 'pinia'
2
2
  import { ref } from 'vue'
3
3
  import type { Pipeline } from '~/types/domain'
4
- import type { GateConfigForm, PipelinePurpose, RetiredPipelineWire } from '@cat-factory/contracts'
4
+ import type {
5
+ GateConfigForm,
6
+ PipelinePurpose,
7
+ RetiredPipelineWire,
8
+ RunDefaultScope,
9
+ } from '@cat-factory/contracts'
10
+ import { declaredDefaultPipelineId } from '@cat-factory/contracts'
5
11
  import { useUpsertList } from '~/composables/useUpsertList'
6
12
  import { createDraftStepState, type PipelinesContext } from '~/stores/pipelines/context'
7
13
  import { createPipelineDraftActions } from '~/stores/pipelines/draftActions'
@@ -124,6 +130,23 @@ export const usePipelinesStore = defineStore('pipelines', () => {
124
130
  return pipelines.value.find((p) => p.id === id)
125
131
  }
126
132
 
133
+ /**
134
+ * The pipeline id this workspace has DECLARED as its default for a resolution scope, or undefined
135
+ * when no row claims it.
136
+ *
137
+ * The rule itself is `declaredDefaultPipelineId` in `@cat-factory/contracts`, shared with the
138
+ * engine: the SPA pre-selects on its start controls what the backend falls back to when a headless
139
+ * caller names none, and two readings of "the default" is how a Start button comes to run
140
+ * something other than what the board said it would.
141
+ *
142
+ * Undefined is a real answer, not a lookup failure, and each caller composes its own fallback with
143
+ * it: the start controls `defaultBuildPipelineId` (the interface-mode rung), the backend catalog
144
+ * order.
145
+ */
146
+ function declaredDefaultId(scope: RunDefaultScope): string | undefined {
147
+ return declaredDefaultPipelineId(pipelines.value, scope)
148
+ }
149
+
127
150
  // The draft manipulation + persistence operations, split into cohesive factories sharing the
128
151
  // state above (a size-only extraction — behaviour is identical to the former in-closure
129
152
  // functions). Persistence drives the draft-lifecycle helpers (`clearDraft`/`loadForEdit`).
@@ -173,6 +196,7 @@ export const usePipelinesStore = defineStore('pipelines', () => {
173
196
  hydrate,
174
197
  hydrateGateConfigForms,
175
198
  getPipeline,
199
+ declaredDefaultId,
176
200
  ...draftActions,
177
201
  ...persistence,
178
202
  }
@@ -611,6 +611,10 @@
611
611
  "label": "Unbeaufsichtigte Läufe ohne Wartezeit auf eine Person abschließen",
612
612
  "unattendedHint": "Wenn eine automatische Schleife aufgibt (ein Companion an seinem Überarbeitungslimit, eine Prüfung an ihrem Durchlauflimit, unbearbeitete Folgepunkte), läuft der Durchlauf nachvollziehbar weiter statt anzuhalten. Von der Pipeline angeforderte Gates wie manuelles Testen, Review und Freigabe halten den Durchlauf weiterhin an.",
613
613
  "attendedHint": "Wenn eine automatische Schleife aufgibt, hält der Durchlauf an und wartet auf eine Entscheidung. Richtig für ein Board, das jemand beobachtet; ein über die API gestarteter Durchlauf wartet unbegrenzt."
614
+ },
615
+ "autoAnswer": {
616
+ "label": "Mindestzuversicht für Auto-Antworten (%)",
617
+ "hint": "Wie sicher der Requirement Writer sein muss, damit ein unbeobachteter Lauf seinen Vorschlag übernimmt statt auf eine Person zu warten. Infrage kommen nur Befunde, die der Reviewer ohne Product Owner für beantwortbar hielt."
614
618
  }
615
619
  },
616
620
  "observabilityConnection": {
@@ -4509,7 +4513,17 @@
4509
4513
  "binaryComparison": "Kandidaten vor der Lieferung vergleichen",
4510
4514
  "binaryPerGenerator": "Kandidaten pro Integration",
4511
4515
  "binaryMultiSelect": "Mehrere behalten erlauben",
4512
- "binaryComparisonUnreachable": "Dieser Schritt kann nur einen Kandidaten pro Motiv erzeugen, es gäbe also nichts zu vergleichen und der einzige würde ungefragt behalten. Wähle eine zweite Integration oder erhöhe die Anzahl der Kandidaten pro Integration."
4516
+ "binaryComparisonUnreachable": "Dieser Schritt kann nur einen Kandidaten pro Motiv erzeugen, es gäbe also nichts zu vergleichen und der einzige würde ungefragt behalten. Wähle eine zweite Integration oder erhöhe die Anzahl der Kandidaten pro Integration.",
4517
+ "scopeDefault": {
4518
+ "interactive": "Standard in der App",
4519
+ "interactiveHint": "Was eine vom Board gestartete Aufgabe ausführt, wenn sie keine eigene Pipeline festlegt.",
4520
+ "unattended": "Standard ohne Aufsicht",
4521
+ "unattendedHint": "Was ein Lauf ohne Beobachter ausführt (API, Ticket, Zeitplan), wenn die Aufgabe keine Pipeline festlegt.",
4522
+ "claimInteractive": "Als Standard in der App festlegen",
4523
+ "releaseInteractive": "Nicht mehr Standard in der App",
4524
+ "claimUnattended": "Als Standard ohne Aufsicht festlegen",
4525
+ "releaseUnattended": "Nicht mehr Standard ohne Aufsicht"
4526
+ }
4513
4527
  },
4514
4528
  "progress": {
4515
4529
  "status": {
@@ -4794,6 +4808,17 @@
4794
4808
  "reReview": "Die Anforderungen konnten nicht erneut geprüft werden",
4795
4809
  "proceed": "Es konnte nicht fortgefahren werden",
4796
4810
  "resolveReview": "Die Prüfung konnte nicht abgeschlossen werden"
4811
+ },
4812
+ "findingClass": {
4813
+ "judgement": "Ihre Entscheidung nötig",
4814
+ "judgementHint": "Eine Geschäfts-, Produkt- oder Domänenentscheidung oder etwas, das dem Reviewer nicht mitgeteilt wurde. Nur Sie können das klären.",
4815
+ "practice": "Aus der Praxis beantwortbar",
4816
+ "practiceHint": "Durch etablierte Praxis, den bereits genutzten Stack oder den vorliegenden Kontext geklärt. Vorbefüllt zum Übernehmen oder Ändern."
4817
+ },
4818
+ "confidence": {
4819
+ "high": "Hohe Zuversicht",
4820
+ "medium": "Mittlere Zuversicht",
4821
+ "low": "Geringe Zuversicht"
4797
4822
  }
4798
4823
  },
4799
4824
  "bootstrap": {
@@ -3347,6 +3347,10 @@
3347
3347
  "label": "Finish unattended runs without waiting for a person",
3348
3348
  "unattendedHint": "When an automatic loop gives up (a companion at its rework cap, a review at its pass cap, untriaged follow-ups), the run proceeds on the record instead of parking. Gates the pipeline asks for, such as human testing, review and approval, still stop the run.",
3349
3349
  "attendedHint": "When an automatic loop gives up, the run parks and waits for someone to choose. Right for a board somebody is watching; a run started over the API waits indefinitely."
3350
+ },
3351
+ "autoAnswer": {
3352
+ "label": "Auto-answer confidence floor (%)",
3353
+ "hint": "How sure the Requirement Writer must be for an unwatched run to keep its suggested answer instead of stopping for a person. Only findings the reviewer judged answerable without a product owner are eligible."
3350
3354
  }
3351
3355
  },
3352
3356
  "observabilityConnection": {
@@ -5117,7 +5121,17 @@
5117
5121
  "binaryComparison": "Compare candidates before delivering",
5118
5122
  "binaryPerGenerator": "Candidates per integration",
5119
5123
  "binaryMultiSelect": "Allow keeping more than one",
5120
- "binaryComparisonUnreachable": "This step can only produce one candidate per subject, so nothing would be compared and the only one would be kept without asking. Select a second integration, or raise the candidates-per-integration count."
5124
+ "binaryComparisonUnreachable": "This step can only produce one candidate per subject, so nothing would be compared and the only one would be kept without asking. Select a second integration, or raise the candidates-per-integration count.",
5125
+ "scopeDefault": {
5126
+ "interactive": "In-app default",
5127
+ "interactiveHint": "What a task started from the board runs when it pins no pipeline of its own.",
5128
+ "unattended": "Unattended default",
5129
+ "unattendedHint": "What a run nobody is watching runs (the API, a ticket, a schedule) when the task pins no pipeline.",
5130
+ "claimInteractive": "Make the in-app default",
5131
+ "releaseInteractive": "Stop being the in-app default",
5132
+ "claimUnattended": "Make the unattended default",
5133
+ "releaseUnattended": "Stop being the unattended default"
5134
+ }
5121
5135
  },
5122
5136
  "progress": {
5123
5137
  "status": {
@@ -5654,6 +5668,17 @@
5654
5668
  "reReview": "Could not re-review the requirements",
5655
5669
  "proceed": "Could not proceed",
5656
5670
  "resolveReview": "Could not resolve the review"
5671
+ },
5672
+ "findingClass": {
5673
+ "judgement": "Needs your decision",
5674
+ "judgementHint": "A business, product or domain call, or something the reviewer was not told. Only you can settle these.",
5675
+ "practice": "Answerable from practice",
5676
+ "practiceHint": "Settled by established practice, the stack already in use, or the context provided. Pre-filled for you to keep or change."
5677
+ },
5678
+ "confidence": {
5679
+ "high": "High confidence",
5680
+ "medium": "Medium confidence",
5681
+ "low": "Low confidence"
5657
5682
  }
5658
5683
  },
5659
5684
  "clarity": {