@cat-factory/app 0.80.0 → 0.82.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.
@@ -0,0 +1,78 @@
1
+ <script setup lang="ts">
2
+ // Inspector body for an `initiative`-level block: the entity's status + goal, the
3
+ // "Run planning" control (pinned to the Initiative Planning pipeline — the engine
4
+ // refuses any other on this block), and the tracker window opener. Read-only in
5
+ // this slice; plan/policy editing lands with the execution loop.
6
+ import type { Block, InitiativeStatus } from '~/types/domain'
7
+ import { INITIATIVE_STATUS_LABEL_KEYS, initiativeProgress } from '~/utils/initiative'
8
+
9
+ const props = defineProps<{ block: Block }>()
10
+
11
+ const initiatives = useInitiativesStore()
12
+ const pipelines = usePipelinesStore()
13
+ const execution = useExecutionStore()
14
+ const ui = useUiStore()
15
+ const { t } = useI18n()
16
+
17
+ const initiative = computed(() => initiatives.forBlock(props.block.id))
18
+
19
+ const status = computed<InitiativeStatus>(() => initiative.value?.status ?? 'planning')
20
+
21
+ // The ONLY pipeline runnable on an initiative block (see the engine's runnable guard).
22
+ const planningPipeline = computed(() => pipelines.pipelines.find((p) => p.id === 'pl_initiative'))
23
+ const running = computed(() => !!props.block.executionId)
24
+
25
+ function runPlanning() {
26
+ if (planningPipeline.value) void execution.start(props.block.id, planningPipeline.value)
27
+ }
28
+ function openTracker() {
29
+ ui.openInitiativeTracker(props.block.id)
30
+ }
31
+
32
+ const progress = computed(() => initiativeProgress(initiative.value?.items))
33
+ </script>
34
+
35
+ <template>
36
+ <div class="space-y-3" data-testid="initiative-inspector">
37
+ <div class="flex items-center gap-2">
38
+ <UBadge color="primary" variant="subtle" size="sm">
39
+ {{ t(INITIATIVE_STATUS_LABEL_KEYS[status]) }}
40
+ </UBadge>
41
+ <span v-if="progress" class="text-[11px] text-slate-400">
42
+ {{ t('initiative.card.progress', { done: progress.settled, total: progress.total }) }}
43
+ </span>
44
+ </div>
45
+
46
+ <p v-if="initiative?.goal" class="whitespace-pre-wrap text-[12px] text-slate-300">
47
+ {{ initiative.goal }}
48
+ </p>
49
+
50
+ <div class="flex flex-wrap items-center gap-2">
51
+ <UButton
52
+ data-testid="initiative-run-planning"
53
+ color="primary"
54
+ variant="soft"
55
+ size="sm"
56
+ icon="i-lucide-play"
57
+ :disabled="!planningPipeline || running"
58
+ @click="runPlanning"
59
+ >
60
+ {{ t('initiative.inspector.runPlanning') }}
61
+ </UButton>
62
+ <UButton
63
+ data-testid="initiative-inspector-tracker"
64
+ color="neutral"
65
+ variant="soft"
66
+ size="sm"
67
+ icon="i-lucide-list-checks"
68
+ @click="openTracker"
69
+ >
70
+ {{ t('initiative.card.openTracker') }}
71
+ </UButton>
72
+ </div>
73
+
74
+ <p class="text-[11px] text-slate-500">
75
+ {{ t('initiative.inspector.hint') }}
76
+ </p>
77
+ </div>
78
+ </template>
@@ -38,6 +38,9 @@ const reRequestNotes = ref<Record<string, string>>({})
38
38
  // Freeform "do it differently" comment when redoing a merge the human was unhappy with.
39
39
  const redoComment = ref('')
40
40
  const showRedo = ref(false)
41
+ // Human's explicit collapse choice for the whole incorporated-requirements section; null = follow
42
+ // the default (collapse it while there's still work to do so it doesn't dominate the window).
43
+ const docCollapsedOverride = ref<boolean | null>(null)
41
44
 
