@cat-factory/app 0.298.0 → 0.300.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 (36) hide show
  1. package/README.md +15 -1
  2. package/app/components/assistant/AssistantModal.logic.spec.ts +80 -0
  3. package/app/components/assistant/AssistantModal.logic.ts +45 -0
  4. package/app/components/assistant/AssistantModal.vue +260 -0
  5. package/app/composables/api/assistant.ts +29 -0
  6. package/app/composables/useApi.ts +2 -0
  7. package/app/composables/useNavContributions.ts +1 -0
  8. package/app/composables/usePipelineErrorToast.ts +2 -0
  9. package/app/modular/nav-contributions.spec.ts +2 -0
  10. package/app/modular/nav-contributions.ts +22 -0
  11. package/app/pages/index.vue +2 -0
  12. package/app/stores/assistant.ts +69 -0
  13. package/app/stores/brainstorm.ts +9 -2
  14. package/app/stores/clarity.ts +9 -2
  15. package/app/stores/consensus.ts +7 -2
  16. package/app/stores/docInterview.ts +3 -1
  17. package/app/stores/initiative.ts +4 -1
  18. package/app/stores/notifications.ts +2 -13
  19. package/app/stores/perKeyWrites.spec.ts +168 -0
  20. package/app/stores/requirements/recommendations.ts +25 -0
  21. package/app/stores/requirements.spec.ts +89 -1
  22. package/app/stores/requirements.ts +24 -19
  23. package/app/stores/ui/modals.ts +15 -0
  24. package/app/types/assistant.ts +24 -0
  25. package/app/types/domain.ts +1 -0
  26. package/i18n/locales/de.json +49 -1
  27. package/i18n/locales/en.json +49 -1
  28. package/i18n/locales/es.json +49 -1
  29. package/i18n/locales/fr.json +49 -1
  30. package/i18n/locales/he.json +49 -1
  31. package/i18n/locales/it.json +49 -1
  32. package/i18n/locales/ja.json +49 -1
  33. package/i18n/locales/pl.json +49 -1
  34. package/i18n/locales/tr.json +49 -1
  35. package/i18n/locales/uk.json +49 -1
  36. package/package.json +3 -3
