@cat-factory/app 0.196.0 → 0.197.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.
Files changed (54) hide show
  1. package/README.md +49 -3
  2. package/app/components/brainstorm/BrainstormWindow.vue +11 -4
  3. package/app/components/clarity/ClarityReviewWindow.vue +11 -4
  4. package/app/components/initiative/InitiativePlanReview.vue +44 -37
  5. package/app/components/initiative/InitiativeTrackerWindow.vue +12 -9
  6. package/app/components/layout/SideBar.vue +13 -2
  7. package/app/components/layout/UiModeSwitcher.vue +66 -39
  8. package/app/components/panels/ResultWindowShell.logic.spec.ts +174 -0
  9. package/app/components/panels/ResultWindowShell.logic.ts +31 -0
  10. package/app/components/panels/ResultWindowShell.vue +37 -8
  11. package/app/components/prReview/PrReviewWindow.vue +15 -7
  12. package/app/components/requirements/RequirementsReviewWindow.vue +15 -5
  13. package/app/components/spec/ServiceSpecWindow.vue +7 -4
  14. package/app/components/testing/TestReportWindow.vue +11 -6
  15. package/app/components/tutorial/TutorialOverlay.logic.spec.ts +126 -0
  16. package/app/components/tutorial/TutorialOverlay.logic.ts +92 -0
  17. package/app/components/tutorial/TutorialOverlay.vue +273 -0
  18. package/app/components/tutorial/TutorialPrompt.vue +102 -0
  19. package/app/composables/pipelineErrorToast/bespokeConflicts.ts +181 -0
  20. package/app/composables/useNavContributions.ts +1 -0
  21. package/app/composables/usePipelineErrorToast.ts +6 -164
  22. package/app/composables/useTutorialTours.ts +18 -0
  23. package/app/modular/nav-contributions.spec.ts +4 -0
  24. package/app/modular/nav-contributions.ts +38 -3
  25. package/app/modular/nav-gates.ts +7 -0
  26. package/app/modular/registry.spec.ts +1 -0
  27. package/app/modular/registry.ts +3 -1
  28. package/app/modular/slots.ts +7 -0
  29. package/app/modular/tutorial-tours.spec.ts +107 -0
  30. package/app/modular/tutorial-tours.ts +147 -0
  31. package/app/pages/index.vue +61 -0
  32. package/app/stores/board/dependencies.ts +52 -0
  33. package/app/stores/board/placement.ts +4 -37
  34. package/app/stores/execution/pendingGates.ts +109 -0
  35. package/app/stores/execution.ts +7 -94
  36. package/app/stores/requirements/recommendations.ts +77 -0
  37. package/app/stores/requirements.ts +17 -43
  38. package/app/stores/tutorial.spec.ts +135 -0
  39. package/app/stores/tutorial.ts +145 -0
  40. package/app/stores/workspace/commands.ts +77 -0
  41. package/app/stores/workspace.ts +11 -50
  42. package/app/utils/tutorial.spec.ts +68 -0
  43. package/app/utils/tutorial.ts +192 -0
  44. package/i18n/locales/de.json +90 -2
  45. package/i18n/locales/en.json +96 -2
  46. package/i18n/locales/es.json +90 -2
  47. package/i18n/locales/fr.json +90 -2
  48. package/i18n/locales/he.json +90 -2
  49. package/i18n/locales/it.json +90 -2
  50. package/i18n/locales/ja.json +90 -2
  51. package/i18n/locales/pl.json +90 -2
  52. package/i18n/locales/tr.json +90 -2
  53. package/i18n/locales/uk.json +90 -2
  54. package/package.json +1 -1