42
45
  // The seam contract (open/blockId/close + Escape handling + load-on-open) lives in
43
46
  // `useResultView`, so this window can't drift from the others. Declaring `onOpen` makes the
@@ -52,6 +55,7 @@ const { open, blockId, instanceId, stepIndex, close } = useResultView('requireme
52
55
  reRequestNotes.value = {}
53
56
  redoComment.value = ''
54
57
  showRedo.value = false
58
+ docCollapsedOverride.value = null
55
59
  void requirements.load(id)
56
60
  },
57
61
  })
@@ -256,6 +260,41 @@ const recommendationProgress = computed(() => {
256
260
  const ready = readyRecommendations.value.filter((r) => batchTimes.has(r.createdAt)).length
257
261
  return { ready, total: ready + generating.length }
258
262
  })
263
+ // Whether the human still has something to act on (findings to answer/dismiss or recommendations
264
+ // to decide). Drives the incorporated-document default collapse so the reference doc doesn't push
265
+ // the actionable findings/recommendations off-screen while there's still work.
266
+ const hasActionableWork = computed(() => {
267
+ if (!review.value) return false
268
+ const findingWork = review.value.items.some(
269
+ (i) => i.status === 'open' || i.status === 'answered' || i.status === 'recommend_requested',
270
+ )
271
+ return (
272
+ findingWork ||
273
+ readyRecommendations.value.length > 0 ||
274
+ generatingRecommendations.value.length > 0
275
+ )
276
+ })
277
+ // The whole incorporated-requirements section collapses as a unit (independent of the per-heading
278
+ // collapse below). Default: collapsed only in the pre-incorporation `ready`-style phase while
279
+ // there's still actionable work — so the (potentially long) reference doc stays out of the way of
280
+ // the findings the human is working through. In `merged` (inspect the draft to decide re-review vs
281
+ // redo) and `incorporated` (the settled deliverable) the document IS the thing to read, so it
282
+ // defaults expanded. The human's explicit toggle wins within a phase; a status change (below)
283
+ // clears it so a collapse from one phase doesn't leak into the next.
284
+ const docCollapsed = computed(
285
+ () =>
286
+ docCollapsedOverride.value ?? (!incorporated.value && !merged.value && hasActionableWork.value),
287
+ )
288
+ function toggleDoc() {
289
+ docCollapsedOverride.value = !docCollapsed.value
290
+ }
291
+ // Reset the manual collapse on every status transition so a collapse chosen in one phase doesn't
292
+ // persist into the next (e.g. a `ready` collapse leaking into `merged`, or surviving convergence to
293
+ // `incorporated` and hiding the final requirements) — each phase then falls back to its own default.
294
+ watch(status, () => {
295
+ docCollapsedOverride.value = null
296
+ })
297
+
259
298
  function isMarkedForRecommend(item: RequirementReviewItem): boolean {
260
299
  return markedForRecommend.value.has(item.id)
261
300
  }
@@ -751,9 +790,19 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
751
790
  </div>
752
791
  </section>
753
792
 
754
- <!-- incorporated document: the standard-format requirements -->
793
+ <!-- incorporated document: the standard-format requirements. The whole section
794
+ collapses as a unit (a long doc otherwise pushes the findings/recommendations
795
+ off-screen); the per-heading toggles below still work when it's expanded. -->
755
796
  <section v-if="outline" class="mt-6 border-t border-slate-800 pt-5">
756
- <div class="mb-3 flex items-center gap-1.5 text-[11px] text-emerald-400">
797
+ <button
798
+ class="mb-3 flex w-full items-center gap-1.5 text-[11px] text-emerald-400"
799
+ @click="toggleDoc"
800
+ >
801
+ <UIcon
802
+ name="i-lucide-chevron-right"
803
+ class="h-3.5 w-3.5 shrink-0 transition-transform"
804
+ :class="docCollapsed ? '' : 'rotate-90'"
805
+ />
757
806
  <UIcon name="i-lucide-file-check-2" class="h-3.5 w-3.5" />
