@cat-factory/app 0.266.0 → 0.267.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/components/board/AddTaskModal.vue +3 -0
- package/app/components/board/nodes/TaskCard.vue +7 -1
- package/app/components/judge/JudgeResultView.vue +11 -8
- package/app/components/panels/StepFragmentAdherence.vue +5 -5
- package/app/components/panels/StepMetadataCard.vue +27 -12
- package/app/components/panels/StepTestReport.vue +7 -4
- package/app/components/pipeline/PipelineBuilder.vue +62 -39
- package/app/components/prReview/PrReviewWindow.vue +23 -14
- package/app/components/requirements/RequirementsReviewWindow.logic.spec.ts +52 -10
- package/app/components/requirements/RequirementsReviewWindow.logic.ts +44 -6
- package/app/components/requirements/RequirementsReviewWindow.vue +114 -17
- package/app/components/settings/RiskPolicyPanel.vue +27 -0
- package/app/components/testing/TestReportWindow.vue +24 -16
- package/app/composables/usePipelineLibraryActions.ts +82 -0
- package/app/stores/pipelines/persistence.ts +32 -2
- package/app/stores/pipelines.ts +25 -1
- package/i18n/locales/de.json +26 -1
- package/i18n/locales/en.json +26 -1
- package/i18n/locales/es.json +26 -1
- package/i18n/locales/fr.json +26 -1
- package/i18n/locales/he.json +26 -1
- package/i18n/locales/it.json +26 -1
- package/i18n/locales/ja.json +26 -1
- package/i18n/locales/pl.json +26 -1
- package/i18n/locales/tr.json +26 -1
- package/i18n/locales/uk.json +26 -1
- package/package.json +2 -2
|
@@ -429,6 +429,9 @@ function defaultPipelineIdFor(type: TaskTypeChoice): string {
|
|
|
429
429
|
const preset =
|
|
430
430
|
custom?.defaultPipelineId ??
|
|
431
431
|
DEFAULT_PIPELINE_FOR_TYPE[type] ??
|
|
432
|
+
// The workspace's own declared in-app default, ahead of the interface-mode rung, so this form
|
|
433
|
+
// and the task card's plain Start still cannot disagree (see `declaredDefaultId`).
|
|
434
|
+
pipelines.declaredDefaultId('interactive') ??
|
|
432
435
|
defaultBuildPipelineId(uiMode.isAdvanced)
|
|
433
436
|
return pipelines.pipelines.some((p) => p.id === preset) ? preset : ''
|
|
434
437
|
}
|
|
@@ -92,7 +92,13 @@ const defaultPipeline = computed<{ id: string; name: string } | undefined>(() =>
|
|
|
92
92
|
}
|
|
93
93
|
)
|
|
94
94
|
}
|
|
95
|
-
|
|
95
|
+
// No pin: the workspace's own DECLARED in-app default outranks the interface-mode rung, because
|
|
96
|
+
// an operator who named one said something a tier cannot overrule.
|
|
97
|
+
const declared = pipelines.declaredDefaultId('interactive')
|
|
98
|
+
return (
|
|
99
|
+
pipelines.getPipeline(declared ?? defaultBuildPipelineId(uiMode.isAdvanced)) ??
|
|
100
|
+
pipelines.pipelines[0]
|
|
101
|
+
)
|
|
96
102
|
})
|
|
97
103
|
|
|
98
104
|
/** The PR the implementer agent opened for this task, if any. */
|
|
@@ -12,6 +12,7 @@ import { agentKindMeta } from '~/utils/catalog'
|
|
|
12
12
|
import type { JudgeFinding, JudgeStepState } from '~/types/execution'
|
|
13
13
|
import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
|
|
14
14
|
import CopyButton from '~/components/common/CopyButton.vue'
|
|
15
|
+
import MarkdownProse from '~/components/common/MarkdownProse.vue'
|
|
15
16
|
|
|
16
17
|
const board = useBoardStore()
|
|
17
18
|
const execution = useExecutionStore()
|
|
@@ -190,11 +191,14 @@ async function act(choice: 'proceed' | 'bounce' | 'stop') {
|
|
|
190
191
|
{{ judge.note }}
|
|
191
192
|
</p>
|
|
192
193
|
|
|
194
|
+
<!-- The verdict a human reads. Markdown, because the judge prompt asks for a verdict
|
|
195
|
+
line plus grouped bullets rather than one paragraph. -->
|
|
193
196
|
<div v-if="verdict?.summary" class="relative mt-3">
|
|
194
197
|
<CopyButton :text="verdict.summary" class="absolute end-1 top-1" />
|
|
195
|
-
<
|
|
196
|
-
|
|
197
|
-
|
|
198
|
+
<MarkdownProse
|
|
199
|
+
:text="verdict.summary"
|
|
200
|
+
class="pe-8 text-[13px] leading-relaxed text-slate-200"
|
|
201
|
+
/>
|
|
198
202
|
</div>
|
|
199
203
|
|
|
200
204
|
<section v-if="findings.length" class="mt-4">
|
|
@@ -220,12 +224,11 @@ async function act(choice: 'proceed' | 'bounce' | 'stop') {
|
|
|
220
224
|
finding.where
|
|
221
225
|
}}</code>
|
|
222
226
|
</div>
|
|
223
|
-
<
|
|
227
|
+
<MarkdownProse
|
|
224
228
|
v-if="finding.detail"
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
</p>
|
|
229
|
+
:text="finding.detail"
|
|
230
|
+
class="mt-1 text-[12px] leading-relaxed text-slate-400"
|
|
231
|
+
/>
|
|
229
232
|
</li>
|
|
230
233
|
</ul>
|
|
231
234
|
</section>
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// reviewer reported at least one standard; when none were reachable the reviewer says so in its
|
|
7
7
|
// summary instead (so there is nothing to show here).
|
|
8
8
|
import type { FragmentAdherence } from '~/types/execution'
|
|
9
|
+
import MarkdownProse from '~/components/common/MarkdownProse.vue'
|
|
9
10
|
|
|
10
11
|
const props = defineProps<{ items: FragmentAdherence }>()
|
|
11
12
|
const { t } = useI18n()
|
|
@@ -63,12 +64,11 @@ function label(item: FragmentAdherence[number]): string {
|
|
|
63
64
|
{{ t('panels.stepDetail.adherence.outOfTen', { value: item.rating }) }}
|
|
64
65
|
</span>
|
|
65
66
|
</div>
|
|
66
|
-
<
|
|
67
|
+
<MarkdownProse
|
|
67
68
|
v-if="item.assessment"
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
</p>
|
|
69
|
+
:text="item.assessment"
|
|
70
|
+
class="mt-1 text-[12px] leading-relaxed text-slate-300"
|
|
71
|
+
/>
|
|
72
72
|
<div v-if="item.relatedFindings.length" class="mt-1.5">
|
|
73
73
|
<p class="text-[10px] font-semibold uppercase tracking-wide text-slate-500">
|
|
74
74
|
{{ t('panels.stepDetail.adherence.relatedFindings') }}
|
|
@@ -4,6 +4,8 @@ import type { AgentState, PipelineStep, CompanionVerdict, StepApproval } from '~
|
|
|
4
4
|
import { subtaskIconClass } from '~/utils/pipelineRender'
|
|
5
5
|
import StepModelActivity from '~/components/observability/StepModelActivity.vue'
|
|
6
6
|
import StepContainerStatus from '~/components/panels/StepContainerStatus.vue'
|
|
7
|
+
import CopyButton from '~/components/common/CopyButton.vue'
|
|
8
|
+
import MarkdownProse from '~/components/common/MarkdownProse.vue'
|
|
7
9
|
|
|
8
10
|
// The step's metadata card body: state/timing/model/run id, the container cold-boot
|
|
9
11
|
// phase, the live subtask breakdown, the LLM observability rollup, the applied
|
|
@@ -281,22 +283,35 @@ async function copyRunId() {
|
|
|
281
283
|
{{ latestVerdict?.passed ? '≥' : '<' }} {{ pctOf(latestVerdict!.threshold) }}
|
|
282
284
|
</UBadge>
|
|
283
285
|
</div>
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
<
|
|
286
|
+
<!-- One card per correction round: the score on its own line, then the reviewer's
|
|
287
|
+
challenge as rendered markdown. The feedback used to trail the score inside the same
|
|
288
|
+
line, which turned a multi-point review into one unreadable run of text. -->
|
|
289
|
+
<ol class="mt-2 space-y-2">
|
|
290
|
+
<li
|
|
291
|
+
v-for="(v, i) in companionVerdicts"
|
|
292
|
+
:key="i"
|
|
293
|
+
data-testid="companion-verdict"
|
|
294
|
+
class="relative rounded-lg border border-slate-800 bg-slate-900/60 px-3 py-2"
|
|
295
|
+
>
|
|
296
|
+
<CopyButton v-if="v.feedback" :text="v.feedback" class="absolute end-1 top-1" />
|
|
297
|
+
<div class="flex items-center gap-2 text-[12px]">
|
|
298
|
+
<span
|
|
299
|
+
class="inline-flex h-4 shrink-0 items-center rounded px-1 font-mono text-[11px] tabular-nums"
|
|
300
|
+
:class="
|
|
301
|
+
v.passed ? 'bg-emerald-500/15 text-emerald-300' : 'bg-amber-500/15 text-amber-300'
|
|
302
|
+
"
|
|
303
|
+
>
|
|
304
|
+
{{ i + 1 }}
|
|
305
|
+
</span>
|
|
295
306
|
<span :class="v.passed ? 'text-emerald-300' : 'text-amber-300'">
|
|
296
307
|
{{ pctOf(v.rating) }} {{ v.passed ? '≥' : '<' }} {{ pctOf(v.threshold) }}
|
|
297
308
|
</span>
|
|
298
|
-
<span v-if="v.feedback" class="ms-1 text-slate-400">— {{ v.feedback }}</span>
|
|
299
309
|
</div>
|
|
310
|
+
<MarkdownProse
|
|
311
|
+
v-if="v.feedback"
|
|
312
|
+
:text="v.feedback"
|
|
313
|
+
class="mt-1.5 pe-6 text-[12px] leading-relaxed text-slate-300"
|
|
314
|
+
/>
|
|
300
315
|
</li>
|
|
301
316
|
</ol>
|
|
302
317
|
<p v-if="companionVerdicts.length > 1" class="mt-1 text-[11px] text-slate-500">
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import type { RequirementVerdictStatus, TestReport } from '~/types/domain'
|
|
3
3
|
import type { TesterStepState } from '~/types/execution'
|
|
4
4
|
import { resolveVerdictMeta, type VerdictMeta } from './StepTestReport.logic'
|
|
5
|
+
import MarkdownProse from '~/components/common/MarkdownProse.vue'
|
|
5
6
|
|
|
6
7
|
// A tester step's latest structured report (what was tested, the per-area outcomes,
|
|
7
8
|
// the concerns it raised and the greenlight verdict) plus the fixer-loop phase.
|
|
@@ -57,9 +58,11 @@ function verdictMeta(status: RequirementVerdictStatus): VerdictMeta {
|
|
|
57
58
|
}}<span v-if="phase.phase === 'fixing'"> {{ t('panels.testReport.fixing') }}</span>
|
|
58
59
|
</span>
|
|
59
60
|
</div>
|
|
60
|
-
<
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
<MarkdownProse
|
|
62
|
+
v-if="report.summary"
|
|
63
|
+
:text="report.summary"
|
|
64
|
+
class="mb-3 text-[13px] leading-relaxed text-slate-300"
|
|
65
|
+
/>
|
|
63
66
|
|
|
64
67
|
<div v-if="report.tested.length" class="mb-3">
|
|
65
68
|
<div class="mb-1 text-[11px] text-slate-500">{{ t('panels.testReport.tested') }}</div>
|
|
@@ -123,7 +126,7 @@ function verdictMeta(status: RequirementVerdictStatus): VerdictMeta {
|
|
|
123
126
|
>
|
|
124
127
|
<span class="font-medium text-slate-200">{{ c.title }}</span>
|
|
125
128
|
</div>
|
|
126
|
-
<
|
|
129
|
+
<MarkdownProse v-if="c.detail" :text="c.detail" class="mt-1 text-slate-400" />
|
|
127
130
|
</div>
|
|
128
131
|
</div>
|
|
129
132
|
</section>
|
|
@@ -399,45 +399,10 @@ const library = computed(() =>
|
|
|
399
399
|
}),
|
|
400
400
|
)
|
|
401
401
|
const visiblePipelines = computed(() => library.value.offered)
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
} catch {
|
|
407
|
-
toast.add({ title: t('pipeline.builder.toast.updateFailed'), color: 'error' })
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
/** Load a custom pipeline into the draft for in-place editing. */
|
|
412
|
-
function edit(p: Pipeline) {
|
|
413
|
-
pipelines.loadForEdit(p)
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
const { confirm } = useConfirm()
|
|
417
|
-
async function removePipeline(p: Pipeline) {
|
|
418
|
-
const ok = await confirm({
|
|
419
|
-
title: t('pipeline.builder.confirmDeletePipeline.title'),
|
|
420
|
-
description: t('pipeline.builder.confirmDeletePipeline.body', { name: p.name }),
|
|
421
|
-
variant: 'destructive',
|
|
422
|
-
confirmLabel: t('common.delete'),
|
|
423
|
-
icon: 'i-lucide-trash-2',
|
|
424
|
-
})
|
|
425
|
-
if (ok) void pipelines.removePipeline(p.id)
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
/** Clone any pipeline (incl. a read-only built-in) into an editable copy, then edit it. */
|
|
429
|
-
async function clone(p: Pipeline) {
|
|
430
|
-
try {
|
|
431
|
-
const copy = await pipelines.clonePipeline(p.id)
|
|
432
|
-
toast.add({
|
|
433
|
-
title: t('pipeline.builder.toast.cloned', { name: p.name, copy: copy.name }),
|
|
434
|
-
color: 'success',
|
|
435
|
-
icon: 'i-lucide-copy',
|
|
436
|
-
})
|
|
437
|
-
} catch {
|
|
438
|
-
toast.add({ title: t('pipeline.builder.toast.cloneFailed'), color: 'error' })
|
|
439
|
-
}
|
|
440
|
-
}
|
|
402
|
+
// The library ROW's actions (archive, the two scope defaults, edit, clone, delete) — one cohesive
|
|
403
|
+
// group, extracted so this component stays inside its size budget. See
|
|
404
|
+
// `usePipelineLibraryActions` for why they belong together.
|
|
405
|
+
const { toggleArchive, toggleDefault, edit, removePipeline, clone } = usePipelineLibraryActions()
|
|
441
406
|
</script>
|
|
442
407
|
|
|
443
408
|
<template>
|
|
@@ -1260,6 +1225,32 @@ async function clone(p: Pipeline) {
|
|
|
1260
1225
|
>
|
|
1261
1226
|
{{ t('pipeline.builder.defaultBadge') }}
|
|
1262
1227
|
</UBadge>
|
|
1228
|
+
<!-- Which scope this rung is the default for. Shown at BOTH interface tiers even
|
|
1229
|
+
though the controls below are advanced-only: the control is an override, the
|
|
1230
|
+
resulting default is a decision, and a decision nobody can see is the
|
|
1231
|
+
concealed-setting failure. -->
|
|
1232
|
+
<UBadge
|
|
1233
|
+
v-if="p.isDefault"
|
|
1234
|
+
color="primary"
|
|
1235
|
+
variant="subtle"
|
|
1236
|
+
size="xs"
|
|
1237
|
+
class="shrink-0"
|
|
1238
|
+
:title="t('pipeline.builder.scopeDefault.interactiveHint')"
|
|
1239
|
+
data-testid="pipeline-interactive-default"
|
|
1240
|
+
>
|
|
1241
|
+
{{ t('pipeline.builder.scopeDefault.interactive') }}
|
|
1242
|
+
</UBadge>
|
|
1243
|
+
<UBadge
|
|
1244
|
+
v-if="p.isUnattendedDefault"
|
|
1245
|
+
color="info"
|
|
1246
|
+
variant="subtle"
|
|
1247
|
+
size="xs"
|
|
1248
|
+
class="shrink-0"
|
|
1249
|
+
:title="t('pipeline.builder.scopeDefault.unattendedHint')"
|
|
1250
|
+
data-testid="pipeline-unattended-default"
|
|
1251
|
+
>
|
|
1252
|
+
{{ t('pipeline.builder.scopeDefault.unattended') }}
|
|
1253
|
+
</UBadge>
|
|
1263
1254
|
<span class="shrink-0 text-[10px] text-slate-500">
|
|
1264
1255
|
{{
|
|
1265
1256
|
t(
|
|
@@ -1273,6 +1264,38 @@ async function clone(p: Pipeline) {
|
|
|
1273
1264
|
<div
|
|
1274
1265
|
class="flex shrink-0 items-center opacity-0 transition group-hover:opacity-100"
|
|
1275
1266
|
>
|
|
1267
|
+
<!-- The two DEFAULT claims, advanced-tier (see `toggleDefault`). An archived
|
|
1268
|
+
pipeline is not offered either: the backend refuses a hidden row as a
|
|
1269
|
+
default, and a control that can only fail is worse than no control. Safe
|
|
1270
|
+
to hide rather than a way to strand a claim, because the same rule refuses
|
|
1271
|
+
ARCHIVING a row that still holds one: a hidden row never holds a default,
|
|
1272
|
+
so there is never one here to release. -->
|
|
1273
|
+
<template v-if="uiMode.isAdvanced && !p.archived && !p.internal">
|
|
1274
|
+
<UButton
|
|
1275
|
+
:icon="p.isDefault ? 'i-lucide-star' : 'i-lucide-star-off'"
|
|
1276
|
+
:color="p.isDefault ? 'primary' : 'neutral'"
|
|
1277
|
+
variant="ghost"
|
|
1278
|
+
size="xs"
|
|
1279
|
+
:title="
|
|
1280
|
+
p.isDefault
|
|
1281
|
+
? t('pipeline.builder.scopeDefault.releaseInteractive')
|
|
1282
|
+
: t('pipeline.builder.scopeDefault.claimInteractive')
|
|
1283
|
+
"
|
|
1284
|
+
@click="toggleDefault(p, 'interactive')"
|
|
1285
|
+
/>
|
|
1286
|
+
<UButton
|
|
1287
|
+
:icon="p.isUnattendedDefault ? 'i-lucide-bot' : 'i-lucide-bot-off'"
|
|
1288
|
+
:color="p.isUnattendedDefault ? 'info' : 'neutral'"
|
|
1289
|
+
variant="ghost"
|
|
1290
|
+
size="xs"
|
|
1291
|
+
:title="
|
|
1292
|
+
p.isUnattendedDefault
|
|
1293
|
+
? t('pipeline.builder.scopeDefault.releaseUnattended')
|
|
1294
|
+
: t('pipeline.builder.scopeDefault.claimUnattended')
|
|
1295
|
+
"
|
|
1296
|
+
@click="toggleDefault(p, 'unattended')"
|
|
1297
|
+
/>
|
|
1298
|
+
</template>
|
|
1276
1299
|
<!-- Archive/unarchive: organize the library without deleting. Works on
|
|
1277
1300
|
built-ins too (view metadata, not structure). -->
|
|
1278
1301
|
<UButton
|
|
@@ -29,6 +29,7 @@ import { activeChunkLabels, chunkReviewPercent, hasNoSlicePlan } from '~/utils/p
|
|
|
29
29
|
import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
|
|
30
30
|
import StepRunMeta from '~/components/panels/StepRunMeta.vue'
|
|
31
31
|
import StepFragmentAdherence from '~/components/panels/StepFragmentAdherence.vue'
|
|
32
|
+
import MarkdownProse from '~/components/common/MarkdownProse.vue'
|
|
32
33
|
|
|
33
34
|
const execution = useExecutionStore()
|
|
34
35
|
const board = useBoardStore()
|
|
@@ -527,13 +528,13 @@ async function onDismiss(id: string): Promise<void> {
|
|
|
527
528
|
|
|
528
529
|
<!-- The reviewer's overall assessment. Prose, so it takes the reading measure (see the
|
|
529
530
|
shell's `width` prop) — this window is `full`-width. -->
|
|
530
|
-
<
|
|
531
|
+
<div
|
|
531
532
|
v-if="state?.summary"
|
|
532
533
|
class="mb-3 max-w-3xl rounded-md bg-slate-800/50 px-3 py-2 text-[12px] text-slate-300"
|
|
533
534
|
>
|
|
534
|
-
<span class="text-slate-500">{{ t('prReview.summaryLabel') }}</span>
|
|
535
|
-
|
|
536
|
-
</
|
|
535
|
+
<span class="mb-1 block text-slate-500">{{ t('prReview.summaryLabel') }}</span>
|
|
536
|
+
<MarkdownProse :text="state.summary" />
|
|
537
|
+
</div>
|
|
537
538
|
|
|
538
539
|
<!-- Best-practice adherence: per standard folded into the reviewer's prompt, a 1..10
|
|
539
540
|
rating of how well the PR adheres + the issues that standard surfaced. -->
|
|
@@ -680,12 +681,17 @@ async function onDismiss(id: string): Promise<void> {
|
|
|
680
681
|
bucket to `full`, so these are the three paragraphs the width would
|
|
681
682
|
otherwise have stretched furthest; the path/line row, the badges and the
|
|
682
683
|
per-finding actions are what it is actually for. -->
|
|
683
|
-
<
|
|
684
|
-
|
|
684
|
+
<MarkdownProse
|
|
685
|
+
v-if="f.detail"
|
|
686
|
+
:text="f.detail"
|
|
687
|
+
class="mt-1 max-w-3xl text-[12px] text-slate-300"
|
|
685
688
|
:class="isRetracted(f) ? 'line-through' : ''"
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
+
/>
|
|
690
|
+
<!-- The suggested fix is a VALUE a human copies (a patch line, a command, a
|
|
691
|
+
path), not prose, so it stays preformatted: markdown would emphasise the
|
|
692
|
+
`__dunder__` in an identifier, curl the quotes in a command, and drop the
|
|
693
|
+
indentation of anything longer than a line. Same reason the CI gate's
|
|
694
|
+
failure summary is left alone. -->
|
|
689
695
|
<p
|
|
690
696
|
v-if="f.suggestedFix"
|
|
691
697
|
class="mt-1 max-w-3xl whitespace-pre-wrap rounded-md bg-slate-800/50 px-2 py-1 text-[11px] text-slate-300"
|
|
@@ -696,10 +702,10 @@ async function onDismiss(id: string): Promise<void> {
|
|
|
696
702
|
|
|
697
703
|
<!-- The investigator's justification (why the finding holds up / was retracted),
|
|
698
704
|
or the reason the challenge investigation failed. -->
|
|
699
|
-
<
|
|
705
|
+
<div
|
|
700
706
|
v-if="f.challenge?.justification"
|
|
701
707
|
data-testid="pr-review-finding-justification"
|
|
702
|
-
class="mt-1.5 max-w-3xl
|
|
708
|
+
class="mt-1.5 max-w-3xl rounded-md px-2 py-1 text-[11px]"
|
|
703
709
|
:class="
|
|
704
710
|
isRetracted(f)
|
|
705
711
|
? 'bg-rose-500/10 text-rose-200'
|
|
@@ -708,13 +714,16 @@ async function onDismiss(id: string): Promise<void> {
|
|
|
708
714
|
: 'bg-sky-500/10 text-sky-200'
|
|
709
715
|
"
|
|
710
716
|
>
|
|
711
|
-
|
|
717
|
+
<!-- A label that used to prefix its value inline now heads the block the
|
|
718
|
+
rendered prose became, so it needs to READ as a heading line rather than
|
|
719
|
+
as a stray word above a paragraph. -->
|
|
720
|
+
<span class="mb-1 block font-medium">{{
|
|
712
721
|
isChallengeFailed(f)
|
|
713
722
|
? t('prReview.challenge.failedLabel')
|
|
714
723
|
: t('prReview.challenge.verdictLabel')
|
|
715
724
|
}}</span>
|
|
716
|
-
|
|
717
|
-
</
|
|
725
|
+
<MarkdownProse :text="f.challenge.justification" />
|
|
726
|
+
</div>
|
|
718
727
|
|
|
719
728
|
<!-- Per-finding actions: Challenge + Dismiss (only while awaiting a selection). -->
|
|
720
729
|
<div
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
2
|
import {
|
|
3
3
|
findingAttention,
|
|
4
|
+
findingClass,
|
|
4
5
|
orderFindings,
|
|
5
6
|
reconcileFindingOrder,
|
|
6
7
|
type FindingRecommendationState,
|
|
@@ -21,6 +22,7 @@ const item = (
|
|
|
21
22
|
id: string,
|
|
22
23
|
status: RequirementReviewItem['status'],
|
|
23
24
|
severity: RequirementReviewItem['severity'] = 'medium',
|
|
25
|
+
autoAnswerable?: boolean,
|
|
24
26
|
): RequirementReviewItem => ({
|
|
25
27
|
id,
|
|
26
28
|
category: 'question',
|
|
@@ -29,6 +31,7 @@ const item = (
|
|
|
29
31
|
detail: `detail for ${id}`,
|
|
30
32
|
status,
|
|
31
33
|
reply: null,
|
|
34
|
+
...(autoAnswerable === undefined ? {} : { autoAnswerable }),
|
|
32
35
|
createdAt: 0,
|
|
33
36
|
updatedAt: 0,
|
|
34
37
|
})
|
|
@@ -98,11 +101,37 @@ describe('orderFindings', () => {
|
|
|
98
101
|
expect(order(items).map((entry) => entry.id)).toEqual(['low-open', 'high-handled'])
|
|
99
102
|
})
|
|
100
103
|
|
|
101
|
-
it('tags each entry with the
|
|
104
|
+
it('tags each entry with the buckets its position came from', () => {
|
|
102
105
|
const items = [item('a', 'open'), item('b', 'dismissed')]
|
|
103
106
|
expect(order(items)).toEqual([
|
|
104
|
-
{ id: 'a', attention: 'action' },
|
|
105
|
-
{ id: 'b', attention: 'settled' },
|
|
107
|
+
{ id: 'a', attention: 'action', group: 'judgement' },
|
|
108
|
+
{ id: 'b', attention: 'settled', group: 'judgement' },
|
|
109
|
+
])
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
// The GROUP is the primary key, ahead of attention: the two groups have different audiences, so
|
|
113
|
+
// each is its own list ordered by what is left in it, rather than one list interleaving work the
|
|
114
|
+
// reader owns with work the platform may already have answered.
|
|
115
|
+
it('puts the judgement group ahead of the practice group, settled or not', () => {
|
|
116
|
+
const items = [
|
|
117
|
+
item('practice-open', 'open', 'high', true),
|
|
118
|
+
item('judgement-settled', 'answered', 'low', false),
|
|
119
|
+
]
|
|
120
|
+
expect(order(items).map((entry) => entry.id)).toEqual(['judgement-settled', 'practice-open'])
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('keeps attention ordering INSIDE each group', () => {
|
|
124
|
+
const items = [
|
|
125
|
+
item('practice-settled', 'answered', 'high', true),
|
|
126
|
+
item('practice-open', 'open', 'low', true),
|
|
127
|
+
item('judgement-settled', 'answered', 'high', false),
|
|
128
|
+
item('judgement-open', 'open', 'low', false),
|
|
129
|
+
]
|
|
130
|
+
expect(order(items).map((entry) => entry.id)).toEqual([
|
|
131
|
+
'judgement-open',
|
|
132
|
+
'judgement-settled',
|
|
133
|
+
'practice-open',
|
|
134
|
+
'practice-settled',
|
|
106
135
|
])
|
|
107
136
|
})
|
|
108
137
|
|
|
@@ -111,10 +140,23 @@ describe('orderFindings', () => {
|
|
|
111
140
|
})
|
|
112
141
|
})
|
|
113
142
|
|
|
143
|
+
describe('findingClass', () => {
|
|
144
|
+
it('reads the reviewer classification', () => {
|
|
145
|
+
expect(findingClass({ autoAnswerable: true })).toBe('practice')
|
|
146
|
+
expect(findingClass({ autoAnswerable: false })).toBe('judgement')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
// An unclassified finding (a reviewer pass predating the flag, or a garbled reply) lands in the
|
|
150
|
+
// group that asks a person, matching how the contract and the engine both read it.
|
|
151
|
+
it('reads an unclassified finding as needing a person', () => {
|
|
152
|
+
expect(findingClass({})).toBe('judgement')
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
114
156
|
describe('reconcileFindingOrder', () => {
|
|
115
157
|
const desired: OrderedFinding[] = [
|
|
116
|
-
{ id: 'a', attention: 'action' },
|
|
117
|
-
{ id: 'b', attention: 'settled' },
|
|
158
|
+
{ id: 'a', attention: 'action', group: 'judgement' },
|
|
159
|
+
{ id: 'b', attention: 'settled', group: 'judgement' },
|
|
118
160
|
]
|
|
119
161
|
|
|
120
162
|
it('falls back to the computed order when nothing is pinned', () => {
|
|
@@ -124,21 +166,21 @@ describe('reconcileFindingOrder', () => {
|
|
|
124
166
|
it('holds the pinned order while it covers the same findings', () => {
|
|
125
167
|
// `b` has since been answered and would now sink, but the pin keeps the list still.
|
|
126
168
|
const pinned: OrderedFinding[] = [
|
|
127
|
-
{ id: 'b', attention: 'action' },
|
|
128
|
-
{ id: 'a', attention: 'action' },
|
|
169
|
+
{ id: 'b', attention: 'action', group: 'judgement' },
|
|
170
|
+
{ id: 'a', attention: 'action', group: 'judgement' },
|
|
129
171
|
]
|
|
130
172
|
expect(reconcileFindingOrder(desired, pinned)).toBe(pinned)
|
|
131
173
|
})
|
|
132
174
|
|
|
133
175
|
it('drops a pin that no longer covers every finding, so a new one can never be hidden', () => {
|
|
134
|
-
const pinned: OrderedFinding[] = [{ id: 'a', attention: 'action' }]
|
|
176
|
+
const pinned: OrderedFinding[] = [{ id: 'a', attention: 'action', group: 'judgement' }]
|
|
135
177
|
expect(reconcileFindingOrder(desired, pinned)).toEqual(desired)
|
|
136
178
|
})
|
|
137
179
|
|
|
138
180
|
it('drops a pin naming a finding the review no longer has', () => {
|
|
139
181
|
const pinned: OrderedFinding[] = [
|
|
140
|
-
{ id: 'a', attention: 'action' },
|
|
141
|
-
{ id: 'gone', attention: 'action' },
|
|
182
|
+
{ id: 'a', attention: 'action', group: 'judgement' },
|
|
183
|
+
{ id: 'gone', attention: 'action', group: 'judgement' },
|
|
142
184
|
]
|
|
143
185
|
expect(reconcileFindingOrder(desired, pinned)).toEqual(desired)
|
|
144
186
|
})
|
|
@@ -23,6 +23,32 @@ import type { RequirementReviewItem, ReviewItemSeverity } from '~/types/requirem
|
|
|
23
23
|
*/
|
|
24
24
|
export type FindingAttention = 'action' | 'waiting' | 'settled'
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* WHO can answer a finding, which is the reviewer's own `autoAnswerable` judgement read as the two
|
|
28
|
+
* groups it sorts findings into.
|
|
29
|
+
*
|
|
30
|
+
* - `judgement` — it takes a business / product / domain decision, or information the reviewer was
|
|
31
|
+
* not given. Only a person can settle it, and a run nobody is watching parks on it.
|
|
32
|
+
* - `practice` — a confident answer follows from universal best practice, from the idiomatic
|
|
33
|
+
* approach of a stack the work already uses, or from the context already provided. The
|
|
34
|
+
* Requirement Writer pre-answers these, and an unattended run may adopt a sufficiently confident
|
|
35
|
+
* suggestion without waiting (see `reviewSettledForUnattended`).
|
|
36
|
+
*
|
|
37
|
+
* The two are the PRIMARY grouping in the window, ahead of how much attention a finding wants,
|
|
38
|
+
* because they are answered by different people rather than at different times: the value of
|
|
39
|
+
* seeing them apart is knowing which of them is yours.
|
|
40
|
+
*/
|
|
41
|
+
export type FindingClass = 'judgement' | 'practice'
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Which group a finding is in. ABSENT `autoAnswerable` reads as `judgement`, matching the contract's
|
|
45
|
+
* own reading of an unclassified finding (a pass that predates the flag, or a garbled reply): the
|
|
46
|
+
* safe direction is the one that asks a person.
|
|
47
|
+
*/
|
|
48
|
+
export function findingClass(item: Pick<RequirementReviewItem, 'autoAnswerable'>): FindingClass {
|
|
49
|
+
return item.autoAnswerable === true ? 'practice' : 'judgement'
|
|
50
|
+
}
|
|
51
|
+
|
|
26
52
|
/** What the review's recommendation collection currently holds for one finding. */
|
|
27
53
|
export interface FindingRecommendationState {
|
|
28
54
|
/** A requested suggestion is still being generated by the Writer. */
|
|
@@ -31,13 +57,17 @@ export interface FindingRecommendationState {
|
|
|
31
57
|
ready: boolean
|
|
32
58
|
}
|
|
33
59
|
|
|
34
|
-
/** One finding in the rendered order, tagged with the
|
|
60
|
+
/** One finding in the rendered order, tagged with the buckets its position came from. */
|
|
35
61
|
export interface OrderedFinding {
|
|
36
62
|
id: string
|
|
37
63
|
attention: FindingAttention
|
|
64
|
+
group: FindingClass
|
|
38
65
|
}
|
|
39
66
|
|
|
40
67
|
const ATTENTION_RANK: Record<FindingAttention, number> = { action: 0, waiting: 1, settled: 2 }
|
|
68
|
+
// `judgement` first: it is the group that always needs the person reading this, whatever state its
|
|
69
|
+
// findings are in, and the practice group is the one the platform may have already answered.
|
|
70
|
+
const CLASS_RANK: Record<FindingClass, number> = { judgement: 0, practice: 1 }
|
|
41
71
|
const SEVERITY_RANK: Record<ReviewItemSeverity, number> = { high: 0, medium: 1, low: 2 }
|
|
42
72
|
|
|
43
73
|
/**
|
|
@@ -60,10 +90,16 @@ export function findingAttention(
|
|
|
60
90
|
}
|
|
61
91
|
|
|
62
92
|
/**
|
|
63
|
-
* The findings in the order the window should render them:
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* the
|
|
93
|
+
* The findings in the order the window should render them: the two GROUPS first (the ones only a
|
|
94
|
+
* person can decide, then the ones practice can answer), and within each of them unreacted-to
|
|
95
|
+
* first, then whatever the Writer is still working on, then everything already handled. Severity
|
|
96
|
+
* stays the next key (the order the window has always used within a bucket), and the reviewer's own
|
|
97
|
+
* ordering breaks the remaining ties so the sort is total and stable.
|
|
98
|
+
*
|
|
99
|
+
* Group ahead of attention, which is a change of primary key rather than an extra tiebreak: a
|
|
100
|
+
* settled judgement finding now sorts above an open practice one. That is the intended reading —
|
|
101
|
+
* the groups are two lists with different audiences, and each is internally ordered by what is
|
|
102
|
+
* left to do in it, rather than one list interleaving work the reader owns with work they do not.
|
|
67
103
|
*/
|
|
68
104
|
export function orderFindings(
|
|
69
105
|
items: readonly RequirementReviewItem[],
|
|
@@ -73,16 +109,18 @@ export function orderFindings(
|
|
|
73
109
|
.map((item, index) => ({
|
|
74
110
|
id: item.id,
|
|
75
111
|
attention: findingAttention(item, recommendationFor(item)),
|
|
112
|
+
group: findingClass(item),
|
|
76
113
|
severity: item.severity,
|
|
77
114
|
index,
|
|
78
115
|
}))
|
|
79
116
|
.sort(
|
|
80
117
|
(a, b) =>
|
|
118
|
+
CLASS_RANK[a.group] - CLASS_RANK[b.group] ||
|
|
81
119
|
ATTENTION_RANK[a.attention] - ATTENTION_RANK[b.attention] ||
|
|
82
120
|
SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] ||
|
|
83
121
|
a.index - b.index,
|
|
84
122
|
)
|
|
85
|
-
.map(({ id, attention }) => ({ id, attention }))
|
|
123
|
+
.map(({ id, attention, group }) => ({ id, attention, group }))
|
|
86
124
|
}
|
|
87
125
|
|
|
88
126
|
/**
|