@cat-factory/app 0.281.0 → 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>
@@ -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 }) }}
@@ -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
+ })
@@ -0,0 +1,58 @@
1
+ import { SKILL_GROUPS } from '@cat-factory/contracts'
2
+ import type { SkillGroup, SkillSummary } from '~/types/domain'
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Shared presentation and filtering for the account's Claude Skills catalog.
6
+ //
7
+ // A skill declares a GROUP in its `SKILL.md` frontmatter (what kind of work its playbook does),
8
+ // and the surfaces that offer skills each want a different slice of the catalog: the review
9
+ // task's queue offers the `review` group, the library manager lists everything grouped. Both the
10
+ // label lookup and the filter live here, once, rather than in each component.
11
+ //
12
+ // The label map is an exhaustive `Record<SkillGroup, string>`, so adding a member to the contracts
13
+ // vocabulary fails the typecheck here instead of rendering a raw id (or nothing) in a picker.
14
+ // ---------------------------------------------------------------------------
15
+
16
+ /** i18n catalog key per group. Prose, so keys rather than the constants VCS labels use. */
17
+ export const SKILL_GROUP_LABEL_KEYS: Record<SkillGroup, string> = {
18
+ build: 'skills.groups.build',
19
+ review: 'skills.groups.review',
20
+ test: 'skills.groups.test',
21
+ write: 'skills.groups.write',
22
+ plan: 'skills.groups.plan',
23
+ operate: 'skills.groups.operate',
24
+ other: 'skills.groups.other',
25
+ }
26
+
27
+ /** Icon per group, so a picker row reads as its shelf at a glance. */
28
+ export const SKILL_GROUP_ICONS: Record<SkillGroup, string> = {
29
+ build: 'i-lucide-hammer',
30
+ review: 'i-lucide-clipboard-check',
31
+ test: 'i-lucide-flask-conical',
32
+ write: 'i-lucide-pen-line',
33
+ plan: 'i-lucide-map',
34
+ operate: 'i-lucide-server-cog',
35
+ other: 'i-lucide-book-open-check',
36
+ }
37
+
38
+ /**
39
+ * Display order for a grouped listing: the delivery loop first, `other` last. Derived FROM the
40
+ * contracts vocabulary rather than restated, so a member added there appears (before `other`)
41
+ * instead of silently vanishing from a listing that hard-coded the order.
42
+ */
43
+ export const SKILL_GROUP_ORDER: readonly SkillGroup[] = [
44
+ ...SKILL_GROUPS.filter((g) => g !== 'other'),
45
+ 'other',
46
+ ]
47
+
48
+ /**
49
+ * The catalog entries a surface offering `group` may show, in catalog order.
50
+ *
51
+ * Filtering on the group the BACKEND already normalized: a summary's group is narrowed to the
52
+ * wire vocabulary before it reaches the snapshot, so nothing here re-derives a classification the
53
+ * catalog owns, and a skill whose manifest declared an unknown group is offered under `other`
54
+ * rather than being offered everywhere or nowhere.
55
+ */
56
+ export function skillsInGroup(catalog: readonly SkillSummary[], group: SkillGroup): SkillSummary[] {
57
+ return catalog.filter((skill) => skill.group === group)
58
+ }
@@ -7380,7 +7380,8 @@
7380
7380
  "title": "Synchronisierte Skills",
7381
7381
  "empty": "Noch keine Skills synchronisiert. Verknüpfe unten eine Repo-Quelle, um ihre Skill-Ordner zu importieren.",
7382
7382
  "resources": "{count} Ressourcen",
7383
- "pinned": "fixiert auf {commit}"
7383
+ "pinned": "fixiert auf {commit}",
7384
+ "groupUnknown": "Gibt die Gruppe „{group}“ an, die diese Version nicht kennt; sie wird unter Sonstige geführt."
7384
7385
  },