758
807
  <span class="font-semibold uppercase tracking-wide">
759
808
  {{
@@ -762,29 +811,31 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
762
811
  : t('requirements.incorporatedDraft')
763
812
  }}
764
813
  </span>
765
- </div>
766
- <div v-for="s in outline.sections" :key="s.id" class="mb-2">
767
- <button
768
- v-if="s.title"
769
- class="group flex w-full items-center gap-2 text-start"
770
- @click="toggle(s.id)"
771
- >
772
- <UIcon
773
- name="i-lucide-chevron-right"
774
- class="h-3.5 w-3.5 shrink-0 text-slate-500 transition-transform"
775
- :class="collapsed[s.id] ? '' : 'rotate-90'"
776
- />
777
- <span
778
- class="font-semibold text-white"
779
- :class="s.depth <= 1 ? 'text-base' : s.depth === 2 ? 'text-sm' : 'text-xs'"
780
- v-html="s.titleHtml"
814
+ </button>
815
+ <div v-show="!docCollapsed">
816
+ <div v-for="s in outline.sections" :key="s.id" class="mb-2">
817
+ <button
818
+ v-if="s.title"
819
+ class="group flex w-full items-center gap-2 text-start"
820
+ @click="toggle(s.id)"
821
+ >
822
+ <UIcon
823
+ name="i-lucide-chevron-right"
824
+ class="h-3.5 w-3.5 shrink-0 text-slate-500 transition-transform"
825
+ :class="collapsed[s.id] ? '' : 'rotate-90'"
826
+ />
827
+ <span
828
+ class="font-semibold text-white"
829
+ :class="s.depth <= 1 ? 'text-base' : s.depth === 2 ? 'text-sm' : 'text-xs'"
830
+ v-html="s.titleHtml"
831
+ />
832
+ </button>
833
+ <div
834
+ v-show="!s.title || !collapsed[s.id]"
835
+ class="reader-prose mt-1 ps-5.5 text-[13px] leading-relaxed text-slate-300"
836
+ v-html="s.bodyHtml"
781
837
  />
782
- </button>
783
- <div
784
- v-show="!s.title || !collapsed[s.id]"
785
- class="reader-prose mt-1 ps-5.5 text-[13px] leading-relaxed text-slate-300"
786
- v-html="s.bodyHtml"
787
- />
838
+ </div>
788
839
  </div>
789
840
  </section>
790
841
  </template>
@@ -806,12 +857,63 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
806
857
  <span>{{ t('requirements.stats.answered') }}</span>
807
858
  <span class="text-slate-300">{{ answeredCount }}</span>
808
859
  </div>
860
+ <!-- awaited recommendations — kept here (always visible) so the human can see what
861
+ the Writer is still producing / what's waiting on them even while reading the
862
+ incorporated document or acting elsewhere in the window. -->
863
+ <template v-if="generatingRecommendations.length || readyRecommendations.length">
864
+ <div
865
+ class="flex items-center gap-1.5 border-t border-slate-800/60 pt-2 text-indigo-300"
866
+ >
867
+ <UIcon name="i-lucide-wand-2" class="h-3 w-3" />
868
+ <span class="font-medium">{{ t('requirements.stats.recommendations') }}</span>
869
+ </div>
870
+ <div
871
+ v-if="generatingRecommendations.length"
872
+ class="flex items-center justify-between"
873
+ >
874
+ <span>{{ t('requirements.stats.recsGenerating') }}</span>
875
+ <span class="text-indigo-300">{{ generatingRecommendations.length }}</span>
876
+ </div>
877
+ <div v-if="readyRecommendations.length" class="flex items-center justify-between">
878
+ <span>{{ t('requirements.stats.recsToReview') }}</span>
879
+ <span class="text-indigo-300">{{ readyRecommendations.length }}</span>
880
+ </div>
881
+ </template>
809
882
  <div v-if="review.model" class="flex items-center justify-between">
