@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.
- package/app/components/board/CreateInitiativeModal.vue +113 -0
- package/app/components/board/nodes/BlockNode.vue +21 -1
- package/app/components/board/nodes/InitiativeCard.vue +107 -0
- package/app/components/initiative/InitiativeTrackerWindow.vue +279 -0
- package/app/components/panels/InspectorPanel.vue +5 -0
- package/app/components/panels/StepResultViewHost.vue +5 -0
- package/app/components/panels/inspector/InitiativeInspector.vue +78 -0
- package/app/components/requirements/RequirementsReviewWindow.vue +126 -42
- package/app/composables/api/initiative.ts +28 -0
- package/app/composables/useApi.ts +2 -0
- package/app/composables/useBlockQueries.ts +22 -3
- package/app/composables/useWorkspaceStream.ts +5 -0
- package/app/pages/index.vue +1 -0
- package/app/stores/board.ts +20 -6
- package/app/stores/initiative.ts +98 -0
- package/app/stores/ui.ts +18 -0
- package/app/stores/workspace.ts +3 -0
- package/app/types/domain.ts +1 -0
- package/app/types/initiative.ts +18 -0
- package/app/utils/catalog.ts +24 -0
- package/app/utils/initiative.ts +57 -0
- package/i18n/locales/en.json +70 -1
- package/i18n/locales/es.json +70 -1
- package/i18n/locales/fr.json +70 -1
- package/i18n/locales/he.json +70 -1
- package/i18n/locales/ja.json +70 -1
- package/i18n/locales/pl.json +70 -1
- package/i18n/locales/tr.json +70 -1
- package/i18n/locales/uk.json +70 -1
- package/package.json +2 -2
|
@@ -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
|
-
<
|
|
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
|
-
</
|
|
766
|
-
<div v-
|
|
767
|
-
<
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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
|
-
</
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
package/app/pages/index.vue
CHANGED
package/app/stores/board.ts
CHANGED
|
@@ -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
|
-
/**
|
|
26
|
-
|
|
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
|
|
259
|
-
// that link — snapshot the originals so a failed delete restores them
|
|
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)) ||
|
|
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) => ({
|
|
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,
|