7385
7386
  "sources": {
7386
7387
  "title": "Repo-Quellen",
@@ -7417,6 +7418,24 @@
7417
7418
  "checkSourceFailed": "Quelle konnte nicht geprüft werden",
7418
7419
  "sourceUnlinked": "Quelle getrennt",
7419
7420
  "unlinkSourceFailed": "Quelle konnte nicht getrennt werden"
7421
+ },
7422
+ "groups": {
7423
+ "build": "Bauen",
7424
+ "review": "Review",
7425
+ "test": "Testen",
7426
+ "write": "Schreiben",
7427
+ "plan": "Planen",
7428
+ "operate": "Betrieb",
7429
+ "other": "Sonstige"
7430
+ },
7431
+ "reviewQueue": {
7432
+ "label": "Review-Skills",
7433
+ "hint": "Stelle spezialisierte Review-Playbooks aus deiner Skill-Bibliothek in eine Warteschlange. Der Reviewer wendet sie in der gewählten Reihenfolge an.",
7434
+ "pickerEmpty": "Keine Review-Skills im Katalog. Ergänze group: review in der SKILL.md eines Skills, damit er hier erscheint.",
7435
+ "capped": "Ein Review führt höchstens {max} Skills aus.",
7436
+ "done": "Fertig",
7437
+ "save": "Speichern",
7438
+ "revert": "Zurücksetzen"
7420
7439
  }
7421
7440
  },
7422
7441
  "merge": {
@@ -7639,7 +7639,8 @@
7639
7639
  "title": "Synced skills",
7640
7640
  "empty": "No skills synced yet. Link a repo source below to import its skill folders.",
7641
7641
  "resources": "{count} resources",
7642
- "pinned": "pinned {commit}"
7642
+ "pinned": "pinned {commit}",
7643
+ "groupUnknown": "Declares the group “{group}”, which this version does not know, so it is filed under Other."
7643
7644
  },
7644
7645
  "sources": {
7645
7646
  "title": "Repo sources",
@@ -7676,6 +7677,24 @@
7676
7677
  "checkSourceFailed": "Could not check source",
7677
7678
  "sourceUnlinked": "Source unlinked",
7678
7679
  "unlinkSourceFailed": "Could not unlink source"
7680
+ },
7681
+ "groups": {
7682
+ "build": "Build",
7683
+ "review": "Review",
7684
+ "test": "Test",
7685
+ "write": "Write",
7686
+ "plan": "Plan",
7687
+ "operate": "Operate",
7688
+ "other": "Other"
7689
+ },
7690
+ "reviewQueue": {
7691
+ "label": "Review skills",
7692
+ "hint": "Queue specialist review playbooks from your skill library. The reviewer applies them in the order you pick.",
7693
+ "pickerEmpty": "No review skills in your catalog. Add group: review to a skill’s SKILL.md to offer it here.",
7694
+ "capped": "A review carries at most {max} skills.",
7695
+ "done": "Done",
7696
+ "save": "Save",
7697
+ "revert": "Revert"
7679
7698
  }
7680
7699
  },
7681
7700
  "merge": {
@@ -7380,7 +7380,8 @@
7380
7380
  "title": "Habilidades sincronizadas",
7381
7381
  "empty": "Aún no hay habilidades sincronizadas. Vincula una fuente de repositorio abajo para importar sus carpetas de habilidades.",
7382
7382
  "resources": "{count} recursos",
7383
- "pinned": "fijada en {commit}"
7383
+ "pinned": "fijada en {commit}",
7384
+ "groupUnknown": "Declara el grupo «{group}», que esta versión no conoce, así que se archiva en Otras."
7384
7385
  },
