@cat-factory/app 0.71.2 → 0.72.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.
@@ -0,0 +1,267 @@
1
+ <script setup lang="ts">
2
+ // Dedicated result view for a completed `merger` step. The merger agent scores the PR
3
+ // (complexity / risk / impact + a rationale) and the engine records its structured
4
+ // decision on the step (`step.custom`, a `MergeDecision`): whether it auto-merged or
5
+ // routed the PR to a human, and WHY. This renders that verdict — the three scores as
6
+ // bars against their preset ceilings, the rationale, and a plain-language decision
7
+ // banner — instead of the agent's raw JSON. Opened via the universal result-view host,
8
+ // the same seam the requirements / tester windows use.
9
+ import { computed } from 'vue'
10
+ import type { MergeAxis, MergeDecision } from '@cat-factory/contracts'
11
+ import StepRunMeta from '~/components/panels/StepRunMeta.vue'
12
+ import StepRestartControl from '~/components/panels/StepRestartControl.vue'
13
+
14
+ const board = useBoardStore()
15
+ const execution = useExecutionStore()
16
+ const agents = useAgentsStore()
17
+ const { t, n } = useI18n()
18
+
19
+ // Shared seam contract (open/blockId/close + Escape). No loader: the verdict is read
20
+ // straight off the execution step.
21
+ const { open, blockId, instanceId, stepIndex, close } = useResultView('merger')
22
+ const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
23
+
24
+ const instance = computed(() =>
25
+ instanceId.value === null ? null : (execution.getInstance(instanceId.value) ?? null),
26
+ )
27
+ const step = computed(() => {
28
+ if (instance.value === null || stepIndex.value === null) return null
29
+ return instance.value.steps[stepIndex.value] ?? null
30
+ })
31
+ const meta = computed(() => (step.value ? agents.get(step.value.agentKind) : undefined))
32
+
33
+ const headerLabel = computed(() => meta.value?.label ?? t('panels.mergerResult.title'))
34
+ const headerTitle = computed(() =>
35
+ block.value
36
+ ? t('panels.mergerResult.titleWithBlock', { title: block.value.title })
37
+ : headerLabel.value,
38
+ )
39
+
40
+ /** The engine's structured verdict; null for a step that predates the structured decision. */
41
+ const decision = computed<MergeDecision | null>(() => {
42
+ const custom = step.value?.custom
43
+ return custom && typeof custom === 'object' && 'outcome' in custom
44
+ ? (custom as MergeDecision)
45
+ : null
46
+ })
47
+
48
+ const merged = computed(() => decision.value?.outcome === 'auto_merged')
49
+ // Only redden bars when a threshold breach is the ACTUAL reason for review. For
50
+ // `auto_merge_disabled` / `no_rationale` / `no_assessment` a score above its ceiling is
51
+ // incidental, so it must not imply the axis is what caused the review.
52
+ const exceeded = computed(() =>
53
+ decision.value?.reason === 'exceeded_thresholds'
54
+ ? new Set<MergeAxis>(decision.value.exceededAxes)
55
+ : new Set<MergeAxis>(),
56
+ )
57
+
58
+ // Exhaustive enum → i18n-key maps keyed off the contract unions, so adding a new
59
+ // `MergeDecision` reason/outcome (or a merge axis) fails typecheck here until its key is
60
+ // added — the drift guard the dynamic `t(\`...\${x}\`)` lookups can't provide on their own.
61
+ const REASON_KEYS: Record<MergeDecision['reason'], string> = {
62
+ within_thresholds: 'panels.mergerResult.reason.within_thresholds',
63
+ exceeded_thresholds: 'panels.mergerResult.reason.exceeded_thresholds',
64
+ auto_merge_disabled: 'panels.mergerResult.reason.auto_merge_disabled',
65
+ no_rationale: 'panels.mergerResult.reason.no_rationale',
66
+ no_assessment: 'panels.mergerResult.reason.no_assessment',
67
+ merge_failed: 'panels.mergerResult.reason.merge_failed',
68
+ }
69
+ const OUTCOME_KEYS: Record<MergeDecision['outcome'], string> = {
70
+ auto_merged: 'panels.mergerResult.outcome.auto_merged',
71
+ awaiting_review: 'panels.mergerResult.outcome.awaiting_review',
72
+ }
73
+ const AXIS_KEYS: Record<MergeAxis, string> = {
74
+ complexity: 'panels.mergerResult.axis.complexity',
75
+ risk: 'panels.mergerResult.axis.risk',
76
+ impact: 'panels.mergerResult.axis.impact',
77
+ }
78
+
79
+ const outcomeText = computed(() => (decision.value ? t(OUTCOME_KEYS[decision.value.outcome]) : ''))
80
+
81
+ /** The three axes with their score + preset ceiling, for the bar rows. */
82
+ const axes = computed(() => {
83
+ const d = decision.value
84
+ if (!d?.assessment) return []
85
+ return [
86
+ {
87
+ key: 'complexity' as const,
88
+ label: t(AXIS_KEYS.complexity),
89
+ score: d.assessment.complexity,
90
+ ceiling: d.thresholds.maxComplexity,
91
+ },
92
+ {
93
+ key: 'risk' as const,
94
+ label: t(AXIS_KEYS.risk),
95
+ score: d.assessment.risk,
96
+ ceiling: d.thresholds.maxRisk,
97
+ },
98
+ {
99
+ key: 'impact' as const,
100
+ label: t(AXIS_KEYS.impact),
101
+ score: d.assessment.impact,
102
+ ceiling: d.thresholds.maxImpact,
103
+ },
104
+ ]
105
+ })
106
+
107
+ /** The plain-language "why" line, interpolating the preset + any exceeded axes. */
108
+ const reasonText = computed(() => {
109
+ const d = decision.value
110
+ if (!d) return ''
111
+ const axisLabels = d.exceededAxes.map((a) => t(AXIS_KEYS[a])).join(', ')
112
+ return t(REASON_KEYS[d.reason], {
113
+ preset: d.thresholds.presetName,
114
+ axes: axisLabels,
115
+ })
116
+ })
117
+ </script>
118
+
119
+ <template>
120
+ <Teleport to="body">
121
+ <div
122
+ v-if="open"
123
+ class="fixed inset-0 z-50 flex max-h-[100dvh] items-stretch justify-center bg-slate-950/70 backdrop-blur-sm"
124
+ @click.self="close"
125
+ >
126
+ <div
127
+ class="m-4 flex w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl"
128
+ >
129
+ <!-- Header -->
130
+ <header class="flex items-center gap-3 border-b border-slate-800 px-5 py-3">
131
+ <span
132
+ class="flex h-8 w-8 items-center justify-center rounded-lg bg-lime-500/15 text-lime-300"
133
+ >
134
+ <UIcon :name="meta?.icon ?? 'i-lucide-git-pull-request'" class="h-4 w-4" />
135
+ </span>
136
+ <div class="min-w-0 flex-1">
137
+ <h2 class="truncate text-sm font-semibold text-slate-100">{{ headerTitle }}</h2>
138
+ <p class="truncate text-[11px] text-slate-400">
139
+ {{ t('panels.mergerResult.description') }}
140
+ </p>
141
+ </div>
142
+ <StepRestartControl
143
+ :instance-id="instanceId"
144
+ :step-index="stepIndex"
145
+ @restarted="close"
146
+ />
147
+ <button
148
+ class="rounded-md p-1.5 text-slate-400 hover:bg-slate-800 hover:text-slate-200"
149
+ @click="close"
150
+ >
151
+ <UIcon name="i-lucide-x" class="h-4 w-4" />
152
+ </button>
153
+ </header>
154
+
155
+ <div class="flex min-h-0 flex-1">
156
+ <div class="min-w-0 flex-1 overflow-y-auto px-5 py-4">
157
+ <template v-if="decision">
158
+ <!-- Decision banner: auto-merged (success) vs awaiting human review (warning). -->
159
+ <div
160
+ class="mb-4 flex items-start gap-3 rounded-lg border p-3"
161
+ :class="
162
+ merged
163
+ ? 'border-emerald-800/70 bg-emerald-500/10'
164
+ : 'border-amber-800/70 bg-amber-500/10'
165
+ "
166
+ data-testid="merger-decision"
167
+ :data-outcome="decision.outcome"
168
+ >
169
+ <UIcon
170
+ :name="merged ? 'i-lucide-git-merge' : 'i-lucide-user-round-check'"
171
+ class="mt-0.5 h-5 w-5 shrink-0"
172
+ :class="merged ? 'text-emerald-300' : 'text-amber-300'"
173
+ />
174
+ <div class="min-w-0">
175
+ <p
176
+ class="text-sm font-semibold"
177
+ :class="merged ? 'text-emerald-200' : 'text-amber-200'"
178
+ >
179
+ {{ outcomeText }}
180
+ </p>
181
+ <p class="mt-0.5 text-[13px] leading-relaxed text-slate-300">{{ reasonText }}</p>
182
+ </div>
183
+ </div>
184
+
185
+ <!-- Scores vs the resolved preset's ceilings. -->
186
+ <template v-if="axes.length">
187
+ <h3 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
188
+ {{ t('panels.mergerResult.scores') }}
189
+ </h3>
190
+ <div class="space-y-2 rounded-lg border border-slate-800 bg-slate-950/40 p-3">
191
+ <div v-for="axis in axes" :key="axis.key" class="flex items-center gap-2">
192
+ <span class="w-20 shrink-0 text-xs text-slate-400">{{ axis.label }}</span>
193
+ <div class="h-1.5 flex-1 overflow-hidden rounded-full bg-slate-800">
194
+ <div
195
+ class="h-full rounded-full"
196
+ :class="exceeded.has(axis.key) ? 'bg-rose-500' : 'bg-emerald-500'"
197
+ :style="{ width: `${Math.round(axis.score * 100)}%` }"
198
+ />
199
+ </div>
200
+ <span
201
+ class="w-11 shrink-0 text-end text-xs tabular-nums"
202
+ :class="exceeded.has(axis.key) ? 'text-rose-300' : 'text-slate-300'"
203
+ >
204
+ {{ n(axis.score, { key: 'percent' }) }}
205
+ </span>
206
+ <span class="w-24 shrink-0 text-end text-[10px] tabular-nums text-slate-500">
207
+ {{
208
+ t('panels.mergerResult.ceiling', {
209
+ value: n(axis.ceiling, { key: 'percent' }),
210
+ })
211
+ }}
212
+ </span>
213
+ </div>
214
+ </div>
215
+ </template>
216
+
217
+ <!-- The agent's prose justification. -->
218
+ <template v-if="decision.assessment?.rationale">
219
+ <h3
220
+ class="mb-2 mt-4 text-[11px] font-semibold uppercase tracking-wide text-slate-500"
221
+ >
222
+ {{ t('panels.mergerResult.rationale') }}
223
+ </h3>
224
+ <p class="whitespace-pre-wrap text-[13px] leading-relaxed text-slate-300">
225
+ {{ decision.assessment.rationale }}
226
+ </p>
227
+ </template>
228
+ <p v-else class="text-[13px] italic leading-relaxed text-slate-500">
229
+ {{ t('panels.mergerResult.noAssessment') }}
230
+ </p>
231
+ </template>
232
+
233
+ <!-- Pre-structured runs kept only the raw prose output. -->
234
+ <p
235
+ v-else-if="step?.output"
236
+ class="whitespace-pre-wrap text-[13px] leading-relaxed text-slate-300"
237
+ >
238
+ {{ step.output }}
239
+ </p>
240
+ <div
241
+ v-else
242
+ class="flex h-full flex-col items-center justify-center gap-2 text-center text-slate-400"
243
+ >
244
+ <UIcon name="i-lucide-git-pull-request" class="h-8 w-8 opacity-40" />
245
+ <p class="text-sm">{{ t('panels.mergerResult.noResult') }}</p>
246
+ </div>
247
+ </div>
248
+
249
+ <!-- Sidebar: shared run metadata. -->
250
+ <aside
251
+ class="hidden w-60 shrink-0 flex-col gap-4 border-s border-slate-800 bg-slate-900/50 px-4 py-4 lg:flex"
252
+ >
253
+ <StepRunMeta
254
+ v-if="step"
255
+ :step="step"
256
+ :instance-id="instanceId ?? undefined"
257
+ :step-number="stepIndex === null ? undefined : stepIndex + 1"
258
+ :total-steps="instance?.steps.length"
259
+ :run-failed="instance?.status === 'failed'"
260
+ :failure-at="instance?.failure?.occurredAt"
261
+ />
262
+ </aside>
263
+ </div>
264
+ </div>
265
+ </div>
266
+ </Teleport>
267
+ </template>
@@ -22,6 +22,7 @@ import ConsensusSessionWindow from '~/components/consensus/ConsensusSessionWindo
22
22
  import GenericStructuredResultView from '~/components/panels/GenericStructuredResultView.vue'
