@cat-factory/app 0.17.2 → 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.
@@ -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) {
@@ -24,6 +24,30 @@ const services = useServicesStore()
24
24
  const composePath = computed(() => props.block.testComposePath ?? '')
25
25
  const noInfra = computed(() => props.block.noInfraDependencies === true)
26
26
 
27
+ // The default test environment a task under this service is spawned with. `local`
28
+ // stands the dependencies up via docker-compose (or "no infra"); `ephemeral` runs
29
+ // against a provisioned environment. A task can override it per-task in its agent
30
+ // settings. Absent ⇒ the built-in `ephemeral`.
31
+ type TestEnvironment = 'local' | 'ephemeral'
32
+ const TEST_ENVIRONMENTS: { value: TestEnvironment; label: string; hint: string }[] = [
33
+ {
34
+ value: 'ephemeral',
35
+ label: 'Ephemeral environment',
36
+ hint: 'tests run against a provisioned env',
37
+ },
38
+ { value: 'local', label: 'Local (docker-compose)', hint: 'the Tester stands deps up locally' },
39
+ ]
40
+ const effectiveTestEnv = computed<TestEnvironment>(
41
+ () => props.block.defaultTestEnvironment ?? 'ephemeral',
42
+ )
43
+ function setDefaultTestEnv(value: TestEnvironment) {
44
+ board.updateBlock(props.block.id, { defaultTestEnvironment: value })
45
+ }
46
+
47
+ // The provisioning hints (cloud provider + instance size) are advisory inputs to the
48
+ // ephemeral-environment provisioner, not commonly tuned — keep them collapsed by default.
49
+ const showProvisioning = ref(false)
50
+
27
51
  // The repo + service subdirectory backing this frame, for the compose-file browser.
28
52
  // A monorepo service isn't on the `github_repos` blockId link (that stays null), so
29
53
  // fall back to the service catalog mapping, which carries the repo + directory.
@@ -93,6 +117,27 @@ const missingInfra = computed(() => !noInfra.value && composePath.value.trim() =
93
117
  Test infrastructure
94
118
  </div>
95
119
 
120
+ <div class="space-y-1">
121
+ <span class="text-[11px] text-slate-400">Default test environment</span>
122
+ <div class="flex flex-wrap gap-1">
123
+ <UButton
124
+ v-for="e in TEST_ENVIRONMENTS"
125
+ :key="e.value"
126
+ :color="effectiveTestEnv === e.value ? 'primary' : 'neutral'"
127
+ :variant="effectiveTestEnv === e.value ? 'soft' : 'ghost'"
128
+ size="xs"
129
+ :title="e.hint"
130
+ @click="setDefaultTestEnv(e.value)"
131
+ >
132
+ {{ e.label }}
133
+ </UButton>
134
+ </div>
135
+ <p class="text-[11px] leading-snug text-slate-500">
136
+ The default tasks under this service are spawned with — each task can override it in its
137
+ agent settings.
138
+ </p>
139
+ </div>
140
+
96
141
  <div class="space-y-1">
97
142
  <label class="text-[11px] text-slate-400">docker-compose path</label>
98
143
  <div class="flex items-center gap-1">
@@ -163,35 +208,59 @@ const missingInfra = computed(() => !noInfra.value && composePath.value.trim() =
163
208
  Tester won't start.
164
209
  </p>
165
210
 
166
- <div class="space-y-1">
167
- <span class="text-[11px] text-slate-400">Cloud provider</span>
168
- <div class="flex flex-wrap gap-1">
169
- <UButton
170
- v-for="p in PROVIDERS"
171
- :key="p.value"
172
- :color="effectiveProvider === p.value ? 'primary' : 'neutral'"
173
- :variant="effectiveProvider === p.value ? 'soft' : 'ghost'"
174
- size="xs"
175
- @click="setProvider(p.value)"
176
- >
177
- {{ p.label }}
178
- </UButton>
179
- </div>
180
- </div>
211
+ <!-- Provisioning hints: advisory inputs to the ephemeral-environment provisioner.
212
+ Collapsed by default — most services never tune them. -->
213
+ <div class="border-t border-slate-800 pt-2">
214
+ <button
215
+ type="button"
216
+ class="flex w-full items-center gap-1.5 text-left text-[11px] font-semibold uppercase tracking-wide text-slate-500 hover:text-slate-300"
217
+ @click="showProvisioning = !showProvisioning"
218
+ >
219
+ <UIcon
220
+ :name="showProvisioning ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
221
+ class="h-3.5 w-3.5"
222
+ />
223
+ Ephemeral environment provisioning
224
+ </button>
181
225
 
182
- <div class="space-y-1">
183
- <span class="text-[11px] text-slate-400">Instance size</span>
184
- <div class="flex flex-wrap gap-1">
185
- <UButton
186
- v-for="s in SIZES"
187
- :key="s.value"
188
- :color="(block.instanceSize ?? 'medium') === s.value ? 'primary' : 'neutral'"
189
- :variant="(block.instanceSize ?? 'medium') === s.value ? 'soft' : 'ghost'"
190
- size="xs"
191
- @click="setSize(s.value)"
192
- >
193
- {{ s.label }}
194
- </UButton>
226
+ <div v-if="showProvisioning" class="mt-2 space-y-3">
227
+ <p class="text-[11px] leading-snug text-slate-500">
228
+ A hint for provisioning this service's ephemeral test environment — which cloud provider
229
+ to deploy to and how large an instance to request. Ignored for local (docker-compose)
230
+ testing.
231
+ </p>
232
+
233
+ <div class="space-y-1">
234
+ <span class="text-[11px] text-slate-400">Cloud provider</span>
235
+ <div class="flex flex-wrap gap-1">
236
+ <UButton
237
+ v-for="p in PROVIDERS"
238
+ :key="p.value"
239
+ :color="effectiveProvider === p.value ? 'primary' : 'neutral'"
240
+ :variant="effectiveProvider === p.value ? 'soft' : 'ghost'"
241
+ size="xs"
242
+ @click="setProvider(p.value)"
243
+ >
244
+ {{ p.label }}
245
+ </UButton>
246
+ </div>
247
+ </div>
248
+
249
+ <div class="space-y-1">
250
+ <span class="text-[11px] text-slate-400">Instance size</span>
251
+ <div class="flex flex-wrap gap-1">
252
+ <UButton
253
+ v-for="s in SIZES"
254
+ :key="s.value"
255
+ :color="(block.instanceSize ?? 'medium') === s.value ? 'primary' : 'neutral'"
256
+ :variant="(block.instanceSize ?? 'medium') === s.value ? 'soft' : 'ghost'"
257
+ size="xs"
258
+ @click="setSize(s.value)"
259
+ >
260
+ {{ s.label }}
261
+ </UButton>
262
+ </div>
263
+ </div>
195
264
  </div>
196
265
  </div>
197
266
  </div>
@@ -29,6 +29,33 @@ const descriptors = computed(() => {
29
29
 
30
30
  const run = computed(() => execution.getByBlock(props.block.id))
31
31
 
32
+ // The Tester's environment descriptor inherits its default from the service frame this
33
+ // task lives under (set in the service inspector); a task only overrides it by clicking.
34
+ // Walk up the parent chain (frame → module → task) to find that default.
35
+ const serviceDefaultTestEnv = computed<'local' | 'ephemeral' | undefined>(() => {
36
+ let cur: Block | undefined = props.block
37
+ for (let i = 0; i < 8 && cur; i++) {
38
+ if (cur.level === 'frame') return cur.defaultTestEnvironment
39
+ if (!cur.parentId) break
40
+ cur = board.getBlock(cur.parentId)
41
+ }
42
+ return undefined
43
+ })
44
+
45
+ /** The effective default for a descriptor — the inherited service value for the Tester's
46
+ * environment, otherwise the descriptor's own static default. */
47
+ function effectiveDefault(d: { id: string; default: string }): string {
48
+ if (d.id === 'tester.environment' && serviceDefaultTestEnv.value) {
49
+ return serviceDefaultTestEnv.value
50
+ }
51
+ return d.default
52
+ }
53
+
54
+ /** Whether a descriptor's shown value is inherited (not explicitly pinned on this task). */
55
+ function isInherited(d: { id: string }): boolean {
56
+ return d.id === 'tester.environment' && props.block.agentConfig?.[d.id] === undefined
57
+ }
58
+
32
59
  /** A descriptor freezes once its contributing agent's step has left `pending`. */
33
60
  function isFrozen(agentKind: string): boolean {
34
61
  const steps = run.value?.steps
@@ -55,19 +82,24 @@ function setValue(id: string, value: string) {
55
82
  <div v-for="d in descriptors" :key="d.id" class="space-y-1">
56
83
  <div class="flex items-center justify-between">
57
84
  <span class="text-[11px] text-slate-400">{{ d.label }}</span>
58
- <UIcon
59
- v-if="isFrozen(d.agentKind)"
60
- name="i-lucide-lock"
61
- class="h-3 w-3 text-slate-500"
62
- title="Frozen — the agent has started"
63
- />
85
+ <div class="flex items-center gap-1.5">
86
+ <span v-if="isInherited(d)" class="text-[10px] text-slate-500"
87
+ >inherited from service</span
88
+ >
89
+ <UIcon
90
+ v-if="isFrozen(d.agentKind)"
91
+ name="i-lucide-lock"
92
+ class="h-3 w-3 text-slate-500"
93
+ title="Frozen — the agent has started"
94
+ />
95
+ </div>
64
96
  </div>
65
97
  <div class="flex flex-wrap gap-1">
66
98
  <UButton
67
99
  v-for="opt in d.options"
68
100
  :key="opt.value"
69
- :color="valueOf(d.id, d.default) === opt.value ? 'primary' : 'neutral'"
70
- :variant="valueOf(d.id, d.default) === opt.value ? 'soft' : 'ghost'"
101
+ :color="valueOf(d.id, effectiveDefault(d)) === opt.value ? 'primary' : 'neutral'"
102
+ :variant="valueOf(d.id, effectiveDefault(d)) === opt.value ? 'soft' : 'ghost'"
71
103
  size="xs"
72
104
  :disabled="isFrozen(d.agentKind)"
73
105
  @click="setValue(d.id, opt.value)"
@@ -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))
@@ -203,12 +203,17 @@ async function clone(p: Pipeline) {
203
203
  </script>
204
204
 
205
205
  <template>
206
- <USlideover v-model:open="open" title="Pipeline builder" side="left">
206
+ <USlideover
207
+ v-model:open="open"
208
+ title="Pipeline builder"
209
+ side="left"
210
+ :ui="{ content: 'max-w-[90vw] sm:max-w-2xl lg:max-w-5xl xl:max-w-6xl' }"
211
+ >
207
212
  <template #body>
208
- <div class="grid h-full grid-cols-2 gap-4">
213
+ <div class="grid h-full grid-cols-1 gap-4 lg:grid-cols-3">
209
214
  <!-- agent palette -->
210
- <div class="overflow-y-auto pr-1">
211
- <div class="mb-2 flex items-center justify-between gap-2">
215
+ <div class="flex min-h-0 flex-col overflow-hidden">
216
+ <div class="mb-2 flex shrink-0 items-center justify-between gap-2">
212
217
  <h3 class="text-xs font-semibold uppercase tracking-wide text-slate-400">
213
218
  Agent palette
214
219
  </h3>
@@ -222,11 +227,13 @@ async function clone(p: Pipeline) {
222
227
  Add agent
223
228
  </UButton>
224
229
  </div>
225
- <AgentPalette @add="add" />
230
+ <div class="min-h-0 flex-1 overflow-y-auto pr-1">
231
+ <AgentPalette @add="add" />
232
+ </div>
226
233
  </div>
227
234
 
228
235
  <!-- draft chain -->
229
- <div class="flex flex-col">
236
+ <div class="flex min-h-0 flex-col overflow-hidden">
230
237
  <div class="mb-2 flex items-center justify-between gap-2">
231
238
  <h3 class="text-xs font-semibold uppercase tracking-wide text-slate-400">Pipeline</h3>
232
239
  <UButton
@@ -286,7 +293,7 @@ async function clone(p: Pipeline) {
286
293
  Click agents on the left to assemble a linear pipeline.
287
294
  </div>
288
295
 
289
- <ol v-else class="flex-1 space-y-2 overflow-y-auto">
296
+ <ol v-else class="min-h-0 flex-1 space-y-2 overflow-y-auto pr-1">
290
297
  <li
291
298
  v-for="(unit, vi) in pipelines.units"
292
299
  :key="unit.index"
@@ -576,160 +583,160 @@ async function clone(p: Pipeline) {
576
583
  </div>
577
584
  </li>
578
585
  </ol>
586
+ </div>
579
587
 
580
- <!-- Saved pipelines: review the library + delete (the run affordance
581
- moved to the task card / inspector when the palettes were removed). -->
582
- <div v-if="pipelines.pipelines.length" class="mt-4 border-t border-slate-800 pt-3">
583
- <div class="mb-2 flex items-center justify-between gap-2">
584
- <h3 class="text-xs font-semibold uppercase tracking-wide text-slate-400">
585
- Saved pipelines
586
- </h3>
587
- <UButton
588
- v-if="archivedCount"
589
- :icon="showArchived ? 'i-lucide-archive-restore' : 'i-lucide-archive'"
590
- :color="showArchived ? 'primary' : 'neutral'"
591
- variant="ghost"
592
- size="xs"
593
- @click="showArchived = !showArchived"
594
- >
595
- {{ showArchived ? 'Hide archived' : `Archived (${archivedCount})` }}
596
- </UButton>
597
- </div>
598
-
599
- <!-- Label filter chips. -->
600
- <div v-if="allLabels.length" class="mb-2 flex flex-wrap items-center gap-1">
601
- <UBadge
602
- :color="labelFilter === null ? 'primary' : 'neutral'"
603
- variant="soft"
604
- size="xs"
605
- class="cursor-pointer"
606
- @click="labelFilter = null"
607
- >
608
- All
609
- </UBadge>
610
- <UBadge
611
- v-for="l in allLabels"
612
- :key="l"
613
- :color="labelFilter === l ? 'primary' : 'neutral'"
614
- variant="soft"
615
- size="xs"
616
- class="cursor-pointer"
617
- @click="labelFilter = labelFilter === l ? null : l"
618
- >
619
- {{ l }}
620
- </UBadge>
621
- </div>
622
-
623
- <ul class="space-y-1.5">
624
- <li
625
- v-for="p in visiblePipelines"
626
- :key="p.id"
627
- class="group rounded-lg border border-slate-700 bg-slate-800/40"
628
- :class="{ 'opacity-60': p.archived }"
629
- >
630
- <div class="flex items-center gap-2 px-2 py-1.5">
631
- <button
632
- type="button"
633
- class="flex min-w-0 flex-1 items-center gap-2 text-left"
634
- @click="toggleSaved(p.id)"
588
+ <!-- Saved pipelines: review the library + delete (the run affordance
589
+ moved to the task card / inspector when the palettes were removed). -->
590
+ <div v-if="pipelines.pipelines.length" class="flex min-h-0 flex-col overflow-hidden">
591
+ <div class="mb-2 flex shrink-0 items-center justify-between gap-2">
592
+ <h3 class="text-xs font-semibold uppercase tracking-wide text-slate-400">
593
+ Saved pipelines
594
+ </h3>
595
+ <UButton
596
+ v-if="archivedCount"
597
+ :icon="showArchived ? 'i-lucide-archive-restore' : 'i-lucide-archive'"
598
+ :color="showArchived ? 'primary' : 'neutral'"
599
+ variant="ghost"
600
+ size="xs"
601
+ @click="showArchived = !showArchived"
602
+ >
603
+ {{ showArchived ? 'Hide archived' : `Archived (${archivedCount})` }}
604
+ </UButton>
605
+ </div>
606
+
607
+ <!-- Label filter chips. -->
608
+ <div v-if="allLabels.length" class="mb-2 flex shrink-0 flex-wrap items-center gap-1">
609
+ <UBadge
610
+ :color="labelFilter === null ? 'primary' : 'neutral'"
611
+ variant="soft"
612
+ size="xs"
613
+ class="cursor-pointer"
614
+ @click="labelFilter = null"
615
+ >
616
+ All
617
+ </UBadge>
618
+ <UBadge
619
+ v-for="l in allLabels"
620
+ :key="l"
621
+ :color="labelFilter === l ? 'primary' : 'neutral'"
622
+ variant="soft"
623
+ size="xs"
624
+ class="cursor-pointer"
625
+ @click="labelFilter = labelFilter === l ? null : l"
626
+ >
627
+ {{ l }}
628
+ </UBadge>
629
+ </div>
630
+
631
+ <ul class="min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1">
632
+ <li
633
+ v-for="p in visiblePipelines"
634
+ :key="p.id"
635
+ class="group rounded-lg border border-slate-700 bg-slate-800/40"
636
+ :class="{ 'opacity-60': p.archived }"
637
+ >
638
+ <div class="flex items-center gap-2 px-2 py-1.5">
639
+ <button
640
+ type="button"
641
+ class="flex min-w-0 flex-1 items-center gap-2 text-left"
642
+ @click="toggleSaved(p.id)"
643
+ >
644
+ <UIcon
645
+ :name="
646
+ expandedSaved.has(p.id) ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'
647
+ "
648
+ class="h-3.5 w-3.5 shrink-0 text-slate-500"
649
+ />
650
+ <span class="min-w-0 flex-1 truncate text-xs text-slate-200">{{ p.name }}</span>
651
+ <UBadge
652
+ v-for="l in p.labels ?? []"
653
+ :key="l"
654
+ color="info"
655
+ variant="soft"
656
+ size="xs"
657
+ class="shrink-0"
635
658
  >
636
- <UIcon
637
- :name="
638
- expandedSaved.has(p.id) ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'
639
- "
640
- class="h-3.5 w-3.5 shrink-0 text-slate-500"
641
- />
642
- <span class="min-w-0 flex-1 truncate text-xs text-slate-200">{{ p.name }}</span>
643
- <UBadge
644
- v-for="l in p.labels ?? []"
645
- :key="l"
646
- color="info"
647
- variant="soft"
648
- size="xs"
649
- class="shrink-0"
650
- >
651
- {{ l }}
652
- </UBadge>
653
- <UBadge
654
- v-if="p.builtin"
655
- color="neutral"
656
- variant="soft"
657
- size="xs"
658
- class="shrink-0"
659
- >
660
- default
661
- </UBadge>
662
- <span class="shrink-0 text-[10px] text-slate-500">
663
- {{ p.agentKinds.length }} {{ p.agentKinds.length === 1 ? 'step' : 'steps' }}
664
- </span>
665
- </button>
666
- <div
667
- class="flex shrink-0 items-center opacity-0 transition group-hover:opacity-100"
659
+ {{ l }}
660
+ </UBadge>
661
+ <UBadge
662
+ v-if="p.builtin"
663
+ color="neutral"
664
+ variant="soft"
665
+ size="xs"
666
+ class="shrink-0"
668
667
  >
669
- <!-- Archive/unarchive: organize the library without deleting. Works on
668
+ default
669
+ </UBadge>
670
+ <span class="shrink-0 text-[10px] text-slate-500">
671
+ {{ p.agentKinds.length }} {{ p.agentKinds.length === 1 ? 'step' : 'steps' }}
672
+ </span>
673
+ </button>
674
+ <div
675
+ class="flex shrink-0 items-center opacity-0 transition group-hover:opacity-100"
676
+ >
677
+ <!-- Archive/unarchive: organize the library without deleting. Works on
670
678
  built-ins too (view metadata, not structure). -->
671
- <UButton
672
- :icon="p.archived ? 'i-lucide-archive-restore' : 'i-lucide-archive'"
673
- color="neutral"
674
- variant="ghost"
675
- size="xs"
676
- :title="p.archived ? 'Unarchive' : 'Archive (hide from the default view)'"
677
- @click="toggleArchive(p)"
678
- />
679
- <!-- Clone is available on every pipeline — it's how a read-only
679
+ <UButton
680
+ :icon="p.archived ? 'i-lucide-archive-restore' : 'i-lucide-archive'"
681
+ color="neutral"
682
+ variant="ghost"
683
+ size="xs"
684
+ :title="p.archived ? 'Unarchive' : 'Archive (hide from the default view)'"
685
+ @click="toggleArchive(p)"
686
+ />
687
+ <!-- Clone is available on every pipeline — it's how a read-only
680
688
  built-in template becomes an editable copy. -->
681
- <UButton
682
- icon="i-lucide-copy"
683
- color="neutral"
684
- variant="ghost"
685
- size="xs"
686
- :title="p.builtin ? 'Clone this default into an editable copy' : 'Clone'"
687
- @click="clone(p)"
688
- />
689
- <!-- Built-in templates are read-only; only custom pipelines edit in place. -->
690
- <UButton
691
- v-if="!p.builtin"
692
- icon="i-lucide-pencil"
693
- color="neutral"
694
- variant="ghost"
695
- size="xs"
696
- title="Edit this pipeline"
697
- @click="edit(p)"
698
- />
699
- <!-- Built-in templates are read-only — they can be cloned but not
689
+ <UButton
690
+ icon="i-lucide-copy"
691
+ color="neutral"
692
+ variant="ghost"
693
+ size="xs"
694
+ :title="p.builtin ? 'Clone this default into an editable copy' : 'Clone'"
695
+ @click="clone(p)"
696
+ />
697
+ <!-- Built-in templates are read-only; only custom pipelines edit in place. -->
698
+ <UButton
699
+ v-if="!p.builtin"
700
+ icon="i-lucide-pencil"
701
+ color="neutral"
702
+ variant="ghost"
703
+ size="xs"
704
+ title="Edit this pipeline"
705
+ @click="edit(p)"
706
+ />
707
+ <!-- Built-in templates are read-only — they can be cloned but not
700
708
  deleted (the backend rejects it too); only custom ones delete. -->
701
- <UButton
702
- v-if="!p.builtin"
703
- icon="i-lucide-trash-2"
704
- color="neutral"
705
- variant="ghost"
706
- size="xs"
707
- @click="pipelines.removePipeline(p.id)"
708
- />
709
- </div>
709
+ <UButton
710
+ v-if="!p.builtin"
711
+ icon="i-lucide-trash-2"
712
+ color="neutral"
713
+ variant="ghost"
714
+ size="xs"
715
+ @click="pipelines.removePipeline(p.id)"
716
+ />
710
717
  </div>
718
+ </div>
711
719
 
712
- <!-- Full ordered step list, revealed on click. -->
713
- <ol
714
- v-if="expandedSaved.has(p.id)"
715
- class="space-y-1 border-t border-slate-800 px-2 py-2 pl-7"
720
+ <!-- Full ordered step list, revealed on click. -->
721
+ <ol
722
+ v-if="expandedSaved.has(p.id)"
723
+ class="space-y-1 border-t border-slate-800 px-2 py-2 pl-7"
724
+ >
725
+ <li
726
+ v-for="(k, i) in p.agentKinds"
727
+ :key="i"
728
+ class="flex items-center gap-2"
729
+ :class="{ 'opacity-50 line-through': p.enabled?.[i] === false }"
730
+ :title="p.enabled?.[i] === false ? 'Disabled — skipped at run' : undefined"
716
731
  >
717
- <li
718
- v-for="(k, i) in p.agentKinds"
719
- :key="i"
720
- class="flex items-center gap-2"
721
- :class="{ 'opacity-50 line-through': p.enabled?.[i] === false }"
722
- :title="p.enabled?.[i] === false ? 'Disabled — skipped at run' : undefined"
723
- >
724
- <span class="w-4 shrink-0 text-center text-[10px] text-slate-500">{{
725
- i + 1
726
- }}</span>
727
- <AgentKindIcon :kind="k" show-label />
728
- </li>
729
- </ol>
730
- </li>
731
- </ul>
732
- </div>
732
+ <span class="w-4 shrink-0 text-center text-[10px] text-slate-500">{{
733
+ i + 1
734
+ }}</span>
735
+ <AgentKindIcon :kind="k" show-label />
736
+ </li>
737
+ </ol>
738
+ </li>
739
+ </ul>
733
740
  </div>
734
741
  </div>
735
742
  </template>
@@ -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.
@@ -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
  }
@@ -143,6 +143,13 @@ export interface Block {
143
143
  testComposePath?: string
144
144
  /** service-only (frame): the service has no infra dependencies to stand up. */
145
145
  noInfraDependencies?: boolean
146
+ /**
147
+ * service-only (frame): the default test environment a task under this service is
148
+ * spawned with — `local` (docker-compose infra) or `ephemeral` (provisioned env).
149
+ * Tasks inherit it unless they override via their `tester.environment` config.
150
+ * absent = the built-in `ephemeral`.
151
+ */
152
+ defaultTestEnvironment?: 'local' | 'ephemeral'
146
153
  /** service-only (frame): cloud provider the service's jobs run on; absent = account default. */
147
154
  cloudProvider?: CloudProvider
148
155
  /** service-only (frame): abstract instance size for the service's jobs; absent = default. */
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.17.2",
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",