7385
7386
  "sources": {
7386
7387
  "title": "Fuentes de repositorio",
@@ -7417,6 +7418,24 @@
7417
7418
  "checkSourceFailed": "No se pudo comprobar la fuente",
7418
7419
  "sourceUnlinked": "Fuente desvinculada",
7419
7420
  "unlinkSourceFailed": "No se pudo desvincular la fuente"
7421
+ },
7422
+ "groups": {
7423
+ "build": "Construir",
7424
+ "review": "Revisar",
7425
+ "test": "Probar",
7426
+ "write": "Escribir",
7427
+ "plan": "Planificar",
7428
+ "operate": "Operar",
7429
+ "other": "Otras"
7430
+ },
7431
+ "reviewQueue": {
7432
+ "label": "Habilidades de revisión",
7433
+ "hint": "Pon en cola manuales de revisión especializados de tu biblioteca de habilidades. El revisor los aplica en el orden que elijas.",
7434
+ "pickerEmpty": "No hay habilidades de revisión en tu catálogo. Añade group: review al SKILL.md de una habilidad para ofrecerla aquí.",
7435
+ "capped": "Una revisión lleva como máximo {max} habilidades.",
7436
+ "done": "Listo",
7437
+ "save": "Guardar",
7438
+ "revert": "Descartar"
7420
7439
  }
7421
7440
  },
7422
7441
  "merge": {
@@ -7380,7 +7380,8 @@
7380
7380
  "title": "Compétences synchronisées",
7381
7381
  "empty": "Aucune compétence synchronisée pour l'instant. Reliez une source de dépôt ci-dessous pour importer ses dossiers de compétences.",
7382
7382
  "resources": "{count} ressources",
7383
- "pinned": "épinglée sur {commit}"
7383
+ "pinned": "épinglée sur {commit}",
7384
+ "groupUnknown": "Déclare le groupe « {group} », inconnu de cette version : la compétence est classée dans Autres."
7384
7385
  },
7385
7386
  "sources": {
7386
7387
  "title": "Sources de dépôt",
@@ -7417,6 +7418,24 @@
7417
7418
  "checkSourceFailed": "Impossible de vérifier la source",
7418
7419
  "sourceUnlinked": "Source dissociée",
7419
7420
  "unlinkSourceFailed": "Impossible de dissocier la source"
7421
+ },
7422
+ "groups": {
7423
+ "build": "Construire",
7424
+ "review": "Relire",
7425
+ "test": "Tester",
7426
+ "write": "Rédiger",
7427
+ "plan": "Planifier",
7428
+ "operate": "Exploiter",
7429
+ "other": "Autres"
7430
+ },
7431
+ "reviewQueue": {
7432
+ "label": "Compétences de relecture",
7433
+ "hint": "Mettez en file des guides de relecture spécialisés issus de votre bibliothèque. Le relecteur les applique dans l’ordre choisi.",
7434
+ "pickerEmpty": "Aucune compétence de relecture dans votre catalogue. Ajoutez group: review au SKILL.md d’une compétence pour la proposer ici.",
7435
+ "capped": "Une relecture porte au maximum {max} compétences.",
7436
+ "done": "Terminé",
7437
+ "save": "Enregistrer",
7438
+ "revert": "Rétablir"
7420
7439
  }
7421
7440
  },
7422
7441
  "merge": {
@@ -7380,7 +7380,8 @@
7380
7380
  "title": "כישורים מסונכרנים",
7381
7381
  "empty": "עדיין אין כישורים מסונכרנים. קישר מקור מאגר למטה כדי לייבא את תיקיות הכישורים שלו.",
7382
7382
  "resources": "{count} משאבים",
7383
- "pinned": "מקובע ל-{commit}"
7383
+ "pinned": "מקובע ל-{commit}",
7384
+ "groupUnknown": "מצהיר על הקבוצה „{group}”, שגרסה זו אינה מכירה, ולכן הוא מסווג תחת אחר."
7384
7385
  },