810
883
  <span>{{ t('requirements.stats.model') }}</span>
811
884
  <span class="truncate ps-2 text-slate-500">{{ review.model }}</span>
812
885
  </div>
813
886
  </div>
814
887
 
888
+ <!-- Request the Requirement Writer for the marked findings. Kept OUT of the
889
+ status-scoped blocks below so it's available whenever the review is still
890
+ editable — the `ready` first pass AND a `merged` review being reworked — not
891
+ only when status is exactly `ready`. Scoped to exactly those two states (NOT a
892
+ bare `!frozen`, which would also expose it in `exceeded`, where the run is parked
893
+ on the cap decision and a fresh recommendation batch has no path to settle). -->
894
+ <div
895
+ v-if="review && markedForRecommend.size > 0 && (status === 'ready' || merged)"
896
+ class="border-t border-slate-800 pt-4"
897
+ >
898
+ <UButton
899
+ color="primary"
900
+ variant="soft"
901
+ size="sm"
902
+ block
903
+ icon="i-lucide-wand-2"
904
+ :loading="recommending"
905
+ @click="requestRecommendations"
906
+ >
907
+ {{
908
+ t(
909
+ 'requirements.actions.requestRecommendations',
910
+ { count: markedForRecommend.size },
911
+ markedForRecommend.size,
912
+ )
913
+ }}
914
+ </UButton>
915
+ </div>
916
+
815
917
  <!-- action: ready (answer → incorporate / proceed) -->
816
918
  <div
817
919
  v-if="review && status === 'ready'"
@@ -841,24 +943,6 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
841
943
  >
842
944
  {{ t('requirements.actions.incorporateAnswers') }}
843
945
  </UButton>
844
- <UButton
845
- v-if="markedForRecommend.size > 0"
846
- color="primary"
847
- variant="soft"
848
- size="sm"
849
- block
850
- icon="i-lucide-wand-2"
851
- :loading="recommending"
852
- @click="requestRecommendations"
853
- >
854
- {{
855
- t(
856
- 'requirements.actions.requestRecommendations',
857
- { count: markedForRecommend.size },
858
- markedForRecommend.size,
859
- )
860
- }}
861
- </UButton>
862
946
  <p class="text-[11px] leading-relaxed text-slate-500">
863
947
  <template v-if="canProceed">
864
948
  {{ t('requirements.help.canProceed') }}
@@ -0,0 +1,28 @@
1
+ import {
2
+ createInitiativeContract,
3
+ getInitiativeByBlockContract,
4
+ getInitiativeContract,
5
+ listInitiativesContract,
6
+ } from '@cat-factory/contracts'
7
+ import type { ApiContext } from './context'
8
+
9
+ /** Initiatives: the long-running multi-task work containers (create + tracker reads). */
10
+ export function initiativeApi({ send, ws }: ApiContext) {
11
+ return {
12
+ // Create the initiative-level board block AND its empty entity in one call.
13
+ createInitiative: (
14
+ workspaceId: string,
15
+ body: { frameId: string; title: string; description?: string },
16
+ ) => send(createInitiativeContract, { pathPrefix: ws(workspaceId), body }),
17
+
18
+ listInitiatives: (workspaceId: string) =>
19
+ send(listInitiativesContract, { pathPrefix: ws(workspaceId) }),
20
+
21
+ getInitiative: (workspaceId: string, initiativeId: string) =>
22
+ send(getInitiativeContract, { pathPrefix: ws(workspaceId), pathParams: { initiativeId } }),
23
+
24
+ // The tracker window's load path: the initiative anchored to a board block.
25
+ getInitiativeByBlock: (workspaceId: string, blockId: string) =>
26
+ send(getInitiativeByBlockContract, { pathPrefix: ws(workspaceId), pathParams: { blockId } }),
27
+ }
28
+ }
@@ -13,6 +13,7 @@ import { githubApi } from './api/github'
13
13
  import { humanReviewApi } from './api/humanReview'