23
23
  import ServiceSpecWindow from '~/components/spec/ServiceSpecWindow.vue'
24
24
  import FollowUpWindow from '~/components/followUp/FollowUpWindow.vue'
25
+ import MergerResultView from '~/components/panels/MergerResultView.vue'
25
26
 
26
27
  const ui = useUiStore()
27
28
 
@@ -48,6 +49,9 @@ const STEP_RESULT_VIEWS: Record<string, Component> = {
48
49
  // The future-looking Follow-up companion: the Coder's surfaced loose ends / questions.
49
50
  // Opened directly via `ui.openFollowUps` (the blinking chip + the `followup_pending` card).
50
51
  'follow-ups': FollowUpWindow,
52
+ // The merger's verdict: the PR's complexity/risk/impact scores + the engine's auto-merge
53
+ // or awaiting-review decision (and why), instead of the agent's raw JSON.
54
+ merger: MergerResultView,
51
55
  }
52
56
 
53
57
  const active = computed<Component | null>(() => {
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { computed } from 'vue'
2
+ import { computed, onMounted, onUnmounted, watch } from 'vue'
3
3
  import type {
4
4
  Block,
5
5
  FrontendBackendBinding,
@@ -7,6 +7,7 @@ import type {
7
7
  FrontendEnvInjection,
8
8
  FrontendPackageManager,
9
9
  FrontendServeMode,
10
+ PreviewStatus,
10
11
  } from '~/types/domain'
11
12
 
12
13
  // Frontend-frame (`type: 'frontend'`) configuration: how to build, serve, and mock this
@@ -19,6 +20,7 @@ const props = defineProps<{ block: Block }>()
19
20
 
20
21
  const board = useBoardStore()
21
22
  const auth = useAuthStore()
23
+ const preview = usePreviewStore()
22
24
  const { t } = useI18n()
23
25
 
24
26
  // A browsable preview needs a long-lived host serve, so it is a local/node runtime capability
@@ -94,6 +96,39 @@ function addBinding() {
94
96
  function removeBinding(index: number) {
95
97
  save({ backendBindings: bindings.value.filter((_, i) => i !== index) })
96
98
  }
99
+
100
+ // ---- Browsable preview (live runtime state, distinct from the persisted toggle) ----------
101
+ // The live preview is a separate resource fetched from the preview endpoints; the store keys it
102
+ // by frame id and self-polls while it is `starting`. Only relevant on a preview-capable runtime
103
+ // with the toggle on.
104
+ const previewActive = computed(() => previewSupported.value && config.value.previewEnabled === true)
105
+ const previewState = computed(() => preview.byFrame[props.block.id])
106
+ const previewStatus = computed<PreviewStatus>(() => previewState.value?.status ?? 'stopped')
107
+ const previewBusy = computed(() => preview.busy[props.block.id] === true)
108
+ // A start/stop request error (e.g. an unsupported runtime 503s) — shown inline; distinct from a
109
+ // `failed` preview (a build that came up then broke), which carries `previewState.error`.
110
+ const previewRequestError = computed(() => preview.requestError[props.block.id])
111
+
112
+ // Exhaustive status → catalog key (tier-2 guard for the runtime-built lookup): adding a
113
+ // PreviewStatus value without a label trips the typecheck on this map.
114
+ const PREVIEW_STATUS_KEYS: Record<PreviewStatus, string> = {
115
+ starting: 'inspector.frontendConfig.preview.status.starting',
116
+ ready: 'inspector.frontendConfig.preview.status.ready',
117
+ failed: 'inspector.frontendConfig.preview.status.failed',
118
+ stopped: 'inspector.frontendConfig.preview.status.stopped',
119
+ }
120
+ const previewStatusLabel = computed(() => t(PREVIEW_STATUS_KEYS[previewStatus.value]))
121
+
122
+ // Load the current state when the preview surface becomes relevant (mount + toggling it on, or
123
+ // selecting a different frontend frame), so the inspector reflects an already-running preview.
124
+ function refreshPreview() {
125
+ if (previewActive.value) void preview.refresh(props.block.id)
126
+ }
127
+ onMounted(refreshPreview)
128
+ watch(() => [previewActive.value, props.block.id], refreshPreview)
129
+ // Stop the store's self-poll for this frame when the inspector closes, so a `starting` preview
130
+ // doesn't keep polling in the background after the panel is gone.
131
+ onUnmounted(() => preview.stopPolling(props.block.id))
97
132
  </script>
98
133
 
99
134
  <template>
@@ -359,6 +394,81 @@ function removeBinding(index: number) {
359
394
  : t('inspector.frontendConfig.previewUnsupported')
360
395
  }}
361
396
  </p>
397
+
398
+ <!-- Live preview control: start / open (clickable URL) / stop, reflecting the runtime state. -->
399
+ <div v-if="previewActive" class="mt-2 space-y-1.5" data-testid="preview-panel">
400
+ <div class="flex items-center gap-2">
401
+ <span
402
+ class="text-[11px] font-medium"
403
+ :class="{
404
+ 'text-emerald-400': previewStatus === 'ready',
405
+ 'text-amber-400': previewStatus === 'starting',
406
+ 'text-rose-400': previewStatus === 'failed',
407
+ 'text-slate-400': previewStatus === 'stopped',
408
+ }"
409
+ data-testid="preview-status"
410
+ >
411
+ {{ previewStatusLabel }}
412
+ </span>
413
+
414
+ <UButton
415
+ v-if="previewStatus === 'ready' && previewState?.url"
416
+ :to="previewState.url"
417
+ target="_blank"
418
+ rel="noopener"
419
+ size="xs"
420
+ variant="soft"
421
+ color="primary"
422
+ trailing-icon="i-lucide-external-link"
423
+ data-testid="preview-url"
424
+ >
425
+ {{ t('inspector.frontendConfig.preview.open') }}
426
+ </UButton>
427
+ </div>
428
+
429
+ <div class="flex flex-wrap gap-1">
430
+ <UButton
431
+ v-if="previewStatus === 'stopped' || previewStatus === 'failed'"
432
+ size="xs"
433
+ variant="soft"
434
+ color="primary"
435
+ icon="i-lucide-play"
436
+ :loading="previewBusy"
437
+ data-testid="preview-start"
438
+ @click="preview.start(props.block.id)"
439
+ >
440
+ {{ t('inspector.frontendConfig.preview.start') }}
441
+ </UButton>
442
+ <UButton
443
+ v-if="previewStatus === 'ready' || previewStatus === 'starting'"
444
+ size="xs"
445
+ variant="ghost"
446
+ color="neutral"
447
+ icon="i-lucide-square"
448
+ :loading="previewBusy"
449
+ data-testid="preview-stop"
450
+ @click="preview.stop(props.block.id)"
451
+ >
452
+ {{ t('inspector.frontendConfig.preview.stop') }}
453
+ </UButton>
454
+ </div>
455
+
456
+ <p
457
+ v-if="previewStatus === 'failed' && previewState?.error"
458
+ class="text-[11px] leading-snug text-rose-400"
459
+ data-testid="preview-error"
460
+ >
461
+ {{ previewState.error }}
462
+ </p>
463
+
464
+ <p
465
+ v-if="previewRequestError"
466
+ class="text-[11px] leading-snug text-rose-400"
467
+ data-testid="preview-request-error"
468
+ >
469
+ {{ previewRequestError }}
470
+ </p>
471
+ </div>
362
472
  </div>
363
473
  </div>
364
474
  </template>
@@ -99,7 +99,12 @@ function labelForStep(s: {
99
99
  // container is still cold-booting → "Spinning up"; up with a known phase → the phase
100
100
  // label ("Agent running" / "Preparing workspace"), so a finished cold-boot no longer
101
101
  // collapses into a blank "Working". A failed run's mid-flight step isn't booting.
102
- if (!runFailed.value) {
102
+ //
103
+ // Only while the step is STILL RUNNING, though: the run's one shared container is kept
104
+ // alive until the pipeline's final step, so a step that has already finished (e.g. the
105
+ // merger, which resolves + advances to a trailing gate) would otherwise keep reading the
106
+ // stale "Agent running" phase even though its state is `done`. A done step reads "Done".
107
+ if (!runFailed.value && s.state !== 'done') {
103
108
  if (s.container?.status === 'starting') return t('inspector.execution.spinningUp')
104
109
  if (s.container?.status === 'up') {
105
110
  const label = containerPhaseLabel(s.container.phase, { t, te })
@@ -0,0 +1,20 @@
1
+ import {
2
+ getPreviewContract,
3
+ startPreviewContract,
4
+ stopPreviewContract,
5
+ } from '@cat-factory/contracts'
6
+ import type { ApiContext } from './context'
7
+
8
+ /** Browsable frontend preview: start / poll / stop a served preview for a `frontend` frame. */
9
+ export function previewApi({ send, ws }: ApiContext) {
10
+ return {
11
+ getPreview: (workspaceId: string, frameId: string) =>
12
+ send(getPreviewContract, { pathPrefix: ws(workspaceId), pathParams: { frameId } }),
13
+
14
+ startPreview: (workspaceId: string, frameId: string) =>
15
+ send(startPreviewContract, { pathPrefix: ws(workspaceId), pathParams: { frameId } }),
16
+
17
+ stopPreview: (workspaceId: string, frameId: string) =>
18
+ send(stopPreviewContract, { pathPrefix: ws(workspaceId), pathParams: { frameId } }),
19
+ }
20
+ }
@@ -22,6 +22,7 @@ import { presetsApi } from './api/presets'
22
22
  import { providerConnectionsApi } from './api/providerConnections'
23
23
  import { provisioningLogsApi } from './api/provisioningLogs'
24
24
  import { recurringApi } from './api/recurring'
25
+ import { previewApi } from './api/preview'
25
26
  import { releaseHealthApi } from './api/releaseHealth'
26
27
  import { sandboxApi } from './api/sandbox'
27
28
  import { reviewsApi } from './api/reviews'
@@ -111,6 +112,7 @@ export function useApi() {
111
112
  ...infraHandlersApi(ctx),
112
113
  ...provisioningLogsApi(ctx),
113
114
  ...releaseHealthApi(ctx),
115
+ ...previewApi(ctx),
114
116
  ...recurringApi(ctx),
115
117
  ...sandboxApi(ctx),
116
118
  ...githubApi(ctx),
@@ -0,0 +1,94 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref } from 'vue'
3
+ import type { PreviewState } from '~/types/domain'
4
+ import { useWorkspaceStore } from '~/stores/workspace'
5
+
6
+ /**
7
+ * The browsable-frontend-preview runtime state, keyed by `frontend` frame id. Distinct from
8
+ * the frame's persisted `frontendConfig.previewEnabled` flag: this is the LIVE resource (a
9
+ * container building/serving the app on a host URL) fetched from the three preview endpoints.
10
+ * The three calls all return the same {@link PreviewState}, so each action just stores the
11
+ * result. While a preview is `starting` the store self-polls until it settles (ready/failed),
12
+ * so the inspector reflects the URL the moment it comes up — no manual refresh.
13
+ */
14
+ export const usePreviewStore = defineStore('preview', () => {
15
+ const api = useApi()
16
+
17
+ /** frameId → its latest preview state. */
18
+ const byFrame = ref<Record<string, PreviewState>>({})
19
+ /** frameId → a start/stop request is in flight (drives the button loading state). */
20
+ const busy = ref<Record<string, boolean>>({})
21
+ /** frameId → the last start/stop request error (e.g. the runtime 503s), else undefined. */
22
+ const requestError = ref<Record<string, string | undefined>>({})
23
+
24
+ // Active poll timers while a preview is `starting`, so a settled/left preview stops polling.
25
+ const timers = new Map<string, ReturnType<typeof setTimeout>>()
26
+ const POLL_INTERVAL_MS = 2_500
27
+
28
+ function stopPolling(frameId: string) {
29
+ const timer = timers.get(frameId)
30
+ if (timer) {
31
+ clearTimeout(timer)
32
+ timers.delete(frameId)
33
+ }
34
+ }
35
+
36
+ function apply(frameId: string, state: PreviewState) {
37
+ byFrame.value[frameId] = state
38
+ if (state.status === 'starting') {
39
+ stopPolling(frameId)
40
+ timers.set(
41
+ frameId,
42
+ setTimeout(() => void refresh(frameId), POLL_INTERVAL_MS),
43
+ )
44
+ } else {
45
+ stopPolling(frameId)
46
+ }
47
+ }
48
+
49
+ /** Fetch the current preview state for a frame (used on mount + as the poll tick). */
50
+ async function refresh(frameId: string): Promise<void> {
51
+ const ws = useWorkspaceStore()
52
+ try {
53
+ apply(frameId, await api.getPreview(ws.requireId(), frameId))
54
+ } catch {
55
+ // A transient error leaves the last known state; stop polling so we don't spin.
56
+ stopPolling(frameId)
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Start (or restart) the preview for a frame. A request failure (e.g. the runtime replies 503)
62
+ * is captured in {@link requestError} rather than escaping as an unhandled rejection from the
63
+ * click handler.
64
+ */
65
+ async function start(frameId: string): Promise<void> {
66
+ const ws = useWorkspaceStore()
67
+ busy.value[frameId] = true
68
+ requestError.value[frameId] = undefined
69
+ try {
70
+ apply(frameId, await api.startPreview(ws.requireId(), frameId))
71
+ } catch (err) {
72
+ requestError.value[frameId] = err instanceof Error ? err.message : String(err)
73
+ } finally {
74
+ busy.value[frameId] = false
75
+ }
76
+ }
77
+
78
+ /** Stop the preview for a frame. Failures are captured in {@link requestError}, not thrown. */
79
+ async function stop(frameId: string): Promise<void> {
80
+ const ws = useWorkspaceStore()
81
+ busy.value[frameId] = true
82
+ requestError.value[frameId] = undefined
83
+ try {
84
+ stopPolling(frameId)
85
+ apply(frameId, await api.stopPreview(ws.requireId(), frameId))
86
+ } catch (err) {
87
+ requestError.value[frameId] = err instanceof Error ? err.message : String(err)
88
+ } finally {
89
+ busy.value[frameId] = false
90
+ }
91
+ }
92
+
93
+ return { byFrame, busy, requestError, refresh, start, stop, stopPolling }
94
+ })
@@ -63,6 +63,8 @@ export type {
63
63
  KaizenVerifiedCombo,
64
64
  KaizenOverview,
65
65
  WorkspaceEvent,
66
+ PreviewState,
67
+ PreviewStatus,
66
68
  } from '@cat-factory/contracts'
67
69
 
68
70
  import type { AgentCategory, AgentKind } from '@cat-factory/contracts'
@@ -405,6 +405,9 @@ export const SYSTEM_AGENT_META: Record<string, AgentArchetype> = {
405
405
  icon: 'i-lucide-git-pull-request',
406
406
  color: '#a3e635',
407
407
  description: 'Scores the PR and auto-merges within the task thresholds, or asks for review.',
408
+ // The merger's verdict is structured (scores + the engine's auto-merge / review
409
+ // decision), so it opens a dedicated result view instead of the raw-JSON prose panel.
410
+ resultView: 'merger',
408
411
  },
409
412
  'human-review': {
410
413
  kind: 'human-review',
@@ -485,6 +485,17 @@
485
485
  "mock": "Mock (WireMock)",
486
486
  "remove": "Remove binding",
487
487
  "empty": "No backend bindings. Add one to point an env var at a service or a mock."
488
+ },
489
+ "preview": {
490
+ "open": "Open preview",
491
+ "start": "Start preview",
492
+ "stop": "Stop",
493
+ "status": {
494
+ "starting": "Starting…",
495
+ "ready": "Running",
496
+ "failed": "Failed",
497
+ "stopped": "Not running"
498
+ }
488
499
  }
489
500
  },
490
501
  "releaseHealth": {
@@ -721,6 +732,33 @@
721
732
  "noResult": "No result yet.",
722
733
  "noResultHint": "The structured output appears once this agent finishes. While it runs, the step shows live progress on the board."
723
734
  },
735
+ "mergerResult": {
736
+ "title": "Merge decision",
737
+ "titleWithBlock": "Merge decision: {title}",
738
+ "description": "How the PR scored and what the engine decided.",
739
+ "outcome": {
740
+ "auto_merged": "Auto-merged",
741
+ "awaiting_review": "Awaiting human review"
742
+ },
743
+ "reason": {
744
+ "within_thresholds": "Every score is within the {preset} thresholds, so the PR was merged automatically.",
745
+ "exceeded_thresholds": "{axes} exceeded the {preset} thresholds, so the PR is waiting for a human to merge.",
746
+ "auto_merge_disabled": "The {preset} preset sends every PR to a human, so this one is waiting for review.",
747
+ "no_rationale": "The merger scored the PR but gave no rationale, so the verdict could not be trusted to auto-merge; the PR is waiting for a human to merge.",
748
+ "no_assessment": "The merger did not return a parseable assessment, so the PR is waiting for a human to merge.",
749
+ "merge_failed": "The scores were within the {preset} thresholds, but the automatic merge could not complete (for example branch protection or a conflict), so the PR is waiting for a human to merge."
750
+ },
751
+ "scores": "Scores",
752
+ "axis": {
753
+ "complexity": "Complexity",
754
+ "risk": "Risk",
755
+ "impact": "Impact"
756
+ },
757
+ "ceiling": "ceiling {value}",
758
+ "rationale": "Rationale",
759
+ "noAssessment": "The merger did not return a scored assessment.",
760
+ "noResult": "No merge decision recorded yet."
761
+ },
724
762
  "testReport": {
725
763
  "title": "Test report",
726
764
  "greenlit": "Greenlit",
@@ -445,6 +445,17 @@
445
445
  "mock": "Simulación (WireMock)",
446
446
  "remove": "Eliminar vinculación",
447
447
  "empty": "No hay vinculaciones de backend. Añade una para apuntar una variable de entorno a un servicio o a una simulación."
448
+ },
449
+ "preview": {
450
+ "open": "Abrir vista previa",
451
+ "start": "Iniciar vista previa",
452
+ "stop": "Detener",
453
+ "status": {
454
+ "starting": "Iniciando…",
455
+ "ready": "En ejecución",
456
+ "failed": "Error",
457
+ "stopped": "No iniciada"
458
+ }
448
459
  }
449
460
  },
450
461
  "releaseHealth": {
@@ -681,6 +692,33 @@
681
692
  "noResult": "Aún no hay resultado.",
682
693
  "noResultHint": "La salida estructurada aparece cuando este agente termina. Mientras se ejecuta, el paso muestra el progreso en vivo en el tablero."
683
694
  },
695
+ "mergerResult": {
696
+ "title": "Decisión de fusión",
697
+ "titleWithBlock": "Decisión de fusión: {title}",
698
+ "description": "Cómo se puntuó el PR y qué decidió el motor.",
699
+ "outcome": {
700
+ "auto_merged": "Fusionado automáticamente",
701
+ "awaiting_review": "Esperando revisión humana"
702
+ },
703
+ "reason": {
704
+ "within_thresholds": "Todas las puntuaciones están dentro de los umbrales de {preset}, por lo que el PR se fusionó automáticamente.",
705
+ "exceeded_thresholds": "{axes} superó los umbrales de {preset}, por lo que el PR espera a que una persona lo fusione.",
706
+ "auto_merge_disabled": "El preajuste {preset} envía todos los PR a una persona, así que este espera revisión.",
707
+ "no_rationale": "El fusionador puntuó el PR pero no dio ninguna justificación, así que no se pudo confiar en el veredicto para fusionar automáticamente; el PR espera a que una persona lo fusione.",
708
+ "no_assessment": "El fusionador no devolvió una evaluación analizable, por lo que el PR espera a que una persona lo fusione.",
709
+ "merge_failed": "Las puntuaciones estaban dentro de los umbrales de {preset}, pero la fusión automática no pudo completarse (por ejemplo, protección de rama o un conflicto), por lo que el PR espera a que una persona lo fusione."
710
+ },
711
+ "scores": "Puntuaciones",
712
+ "axis": {
713
+ "complexity": "Complejidad",
714
+ "risk": "Riesgo",
715
+ "impact": "Impacto"
716
+ },
717
+ "ceiling": "límite {value}",
718
+ "rationale": "Justificación",
719
+ "noAssessment": "El fusionador no devolvió una evaluación puntuada.",
720
+ "noResult": "Aún no se ha registrado ninguna decisión de fusión."
721
+ },
684
722
  "testReport": {
685
723
  "title": "Informe de pruebas",
686
724
  "greenlit": "Aprobado",
@@ -445,6 +445,17 @@
445
445
  "mock": "Simulation (WireMock)",
446
446
  "remove": "Supprimer la liaison",
447
447
  "empty": "Aucune liaison de backend. Ajoutez-en une pour pointer une variable d'environnement vers un service ou une simulation."
448
+ },
449
+ "preview": {
450
+ "open": "Ouvrir l'aperçu",
451
+ "start": "Démarrer l'aperçu",
452
+ "stop": "Arrêter",
453
+ "status": {
454
+ "starting": "Démarrage…",
455
+ "ready": "En cours",
456
+ "failed": "Échec",
457
+ "stopped": "Non démarré"
458
+ }
448
459
  }
449
460
  },
450
461
  "releaseHealth": {
@@ -681,6 +692,33 @@
681
692
  "noResult": "Pas encore de résultat.",
682
693
  "noResultHint": "La sortie structurée apparaît une fois que cet agent a terminé. Pendant son exécution, l'étape affiche sa progression en direct sur le tableau."
683
694
  },
695
+ "mergerResult": {
696
+ "title": "Décision de fusion",
697
+ "titleWithBlock": "Décision de fusion : {title}",
698
+ "description": "Comment la PR a été évaluée et ce que le moteur a décidé.",
699
+ "outcome": {
700
+ "auto_merged": "Fusionnée automatiquement",
701
+ "awaiting_review": "En attente de revue humaine"
702
+ },
703
+ "reason": {
704
+ "within_thresholds": "Tous les scores sont dans les seuils de {preset}, la PR a donc été fusionnée automatiquement.",
705
+ "exceeded_thresholds": "{axes} a dépassé les seuils de {preset}, la PR attend donc une fusion par une personne.",
706
+ "auto_merge_disabled": "Le préréglage {preset} envoie chaque PR à une personne ; celle-ci attend donc une revue.",
707
+ "no_rationale": "Le fusionneur a évalué la PR mais n'a donné aucune justification, le verdict n'a donc pas pu être approuvé pour une fusion automatique ; la PR attend une fusion par une personne.",
708
+ "no_assessment": "Le fusionneur n'a pas renvoyé d'évaluation exploitable, la PR attend donc une fusion par une personne.",
709
+ "merge_failed": "Les scores étaient dans les seuils de {preset}, mais la fusion automatique n'a pas pu aboutir (par exemple protection de branche ou conflit), la PR attend donc une fusion par une personne."
710
+ },
711
+ "scores": "Scores",
712
+ "axis": {
713
+ "complexity": "Complexité",
714
+ "risk": "Risque",
715
+ "impact": "Impact"
716
+ },
717
+ "ceiling": "plafond {value}",
718
+ "rationale": "Justification",
719
+ "noAssessment": "Le fusionneur n'a pas renvoyé d'évaluation chiffrée.",
720
+ "noResult": "Aucune décision de fusion enregistrée pour le moment."
721
+ },
684
722
  "testReport": {
685
723
  "title": "Rapport de test",
686
724
  "greenlit": "Validé",
@@ -445,6 +445,17 @@
445
445
  "mock": "הדמיה (WireMock)",
446
446
  "remove": "הסר קישור",
447
447
  "empty": "אין קישורי backend. הוסף אחד כדי להפנות משתנה סביבה לשירות או להדמיה."
448
+ },
449
+ "preview": {
450
+ "open": "פתח תצוגה מקדימה",
451
+ "start": "הפעל תצוגה מקדימה",
452
+ "stop": "עצור",
453
+ "status": {
454
+ "starting": "מתחיל…",
455
+ "ready": "פועל",
456
+ "failed": "נכשל",
457
+ "stopped": "לא פועל"
458
+ }
448
459
  }
449
460
  },
450
461
  "releaseHealth": {
@@ -681,6 +692,33 @@
681
692
  "noResult": "אין עדיין תוצאה.",
682
693
  "noResultHint": "הפלט המובנה מופיע ברגע שהסוכן מסיים. בזמן הריצה השלב מציג התקדמות חיה על הלוח."
683
694
  },
695
+ "mergerResult": {
696
+ "title": "החלטת מיזוג",
697
+ "titleWithBlock": "החלטת מיזוג: {title}",
698
+ "description": "כיצד ה-PR דורג ומה המנוע החליט.",
699
+ "outcome": {
700
+ "auto_merged": "מוזג אוטומטית",
701
+ "awaiting_review": "ממתין לבדיקת אדם"
702
+ },
703
+ "reason": {
704
+ "within_thresholds": "כל הציונים בתוך ספי {preset}, ולכן ה-PR מוזג אוטומטית.",
705
+ "exceeded_thresholds": "{axes} חרג מספי {preset}, ולכן ה-PR ממתין למיזוג ידני.",
706
+ "auto_merge_disabled": "הקדם-הגדרה {preset} שולחת כל PR לאדם, ולכן זה ממתין לבדיקה.",
707
+ "no_rationale": "הממזג נתן ציון ל-PR אך לא סיפק נימוק, ולכן לא ניתן היה לסמוך על ההכרעה למיזוג אוטומטי; ה-PR ממתין למיזוג ידני.",
708
+ "no_assessment": "הממזג לא החזיר הערכה שניתן לפענח, ולכן ה-PR ממתין למיזוג ידני.",
709
+ "merge_failed": "הציונים היו בתוך ספי {preset}, אך המיזוג האוטומטי לא הושלם (למשל הגנת ענף או התנגשות), ולכן ה-PR ממתין למיזוג ידני."
710
+ },
711
+ "scores": "ציונים",
712
+ "axis": {
713
+ "complexity": "מורכבות",
714
+ "risk": "סיכון",
715
+ "impact": "השפעה"
716
+ },
717
+ "ceiling": "תקרה {value}",
718
+ "rationale": "נימוק",
719
+ "noAssessment": "הממזג לא החזיר הערכה מדורגת.",
720
+ "noResult": "עדיין לא נרשמה החלטת מיזוג."
721
+ },
684
722
  "testReport": {
685
723
  "title": "דוח בדיקות",
686
724
  "greenlit": "אושר",
@@ -445,6 +445,17 @@
445
445
  "mock": "モック(WireMock)",
446
446
  "remove": "バインディングを削除",
447
447
  "empty": "バックエンドバインディングがありません。追加して、環境変数をサービスまたはモックに向けてください。"
448
+ },
449
+ "preview": {
450
+ "open": "プレビューを開く",
451
+ "start": "プレビューを開始",
452
+ "stop": "停止",
453
+ "status": {
454
+ "starting": "開始中…",
455
+ "ready": "実行中",
456
+ "failed": "失敗",
457
+ "stopped": "停止中"
458
+ }
448
459
  }
449
460
  },
450
461
  "releaseHealth": {
@@ -681,6 +692,33 @@
681
692
  "noResult": "まだ結果はありません。",
682
693
  "noResultHint": "構造化出力はこのエージェントの完了後に表示されます。実行中はステップがボード上にライブ進捗を表示します。"
683
694
  },
695
+ "mergerResult": {
696
+ "title": "マージの判定",
697
+ "titleWithBlock": "マージの判定: {title}",
698
+ "description": "PR のスコアとエンジンの判断内容。",
699
+ "outcome": {
700
+ "auto_merged": "自動マージ済み",
701
+ "awaiting_review": "人によるレビュー待ち"
702
+ },
703
+ "reason": {
704
+ "within_thresholds": "すべてのスコアが {preset} のしきい値内のため、PR は自動的にマージされました。",
705
+ "exceeded_thresholds": "{axes} が {preset} のしきい値を超えたため、PR は人によるマージを待っています。",
706
+ "auto_merge_disabled": "{preset} プリセットはすべての PR を人に回すため、この PR はレビュー待ちです。",
707
+ "no_rationale": "マージ担当は PR を採点しましたが根拠を示さなかったため、自動マージするには判定を信頼できませんでした。PR は人によるマージを待っています。",
708
+ "no_assessment": "マージ担当が解析可能な評価を返さなかったため、PR は人によるマージを待っています。",
709
+ "merge_failed": "スコアは {preset} のしきい値内でしたが、自動マージを完了できなかった(例: ブランチ保護や競合)ため、PR は人によるマージを待っています。"
710
+ },
711
+ "scores": "スコア",
712
+ "axis": {
713
+ "complexity": "複雑さ",
714
+ "risk": "リスク",
715
+ "impact": "影響"
716
+ },
717
+ "ceiling": "上限 {value}",
718
+ "rationale": "根拠",
719
+ "noAssessment": "マージ担当はスコア付き評価を返しませんでした。",
720
+ "noResult": "マージの判定はまだ記録されていません。"
721
+ },
684
722
  "testReport": {
685
723
  "title": "テストレポート",
686
724
  "greenlit": "承認済み",
@@ -445,6 +445,17 @@
445
445
  "mock": "Mock (WireMock)",
446
446
  "remove": "Usuń powiązanie",
447
447
  "empty": "Brak powiązań backendu. Dodaj jedno, aby skierować zmienną środowiskową do usługi lub mocka."
448
+ },
449
+ "preview": {
450
+ "open": "Otwórz podgląd",
451
+ "start": "Uruchom podgląd",
452
+ "stop": "Zatrzymaj",
453
+ "status": {
454
+ "starting": "Uruchamianie…",
455
+ "ready": "Działa",
456
+ "failed": "Błąd",
457
+ "stopped": "Nie uruchomiono"
458
+ }
448
459
  }
449
460
  },
450
461
  "releaseHealth": {
@@ -681,6 +692,33 @@
681
692
  "noResult": "Brak wyniku.",
682
693
  "noResultHint": "Ustrukturyzowany wynik pojawia się po zakończeniu pracy tego agenta. W trakcie działania krok pokazuje postęp na żywo na tablicy."
683
694
  },
695
+ "mergerResult": {
696
+ "title": "Decyzja o scaleniu",
697
+ "titleWithBlock": "Decyzja o scaleniu: {title}",
698
+ "description": "Jak oceniono PR i co zdecydował silnik.",
699
+ "outcome": {
700
+ "auto_merged": "Scalono automatycznie",
701
+ "awaiting_review": "Oczekuje na przegląd przez człowieka"
702
+ },
703
+ "reason": {
704
+ "within_thresholds": "Wszystkie oceny mieszczą się w progach {preset}, więc PR został scalony automatycznie.",
705
+ "exceeded_thresholds": "{axes} przekroczył progi {preset}, więc PR czeka na scalenie przez człowieka.",
706
+ "auto_merge_disabled": "Ustawienie {preset} kieruje każdy PR do człowieka, więc ten czeka na przegląd.",
707
+ "no_rationale": "Scalający ocenił PR, ale nie podał uzasadnienia, więc werdyktowi nie można było zaufać na tyle, by scalić automatycznie; PR czeka na scalenie przez człowieka.",
708
+ "no_assessment": "Scalający nie zwrócił możliwej do przetworzenia oceny, więc PR czeka na scalenie przez człowieka.",
709
+ "merge_failed": "Oceny mieściły się w progach {preset}, ale automatyczne scalenie nie mogło się powieść (np. ochrona gałęzi lub konflikt), więc PR czeka na scalenie przez człowieka."
710
+ },
711
+ "scores": "Oceny",
712
+ "axis": {
713
+ "complexity": "Złożoność",
714
+ "risk": "Ryzyko",
715
+ "impact": "Wpływ"
716
+ },
717
+ "ceiling": "limit {value}",
718
+ "rationale": "Uzasadnienie",
719
+ "noAssessment": "Scalający nie zwrócił oceny punktowej.",
720
+ "noResult": "Nie zarejestrowano jeszcze decyzji o scaleniu."
721
+ },
684
722
  "testReport": {
685
723
  "title": "Raport z testów",
686
724
  "greenlit": "Zatwierdzone",
@@ -445,6 +445,17 @@
445
445
  "mock": "Taklit (WireMock)",
446
446
  "remove": "Bağlamayı kaldır",
447
447
  "empty": "Backend bağlaması yok. Bir ortam değişkenini bir hizmete veya taklide yönlendirmek için bir tane ekleyin."
448
+ },
449
+ "preview": {
450
+ "open": "Önizlemeyi aç",
451
+ "start": "Önizlemeyi başlat",
452
+ "stop": "Durdur",
453
+ "status": {
454
+ "starting": "Başlatılıyor…",
455
+ "ready": "Çalışıyor",
456
+ "failed": "Başarısız",
457
+ "stopped": "Başlatılmadı"
458
+ }
448
459
  }
449
460
  },
450
461
  "releaseHealth": {
@@ -681,6 +692,33 @@
681
692
  "noResult": "Henüz sonuç yok.",
682
693
  "noResultHint": "Yapılandırılmış çıktı bu ajan tamamlandığında görünür. Çalışırken adım, panoda canlı ilerlemeyi gösterir."
683
694
  },
695
+ "mergerResult": {
696
+ "title": "Birleştirme kararı",
697
+ "titleWithBlock": "Birleştirme kararı: {title}",
698
+ "description": "PR'nin nasıl puanlandığı ve motorun ne karar verdiği.",
699
+ "outcome": {
700
+ "auto_merged": "Otomatik birleştirildi",
701
+ "awaiting_review": "İnsan incelemesi bekleniyor"
702
+ },
703
+ "reason": {
704
+ "within_thresholds": "Tüm puanlar {preset} eşiklerinin içinde olduğundan PR otomatik olarak birleştirildi.",
705
+ "exceeded_thresholds": "{axes} {preset} eşiklerini aştığından PR bir kişinin birleştirmesini bekliyor.",
706
+ "auto_merge_disabled": "{preset} ön ayarı her PR'yi bir kişiye yönlendirir, bu yüzden bu PR inceleme bekliyor.",
707
+ "no_rationale": "Birleştirici PR'yi puanladı ancak bir gerekçe vermedi, bu yüzden karara otomatik birleştirme için güvenilemedi; PR bir kişinin birleştirmesini bekliyor.",
708
+ "no_assessment": "Birleştirici ayrıştırılabilir bir değerlendirme döndürmedi, bu yüzden PR bir kişinin birleştirmesini bekliyor.",
709
+ "merge_failed": "Puanlar {preset} eşiklerinin içindeydi ancak otomatik birleştirme tamamlanamadı (örneğin dal koruması veya bir çakışma), bu yüzden PR bir kişinin birleştirmesini bekliyor."
710
+ },
711
+ "scores": "Puanlar",
712
+ "axis": {
713
+ "complexity": "Karmaşıklık",
714
+ "risk": "Risk",
715
+ "impact": "Etki"
716
+ },
717
+ "ceiling": "üst sınır {value}",
718
+ "rationale": "Gerekçe",
719
+ "noAssessment": "Birleştirici puanlı bir değerlendirme döndürmedi.",
720
+ "noResult": "Henüz bir birleştirme kararı kaydedilmedi."
721
+ },
684
722
  "testReport": {
685
723
  "title": "Test raporu",
686
724
  "greenlit": "Onaylandı",
@@ -445,6 +445,17 @@
445
445
  "mock": "Мок (WireMock)",
446
446
  "remove": "Видалити прив'язку",
447
447
  "empty": "Немає прив'язок backend. Додайте одну, щоб спрямувати змінну середовища на сервіс або мок."
448
+ },
449
+ "preview": {
450
+ "open": "Відкрити попередній перегляд",
451
+ "start": "Запустити попередній перегляд",
452
+ "stop": "Зупинити",
453
+ "status": {
454
+ "starting": "Запуск…",
455
+ "ready": "Працює",
456
+ "failed": "Помилка",
457
+ "stopped": "Не запущено"
458
+ }
448
459
  }
449
460
  },
450
461
  "releaseHealth": {
@@ -681,6 +692,33 @@
681
692
  "noResult": "Результату ще немає.",
682
693
  "noResultHint": "Структурований вивід з'являється, щойно агент завершить роботу. Поки він працює, крок показує живий прогрес на дошці."
683
694
  },
695
+ "mergerResult": {
696
+ "title": "Рішення про злиття",
697
+ "titleWithBlock": "Рішення про злиття: {title}",
698
+ "description": "Як оцінено PR і що вирішив рушій.",
699
+ "outcome": {
700
+ "auto_merged": "Автоматично злито",
701
+ "awaiting_review": "Очікує на перевірку людиною"
702
+ },
703
+ "reason": {
704
+ "within_thresholds": "Усі оцінки в межах порогів {preset}, тому PR злито автоматично.",
705
+ "exceeded_thresholds": "{axes} перевищив пороги {preset}, тому PR очікує на злиття людиною.",
706
+ "auto_merge_disabled": "Пресет {preset} надсилає кожен PR людині, тож цей очікує на перевірку.",
707
+ "no_rationale": "Модуль злиття оцінив PR, але не надав обґрунтування, тому вердикту не можна було довіряти для автоматичного злиття; PR очікує на злиття людиною.",
708
+ "no_assessment": "Модуль злиття не повернув придатну для обробки оцінку, тому PR очікує на злиття людиною.",
709
+ "merge_failed": "Оцінки були в межах порогів {preset}, але автоматичне злиття не вдалося завершити (наприклад, захист гілки або конфлікт), тому PR очікує на злиття людиною."
710
+ },
711
+ "scores": "Оцінки",
712
+ "axis": {
713
+ "complexity": "Складність",
714
+ "risk": "Ризик",
715
+ "impact": "Вплив"
716
+ },
717
+ "ceiling": "межа {value}",
718
+ "rationale": "Обґрунтування",
719
+ "noAssessment": "Модуль злиття не повернув оцінену експертизу.",
720
+ "noResult": "Рішення про злиття ще не зафіксовано."
721
+ },
684
722
  "testReport": {
685
723
  "title": "Звіт про тестування",
686
724
  "greenlit": "Схвалено",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.71.2",
3
+ "version": "0.72.0",
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",
@@ -34,7 +34,7 @@
34
34
  "valibot": "^1.4.2",
35
35
  "vue": "^3.5.39",
36
36
  "wretch": "^3.0.9",
37
- "@cat-factory/contracts": "0.78.0"
37
+ "@cat-factory/contracts": "0.79.0"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@toad-contracts/testing": "0.3.2",