@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.
@@ -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
- return pipelines.getPipeline(defaultBuildPipelineId(uiMode.isAdvanced)) ?? pipelines.pipelines[0]
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
- <p class="whitespace-pre-wrap pe-8 text-[13px] leading-relaxed text-slate-200">
196
- {{ verdict.summary }}
197
- </p>
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
- <p
227
+ <MarkdownProse
224
228
  v-if="finding.detail"
225
- class="mt-1 whitespace-pre-wrap text-[12px] leading-relaxed text-slate-400"
226
- >
227
- {{ finding.detail }}
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
- <p
67
+ <MarkdownProse
67
68
  v-if="item.assessment"
68
- class="mt-1 whitespace-pre-wrap text-[12px] leading-relaxed text-slate-300"
69
- >
70
- {{ item.assessment }}
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
- <ol class="mt-2 space-y-1.5">
285
- <li v-for="(v, i) in companionVerdicts" :key="i" class="flex items-start gap-2 text-[12px]">
286
- <span
287
- class="mt-px inline-flex h-4 shrink-0 items-center rounded px-1 font-mono text-[11px] tabular-nums"
288
- :class="
289
- v.passed ? 'bg-emerald-500/15 text-emerald-300' : 'bg-amber-500/15 text-amber-300'
290
- "
291
- >
292
- {{ i + 1 }}
293
- </span>
294
- <div class="min-w-0">
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
- <p v-if="report.summary" class="mb-3 text-[13px] leading-relaxed text-slate-300">
61
- {{ report.summary }}
62
- </p>
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
- <p v-if="c.detail" class="mt-1 text-slate-400">{{ c.detail }}</p>
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
- async function toggleArchive(p: Pipeline) {
403
- try {
404
- if (p.archived) await pipelines.unarchive(p.id)
405
- else await pipelines.archive(p.id)
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
- <p
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
- {{ state.summary }}
536
- </p>
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
- <p
684
- class="mt-1 max-w-3xl whitespace-pre-wrap text-[12px] text-slate-300"
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
- {{ f.detail }}
688
- </p>
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
- <p
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 whitespace-pre-wrap rounded-md px-2 py-1 text-[11px]"
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
- <span class="font-medium">{{
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
- {{ f.challenge.justification }}
717
- </p>
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 bucket its position came from', () => {
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 bucket its position came from. */
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: unreacted-to first, then whatever the
64
- * Writer is still working on, then everything already handled. Severity stays the secondary key
65
- * (the order the window has always used within a bucket), and the reviewer's own ordering breaks
66
- * the remaining ties so the sort is total and stable.
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
  /**