@@ -138,6 +138,15 @@ const AiProviderOnboardingModal = defineAsyncComponent(
138
138
  const AiPresetMismatchDialog = defineAsyncComponent(
139
139
  () => import('~/components/providers/AiPresetMismatchDialog.vue'),
140
140
  )
141
+ // The in-app tutorial: the launch prompt (auto-opened once for a user who never answered
142
+ // it) and the coach-mark overlay that runs a tour. Both mount only while their store flag
143
+ // is set, so they cost the initial bundle nothing.
144
+ const TutorialPrompt = defineAsyncComponent(
145
+ () => import('~/components/tutorial/TutorialPrompt.vue'),
146
+ )
147
+ const TutorialOverlay = defineAsyncComponent(
148
+ () => import('~/components/tutorial/TutorialOverlay.vue'),
149
+ )
141
150
 
142
151
  const workspace = useWorkspaceStore()
143
152
  const github = useGitHubStore()
@@ -267,6 +276,56 @@ watch(
267
276
  { immediate: true },
268
277
  )
269
278
 
279
+ // Offer the tutorial on launch, once the board is up. Yields to every other startup
280
+ // surface — the GitHub onboarding gate and the advisory/onboarding modals above — so a
281
+ // first launch never stacks the tour prompt on top of a dialog that needs answering
282
+ // first; when one of those is open, the flip of its flag re-fires this watcher and the
283
+ // prompt appears then. The store guards the rest: only a user who never answered is
284
+ // asked, at most once per session.
285
+ //
286
+ // Yielding runs in BOTH directions: an advisory that opens LATER (a health probe that
287
+ // resolves a beat after the board) would otherwise land on top of an open tour prompt,
288
+ // which is the stacking this ordering exists to prevent. So the same watcher withdraws
289
+ // an unanswered prompt and re-arms the offer, and the user is asked once the advisory
290
+ // they actually have to answer is gone.
291
+ //
292
+ // The watcher exists only while it can still do something. A saved decision (hydrated
293
+ // synchronously from the persisted store) means it never registers, and once a decision
294
+ // lands — or the offer has been made and left standing — it stops itself, so the steady
295
+ // state pays nothing for the launch offer: no watcher, no mounted component (the v-ifs
296
+ // below), no store reads.
297
+ const tutorial = useTutorialStore()
298
+ const startupAdvisoryOpen = computed(
299
+ () =>
300
+ needsGitHubInstall.value ||
301
+ githubProbePending.value ||
302
+ ui.pipelineHealthOpen ||
303
+ ui.riskPolicyHealthOpen ||
304
+ ui.modelPresetHealthOpen ||
305
+ ui.aiProviderSetupOpen ||
306
+ ui.aiPresetMismatchOpen,
307
+ )
308
+ // Settled = the offer can never need to act again: a decision exists, or the prompt was
309
+ // auto-opened and is still standing (a deferral clears `promptAutoOpened`, which is
310
+ // exactly what keeps the watcher alive to re-offer).
311
+ const tutorialOfferSettled = () =>
312
+ tutorial.decision !== null || (tutorial.promptAutoOpened && !tutorial.promptOpen)
313
+ if (!tutorialOfferSettled()) {
314
+ // `let` + optional call: with `immediate: true` the first run happens synchronously
315
+ // inside `watch(...)`, before the handle is assigned — the trailing check covers it.
316
+ let stopTutorialOffer: (() => void) | undefined
317
+ stopTutorialOffer = watch(
318
+ () => [workspace.ready, startupAdvisoryOpen.value, tutorial.promptOpen],
319
+ () => {
320
+ if (startupAdvisoryOpen.value) tutorial.deferPrompt()
321
+ else if (workspace.ready) tutorial.maybeOfferOnLaunch()
322
+ if (tutorialOfferSettled()) stopTutorialOffer?.()
323
+ },
324
+ { immediate: true },
325
+ )
326
+ if (tutorialOfferSettled()) stopTutorialOffer()
327
+ }
328
+
270
329
  // Probe the GitHub integration as soon as a board is active (re-probe per board —
271
330
  // connections are per workspace). The result drives the onboarding gate below
272
331
  // before the board mounts, so an unconnected user can't slip past it. `ensureProbed`
@@ -448,6 +507,8 @@ watch(
448
507
  <VendorCredentialsModal v-if="ui.vendorCredentialsOpen" />
449
508
  <AiProviderOnboardingModal v-if="ui.aiProviderSetupOpen" />
450
509
  <AiPresetMismatchDialog v-if="ui.aiPresetMismatchOpen" />
510
+ <TutorialPrompt v-if="tutorial.promptOpen" />
511
+ <TutorialOverlay v-if="tutorial.touring" />
451
512
  </template>
452
513
 
453
514
  <!-- Backend unreachable / bootstrap failed -->
@@ -0,0 +1,52 @@
1
+ import { useWorkspaceStore } from '~/stores/workspace'
2
+ import type { BoardWriteContext } from './context'
3
+
4
+ /**
5
+ * The board's dependency-edge writes. Split out of {@link createBoardPlacement} along the same
6
+ * seam it was split from {@link createBoardMutations}: it closes over the shared
7
+ * {@link BoardWriteContext} so behaviour is identical to the original in-closure functions, and
8
+ * the split is purely to keep every function within the size budget.
9
+ */
10
+ export function createBoardDependencies(ctx: BoardWriteContext) {
11
+ const { getBlock, upsert, api, toast, tr } = ctx
12
+
13
+ /**
14
+ * Toggle a dependency edge target -> source (target dependsOn source). The backend
15
+ * rejects an edge that would close a cycle (422) — surface that as a toast rather than
16
+ * letting it throw unhandled out of a board gesture.
17
+ */
18
+ async function toggleDependency(targetId: string, sourceId: string) {
19
+ if (targetId === sourceId || !getBlock(targetId)) return
20
+ try {
21
+ upsert(await api.toggleDependency(useWorkspaceStore().requireId(), targetId, { sourceId }))
22
+ } catch (e) {
23
+ toast.add({
24
+ title: tr('board.toast.linkFailed'),
25
+ description: e instanceof Error ? e.message : String(e),
26
+ icon: 'i-lucide-triangle-alert',
27
+ color: 'error',
28
+ })
29
+ }
30
+ }
31
+
32
+ /** Remove a dependency edge target -> source if it exists. */
33
+ async function removeDependency(targetId: string, sourceId: string) {
34
+ const t = getBlock(targetId)
35
+ if (!t || !t.dependsOn.includes(sourceId)) return
36
+ // the backend exposes a single toggle; the edge exists, so toggling removes it
37
+ try {
38
+ upsert(await api.toggleDependency(useWorkspaceStore().requireId(), targetId, { sourceId }))
39
+ } catch (e) {
40
+ // Mirror `toggleDependency`: a failure must surface (and leave the edge visible) rather
41
+ // than rejecting unhandled with no feedback.
42
+ toast.add({
43
+ title: tr('board.toast.unlinkFailed'),
44
+ description: e instanceof Error ? e.message : String(e),
45
+ icon: 'i-lucide-triangle-alert',
46
+ color: 'error',
47
+ })
48
+ }
49
+ }
50
+
51
+ return { toggleDependency, removeDependency }
52
+ }
@@ -1,6 +1,7 @@
1
1
  import type { UpdateBlockInput } from '@cat-factory/contracts'
2
2
  import { useServicesStore } from '~/stores/services'
3
3
  import { useWorkspaceStore } from '~/stores/workspace'
4
+ import { createBoardDependencies } from './dependencies'
4
5
  import type { BoardWriteContext } from './context'
5
6
  import { UNDO_WINDOW_MS } from './context'
6
7
 
@@ -215,43 +216,9 @@ export function createBoardPlacement(ctx: BoardWriteContext) {
215
216
  }
216
217
  }
217
218
 
218
- /**
219
- * Toggle a dependency edge target -> source (target dependsOn source). The backend
220
- * rejects an edge that would close a cycle (422) — surface that as a toast rather than
221
- * letting it throw unhandled out of a board gesture.
222
- */
223
- async function toggleDependency(targetId: string, sourceId: string) {
224
- if (targetId === sourceId || !getBlock(targetId)) return
225
- try {
226
- upsert(await api.toggleDependency(useWorkspaceStore().requireId(), targetId, { sourceId }))
227
- } catch (e) {
228
- toast.add({
229
- title: tr('board.toast.linkFailed'),
230
- description: e instanceof Error ? e.message : String(e),
231
- icon: 'i-lucide-triangle-alert',
232
- color: 'error',
233
- })
234
- }
235
- }
236
-
237
- /** Remove a dependency edge target -> source if it exists. */
238
- async function removeDependency(targetId: string, sourceId: string) {
239
- const t = getBlock(targetId)
240
- if (!t || !t.dependsOn.includes(sourceId)) return
241
- // the backend exposes a single toggle; the edge exists, so toggling removes it
242
- try {
243
- upsert(await api.toggleDependency(useWorkspaceStore().requireId(), targetId, { sourceId }))
244
- } catch (e) {
245
- // Mirror `toggleDependency`: a failure must surface (and leave the edge visible) rather
246
- // than rejecting unhandled with no feedback.
247
- toast.add({
248
- title: tr('board.toast.unlinkFailed'),
249
- description: e instanceof Error ? e.message : String(e),
250
- icon: 'i-lucide-triangle-alert',
251
- color: 'error',
252
- })
253
- }
254
- }
219
+ // The dependency-edge writes, split along the same seam into a sibling factory over the same
220
+ // context; re-exposed here so every existing caller is unchanged.
221
+ const { toggleDependency, removeDependency } = createBoardDependencies(ctx)
255
222
 
256
223
  return {
257
224
  reparentBlock,
@@ -0,0 +1,109 @@
1
+ import { computed, type Ref } from 'vue'
2
+ import type { Decision, ExecutionInstance, PipelineStep, StepApproval } from '~/types/domain'
3
+
4
+ /**
5
+ * The read-only projections of what across every cached run is awaiting a human: the open
6
+ * decisions and approval gates, their per-block indexes, and the two badge counts.
7
+ *
8
+ * Created once in the `execution` store setup over its `instances` ref, so the derivations stay
9
+ * behaviourally identical to the former in-closure computeds — a size-only extraction mirroring
10
+ * {@link createExecutionCommands}, not a new seam.
11
+ */
12
+ export function createPendingGateSelectors(instances: Ref<ExecutionInstance[]>) {
13
+ /** How many decisions anywhere are awaiting a human. */
14
+ const pendingDecisionCount = computed(() =>
15
+ instances.value.reduce(
16
+ (n, e) => n + e.steps.filter((s) => s.decision && !s.decision.chosen).length,
17
+ 0,
18
+ ),
19
+ )
20
+
21
+ /** All currently-unresolved decisions across all runs (for the toolbar/queue). */
22
+ const openDecisions = computed(() => {
23
+ const out: {
24
+ instanceId: string
25
+ blockId: string
26
+ decision: Decision
27
+ agentKind: PipelineStep['agentKind']
28
+ }[] = []
29
+ for (const e of instances.value) {
30
+ for (const s of e.steps) {
31
+ if (s.decision && !s.decision.chosen) {
32
+ out.push({
33
+ instanceId: e.id,
34
+ blockId: e.blockId,
35
+ decision: s.decision,
36
+ agentKind: s.agentKind,
37
+ })
38
+ }
39
+ }
40
+ }
41
+ return out
42
+ })
43
+
44
+ /** All currently-pending approval gates across all runs (board badges/queue). */
45
+ const openApprovals = computed(() => {
46
+ const out: {
47
+ instanceId: string
48
+ blockId: string
49
+ approval: StepApproval
50
+ agentKind: PipelineStep['agentKind']
51
+ /**
52
+ * Whether the gate's proposal is a RENDERING of an artifact the step already committed
53
+ * (`step.outputIsRendered`). Projected here because a surface that reviews the proposal
54
+ * WITHOUT the step in hand — the initiative tracker's plan-approval rail — otherwise has
55
+ * no way to tell a rendered document from the agent's raw transcript summary, and would
56
+ * present a one-line summary as though it were the artifact.
57
+ */
58
+ outputIsRendered: boolean
59
+ }[] = []
60
+ for (const e of instances.value) {
61
+ for (const s of e.steps) {
62
+ if (s.approval?.status === 'pending') {
63
+ out.push({
64
+ instanceId: e.id,
65
+ blockId: e.blockId,
66
+ approval: s.approval,
67
+ agentKind: s.agentKind,
68
+ outputIsRendered: s.outputIsRendered === true,
69
+ })
70
+ }
71
+ }
72
+ }
73
+ return out
74
+ })
75
+
76
+ /**
77
+ * Open decisions/approvals grouped by the block they belong to, so a board card
78
+ * resolves its own + its tasks' pending gates with O(1) lookups instead of
79
+ * re-filtering the global lists once per frame on every execution event.
80
+ */
81
+ function groupByBlock<T extends { blockId: string }>(items: T[]): Map<string, T[]> {
82
+ const map = new Map<string, T[]>()
83
+ for (const item of items) {
84
+ const list = map.get(item.blockId)
85
+ if (list) list.push(item)
86
+ else map.set(item.blockId, [item])
87
+ }
88
+ return map
89
+ }
90
+ const decisionsByBlock = computed(() => groupByBlock(openDecisions.value))
91
+ const approvalsByBlock = computed(() => groupByBlock(openApprovals.value))
92
+
93
+ /** How many approval gates anywhere are awaiting a human. */
94
+ const pendingApprovalCount = computed(() =>
95
+ instances.value.reduce(
96
+ (n, e) => n + e.steps.filter((s) => s.approval?.status === 'pending').length,
97
+ 0,
98
+ ),
99
+ )
100
+
101
+ return {
102
+ pendingDecisionCount,
103
+ openDecisions,
104
+ openApprovals,
105
+ decisionsByBlock,
106
+ approvalsByBlock,
107
+ pendingApprovalCount,
108
+ }
109
+ }
@@ -1,7 +1,8 @@
1
1
  import { defineStore } from 'pinia'
2
2
  import { ref, computed } from 'vue'
3
- import type { Decision, ExecutionInstance, PipelineStep, StepApproval } from '~/types/domain'
3
+ import type { ExecutionInstance } from '~/types/domain'
4
4
  import { createExecutionCommands } from '~/stores/execution/commands'
5
+ import { createPendingGateSelectors } from '~/stores/execution/pendingGates'
5
6
 
6
7
  /**
7
8
  * Running pipeline instances. The simulation engine lives on the backend: this
@@ -188,93 +189,10 @@ export const useExecutionStore = defineStore('execution', () => {
188
189
  return runs.find((e) => !isTerminal(e.status)) ?? runs.at(-1)
189
190
  }
190
191
 
191
- /** How many decisions anywhere are awaiting a human. */
192
- const pendingDecisionCount = computed(() =>
193
- instances.value.reduce(
194
- (n, e) => n + e.steps.filter((s) => s.decision && !s.decision.chosen).length,
195
- 0,
196
- ),
197
- )
198
-
199
- /** All currently-unresolved decisions across all runs (for the toolbar/queue). */
200
- const openDecisions = computed(() => {
201
- const out: {
202
- instanceId: string
203
- blockId: string
204
- decision: Decision
205
- agentKind: PipelineStep['agentKind']
206
- }[] = []
207
- for (const e of instances.value) {
208
- for (const s of e.steps) {
209
- if (s.decision && !s.decision.chosen) {
210
- out.push({
211
- instanceId: e.id,
212
- blockId: e.blockId,
213
- decision: s.decision,
214
- agentKind: s.agentKind,
215
- })
216
- }
217
- }
218
- }
219
- return out
220
- })
221
-
222
- /** All currently-pending approval gates across all runs (board badges/queue). */
223
- const openApprovals = computed(() => {
224
- const out: {
225
- instanceId: string
226
- blockId: string
227
- approval: StepApproval
228
- agentKind: PipelineStep['agentKind']
229
- /**
230
- * Whether the gate's proposal is a RENDERING of an artifact the step already committed
231
- * (`step.outputIsRendered`). Projected here because a surface that reviews the proposal
232
- * WITHOUT the step in hand — the initiative tracker's plan-approval rail — otherwise has
233
- * no way to tell a rendered document from the agent's raw transcript summary, and would
234
- * present a one-line summary as though it were the artifact.
235
- */
236
- outputIsRendered: boolean
237
- }[] = []
238
- for (const e of instances.value) {
239
- for (const s of e.steps) {
240
- if (s.approval?.status === 'pending') {
241
- out.push({
242
- instanceId: e.id,
243
- blockId: e.blockId,
244
- approval: s.approval,
245
- agentKind: s.agentKind,
246
- outputIsRendered: s.outputIsRendered === true,
247
- })
248
- }
249
- }
250
- }
251
- return out
252
- })
253
-
254
- /**
255
- * Open decisions/approvals grouped by the block they belong to, so a board card
256
- * resolves its own + its tasks' pending gates with O(1) lookups instead of
257
- * re-filtering the global lists once per frame on every execution event.
258
- */
259
- function groupByBlock<T extends { blockId: string }>(items: T[]): Map<string, T[]> {
260
- const map = new Map<string, T[]>()
261
- for (const item of items) {
262
- const list = map.get(item.blockId)
263
- if (list) list.push(item)
264
- else map.set(item.blockId, [item])
265
- }
266
- return map
267
- }
268
- const decisionsByBlock = computed(() => groupByBlock(openDecisions.value))
269
- const approvalsByBlock = computed(() => groupByBlock(openApprovals.value))
270
-
271
- /** How many approval gates anywhere are awaiting a human. */
272
- const pendingApprovalCount = computed(() =>
273
- instances.value.reduce(
274
- (n, e) => n + e.steps.filter((s) => s.approval?.status === 'pending').length,
275
- 0,
276
- ),
277
- )
192
+ // What across every cached run is awaiting a human (open decisions + approval gates, their
193
+ // per-block indexes and the two badge counts), extracted into a cohesive factory over the same
194
+ // `instances` ref — a size-only split mirroring `createExecutionCommands` below.
195
+ const pendingGates = createPendingGateSelectors(instances)
278
196
 
279
197
  // The run-control commands (start / decide / approve / merge / restart / cancel / stop),
280
198
  // extracted into a cohesive factory sharing the state above (a size-only split mirroring
@@ -290,12 +208,7 @@ export const useExecutionStore = defineStore('execution', () => {
290
208
  byId,
291
209
  getInstance,
292
210
  getByBlock,
293
- pendingDecisionCount,
294
- openDecisions,
295
- openApprovals,
296
- decisionsByBlock,
297
- approvalsByBlock,
298
- pendingApprovalCount,
211
+ ...pendingGates,
299
212
  ...commands,
300
213
  }
301
214
  })
@@ -0,0 +1,77 @@
1
+ import type { Ref } from 'vue'
2
+ import type { RequestRecommendationItem, RequirementReview } from '~/types/requirements'
3
+
4
+ /**
5
+ * Shared state + injected dependencies the recommendation slice closes over. Created once in the
6
+ * `requirements` store setup and threaded into {@link createRecommendationCommands} so the split
7
+ * operations stay behaviourally identical to the original single-closure store — a size-only
8
+ * extraction, not a new seam.
9
+ */
10
+ export interface RecommendationCommandContext {
11
+ api: ReturnType<typeof useApi>
12
+ workspace: ReturnType<typeof useWorkspaceStore>
13
+ /** Block ids whose Requirement Writer is currently producing recommendations. */
14
+ recommending: Ref<Set<string>>
15
+ /** Toggle a block/review id in one of the store's flag sets. */
16
+ withFlag: (set: Ref<Set<string>>, key: string, on: boolean) => void
17
+ /** Commit a server-returned review into the cache. */
18
+ store: (review: RequirementReview) => void
19
+ /** Whether a `pending` recommendation placeholder still exists for the block (server-derived). */
20
+ hasPendingRecommendations: (blockId: string) => boolean
21
+ }
22
+
23
+ /** Ask for, accept, reject and re-request the Requirement Writer's suggested answers. */
24
+ export function createRecommendationCommands(ctx: RecommendationCommandContext) {
25
+ const { api, workspace, recommending, withFlag, store, hasPendingRecommendations } = ctx
26
+
27
+ function isRecommending(blockId: string): boolean {
28
+ return recommending.value.has(blockId) || hasPendingRecommendations(blockId)
29
+ }
30
+
31
+ /**
32
+ * Ask the Requirement Writer to recommend answers for a batch of findings. Each item carries
33
+ * its finding id plus optional per-finding guidance (the note the human typed before choosing
34
+ * "recommend something"). ASYNCHRONOUS: returns at once with `pending` placeholder
35
+ * recommendations (the Writer runs per finding in the durable driver), which fill in (`ready`)
36
+ * via live `requirements` stream events; a notification calls the user back when the batch is
37
+ * ready. The board shows the `recommending` background stage while any placeholder is pending.
38
+ */
39
+ async function requestRecommendations(blockId: string, items: RequestRecommendationItem[]) {
40
+ withFlag(recommending, blockId, true)
41
+ try {
42
+ const updated = await api.requestRecommendations(workspace.requireId(), blockId, items)
43
+ if (updated) store(updated)
44
+ return updated
45
+ } finally {
46
+ withFlag(recommending, blockId, false)
47
+ }
48
+ }
49
+
50
+ /** Accept a recommendation (becomes the finding's answer, folded into the next incorporation). */
51
+ async function acceptRecommendation(review: RequirementReview, recId: string) {
52
+ store(await api.acceptRecommendation(workspace.requireId(), review.id, recId))
53
+ }
54
+
55
+ /** Reject a recommendation (the human then dismisses / answers manually / re-requests). */
56
+ async function rejectRecommendation(review: RequirementReview, recId: string) {
57
+ store(await api.rejectRecommendation(workspace.requireId(), review.id, recId))
58
+ }
59
+
60
+ /** Re-request a recommendation with a "do it differently" note. */
61
+ async function reRequestRecommendation(review: RequirementReview, recId: string, note: string) {
62
+ withFlag(recommending, review.blockId, true)
63
+ try {
64
+ store(await api.reRequestRecommendation(workspace.requireId(), review.id, recId, note))
65
+ } finally {
66
+ withFlag(recommending, review.blockId, false)
67
+ }
68
+ }
69
+
70
+ return {
71
+ isRecommending,
72
+ requestRecommendations,
73
+ acceptRecommendation,
74
+ rejectRecommendation,
75
+ reRequestRecommendation,
76
+ }
77
+ }
@@ -1,12 +1,12 @@
1
1
  import { defineStore } from 'pinia'
2
2
  import { ref } from 'vue'
3
3
  import type {
4
- RequestRecommendationItem,
5
4
  RequirementReview,
6
5
  ResolveRequirementsExceededChoice,
7
6
  ReviewItemStatus,
8
7
  } from '~/types/requirements'
9
8
  import { useWorkspaceStore } from '~/stores/workspace'
9
+ import { createRecommendationCommands } from '~/stores/requirements/recommendations'
10
10
 
11
11
  /**
12
12
  * Requirements-review state. On the pipeline path the reviewer runs as the first gate
@@ -204,48 +204,22 @@ export const useRequirementsStore = defineStore('requirements', () => {
204
204
  return updated
205
205
  }
206
206
 
207
- function isRecommending(blockId: string): boolean {
208
- return recommending.value.has(blockId) || hasPendingRecommendations(blockId)
209
- }
210
-
211
- /**
212
- * Ask the Requirement Writer to recommend answers for a batch of findings. Each item carries
213
- * its finding id plus optional per-finding guidance (the note the human typed before choosing
214
- * "recommend something"). ASYNCHRONOUS: returns at once with `pending` placeholder
215
- * recommendations (the Writer runs per finding in the durable driver), which fill in (`ready`)
216
- * via live `requirements` stream events; a notification calls the user back when the batch is
217
- * ready. The board shows the `recommending` background stage while any placeholder is pending.
218
- */
219
- async function requestRecommendations(blockId: string, items: RequestRecommendationItem[]) {
220
- withFlag(recommending, blockId, true)
221
- try {
222
- const updated = await api.requestRecommendations(workspace.requireId(), blockId, items)
223
- if (updated) store(updated)
224
- return updated
225
- } finally {
226
- withFlag(recommending, blockId, false)
227
- }
228
- }
229
-
230
- /** Accept a recommendation (becomes the finding's answer, folded into the next incorporation). */
231
- async function acceptRecommendation(review: RequirementReview, recId: string) {
232
- store(await api.acceptRecommendation(workspace.requireId(), review.id, recId))
233
- }
234
-
235
- /** Reject a recommendation (the human then dismisses / answers manually / re-requests). */
236
- async function rejectRecommendation(review: RequirementReview, recId: string) {
237
- store(await api.rejectRecommendation(workspace.requireId(), review.id, recId))
238
- }
239
-
240
- /** Re-request a recommendation with a "do it differently" note. */
241
- async function reRequestRecommendation(review: RequirementReview, recId: string, note: string) {
242
- withFlag(recommending, review.blockId, true)
243
- try {
244
- store(await api.reRequestRecommendation(workspace.requireId(), review.id, recId, note))
245
- } finally {
246
- withFlag(recommending, review.blockId, false)
247
- }
248
- }
207
+ // The Requirement Writer recommendation slice (request / accept / reject / re-request),
208
+ // extracted into a cohesive factory over the state above — a size-only split.
209
+ const {
210
+ isRecommending,
211
+ requestRecommendations,
212
+ acceptRecommendation,
213
+ rejectRecommendation,
214
+ reRequestRecommendation,
215
+ } = createRecommendationCommands({
216
+ api,
217
+ workspace,
218
+ recommending,
219
+ withFlag,
220
+ store,
221
+ hasPendingRecommendations,
222
+ })
249
223
 
250
224
  /** Resolve a capped review: extra-round / proceed / stop-reset. */
251
225
  async function resolveExceeded(