@cat-factory/app 0.157.0 → 0.158.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.
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import type { Notification } from '~/types/domain'
3
+ import type { ReviewEffort } from '~/types/merge'
3
4
 
4
5
  // The board's notification inbox: a bell with an open-count badge that opens a
5
6
  // panel of human-actionable items (a PR awaiting a merge decision, a completed
@@ -13,6 +14,7 @@ const notifications = useNotificationsStore()
13
14
  const ui = useUiStore()
14
15
  const access = useWorkspaceAccess()
15
16
  const execution = useExecutionStore()
17
+ const trackRecords = useMergeTrackRecordsStore()
16
18
  const toast = useToast()
17
19
 
18
20
  const busy = ref<string | null>(null)
@@ -34,6 +36,10 @@ type Accent = 'warning' | 'primary' | 'error'
34
36
  const META: Record<Notification['type'], { icon: string; color: Accent }> = {
35
37
  merge_review: { icon: 'i-lucide-git-pull-request-arrow', color: 'warning' },
36
38
  pipeline_complete: { icon: 'i-lucide-circle-check', color: 'primary' },
39
+ // A PR merged directly on the provider left its merge track record untagged. Purely a nudge:
40
+ // "act" records the picked reviewer effort (nothing at all if none was picked) and dismissing
41
+ // it is always fine — the record stays valid untagged.
42
+ merge_tag_request: { icon: 'i-lucide-tag', color: 'primary' },
37
43
  ci_failed: { icon: 'i-lucide-triangle-alert', color: 'error' },
38
44
  test_failed: { icon: 'i-lucide-flask-conical', color: 'error' },
39
45
  // Clicking the title opens the review window for the task (see `reveal`); "act" just marks
@@ -89,6 +95,7 @@ const META: Record<Notification['type'], { icon: string; color: Accent }> = {
89
95
  const ACTION_KEYS: Record<Notification['type'], string> = {
90
96
  merge_review: 'layout.notifications.action.merge_review',
91
97
  pipeline_complete: 'layout.notifications.action.pipeline_complete',
98
+ merge_tag_request: 'layout.notifications.action.merge_tag_request',
92
99
  ci_failed: 'layout.notifications.action.ci_failed',
93
100
  test_failed: 'layout.notifications.action.test_failed',
94
101
  requirement_review: 'layout.notifications.action.requirement_review',
@@ -127,10 +134,57 @@ function accent(n: Notification): Accent {
127
134
  return isUrgent(n) ? 'error' : META[n.type].color
128
135
  }
129
136
 
137
+ /**
138
+ * Which cards collect a reviewer-effort tag: the two that MERGE a PR, plus the post-hoc nudge for
139
+ * one that was merged on the provider. Everything else has nothing to tag.
140
+ */
141
+ function collectsEffort(n: Notification): boolean {
142
+ return (
143
+ n.type === 'merge_review' || n.type === 'pipeline_complete' || n.type === 'merge_tag_request'
144
+ )
145
+ }
146
+
147
+ /**
148
+ * The human's picked effort per card, preselected from evidence rather than starting blank: if the
149
+ * run's `pr-reviewer` step actually recorded findings, review comments plausibly drove rework
150
+ * (`minor`); if it did not, the PR needed nothing (`none`). One tap either confirms that guess or
151
+ * corrects it. `undefined` means the card collects no tag at all.
152
+ */
153
+ const effortByCard = reactive<Record<string, ReviewEffort | null>>({})
154
+
155
+ /** Whether the run's PR review surfaced any findings — the default-preselection signal. */
156
+ function hadReviewFindings(n: Notification): boolean {
157
+ const instance = n.executionId ? execution.getInstance(n.executionId) : undefined
158
+ return (instance?.steps ?? []).some((s) => (s.prReview?.findings?.length ?? 0) > 0)
159
+ }
160
+
161
+ function effortFor(n: Notification): ReviewEffort | null {
162
+ if (!(n.id in effortByCard)) effortByCard[n.id] = hadReviewFindings(n) ? 'minor' : 'none'
163
+ return effortByCard[n.id] ?? null
164
+ }
165
+
166
+ /** The change class the engine recorded for the run, when classification resolved one. */
167
+ function changeClassOf(n: Notification) {
168
+ return n.payload?.changeClass
169
+ }
170
+
171
+ // Load the per-class rollups ONCE, the first time an effort-collecting card is in the inbox, so a
172
+ // merge card can show what that class has historically needed. Not part of the workspace snapshot
173
+ // (it's settings-screen/card context, not board state) and a single request for every class.
174
+ watch(
175
+ () => notifications.open.some(collectsEffort),
176
+ (needed) => {
177
+ if (needed && !trackRecords.loaded && !trackRecords.loading) void trackRecords.load()
178
+ },
179
+ { immediate: true },
180
+ )
181
+
130
182
  async function act(n: Notification) {
131
183
  busy.value = n.id
132
184
  try {
133
- await notifications.act(n.id)
185
+ // Only the merge cards carry a tag; passing `undefined` elsewhere keeps the historical
186
+ // no-body act, so a non-merge card's action is completely unchanged.
187
+ await notifications.act(n.id, collectsEffort(n) ? effortFor(n) : undefined)
134
188
  toast.add({
135
189
  title: t('layout.notifications.toast.acted'),
136
190
  color: 'success',
@@ -327,6 +381,14 @@ function revealDecision(n: Notification) {
327
381
  <UIcon name="i-lucide-external-link" class="h-3 w-3" />
328
382
  {{ t('layout.notifications.openPr') }}
329
383
  </a>
384
+ <MergeEffortChips
385
+ v-if="collectsEffort(n)"
386
+ :model-value="effortFor(n)"
387
+ :change-class="changeClassOf(n)"
388
+ :rollup="changeClassOf(n) ? trackRecords.byClass[changeClassOf(n)!] : null"
389
+ :disabled="busy === n.id || !access.canExecuteRuns.value"
390
+ @update:model-value="effortByCard[n.id] = $event"
391
+ />
330
392
  <div class="mt-2 flex items-center gap-1.5">
331
393
  <UButton
332
394
  data-testid="notification-act"
@@ -0,0 +1,122 @@
1
+ <script setup lang="ts">
2
+ // The reviewer-effort tag picker: ONE TAP, not a form. Shown on a merge-decision card (and the
3
+ // external-merge nudge) so the human who confirms a merge also records how much review the PR
4
+ // actually needed — the ground truth the auto-merge score thresholds are trying to approximate.
5
+ //
6
+ // Tagging is never mandatory: the parent can always act without a selection, and an untagged
7
+ // merge records a null tag. This component only chooses; the parent performs the action.
8
+ //
9
+ // See docs/initiatives/merge-track-record.md.
10
+ import { computed } from 'vue'
11
+ import { REVIEW_EFFORTS } from '@cat-factory/contracts'
12
+ import type { ChangeClass, MergeClassRollup, ReviewEffort } from '~/types/merge'
13
+
14
+ const props = defineProps<{
15
+ /** The currently picked effort, or null for "not chosen". */
16
+ modelValue: ReviewEffort | null
17
+ /** The run's change class, when classification resolved one — shown as context. */
18
+ changeClass?: ChangeClass
19
+ /** That class's accumulated track record, so the human sees what the class usually needs. */
20
+ rollup?: MergeClassRollup | null
21
+ disabled?: boolean
22
+ }>()
23
+
24
+ const emit = defineEmits<{ 'update:modelValue': [ReviewEffort | null] }>()
25
+
26
+ const { t, n } = useI18n()
27
+
28
+ // Per-effort label + icon. Exhaustive Records keyed off the union (a missing member fails the
29
+ // typecheck) with LITERAL catalog keys so the typed-message-keys check sees them; leaf keys
30
+ // mirror the enum value verbatim.
31
+ const EFFORT_LABEL_KEYS: Record<ReviewEffort, string> = {
32
+ none: 'merge.effort.none',
33
+ minor: 'merge.effort.minor',
34
+ major: 'merge.effort.major',
35
+ }
36
+ const EFFORT_ICONS: Record<ReviewEffort, string> = {
37
+ none: 'i-lucide-check',
38
+ minor: 'i-lucide-pencil-line',
39
+ major: 'i-lucide-hammer',
40
+ }
41
+
42
+ // Per-class label. Same exhaustive-Record discipline; `unknown` is included because a card can
43
+ // carry no class at all (classification unavailable) and the caller may still render context.
44
+ const CLASS_LABEL_KEYS: Record<ChangeClass, string> = {
45
+ docs: 'merge.changeClass.docs',
46
+ test: 'merge.changeClass.test',
47
+ dependency: 'merge.changeClass.dependency',
48
+ config: 'merge.changeClass.config',
49
+ source: 'merge.changeClass.source',
50
+ schema: 'merge.changeClass.schema',
51
+ unknown: 'merge.changeClass.unknown',
52
+ }
53
+
54
+ const chips = computed(() =>
55
+ REVIEW_EFFORTS.map((effort) => ({
56
+ effort,
57
+ label: t(EFFORT_LABEL_KEYS[effort]),
58
+ icon: EFFORT_ICONS[effort],
59
+ })),
60
+ )
61
+
62
+ const classLabel = computed(() =>
63
+ props.changeClass ? t(CLASS_LABEL_KEYS[props.changeClass]) : null,
64
+ )
65
+
66
+ /**
67
+ * The class's history line — how many of this class have landed and what share auto-merged. Null
68
+ * until the class has at least one landed record, so a fresh workspace shows nothing rather than
69
+ * a misleading "0%".
70
+ */
71
+ const history = computed(() => {
72
+ const r = props.rollup
73
+ if (!r || r.merged === 0) return null
74
+ // Percentages go through vue-i18n's named `percent` format, never a hand-rolled `* 100 + '%'`.
75
+ return t('merge.effort.classHistory', {
76
+ merged: r.merged,
77
+ autoShare: n(r.autoMerged / r.merged, 'percent'),
78
+ })
79
+ })
80
+
81
+ /** Toggle: tapping the picked chip clears the tag (back to untagged). */
82
+ function pick(effort: ReviewEffort) {
83
+ emit('update:modelValue', props.modelValue === effort ? null : effort)
84
+ }
85
+ </script>
86
+
87
+ <template>
88
+ <div data-testid="merge-effort-chips" class="mt-2">
89
+ <div class="flex items-center gap-1.5">
90
+ <span class="text-[10px] uppercase tracking-wide text-slate-500">
91
+ {{ t('merge.effort.prompt') }}
92
+ </span>
93
+ <UBadge
94
+ v-if="classLabel"
95
+ data-testid="merge-effort-class"
96
+ color="neutral"
97
+ variant="subtle"
98
+ size="sm"
99
+ >
100
+ {{ classLabel }}
101
+ </UBadge>
102
+ </div>
103
+ <div class="mt-1 flex flex-wrap items-center gap-1">
104
+ <UButton
105
+ v-for="chip in chips"
106
+ :key="chip.effort"
107
+ :data-testid="`merge-effort-${chip.effort}`"
108
+ :color="modelValue === chip.effort ? 'primary' : 'neutral'"
109
+ :variant="modelValue === chip.effort ? 'solid' : 'outline'"
110
+ size="xs"
111
+ :icon="chip.icon"
112
+ :disabled="disabled"
113
+ @click="pick(chip.effort)"
114
+ >
115
+ {{ chip.label }}
116
+ </UButton>
117
+ </div>
118
+ <p v-if="history" data-testid="merge-effort-history" class="mt-1 text-[10px] text-slate-500">
119
+ {{ history }}
120
+ </p>
121
+ </div>
122
+ </template>
@@ -7,7 +7,7 @@
7
7
  // banner — instead of the agent's raw JSON. Opened via the universal result-view host,
8
8
  // the same seam the requirements / tester windows use.
9
9
  import { computed } from 'vue'
10
- import type { MergeAxis, MergeDecision } from '@cat-factory/contracts'
10
+ import type { ChangeClass, MergeAxis, MergeDecision } from '@cat-factory/contracts'
11
11
  import StepRunMeta from '~/components/panels/StepRunMeta.vue'
12
12
  import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
13
13
  import MarkdownProse from '~/components/common/MarkdownProse.vue'
@@ -68,6 +68,8 @@ const REASON_KEYS: Record<MergeDecision['reason'], string> = {
68
68
  no_assessment: 'panels.mergerResult.reason.no_assessment',
69
69
  merge_failed: 'panels.mergerResult.reason.merge_failed',
70
70
  merge_partial: 'panels.mergerResult.reason.merge_partial',
71
+ class_auto_merge: 'panels.mergerResult.reason.class_auto_merge',
72
+ class_requires_review: 'panels.mergerResult.reason.class_requires_review',
71
73
  }
72
74
  const OUTCOME_KEYS: Record<MergeDecision['outcome'], string> = {
73
75
  auto_merged: 'panels.mergerResult.outcome.auto_merged',
@@ -81,6 +83,28 @@ const AXIS_KEYS: Record<MergeAxis, string> = {
81
83
 
82
84
  const outcomeText = computed(() => (decision.value ? t(OUTCOME_KEYS[decision.value.outcome]) : ''))
83
85
 
86
+ // Per-change-class label — exhaustive over the union so a new class fails typecheck here until it
87
+ // has a key. Only the classes a decision can actually carry are reachable (`unknown` never lands
88
+ // on a decision: the engine omits the field instead), but the map stays total by construction.
89
+ const CLASS_KEYS: Record<ChangeClass, string> = {
90
+ docs: 'merge.changeClass.docs',
91
+ test: 'merge.changeClass.test',
92
+ dependency: 'merge.changeClass.dependency',
93
+ config: 'merge.changeClass.config',
94
+ source: 'merge.changeClass.source',
95
+ schema: 'merge.changeClass.schema',
96
+ unknown: 'merge.changeClass.unknown',
97
+ }
98
+
99
+ /**
100
+ * The DETERMINISTIC change class the engine derived from the PR's changed files, when it resolved
101
+ * one. Shown beside the outcome so a reader sees WHAT KIND of change the decision was made about —
102
+ * which is what a `class_auto_merge` / `class_requires_review` reason is actually keyed on.
103
+ */
104
+ const changeClassLabel = computed(() =>
105
+ decision.value?.changeClass ? t(CLASS_KEYS[decision.value.changeClass]) : null,
106
+ )
107
+
84
108
  /** The three axes with their score + preset ceiling, for the bar rows. */
85
109
  const axes = computed(() => {
86
110
  const d = decision.value
@@ -150,12 +174,23 @@ const reasonText = computed(() => {
150
174
  :class="merged ? 'text-emerald-300' : 'text-amber-300'"
151
175
  />
152
176
  <div class="min-w-0">
153
- <p
154
- class="text-sm font-semibold"
155
- :class="merged ? 'text-emerald-200' : 'text-amber-200'"
156
- >
157
- {{ outcomeText }}
158
- </p>
177
+ <div class="flex flex-wrap items-center gap-2">
178
+ <p
179
+ class="text-sm font-semibold"
180
+ :class="merged ? 'text-emerald-200' : 'text-amber-200'"
181
+ >
182
+ {{ outcomeText }}
183
+ </p>
184
+ <UBadge
185
+ v-if="changeClassLabel"
186
+ data-testid="merger-change-class"
187
+ color="neutral"
188
+ variant="subtle"
189
+ size="sm"
190
+ >
191
+ {{ changeClassLabel }}
192
+ </UBadge>
193
+ </div>
159
194
  <p class="mt-0.5 text-[13px] leading-relaxed text-slate-300">{{ reasonText }}</p>
160
195
  </div>
161
196
  </div>
@@ -13,6 +13,8 @@ import EmptyState from '~/components/common/EmptyState.vue'
13
13
  import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
14
14
  import { useNowTick, stepDurationLabel } from '~/composables/useStepTimer'
15
15
  import type { PipelineStep } from '~/types/execution'
16
+ import type { ChangeClass, ReviewEffort } from '~/types/merge'
17
+ import MergeEffortChips from '~/components/merge/MergeEffortChips.vue'
16
18
 
17
19
  const props = defineProps<{ block: Block }>()
18
20
 
@@ -21,6 +23,7 @@ const agentRuns = useAgentRunsStore()
21
23
  const ui = useUiStore()
22
24
  const models = useModelsStore()
23
25
  const reviews = useReviewStage()
26
+ const trackRecords = useMergeTrackRecordsStore()
24
27
  const access = useWorkspaceAccess()
25
28
  const { t, te } = useI18n()
26
29
  const { confirm } = useConfirm()
@@ -203,6 +206,39 @@ async function resetRun() {
203
206
  }
204
207
  }
205
208
 
209
+ /**
210
+ * The reviewer-effort tag for this merge, preselected from evidence rather than starting blank: if
211
+ * the run's `pr-reviewer` step recorded findings, review comments plausibly drove rework
212
+ * (`minor`); if it did not, the PR needed nothing (`none`). One tap confirms or corrects it, and
213
+ * `null` merges untagged — tagging is a nudge, never a gate.
214
+ */
215
+ const mergeEffort = ref<ReviewEffort | null>(null)
216
+
217
+ watch(
218
+ () => props.block.status === 'pr_ready',
219
+ (awaiting) => {
220
+ if (!awaiting) return
221
+ // ONE request for every class, only once and only when a merge decision is actually pending.
222
+ if (!trackRecords.loaded && !trackRecords.loading) void trackRecords.load()
223
+ // Preselect from evidence rather than leaving it blank. Set HERE (not at setup) because the
224
+ // run instance may still be loading when this component first mounts.
225
+ mergeEffort.value ??= (instance.value?.steps ?? []).some(
226
+ (s) => (s.prReview?.findings?.length ?? 0) > 0,
227
+ )
228
+ ? 'minor'
229
+ : 'none'
230
+ },
231
+ { immediate: true },
232
+ )
233
+
234
+ /** The change class the engine recorded on the run's merger step, when one resolved. */
235
+ const mergeChangeClass = computed<ChangeClass | undefined>(() => {
236
+ const decision = instance.value?.steps.find((s) => s.agentKind === 'merger')?.custom as
237
+ | { changeClass?: ChangeClass }
238
+ | undefined
239
+ return decision?.changeClass
240
+ })
241
+
206
242
  // Merging a PR is consequential and effectively irreversible — confirm first. `execution.mergePr`
207
243
  // surfaces its own error toast, so no catch is needed here.
208
244
  async function mergePr() {
@@ -213,7 +249,7 @@ async function mergePr() {
213
249
  icon: 'i-lucide-git-merge',
214
250
  })
215
251
  if (!ok) return
216
- await execution.mergePr(props.block.id)
252
+ await execution.mergePr(props.block.id, mergeEffort.value)
217
253
  }
218
254
  </script>
219
255
 
@@ -548,17 +584,25 @@ async function mergePr() {
548
584
  :description="t('inspector.execution.empty.body')"
549
585
  />
550
586
 
551
- <!-- PR ready: merge -->
552
- <UButton
553
- v-if="block.status === 'pr_ready'"
554
- color="success"
555
- variant="solid"
556
- size="sm"
557
- icon="i-lucide-git-merge"
558
- block
559
- @click="mergePr"
560
- >
561
- {{ t('inspector.execution.mergePr') }}
562
- </UButton>
587
+ <!-- PR ready: record how much review it needed, then merge. -->
588
+ <template v-if="block.status === 'pr_ready'">
589
+ <MergeEffortChips
590
+ v-model="mergeEffort"
591
+ :change-class="mergeChangeClass"
592
+ :rollup="mergeChangeClass ? trackRecords.byClass[mergeChangeClass] : null"
593
+ />
594
+ <UButton
595
+ class="mt-2"
596
+ color="success"
597
+ variant="solid"
598
+ size="sm"
599
+ icon="i-lucide-git-merge"
600
+ block
601
+ data-testid="inspector-merge-pr"
602
+ @click="mergePr"
603
+ >
604
+ {{ t('inspector.execution.mergePr') }}
605
+ </UButton>
606
+ </template>
563
607
  </InspectorSection>
564
608
  </template>
@@ -0,0 +1,140 @@
1
+ <script setup lang="ts">
2
+ // Per-change-class auto-merge rules for one merge preset, shown NEXT TO each class's accumulated
3
+ // track record — the number that justifies widening a rule. That pairing is the whole point: an
4
+ // operator flips `docs` to "always auto-merge" because the history says humans found nothing to
5
+ // say about docs changes, not on a hunch.
6
+ //
7
+ // `unknown` is deliberately not listed: no rule may ever match it (an unclassifiable diff must
8
+ // fall back to the score ceilings), so offering one would be a lie. See
9
+ // docs/initiatives/merge-track-record.md.
10
+ import { computed } from 'vue'
11
+ import { autoMergeShare, frictionlessShare, RULEABLE_CHANGE_CLASSES } from '@cat-factory/contracts'
12
+ import type { MergeClassRule, MergeClassRules } from '~/types/merge'
13
+
14
+ const props = defineProps<{
15
+ /** The preset's current rules; an absent class means "use the score ceilings". */
16
+ modelValue: MergeClassRules
17
+ /** Whether the preset auto-merges at all — a rule can never override `autoMergeEnabled: false`. */
18
+ autoMergeEnabled: boolean
19
+ disabled?: boolean
20
+ }>()
21
+
22
+ const emit = defineEmits<{ 'update:modelValue': [MergeClassRules] }>()
23
+
24
+ const { t, n } = useI18n()
25
+ const trackRecords = useMergeTrackRecordsStore()
26
+
27
+ // Load the rollups once when the editor mounts. ONE request returns every class (a single SQL
28
+ // aggregate server-side), so this never fans out per class.
29
+ onMounted(() => {
30
+ if (!trackRecords.loaded && !trackRecords.loading) void trackRecords.load()
31
+ })
32
+
33
+ // Per-class + per-rule labels. Exhaustive Records keyed off the unions (a missing member fails the
34
+ // typecheck) with LITERAL catalog keys so the typed-message-keys check sees them.
35
+ const CLASS_LABEL_KEYS: Record<(typeof RULEABLE_CHANGE_CLASSES)[number], string> = {
36
+ docs: 'merge.changeClass.docs',
37
+ test: 'merge.changeClass.test',
38
+ dependency: 'merge.changeClass.dependency',
39
+ config: 'merge.changeClass.config',
40
+ source: 'merge.changeClass.source',
41
+ schema: 'merge.changeClass.schema',
42
+ }
43
+ const RULE_LABEL_KEYS: Record<MergeClassRule, string> = {
44
+ thresholds: 'settings.riskPolicy.classRules.rule.thresholds',
45
+ always: 'settings.riskPolicy.classRules.rule.always',
46
+ never: 'settings.riskPolicy.classRules.rule.never',
47
+ }
48
+
49
+ const RULE_OPTIONS = computed<{ value: MergeClassRule; label: string }[]>(() => [
50
+ { value: 'thresholds', label: t(RULE_LABEL_KEYS.thresholds) },
51
+ { value: 'always', label: t(RULE_LABEL_KEYS.always) },
52
+ { value: 'never', label: t(RULE_LABEL_KEYS.never) },
53
+ ])
54
+
55
+ /** One row: the class, its current rule, and the evidence behind it. */
56
+ const rows = computed(() =>
57
+ RULEABLE_CHANGE_CLASSES.map((changeClass) => {
58
+ const rollup = trackRecords.byClass[changeClass]
59
+ const auto = autoMergeShare(rollup)
60
+ const frictionless = frictionlessShare(rollup)
61
+ return {
62
+ changeClass,
63
+ label: t(CLASS_LABEL_KEYS[changeClass]),
64
+ rule: props.modelValue[changeClass] ?? ('thresholds' as MergeClassRule),
65
+ merged: rollup.merged,
66
+ // Null until something has landed / been tagged, so a fresh workspace shows "no data yet"
67
+ // rather than a misleading 0%.
68
+ autoShare: auto,
69
+ frictionlessShare: frictionless,
70
+ }
71
+ }),
72
+ )
73
+
74
+ /** Set (or clear) a class's rule. `thresholds` is stored as an OMISSION — it IS the default. */
75
+ function setRule(changeClass: (typeof RULEABLE_CHANGE_CLASSES)[number], rule: MergeClassRule) {
76
+ const next: MergeClassRules = { ...props.modelValue }
77
+ if (rule === 'thresholds') delete next[changeClass]
78
+ else next[changeClass] = rule
79
+ emit('update:modelValue', next)
80
+ }
81
+ </script>
82
+
83
+ <template>
84
+ <div data-testid="merge-class-rules" class="space-y-2">
85
+ <div>
86
+ <span class="block text-[10px] uppercase tracking-wide text-slate-500">
87
+ {{ t('settings.riskPolicy.classRules.heading') }}
88
+ </span>
89
+ <p class="mt-0.5 text-[11px] leading-snug text-slate-500">
90
+ {{ t('settings.riskPolicy.classRules.help') }}
91
+ </p>
92
+ <p v-if="!autoMergeEnabled" class="mt-1 text-[11px] leading-snug text-amber-400/90">
93
+ {{ t('settings.riskPolicy.classRules.autoMergeOffWarning') }}
94
+ </p>
95
+ </div>
96
+
97
+ <div
98
+ v-for="row in rows"
99
+ :key="row.changeClass"
100
+ class="flex flex-wrap items-center gap-2 rounded-md border border-slate-700/50 bg-slate-900/30 px-2 py-1.5"
101
+ :data-testid="`merge-class-row-${row.changeClass}`"
102
+ >
103
+ <span class="min-w-[7rem] text-xs text-slate-300">{{ row.label }}</span>
104
+ <USelect
105
+ :model-value="row.rule"
106
+ :items="RULE_OPTIONS"
107
+ value-key="value"
108
+ size="sm"
109
+ class="w-44"
110
+ :disabled="disabled"
111
+ :data-testid="`merge-class-rule-${row.changeClass}`"
112
+ @update:model-value="setRule(row.changeClass, $event as MergeClassRule)"
113
+ />
114
+ <span
115
+ class="text-[11px] text-slate-500"
116
+ :data-testid="`merge-class-record-${row.changeClass}`"
117
+ >
118
+ <template v-if="row.merged === 0">
119
+ {{ t('settings.riskPolicy.classRules.noData') }}
120
+ </template>
121
+ <template v-else>
122
+ {{
123
+ t('settings.riskPolicy.classRules.record', {
124
+ merged: row.merged,
125
+ autoShare: n(row.autoShare ?? 0, 'percent'),
126
+ })
127
+ }}
128
+ <template v-if="row.frictionlessShare !== null">
129
+ &middot;
130
+ {{
131
+ t('settings.riskPolicy.classRules.frictionless', {
132
+ share: n(row.frictionlessShare, 'percent'),
133
+ })
134
+ }}
135
+ </template>
136
+ </template>
137
+ </span>
138
+ </div>
139
+ </div>
140
+ </template>
@@ -5,7 +5,7 @@
5
5
  // task inspector's "Merge policy" dropdown selects from. Exactly one preset is the
6
6
  // default; it cannot be deleted or un-defaulted (the backend enforces this too).
7
7
  import { computed, reactive, ref, watch } from 'vue'
8
- import type { RiskPolicy, RequirementConcernLevel } from '~/types/merge'
8
+ import type { MergeClassRules, RiskPolicy, RequirementConcernLevel } from '~/types/merge'
9
9
  import type { StepGating } from '@cat-factory/contracts'
10
10
 
11
11
  const { t } = useI18n()
@@ -43,6 +43,9 @@ interface Draft {
43
43
  maxRequirementIterations: number
44
44
  maxRequirementConcernAllowed: RequirementConcernLevel
45
45
  autoMergeEnabled: boolean
46
+ // Per-change-class auto-merge rules. An OMITTED class means "use the score ceilings above",
47
+ // so `{}` is the identity — the editor stores `thresholds` as an omission for that reason.
48
+ classRules: MergeClassRules
46
49
  // Implementation-fork decision gating (edited 0..100, stored 0..1); disabled ⇒ off in `auto`.
47
50
  forkEnabled: boolean
48
51
  forkMinComplexity: number
@@ -79,6 +82,7 @@ function toDraft(p: RiskPolicy): Draft {
79
82
  maxRequirementIterations: p.maxRequirementIterations,
80
83
  maxRequirementConcernAllowed: p.maxRequirementConcernAllowed,
81
84
  autoMergeEnabled: p.autoMergeEnabled,
85
+ classRules: { ...p.classRules },
82
86
  forkEnabled: p.forkDecision?.enabled ?? false,
83
87
  forkMinComplexity: Math.round((p.forkDecision?.minComplexity ?? 0.5) * 100),
84
88
  forkMinRisk: Math.round((p.forkDecision?.minRisk ?? 0.4) * 100),
@@ -121,6 +125,7 @@ async function save(p: RiskPolicy) {
121
125
  maxRequirementIterations: d.maxRequirementIterations,
122
126
  maxRequirementConcernAllowed: d.maxRequirementConcernAllowed,
123
127
  autoMergeEnabled: d.autoMergeEnabled,
128
+ classRules: d.classRules,
124
129
  forkDecision: forkGating(d),
125
130
  })
126
131
  toast.add({
@@ -176,6 +181,7 @@ const draft = reactive<Draft>({
176
181
  maxRequirementIterations: 6,
177
182
  maxRequirementConcernAllowed: 'none',
178
183
  autoMergeEnabled: true,
184
+ classRules: {},
179
185
  forkEnabled: false,
180
186
  forkMinComplexity: 50,
181
187
  forkMinRisk: 40,
@@ -196,10 +202,12 @@ async function create() {
196
202
  maxRequirementIterations: draft.maxRequirementIterations,
197
203
  maxRequirementConcernAllowed: draft.maxRequirementConcernAllowed,
198
204
  autoMergeEnabled: draft.autoMergeEnabled,
205
+ classRules: draft.classRules,
199
206
  forkDecision: forkGating(draft),
200
207
  })
201
208
  draft.name = ''
202
209
  draft.autoMergeEnabled = true
210
+ draft.classRules = {}
203
211
  toast.add({
204
212
  title: t('settings.riskPolicy.toast.created'),
205
213
  icon: 'i-lucide-check',
@@ -341,6 +349,16 @@ async function create() {
341
349
  </label>
342
350
  </div>
343
351
 
352
+ <!-- Per-change-class auto-merge rules, each shown beside that class's accumulated track
353
+ record — the number that justifies widening the rule. -->
354
+ <div class="mt-3 rounded-md border border-slate-800 bg-slate-900/40 p-3">
355
+ <MergeClassRulesEditor
356
+ v-model="drafts[p.id]!.classRules"
357
+ :auto-merge-enabled="drafts[p.id]!.autoMergeEnabled"
358
+ :disabled="busy === p.id"
359
+ />
360
+ </div>
361
+
344
362
  <!-- Implementation-fork decision gate: propose materially different approaches before the
345
363
  Coder writes code (in `auto` tri-state, gated on the task estimate). -->
346
364
  <div class="mt-3 rounded-md border border-slate-800 bg-slate-900/40 p-3">
@@ -32,6 +32,7 @@ const back = useIntegrationBack(open)
32
32
  const ROUTABLE = computed<{ type: NotificationType; label: string }[]>(() => [
33
33
  { type: 'merge_review', label: t('slack.routable.merge_review') },
34
34
  { type: 'pipeline_complete', label: t('slack.routable.pipeline_complete') },
35
+ { type: 'merge_tag_request', label: t('slack.routable.merge_tag_request') },
35
36
  { type: 'ci_failed', label: t('slack.routable.ci_failed') },
36
37
  { type: 'test_failed', label: t('slack.routable.test_failed') },
37
38
  { type: 'requirement_review', label: t('slack.routable.requirement_review') },
@@ -54,6 +55,7 @@ const ROLE_OPTIONS = computed<{ label: string; value: SlackMemberRole }[]>(() =>
54
55
  const routes = reactive<Record<NotificationType, SlackRoute>>({
55
56
  merge_review: { enabled: false, channel: '' },
56
57
  pipeline_complete: { enabled: false, channel: '' },
58
+ merge_tag_request: { enabled: false, channel: '' },
57
59
  ci_failed: { enabled: false, channel: '' },
58
60
  test_failed: { enabled: false, channel: '' },
59
61
  requirement_review: { enabled: false, channel: '' },
@@ -17,6 +17,7 @@ import {
17
17
  } from '@cat-factory/contracts'
18
18
  import type { RequestStepChangesInput } from '@cat-factory/contracts'
19
19
  import type { IterationCapChoice } from '~/types/execution'
20
+ import type { ReviewEffort } from '~/types/merge'
20
21
  import type { ApiContext } from './context'
21
22
 
22
23
  /** Run lifecycle (start/cancel/decisions/approvals/restart) + LLM metrics + spend. */
@@ -38,8 +39,14 @@ export function executionApi({ send, sendWith, ws, pwHeaders }: ApiContext) {
38
39
  cancelExecution: (workspaceId: string, blockId: string) =>
39
40
  send(cancelExecutionContract, { pathPrefix: ws(workspaceId), pathParams: { blockId } }),
40
41
 
41
- mergeBlock: (workspaceId: string, blockId: string) =>
42
- send(mergeBlockContract, { pathPrefix: ws(workspaceId), pathParams: { blockId } }),
42
+ // `reviewEffort` records the reviewer-effort tag onto the block's merge track record in the
43
+ // same request as the merge (see the notification `act` counterpart). Always optional.
44
+ mergeBlock: (workspaceId: string, blockId: string, reviewEffort?: ReviewEffort | null) =>
45
+ send(mergeBlockContract, {
46
+ pathPrefix: ws(workspaceId),
47
+ pathParams: { blockId },
48
+ body: reviewEffort === undefined ? {} : { reviewEffort },
49
+ }),
43
50
 
44
51
  resolveDecision: (
45
52
  workspaceId: string,