@@ -30,8 +30,13 @@ export const useConsensusStore = defineStore('consensus', () => {
30
30
  return loading.value.has(blockId)
31
31
  }
32
32
 
33
+ /**
34
+ * Write one block's session into the cache, IN PLACE: per-key, never a whole-record clone.
35
+ * `sessions` is a deep reactive ref, so replacing the record retriggered every consumer keyed on
36
+ * an UNCHANGED block; assigning the key retriggers only the block that actually changed.
37
+ */
33
38
  function store(session: ConsensusSession) {
34
- sessions.value = { ...sessions.value, [session.blockId]: session }
39
+ sessions.value[session.blockId] = session
35
40
  }
36
41
 
37
42
  /** Patch the cache from a live `consensus` stream event (newest wins per block). */
@@ -62,7 +67,7 @@ export const useConsensusStore = defineStore('consensus', () => {
62
67
  if (session) {
63
68
  if (!existing || session.updatedAt >= existing.updatedAt) store(session)
64
69
  } else if (existing === undefined) {
65
- sessions.value = { ...sessions.value, [blockId]: null }
70
+ sessions.value[blockId] = null
66
71
  }
67
72
  } catch {
68
73
  // Consensus off / no session — leave the cache as-is; the window shows its empty state.
@@ -30,7 +30,9 @@ export const useDocInterviewStore = defineStore('docInterview', () => {
30
30
  function upsert(session: DocInterviewSession) {
31
31
  const existing = byBlock.value[session.blockId]
32
32
  if (existing && existing.updatedAt > session.updatedAt) return
33
- byBlock.value = { ...byBlock.value, [session.blockId]: session }
33
+ // Per-key, never a whole-record clone: `byBlock` is a deep reactive ref, so replacing the
34
+ // record retriggered every consumer keyed on an UNCHANGED block.
35
+ byBlock.value[session.blockId] = session
34
36
  }
35
37
 
36
38
  /** Re-fetch one block's session (the interview window's load path). */
@@ -96,7 +96,10 @@ export const useInitiativesStore = defineStore('initiatives', () => {
96
96
  function upsert(initiative: Initiative) {
97
97
  const existing = byBlock.value[initiative.blockId]
98
98
  if (existing && existing.rev > initiative.rev) return
99
- byBlock.value = { ...byBlock.value, [initiative.blockId]: initiative }
99
+ // Per-key, never a whole-record clone: `byBlock` is a deep reactive ref, so replacing the
100
+ // record retriggered every consumer keyed on an UNCHANGED block. {@link hydrate} still
101
+ // replaces it wholesale, because a snapshot is authoritative for EXISTENCE.
102
+ byBlock.value[initiative.blockId] = initiative
100
103
  }
101
104
 
102
105
  /**
@@ -16,8 +16,8 @@ import { useWorkspaceStore } from '~/stores/workspace'
16
16
  * Open, human-actionable notifications surfaced on the board (a PR awaiting a
17
17
  * merge decision, a completed pipeline awaiting confirmation, CI that gave up).
18
18
  * Hydrated from the workspace snapshot and patched live by the `notification`
19
- * WorkspaceEvent (see `useWorkspaceStream`). The board renders an inbox + a
20
- * per-block badge from `open` / `byBlock`.
19
+ * WorkspaceEvent (see `useWorkspaceStream`). The board renders an inbox from `open`, and the
20
+ * per-block review-wait stamps the swimlanes need from {@link reviewDebtByBlock}.
21
21
  */
22
22
  export const useNotificationsStore = defineStore('notifications', () => {
23
23
  const api = useApi()
@@ -110,16 +110,6 @@ export const useNotificationsStore = defineStore('notifications', () => {
110
110
  upsertOpen(notification)
111
111
  }
112
112
 
113
- /** Open notifications for a given block (for the board card badge). */
114
- const byBlock = computed<Record<string, Notification[]>>(() => {
115
- const map: Record<string, Notification[]> = {}
116
- for (const n of open.value) {
117
- if (!n.blockId) continue
118
- ;(map[n.blockId] ??= []).push(n)
119
- }
120
- return map
121
- })
122
-
123
113
  /**
124
114
  * Per-block "waiting since", derived from the open review-wait cards by the same
125
115
  * `collectReviewDebt` the backend's friction check uses. It is the fallback source for the park
@@ -213,7 +203,6 @@ export const useNotificationsStore = defineStore('notifications', () => {
213
203
  hydrate,
214
204
  hydrateBaseline,
215
205
  upsert,
216
- byBlock,
217
206
  reviewDebtByBlock,
218
207
  count,
219
208
  act,
@@ -0,0 +1,168 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { computed } from 'vue'
3
+ import type { BrainstormSession } from '~/types/brainstorm'
4
+ import type { ClarityReview } from '~/types/clarity'
5
+ import type { ConsensusSession } from '~/types/consensus'
6
+ import type { DocInterviewSession, Initiative } from '~/types/domain'
7
+ import type { RequirementReview } from '~/types/requirements'
8
+ import { useBrainstormStore } from '~/stores/brainstorm'
9
+ import { useClarityStore } from '~/stores/clarity'
10
+ import { useConsensusStore } from '~/stores/consensus'
11
+ import { useDocInterviewStore } from '~/stores/docInterview'
12
+ import { useInitiativesStore } from '~/stores/initiative'
13
+ import { useRequirementsStore } from '~/stores/requirements'
14
+
15
+ // The review-family stores all hold a `Record<blockId, T>` in a DEEP reactive ref and all patch it
16
+ // from a live stream event. `x.value = { ...x.value, [id]: v }` is a write to the REF, a dependency
17
+ // every reader shares whatever key it reads, so one event woke every card on the board;
18
+ // `x.value[id] = v` keeps the invalidation on the key that changed. The rule is stated once in
19
+ // `frontend/app/README.md` ("A record keyed by block id is written PER KEY, never replaced").
20
+ //
21
+ // One table rather than six near-identical specs, because the property is one property and a store
22
+ // that JOINS this family should have exactly one obvious place to be added. Each row asserts the
23
+ // two halves that can regress independently: an event for another block does not invalidate this
24
+ // block's reader, and a FIRST write still reaches a reader that read the key while it was absent
25
+ // (Vue tracks a missing-key read, which is what makes the in-place write safe at all).
26
+
27
+ interface StoreCase {
28
+ name: string
29
+ /** Patch the store from a live event for `blockId`, tagged with `mark` so the read can see it. */
30
+ write: (blockId: string, mark: string) => void
31
+ /** What a card renders off that block: the tag, or null when the store holds nothing. */
32
+ read: (blockId: string) => string | null
33
+ }
34
+
35
+ const cases: StoreCase[] = [
36
+ {
37
+ name: 'requirements',
38
+ write: (blockId, mark) => {
39
+ useRequirementsStore().upsert({
40
+ id: mark,
41
+ blockId,
42
+ status: 'ready',
43
+ iteration: 1,
44
+ maxIterations: 3,
45
+ items: [],
46
+ updatedAt: 1,
47
+ } as unknown as RequirementReview)
48
+ },
49
+ read: (blockId) => useRequirementsStore().reviewFor(blockId)?.id ?? null,
50
+ },
51
+ {
52
+ name: 'clarity',
53
+ write: (blockId, mark) => {
54
+ useClarityStore().upsert({
55
+ id: mark,
56
+ blockId,
57
+ status: 'ready',
58
+ items: [],
59
+ updatedAt: 1,
60
+ } as unknown as ClarityReview)
61
+ },
62
+ read: (blockId) => useClarityStore().reviewFor(blockId)?.id ?? null,
63
+ },
64
+ {
65
+ name: 'brainstorm',
66
+ // Keyed by block+STAGE, so a block legitimately holds one live session per stage; the
67
+ // invalidation still has to land on the one composite key that changed.
68
+ write: (blockId, mark) => {
69
+ useBrainstormStore().upsert({
70
+ id: mark,
71
+ blockId,
72
+ stage: 'requirements',
73
+ status: 'ready',
74
+ options: [],
75
+ updatedAt: 1,
76
+ } as unknown as BrainstormSession)
77
+ },
78
+ read: (blockId) => useBrainstormStore().sessionFor(blockId, 'requirements')?.id ?? null,
79
+ },
80
+ {
81
+ name: 'consensus',
82
+ write: (blockId, mark) => {
83
+ useConsensusStore().upsert({
84
+ id: mark,
85
+ blockId,
86
+ status: 'complete',
87
+ participants: [],
88
+ rounds: [],
89
+ synthesis: null,
90
+ createdAt: 1,
91
+ updatedAt: 1,
92
+ } as unknown as ConsensusSession)
93
+ },
94
+ read: (blockId) => useConsensusStore().sessionFor(blockId)?.id ?? null,
95
+ },
96
+ {
97
+ name: 'docInterview',
98
+ write: (blockId, mark) => {
99
+ useDocInterviewStore().upsert({
100
+ id: mark,
101
+ blockId,
102
+ status: 'awaiting_answers',
103
+ round: 1,
104
+ maxRounds: 3,
105
+ qa: [],
106
+ createdAt: 1,
107
+ updatedAt: 1,
108
+ } as unknown as DocInterviewSession)
109
+ },
110
+ read: (blockId) => useDocInterviewStore().forBlock(blockId)?.id ?? null,
111
+ },
112
+ {
113
+ name: 'initiative',
114
+ write: (blockId, mark) => {
115
+ useInitiativesStore().upsert({
116
+ id: mark,
117
+ blockId,
118
+ slug: 'i',
119
+ title: 'I',
120
+ rev: 1,
121
+ } as unknown as Initiative)
122
+ },
123
+ read: (blockId) => useInitiativesStore().forBlock(blockId)?.id ?? null,
124
+ },
125
+ ]
126
+
127
+ describe.each(cases)('$name store per-key writes', ({ write, read }) => {
128
+ it('an event for one block does not invalidate a consumer reading another', () => {
129
+ write('blk-a', 'a1')
130
+
131
+ let evaluations = 0
132
+ const forA = computed(() => {
133
+ evaluations++
134
+ return read('blk-a')
135
+ })
136
+ expect(forA.value).toBe('a1')
137
+ expect(evaluations).toBe(1)
138
+
139
+ // A brand-new key, then a rewrite of that existing one: neither is about `blk-a`.
140
+ write('blk-b', 'b1')
141
+ expect(forA.value).toBe('a1')
142
+ write('blk-b', 'b2')
143
+ expect(forA.value).toBe('a1')
144
+ expect(evaluations).toBe(1)
145
+
146
+ // The block's OWN event still reaches it.
147
+ write('blk-a', 'a2')
148
+ expect(forA.value).toBe('a2')
149
+ expect(evaluations).toBe(2)
150
+ })
151
+
152
+ it('a FIRST write reaches a reader that read the key while it was absent', () => {
153
+ // The half an in-place write could plausibly lose: the reader tracked a key that did not
154
+ // exist. Vue tracks a missing-key read, so ADDING the key notifies it. Without this the
155
+ // window a card opens before its first event would render its empty state forever.
156
+ let evaluations = 0
157
+ const forC = computed(() => {
158
+ evaluations++
159
+ return read('blk-c')
160
+ })
161
+ expect(forC.value).toBeNull()
162
+ expect(evaluations).toBe(1)
163
+
164
+ write('blk-c', 'c1')
165
+ expect(forC.value).toBe('c1')
166
+ expect(evaluations).toBe(2)
167
+ })
168
+ })
@@ -20,6 +20,31 @@ export interface RecommendationCommandContext {
20
20
  hasPendingRecommendations: (blockId: string) => boolean
21
21
  }
22
22
 
23
+ /**
24
+ * Whether the Requirement Writer is still producing recommendations for THIS review: a `pending`
25
+ * placeholder exists. Server-derived, so the "Recommending…" state survives the window closing
26
+ * and a page reload; the client-local `recommending` set only covers the request round-trip.
27
+ *
28
+ * Memoised on the review OBJECT, like the settlement tallies next door, and for the same reason:
29
+ * the store REPLACES the review on every write, so identity self-invalidates and a superseded
30
+ * review is collected along with its answer.
31
+ *
32
+ * Per REVIEW rather than per record, which is the whole point. `backgroundStage` asks this on the
33
+ * per-CARD path, and a `computed` over the `reviews` record tracks the ref plus every key in it,
34
+ * so one review event would re-evaluate every card's stage, the fan-out the per-key write exists
35
+ * to remove. Reading one key depends on one key.
36
+ */
37
+ const pendingByReview = new WeakMap<RequirementReview, boolean>()
38
+
39
+ export function awaitsRecommendations(review: RequirementReview): boolean {
40
+ let pending = pendingByReview.get(review)
41
+ if (pending === undefined) {
42
+ pending = (review.recommendations ?? []).some((r) => r.status === 'pending')
43
+ pendingByReview.set(review, pending)
44
+ }
45
+ return pending
46
+ }
47
+
23
48
  /** Ask for, accept, reject and re-request the Requirement Writer's suggested answers. */
24
49
  export function createRecommendationCommands(ctx: RecommendationCommandContext) {
25
50
  const { api, workspace, recommending, withFlag, store, hasPendingRecommendations } = ctx
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect, beforeEach, vi } from 'vitest'
2
- import type { RequirementReview } from '~/types/requirements'
2
+ import { computed } from 'vue'
3
+ import type { RequirementRecommendation, RequirementReview } from '~/types/requirements'
3
4
  import { useRequirementsStore } from '~/stores/requirements'
4
5
  import { useWorkspaceStore } from '~/stores/workspace'
5
6
 
@@ -18,6 +19,20 @@ function review(over: Partial<RequirementReview> = {}): RequirementReview {
18
19
  } as RequirementReview
19
20
  }
20
21
 
22
+ /** A `pending` Writer placeholder: the state `backgroundStage` reads as "recommending". */
23
+ function pendingRecommendation(id: string): RequirementRecommendation {
24
+ return {
25
+ id,
26
+ sourceFinding: { title: 'f', detail: 'd', itemId: 'i1' },
27
+ recommendedText: '',
28
+ status: 'pending',
29
+ note: null,
30
+ groundedInFragment: null,
31
+ createdAt: 1,
32
+ updatedAt: 1,
33
+ } as RequirementRecommendation
34
+ }
35
+
21
36
  describe('requirements store load() loading flag', () => {
22
37
  beforeEach(() => {
23
38
  // The store resolves its workspace id from the workspace store at call time.
@@ -113,3 +128,76 @@ describe('requirements store live-event upsert guard', () => {
113
128
  expect(store.reviewFor('b1')?.id).toBe('rr2')
114
129
  })
115
130
  })
131
+
132
+ describe('requirements store per-key writes', () => {
133
+ it('an event for one block does not invalidate a consumer reading another', () => {
134
+ // Every card on the board reads its OWN block's review (the "Recommending…"/gate badge), so
135
+ // one review event used to wake every card: the store replaced the whole record, which is a
136
+ // write to the ref itself and therefore a dependency every reader shares. Writing the key
137
+ // keeps the invalidation on the block that changed.
138
+ const store = useRequirementsStore()
139
+ store.upsert(review({ id: 'rr-a', blockId: 'blk-a', updatedAt: 1 }))
140
+
141
+ let evaluations = 0
142
+ const forA = computed(() => {
143
+ evaluations++
144
+ return store.reviewFor('blk-a')?.id ?? null
145
+ })
146
+ expect(forA.value).toBe('rr-a')
147
+ expect(evaluations).toBe(1)
148
+
149
+ // A brand-new key, then a rewrite of an existing one: neither is about `blk-a`.
150
+ store.upsert(review({ id: 'rr-b', blockId: 'blk-b', updatedAt: 1 }))
151
+ expect(forA.value).toBe('rr-a')
152
+ store.upsert(review({ id: 'rr-b', blockId: 'blk-b', updatedAt: 2 }))
153
+ expect(forA.value).toBe('rr-a')
154
+ expect(evaluations).toBe(1)
155
+
156
+ // The block's OWN event still reaches it.
157
+ store.upsert(review({ id: 'rr-a2', blockId: 'blk-a', updatedAt: 2 }))
158
+ expect(forA.value).toBe('rr-a2')
159
+ expect(evaluations).toBe(2)
160
+ })
161
+
162
+ it('the per-card STAGE read depends on one block too, pending recommendations included', () => {
163
+ // `backgroundStage` is the read every card actually makes (TaskCard/BlockNode via
164
+ // `useReviewStage`), and it is the one the per-key write alone does not fix: while the pending
165
+ // -recommendation answer came from a `computed` over the whole `reviews` record, that computed
166
+ // tracked every key, so one event still re-evaluated the stage of every card on the board.
167
+ // Answering off the block's own review object is what closes it.
168
+ const store = useRequirementsStore()
169
+ store.upsert(review({ id: 'rr-a', blockId: 'blk-a', updatedAt: 1 }))
170
+
171
+ let evaluations = 0
172
+ const stageForA = computed(() => {
173
+ evaluations++
174
+ return store.backgroundStage('blk-a')
175
+ })
176
+ expect(stageForA.value).toBeNull()
177
+ expect(evaluations).toBe(1)
178
+
179
+ // Another block starts recommending: not this card's business.
180
+ store.upsert(
181
+ review({
182
+ id: 'rr-b',
183
+ blockId: 'blk-b',
184
+ updatedAt: 1,
185
+ recommendations: [pendingRecommendation('rec-1')],
186
+ }),
187
+ )
188
+ expect(stageForA.value).toBeNull()
189
+ expect(evaluations).toBe(1)
190
+
191
+ // This block's own placeholder still surfaces the working state.
192
+ store.upsert(
193
+ review({
194
+ id: 'rr-a2',
195
+ blockId: 'blk-a',
196
+ updatedAt: 2,
197
+ recommendations: [pendingRecommendation('rec-2')],
198
+ }),
199
+ )
200
+ expect(stageForA.value).toBe('recommending')
201
+ expect(evaluations).toBe(2)
202
+ })
203
+ })
@@ -1,5 +1,5 @@
1
1
  import { defineStore } from 'pinia'
2
- import { computed, ref } from 'vue'
2
+ import { ref } from 'vue'
3
3
  import type {
4
4
  RequirementReview,
5
5
  ResolveRequirementsExceededChoice,
@@ -16,7 +16,10 @@ import {
16
16
  canProceed,
17
17
  openCount,
18
18
  } from '~/stores/requirements/settlement'
19
- import { createRecommendationCommands } from '~/stores/requirements/recommendations'
19
+ import {
20
+ awaitsRecommendations,
21
+ createRecommendationCommands,
22
+ } from '~/stores/requirements/recommendations'
20
23
 
21
24
  /**
22
25
  * Requirements-review state. On the pipeline path the reviewer runs as the first gate
@@ -55,24 +58,16 @@ export const useRequirementsStore = defineStore('requirements', () => {
55
58
  function reviewFor(blockId: string): RequirementReview | null {
56
59
  return reviews.value[blockId] ?? null
57
60
  }
58
- /** Whether the Requirement Writer is still producing recommendations for a block (a `pending`
59
- * placeholder exists). Server-derived, so the "Recommending…" state survives the window closing
60
- * and a page reload — the client-local `recommending` set only covers the request round-trip. */
61
61
  /**
62
- * Blocks whose stored review still carries a `pending` recommendation placeholder, derived once
63
- * per change to `reviews` rather than per call. {@link backgroundStage} asks this on the per-CARD
64
- * path, so as a function it re-scanned one review's recommendation list for every card on the
65
- * board on every event.
62
+ * Whether the Requirement Writer is still producing recommendations for a block. Reads ONE key
63
+ * and answers off the review object itself ({@link awaitsRecommendations} memoises the scan on
64
+ * that object), so a card asking about its own block depends only on its own block. A `computed`
65
+ * over the whole record would be the fan-out again: it tracks every key, so one review event
66
+ * would re-evaluate the stage of every card on the board.
66
67
  */
67
- const blocksAwaitingRecommendations = computed(() => {
68
- const blocks = new Set<string>()
69
- for (const [blockId, review] of Object.entries(reviews.value)) {
70
- if ((review?.recommendations ?? []).some((r) => r.status === 'pending')) blocks.add(blockId)
71
- }
72
- return blocks
73
- })
74
68
  function hasPendingRecommendations(blockId: string): boolean {
75
- return blocksAwaitingRecommendations.value.has(blockId)
69
+ const review = reviews.value[blockId]
70
+ return review ? awaitsRecommendations(review) : false
76
71
  }
77
72
  /**
78
73
  * The async background stage a block's review is in, or null. While the driver folds the
@@ -96,8 +91,16 @@ export const useRequirementsStore = defineStore('requirements', () => {
96
91
  return incorporating.value.has(reviewId)
97
92
  }
98
93
 
94
+ /**
95
+ * Write one block's review into the cache, IN PLACE.
96
+ *
97
+ * Per-key, never a whole-record clone: `reviews` is a deep reactive ref, so a consumer reading
98
+ * `reviews[someBlockId]` depends on THAT key. Replacing the record retriggered every one of
99
+ * them (a card, a badge, an inspector panel for an untouched block) on every event; assigning
100
+ * the key retriggers only the consumers of the block that actually changed.
101
+ */
99
102
  function store(review: RequirementReview) {
100
- reviews.value = { ...reviews.value, [review.blockId]: review }
103
+ reviews.value[review.blockId] = review
101
104
  }
102
105
 
103
106
  /** Patch the cache from a live `requirements` stream event (newest wins per block). */
@@ -139,7 +142,9 @@ export const useRequirementsStore = defineStore('requirements', () => {
139
142
  try {
140
143
  const review = await api.getRequirementReview(workspace.requireId(), blockId)
141
144
  available.value = true
142
- reviews.value = { ...reviews.value, [blockId]: review }
145
+ // Written by key like `store()`, and directly because a load resolving to "none exists"
146
+ // caches a null the getter reads as "fetched, absent".
147
+ reviews.value[blockId] = review
143
148
  } catch {
144
149
  // 503 (feature off) or any error → hide the UI entry points.
145
150
  available.value = false
@@ -216,6 +216,11 @@ function createDocumentTaskModals(resetHubReturn: ResetHubReturn) {
216
216
  // the create-in target AND scopes the issue search to the frame's linked repo.
217
217
  // Null → the unscoped "import an issue" surface (workspace-wide search).
218
218
  const taskImport = ref<{ source: TaskSourceKind | null; containerId: string | null } | null>(null)
219
+ // In-app assistant: the prompt box that routes one sentence to one board action. It carries no
220
+ // subject (a turn resolves every name it needs from the board itself), so a plain flag says
221
+ // everything the host needs to know.
222
+ const assistantOpen = ref(false)
223
+
219
224
  // Bug hunt: pick a tracker + one of its boards, rank its open unassigned bugs, adopt one.
220
225
  // `containerId` (a service frame or module) preselects where an adopted bug lands; null →
221
226
  // opened standalone, and the modal offers every container on the board.
@@ -293,6 +298,13 @@ function createDocumentTaskModals(resetHubReturn: ResetHubReturn) {
293
298
  function closeTaskImport() {
294
299
  taskImport.value = null
295
300
  }
301
+ function openAssistant() {
302
+ resetHubReturn()
303
+ assistantOpen.value = true
304
+ }
305
+ function closeAssistant() {
306
+ assistantOpen.value = false
307
+ }
296
308
  function openBugHunt(source: TaskSourceKind | null = null, containerId: string | null = null) {
297
309
  resetHubReturn()
298
310
  bugHunt.value = { source, containerId }
@@ -341,6 +353,7 @@ function createDocumentTaskModals(resetHubReturn: ResetHubReturn) {
341
353
  spawnPreview,
342
354
  taskConnect,
343
355
  taskImport,
356
+ assistantOpen,
344
357
  bugHunt,
345
358
  startFromDesign,
346
359
  addTaskContainerId,
@@ -360,6 +373,8 @@ function createDocumentTaskModals(resetHubReturn: ResetHubReturn) {
360
373
  closeTaskConnect,
361
374
  openTaskImport,
362
375
  closeTaskImport,
376
+ openAssistant,
377
+ closeAssistant,
363
378
  openBugHunt,
364
379
  closeBugHunt,
365
380
  openStartFromDesign,
@@ -0,0 +1,24 @@
1
+ // ---------------------------------------------------------------------------
2
+ // The in-app assistant: one natural-language prompt, one action the platform performs on the
3
+ // person's behalf (declare a service dependency, add a service from a repository URL, file a task
4
+ // from a tracker issue).
5
+ //
6
+ // A turn answers with DATA, never with model prose: the outcome variant carries the ids and names
7
+ // of what was touched, or the machine-readable reason it could not act, and the SPA renders every
8
+ // sentence from those members through the i18n catalog. All wire shapes are sourced from
9
+ // @cat-factory/contracts (single source of truth).
10
+ // ---------------------------------------------------------------------------
11
+
12
+ export type {
13
+ AssistantActionId,
14
+ AssistantActionResult,
15
+ AssistantAnswer,
16
+ AssistantArguments,
17
+ AssistantCapability,
18
+ AssistantClarificationReason,
19
+ AssistantDeclineReason,
20
+ AssistantOutcome,
21
+ AssistantServiceRef,
22
+ AssistantTurn,
23
+ AssistantTurnInput,
24
+ } from '@cat-factory/contracts'
@@ -220,6 +220,7 @@ export type * from './skills'
220
220
  export type * from './foundationalServices'
221
221
  export type * from './documents'
222
222
  export type * from './tasks'
223
+ export type * from './assistant'
223
224
  export type * from './bugHunt'
224
225
  export type * from './bootstrap'
225
226
  export type * from './envConfigRepair'
@@ -2712,6 +2712,7 @@
2712
2712
  },
2713
2713
  "cmd": {
2714
2714
  "newPipeline": "Eine Pipeline bauen",
2715
+ "assistant": "Assistenten öffnen",
2715
2716
  "addFromRepo": "Service aus vorhandenem Repo hinzufügen",
2716
2717
  "bootstrapRepo": "Ein neues Repo bootstrappen",
2717
2718
  "githubManage": "GitHub-Verbindung verwalten",
@@ -2739,6 +2740,7 @@
2739
2740
  },
2740
2741
  "keywords": {
2741
2742
  "newPipeline": "pipeline agents chain",
2743
+ "assistant": "ki assistent fragen eingabe für mich erledigen",
2742
2744
  "addFromRepo": "github import existing",
2743
2745
  "bootstrapRepo": "scaffold create reference architecture",
2744
2746
  "github": "git repos pull requests issues",
@@ -4514,6 +4516,49 @@
4514
4516
  "missingBoard": "Wähle das Board aus, auf dem gesucht werden soll."
4515
4517
  }
4516
4518
  },
4519
+ "assistant": {
4520
+ "title": "Assistent",
4521
+ "intro": "Beschreiben Sie, was erledigt werden soll, und der Assistent führt es auf dem Board aus: eine Abhängigkeit zwischen zwei Services erklären, einen Service über eine Repository-URL hinzufügen oder eine Aufgabe aus einem Tracker-Ticket anlegen.",
4522
+ "unavailable": "Für diese Installation ist kein Modell konfiguriert, daher kann der Assistent keine Anfrage lesen. Richten Sie einen Modellanbieter ein, um ihn zu aktivieren.",
4523
+ "placeholder": "z. B. der Checkout-Service hängt vom Payments-Service ab",
4524
+ "submit": "Ausführen",
4525
+ "submitHint": "Strg+Enter, auf dem Mac Cmd+Enter",
4526
+ "examplesTitle": "Das können Sie fragen",
4527
+ "showOnBoard": "Auf dem Board zeigen",
4528
+ "declined": "Keine Aktion des Assistenten passt zu dieser Anfrage. Er kann eine Abhängigkeit zwischen zwei Services erklären, einen Service über eine Repository-URL hinzufügen oder eine Aufgabe aus einem Tracker-Ticket anlegen.",
4529
+ "actions": {
4530
+ "declare-service-dependency": {
4531
+ "label": "Service-Abhängigkeit erklären",
4532
+ "example": "der Checkout-Service hängt vom Payments-Service ab"
4533
+ },
4534
+ "add-service-from-repo": {
4535
+ "label": "Service aus einem Repository hinzufügen",
4536
+ "example": "füge https://github.com/acme/payments als Service hinzu"
4537
+ },
4538
+ "create-task-from-issue": {
4539
+ "label": "Aufgabe aus einem Ticket anlegen",
4540
+ "example": "erstelle eine Aufgabe aus https://github.com/acme/payments/issues/12"
4541
+ }
4542
+ },
4543
+ "done": {
4544
+ "dependencyDeclared": "{consumer} hängt jetzt von {provider} ab, beide werden also gestartet, wenn {consumer} getestet wird.",
4545
+ "dependencyAlready": "{consumer} hängt bereits von {provider} ab, es hat sich nichts geändert.",
4546
+ "serviceAdded": "{service} hinzugefügt, gestützt auf {repo}.",
4547
+ "serviceMounted": "{repo} liegt bereits einem Service dieser Organisation zugrunde, deshalb wurde {service} auf dieses Board gelegt statt neu angelegt.",
4548
+ "taskCreated": "{issue} als „{task}“ unter {service} angelegt."
4549
+ },
4550
+ "needsInput": {
4551
+ "missing_argument": "In dieser Anfrage fehlt etwas, das der Assistent braucht. Formulieren Sie sie vollständig und versuchen Sie es erneut.",
4552
+ "invalid_argument": "Einer der Werte in dieser Anfrage ist nicht verwendbar. Prüfen Sie ihn und versuchen Sie es erneut.",
4553
+ "unknown_service": "Kein Service auf diesem Board trägt diesen Namen.",
4554
+ "ambiguous_service": "Mehrere Services passen zu diesem Namen. Welchen meinen Sie?",
4555
+ "unknown_repository": "Dieser Workspace hat keine Verbindung zu diesem Repository. Verbinden Sie es zuerst und fragen Sie dann erneut.",
4556
+ "unreadable_repository_url": "Das ist keine Repository-URL, die der Assistent lesen kann. Fügen Sie die Webadresse des Repositorys ein.",
4557
+ "unknown_issue_source": "Kein verbundener Tracker erkennt diesen Ticket-Link. Verbinden Sie zuerst den zugehörigen Tracker.",
4558
+ "ambiguous_issue_source": "Zwei verbundene Tracker erkennen diese Angabe. Fügen Sie stattdessen die vollständige Webadresse des Tickets ein.",
4559
+ "unresolved_issue_service": "Der Assistent konnte nicht erkennen, unter welchem Service das angelegt werden soll. Nennen Sie den Service in Ihrer Anfrage."
4560
+ }
4561
+ },
4517
4562
  "pipeline": {
4518
4563
  "iterationCap": {
4519
4564
  "extraRound": "Noch eine Runde",
@@ -6095,6 +6140,7 @@
6095
6140
  "collapseSidebar": "Seitenleiste einklappen",
6096
6141
  "commandBar": "Suchen oder einen Befehl ausführen…",
6097
6142
  "create": "Erstellen",
6143
+ "assistant": "Assistent",
6098
6144
  "buildPipeline": "Eine Pipeline erstellen",
6099
6145
  "repositories": "Repositories",
6100
6146
  "addFromRepo": "Aus vorhandenem Repo hinzufügen",
@@ -6144,7 +6190,9 @@
6144
6190
  "service_catalog_unreachable": "Das Entwicklerportal dieses Boards hat nicht geantwortet oder eine Antwort geliefert, die diese Plattform nicht lesen kann. Hier ist nichts falsch konfiguriert und keine Änderung hilft: versuche es erneut, sobald das Portal erreichbar ist.",
6145
6191
  "service_catalog_unauthorized": "Das Entwicklerportal hat die gespeicherte Zugangsdaten abgelehnt. Sie wurden wahrscheinlich rotiert oder widerrufen: öffne die Servicekatalog-Einstellungen und trage aktuelle ein.",
6146
6192
  "service_catalog_filter_missing": "Der Entitätsfilter dieser Verbindung konnte nicht gelesen werden, es gibt also nichts zu importieren. Speichere die Servicekatalog-Verbindung erneut, um ihn wiederherzustellen.",
6147
- "service_catalog_response_too_large": "Das Entwicklerportal hat mit mehr Daten geantwortet, als diese Plattform in einer Antwort aufnimmt. Öffne die Servicekatalog-Einstellungen und senke das Service-Limit oder deaktiviere den Import von Schnittstellendefinitionen, und importiere dann erneut."
6193
+ "service_catalog_response_too_large": "Das Entwicklerportal hat mit mehr Daten geantwortet, als diese Plattform in einer Antwort aufnimmt. Öffne die Servicekatalog-Einstellungen und senke das Service-Limit oder deaktiviere den Import von Schnittstellendefinitionen, und importiere dann erneut.",
6194
+ "assistant_generation_failed": "Der Modellanbieter des Assistenten hat nicht geantwortet. Er ist korrekt konfiguriert und hier muss nichts eingerichtet werden: Versuchen Sie die Anfrage gleich noch einmal.",
6195
+ "assistant_reply_unreadable": "Das Modell des Assistenten hat etwas geantwortet, das diese Plattform nicht als Entscheidung ausführen kann. Eine Umformulierung hilft selten: Wählen Sie in den Modelleinstellungen ein anderes Modell für den Assistenten."
6148
6196
  }
6149
6197
  },
6150
6198
  "reason": {