7385
7386
  "sources": {
7386
7387
  "title": "מקורות מאגר",
@@ -7417,6 +7418,24 @@
7417
7418
  "checkSourceFailed": "לא ניתן לבדוק את המקור",
7418
7419
  "sourceUnlinked": "קישור המקור בוטל",
7419
7420
  "unlinkSourceFailed": "לא ניתן לבטל את קישור המקור"
7421
+ },
7422
+ "groups": {
7423
+ "build": "בנייה",
7424
+ "review": "סקירה",
7425
+ "test": "בדיקה",
7426
+ "write": "כתיבה",
7427
+ "plan": "תכנון",
7428
+ "operate": "תפעול",
7429
+ "other": "אחר"
7430
+ },
7431
+ "reviewQueue": {
7432
+ "label": "כישורי סקירה",
7433
+ "hint": "הוסיפו לתור ספרי סקירה ייעודיים מספריית הכישורים. הסוקר מיישם אותם לפי סדר הבחירה.",
7434
+ "pickerEmpty": "אין כישורי סקירה בקטלוג. הוסיפו group: review לקובץ SKILL.md של כישור כדי להציע אותו כאן.",
7435
+ "capped": "סקירה נושאת {max} כישורים לכל היותר.",
7436
+ "done": "סיום",
7437
+ "save": "שמירה",
7438
+ "revert": "שחזור"
7420
7439
  }
7421
7440
  },
7422
7441
  "merge": {
@@ -7380,7 +7380,8 @@
7380
7380
  "title": "Competenze sincronizzate",
7381
7381
  "empty": "Nessuna competenza ancora sincronizzata. Collega una fonte di repository qui sotto per importare le sue cartelle di competenze.",
7382
7382
  "resources": "{count} risorse",
7383
- "pinned": "fissata su {commit}"
7383
+ "pinned": "fissata su {commit}",
7384
+ "groupUnknown": "Dichiara il gruppo «{group}», che questa versione non conosce, quindi è archiviata in Altre."
7384
7385
  },
7385
7386
  "sources": {
7386
7387
  "title": "Fonti di repository",
@@ -7417,6 +7418,24 @@
7417
7418
  "checkSourceFailed": "Impossibile controllare la fonte",
7418
7419
  "sourceUnlinked": "Fonte scollegata",
7419
7420
  "unlinkSourceFailed": "Impossibile scollegare la fonte"
7421
+ },
7422
+ "groups": {
7423
+ "build": "Costruire",
7424
+ "review": "Revisionare",
7425
+ "test": "Testare",
7426
+ "write": "Scrivere",
7427
+ "plan": "Pianificare",
7428
+ "operate": "Operare",
7429
+ "other": "Altre"
7430
+ },
7431
+ "reviewQueue": {
7432
+ "label": "Competenze di revisione",
7433
+ "hint": "Metti in coda manuali di revisione specialistici dalla tua libreria. Il revisore li applica nell’ordine scelto.",
7434
+ "pickerEmpty": "Nessuna competenza di revisione nel catalogo. Aggiungi group: review al SKILL.md di una competenza per proporla qui.",
7435
+ "capped": "Una revisione porta al massimo {max} competenze.",
7436
+ "done": "Fatto",
7437
+ "save": "Salva",
7438
+ "revert": "Ripristina"
7420
7439
  }
7421
7440
  },
7422
7441
  "merge": {
@@ -7380,7 +7380,8 @@
7380
7380
  "title": "同期済みスキル",
7381
7381
  "empty": "まだスキルが同期されていません。下のリポジトリソースをリンクしてスキルフォルダーをインポートしてください。",
7382
7382
  "resources": "リソース {count} 件",
7383
- "pinned": "{commit} に固定"
7383
+ "pinned": "{commit} に固定",
7384
+ "groupUnknown": "このバージョンが知らないグループ「{group}」を宣言しているため、その他に分類しています。"
7384
7385
  },
