@cat-factory/app 0.195.0 → 0.195.2
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.
- package/app/components/board/nodes/BlockNode.vue +23 -4
- package/app/components/initiative/InitiativePlanDecision.vue +134 -0
- package/app/components/initiative/InitiativePlanNotice.vue +69 -0
- package/app/components/initiative/InitiativePlanReview.vue +278 -254
- package/app/components/initiative/InitiativeTrackerWindow.vue +72 -25
- package/app/components/judge/JudgeResultView.vue +5 -1
- package/app/components/merge/MergeEffortChips.vue +5 -1
- package/app/components/panels/ReportsPanel.vue +5 -2
- package/app/components/panels/ReportsSpendBreakdown.vue +5 -1
- package/app/components/panels/StepEffortReport.vue +11 -2
- package/app/components/panels/StepFragmentAdherence.vue +7 -2
- package/app/components/panels/StepMetadataCard.vue +4 -1
- package/app/components/pipeline/EstimateThresholdFields.vue +74 -0
- package/app/components/pipeline/OutputBudgetInput.vue +1 -0
- package/app/components/pipeline/PipelineBuilder.vue +48 -96
- package/app/components/settings/ConsensusGroupsSection.vue +12 -3
- package/app/utils/estimateGating.spec.ts +44 -0
- package/app/utils/estimateGating.ts +60 -0
- package/app/utils/initiative.spec.ts +43 -0
- package/app/utils/initiative.ts +23 -0
- package/i18n/locales/de.json +28 -4
- package/i18n/locales/en.json +28 -4
- package/i18n/locales/es.json +28 -4
- package/i18n/locales/fr.json +28 -4
- package/i18n/locales/he.json +28 -4
- package/i18n/locales/it.json +28 -4
- package/i18n/locales/ja.json +28 -4
- package/i18n/locales/pl.json +28 -4
- package/i18n/locales/tr.json +28 -4
- package/i18n/locales/uk.json +28 -4
- package/package.json +1 -1
|
@@ -23,10 +23,12 @@ import {
|
|
|
23
23
|
INITIATIVE_STATUS_LABEL_KEYS,
|
|
24
24
|
initiativeProgress,
|
|
25
25
|
pendingCheckpointPhase,
|
|
26
|
+
planReviewDocument,
|
|
26
27
|
} from '~/utils/initiative'
|
|
27
28
|
import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
|
|
28
29
|
import StepRunMeta from '~/components/panels/StepRunMeta.vue'
|
|
29
30
|
import InitiativePlanReview from '~/components/initiative/InitiativePlanReview.vue'
|
|
31
|
+
import InitiativePlanNotice from '~/components/initiative/InitiativePlanNotice.vue'
|
|
30
32
|
|
|
31
33
|
const board = useBoardStore()
|
|
32
34
|
const initiatives = useInitiativesStore()
|
|
@@ -61,6 +63,24 @@ const {
|
|
|
61
63
|
stepIndex: () => stepIndex.value,
|
|
62
64
|
})
|
|
63
65
|
|
|
66
|
+
/**
|
|
67
|
+
* The `StepRunMeta` prop bundle, or null when no step speaks for this window. Bound as one object
|
|
68
|
+
* because it has two homes — the tracker's end-side column, and the plan review's sidebar while the
|
|
69
|
+
* review owns the window — and the run details must read identically in both.
|
|
70
|
+
*/
|
|
71
|
+
const runMeta = computed(() =>
|
|
72
|
+
metaStep.value
|
|
73
|
+
? {
|
|
74
|
+
step: metaStep.value,
|
|
75
|
+
instanceId: runId.value,
|
|
76
|
+
stepNumber: position.value,
|
|
77
|
+
totalSteps: totalSteps.value,
|
|
78
|
+
runFailed: runFailed.value,
|
|
79
|
+
failureAt: failureAt.value,
|
|
80
|
+
}
|
|
81
|
+
: null,
|
|
82
|
+
)
|
|
83
|
+
|
|
64
84
|
const phases = computed(() => initiative.value?.phases ?? [])
|
|
65
85
|
function itemsOf(phaseId: string): InitiativeItem[] {
|
|
66
86
|
return (initiative.value?.items ?? []).filter((i) => i.phaseId === phaseId)
|
|
@@ -103,9 +123,19 @@ async function checkpointControl(action: 'resume' | 'cancel') {
|
|
|
103
123
|
// ---- Plan review: the planner step's human gate, resolved right here -----------------------
|
|
104
124
|
// Derived from the BLOCK (via the shared planning composable), not from this window's own
|
|
105
125
|
// `stepIndex`: the card / inspector open the tracker with no step, and that is the entry point a
|
|
106
|
-
// human parked on the gate actually uses. So the
|
|
126
|
+
// human parked on the gate actually uses. So the review appears on every route into the window.
|
|
107
127
|
const { planApproval } = useInitiativePlanning(() => blockId.value ?? '')
|
|
108
128
|
|
|
129
|
+
/**
|
|
130
|
+
* The plan document the parked gate offers, or `''` — which is also the layout decision. A rendered
|
|
131
|
+
* plan is a REPLACEMENT for the tracker body rather than a card above it: the render reads the
|
|
132
|
+
* ingested entity, so the sections below would be a second copy of what the reviewer is reading,
|
|
133
|
+
* and everything the tracker adds on top (PR links, item curation, checkpoints, follow-ups) is
|
|
134
|
+
* execution-time state that cannot exist until the plan is committed. With no document, the tracker
|
|
135
|
+
* body IS the plan, so the gate takes the compact notice above it instead.
|
|
136
|
+
*/
|
|
137
|
+
const planDocument = computed(() => planReviewDocument(planApproval.value))
|
|
138
|
+
|
|
109
139
|
const policyRules = computed(() => initiative.value?.policy?.rules ?? [])
|
|
110
140
|
function ruleAxes(rule: { minComplexity?: number; minRisk?: number; minImpact?: number }): string {
|
|
111
141
|
const axes = [
|
|
@@ -236,30 +266,54 @@ async function savePolicy() {
|
|
|
236
266
|
</UBadge>
|
|
237
267
|
</template>
|
|
238
268
|
|
|
239
|
-
|
|
269
|
+
<!-- The planner's human gate, with the plan rendered as a document. This window is where the
|
|
270
|
+
park ROUTES (the planner's archetype declares this result view), so it is the only surface
|
|
271
|
+
that can resolve it — and while it is parked the review OWNS the window: an outline sidebar,
|
|
272
|
+
the plan at full height, per-block commenting and the commands in an end-side rail, the same
|
|
273
|
+
tools and the same shape the step reader gives the architect's prose. The tracker body it
|
|
274
|
+
replaces would only repeat the plan (see `planDocument`). -->
|
|
275
|
+
<InitiativePlanReview
|
|
276
|
+
v-if="planApproval && planDocument"
|
|
277
|
+
:approval="planApproval.approval"
|
|
278
|
+
:instance-id="planApproval.instanceId"
|
|
279
|
+
:can-execute="access.canExecuteRuns.value"
|
|
280
|
+
:plan-document="planDocument"
|
|
281
|
+
>
|
|
282
|
+
<template v-if="runMeta" #run-details>
|
|
283
|
+
<StepRunMeta v-bind="runMeta" />
|
|
284
|
+
</template>
|
|
285
|
+
</InitiativePlanReview>
|
|
286
|
+
|
|
287
|
+
<div v-else class="flex min-h-0 flex-1">
|
|
240
288
|
<div class="min-w-0 flex-1 overflow-y-auto px-5 py-4">
|
|
241
|
-
<!--
|
|
289
|
+
<!-- A parked gate whose step rendered no plan: the commands, plus a notice pointing at the
|
|
290
|
+
sections below — which in that case are the only rendering of the plan there is.
|
|
291
|
+
Deliberately OUTSIDE the entity branch: the gate lives on the RUN, so it is parked
|
|
292
|
+
before `initiatives.load()` has resolved (and stays parked if it fails), and a window
|
|
293
|
+
that answered such a gate with the empty state alone would leave it unresolvable from
|
|
294
|
+
the UI. `hasSections` is what keeps the notice honest about whether the sections it
|
|
295
|
+
points at are actually rendered underneath. -->
|
|
296
|
+
<InitiativePlanNotice
|
|
297
|
+
v-if="planApproval"
|
|
298
|
+
:approval="planApproval.approval"
|
|
299
|
+
:instance-id="planApproval.instanceId"
|
|
300
|
+
:can-execute="access.canExecuteRuns.value"
|
|
301
|
+
:has-sections="!!initiative"
|
|
302
|
+
/>
|
|
303
|
+
|
|
304
|
+
<!-- No entity yet (module unwired / still creating). Centred in the column when it is the
|
|
305
|
+
only thing in it; merely inset when the notice above it means `h-full` would overflow
|
|
306
|
+
the scroller by the notice's own height. -->
|
|
242
307
|
<div
|
|
243
308
|
v-if="!initiative"
|
|
244
|
-
class="flex
|
|
309
|
+
class="flex flex-col items-center justify-center gap-2 text-center text-slate-400"
|
|
310
|
+
:class="planApproval ? 'py-16' : 'h-full'"
|
|
245
311
|
>
|
|
246
312
|
<UIcon name="i-lucide-milestone" class="h-8 w-8 opacity-40" />
|
|
247
313
|
<p class="text-sm">{{ t('initiative.tracker.empty') }}</p>
|
|
248
314
|
</div>
|
|
249
315
|
|
|
250
316
|
<template v-else>
|
|
251
|
-
<!-- The planner's human gate. This window is where the park ROUTES (the planner's
|
|
252
|
-
archetype declares this result view), so it is the only surface that can resolve
|
|
253
|
-
it — and the plan it judges is rendered as a navigable document with per-block
|
|
254
|
-
commenting, the same tools the step reader gives the architect's prose. -->
|
|
255
|
-
<InitiativePlanReview
|
|
256
|
-
v-if="planApproval"
|
|
257
|
-
:approval="planApproval.approval"
|
|
258
|
-
:instance-id="planApproval.instanceId"
|
|
259
|
-
:can-execute="access.canExecuteRuns.value"
|
|
260
|
-
:output-is-rendered="planApproval.outputIsRendered"
|
|
261
|
-
/>
|
|
262
|
-
|
|
263
317
|
<!-- Paused at a phase checkpoint (D2): a completed checkpoint phase is awaiting
|
|
264
318
|
review before the next phase spawns. Read the phase's artifacts/PRs below,
|
|
265
319
|
then resume (continue) or cancel (stop) the initiative right here. -->
|
|
@@ -646,18 +700,11 @@ async function savePolicy() {
|
|
|
646
700
|
through `useResultViewRunMeta`, so it is present on the card / inspector entry point
|
|
647
701
|
too — where this window carries no step index of its own. -->
|
|
648
702
|
<aside
|
|
649
|
-
v-if="
|
|
703
|
+
v-if="runMeta"
|
|
650
704
|
data-testid="initiative-tracker-run-meta"
|
|
651
705
|
class="hidden w-60 shrink-0 flex-col gap-4 overflow-y-auto border-s border-slate-800 bg-slate-900/50 px-4 py-4 lg:flex"
|
|
652
706
|
>
|
|
653
|
-
<StepRunMeta
|
|
654
|
-
:step="metaStep"
|
|
655
|
-
:instance-id="runId"
|
|
656
|
-
:step-number="position"
|
|
657
|
-
:total-steps="totalSteps"
|
|
658
|
-
:run-failed="runFailed"
|
|
659
|
-
:failure-at="failureAt"
|
|
660
|
-
/>
|
|
707
|
+
<StepRunMeta v-bind="runMeta" />
|
|
661
708
|
</aside>
|
|
662
709
|
</div>
|
|
663
710
|
</ResultWindowShell>
|
|
@@ -151,7 +151,11 @@ async function act(choice: 'proceed' | 'bounce' | 'stop') {
|
|
|
151
151
|
<span class="text-[13px] font-semibold text-slate-100">
|
|
152
152
|
{{ score === null ? t('judge.notScored') : n(score, 'percent') }}
|
|
153
153
|
</span>
|
|
154
|
-
<span
|
|
154
|
+
<span
|
|
155
|
+
v-if="threshold !== null"
|
|
156
|
+
class="text-[12px] text-slate-400"
|
|
157
|
+
:title="t('judge.thresholdHint')"
|
|
158
|
+
>
|
|
155
159
|
{{ t('judge.threshold', { threshold: n(threshold, 'percent') }) }}
|
|
156
160
|
</span>
|
|
157
161
|
<span v-if="judge.disposition" class="text-[12px] text-slate-400">
|
|
@@ -87,7 +87,10 @@ function pick(effort: ReviewEffort) {
|
|
|
87
87
|
<template>
|
|
88
88
|
<div data-testid="merge-effort-chips" class="mt-2">
|
|
89
89
|
<div class="flex items-center gap-1.5">
|
|
90
|
-
<span
|
|
90
|
+
<span
|
|
91
|
+
class="text-[10px] uppercase tracking-wide text-slate-500"
|
|
92
|
+
:title="t('merge.effort.promptHint')"
|
|
93
|
+
>
|
|
91
94
|
{{ t('merge.effort.prompt') }}
|
|
92
95
|
</span>
|
|
93
96
|
<UBadge
|
|
@@ -96,6 +99,7 @@ function pick(effort: ReviewEffort) {
|
|
|
96
99
|
color="neutral"
|
|
97
100
|
variant="subtle"
|
|
98
101
|
size="sm"
|
|
102
|
+
:title="t('merge.changeClass.hint')"
|
|
99
103
|
>
|
|
100
104
|
{{ classLabel }}
|
|
101
105
|
</UBadge>
|
|
@@ -309,12 +309,15 @@ watch(
|
|
|
309
309
|
</div>
|
|
310
310
|
</div>
|
|
311
311
|
<div class="mt-2 flex items-center gap-4 text-[11px] text-slate-500">
|
|
312
|
-
<span class="flex items-center gap-1">
|
|
312
|
+
<span class="flex items-center gap-1" :title="t('reports.legend.meteredHint')">
|
|
313
313
|
<span class="h-2 w-2 rounded-sm bg-violet-500" />{{
|
|
314
314
|
t('reports.legend.metered')
|
|
315
315
|
}}
|
|
316
316
|
</span>
|
|
317
|
-
<span
|
|
317
|
+
<span
|
|
318
|
+
class="flex items-center gap-1"
|
|
319
|
+
:title="t('reports.legend.subscriptionHint')"
|
|
320
|
+
>
|
|
318
321
|
<span class="h-2 w-2 rounded-sm bg-amber-600" />{{
|
|
319
322
|
t('reports.legend.subscription')
|
|
320
323
|
}}
|
|
@@ -40,7 +40,11 @@ const max = computed(() => maxOf(props.rows, spendMagnitude))
|
|
|
40
40
|
figure would report money that was never billed. -->
|
|
41
41
|
<span class="shrink-0 tabular-nums text-slate-400">
|
|
42
42
|
{{ money(row.meteredCost) }}
|
|
43
|
-
<span
|
|
43
|
+
<span
|
|
44
|
+
v-if="row.subscriptionCost > 0"
|
|
45
|
+
class="text-amber-400"
|
|
46
|
+
:title="t('reports.legend.subscriptionHint')"
|
|
47
|
+
>
|
|
44
48
|
{{ t('reports.spend.subscriptionAside', { value: money(row.subscriptionCost) }) }}
|
|
45
49
|
</span>
|
|
46
50
|
</span>
|
|
@@ -42,7 +42,12 @@ const difficultyClass = computed(() => BAR_CLASS[effortBand(props.report.difficu
|
|
|
42
42
|
</div>
|
|
43
43
|
|
|
44
44
|
<div class="flex items-center gap-2">
|
|
45
|
-
<span
|
|
45
|
+
<span
|
|
46
|
+
class="text-[12px] text-slate-300"
|
|
47
|
+
:title="t('panels.stepDetail.effort.difficultyHint')"
|
|
48
|
+
>
|
|
49
|
+
{{ t('panels.stepDetail.effort.difficulty') }}
|
|
50
|
+
</span>
|
|
46
51
|
<div class="h-1.5 flex-1 overflow-hidden rounded-full bg-slate-700/60">
|
|
47
52
|
<div
|
|
48
53
|
class="h-full rounded-full"
|
|
@@ -50,7 +55,11 @@ const difficultyClass = computed(() => BAR_CLASS[effortBand(props.report.difficu
|
|
|
50
55
|
:style="{ width: `${difficultyPct}%` }"
|
|
51
56
|
/>
|
|
52
57
|
</div>
|
|
53
|
-
<span
|
|
58
|
+
<span
|
|
59
|
+
data-testid="step-effort-difficulty"
|
|
60
|
+
class="text-[12px] font-medium text-slate-200"
|
|
61
|
+
:title="t('panels.stepDetail.effort.difficultyHint')"
|
|
62
|
+
>
|
|
54
63
|
{{ t('panels.stepDetail.effort.outOfTen', { value: report.difficulty }) }}
|
|
55
64
|
</span>
|
|
56
65
|
</div>
|
|
@@ -33,7 +33,9 @@ function label(item: FragmentAdherence[number]): string {
|
|
|
33
33
|
class="mb-2 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-400"
|
|
34
34
|
>
|
|
35
35
|
<UIcon name="i-lucide-clipboard-check" class="h-3.5 w-3.5" />
|
|
36
|
-
<span
|
|
36
|
+
<span :title="t('panels.stepDetail.adherence.headingHint')">
|
|
37
|
+
{{ t('panels.stepDetail.adherence.heading') }}
|
|
38
|
+
</span>
|
|
37
39
|
</div>
|
|
38
40
|
|
|
39
41
|
<div class="space-y-2.5">
|
|
@@ -54,7 +56,10 @@ function label(item: FragmentAdherence[number]): string {
|
|
|
54
56
|
:style="{ width: `${ratingPct(item.rating)}%` }"
|
|
55
57
|
/>
|
|
56
58
|
</div>
|
|
57
|
-
<span
|
|
59
|
+
<span
|
|
60
|
+
class="shrink-0 text-[12px] font-medium text-slate-200"
|
|
61
|
+
:title="t('panels.stepDetail.adherence.ratingHint')"
|
|
62
|
+
>
|
|
58
63
|
{{ t('panels.stepDetail.adherence.outOfTen', { value: item.rating }) }}
|
|
59
64
|
</span>
|
|
60
65
|
</div>
|
|
@@ -207,7 +207,10 @@ async function copyRunId() {
|
|
|
207
207
|
|
|
208
208
|
<!-- standards (prompt fragments) folded into this step -->
|
|
209
209
|
<div v-if="step.selectedFragmentIds && step.selectedFragmentIds.length" class="mt-4">
|
|
210
|
-
<div
|
|
210
|
+
<div
|
|
211
|
+
class="text-[11px] uppercase tracking-wide text-slate-500"
|
|
212
|
+
:title="t('panels.stepMeta.standardsAppliedHint')"
|
|
213
|
+
>
|
|
211
214
|
{{ t('panels.stepMeta.standardsApplied') }}
|
|
212
215
|
</div>
|
|
213
216
|
<div class="mt-1 flex flex-wrap gap-1">
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The estimate-gate threshold row, shared by every gate editor in the pipeline builder: a
|
|
3
|
+
// companion step's own gate, a consensus step's escalation gate, and the Tester QC companion's
|
|
4
|
+
// gate. One component because the three rendered the same 0..1 inputs from three copies of the
|
|
5
|
+
// markup — and, more to the point, because the explanation these numbers need has to be worded
|
|
6
|
+
// once. A bare "risk >=" label says nothing about where the score comes from, what its scale is,
|
|
7
|
+
// or how the axes combine, and the scale is the trap: every other score the product shows a user
|
|
8
|
+
// (judge verdicts, effort difficulty, adherence ratings) is out of ten, so an unlabelled 0..1
|
|
9
|
+
// field reads as "risk 1 out of 10" and gates a step that was meant to run on everything.
|
|
10
|
+
//
|
|
11
|
+
// The gate itself is edited by the parent (the builder mutates store drafts in place), so this
|
|
12
|
+
// component only renders and reports: it never writes through the `gating` prop.
|
|
13
|
+
import type { EstimateAxis } from '~/utils/estimateGating'
|
|
14
|
+
import {
|
|
15
|
+
ESTIMATE_AXIS_FIELD,
|
|
16
|
+
ESTIMATE_AXIS_HINT_KEYS,
|
|
17
|
+
ESTIMATE_AXIS_LABEL_KEYS,
|
|
18
|
+
parseAxisThreshold,
|
|
19
|
+
} from '~/utils/estimateGating'
|
|
20
|
+
|
|
21
|
+
const props = defineProps<{
|
|
22
|
+
/** The gate being edited. Read-only here — changes go back through `update`. */
|
|
23
|
+
gating: { minComplexity?: number; minRisk?: number; minImpact?: number }
|
|
24
|
+
/** Which axes this gate exposes, in render order. */
|
|
25
|
+
axes: readonly EstimateAxis[]
|
|
26
|
+
/** What clearing the gate DOES, which differs per gate and is half the explanation. */
|
|
27
|
+
outcome: 'step' | 'consensus'
|
|
28
|
+
}>()
|
|
29
|
+
|
|
30
|
+
const emit = defineEmits<{ update: [axis: EstimateAxis, value: number | undefined] }>()
|
|
31
|
+
|
|
32
|
+
const { t } = useI18n()
|
|
33
|
+
|
|
34
|
+
// Exhaustive Record over the two outcomes with LITERAL keys, so the typed-message-key check sees
|
|
35
|
+
// them and a third outcome fails the typecheck rather than falling back to the step wording.
|
|
36
|
+
const OUTCOME_HINT_KEYS: Record<'step' | 'consensus', string> = {
|
|
37
|
+
step: 'pipeline.builder.gatingRunWhenAnyHint',
|
|
38
|
+
consensus: 'pipeline.builder.gatingRunWhenAnyConsensusHint',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const fields = computed(() =>
|
|
42
|
+
props.axes.map((axis) => ({
|
|
43
|
+
axis,
|
|
44
|
+
label: t(ESTIMATE_AXIS_LABEL_KEYS[axis]),
|
|
45
|
+
hint: t(ESTIMATE_AXIS_HINT_KEYS[axis]),
|
|
46
|
+
value: props.gating[ESTIMATE_AXIS_FIELD[axis]],
|
|
47
|
+
})),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
function commit(axis: EstimateAxis, raw: string) {
|
|
51
|
+
emit('update', axis, parseAxisThreshold(raw))
|
|
52
|
+
}
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
<template>
|
|
56
|
+
<div class="flex flex-wrap items-center gap-2 border-t border-slate-800 pt-2">
|
|
57
|
+
<span class="text-[10px] text-slate-500" :title="t(OUTCOME_HINT_KEYS[outcome])">
|
|
58
|
+
{{ t('pipeline.builder.runWhenAny') }}
|
|
59
|
+
</span>
|
|
60
|
+
<template v-for="f in fields" :key="f.axis">
|
|
61
|
+
<label class="text-slate-400" :title="f.hint">{{ f.label }}</label>
|
|
62
|
+
<input
|
|
63
|
+
:value="f.value"
|
|
64
|
+
:title="f.hint"
|
|
65
|
+
type="number"
|
|
66
|
+
min="0"
|
|
67
|
+
max="1"
|
|
68
|
+
step="0.1"
|
|
69
|
+
class="w-14 rounded border border-slate-700 bg-slate-900 px-1.5 py-0.5 text-slate-100"
|
|
70
|
+
@change="commit(f.axis, ($event.target as HTMLInputElement).value)"
|
|
71
|
+
/>
|
|
72
|
+
</template>
|
|
73
|
+
</div>
|
|
74
|
+
</template>
|
|
@@ -63,6 +63,7 @@ function commit(raw: string | number) {
|
|
|
63
63
|
:min="MIN_AGENT_MAX_OUTPUT_TOKENS"
|
|
64
64
|
:max="MAX_AGENT_MAX_OUTPUT_TOKENS"
|
|
65
65
|
:placeholder="placeholder"
|
|
66
|
+
:title="t('pipeline.outputBudget.hint')"
|
|
66
67
|
:disabled="disabled"
|
|
67
68
|
@change="commit(($event.target as HTMLInputElement).value)"
|
|
68
69
|
/>
|
|
@@ -5,7 +5,9 @@ import type { AgentKind, Pipeline, PipelinePurpose } from '~/types/domain'
|
|
|
5
5
|
import AgentPalette from '~/components/palettes/AgentPalette.vue'
|
|
6
6
|
import AgentKindIcon from '~/components/pipeline/AgentKindIcon.vue'
|
|
7
7
|
import AgentPromptEditor from '~/components/pipeline/AgentPromptEditor.vue'
|
|
8
|
+
import EstimateThresholdFields from '~/components/pipeline/EstimateThresholdFields.vue'
|
|
8
9
|
import OutputBudgetInput from '~/components/pipeline/OutputBudgetInput.vue'
|
|
10
|
+
import { ESTIMATE_AXES, ESTIMATE_AXIS_FIELD, type EstimateAxis } from '~/utils/estimateGating'
|
|
9
11
|
import { showOverrideField } from '~/utils/uiMode'
|
|
10
12
|
import {
|
|
11
13
|
agentKindMeta,
|
|
@@ -47,6 +49,31 @@ function addParticipant(i: number) {
|
|
|
47
49
|
function removeParticipant(i: number, pIdx: number) {
|
|
48
50
|
pipelines.draftConsensus[i]?.participants.splice(pIdx, 1)
|
|
49
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* The consensus escalation gate exposes only the two axes its toggle seeds (risk + impact) —
|
|
54
|
+
* complexity is a fine reason to run a deeper TEST audit but a poor reason to convene a panel,
|
|
55
|
+
* which is about disagreement risk. A module constant rather than a template literal so the
|
|
56
|
+
* array identity is stable across re-renders.
|
|
57
|
+
*/
|
|
58
|
+
const CONSENSUS_ESTIMATE_AXES: readonly EstimateAxis[] = ['risk', 'impact']
|
|
59
|
+
|
|
60
|
+
// Writing one axis floor back onto each of the three draft gates. These are functions rather
|
|
61
|
+
// than assignments inlined in the template because a template `v-if` does not narrow inside an
|
|
62
|
+
// event handler's closure, so the inline form needs a non-null assertion at every call site —
|
|
63
|
+
// and a `!` on a gate that has since been toggled off is the one shape that throws.
|
|
64
|
+
function setCompanionGatingAxis(i: number | null, axis: EstimateAxis, value: number | undefined) {
|
|
65
|
+
const gating = i === null ? undefined : pipelines.draftGating[i]
|
|
66
|
+
if (gating) gating[ESTIMATE_AXIS_FIELD[axis]] = value
|
|
67
|
+
}
|
|
68
|
+
function setConsensusGatingAxis(i: number, axis: EstimateAxis, value: number | undefined) {
|
|
69
|
+
const gating = pipelines.draftConsensus[i]?.gating
|
|
70
|
+
if (gating) gating[ESTIMATE_AXIS_FIELD[axis]] = value
|
|
71
|
+
}
|
|
72
|
+
function setTesterQualityGatingAxis(i: number, axis: EstimateAxis, value: number | undefined) {
|
|
73
|
+
const gating = pipelines.draftTesterQuality[i]?.gating
|
|
74
|
+
if (gating) gating[ESTIMATE_AXIS_FIELD[axis]] = value
|
|
75
|
+
}
|
|
76
|
+
|
|
50
77
|
/** Toggle gating on/off for a draft step's consensus config. */
|
|
51
78
|
function toggleGating(i: number) {
|
|
52
79
|
const cfg = pipelines.draftConsensus[i]
|
|
@@ -786,43 +813,15 @@ async function clone(p: Pipeline) {
|
|
|
786
813
|
@click="pipelines.toggleDraftGating(unit.companionIndex)"
|
|
787
814
|
/>
|
|
788
815
|
</div>
|
|
789
|
-
<
|
|
816
|
+
<EstimateThresholdFields
|
|
790
817
|
v-if="pipelines.draftGating[unit.companionIndex]?.enabled"
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
}}</label>
|
|
799
|
-
<input
|
|
800
|
-
v-model.number="pipelines.draftGating[unit.companionIndex]!.minComplexity"
|
|
801
|
-
type="number"
|
|
802
|
-
min="0"
|
|
803
|
-
max="1"
|
|
804
|
-
step="0.1"
|
|
805
|
-
class="w-14 rounded border border-slate-700 bg-slate-900 px-1.5 py-0.5 text-slate-100"
|
|
806
|
-
/>
|
|
807
|
-
<label class="text-slate-400">{{ t('pipeline.builder.riskThreshold') }}</label>
|
|
808
|
-
<input
|
|
809
|
-
v-model.number="pipelines.draftGating[unit.companionIndex]!.minRisk"
|
|
810
|
-
type="number"
|
|
811
|
-
min="0"
|
|
812
|
-
max="1"
|
|
813
|
-
step="0.1"
|
|
814
|
-
class="w-14 rounded border border-slate-700 bg-slate-900 px-1.5 py-0.5 text-slate-100"
|
|
815
|
-
/>
|
|
816
|
-
<label class="text-slate-400">{{ t('pipeline.builder.impactThreshold') }}</label>
|
|
817
|
-
<input
|
|
818
|
-
v-model.number="pipelines.draftGating[unit.companionIndex]!.minImpact"
|
|
819
|
-
type="number"
|
|
820
|
-
min="0"
|
|
821
|
-
max="1"
|
|
822
|
-
step="0.1"
|
|
823
|
-
class="w-14 rounded border border-slate-700 bg-slate-900 px-1.5 py-0.5 text-slate-100"
|
|
824
|
-
/>
|
|
825
|
-
</div>
|
|
818
|
+
:gating="pipelines.draftGating[unit.companionIndex]!"
|
|
819
|
+
:axes="ESTIMATE_AXES"
|
|
820
|
+
outcome="step"
|
|
821
|
+
@update="
|
|
822
|
+
(axis, value) => setCompanionGatingAxis(unit.companionIndex, axis, value)
|
|
823
|
+
"
|
|
824
|
+
/>
|
|
826
825
|
</div>
|
|
827
826
|
|
|
828
827
|
<!-- Consensus config (shown when the step is consensus-enabled). -->
|
|
@@ -953,31 +952,14 @@ async function clone(p: Pipeline) {
|
|
|
953
952
|
:title="t('pipeline.builder.consensusGateTooltip')"
|
|
954
953
|
@click="toggleGating(unit.index)"
|
|
955
954
|
/>
|
|
956
|
-
<template v-if="pipelines.draftConsensus[unit.index]!.gating?.enabled">
|
|
957
|
-
<label class="text-slate-400">{{
|
|
958
|
-
t('pipeline.builder.riskThreshold')
|
|
959
|
-
}}</label>
|
|
960
|
-
<input
|
|
961
|
-
v-model.number="pipelines.draftConsensus[unit.index]!.gating!.minRisk"
|
|
962
|
-
type="number"
|
|
963
|
-
min="0"
|
|
964
|
-
max="1"
|
|
965
|
-
step="0.1"
|
|
966
|
-
class="w-14 rounded border border-slate-700 bg-slate-900 px-1.5 py-0.5 text-slate-100"
|
|
967
|
-
/>
|
|
968
|
-
<label class="text-slate-400">{{
|
|
969
|
-
t('pipeline.builder.impactThreshold')
|
|
970
|
-
}}</label>
|
|
971
|
-
<input
|
|
972
|
-
v-model.number="pipelines.draftConsensus[unit.index]!.gating!.minImpact"
|
|
973
|
-
type="number"
|
|
974
|
-
min="0"
|
|
975
|
-
max="1"
|
|
976
|
-
step="0.1"
|
|
977
|
-
class="w-14 rounded border border-slate-700 bg-slate-900 px-1.5 py-0.5 text-slate-100"
|
|
978
|
-
/>
|
|
979
|
-
</template>
|
|
980
955
|
</div>
|
|
956
|
+
<EstimateThresholdFields
|
|
957
|
+
v-if="pipelines.draftConsensus[unit.index]!.gating?.enabled"
|
|
958
|
+
:gating="pipelines.draftConsensus[unit.index]!.gating!"
|
|
959
|
+
:axes="CONSENSUS_ESTIMATE_AXES"
|
|
960
|
+
outcome="consensus"
|
|
961
|
+
@update="(axis, value) => setConsensusGatingAxis(unit.index, axis, value)"
|
|
962
|
+
/>
|
|
981
963
|
</template>
|
|
982
964
|
</div>
|
|
983
965
|
|
|
@@ -1013,43 +995,13 @@ async function clone(p: Pipeline) {
|
|
|
1013
995
|
@click="pipelines.toggleDraftTesterQualityGating(unit.index)"
|
|
1014
996
|
/>
|
|
1015
997
|
</div>
|
|
1016
|
-
<
|
|
998
|
+
<EstimateThresholdFields
|
|
1017
999
|
v-if="pipelines.draftTesterQuality[unit.index]?.gating?.enabled"
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
<label class="text-slate-400">{{
|
|
1024
|
-
t('pipeline.builder.complexityThreshold')
|
|
1025
|
-
}}</label>
|
|
1026
|
-
<input
|
|
1027
|
-
v-model.number="pipelines.draftTesterQuality[unit.index]!.gating!.minComplexity"
|
|
1028
|
-
type="number"
|
|
1029
|
-
min="0"
|
|
1030
|
-
max="1"
|
|
1031
|
-
step="0.1"
|
|
1032
|
-
class="w-14 rounded border border-slate-700 bg-slate-900 px-1.5 py-0.5 text-slate-100"
|
|
1033
|
-
/>
|
|
1034
|
-
<label class="text-slate-400">{{ t('pipeline.builder.riskThreshold') }}</label>
|
|
1035
|
-
<input
|
|
1036
|
-
v-model.number="pipelines.draftTesterQuality[unit.index]!.gating!.minRisk"
|
|
1037
|
-
type="number"
|
|
1038
|
-
min="0"
|
|
1039
|
-
max="1"
|
|
1040
|
-
step="0.1"
|
|
1041
|
-
class="w-14 rounded border border-slate-700 bg-slate-900 px-1.5 py-0.5 text-slate-100"
|
|
1042
|
-
/>
|
|
1043
|
-
<label class="text-slate-400">{{ t('pipeline.builder.impactThreshold') }}</label>
|
|
1044
|
-
<input
|
|
1045
|
-
v-model.number="pipelines.draftTesterQuality[unit.index]!.gating!.minImpact"
|
|
1046
|
-
type="number"
|
|
1047
|
-
min="0"
|
|
1048
|
-
max="1"
|
|
1049
|
-
step="0.1"
|
|
1050
|
-
class="w-14 rounded border border-slate-700 bg-slate-900 px-1.5 py-0.5 text-slate-100"
|
|
1051
|
-
/>
|
|
1052
|
-
</div>
|
|
1000
|
+
:gating="pipelines.draftTesterQuality[unit.index]!.gating!"
|
|
1001
|
+
:axes="ESTIMATE_AXES"
|
|
1002
|
+
outcome="step"
|
|
1003
|
+
@update="(axis, value) => setTesterQualityGatingAxis(unit.index, axis, value)"
|
|
1004
|
+
/>
|
|
1053
1005
|
</div>
|
|
1054
1006
|
</li>
|
|
1055
1007
|
</ol>
|
|
@@ -427,7 +427,10 @@ function fail(title: string, e: unknown) {
|
|
|
427
427
|
{{ t('settings.consensusGroups.editor.gatedHint') }}
|
|
428
428
|
</p>
|
|
429
429
|
<div v-if="editor.gated" class="flex flex-wrap items-center gap-3 text-xs">
|
|
430
|
-
<label
|
|
430
|
+
<label
|
|
431
|
+
class="flex items-center gap-1.5 text-slate-400"
|
|
432
|
+
:title="t('pipeline.builder.riskThresholdHint')"
|
|
433
|
+
>
|
|
431
434
|
{{ t('pipeline.builder.riskThreshold') }}
|
|
432
435
|
<UInput
|
|
433
436
|
v-model.number="editor.minRisk"
|
|
@@ -439,7 +442,10 @@ function fail(title: string, e: unknown) {
|
|
|
439
442
|
class="w-20"
|
|
440
443
|
/>
|
|
441
444
|
</label>
|
|
442
|
-
<label
|
|
445
|
+
<label
|
|
446
|
+
class="flex items-center gap-1.5 text-slate-400"
|
|
447
|
+
:title="t('pipeline.builder.impactThresholdHint')"
|
|
448
|
+
>
|
|
443
449
|
{{ t('pipeline.builder.impactThreshold') }}
|
|
444
450
|
<UInput
|
|
445
451
|
v-model.number="editor.minImpact"
|
|
@@ -451,7 +457,10 @@ function fail(title: string, e: unknown) {
|
|
|
451
457
|
class="w-20"
|
|
452
458
|
/>
|
|
453
459
|
</label>
|
|
454
|
-
<label
|
|
460
|
+
<label
|
|
461
|
+
class="flex items-center gap-1.5 text-slate-400"
|
|
462
|
+
:title="t('pipeline.builder.complexityThresholdHint')"
|
|
463
|
+
>
|
|
455
464
|
{{ t('settings.consensusGroups.editor.complexityThreshold') }}
|
|
456
465
|
<UInput
|
|
457
466
|
v-model.number="editor.minComplexity"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
ESTIMATE_AXES,
|
|
4
|
+
ESTIMATE_AXIS_FIELD,
|
|
5
|
+
ESTIMATE_AXIS_HINT_KEYS,
|
|
6
|
+
ESTIMATE_AXIS_LABEL_KEYS,
|
|
7
|
+
parseAxisThreshold,
|
|
8
|
+
} from './estimateGating'
|
|
9
|
+
|
|
10
|
+
describe('parseAxisThreshold', () => {
|
|
11
|
+
it('clears the axis on an empty or unparseable field rather than storing a zero floor', () => {
|
|
12
|
+
// The two are opposites: an unset axis is not considered, where a 0 floor always passes.
|
|
13
|
+
expect(parseAxisThreshold('')).toBeUndefined()
|
|
14
|
+
expect(parseAxisThreshold(' ')).toBeUndefined()
|
|
15
|
+
expect(parseAxisThreshold('abc')).toBeUndefined()
|
|
16
|
+
expect(parseAxisThreshold('0')).toBe(0)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('keeps a value inside the estimator scale', () => {
|
|
20
|
+
expect(parseAxisThreshold('0.6')).toBe(0.6)
|
|
21
|
+
expect(parseAxisThreshold(' 0.25 ')).toBe(0.25)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('clamps out-of-range input instead of rejecting it', () => {
|
|
25
|
+
// A fat-fingered extra digit lands on the ceiling rather than failing the save with a 422.
|
|
26
|
+
expect(parseAxisThreshold('10')).toBe(1)
|
|
27
|
+
expect(parseAxisThreshold('-3')).toBe(0)
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe('estimate axis vocabulary', () => {
|
|
32
|
+
it('covers every axis in both key maps and the field map', () => {
|
|
33
|
+
for (const axis of ESTIMATE_AXES) {
|
|
34
|
+
expect(ESTIMATE_AXIS_LABEL_KEYS[axis]).toBeTruthy()
|
|
35
|
+
expect(ESTIMATE_AXIS_HINT_KEYS[axis]).toBeTruthy()
|
|
36
|
+
expect(ESTIMATE_AXIS_FIELD[axis]).toBeTruthy()
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('gives each axis its own hint, so no two axes explain the same thing', () => {
|
|
41
|
+
const hints = ESTIMATE_AXES.map((axis) => ESTIMATE_AXIS_HINT_KEYS[axis])
|
|
42
|
+
expect(new Set(hints).size).toBe(ESTIMATE_AXES.length)
|
|
43
|
+
})
|
|
44
|
+
})
|