@cat-factory/app 0.18.0 → 0.18.1
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/nodes/TaskCard.vue +3 -1
- package/app/components/clarity/ClarityReviewWindow.vue +3 -0
- package/app/components/panels/inspector/TaskExecution.vue +3 -1
- package/app/components/pipeline/PipelineProgress.vue +3 -1
- package/app/components/requirements/RequirementsReviewWindow.vue +265 -18
- package/app/composables/api/reviews.ts +33 -0
- package/app/composables/useReviewStage.ts +1 -1
- package/app/stores/requirements.ts +50 -1
- package/app/types/requirements.ts +27 -1
- package/package.json +1 -1
|
@@ -117,7 +117,9 @@ const reviewStageLabel = computed(() =>
|
|
|
117
117
|
? 'Incorporating answers…'
|
|
118
118
|
: reviewStage.value === 'reviewing'
|
|
119
119
|
? 'Re-reviewing…'
|
|
120
|
-
:
|
|
120
|
+
: reviewStage.value === 'recommending'
|
|
121
|
+
? 'Recommending…'
|
|
122
|
+
: null,
|
|
121
123
|
)
|
|
122
124
|
const pendingApproval = computed(() => {
|
|
123
125
|
const a = execution.openApprovals.find((a) => a.blockId === props.taskId)
|
|
@@ -105,6 +105,9 @@ const STATUS_COLOR = {
|
|
|
105
105
|
answered: 'info',
|
|
106
106
|
resolved: 'success',
|
|
107
107
|
dismissed: 'neutral',
|
|
108
|
+
// Clarity review doesn't request Requirement-Writer recommendations, but the item-status
|
|
109
|
+
// type is shared with the requirements review, so the map must be exhaustive.
|
|
110
|
+
recommend_requested: 'primary',
|
|
108
111
|
} as const satisfies Record<ReviewItemStatus, string>
|
|
109
112
|
|
|
110
113
|
function notifyError(title: string, e: unknown) {
|
|
@@ -22,7 +22,9 @@ const reviewStageLabel = computed(() =>
|
|
|
22
22
|
? 'Incorporating…'
|
|
23
23
|
: reviewStage.value === 'reviewing'
|
|
24
24
|
? 'Re-reviewing…'
|
|
25
|
-
:
|
|
25
|
+
: reviewStage.value === 'recommending'
|
|
26
|
+
? 'Recommending…'
|
|
27
|
+
: null,
|
|
26
28
|
)
|
|
27
29
|
|
|
28
30
|
const instance = computed(() => execution.getInstance(props.block.executionId))
|
|
@@ -32,7 +32,9 @@ function reviewStageLabel(agentKind: string | undefined): string | null {
|
|
|
32
32
|
? 'Incorporating…'
|
|
33
33
|
: stage === 'reviewing'
|
|
34
34
|
? 'Re-reviewing…'
|
|
35
|
-
:
|
|
35
|
+
: stage === 'recommending'
|
|
36
|
+
? 'Recommending…'
|
|
37
|
+
: null
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
// Clicking an agent opens its step-detail overlay — execution metadata (state,
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { parseOutputOutline } from '~/utils/agentOutput'
|
|
11
11
|
import StepRestartControl from '~/components/panels/StepRestartControl.vue'
|
|
12
12
|
import type {
|
|
13
|
+
RequirementRecommendation,
|
|
13
14
|
RequirementReview,
|
|
14
15
|
RequirementReviewItem,
|
|
15
16
|
ReviewItemCategory,
|
|
@@ -23,6 +24,15 @@ const toast = useToast()
|
|
|
23
24
|
|
|
24
25
|
// Draft replies, keyed by item id, so editing one item doesn't disturb others.
|
|
25
26
|
const drafts = ref<Record<string, string>>({})
|
|
27
|
+
// The server-side reply each draft was last seeded/synced to, so the seeding watch can refresh
|
|
28
|
+
// a draft when the recorded reply changes server-side (e.g. accepting a recommendation sets the
|
|
29
|
+
// finding's answer) WITHOUT clobbering a reply the human is actively editing.
|
|
30
|
+
const seededReply = ref<Record<string, string>>({})
|
|
31
|
+
// Findings the human marked for a Requirement-Writer recommendation, batched until they
|
|
32
|
+
// click "Request recommendations" (so the Writer runs once over the whole batch).
|
|
33
|
+
const markedForRecommend = ref<Set<string>>(new Set())
|
|
34
|
+
// Re-request "do it differently" notes, keyed by recommendation id.
|
|
35
|
+
const reRequestNotes = ref<Record<string, string>>({})
|
|
26
36
|
// Freeform "do it differently" comment when redoing a merge the human was unhappy with.
|
|
27
37
|
const redoComment = ref('')
|
|
28
38
|
const showRedo = ref(false)
|
|
@@ -35,6 +45,9 @@ const showRedo = ref(false)
|
|
|
35
45
|
const { open, blockId, instanceId, stepIndex, close } = useResultView('requirements-review', {
|
|
36
46
|
onOpen: (id) => {
|
|
37
47
|
drafts.value = {}
|
|
48
|
+
seededReply.value = {}
|
|
49
|
+
markedForRecommend.value = new Set()
|
|
50
|
+
reRequestNotes.value = {}
|
|
38
51
|
redoComment.value = ''
|
|
39
52
|
showRedo.value = false
|
|
40
53
|
void requirements.load(id)
|
|
@@ -109,6 +122,7 @@ const STATUS_COLOR = {
|
|
|
109
122
|
answered: 'info',
|
|
110
123
|
resolved: 'success',
|
|
111
124
|
dismissed: 'neutral',
|
|
125
|
+
recommend_requested: 'primary',
|
|
112
126
|
} as const satisfies Record<ReviewItemStatus, string>
|
|
113
127
|
|
|
114
128
|
function notifyError(title: string, e: unknown) {
|
|
@@ -120,18 +134,73 @@ function notifyError(title: string, e: unknown) {
|
|
|
120
134
|
})
|
|
121
135
|
}
|
|
122
136
|
|
|
123
|
-
|
|
124
|
-
|
|
137
|
+
// Answers auto-save: there is no explicit "save" button. The textarea is pre-seeded with
|
|
138
|
+
// the recorded reply (see the watch below); editing and blurring persists it. Persist only
|
|
139
|
+
// when the trimmed draft actually differs from what's already recorded, so blurring an
|
|
140
|
+
// untouched field is a no-op.
|
|
141
|
+
async function persistDraft(item: RequirementReviewItem) {
|
|
142
|
+
if (!review.value || frozen.value) return
|
|
125
143
|
const text = (drafts.value[item.id] ?? '').trim()
|
|
126
|
-
if (!text) return
|
|
144
|
+
if (!text || text === (item.reply ?? '').trim()) return
|
|
127
145
|
try {
|
|
128
146
|
await requirements.reply(review.value, item.id, text)
|
|
129
|
-
drafts.value = { ...drafts.value, [item.id]: '' }
|
|
130
147
|
} catch (e) {
|
|
131
148
|
notifyError('Could not save the answer', e)
|
|
132
149
|
}
|
|
133
150
|
}
|
|
134
151
|
|
|
152
|
+
// Persist every dirty draft before an action that consumes the answers, so a value the
|
|
153
|
+
// user typed but never blurred out of isn't lost.
|
|
154
|
+
async function flushDrafts() {
|
|
155
|
+
if (!review.value) return
|
|
156
|
+
for (const item of review.value.items) {
|
|
157
|
+
if (item.status === 'open' || item.status === 'answered') await persistDraft(item)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Seed a draft for each finding from its recorded reply so the textarea shows the current
|
|
162
|
+
// answer (editing in place). New findings from a re-review get seeded; and when the recorded
|
|
163
|
+
// reply changes server-side (e.g. accepting a recommendation writes the finding's answer) a
|
|
164
|
+
// draft the user hasn't diverged from is refreshed to match. Drafts the user is actively
|
|
165
|
+
// editing are left untouched.
|
|
166
|
+
watch(
|
|
167
|
+
review,
|
|
168
|
+
(r) => {
|
|
169
|
+
if (!r) return
|
|
170
|
+
const nextDrafts = { ...drafts.value }
|
|
171
|
+
const nextSeeded = { ...seededReply.value }
|
|
172
|
+
let changed = false
|
|
173
|
+
for (const item of r.items) {
|
|
174
|
+
const reply = item.reply ?? ''
|
|
175
|
+
if (!(item.id in nextDrafts)) {
|
|
176
|
+
nextDrafts[item.id] = reply
|
|
177
|
+
nextSeeded[item.id] = reply
|
|
178
|
+
changed = true
|
|
179
|
+
continue
|
|
180
|
+
}
|
|
181
|
+
const draft = nextDrafts[item.id] ?? ''
|
|
182
|
+
const seeded = nextSeeded[item.id] ?? ''
|
|
183
|
+
if (draft === seeded && draft !== reply) {
|
|
184
|
+
// The user hasn't diverged from the last seeded value but the server reply changed —
|
|
185
|
+
// refresh the textarea to the new answer (e.g. an accepted recommendation).
|
|
186
|
+
nextDrafts[item.id] = reply
|
|
187
|
+
nextSeeded[item.id] = reply
|
|
188
|
+
changed = true
|
|
189
|
+
} else if (draft === reply && seeded !== reply) {
|
|
190
|
+
// The draft already matches the server (e.g. the user's answer was just persisted) —
|
|
191
|
+
// record it so a later server-side change can be detected.
|
|
192
|
+
nextSeeded[item.id] = reply
|
|
193
|
+
changed = true
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (changed) {
|
|
197
|
+
drafts.value = nextDrafts
|
|
198
|
+
seededReply.value = nextSeeded
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
{ immediate: true },
|
|
202
|
+
)
|
|
203
|
+
|
|
135
204
|
async function setStatus(item: RequirementReviewItem, itemStatus: ReviewItemStatus) {
|
|
136
205
|
if (!review.value) return
|
|
137
206
|
try {
|
|
@@ -141,9 +210,75 @@ async function setStatus(item: RequirementReviewItem, itemStatus: ReviewItemStat
|
|
|
141
210
|
}
|
|
142
211
|
}
|
|
143
212
|
|
|
213
|
+
// --- Requirement Writer recommendations -----------------------------------
|
|
214
|
+
const recommending = computed(() =>
|
|
215
|
+
blockId.value ? requirements.isRecommending(blockId.value) : false,
|
|
216
|
+
)
|
|
217
|
+
// Recommendations still awaiting a human decision (the ones to surface for review).
|
|
218
|
+
const pendingRecommendations = computed<RequirementRecommendation[]>(() =>
|
|
219
|
+
(review.value?.recommendations ?? []).filter((r) => r.status === 'ready'),
|
|
220
|
+
)
|
|
221
|
+
function isMarkedForRecommend(item: RequirementReviewItem): boolean {
|
|
222
|
+
return markedForRecommend.value.has(item.id)
|
|
223
|
+
}
|
|
224
|
+
function toggleRecommend(item: RequirementReviewItem) {
|
|
225
|
+
const next = new Set(markedForRecommend.value)
|
|
226
|
+
if (next.has(item.id)) next.delete(item.id)
|
|
227
|
+
else next.add(item.id)
|
|
228
|
+
markedForRecommend.value = next
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Fire the Writer over the whole marked batch at once (grounded on the project's
|
|
232
|
+
// best-practice standards, specs/tech-specs and web search).
|
|
233
|
+
async function requestRecommendations() {
|
|
234
|
+
if (!blockId.value || markedForRecommend.value.size === 0) return
|
|
235
|
+
const ids = [...markedForRecommend.value]
|
|
236
|
+
try {
|
|
237
|
+
await requirements.requestRecommendations(blockId.value, ids)
|
|
238
|
+
markedForRecommend.value = new Set()
|
|
239
|
+
toast.add({
|
|
240
|
+
title: `Requesting ${ids.length} recommendation${ids.length === 1 ? '' : 's'}…`,
|
|
241
|
+
icon: 'i-lucide-sparkles',
|
|
242
|
+
})
|
|
243
|
+
} catch (e) {
|
|
244
|
+
notifyError('Could not request recommendations', e)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function acceptRecommendation(rec: RequirementRecommendation) {
|
|
249
|
+
if (!review.value) return
|
|
250
|
+
try {
|
|
251
|
+
await requirements.acceptRecommendation(review.value, rec.id)
|
|
252
|
+
} catch (e) {
|
|
253
|
+
notifyError('Could not accept the recommendation', e)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function rejectRecommendation(rec: RequirementRecommendation) {
|
|
258
|
+
if (!review.value) return
|
|
259
|
+
try {
|
|
260
|
+
await requirements.rejectRecommendation(review.value, rec.id)
|
|
261
|
+
} catch (e) {
|
|
262
|
+
notifyError('Could not reject the recommendation', e)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function reRequestRecommendation(rec: RequirementRecommendation) {
|
|
267
|
+
if (!review.value) return
|
|
268
|
+
const note = (reRequestNotes.value[rec.id] ?? '').trim()
|
|
269
|
+
if (!note) return
|
|
270
|
+
try {
|
|
271
|
+
await requirements.reRequestRecommendation(review.value, rec.id, note)
|
|
272
|
+
reRequestNotes.value = { ...reRequestNotes.value, [rec.id]: '' }
|
|
273
|
+
} catch (e) {
|
|
274
|
+
notifyError('Could not re-request the recommendation', e)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
144
278
|
async function incorporate(feedback?: string) {
|
|
145
279
|
if (!review.value || !blockId.value) return
|
|
146
280
|
try {
|
|
281
|
+
await flushDrafts()
|
|
147
282
|
await requirements.incorporate(review.value, feedback)
|
|
148
283
|
} catch (e) {
|
|
149
284
|
notifyError('Could not incorporate the answers', e)
|
|
@@ -183,6 +318,7 @@ async function proceed() {
|
|
|
183
318
|
if (!blockId.value) return
|
|
184
319
|
acting.value = true
|
|
185
320
|
try {
|
|
321
|
+
await flushDrafts()
|
|
186
322
|
await requirements.proceed(blockId.value)
|
|
187
323
|
toast.add({ title: 'Proceeding to the next phase', icon: 'i-lucide-arrow-right' })
|
|
188
324
|
} catch (e) {
|
|
@@ -350,9 +486,10 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
|
|
|
350
486
|
{{ item.detail }}
|
|
351
487
|
</p>
|
|
352
488
|
|
|
353
|
-
<!-- recorded answer
|
|
489
|
+
<!-- recorded answer (only for non-editable findings — for editable
|
|
490
|
+
ones the answer lives in the textarea below, seeded from the reply) -->
|
|
354
491
|
<div
|
|
355
|
-
v-if="item.reply"
|
|
492
|
+
v-if="item.reply && item.status !== 'open' && item.status !== 'answered'"
|
|
356
493
|
class="mt-2 rounded-md border-l-2 border-slate-700 bg-slate-950/40 px-3 py-1.5 text-sm text-slate-300"
|
|
357
494
|
>
|
|
358
495
|
<span class="text-[10px] uppercase tracking-wide text-slate-500">
|
|
@@ -361,7 +498,8 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
|
|
|
361
498
|
<p class="whitespace-pre-line">{{ item.reply }}</p>
|
|
362
499
|
</div>
|
|
363
500
|
|
|
364
|
-
<!-- react: answer (relevant) or dismiss (irrelevant).
|
|
501
|
+
<!-- react: answer (relevant) or dismiss (irrelevant). The answer
|
|
502
|
+
auto-saves on blur — no explicit save button. Disabled once the
|
|
365
503
|
requirements are settled / awaiting a higher-level decision. -->
|
|
366
504
|
<template v-if="item.status === 'open' || item.status === 'answered'">
|
|
367
505
|
<UTextarea
|
|
@@ -370,20 +508,11 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
|
|
|
370
508
|
autoresize
|
|
371
509
|
size="sm"
|
|
372
510
|
class="mt-2 w-full"
|
|
373
|
-
|
|
511
|
+
placeholder="Answer this finding…"
|
|
374
512
|
:disabled="frozen"
|
|
513
|
+
@blur="persistDraft(item)"
|
|
375
514
|
/>
|
|
376
515
|
<div class="mt-2 flex flex-wrap items-center gap-2">
|
|
377
|
-
<UButton
|
|
378
|
-
color="primary"
|
|
379
|
-
variant="soft"
|
|
380
|
-
size="xs"
|
|
381
|
-
icon="i-lucide-corner-down-left"
|
|
382
|
-
:disabled="!(drafts[item.id] ?? '').trim() || frozen"
|
|
383
|
-
@click="submitReply(item)"
|
|
384
|
-
>
|
|
385
|
-
Save answer
|
|
386
|
-
</UButton>
|
|
387
516
|
<UButton
|
|
388
517
|
color="neutral"
|
|
389
518
|
variant="ghost"
|
|
@@ -394,9 +523,32 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
|
|
|
394
523
|
>
|
|
395
524
|
Dismiss as irrelevant
|
|
396
525
|
</UButton>
|
|
526
|
+
<UButton
|
|
527
|
+
:color="isMarkedForRecommend(item) ? 'primary' : 'neutral'"
|
|
528
|
+
:variant="isMarkedForRecommend(item) ? 'soft' : 'ghost'"
|
|
529
|
+
size="xs"
|
|
530
|
+
icon="i-lucide-wand-2"
|
|
531
|
+
:disabled="frozen"
|
|
532
|
+
@click="toggleRecommend(item)"
|
|
533
|
+
>
|
|
534
|
+
{{
|
|
535
|
+
isMarkedForRecommend(item)
|
|
536
|
+
? 'Marked for recommendation'
|
|
537
|
+
: 'Recommend something'
|
|
538
|
+
}}
|
|
539
|
+
</UButton>
|
|
397
540
|
</div>
|
|
398
541
|
</template>
|
|
399
542
|
|
|
543
|
+
<!-- finding awaiting a recommendation batch -->
|
|
544
|
+
<div
|
|
545
|
+
v-else-if="item.status === 'recommend_requested'"
|
|
546
|
+
class="mt-2 flex items-center gap-1.5 text-xs text-indigo-300"
|
|
547
|
+
>
|
|
548
|
+
<UIcon name="i-lucide-wand-2" class="h-3.5 w-3.5" />
|
|
549
|
+
Recommendation requested — review the suggestion below.
|
|
550
|
+
</div>
|
|
551
|
+
|
|
400
552
|
<!-- reopen a dismissed finding -->
|
|
401
553
|
<div v-else-if="item.status === 'dismissed'" class="mt-2">
|
|
402
554
|
<UButton
|
|
@@ -415,6 +567,87 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
|
|
|
415
567
|
</div>
|
|
416
568
|
</div>
|
|
417
569
|
|
|
570
|
+
<!-- Requirement-Writer recommendations awaiting a human decision -->
|
|
571
|
+
<section
|
|
572
|
+
v-if="pendingRecommendations.length"
|
|
573
|
+
class="mt-6 border-t border-slate-800 pt-5"
|
|
574
|
+
>
|
|
575
|
+
<div class="mb-3 flex items-center gap-1.5 text-[11px] text-indigo-300">
|
|
576
|
+
<UIcon name="i-lucide-wand-2" class="h-3.5 w-3.5" />
|
|
577
|
+
<span class="font-semibold uppercase tracking-wide">Recommended answers</span>
|
|
578
|
+
</div>
|
|
579
|
+
<div class="flex flex-col gap-3">
|
|
580
|
+
<div
|
|
581
|
+
v-for="rec in pendingRecommendations"
|
|
582
|
+
:key="rec.id"
|
|
583
|
+
class="rounded-lg border border-indigo-900/50 bg-indigo-950/20 p-3"
|
|
584
|
+
>
|
|
585
|
+
<div class="flex flex-wrap items-center gap-1.5">
|
|
586
|
+
<span class="text-sm font-medium text-white">{{
|
|
587
|
+
rec.sourceFinding.title
|
|
588
|
+
}}</span>
|
|
589
|
+
<UBadge
|
|
590
|
+
v-if="rec.groundedInFragment"
|
|
591
|
+
size="xs"
|
|
592
|
+
variant="subtle"
|
|
593
|
+
color="success"
|
|
594
|
+
icon="i-lucide-badge-check"
|
|
595
|
+
>
|
|
596
|
+
Current standard: {{ rec.groundedInFragment.title }}
|
|
597
|
+
</UBadge>
|
|
598
|
+
</div>
|
|
599
|
+
<p class="mt-2 whitespace-pre-line text-sm text-slate-300">
|
|
600
|
+
{{ rec.recommendedText }}
|
|
601
|
+
</p>
|
|
602
|
+
<div class="mt-2 flex flex-wrap items-center gap-2">
|
|
603
|
+
<UButton
|
|
604
|
+
color="primary"
|
|
605
|
+
variant="soft"
|
|
606
|
+
size="xs"
|
|
607
|
+
icon="i-lucide-check"
|
|
608
|
+
:disabled="frozen"
|
|
609
|
+
@click="acceptRecommendation(rec)"
|
|
610
|
+
>
|
|
611
|
+
Accept
|
|
612
|
+
</UButton>
|
|
613
|
+
<UButton
|
|
614
|
+
color="neutral"
|
|
615
|
+
variant="ghost"
|
|
616
|
+
size="xs"
|
|
617
|
+
icon="i-lucide-x"
|
|
618
|
+
:disabled="frozen"
|
|
619
|
+
@click="rejectRecommendation(rec)"
|
|
620
|
+
>
|
|
621
|
+
Reject
|
|
622
|
+
</UButton>
|
|
623
|
+
</div>
|
|
624
|
+
<!-- re-request with a note (an alternative to rejecting outright) -->
|
|
625
|
+
<div class="mt-2 flex items-start gap-2">
|
|
626
|
+
<UTextarea
|
|
627
|
+
v-model="reRequestNotes[rec.id]"
|
|
628
|
+
:rows="1"
|
|
629
|
+
autoresize
|
|
630
|
+
size="sm"
|
|
631
|
+
class="flex-1"
|
|
632
|
+
placeholder="Ask for a different recommendation…"
|
|
633
|
+
:disabled="frozen || recommending"
|
|
634
|
+
/>
|
|
635
|
+
<UButton
|
|
636
|
+
color="neutral"
|
|
637
|
+
variant="soft"
|
|
638
|
+
size="xs"
|
|
639
|
+
icon="i-lucide-rotate-cw"
|
|
640
|
+
:loading="recommending"
|
|
641
|
+
:disabled="!(reRequestNotes[rec.id] ?? '').trim() || frozen"
|
|
642
|
+
@click="reRequestRecommendation(rec)"
|
|
643
|
+
>
|
|
644
|
+
Re-request
|
|
645
|
+
</UButton>
|
|
646
|
+
</div>
|
|
647
|
+
</div>
|
|
648
|
+
</div>
|
|
649
|
+
</section>
|
|
650
|
+
|
|
418
651
|
<!-- incorporated document: the standard-format requirements -->
|
|
419
652
|
<section v-if="outline" class="mt-6 border-t border-slate-800 pt-5">
|
|
420
653
|
<div class="mb-3 flex items-center gap-1.5 text-[11px] text-emerald-400">
|
|
@@ -500,6 +733,20 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
|
|
|
500
733
|
>
|
|
501
734
|
Incorporate answers
|
|
502
735
|
</UButton>
|
|
736
|
+
<UButton
|
|
737
|
+
v-if="markedForRecommend.size > 0"
|
|
738
|
+
color="primary"
|
|
739
|
+
variant="soft"
|
|
740
|
+
size="sm"
|
|
741
|
+
block
|
|
742
|
+
icon="i-lucide-wand-2"
|
|
743
|
+
:loading="recommending"
|
|
744
|
+
@click="requestRecommendations"
|
|
745
|
+
>
|
|
746
|
+
Request {{ markedForRecommend.size }} recommendation{{
|
|
747
|
+
markedForRecommend.size === 1 ? '' : 's'
|
|
748
|
+
}}
|
|
749
|
+
</UButton>
|
|
503
750
|
<p class="text-[11px] leading-relaxed text-slate-500">
|
|
504
751
|
<template v-if="canProceed">
|
|
505
752
|
Every finding is dismissed — proceed to the next phase without reworking.
|
|
@@ -82,6 +82,39 @@ export function reviewsApi({ http, ws }: ApiContext) {
|
|
|
82
82
|
{ method: 'POST', body: { choice } },
|
|
83
83
|
),
|
|
84
84
|
|
|
85
|
+
// Ask the Requirement Writer to recommend grounded answers for a batch of findings (by
|
|
86
|
+
// item id). Returns the review with `ready` recommendations for the human to act on.
|
|
87
|
+
requestRecommendations: (workspaceId: string, blockId: string, itemIds: string[]) =>
|
|
88
|
+
http<RequirementReview | null>(
|
|
89
|
+
`${ws(workspaceId)}/blocks/${encodeURIComponent(blockId)}/requirement-review/recommend`,
|
|
90
|
+
{ method: 'POST', body: { itemIds } },
|
|
91
|
+
),
|
|
92
|
+
|
|
93
|
+
// Accept a recommendation (becomes the finding's answer), reject it, or re-request it
|
|
94
|
+
// with a "do it differently" note.
|
|
95
|
+
acceptRecommendation: (workspaceId: string, reviewId: string, recId: string) =>
|
|
96
|
+
http<RequirementReview>(
|
|
97
|
+
`${ws(workspaceId)}/requirement-reviews/${encodeURIComponent(reviewId)}/recommendations/${encodeURIComponent(recId)}/accept`,
|
|
98
|
+
{ method: 'POST' },
|
|
99
|
+
),
|
|
100
|
+
|
|
101
|
+
rejectRecommendation: (workspaceId: string, reviewId: string, recId: string) =>
|
|
102
|
+
http<RequirementReview>(
|
|
103
|
+
`${ws(workspaceId)}/requirement-reviews/${encodeURIComponent(reviewId)}/recommendations/${encodeURIComponent(recId)}/reject`,
|
|
104
|
+
{ method: 'POST' },
|
|
105
|
+
),
|
|
106
|
+
|
|
107
|
+
reRequestRecommendation: (
|
|
108
|
+
workspaceId: string,
|
|
109
|
+
reviewId: string,
|
|
110
|
+
recId: string,
|
|
111
|
+
note: string,
|
|
112
|
+
) =>
|
|
113
|
+
http<RequirementReview>(
|
|
114
|
+
`${ws(workspaceId)}/requirement-reviews/${encodeURIComponent(reviewId)}/recommendations/${encodeURIComponent(recId)}/re-request`,
|
|
115
|
+
{ method: 'POST', body: { note } },
|
|
116
|
+
),
|
|
117
|
+
|
|
85
118
|
// ---- clarity review (bug-report triage reviewer agent) ---------------
|
|
86
119
|
// The current review for a block (null when none has been run). A 503 means
|
|
87
120
|
// the feature is unconfigured (the panel hides on any error here).
|
|
@@ -2,7 +2,7 @@ import { useRequirementsStore } from '~/stores/requirements'
|
|
|
2
2
|
import { useClarityStore } from '~/stores/clarity'
|
|
3
3
|
|
|
4
4
|
/** The async stage an iterative reviewer gate is mid-cycle in, or null. */
|
|
5
|
-
export type ReviewStage = 'incorporating' | 'reviewing' | null
|
|
5
|
+
export type ReviewStage = 'incorporating' | 'reviewing' | 'recommending' | null
|
|
6
6
|
|
|
7
7
|
// Both iterative reviewer gates (`requirements-review` over a feature brief and
|
|
8
8
|
// `clarity-review` over a bug report) drive the same answer → incorporate → re-review
|
|
@@ -29,6 +29,8 @@ export const useRequirementsStore = defineStore('requirements', () => {
|
|
|
29
29
|
const reviewing = ref<Set<string>>(new Set())
|
|
30
30
|
/** Review ids currently incorporating their answers. */
|
|
31
31
|
const incorporating = ref<Set<string>>(new Set())
|
|
32
|
+
/** Block ids whose Requirement Writer is currently producing recommendations. */
|
|
33
|
+
const recommending = ref<Set<string>>(new Set())
|
|
32
34
|
/** Block ids whose current review is being fetched (the initial `load`). */
|
|
33
35
|
const loadingByBlock = ref<Set<string>>(new Set())
|
|
34
36
|
/**
|
|
@@ -48,7 +50,8 @@ export const useRequirementsStore = defineStore('requirements', () => {
|
|
|
48
50
|
* needed — so the board suppresses the "Approval needed" gate and shows this working state
|
|
49
51
|
* instead, with copy that names which of the two stages is running.
|
|
50
52
|
*/
|
|
51
|
-
function backgroundStage(blockId: string): 'incorporating' | 'reviewing' | null {
|
|
53
|
+
function backgroundStage(blockId: string): 'incorporating' | 'reviewing' | 'recommending' | null {
|
|
54
|
+
if (recommending.value.has(blockId)) return 'recommending'
|
|
52
55
|
const status = reviews.value[blockId]?.status
|
|
53
56
|
return status === 'incorporating' || status === 'reviewing' ? status : null
|
|
54
57
|
}
|
|
@@ -172,6 +175,47 @@ export const useRequirementsStore = defineStore('requirements', () => {
|
|
|
172
175
|
return updated
|
|
173
176
|
}
|
|
174
177
|
|
|
178
|
+
function isRecommending(blockId: string): boolean {
|
|
179
|
+
return recommending.value.has(blockId)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Ask the Requirement Writer to recommend answers for a batch of findings (by item id).
|
|
184
|
+
* Runs the Writer inline (grounded on best-practice fragments → spec/tech-spec → web) and
|
|
185
|
+
* returns the review with `ready` recommendations to accept/reject. Shows a `recommending`
|
|
186
|
+
* background stage on the board while it runs.
|
|
187
|
+
*/
|
|
188
|
+
async function requestRecommendations(blockId: string, itemIds: string[]) {
|
|
189
|
+
withFlag(recommending, blockId, true)
|
|
190
|
+
try {
|
|
191
|
+
const updated = await api.requestRecommendations(workspace.requireId(), blockId, itemIds)
|
|
192
|
+
if (updated) store(updated)
|
|
193
|
+
return updated
|
|
194
|
+
} finally {
|
|
195
|
+
withFlag(recommending, blockId, false)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Accept a recommendation (becomes the finding's answer, folded into the next incorporation). */
|
|
200
|
+
async function acceptRecommendation(review: RequirementReview, recId: string) {
|
|
201
|
+
store(await api.acceptRecommendation(workspace.requireId(), review.id, recId))
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Reject a recommendation (the human then dismisses / answers manually / re-requests). */
|
|
205
|
+
async function rejectRecommendation(review: RequirementReview, recId: string) {
|
|
206
|
+
store(await api.rejectRecommendation(workspace.requireId(), review.id, recId))
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Re-request a recommendation with a "do it differently" note. */
|
|
210
|
+
async function reRequestRecommendation(review: RequirementReview, recId: string, note: string) {
|
|
211
|
+
withFlag(recommending, review.blockId, true)
|
|
212
|
+
try {
|
|
213
|
+
store(await api.reRequestRecommendation(workspace.requireId(), review.id, recId, note))
|
|
214
|
+
} finally {
|
|
215
|
+
withFlag(recommending, review.blockId, false)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
175
219
|
/** Resolve a capped review: extra-round / proceed / stop-reset. */
|
|
176
220
|
async function resolveExceeded(
|
|
177
221
|
blockId: string,
|
|
@@ -190,6 +234,7 @@ export const useRequirementsStore = defineStore('requirements', () => {
|
|
|
190
234
|
isReviewing,
|
|
191
235
|
isLoading,
|
|
192
236
|
isIncorporating,
|
|
237
|
+
isRecommending,
|
|
193
238
|
openCount,
|
|
194
239
|
answeredCount,
|
|
195
240
|
allSettled,
|
|
@@ -202,6 +247,10 @@ export const useRequirementsStore = defineStore('requirements', () => {
|
|
|
202
247
|
reReview,
|
|
203
248
|
proceed,
|
|
204
249
|
resolveExceeded,
|
|
250
|
+
requestRecommendations,
|
|
251
|
+
acceptRecommendation,
|
|
252
|
+
rejectRecommendation,
|
|
253
|
+
reRequestRecommendation,
|
|
205
254
|
// Patch the cache from a live `requirements` stream event.
|
|
206
255
|
upsert: store,
|
|
207
256
|
}
|
|
@@ -10,7 +10,12 @@ export type ReviewItemCategory = 'gap' | 'clarification' | 'assumption' | 'risk'
|
|
|
10
10
|
|
|
11
11
|
export type ReviewItemSeverity = 'low' | 'medium' | 'high'
|
|
12
12
|
|
|
13
|
-
export type ReviewItemStatus =
|
|
13
|
+
export type ReviewItemStatus =
|
|
14
|
+
| 'open'
|
|
15
|
+
| 'answered'
|
|
16
|
+
| 'resolved'
|
|
17
|
+
| 'dismissed'
|
|
18
|
+
| 'recommend_requested'
|
|
14
19
|
|
|
15
20
|
export interface RequirementReviewItem {
|
|
16
21
|
id: string
|
|
@@ -45,6 +50,25 @@ export type RequirementReviewStatus =
|
|
|
45
50
|
/** How a human resolves a review that hit its iteration cap. */
|
|
46
51
|
export type ResolveRequirementsExceededChoice = 'extra-round' | 'proceed' | 'stop-reset'
|
|
47
52
|
|
|
53
|
+
/** Lifecycle of a Requirement-Writer recommendation. */
|
|
54
|
+
export type RecommendationStatus = 'ready' | 'accepted' | 'rejected'
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A Requirement-Writer suggestion for one finding. First-class on the review (survives the
|
|
58
|
+
* re-review item churn); the source finding is snapshotted by title/detail. `groundedInFragment`
|
|
59
|
+
* marks a suggestion taken straight from a best-practice fragment (the "current standard").
|
|
60
|
+
*/
|
|
61
|
+
export interface RequirementRecommendation {
|
|
62
|
+
id: string
|
|
63
|
+
sourceFinding: { title: string; detail: string }
|
|
64
|
+
recommendedText: string
|
|
65
|
+
status: RecommendationStatus
|
|
66
|
+
note: string | null
|
|
67
|
+
groundedInFragment: { id: string; title: string } | null
|
|
68
|
+
createdAt: number
|
|
69
|
+
updatedAt: number
|
|
70
|
+
}
|
|
71
|
+
|
|
48
72
|
export interface RequirementReview {
|
|
49
73
|
id: string
|
|
50
74
|
blockId: string
|
|
@@ -56,6 +80,8 @@ export interface RequirementReview {
|
|
|
56
80
|
iteration: number
|
|
57
81
|
/** The reviewer-pass budget (from the task's merge preset; an extra round bumps it). */
|
|
58
82
|
maxIterations: number
|
|
83
|
+
/** Requirement-Writer suggestions awaiting (or settled by) human accept/reject. */
|
|
84
|
+
recommendations: RequirementRecommendation[]
|
|
59
85
|
createdAt: number
|
|
60
86
|
updatedAt: number
|
|
61
87
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.1",
|
|
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",
|