7385
7386
  "sources": {
7386
7387
  "title": "リポジトリソース",
@@ -7417,6 +7418,24 @@
7417
7418
  "checkSourceFailed": "ソースを確認できませんでした",
7418
7419
  "sourceUnlinked": "ソースのリンクを解除しました",
7419
7420
  "unlinkSourceFailed": "ソースのリンクを解除できませんでした"
7421
+ },
7422
+ "groups": {
7423
+ "build": "ビルド",
7424
+ "review": "レビュー",
7425
+ "test": "テスト",
7426
+ "write": "ライティング",
7427
+ "plan": "計画",
7428
+ "operate": "運用",
7429
+ "other": "その他"
7430
+ },
7431
+ "reviewQueue": {
7432
+ "label": "レビュースキル",
7433
+ "hint": "スキルライブラリから専門的なレビュー手順書をキューに追加します。レビュアーは選んだ順に適用します。",
7434
+ "pickerEmpty": "カタログにレビュースキルがありません。スキルの SKILL.md に group: review を追加するとここに表示されます。",
7435
+ "capped": "1 回のレビューで扱えるスキルは最大 {max} 件です。",
7436
+ "done": "完了",
7437
+ "save": "保存",
7438
+ "revert": "元に戻す"
7420
7439
  }
7421
7440
  },
7422
7441
  "merge": {
@@ -7380,7 +7380,8 @@
7380
7380
  "title": "Zsynchronizowane umiejętności",
7381
7381
  "empty": "Nie zsynchronizowano jeszcze żadnych umiejętności. Połącz poniżej źródło repozytorium, aby zaimportować jego foldery umiejętności.",
7382
7382
  "resources": "zasoby: {count}",
7383
- "pinned": "przypięta do {commit}"
7383
+ "pinned": "przypięta do {commit}",
7384
+ "groupUnknown": "Deklaruje grupę „{group}”, której ta wersja nie zna, więc trafia do Inne."
7384
7385
  },
7385
7386
  "sources": {
7386
7387
  "title": "Źródła repozytoriów",
@@ -7417,6 +7418,24 @@
7417
7418
  "checkSourceFailed": "Nie udało się sprawdzić źródła",
7418
7419
  "sourceUnlinked": "Źródło odłączone",
7419
7420
  "unlinkSourceFailed": "Nie udało się odłączyć źródła"
7421
+ },
7422
+ "groups": {
7423
+ "build": "Budowanie",
7424
+ "review": "Przegląd",
7425
+ "test": "Testowanie",
7426
+ "write": "Pisanie",
7427
+ "plan": "Planowanie",
7428
+ "operate": "Utrzymanie",
7429
+ "other": "Inne"
7430
+ },
7431
+ "reviewQueue": {
7432
+ "label": "Umiejętności przeglądu",
7433
+ "hint": "Dodaj do kolejki wyspecjalizowane podręczniki przeglądu z biblioteki umiejętności. Recenzent zastosuje je w wybranej kolejności.",
7434
+ "pickerEmpty": "Brak umiejętności przeglądu w katalogu. Dodaj group: review w pliku SKILL.md umiejętności, aby pojawiła się tutaj.",
7435
+ "capped": "Jeden przegląd obejmuje najwyżej {max} umiejętności.",
7436
+ "done": "Gotowe",
7437
+ "save": "Zapisz",
7438
+ "revert": "Przywróć"
7420
7439
  }
7421
7440
  },
7422
7441
  "merge": {
@@ -7380,7 +7380,8 @@
7380
7380
  "title": "Senkronize beceriler",
7381
7381
  "empty": "Henüz senkronize edilmiş beceri yok. Beceri klasörlerini içe aktarmak için aşağıda bir depo kaynağı bağlayın.",
7382
7382
  "resources": "{count} kaynak",
7383
- "pinned": "{commit} üzerine sabitlendi"
7383
+ "pinned": "{commit} üzerine sabitlendi",
7384
+ "groupUnknown": "Bu sürümün bilmediği “{group}” grubunu bildiriyor, bu yüzden Diğer altında listeleniyor."
7384
7385
  },
