@cat-factory/app 0.280.2 → 0.282.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.
@@ -28,6 +28,7 @@ import type { AppSlots, ResultViewContribution } from '~/modular/slots'
28
28
  import ContextAttachmentFields from '~/components/context/ContextAttachmentFields.vue'
29
29
  import DescriptorFields from '~/components/common/DescriptorFields.vue'
30
30
  import FragmentSelector from '~/components/fragments/FragmentSelector.vue'
31
+ import ReviewSkillQueue from '~/components/skills/ReviewSkillQueue.vue'
31
32
  import RiskPolicyPicker from '~/components/riskPolicy/RiskPolicyPicker.vue'
32
33
  import { parseConflict } from '~/composables/usePipelineErrorToast'
33
34
  import { apiErrorEnvelope } from '~/composables/api/errors'
@@ -143,6 +144,9 @@ const docOutlineHints = ref('')
143
144
  // review focus. The single input is parsed into the contract's `prUrl`/`prNumber` fields.
144
145
  const reviewPrRef = ref('')
145
146
  const reviewFocus = ref('')
147
+ // Specialist review playbooks queued onto the review, in the order the reviewer applies them.
148
+ // Offered from the account catalog's `review` group only (see ReviewSkillQueue).
149
+ const reviewSkillIds = ref<string[]>([])
146
150
 
147
151
  // Best-practice prompt fragments the user pins on the task up front (folded into its agents
148
152
  // on top of the service-level standards, exactly like the inspector's picker). Chosen from the