14
14
  import { humanTestApi } from './api/humanTest'
15
15
  import { infraHandlersApi } from './api/infraHandlers'
16
+ import { initiativeApi } from './api/initiative'
16
17
  import { visualConfirmApi } from './api/visualConfirm'
17
18
  import { kaizenApi } from './api/kaizen'
18
19
  import { localSettingsApi } from './api/localSettings'
@@ -112,6 +113,7 @@ export function useApi() {
112
113
  ...presetsApi(ctx),
113
114
  ...providerConnectionsApi(ctx),
114
115
  ...infraHandlersApi(ctx),
116
+ ...initiativeApi(ctx),
115
117
  ...provisioningLogsApi(ctx),
116
118
  ...releaseHealthApi(ctx),
117
119
  ...packageRegistriesApi(ctx),
@@ -59,6 +59,11 @@ export function useBlockQueries(blocks: Ref<Block[]>) {
59
59
  return childrenOf(serviceId).filter((b) => b.level === 'module')
60
60
  }
61
61
 
62
+ /** Initiative containers inside a service (frame children, like modules). */
63
+ function initiativesOf(serviceId: string) {
64
+ return childrenOf(serviceId).filter((b) => b.level === 'initiative')
65
+ }
66
+
62
67
  /** Tasks anywhere under a container — directly, or nested inside its modules. */
63
68
  function allTasksUnder(containerId: string): Block[] {
64
69
  const direct = tasksOf(containerId)
@@ -124,9 +129,17 @@ export function useBlockQueries(blocks: Ref<Block[]>) {
124
129
  */
125
130
  function frameStatus(frameId: string): BlockStatus {
126
131
  const tasks = allTasksUnder(frameId)
127
- if (tasks.length === 0) return 'planned'
128
- if (tasks.some((t) => t.status === 'blocked')) return 'blocked'
129
- if (tasks.some((t) => t.status === 'in_progress' || t.status === 'pr_ready'))
132
+ // Initiative containers are frame children too: a frame holding only an initiative
133
+ // is NOT empty, and an active (planning/executing block `in_progress`) or blocked
134
+ // initiative drives the frame's activity dot just like a task does.
135
+ const inits = initiativesOf(frameId)
136
+ if (tasks.length === 0 && inits.length === 0) return 'planned'
137
+ if (tasks.some((t) => t.status === 'blocked') || inits.some((i) => i.status === 'blocked'))
138
+ return 'blocked'
139
+ if (
140
+ tasks.some((t) => t.status === 'in_progress' || t.status === 'pr_ready') ||
141
+ inits.some((i) => i.status === 'in_progress')
142
+ )
130
143
  return 'in_progress'
131
144
  return 'ready'
132
145
  }
@@ -153,6 +166,11 @@ export function useBlockQueries(blocks: Ref<Block[]>) {
153
166
  w = Math.max(w, m.position.x + s.w + 12)
154
167
  inner = Math.max(inner, m.position.y + s.h + 12)
155
168
  }
169
+ // Initiative cards render inside the frame's drop zone like tasks (230×~170).
170
+ for (const i of initiativesOf(id)) {
171
+ w = Math.max(w, i.position.x + 230 + 12)
172
+ inner = Math.max(inner, i.position.y + 170 + 12)
173
+ }
156
174
  return { w, h: inner + headerH }
157
175
  }
158
176
 
@@ -180,6 +198,7 @@ export function useBlockQueries(blocks: Ref<Block[]>) {
180
198
  childrenOf,
181
199
  tasksOf,
182
200
  modulesOf,
201
+ initiativesOf,
183
202
  allTasksUnder,
184
203
  serviceOf,
185
204
  unmetDeps,
@@ -25,6 +25,7 @@ export function useWorkspaceStream() {
25
25
  const clarity = useClarityStore()
26
26
  const brainstorm = useBrainstormStore()
27
27
  const kaizen = useKaizenStore()
28
+ const initiatives = useInitiativesStore()
28
29
  const api = useApi()
29
30
  const apiBase = useRuntimeConfig().public.apiBase
30
31
 
@@ -103,6 +104,10 @@ export function useWorkspaceStream() {
103
104
  // run cache (so an open run window shows scheduled→running→complete live) and the
104
105
  // Kaizen screen history. Never surfaced on the board.
105
106
  kaizen.upsert(event.grading)
107
+ } else if (event.type === 'initiative') {
108
+ // An initiative changed (created, plan ingested, an item settled) — patch the cache
109
+ // so an open tracker window / the board card reflects the transition live.
110
+ initiatives.upsert(event.initiative)
106
111
  }
107
112
  }
108
113
 
@@ -332,6 +332,7 @@ watch(
332
332
  <AgentStepDetail />
333
333
  <StepResultViewHost />
334
334
  <AddTaskModal />
335
+ <CreateInitiativeModal />
335
336
  <CommandBar />
336
337
  <PersonalCredentialModal />
337
338
  <ConfirmDialog />
@@ -22,8 +22,11 @@ import { useBlockQueries } from '~/composables/useBlockQueries'
22
22
  interface RemovalSnapshot {
23
23
  /** The removed block + all its descendants, in their original order. */
24
24
  removed: Block[]
25
- /** Survivors whose `dependsOn`/`epicId` lost an edge to a removed block (originals to restore). */
26
- edges: { id: string; dependsOn: string[]; epicId: string | null }[]
25
+ /**
26
+ * Survivors whose `dependsOn`/`epicId`/`initiativeId` lost an edge to a removed block
27
+ * (originals to restore on rollback).
28
+ */
29
+ edges: { id: string; dependsOn: string[]; epicId: string | null; initiativeId: string | null }[]
27
30
  }
28
31
 
29
32
  export const useBoardStore = defineStore('board', () => {
@@ -255,15 +258,23 @@ export const useBoardStore = defineStore('board', () => {
255
258
  }
256
259
  }
257
260
  const removed = blocks.value.filter((b) => doomed.has(b.id))
258
- // Survivors that pointed at a doomed block (dependency edge or epic membership) lose
259
- // that link — snapshot the originals so a failed delete restores them faithfully.
261
+ // Survivors that pointed at a doomed block (dependency edge, epic membership, or initiative
262
+ // membership) lose that link — snapshot the originals so a failed delete restores them
263
+ // faithfully. Mirrors the backend `pruneDanglingEdges` detach.
260
264
  const edges = blocks.value
261
265
  .filter(
262
266
  (b) =>
263
267
  !doomed.has(b.id) &&
264
- (b.dependsOn.some((d) => doomed.has(d)) || (b.epicId != null && doomed.has(b.epicId))),
268
+ (b.dependsOn.some((d) => doomed.has(d)) ||
269
+ (b.epicId != null && doomed.has(b.epicId)) ||
270
+ (b.initiativeId != null && doomed.has(b.initiativeId))),
265
271
  )
266
- .map((b) => ({ id: b.id, dependsOn: [...b.dependsOn], epicId: b.epicId ?? null }))
272
+ .map((b) => ({
273
+ id: b.id,
274
+ dependsOn: [...b.dependsOn],
275
+ epicId: b.epicId ?? null,
276
+ initiativeId: b.initiativeId ?? null,
277
+ }))
267
278
  blocks.value = blocks.value.filter((b) => !doomed.has(b.id))
268
279
  for (const b of blocks.value) {
269
280
  if (b.dependsOn.some((d) => doomed.has(d))) {
@@ -271,6 +282,8 @@ export const useBoardStore = defineStore('board', () => {
271
282
  }
272
283
  // A member of a deleted epic loses its membership (the task itself survives).
273
284
  if (b.epicId != null && doomed.has(b.epicId)) b.epicId = null
285
+ // Likewise a task spawned by a deleted initiative loses its (non-structural) membership.
286
+ if (b.initiativeId != null && doomed.has(b.initiativeId)) b.initiativeId = null
274
287
  }
275
288
  return { removed, edges }
276
289
  }
@@ -283,6 +296,7 @@ export const useBoardStore = defineStore('board', () => {
283
296
  if (b) {
284
297
  b.dependsOn = e.dependsOn
285
298
  b.epicId = e.epicId
299
+ b.initiativeId = e.initiativeId
286
300
  }
287
301
  }
288
302
  }
@@ -0,0 +1,98 @@
1
+ import { defineStore } from 'pinia'
2
+ import { computed, ref } from 'vue'
3
+ import type { Initiative } from '~/types/domain'
4
+ import { useWorkspaceStore } from '~/stores/workspace'
5
+ import { useBoardStore } from '~/stores/board'
6
+
7
+ /**
8
+ * Initiative state — the long-running multi-task work containers, keyed by their
9
+ * anchor BLOCK id (the id everything on the board navigates by). Hydrated from the
10
+ * workspace snapshot (`snapshot.initiatives`) and patched live from `initiative`
11
+ * stream events; `create` calls the API and applies the authoritative entity +
12
+ * block the server returns. `available` mirrors the backend's opt-in module (a 503
13
+ * hides the UI). Per-workspace; nothing is persisted client-side.
14
+ *
15
+ * NOTE: distinct from `useTrackerStore` (the workspace's ISSUE-tracker selection) —
16
+ * "tracker" in initiative-land means the initiative's plan/tracker document.
17
+ */
18
+ export const useInitiativesStore = defineStore('initiatives', () => {
19
+ const api = useApi()
20
+ const workspace = useWorkspaceStore()
21
+
22
+ /** null = unknown (not probed), true/false = feature on/off. */
23
+ const available = ref<boolean | null>(null)
24
+ /** The entities keyed by their anchor block id. */
25
+ const byBlock = ref<Record<string, Initiative>>({})
26
+ /** True while a create call is in flight (the modal's submit spinner). */
27
+ const creating = ref(false)
28
+
29
+ const all = computed(() => Object.values(byBlock.value))
30
+
31
+ function forBlock(blockId: string): Initiative | null {
32
+ return byBlock.value[blockId] ?? null
33
+ }
34
+
35
+ /**
36
+ * Rebuild the cache from a snapshot (the hydrate fan-out). The snapshot is authoritative
37
+ * for EXISTENCE (entities it omits are dropped — they were deleted), but NOT for freshness:
38
+ * a stale snapshot captured before a live `initiative` event must not regress a newer entity
39
+ * already patched into the store. So for a blockId present in both, keep whichever `rev` is
40
+ * higher — the same live-event-vs-resync race guard `upsert` applies, mirroring the fix the
41
+ * repo's flake note describes for `agentRuns.hydrate`.
42
+ */
43
+ function hydrate(next: Initiative[] | undefined) {
44
+ if (next === undefined) return
45
+ available.value = true
46
+ const map: Record<string, Initiative> = {}
47
+ for (const initiative of next) {
48
+ const existing = byBlock.value[initiative.blockId]
49
+ map[initiative.blockId] = existing && existing.rev > initiative.rev ? existing : initiative
50
+ }
51
+ byBlock.value = map
52
+ }
53
+
54
+ /** Patch from a live `initiative` stream event or a call response (newest rev wins). */
55
+ function upsert(initiative: Initiative) {
56
+ const existing = byBlock.value[initiative.blockId]
57
+ if (existing && existing.rev > initiative.rev) return
58
+ byBlock.value = { ...byBlock.value, [initiative.blockId]: initiative }
59
+ }
60
+
61
+ /** Create an initiative under a service frame (block + entity in one call). */
62
+ async function create(frameId: string, input: { title: string; description?: string }) {
63
+ if (!workspace.workspaceId) throw new Error('No active workspace')
64
+ creating.value = true
65
+ try {
66
+ const created = await api.createInitiative(workspace.workspaceId, {
67
+ frameId,
68
+ title: input.title,
69
+ ...(input.description ? { description: input.description } : {}),
70
+ })
71
+ useBoardStore().upsert(created.block)
72
+ upsert(created.initiative)
73
+ return created
74
+ } finally {
75
+ creating.value = false
76
+ }
77
+ }
78
+
79
+ /** Re-fetch one block's initiative (the tracker window's load path). */
80
+ async function load(blockId: string) {
81
+ if (!workspace.workspaceId) return
82
+ try {
83
+ const initiative = await api.getInitiativeByBlock(workspace.workspaceId, blockId)
84
+ available.value = true
85
+ if (initiative) upsert(initiative)
86
+ } catch (error) {
87
+ const status = (error as { status?: number } | null)?.status
88
+ if (status === 503) available.value = false
89
+ else throw error
90
+ }
91
+ }
92
+
93
+ function reset() {
94
+ byBlock.value = {}
95
+ }
96
+
97
+ return { available, byBlock, all, creating, forBlock, hydrate, upsert, create, load, reset }
98
+ })
package/app/stores/ui.ts CHANGED
@@ -86,6 +86,10 @@ export const useUiStore = defineStore('ui', () => {
86
86
  // the frame opens it, scoped to that frame).
87
87
  const addRecurringFrameId = ref<string | null>(null)
88
88
 
89
+ // Create-initiative modal: the service frame a new initiative is being created
90
+ // under, or null when closed (mirrors the add-task flow).
91
+ const createInitiativeFrameId = ref<string | null>(null)
92
+
89
93
  // Repo-bootstrap modal (manage reference architectures + launch a bootstrap).
90
94
  const bootstrapOpen = ref(false)
91
95
 
@@ -424,6 +428,12 @@ export const useUiStore = defineStore('ui', () => {
424
428
  function closeAddRecurring() {
425
429
  addRecurringFrameId.value = null
426
430
  }
431
+ function openCreateInitiative(frameId: string) {
432
+ createInitiativeFrameId.value = frameId
433
+ }
434
+ function closeCreateInitiative() {
435
+ createInitiativeFrameId.value = null
436
+ }
427
437
  function openBootstrap() {
428
438
  bootstrapOpen.value = true
429
439
  }
@@ -714,6 +724,10 @@ export const useUiStore = defineStore('ui', () => {
714
724
  function openServiceSpec(blockId: string) {
715
725
  resultView.value = { view: 'service-spec', blockId, instanceId: null, stepIndex: null }
716
726
  }
727
+ // Open the initiative tracker window for an initiative block (board card / inspector).
728
+ function openInitiativeTracker(blockId: string) {
729
+ resultView.value = { view: 'initiative-tracker', blockId, instanceId: null, stepIndex: null }
730
+ }
717
731
  // Open the Follow-up companion window for a run's Coder step (the blinking chip + the
718
732
  // `followup_pending` notification). Resolves the Coder step index from the run when not
719
733
  // given, so callers that only know the run can still open it.
@@ -783,6 +797,7 @@ export const useUiStore = defineStore('ui', () => {
783
797
  addTaskContainerId,
784
798
  addTaskPrefill,
785
799
  addRecurringFrameId,
800
+ createInitiativeFrameId,
786
801
  bootstrapOpen,
787
802
  addServiceOpen,
788
803
  githubOpen,
@@ -856,6 +871,8 @@ export const useUiStore = defineStore('ui', () => {
856
871
  closeAddTask,
857
872
  openAddRecurring,
858
873
  closeAddRecurring,
874
+ openCreateInitiative,
875
+ closeCreateInitiative,
859
876
  openBootstrap,
860
877
  closeBootstrap,
861
878
  openAddService,
@@ -921,6 +938,7 @@ export const useUiStore = defineStore('ui', () => {
921
938
  openClarityReview,
922
939
  openBrainstorm,
923
940
  openServiceSpec,
941
+ openInitiativeTracker,
924
942
  openFollowUps,
925
943
  closeRequirementReview,
926
944
  openStepDetail,