@cat-factory/app 0.195.1 → 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/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/i18n/locales/de.json +25 -2
- package/i18n/locales/en.json +25 -2
- package/i18n/locales/es.json +25 -2
- package/i18n/locales/fr.json +25 -2
- package/i18n/locales/he.json +25 -2
- package/i18n/locales/it.json +25 -2
- package/i18n/locales/ja.json +25 -2
- package/i18n/locales/pl.json +25 -2
- package/i18n/locales/tr.json +25 -2
- package/i18n/locales/uk.json +25 -2
- package/package.json +1 -1
|
@@ -75,6 +75,21 @@ const FRAME_LABEL_KEYS: Record<BlockStatus, string> = {
|
|
|
75
75
|
done: 'board.frame.status.done',
|
|
76
76
|
}
|
|
77
77
|
const statusLabel = computed(() => t(FRAME_LABEL_KEYS[frameStatus.value]))
|
|
78
|
+
// The badge is a ROLLUP over the frame's children, not a status anybody set on the service, and
|
|
79
|
+
// the label alone never says which child produced it — "Needs attention" names no task, and
|
|
80
|
+
// "Live" reads as a health check rather than "caught up". So each status gets its own tooltip
|
|
81
|
+
// naming what it is rolled up from. `pr_ready`/`done` are unreachable here (`frameStatus` caps
|
|
82
|
+
// below `done`: a service is long-lived and never finishes) but the Record stays exhaustive over
|
|
83
|
+
// `BlockStatus` for the same reason the label map does, and mirrors that map's aliasing.
|
|
84
|
+
const FRAME_STATUS_HINT_KEYS: Record<BlockStatus, string> = {
|
|
85
|
+
planned: 'board.frame.statusHint.planned',
|
|
86
|
+
ready: 'board.frame.statusHint.ready',
|
|
87
|
+
in_progress: 'board.frame.statusHint.in_progress',
|
|
88
|
+
blocked: 'board.frame.statusHint.blocked',
|
|
89
|
+
pr_ready: 'board.frame.statusHint.pr_ready',
|
|
90
|
+
done: 'board.frame.statusHint.done',
|
|
91
|
+
}
|
|
92
|
+
const statusHint = computed(() => t(FRAME_STATUS_HINT_KEYS[frameStatus.value]))
|
|
78
93
|
|
|
79
94
|
const selected = computed(() => ui.selectedBlockId === props.id)
|
|
80
95
|
// Services are always expanded to their task canvas, at every zoom level: there is no
|
|
@@ -349,7 +364,7 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
349
364
|
</UBadge>
|
|
350
365
|
</div>
|
|
351
366
|
<div class="flex items-center justify-between">
|
|
352
|
-
<UBadge :color="statusMeta.chip as any" variant="subtle" size="sm">{{
|
|
367
|
+
<UBadge :color="statusMeta.chip as any" variant="subtle" size="sm" :title="statusHint">{{
|
|
353
368
|
statusLabel
|
|
354
369
|
}}</UBadge>
|
|
355
370
|
<span class="text-[11px] text-slate-400">{{
|
|
@@ -474,9 +489,13 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
474
489
|
</div>
|
|
475
490
|
</div>
|
|
476
491
|
<div class="flex items-center gap-1">
|
|
477
|
-
<UBadge
|
|
478
|
-
|
|
479
|
-
|
|
492
|
+
<UBadge
|
|
493
|
+
:color="statusMeta.chip as any"
|
|
494
|
+
variant="subtle"
|
|
495
|
+
size="sm"
|
|
496
|
+
:title="statusHint"
|
|
497
|
+
>{{ statusLabel }}</UBadge
|
|
498
|
+
>
|
|
480
499
|
<!-- Board-authoring buttons (create task / from issue / recurring / initiative)
|
|
481
500
|
are `board.write` — hidden for a read-only viewer, who keeps the badge +
|
|
482
501
|
collapse toggle (view-only affordances). -->
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three axes a `task-estimator` step scores a task on, and the presentation vocabulary every
|
|
3
|
+
* surface that lets a human gate work on them shares.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately NOT `RISK_POLICY_AXES`: those are the axes a `merger` scores a finished pull
|
|
6
|
+
* request on, and although the three names coincide, the two are produced by different agents at
|
|
7
|
+
* different points in a run and mean different things to a reader. Folding them together would
|
|
8
|
+
* make a rename in one silently retitle the other.
|
|
9
|
+
*/
|
|
10
|
+
export const ESTIMATE_AXES = ['complexity', 'risk', 'impact'] as const
|
|
11
|
+
|
|
12
|
+
export type EstimateAxis = (typeof ESTIMATE_AXES)[number]
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Axis → label key. Exhaustive over the union with LITERAL catalog keys, so the typed-message-key
|
|
16
|
+
* check sees them and a new axis fails the typecheck rather than rendering a raw key.
|
|
17
|
+
*/
|
|
18
|
+
export const ESTIMATE_AXIS_LABEL_KEYS: Record<EstimateAxis, string> = {
|
|
19
|
+
complexity: 'pipeline.builder.complexityThreshold',
|
|
20
|
+
risk: 'pipeline.builder.riskThreshold',
|
|
21
|
+
impact: 'pipeline.builder.impactThreshold',
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Axis → the `StepGating`/`ConsensusGating` field carrying its floor. Both schemas spell the
|
|
26
|
+
* three the same way, which is what lets one editor serve every gate in the builder.
|
|
27
|
+
*/
|
|
28
|
+
export const ESTIMATE_AXIS_FIELD: Record<EstimateAxis, 'minComplexity' | 'minRisk' | 'minImpact'> =
|
|
29
|
+
{
|
|
30
|
+
complexity: 'minComplexity',
|
|
31
|
+
risk: 'minRisk',
|
|
32
|
+
impact: 'minImpact',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Axis → the tooltip explaining what the axis measures and on what scale. Same discipline. */
|
|
36
|
+
export const ESTIMATE_AXIS_HINT_KEYS: Record<EstimateAxis, string> = {
|
|
37
|
+
complexity: 'pipeline.builder.complexityThresholdHint',
|
|
38
|
+
risk: 'pipeline.builder.riskThresholdHint',
|
|
39
|
+
impact: 'pipeline.builder.impactThresholdHint',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Read one axis threshold field back into a stored floor.
|
|
44
|
+
*
|
|
45
|
+
* An emptied or unparseable field CLEARS the axis rather than storing `0`, which is the whole
|
|
46
|
+
* reason this is a function: the two are opposites. An unset axis is not considered at all,
|
|
47
|
+
* where a floor of `0` is cleared by every estimate there is — so a user who deletes the number
|
|
48
|
+
* to stop gating on risk would, under a `?? 0`, have gated on it permanently instead.
|
|
49
|
+
*
|
|
50
|
+
* The scale is the estimator's 0..1, and a value outside it is CLAMPED rather than rejected, so
|
|
51
|
+
* a fat-fingered `10` lands on the ceiling instead of silently failing the pipeline save with a
|
|
52
|
+
* 422 from the contract's score bounds.
|
|
53
|
+
*/
|
|
54
|
+
export function parseAxisThreshold(raw: string): number | undefined {
|
|
55
|
+
const trimmed = raw.trim()
|
|
56
|
+
if (trimmed === '') return undefined
|
|
57
|
+
const parsed = Number.parseFloat(trimmed)
|
|
58
|
+
if (!Number.isFinite(parsed)) return undefined
|
|
59
|
+
return Math.min(1, Math.max(0, parsed))
|
|
60
|
+
}
|