@cat-factory/app 0.18.0 → 0.19.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.
@@ -117,7 +117,9 @@ const reviewStageLabel = computed(() =>
117
117
  ? 'Incorporating answers…'
118
118
  : reviewStage.value === 'reviewing'
119
119
  ? 'Re-reviewing…'
120
- : null,
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) {
@@ -14,6 +14,7 @@ const documents = useDocumentsStore()
14
14
  const tasks = useTasksStore()
15
15
  const tracker = useTrackerStore()
16
16
  const releaseHealth = useReleaseHealthStore()
17
+ const userSecrets = useUserSecretsStore()
17
18
 
18
19
  // The selected filing tracker, as a badge label ("GitHub Issues" / "Jira").
19
20
  const trackerLabel = computed(() => {
@@ -27,7 +28,10 @@ const trackerLabel = computed(() => {
27
28
  watch(
28
29
  () => ui.integrationsOpen,
29
30
  (isOpen) => {
30
- if (isOpen) void releaseHealth.ensureLoaded().catch(() => {})
31
+ if (isOpen) {
32
+ void releaseHealth.ensureLoaded().catch(() => {})
33
+ void userSecrets.load().catch(() => {})
34
+ }
31
35
  },
32
36
  )
33
37
 
@@ -75,6 +79,20 @@ const groups = computed<IntegrationGroup[]>(() => {
75
79
  onClick: () => go(ui.openGitHub),
76
80
  })
77
81
  }
82
+ // Per-user GitHub PAT — works on every runtime (used for runs you initiate). Always
83
+ // offered; the badge reflects whether the signed-in user has stored one.
84
+ {
85
+ const pat = userSecrets.statusFor('github_pat')
86
+ code.push({
87
+ key: 'github-pat',
88
+ icon: 'i-lucide-key-round',
89
+ label: 'My GitHub token',
90
+ description: 'A personal access token used for runs you start (pushes, PRs, CI, merge).',
91
+ status: pat ? 'Connected' : undefined,
92
+ connected: !!pat,
93
+ onClick: () => go(ui.openUserSecrets),
94
+ })
95
+ }
78
96
  if (code.length) out.push({ title: 'Source control', items: code })
79
97
 
80
98
  // --- Communication ---------------------------------------------------------
@@ -22,7 +22,9 @@ const reviewStageLabel = computed(() =>
22
22
  ? 'Incorporating…'
23
23
  : reviewStage.value === 'reviewing'
24
24
  ? 'Re-reviewing…'
25
- : null,
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
- : null
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
- async function submitReply(item: RequirementReviewItem) {
124
- if (!review.value) return
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). Disabled once the
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
- :placeholder="item.reply ? 'Refine your answer…' : 'Answer this finding…'"
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.
@@ -0,0 +1,214 @@
1
+ <script setup lang="ts">
2
+ // Per-user settings: "My GitHub token" (and future per-user repository/provider secrets).
3
+ // A generic, descriptor-driven connect form: the backend declares each kind's fields
4
+ // (one secret + optional metadata) and whether a connection test is available; this
5
+ // renders them without hard-coding any kind. Stored PER USER (runs you initiate use YOUR
6
+ // access); the secret is write-only server-side and never shown again.
7
+ import { computed, ref, watch } from 'vue'
8
+ import type { ProviderConfigField, UserSecretKind } from '~/types/userSecrets'
9
+
10
+ const ui = useUiStore()
11
+ const store = useUserSecretsStore()
12
+ const toast = useToast()
13
+
14
+ const open = computed({
15
+ get: () => ui.userSecretsOpen,
16
+ set: (v: boolean) => (v ? ui.openUserSecrets() : ui.closeUserSecrets()),
17
+ })
18
+
19
+ // The kind being edited (only `github_pat` today; descriptors drive the rest generically).
20
+ const kind = ref<UserSecretKind>('github_pat')
21
+ const descriptor = computed(() => store.descriptorFor(kind.value))
22
+ const status = computed(() => store.statusFor(kind.value))
23
+
24
+ // Per-field draft values, keyed by field key. The secret field maps to the wire `secret`;
25
+ // all other fields map into `metadata`.
26
+ const values = ref<Record<string, string>>({})
27
+ const labelDraft = ref('')
28
+ const testResult = ref<{ ok: boolean; message?: string } | null>(null)
29
+ const testing = ref(false)
30
+ const busy = ref(false)
31
+
32
+ function resetDraft() {
33
+ values.value = {}
34
+ labelDraft.value = ''
35
+ testResult.value = null
36
+ // Prefill non-secret metadata from the stored status (secret stays blank — write-only).
37
+ const meta = status.value?.metadata
38
+ if (meta) for (const [k, v] of Object.entries(meta)) values.value[k] = v
39
+ }
40
+
41
+ watch(open, (isOpen) => {
42
+ if (isOpen) void store.load().then(resetDraft)
43
+ })
44
+ watch(kind, resetDraft)
45
+
46
+ const secretField = computed<ProviderConfigField | undefined>(() =>
47
+ descriptor.value?.configFields.find((f) => f.secret),
48
+ )
49
+ const metadataFields = computed<ProviderConfigField[]>(() =>
50
+ (descriptor.value?.configFields ?? []).filter((f) => !f.secret),
51
+ )
52
+
53
+ /** Build the wire payload: the secret field → `secret`, the rest → `metadata`. */
54
+ function buildPayload(): { secret: string; metadata?: Record<string, string> } | null {
55
+ const sf = secretField.value
56
+ if (!sf) return null
57
+ const secret = (values.value[sf.key] ?? '').trim()
58
+ if (!secret) return null
59
+ const metadata: Record<string, string> = {}
60
+ for (const f of metadataFields.value) {
61
+ const v = (values.value[f.key] ?? '').trim()
62
+ if (v) metadata[f.key] = v
63
+ }
64
+ return { secret, ...(Object.keys(metadata).length ? { metadata } : {}) }
65
+ }
66
+
67
+ function notifyError(title: string, e: unknown) {
68
+ toast.add({
69
+ title,
70
+ description: e instanceof Error ? e.message : String(e),
71
+ icon: 'i-lucide-triangle-alert',
72
+ color: 'error',
73
+ })
74
+ }
75
+
76
+ async function test() {
77
+ const payload = buildPayload()
78
+ if (!payload) return
79
+ testing.value = true
80
+ testResult.value = null
81
+ try {
82
+ testResult.value = await store.test(kind.value, payload)
83
+ } catch (e) {
84
+ testResult.value = { ok: false, message: e instanceof Error ? e.message : String(e) }
85
+ } finally {
86
+ testing.value = false
87
+ }
88
+ }
89
+
90
+ async function save() {
91
+ const payload = buildPayload()
92
+ if (!payload) return
93
+ busy.value = true
94
+ try {
95
+ await store.store(kind.value, { ...payload, label: labelDraft.value.trim() || undefined })
96
+ values.value[secretField.value!.key] = ''
97
+ testResult.value = null
98
+ toast.add({
99
+ title: `${descriptor.value?.label ?? 'Secret'} saved`,
100
+ icon: 'i-lucide-check',
101
+ color: 'success',
102
+ })
103
+ } catch (e) {
104
+ notifyError('Could not save secret', e)
105
+ } finally {
106
+ busy.value = false
107
+ }
108
+ }
109
+
110
+ async function remove() {
111
+ busy.value = true
112
+ try {
113
+ await store.remove(kind.value)
114
+ resetDraft()
115
+ toast.add({ title: 'Secret removed', icon: 'i-lucide-check' })
116
+ } catch (e) {
117
+ notifyError('Could not remove secret', e)
118
+ } finally {
119
+ busy.value = false
120
+ }
121
+ }
122
+ </script>
123
+
124
+ <template>
125
+ <UModal v-model:open="open" title="My GitHub token" :ui="{ content: 'max-w-xl' }">
126
+ <template #body>
127
+ <div class="space-y-4">
128
+ <p class="text-xs text-slate-400">
129
+ Store a personal access token used for the runs <strong>you</strong> start — pushes, pull
130
+ requests, the CI gate and merges are attributed to your GitHub access. Stored
131
+ <span class="text-slate-300">just for you</span>; the token is write-only and never shown
132
+ again.
133
+ </p>
134
+
135
+ <div
136
+ v-if="status"
137
+ class="flex items-center justify-between rounded-md border border-slate-700 bg-slate-900/50 px-3 py-2 text-sm"
138
+ >
139
+ <div>
140
+ <span class="font-medium text-slate-200">{{ status.label }}</span>
141
+ <div class="text-[11px] text-emerald-400">Connected · token stored</div>
142
+ </div>
143
+ <UButton
144
+ icon="i-lucide-trash-2"
145
+ color="error"
146
+ variant="ghost"
147
+ size="xs"
148
+ :disabled="busy"
149
+ @click="remove()"
150
+ />
151
+ </div>
152
+
153
+ <div
154
+ v-if="descriptor"
155
+ class="rounded-lg border border-dashed border-slate-700 p-3 space-y-3"
156
+ >
157
+ <p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
158
+ {{ status ? 'Replace token' : 'Add a token' }}
159
+ </p>
160
+
161
+ <UFormField label="Label (optional)">
162
+ <UInput v-model="labelDraft" :placeholder="descriptor.label" />
163
+ </UFormField>
164
+
165
+ <UFormField
166
+ v-for="field in descriptor.configFields"
167
+ :key="field.key"
168
+ :label="field.label + (field.required ? '' : ' (optional)')"
169
+ :help="field.help"
170
+ >
171
+ <UInput
172
+ v-model="values[field.key]"
173
+ :type="field.secret ? 'password' : 'text'"
174
+ class="font-mono"
175
+ :placeholder="field.placeholder"
176
+ />
177
+ </UFormField>
178
+
179
+ <div v-if="descriptor.supportsTest" class="flex items-center gap-2">
180
+ <UButton
181
+ color="neutral"
182
+ variant="soft"
183
+ size="sm"
184
+ icon="i-lucide-plug-zap"
185
+ :loading="testing"
186
+ :disabled="!buildPayload()"
187
+ @click="test()"
188
+ >
189
+ Test connection
190
+ </UButton>
191
+ <span v-if="testResult && testResult.ok" class="text-xs text-emerald-400">
192
+ {{ testResult.message ?? 'Token valid' }}
193
+ </span>
194
+ <span v-else-if="testResult" class="text-xs text-rose-400">
195
+ {{ testResult.message ?? 'Token rejected' }}
196
+ </span>
197
+ </div>
198
+
199
+ <div class="flex justify-end">
200
+ <UButton
201
+ color="primary"
202
+ size="sm"
203
+ :loading="busy"
204
+ :disabled="!buildPayload()"
205
+ @click="save()"
206
+ >
207
+ {{ status ? 'Save' : 'Add token' }}
208
+ </UButton>
209
+ </div>
210
+ </div>
211
+ </div>
212
+ </template>
213
+ </UModal>
214
+ </template>
@@ -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).
@@ -0,0 +1,31 @@
1
+ import type {
2
+ ConnectionTestResult,
3
+ StoreUserSecretInput,
4
+ TestUserSecretInput,
5
+ UserSecretDescriptor,
6
+ UserSecretKind,
7
+ UserSecretStatus,
8
+ } from '~/types/userSecrets'
9
+ import type { ApiContext } from './context'
10
+
11
+ // Per-USER generic secrets (a GitHub PAT today). User-scoped (no workspace); the secret
12
+ // is write-only server-side and never returned — only status metadata + a `hasSecret`
13
+ // flag. `descriptors` drive the generic connect form; `test` probes before save.
14
+ export function userSecretsApi({ http }: ApiContext) {
15
+ return {
16
+ listUserSecrets: () =>
17
+ http<{ secrets: UserSecretStatus[]; descriptors: UserSecretDescriptor[] }>('/user-secrets'),
18
+
19
+ storeUserSecret: (kind: UserSecretKind, body: StoreUserSecretInput) =>
20
+ http<UserSecretStatus>(`/user-secrets/${encodeURIComponent(kind)}`, { method: 'POST', body }),
21
+
22
+ deleteUserSecret: (kind: UserSecretKind) =>
23
+ http(`/user-secrets/${encodeURIComponent(kind)}`, { method: 'DELETE' }),
24
+
25
+ testUserSecret: (kind: UserSecretKind, body: TestUserSecretInput) =>
26
+ http<ConnectionTestResult>(`/user-secrets/${encodeURIComponent(kind)}/test`, {
27
+ method: 'POST',
28
+ body,
29
+ }),
30
+ }
31
+ }
@@ -17,6 +17,7 @@ import { reviewsApi } from './api/reviews'
17
17
  import { slackApi } from './api/slack'
18
18
  import { specApi } from './api/spec'
19
19
  import { tasksApi } from './api/tasks'
20
+ import { userSecretsApi } from './api/userSecrets'
20
21
  import { workspacesApi } from './api/workspaces'
21
22
 
22
23
  /**
@@ -87,5 +88,6 @@ export function useApi() {
87
88
  ...githubApi(ctx),
88
89
  ...slackApi(ctx),
89
90
  ...bootstrapApi(ctx),
91
+ ...userSecretsApi(ctx),
90
92
  }
91
93
  }
@@ -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
@@ -31,6 +31,7 @@ import WorkspaceSettingsPanel from '~/components/settings/WorkspaceSettingsPanel
31
31
  import ObservabilityConnectionPanel from '~/components/settings/ObservabilityConnectionPanel.vue'
32
32
  import ModelConfigurationPanel from '~/components/settings/ModelConfigurationPanel.vue'
33
33
  import LocalModelEndpointsPanel from '~/components/settings/LocalModelEndpointsPanel.vue'
34
+ import UserSecretsSection from '~/components/settings/UserSecretsSection.vue'
34
35
  import OpenRouterCatalogPanel from '~/components/settings/OpenRouterCatalogPanel.vue'
35
36
  import VendorCredentialsModal from '~/components/providers/VendorCredentialsModal.vue'
36
37
  import PersonalCredentialModal from '~/components/providers/PersonalCredentialModal.vue'
@@ -183,6 +184,7 @@ watch(
183
184
  <ObservabilityConnectionPanel />
184
185
  <ModelConfigurationPanel />
185
186
  <LocalModelEndpointsPanel />
187
+ <UserSecretsSection />
186
188
  <OpenRouterCatalogPanel />
187
189
  <VendorCredentialsModal />
188
190
  <PersonalCredentialModal />
@@ -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
  }
package/app/stores/ui.ts CHANGED
@@ -102,6 +102,7 @@ export const useUiStore = defineStore('ui', () => {
102
102
  const vendorCredentialsOpen = ref(false)
103
103
  // Per-user settings panel: the signed-in user's own-machine local model runners.
104
104
  const localModelsOpen = ref(false)
105
+ const userSecretsOpen = ref(false)
105
106
  // Per-workspace settings panel: the OpenRouter dynamic catalog (browse/enable gateway models).
106
107
  const openRouterOpen = ref(false)
107
108
 
@@ -358,6 +359,12 @@ export const useUiStore = defineStore('ui', () => {
358
359
  function closeLocalModels() {
359
360
  localModelsOpen.value = false
360
361
  }
362
+ function openUserSecrets() {
363
+ userSecretsOpen.value = true
364
+ }
365
+ function closeUserSecrets() {
366
+ userSecretsOpen.value = false
367
+ }
361
368
  function openOpenRouter() {
362
369
  openRouterOpen.value = true
363
370
  }
@@ -451,6 +458,7 @@ export const useUiStore = defineStore('ui', () => {
451
458
  modelConfigOpen,
452
459
  vendorCredentialsOpen,
453
460
  localModelsOpen,
461
+ userSecretsOpen,
454
462
  openRouterOpen,
455
463
  aiProviderSetupOpen,
456
464
  aiPresetMismatchOpen,
@@ -512,6 +520,8 @@ export const useUiStore = defineStore('ui', () => {
512
520
  closeVendorCredentials,
513
521
  openLocalModels,
514
522
  closeLocalModels,
523
+ openUserSecrets,
524
+ closeUserSecrets,
515
525
  openOpenRouter,
516
526
  closeOpenRouter,
517
527
  openAiProviderSetup,
@@ -0,0 +1,64 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref } from 'vue'
3
+ import type {
4
+ ConnectionTestResult,
5
+ StoreUserSecretInput,
6
+ TestUserSecretInput,
7
+ UserSecretDescriptor,
8
+ UserSecretKind,
9
+ UserSecretStatus,
10
+ } from '~/types/userSecrets'
11
+
12
+ // The signed-in user's generic secrets (a GitHub PAT today). Stored PER USER (a run you
13
+ // initiate uses YOUR GitHub access), write-only server-side — this store carries only the
14
+ // status metadata + a `hasSecret` flag, plus the kind descriptors that drive the generic
15
+ // connect form. Loaded INDEPENDENTLY of the workspace snapshot, like local runners.
16
+ export const useUserSecretsStore = defineStore('userSecrets', () => {
17
+ const api = useApi()
18
+ const secrets = ref<UserSecretStatus[]>([])
19
+ const descriptors = ref<UserSecretDescriptor[]>([])
20
+ const loading = ref(false)
21
+
22
+ async function load() {
23
+ loading.value = true
24
+ try {
25
+ const { secrets: list, descriptors: descs } = await api.listUserSecrets()
26
+ secrets.value = list
27
+ descriptors.value = descs
28
+ } catch {
29
+ // Auth disabled / not signed in / feature off → nothing to surface.
30
+ secrets.value = []
31
+ descriptors.value = []
32
+ } finally {
33
+ loading.value = false
34
+ }
35
+ }
36
+
37
+ function statusFor(kind: UserSecretKind): UserSecretStatus | undefined {
38
+ return secrets.value.find((s) => s.kind === kind)
39
+ }
40
+
41
+ function descriptorFor(kind: UserSecretKind): UserSecretDescriptor | undefined {
42
+ return descriptors.value.find((d) => d.kind === kind)
43
+ }
44
+
45
+ async function store(kind: UserSecretKind, input: StoreUserSecretInput) {
46
+ const status = await api.storeUserSecret(kind, input)
47
+ secrets.value = [...secrets.value.filter((s) => s.kind !== kind), status]
48
+ return status
49
+ }
50
+
51
+ async function remove(kind: UserSecretKind) {
52
+ await api.deleteUserSecret(kind)
53
+ secrets.value = secrets.value.filter((s) => s.kind !== kind)
54
+ }
55
+
56
+ async function test(
57
+ kind: UserSecretKind,
58
+ input: TestUserSecretInput,
59
+ ): Promise<ConnectionTestResult> {
60
+ return await api.testUserSecret(kind, input)
61
+ }
62
+
63
+ return { secrets, descriptors, loading, load, statusFor, descriptorFor, store, remove, test }
64
+ })
@@ -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 = 'open' | 'answered' | 'resolved' | 'dismissed'
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
  }
@@ -0,0 +1,49 @@
1
+ // Frontend mirrors of the per-user secret + provider-config wire contracts
2
+ // (`@cat-factory/contracts` user-secret.ts + provider-config.ts).
3
+
4
+ export type UserSecretKind = 'github_pat'
5
+
6
+ /** One config value a kind needs, rendered as a single form field. */
7
+ export interface ProviderConfigField {
8
+ key: string
9
+ label: string
10
+ help?: string
11
+ placeholder?: string
12
+ secret?: boolean
13
+ required?: boolean
14
+ type?: 'text' | 'password' | 'select'
15
+ options?: { value: string; label: string }[]
16
+ }
17
+
18
+ /** Read-only status of one stored per-user secret — never the secret value. */
19
+ export interface UserSecretStatus {
20
+ kind: UserSecretKind
21
+ label: string
22
+ hasSecret: boolean
23
+ metadata?: Record<string, string>
24
+ connectedAt: number
25
+ }
26
+
27
+ /** A kind's self-description for the generic connect form. */
28
+ export interface UserSecretDescriptor {
29
+ kind: UserSecretKind
30
+ label: string
31
+ configFields: ProviderConfigField[]
32
+ supportsTest: boolean
33
+ }
34
+
35
+ export interface StoreUserSecretInput {
36
+ label?: string
37
+ secret: string
38
+ metadata?: Record<string, string>
39
+ }
40
+
41
+ export interface TestUserSecretInput {
42
+ secret: string
43
+ metadata?: Record<string, string>
44
+ }
45
+
46
+ export interface ConnectionTestResult {
47
+ ok: boolean
48
+ message?: string
49
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.18.0",
3
+ "version": "0.19.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",