7385
7386
  "sources": {
7386
7387
  "title": "Depo kaynakları",
@@ -7417,6 +7418,24 @@
7417
7418
  "checkSourceFailed": "Kaynak denetlenemedi",
7418
7419
  "sourceUnlinked": "Kaynağın bağlantısı kaldırıldı",
7419
7420
  "unlinkSourceFailed": "Kaynağın bağlantısı kaldırılamadı"
7421
+ },
7422
+ "groups": {
7423
+ "build": "Geliştirme",
7424
+ "review": "İnceleme",
7425
+ "test": "Test",
7426
+ "write": "Yazma",
7427
+ "plan": "Planlama",
7428
+ "operate": "İşletme",
7429
+ "other": "Diğer"
7430
+ },
7431
+ "reviewQueue": {
7432
+ "label": "İnceleme becerileri",
7433
+ "hint": "Beceri kitaplığınızdan uzmanlaşmış inceleme kılavuzlarını sıraya alın. İnceleyici bunları seçtiğiniz sırayla uygular.",
7434
+ "pickerEmpty": "Katalogda inceleme becerisi yok. Bir becerinin SKILL.md dosyasına group: review ekleyin.",
7435
+ "capped": "Bir inceleme en fazla {max} beceri taşır.",
7436
+ "done": "Tamam",
7437
+ "save": "Kaydet",
7438
+ "revert": "Geri al"
7420
7439
  }
7421
7440
  },
7422
7441
  "merge": {
@@ -7380,7 +7380,8 @@
7380
7380
  "title": "Синхронізовані навички",
7381
7381
  "empty": "Ще немає синхронізованих навичок. Пов'яжіть джерело репозиторію нижче, щоб імпортувати його папки навичок.",
7382
7382
  "resources": "ресурсів: {count}",
7383
- "pinned": "закріплено на {commit}"
7383
+ "pinned": "закріплено на {commit}",
7384
+ "groupUnknown": "Оголошує групу «{group}», якої ця версія не знає, тож навичку віднесено до Інше."
7384
7385
  },
7385
7386
  "sources": {
7386
7387
  "title": "Джерела репозиторіїв",
@@ -7417,6 +7418,24 @@
7417
7418
  "checkSourceFailed": "Не вдалося перевірити джерело",
7418
7419
  "sourceUnlinked": "Джерело від'єднано",
7419
7420
  "unlinkSourceFailed": "Не вдалося від'єднати джерело"
7421
+ },
7422
+ "groups": {
7423
+ "build": "Розробка",
7424
+ "review": "Рецензування",
7425
+ "test": "Тестування",
7426
+ "write": "Написання",
7427
+ "plan": "Планування",
7428
+ "operate": "Експлуатація",
7429
+ "other": "Інше"
7430
+ },
7431
+ "reviewQueue": {
7432
+ "label": "Навички рецензування",
7433
+ "hint": "Додайте до черги спеціалізовані посібники рецензування з бібліотеки навичок. Рецензент застосує їх у вибраному порядку.",
7434
+ "pickerEmpty": "У каталозі немає навичок рецензування. Додайте group: review у SKILL.md навички, щоб вона зʼявилася тут.",
7435
+ "capped": "Одне рецензування несе щонайбільше {max} навичок.",
7436
+ "done": "Готово",
7437
+ "save": "Зберегти",
7438
+ "revert": "Скасувати зміни"
7420
7439
  }
7421
7440
  },
7422
7441
  "merge": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.281.0",
3
+ "version": "0.282.0",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -40,7 +40,7 @@
40
40
  "valibot": "^1.4.2",
41
41
  "vue": "3.5.41",
42
42
  "wretch": "^3.0.9",
43
- "@cat-factory/contracts": "0.322.0"
43
+ "@cat-factory/contracts": "0.323.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@toad-contracts/testing": "0.3.2",