@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.
- package/app/components/board/AddTaskModal.vue +10 -0
- package/app/components/panels/inspector/TaskReviewTarget.logic.spec.ts +56 -0
- package/app/components/panels/inspector/TaskReviewTarget.logic.ts +32 -0
- package/app/components/panels/inspector/TaskReviewTarget.vue +74 -5
- package/app/components/settings/TaskSourceCard.vue +2 -0
- package/app/components/skills/ReviewSkillQueue.vue +156 -0
- package/app/components/skills/SkillLibraryManager.vue +13 -1
- package/app/composables/usePipelineErrorToast.ts +8 -0
- package/app/stores/kaizen.spec.ts +3 -0
- package/app/stores/skillLibrary.ts +6 -1
- package/app/stores/skills.spec.ts +27 -1
- package/app/stores/skills.ts +14 -4
- package/app/types/skills.ts +1 -0
- package/app/utils/skills.spec.ts +51 -0
- package/app/utils/skills.ts +58 -0
- package/i18n/locales/de.json +24 -3
- package/i18n/locales/en.json +24 -3
- package/i18n/locales/es.json +24 -3
- package/i18n/locales/fr.json +24 -3
- package/i18n/locales/he.json +24 -3
- package/i18n/locales/it.json +24 -3
- package/i18n/locales/ja.json +24 -3
- package/i18n/locales/pl.json +24 -3
- package/i18n/locales/tr.json +24 -3
- package/i18n/locales/uk.json +24 -3
- package/package.json +2 -2
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
31
|
-
const
|
|
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
|
|
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
|
-
<
|
|
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',
|
|
@@ -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) => ({
|
|
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 => ({
|
|
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()
|
package/app/stores/skills.ts
CHANGED
|
@@ -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
|
|
9
|
-
* `stepOptions.skillId` to one of these
|
|
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
|
})
|
package/app/types/skills.ts
CHANGED
|
@@ -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
|
+
})
|