@@ -320,6 +324,7 @@ function buildTypeFields(): TaskTypeFields | undefined {
320
324
  if (taskType.value === 'review') {
321
325
  const f: TaskTypeFields = { ...parseReviewPrRef(reviewPrRef.value) }
322
326
  if (reviewFocus.value.trim()) f.reviewFocus = reviewFocus.value.trim()
327
+ if (reviewSkillIds.value.length) f.reviewSkillIds = [...reviewSkillIds.value]
323
328
  return Object.keys(f).length ? f : undefined
324
329
  }
325
330
  return buildCustomTypeFields()
@@ -569,6 +574,7 @@ watch(open, (isOpen) => {
569
574
  docOutlineHints.value = ''
570
575
  reviewPrRef.value = ''
571
576
  reviewFocus.value = ''
577
+ reviewSkillIds.value = []
572
578
  // Empty rather than default-seeded: `taskType` was just reset to a BUILT-IN above, which
573
579
  // declares no descriptor fields. Picking a custom type from here runs the `taskType` watcher,
574
580
  // and that is the one place the new type's declared defaults are seeded.
@@ -1170,6 +1176,10 @@ function openReviewFrictionDialog(conflict: NonNullable<ReturnType<typeof parseC
1170
1176
  class="w-full"
1171
1177
  />
1172
1178
  </UFormField>
1179
+ <!-- The team's specialist review playbooks, applied on top of the reviewer's standing
1180
+ role. Always shown (not advanced-only): queueing a security or performance pass is
1181
+ a per-review judgement a reviewer makes, not a platform setting. -->
1182
+ <ReviewSkillQueue v-model="reviewSkillIds" />
1173
1183
  </div>
1174
1184
 
1175
1185
  <!-- A CUSTOM (deployment-registered) task type: a bespoke create-form section when its
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { reviewQueueDirty, reviewSkillQueuePatch } from './TaskReviewTarget.logic'
3
+ import type { TaskTypeFields } from '~/types/domain'
4
+
5
+ const stored: TaskTypeFields = {
6
+ prNumber: 42,
7
+ prUrl: 'https://github.com/acme/app/pull/42',
8
+ reviewFocus: 'the auth changes',
9
+ reviewSkillIds: ['src:s:security', 'src:s:perf'],
10
+ custom: { ticket: 'OPS-1' },
11
+ }
12
+
13
+ describe('reviewSkillQueuePatch', () => {
14
+ it('carries every other built-in key, so editing the queue cannot clear the target PR', () => {
15
+ // The write replaces the built-in half whole. A key left out of this payload is a key erased.
16
+ const patch = reviewSkillQueuePatch(stored, ['src:s:perf'])
17
+ expect(patch.prNumber).toBe(42)
18
+ expect(patch.prUrl).toBe(stored.prUrl)
19
+ expect(patch.reviewFocus).toBe(stored.reviewFocus)
20
+ expect(patch.reviewSkillIds).toEqual(['src:s:perf'])
21
+ })
22
+
23
+ it('CLEARS the queue when the last skill is removed', () => {
24
+ // The case that un-wedges a task whose queued skill left the catalog: every dispatch fails on
25
+ // that id, and removing it is the fix. Carrying the stored ids through would make this a
26
+ // silent no-op, which reads to the user as "the platform ignored me".
27
+ const patch = reviewSkillQueuePatch(stored, [])
28
+ expect(patch).not.toHaveProperty('reviewSkillIds')
29
+ expect(patch.prNumber).toBe(42)
30
+ })
31
+
32
+ it('never sends the custom half, which travels under its own request key', () => {
33
+ expect(reviewSkillQueuePatch(stored, ['src:s:security'])).not.toHaveProperty('custom')
34
+ })
35
+
36
+ it('starts a queue on a task that stored no fields at all', () => {
37
+ expect(reviewSkillQueuePatch(null, ['src:s:security'])).toEqual({
38
+ reviewSkillIds: ['src:s:security'],
39
+ })
40
+ expect(reviewSkillQueuePatch(undefined, [])).toEqual({})
41
+ })
42
+ })
43
+
44
+ describe('reviewQueueDirty', () => {
45
+ it('sees a REORDER, because the reviewer applies the queue in order', () => {
46
+ expect(reviewQueueDirty(['a', 'b'], ['b', 'a'])).toBe(true)
47
+ expect(reviewQueueDirty(['a', 'b'], ['a', 'b'])).toBe(false)
48
+ })
49
+
50
+ it('sees an addition, a removal, and an emptied queue', () => {
51
+ expect(reviewQueueDirty(['a'], ['a', 'b'])).toBe(true)
52
+ expect(reviewQueueDirty(['a', 'b'], ['a'])).toBe(true)
53
+ expect(reviewQueueDirty(['a'], [])).toBe(true)
54
+ expect(reviewQueueDirty([], [])).toBe(false)
55
+ })
56
+ })
@@ -0,0 +1,32 @@
1
+ import type { TaskTypeFields } from '~/types/domain'
2
+
3
+ // The pure half of TaskReviewTarget: what an edit of the review-skill queue SENDS. Extracted for
4
+ // the reason every `*.logic.ts` here is (a decision worth a test should not need a mounted
5
+ // component to reach), and this one carries a rule the shape of the request makes easy to get
6
+ // wrong in exactly one direction.
7
+
8
+ /**
9
+ * The `builtinTaskTypeFields` payload that stores `queue` as this task's review-skill queue.
10
+ *
11
+ * The built-in half is replaced WHOLE by the write (`replaceBuiltinHalf`), so every other built-in
12
+ * key has to be carried or editing the queue would clear the pull request the task reviews. The
13
+ * `custom` half travels under its own request key and is dropped here by construction.
14
+ *
15
+ * The stored queue is dropped from that carry-through FIRST, and that is the whole point: an
16
+ * EMPTY queue is expressed by the key being absent, so spreading the stored bag and then adding
17
+ * the key back only when non-empty would carry the old ids on the one edit that most needs to
18
+ * land. Removing the last queued skill is how a task wedged by a skill that left the catalog gets
19
+ * un-wedged, so it is precisely the case that must not silently no-op.
20
+ */
21
+ export function reviewSkillQueuePatch(
22
+ stored: TaskTypeFields | null | undefined,
23
+ queue: readonly string[],
24
+ ): TaskTypeFields {
25
+ const { custom: _custom, reviewSkillIds: _stored, ...builtin } = stored ?? {}
26
+ return { ...builtin, ...(queue.length ? { reviewSkillIds: [...queue] } : {}) }
27
+ }
28
+
29
+ /** Whether the edit buffer differs from the stored queue, ORDER included (the queue is ordered). */
30
+ export function reviewQueueDirty(stored: readonly string[], draft: readonly string[]): boolean {
31
+ return stored.length !== draft.length || stored.some((id, i) => draft[i] !== id)
32
+ }
@@ -9,11 +9,23 @@
9
9
  // provider's own link (`prUrl`), which is what makes a plain read enough here; a task created
10
10
  // while no VCS was connected keeps only the number, and then the reference reads as text rather
11
11
  // than pretending to be a link.
12
- import { computed } from 'vue'
12
+ //
13
+ // The SKILL QUEUE is editable here, and that is not a convenience. A queued skill that has left
14
+ // the catalog FAILS every dispatch of this task, and the refusal's remedy names the task's own
15
+ // queue as where the fix is made; with the queue frozen at creation that remedy pointed at
16
+ // nothing and the only exit was deleting a task whose id every stored reference holds. Same
17
+ // reasoning as `TaskTypeFields`, which exists for the same shape of dead end.
18
+ import { computed, ref, watch } from 'vue'
13
19
  import type { Block } from '~/types/domain'
14
20
  import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
21
+ import ReviewSkillQueue from '~/components/skills/ReviewSkillQueue.vue'
22
+ import {
23
+ reviewQueueDirty,
24
+ reviewSkillQueuePatch,
25
+ } from '~/components/panels/inspector/TaskReviewTarget.logic'
15
26
 
16
27
  const props = defineProps<{ block: Block }>()
28
+ const board = useBoardStore()
17
29
  const { t } = useI18n()
18
30
 
19
31
  const isReview = computed(() => props.block.taskType === 'review')
@@ -27,13 +39,46 @@ const label = computed(() => {
27
39
  return number ? t('inspector.reviewTarget.prNumber', { number }) : url.value
28
40
  })
29
41
 
30
- /** Nothing to show when the task carries no reference at all (nothing to link or name). */
31
- const hasTarget = computed(() => Boolean(label.value))
42
+ /** The queue as STORED, the baseline the edit buffer is seeded from and compared against. */
43
+ const stored = computed<string[]>(() => fields.value?.reviewSkillIds ?? [])
44
+
45
+ // Local edit buffer, re-seeded whenever the stored queue changes underneath (a live board push,
46
+ // or switching blocks). Editing writes on commit rather than per pick, so a half-built queue
47
+ // never reaches the row a dispatch reads.
48
+ const draft = ref<string[]>([...stored.value])
49
+ watch(stored, (next) => {
50
+ draft.value = [...next]
51
+ })
52
+
53
+ const dirty = computed(() => reviewQueueDirty(stored.value, draft.value))
54
+ const saving = ref(false)
55
+
56
+ /**
57
+ * Write the queue through the BUILT-IN half of the per-type bag, which REPLACES that half whole.
58
+ * What that means for the payload (carry the other built-in keys; express an EMPTY queue by the
59
+ * key's absence rather than by an empty array riding a stored one) is
60
+ * {@link reviewSkillQueuePatch}, which is where it is tested.
61
+ */
62
+ async function save() {
63
+ if (!dirty.value) return
64
+ saving.value = true
65
+ try {
66
+ await board.updateBlock(props.block.id, {
67
+ builtinTaskTypeFields: reviewSkillQueuePatch(props.block.taskTypeFields, draft.value),
68
+ })
69
+ } finally {
70
+ saving.value = false
71
+ }
72
+ }
73
+
74
+ function revert() {
75
+ draft.value = [...stored.value]
76
+ }
32
77
  </script>
33
78
 
34
79
  <template>
35
80
  <InspectorSection
36
- v-if="isReview && hasTarget"
81
+ v-if="isReview"
37
82
  :title="t('inspector.reviewTarget.title')"
38
83
  :hint="t('inspector.reviewTarget.hint')"
39
84
  icon="i-lucide-git-pull-request-arrow"
@@ -56,7 +101,7 @@ const hasTarget = computed(() => Boolean(label.value))
56
101
  <span class="w-full truncate text-start" :title="url">{{ label }}</span>
57
102
  </UButton>
58
103
  <p
59
- v-else
104
+ v-else-if="label"
60
105
  class="rounded-lg border border-slate-800 bg-slate-900/40 p-2.5 text-xs text-slate-300"
61
106
  data-testid="inspector-review-target-link"
62
107
  >
@@ -65,5 +110,29 @@ const hasTarget = computed(() => Boolean(label.value))
65
110
  <p v-if="focus" class="text-xs leading-relaxed text-slate-500">
66
111
  {{ t('inspector.reviewTarget.focus', { focus }) }}
67
112
  </p>
113
+ <div data-testid="inspector-review-skills">
114
+ <ReviewSkillQueue v-model="draft" />
115
+ <div v-if="dirty" class="mt-2 flex items-center gap-2">
116
+ <UButton
117
+ size="xs"
118
+ color="primary"
119
+ variant="soft"
120
+ :loading="saving"
121
+ data-testid="inspector-review-skills-save"
122
+ @click="save"
123
+ >
124
+ {{ t('skills.reviewQueue.save') }}
125
+ </UButton>
126
+ <UButton
127
+ size="xs"
128
+ color="neutral"
129
+ variant="ghost"
130
+ data-testid="inspector-review-skills-revert"
131
+ @click="revert"
132
+ >
133
+ {{ t('skills.reviewQueue.revert') }}
134
+ </UButton>
135
+ </div>
136
+ </div>
68
137
  </InspectorSection>
69
138
  </template>
@@ -82,6 +82,8 @@ const STATUS_UI: Record<
82
82
  auth_failed: { color: 'error', icon: 'i-lucide-key-round' },
83
83
  forbidden: { color: 'error', icon: 'i-lucide-shield-x' },
84
84
  unreachable: { color: 'error', icon: 'i-lucide-wifi-off' },
85
+ // Warning rather than error: nothing about the connection is broken, and the fix is to wait.
86
+ rate_limited: { color: 'warning', icon: 'i-lucide-hourglass' },
85
87
  error: { color: 'error', icon: 'i-lucide-triangle-alert' },
86
88
  }
87
89
  </script>
@@ -0,0 +1,156 @@
1
+ <script setup lang="ts">
2
+ // The specialist review playbooks a REVIEW task queues onto its run: a Performance Review, a
3
+ // Security Review, whatever the team authored. Stored on the task as `taskTypeFields
4
+ // .reviewSkillIds` and resolved per dispatch onto the reviewer's own skills.
5
+ //
6
+ // Only the catalog's `review` group is offered. A skill declares what kind of work it does, so a
7
+ // scaffolding playbook has no business in a picker whose job is to add review lenses; the store's
8
+ // `reviewSkills` owns that filter and this component never re-derives it.
9
+ //
10
+ // ORDER is the point, which is why the selection renders as numbered badges rather than as a set:
11
+ // the reviewer applies the queue in the order it was picked, so the person picking has to be able
12
+ // to see (and change) that order. Clicking a badge removes it; picking again appends.
13
+ //
14
+ // Presentational and `v-model`-driven, like `FragmentSelector`: the caller owns where the list
15
+ // lives and when it commits, so the create form and the inspector's editor bind the same
16
+ // component and cannot offer a person two different pickers for one field.
17
+ import { computed, ref } from 'vue'
18
+ import { MAX_REVIEW_SKILLS } from '@cat-factory/contracts'
19
+ import { useSkillsStore } from '~/stores/skills'
20
+
21
+ const props = defineProps<{
22
+ /** The queued skill ids, in the order the reviewer applies them (`v-model`). */
23
+ modelValue: string[]
24
+ }>()
25
+ const emit = defineEmits<{ 'update:modelValue': [string[]] }>()
26
+
27
+ const skills = useSkillsStore()
28
+ const { t } = useI18n()
29
+
30
+ const open = ref(false)
31
+
32
+ const offered = computed(() => skills.reviewSkills)
33
+ const selectedSet = computed(() => new Set(props.modelValue))
34
+ /**
35
+ * Queued skills in QUEUE order (not catalog order), so the badges read as the run's sequence.
36
+ *
37
+ * NAMED from the whole catalog while only the `review` group is OFFERED: a skill regrouped after
38
+ * it was queued is still queued, and showing it by name is what lets someone recognise the entry
39
+ * they now have to drop. Only an id the catalog cannot resolve at all falls back to the raw id,
40
+ * which is exactly the entry that will fail the next dispatch.
41
+ */
42
+ const queued = computed(() =>
43
+ props.modelValue.map(
44
+ (id) => skills.catalog.find((s) => s.id === id) ?? { id, name: id, description: '' },
45
+ ),
46
+ )
47
+ /** At the cap, unpicked rows stop being offered: the reviewer carries every queued skill's text. */
48
+ const atCap = computed(() => props.modelValue.length >= MAX_REVIEW_SKILLS)
49
+
50
+ function toggle(id: string) {
51
+ if (selectedSet.value.has(id)) {
52
+ emit(
53
+ 'update:modelValue',
54
+ props.modelValue.filter((x) => x !== id),
55
+ )
56
+ return
57
+ }
58
+ if (atCap.value) return
59
+ emit('update:modelValue', [...props.modelValue, id])
60
+ }
61
+ </script>
62
+
63
+ <template>
64
+ <div>
65
+ <div class="mb-1 flex items-center justify-between gap-2">
66
+ <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
67
+ {{ t('skills.reviewQueue.label') }}
68
+ </span>
69
+ <UPopover v-model:open="open" :content="{ align: 'end' }">
70
+ <UButton
71
+ size="xs"
72
+ variant="ghost"
73
+ color="neutral"
74
+ icon="i-lucide-plus"
75
+ trailing-icon="i-lucide-chevron-down"
76
+ data-testid="review-skill-add"
77
+ />
78
+
79
+ <template #content>
80
+ <div
81
+ class="flex max-h-[24rem] w-[min(24rem,92vw)] flex-col"
82
+ data-testid="review-skill-picker-panel"
83
+ >
84
+ <div class="min-h-0 flex-1 overflow-y-auto p-1">
85
+ <template v-if="offered.length">
86
+ <button
87
+ v-for="s in offered"
88
+ :key="s.id"
89
+ type="button"
90
+ class="flex w-full items-start gap-2 rounded px-2 py-1.5 text-start text-sm hover:bg-slate-800/60 disabled:cursor-not-allowed disabled:opacity-40"
91
+ :class="selectedSet.has(s.id) ? 'text-slate-100' : 'text-slate-300'"
92
+ :disabled="atCap && !selectedSet.has(s.id)"
93
+ :aria-pressed="selectedSet.has(s.id)"
94
+ :data-testid="`review-skill-option-${s.id}`"
95
+ @click="toggle(s.id)"
96
+ >
97
+ <UIcon
98
+ :name="selectedSet.has(s.id) ? 'i-lucide-check' : 'i-lucide-plus'"
99
+ class="mt-0.5 h-4 w-4 shrink-0"
100
+ :class="selectedSet.has(s.id) ? 'text-primary-400' : 'text-slate-500'"
101
+ />
102
+ <span class="min-w-0 flex-1">
103
+ <span class="block truncate">{{ s.name }}</span>
104
+ <span class="block truncate text-[11px] text-slate-500">
105
+ {{ s.description }}
106
+ </span>
107
+ </span>
108
+ </button>
109
+ </template>
110
+ <p v-else class="px-2 py-3 text-[12px] text-slate-500">
111
+ {{ t('skills.reviewQueue.pickerEmpty') }}
112
+ </p>
113
+ </div>
114
+
115
+ <p
116
+ v-if="atCap"
117
+ class="border-t border-slate-800 px-2 py-1.5 text-[11px] text-amber-400"
118
+ >
119
+ {{ t('skills.reviewQueue.capped', { max: MAX_REVIEW_SKILLS }) }}
120
+ </p>
121
+ <div class="flex justify-end border-t border-slate-800 p-1.5">
122
+ <UButton
123
+ size="xs"
124
+ color="neutral"
125
+ variant="soft"
126
+ data-testid="review-skill-picker-done"
127
+ @click="open = false"
128
+ >
129
+ {{ t('skills.reviewQueue.done') }}
130
+ </UButton>
131
+ </div>
132
+ </div>
133
+ </template>
134
+ </UPopover>
135
+ </div>
136
+ <div v-if="queued.length" class="flex flex-wrap gap-1">
137
+ <UBadge
138
+ v-for="(s, i) in queued"
139
+ :key="s.id"
140
+ color="primary"
141
+ variant="subtle"
142
+ size="sm"
143
+ class="cursor-pointer"
144
+ :title="s.description"
145
+ data-testid="review-skill-badge"
146
+ @click="toggle(s.id)"
147
+ >
148
+ <span class="me-1 tabular-nums text-slate-400">{{ i + 1 }}</span>
149
+ {{ s.name }}<UIcon name="i-lucide-x" class="ms-0.5 h-3 w-3" />
150
+ </UBadge>
151
+ </div>
152
+ <p v-else class="text-[11px] text-slate-500">
153
+ {{ t('skills.reviewQueue.hint') }}
154
+ </p>
155
+ </div>
156
+ </template>
@@ -8,6 +8,7 @@
8
8
  import { computed, reactive, ref, watch } from 'vue'
9
9
  import type { GitHubAvailableRepo } from '~/types/domain'
10
10
  import { useSkillLibrary } from '~/stores/skillLibrary'
11
+ import { SKILL_GROUP_LABEL_KEYS } from '~/utils/skills'
11
12
  import GitHubRepoSearchSelect from '~/components/github/GitHubRepoSearchSelect.vue'
12
13
  import RepoTreeBrowser from '~/components/github/RepoTreeBrowser.vue'
13
14
 
@@ -176,8 +177,19 @@ async function unlinkSource(id: string) {
176
177
  >
177
178
  <UIcon name="i-lucide-book-open-check" class="mt-0.5 h-4 w-4 shrink-0 text-sky-400" />
178
179
  <div class="min-w-0 flex-1">
179
- <p class="truncate text-sm font-medium text-slate-100">{{ s.name }}</p>
180
+ <div class="flex items-center gap-2">
181
+ <p class="truncate text-sm font-medium text-slate-100">{{ s.name }}</p>
182
+ <UBadge color="neutral" variant="subtle" size="sm" class="shrink-0">
183
+ {{ t(SKILL_GROUP_LABEL_KEYS[s.group]) }}
184
+ </UBadge>
185
+ </div>
180
186
  <p class="text-xs text-slate-400">{{ s.description }}</p>
187
+ <!-- The manifest declared a group this build does not know (a typo, or a member
188
+ retired since the sync). It is filed under Other, and saying which value was
189
+ declared is what lets the author fix their frontmatter. -->
190
+ <p v-if="s.declaredGroup" class="mt-1 text-[11px] text-amber-400">
191
+ {{ t('skills.catalog.groupUnknown', { group: s.declaredGroup }) }}
192
+ </p>
181
193
  <p class="mt-1 flex flex-wrap gap-x-3 text-[11px] text-slate-500">
182
194
  <span v-if="s.resources.length">
183
195
  {{ t('skills.catalog.resources', { count: s.resources.length }) }}
@@ -102,6 +102,14 @@ const CONFLICT_INFO: Record<Exclude<ConflictReason, BespokeConflictReason>, Conf
102
102
  titleKey: 'errors.conflict.title.risk_policy_not_inherited',
103
103
  descriptionKey: 'errors.conflict.description.risk_policy_not_inherited',
104
104
  },
105
+ // Raised only by the public `/api/v1/kaizen/entries/:id/acknowledge` route today, so no SPA
106
+ // action reaches it. Mapped all the same, because the map is exhaustive over the wire
107
+ // vocabulary rather than over the subset this app happens to trigger: the day a Kaizen screen
108
+ // grows an acknowledge button, the copy is already here rather than an untranslated fallback.
109
+ kaizen_entry_not_settled: {
110
+ titleKey: 'errors.conflict.title.kaizen_entry_not_settled',
111
+ descriptionKey: 'errors.conflict.description.kaizen_entry_not_settled',
112
+ },
105
113
  task_limit_reached: {
106
114
  titleKey: 'errors.conflict.title.task_limit_reached',
107
115
  descriptionKey: 'errors.conflict.description.task_limit_reached',
@@ -20,6 +20,9 @@ function grading(over: Partial<KaizenGrading> = {}): KaizenGrading {
20
20
  recommendations: [],
21
21
  graderModel: null,
22
22
  error: null,
23
+ acknowledgedAt: null,
24
+ acknowledgedBy: null,
25
+ acknowledgementNote: null,
23
26
  createdAt: 1,
24
27
  updatedAt: 1,
25
28
  ...over,
@@ -49,7 +49,12 @@ function skillLibrarySetup(resolveAccountId: () => string | null) {
49
49
  /** Mirror the full catalog into the snapshot picker store as lightweight summaries. */
50
50
  function syncPicker() {
51
51
  useSkillsStore().hydrate(
52
- catalog.value.map((s) => ({ id: s.id, name: s.name, description: s.description })),
52
+ catalog.value.map((s) => ({
53
+ id: s.id,
54
+ name: s.name,
55
+ description: s.description,
56
+ group: s.group,
57
+ })),
53
58
  )
54
59
  }
55
60
 
@@ -3,7 +3,12 @@ import { useSkillsStore } from '~/stores/skills'
3
3
  import { usePipelinesStore } from '~/stores/pipelines'
4
4
  import type { SkillSummary } from '~/types/domain'
5
5
 
6
- const summary = (id: string, name = id): SkillSummary => ({ id, name, description: `${name} desc` })
6
+ const summary = (id: string, name = id, group: SkillSummary['group'] = 'other'): SkillSummary => ({
7
+ id,
8
+ name,
9
+ description: `${name} desc`,
10
+ group,
11
+ })
7
12
 
8
13
  /**
9
14
  * The skill picker spans two stores: the snapshot-hydrated `useSkillsStore` (the catalog the
@@ -28,6 +33,27 @@ describe('skills picker store — snapshot-hydrated catalog', () => {
28
33
  })
29
34
  })
30
35
 
36
+ describe('skills store — the review task’s queue offers only review skills', () => {
37
+ it('offers the review group in catalog order and nothing else', () => {
38
+ const skills = useSkillsStore()
39
+ skills.hydrate([
40
+ summary('sk_build', 'Scaffold a service', 'build'),
41
+ summary('sk_sec', 'Security review', 'review'),
42
+ summary('sk_perf', 'Performance review', 'review'),
43
+ summary('sk_loose', 'Uncategorised', 'other'),
44
+ ])
45
+ expect(skills.reviewSkills.map((s) => s.id)).toEqual(['sk_sec', 'sk_perf'])
46
+ })
47
+
48
+ it('offers nothing when the account has authored no review skills', () => {
49
+ // The ordinary state for an account whose catalog is all build playbooks. The picker renders
50
+ // its empty note from this; it must not fall back to offering the whole catalog.
51
+ const skills = useSkillsStore()
52
+ skills.hydrate([summary('sk_build', 'Scaffold a service', 'build')])
53
+ expect(skills.reviewSkills).toEqual([])
54
+ })
55
+ })
56
+
31
57
  describe('pipelines store — per-step skill picker helpers', () => {
32
58
  it('sets, reads and clears a draft skill step’s skillId', () => {
33
59
  const pipelines = usePipelinesStore()
@@ -1,12 +1,14 @@
1
1
  import { defineStore } from 'pinia'
2
- import { ref } from 'vue'
2
+ import { computed, ref } from 'vue'
3
3
  import type { SkillSummary } from '~/types/domain'
4
+ import { skillsInGroup } from '~/utils/skills'
4
5
 
5
6
  /**
6
7
  * The account's repo-sourced Claude Skills catalog (ADR 0024 slice 3),
7
8
  * hydrated from the workspace snapshot as lightweight `{ id, name, description }` summaries.
8
- * Drives the pipeline builder's per-step skill picker: a `skill` step binds its
9
- * `stepOptions.skillId` to one of these. Skills live in ONE tier (the account, shared across its
9
+ * Drives the pipeline builder's per-step skill picker (a `skill` step binds its
10
+ * `stepOptions.skillId` to one of these) and the review task's skill queue, which offers the
11
+ * `review` slice of the same catalog. Skills live in ONE tier (the account, shared across its
10
12
  * workspaces), so a snapshot hydrate is a straight replace — no per-board reset needed. The
11
13
  * account-settings management surface owns the full catalog + sources; it pushes its updated
12
14
  * summaries back here after a sync so the picker stays in step without a board reload.
@@ -14,9 +16,17 @@ import type { SkillSummary } from '~/types/domain'
14
16
  export const useSkillsStore = defineStore('skills', () => {
15
17
  const catalog = ref<SkillSummary[]>([])
16
18
 
19
+ /**
20
+ * The skills a REVIEW task may queue: the catalog's `review` group and nothing else. A skill is
21
+ * offered by what its manifest says it does, so a build or release-notes playbook never reaches
22
+ * a picker whose whole job is to add review lenses. Empty is the ordinary state (an account with
23
+ * no review skills authored yet), not a fault, and the picker says so rather than hiding.
24
+ */
25
+ const reviewSkills = computed(() => skillsInGroup(catalog.value, 'review'))
26
+
17
27
  function hydrate(list: SkillSummary[]) {
18
28
  catalog.value = list
19
29
  }
20
30
 
21
- return { catalog, hydrate }
31
+ return { catalog, reviewSkills, hydrate }
22
32
  })
@@ -8,6 +8,7 @@
8
8
  // All wire shapes are sourced from @cat-factory/contracts (single source of truth).
9
9
 
10
10
  export type {
11
+ SkillGroup,
11
12
  SkillResource,
12
13
  AccountSkill,
13
14
  SkillSource,
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { SKILL_GROUPS } from '@cat-factory/contracts'
3
+ import {
4
+ SKILL_GROUP_ICONS,
5
+ SKILL_GROUP_LABEL_KEYS,
6
+ SKILL_GROUP_ORDER,
7
+ skillsInGroup,
8
+ } from '~/utils/skills'
9
+ import type { SkillSummary } from '~/types/domain'
10
+
11
+ // The group maps are what a picker renders a shelf from, and the vocabulary they cover lives in
12
+ // contracts. What is asserted here is the RELATION that has to hold (every member of the shared
13
+ // vocabulary is covered exactly once), never a hand-written count: adding a group must fail with
14
+ // its name rather than teaching the next person to re-pin a number.
15
+
16
+ const skill = (id: string, group: SkillSummary['group']): SkillSummary => ({
17
+ id,
18
+ name: id,
19
+ description: `${id} desc`,
20
+ group,
21
+ })
22
+
23
+ describe('skill group presentation', () => {
24
+ it('gives every group in the shared vocabulary a label key and an icon', () => {
25
+ for (const group of SKILL_GROUPS) {
26
+ expect(SKILL_GROUP_LABEL_KEYS[group]).toMatch(/^skills\.groups\./)
27
+ expect(SKILL_GROUP_ICONS[group]).toMatch(/^i-lucide-/)
28
+ }
29
+ })
30
+
31
+ it('orders every group exactly once, with the unclassified shelf last', () => {
32
+ expect([...SKILL_GROUP_ORDER].sort()).toEqual([...SKILL_GROUPS].sort())
33
+ expect(SKILL_GROUP_ORDER.at(-1)).toBe('other')
34
+ })
35
+ })
36
+
37
+ describe('skillsInGroup', () => {
38
+ it('keeps catalog order and admits only the asked-for group', () => {
39
+ const catalog = [
40
+ skill('sk_build', 'build'),
41
+ skill('sk_sec', 'review'),
42
+ skill('sk_other', 'other'),
43
+ skill('sk_perf', 'review'),
44
+ ]
45
+ expect(skillsInGroup(catalog, 'review').map((s) => s.id)).toEqual(['sk_sec', 'sk_perf'])
46
+ // A skill whose manifest declared an unknown group arrives already normalized to `other`, so
47
+ // it is offered on the unclassified shelf rather than everywhere or nowhere.
48
+ expect(skillsInGroup(catalog, 'other').map((s) => s.id)).toEqual(['sk_other'])
49
+ expect(skillsInGroup(catalog, 'test')).toEqual([])
50
+ })
51
+ })