@cat-factory/app 1.0.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 (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +88 -0
  3. package/app/app.config.ts +8 -0
  4. package/app/app.vue +11 -0
  5. package/app/assets/css/main.css +100 -0
  6. package/app/components/auth/AuthGate.vue +24 -0
  7. package/app/components/auth/LoginScreen.vue +18 -0
  8. package/app/components/auth/UserMenu.vue +39 -0
  9. package/app/components/board/AgentFailureCard.vue +97 -0
  10. package/app/components/board/AgentStopButton.vue +61 -0
  11. package/app/components/board/BoardCanvas.vue +146 -0
  12. package/app/components/board/TaskDependencyEdges.vue +132 -0
  13. package/app/components/board/nodes/AgentChip.vue +59 -0
  14. package/app/components/board/nodes/BlockNode.vue +347 -0
  15. package/app/components/board/nodes/DecisionBadge.vue +21 -0
  16. package/app/components/board/nodes/DraggableTask.vue +69 -0
  17. package/app/components/board/nodes/ModuleFrame.vue +70 -0
  18. package/app/components/board/nodes/TaskCard.vue +237 -0
  19. package/app/components/bootstrap/BootstrapModal.vue +665 -0
  20. package/app/components/documents/DocumentImportModal.vue +161 -0
  21. package/app/components/documents/DocumentSourceConnectModal.vue +127 -0
  22. package/app/components/documents/SpawnPreviewModal.vue +161 -0
  23. package/app/components/documents/TaskContextDocs.vue +83 -0
  24. package/app/components/focus/BlockFocusView.vue +161 -0
  25. package/app/components/fragments/FragmentLibraryPanel.vue +340 -0
  26. package/app/components/github/GitHubConnect.vue +183 -0
  27. package/app/components/github/GitHubPanel.vue +584 -0
  28. package/app/components/layout/BoardSwitcher.vue +202 -0
  29. package/app/components/layout/BoardToolbar.vue +109 -0
  30. package/app/components/layout/SideBar.vue +193 -0
  31. package/app/components/layout/SpendWarningBanner.vue +107 -0
  32. package/app/components/palettes/AgentPalette.vue +33 -0
  33. package/app/components/palettes/BlockPalette.vue +41 -0
  34. package/app/components/palettes/PipelinePalette.vue +74 -0
  35. package/app/components/panels/DecisionModal.vue +71 -0
  36. package/app/components/panels/InspectorPanel.vue +296 -0
  37. package/app/components/panels/inspector/ContainerSummary.vue +74 -0
  38. package/app/components/panels/inspector/TaskDependencies.vue +70 -0
  39. package/app/components/panels/inspector/TaskExecution.vue +175 -0
  40. package/app/components/panels/inspector/TaskModelSettings.vue +128 -0
  41. package/app/components/panels/inspector/TaskStructure.vue +139 -0
  42. package/app/components/pipeline/PipelineBuilder.vue +227 -0
  43. package/app/components/pipeline/PipelineProgress.vue +246 -0
  44. package/app/components/requirements/RequirementReviewModal.vue +328 -0
  45. package/app/components/scenarios/FeatureScenarios.vue +162 -0
  46. package/app/components/scenarios/ScenarioCard.vue +109 -0
  47. package/app/components/tasks/TaskContextIssues.vue +88 -0
  48. package/app/components/tasks/TaskImportModal.vue +140 -0
  49. package/app/components/tasks/TaskSourceConnectModal.vue +122 -0
  50. package/app/composables/useApi.ts +535 -0
  51. package/app/composables/useBlockDrag.ts +75 -0
  52. package/app/composables/useBlockQueries.ts +136 -0
  53. package/app/composables/useBoardFlow.ts +11 -0
  54. package/app/composables/useDepLabels.ts +26 -0
  55. package/app/composables/useSemanticZoom.ts +16 -0
  56. package/app/composables/useWorkspaceStream.ts +125 -0
  57. package/app/docs/architecture.md +31 -0
  58. package/app/pages/index.vue +80 -0
  59. package/app/stores/accounts.ts +64 -0
  60. package/app/stores/agentRuns.ts +117 -0
  61. package/app/stores/agents.ts +40 -0
  62. package/app/stores/auth.ts +97 -0
  63. package/app/stores/board.spec.ts +197 -0
  64. package/app/stores/board.ts +147 -0
  65. package/app/stores/bootstrap.ts +97 -0
  66. package/app/stores/documents.ts +165 -0
  67. package/app/stores/execution.ts +115 -0
  68. package/app/stores/fragmentLibrary.ts +147 -0
  69. package/app/stores/fragments.ts +40 -0
  70. package/app/stores/github.ts +291 -0
  71. package/app/stores/models.ts +48 -0
  72. package/app/stores/pipelines.ts +77 -0
  73. package/app/stores/requirements.ts +133 -0
  74. package/app/stores/scenarios.spec.ts +82 -0
  75. package/app/stores/scenarios.ts +196 -0
  76. package/app/stores/tasks.spec.ts +71 -0
  77. package/app/stores/tasks.ts +149 -0
  78. package/app/stores/ui.ts +204 -0
  79. package/app/stores/workspace.ts +201 -0
  80. package/app/types/accounts.ts +38 -0
  81. package/app/types/bootstrap.ts +83 -0
  82. package/app/types/documents.ts +92 -0
  83. package/app/types/domain.ts +216 -0
  84. package/app/types/execution.ts +110 -0
  85. package/app/types/fragments.ts +72 -0
  86. package/app/types/github.ts +153 -0
  87. package/app/types/models.ts +48 -0
  88. package/app/types/requirements.ts +38 -0
  89. package/app/types/scenarios.ts +36 -0
  90. package/app/types/tasks.ts +67 -0
  91. package/app/utils/catalog.spec.ts +82 -0
  92. package/app/utils/catalog.ts +185 -0
  93. package/app/utils/dnd.ts +29 -0
  94. package/nuxt.config.ts +43 -0
  95. package/package.json +43 -0
@@ -0,0 +1,165 @@
1
+ import { defineStore } from 'pinia'
2
+ import { computed, ref } from 'vue'
3
+ import type {
4
+ DocumentBoardPlan,
5
+ DocumentConnection,
6
+ DocumentSourceDescriptor,
7
+ DocumentSourceKind,
8
+ SourceDocument,
9
+ } from '~/types/domain'
10
+ import { useWorkspaceStore } from '~/stores/workspace'
11
+
12
+ /**
13
+ * Document-source integration state: the sources the backend offers (and their
14
+ * connect metadata), the workspace's per-source connections, and the pages it
15
+ * has imported — plus the actions that connect/import/plan/spawn/link against the
16
+ * backend. `available` mirrors the backend's opt-in gate: a 503 from the source
17
+ * probe means the integration is off, and the UI hides its entry points (just as
18
+ * `auth.required` gates the login UI). The abstraction is source-agnostic; every
19
+ * action is keyed by a `DocumentSourceKind`. Per-workspace, like the board
20
+ * itself; nothing is persisted client-side.
21
+ */
22
+ export const useDocumentsStore = defineStore('documents', () => {
23
+ const api = useApi()
24
+ const workspace = useWorkspaceStore()
25
+
26
+ /** null = unknown (not probed yet), true/false = integration on/off. */
27
+ const available = ref<boolean | null>(null)
28
+ /** The configured sources and their connect/import descriptors. */
29
+ const sources = ref<DocumentSourceDescriptor[]>([])
30
+ /** Live connections, one per connected source. */
31
+ const connections = ref<DocumentConnection[]>([])
32
+ const documents = ref<SourceDocument[]>([])
33
+ const loading = ref(false)
34
+
35
+ /** Sources the workspace currently has a live connection to. */
36
+ const connectedSources = computed(() =>
37
+ sources.value.filter((s) => connections.value.some((c) => c.source === s.source)),
38
+ )
39
+ const anyConnected = computed(() => connections.value.length > 0)
40
+
41
+ function descriptorFor(source: DocumentSourceKind): DocumentSourceDescriptor | undefined {
42
+ return sources.value.find((s) => s.source === source)
43
+ }
44
+
45
+ function connectionFor(source: DocumentSourceKind): DocumentConnection | undefined {
46
+ return connections.value.find((c) => c.source === source)
47
+ }
48
+
49
+ function isConnected(source: DocumentSourceKind): boolean {
50
+ return connectionFor(source) !== undefined
51
+ }
52
+
53
+ /** Imported documents currently attached to a given block. */
54
+ function docsForBlock(blockId: string): SourceDocument[] {
55
+ return documents.value.filter((d) => d.linkedBlockId === blockId)
56
+ }
57
+
58
+ /** Merge a document returned by the backend into the local cache. */
59
+ function upsertDoc(doc: SourceDocument) {
60
+ const i = documents.value.findIndex(
61
+ (d) => d.source === doc.source && d.externalId === doc.externalId,
62
+ )
63
+ if (i >= 0) documents.value[i] = doc
64
+ else documents.value.unshift(doc)
65
+ }
66
+
67
+ function upsertConnection(conn: DocumentConnection) {
68
+ const i = connections.value.findIndex((c) => c.source === conn.source)
69
+ if (i >= 0) connections.value[i] = conn
70
+ else connections.value.push(conn)
71
+ }
72
+
73
+ /** Probe the integration: resolves `available`, the sources and connections. */
74
+ async function probe() {
75
+ if (!workspace.workspaceId) return
76
+ try {
77
+ const [{ sources: srcs }, { connections: conns }] = await Promise.all([
78
+ api.listDocumentSources(workspace.requireId()),
79
+ api.listDocumentConnections(workspace.requireId()),
80
+ ])
81
+ available.value = true
82
+ sources.value = srcs
83
+ connections.value = conns
84
+ } catch {
85
+ // 503 (integration disabled) or any error → hide the UI entry points.
86
+ available.value = false
87
+ sources.value = []
88
+ connections.value = []
89
+ }
90
+ }
91
+
92
+ /** Connect the workspace to a source with its credential bag. */
93
+ async function connect(source: DocumentSourceKind, credentials: Record<string, string>) {
94
+ const conn = await api.connectDocumentSource(workspace.requireId(), source, credentials)
95
+ upsertConnection(conn)
96
+ available.value = true
97
+ }
98
+
99
+ /** Disconnect the workspace from a source. */
100
+ async function disconnect(source: DocumentSourceKind) {
101
+ await api.disconnectDocumentSource(workspace.requireId(), source)
102
+ connections.value = connections.value.filter((c) => c.source !== source)
103
+ }
104
+
105
+ /** Load the imported documents for the workspace (across sources). */
106
+ async function loadDocuments() {
107
+ documents.value = await api.listDocuments(workspace.requireId())
108
+ }
109
+
110
+ /** Import (fetch + persist) a page by id or URL from a source. */
111
+ async function importDocument(source: DocumentSourceKind, ref: string): Promise<SourceDocument> {
112
+ loading.value = true
113
+ try {
114
+ const doc = await api.importDocument(workspace.requireId(), source, { ref })
115
+ upsertDoc(doc)
116
+ return doc
117
+ } finally {
118
+ loading.value = false
119
+ }
120
+ }
121
+
122
+ /** Preview the board structure a page would expand into (no writes). */
123
+ function plan(source: DocumentSourceKind, externalId: string): Promise<DocumentBoardPlan> {
124
+ return api.planDocument(workspace.requireId(), source, externalId)
125
+ }
126
+
127
+ /** Apply a page's structure to the board, then refresh the board snapshot. */
128
+ async function spawn(source: DocumentSourceKind, externalId: string, frameId?: string) {
129
+ const { result } = await api.spawnDocument(workspace.requireId(), source, {
130
+ externalId,
131
+ frameId,
132
+ })
133
+ await workspace.refresh()
134
+ return result
135
+ }
136
+
137
+ /** Attach an imported page to a block as agent context. */
138
+ async function linkToBlock(blockId: string, source: DocumentSourceKind, externalId: string) {
139
+ const doc = await api.linkDocument(workspace.requireId(), { source, externalId, blockId })
140
+ upsertDoc(doc)
141
+ return doc
142
+ }
143
+
144
+ return {
145
+ available,
146
+ sources,
147
+ connections,
148
+ documents,
149
+ loading,
150
+ connectedSources,
151
+ anyConnected,
152
+ descriptorFor,
153
+ connectionFor,
154
+ isConnected,
155
+ docsForBlock,
156
+ probe,
157
+ connect,
158
+ disconnect,
159
+ loadDocuments,
160
+ importDocument,
161
+ plan,
162
+ spawn,
163
+ linkToBlock,
164
+ }
165
+ })
@@ -0,0 +1,115 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref, computed } from 'vue'
3
+ import type { Decision, ExecutionInstance, Pipeline, PipelineStep } from '~/types/domain'
4
+ import { useWorkspaceStore } from '~/stores/workspace'
5
+
6
+ /**
7
+ * Running pipeline instances. The simulation engine lives on the backend: this
8
+ * store mirrors the server's executions and drives them via the API. Commands
9
+ * call the worker and then refresh the workspace snapshot, since advancing an
10
+ * execution also rolls status/progress up onto its block server-side.
11
+ */
12
+ export const useExecutionStore = defineStore('execution', () => {
13
+ const api = useApi()
14
+ const instances = ref<ExecutionInstance[]>([])
15
+
16
+ /** Replace the cached executions with a server snapshot. */
17
+ function hydrate(next: ExecutionInstance[]) {
18
+ instances.value = next
19
+ }
20
+
21
+ /** Insert or replace a single execution instance pushed by the event stream. */
22
+ function upsert(instance: ExecutionInstance) {
23
+ const i = instances.value.findIndex((e) => e.id === instance.id)
24
+ if (i >= 0) instances.value[i] = instance
25
+ else instances.value.push(instance)
26
+ }
27
+
28
+ const byId = computed(() => {
29
+ const map = new Map<string, ExecutionInstance>()
30
+ for (const e of instances.value) map.set(e.id, e)
31
+ return map
32
+ })
33
+
34
+ function getInstance(id: string | null | undefined) {
35
+ return id ? byId.value.get(id) : undefined
36
+ }
37
+
38
+ function getByBlock(blockId: string) {
39
+ return instances.value.find((e) => e.blockId === blockId)
40
+ }
41
+
42
+ /** How many decisions anywhere are awaiting a human. */
43
+ const pendingDecisionCount = computed(() =>
44
+ instances.value.reduce(
45
+ (n, e) => n + e.steps.filter((s) => s.decision && !s.decision.chosen).length,
46
+ 0,
47
+ ),
48
+ )
49
+
50
+ /** All currently-unresolved decisions across all runs (for the toolbar/queue). */
51
+ const openDecisions = computed(() => {
52
+ const out: {
53
+ instanceId: string
54
+ blockId: string
55
+ decision: Decision
56
+ agentKind: PipelineStep['agentKind']
57
+ }[] = []
58
+ for (const e of instances.value) {
59
+ for (const s of e.steps) {
60
+ if (s.decision && !s.decision.chosen) {
61
+ out.push({
62
+ instanceId: e.id,
63
+ blockId: e.blockId,
64
+ decision: s.decision,
65
+ agentKind: s.agentKind,
66
+ })
67
+ }
68
+ }
69
+ }
70
+ return out
71
+ })
72
+
73
+ /** Start `pipeline` against a block; the server marks the block in-progress. */
74
+ async function start(blockId: string, pipeline: Pipeline) {
75
+ const ws = useWorkspaceStore()
76
+ await api.startExecution(ws.requireId(), blockId, { pipelineId: pipeline.id })
77
+ await ws.refresh()
78
+ }
79
+
80
+ async function resolveDecision(instanceId: string, decisionId: string, choice: string) {
81
+ const ws = useWorkspaceStore()
82
+ await api.resolveDecision(ws.requireId(), instanceId, decisionId, { choice })
83
+ await ws.refresh()
84
+ }
85
+
86
+ /** Merge an open PR (a task in `pr_ready`) — the server completes the task. */
87
+ async function mergePr(blockId: string) {
88
+ const ws = useWorkspaceStore()
89
+ await api.mergeBlock(ws.requireId(), blockId)
90
+ await ws.refresh()
91
+ }
92
+
93
+ /** Cancel the execution running against a block and reset it to planned. */
94
+ async function cancel(blockId: string) {
95
+ const ws = useWorkspaceStore()
96
+ await api.cancelExecution(ws.requireId(), blockId)
97
+ instances.value = instances.value.filter((e) => e.blockId !== blockId)
98
+ await ws.refresh()
99
+ }
100
+
101
+ return {
102
+ instances,
103
+ hydrate,
104
+ upsert,
105
+ byId,
106
+ getInstance,
107
+ getByBlock,
108
+ pendingDecisionCount,
109
+ openDecisions,
110
+ start,
111
+ resolveDecision,
112
+ mergePr,
113
+ cancel,
114
+ }
115
+ })
@@ -0,0 +1,147 @@
1
+ import { defineStore } from 'pinia'
2
+ import { computed, ref } from 'vue'
3
+ import type {
4
+ CreatePromptFragmentInput,
5
+ FragmentSource,
6
+ LinkFragmentSourceInput,
7
+ PromptFragment,
8
+ ResolvedFragment,
9
+ UpdatePromptFragmentInput,
10
+ } from '~/types/domain'
11
+ import { useWorkspaceStore } from '~/stores/workspace'
12
+
13
+ /**
14
+ * Prompt-fragment library state (ADR 0006), scoped to the active board. Holds the
15
+ * board's own (workspace-tier) fragments, its linked guideline repos, and the
16
+ * merged catalog an agent actually sees (built-in ∪ account ∪ workspace). The
17
+ * management surface targets the workspace tier; the resolved read is what every
18
+ * agent run is selected from. `available` mirrors the backend's opt-in gate: a
19
+ * 503 from the resolve probe means the feature is off and the UI hides its entry.
20
+ */
21
+ export const useFragmentLibraryStore = defineStore('fragmentLibrary', () => {
22
+ const api = useApi()
23
+ const workspace = useWorkspaceStore()
24
+
25
+ /** null = not probed yet; true/false = library on/off. */
26
+ const available = ref<boolean | null>(null)
27
+ /** This board's hand-authored + sourced fragments (workspace tier, raw). */
28
+ const fragments = ref<PromptFragment[]>([])
29
+ /** The merged catalog an agent sees (with each entry's winning tier). */
30
+ const resolved = ref<ResolvedFragment[]>([])
31
+ /** Linked guideline repos for this board. */
32
+ const sources = ref<FragmentSource[]>([])
33
+ /** Per-source "changes available" counts from the last status check. */
34
+ const sourceChanges = ref<Record<string, number>>({})
35
+ const loading = ref(false)
36
+
37
+ const builtinCount = computed(() => resolved.value.filter((f) => f.tier === 'builtin').length)
38
+
39
+ /** Probe the feature + load this board's tier, sources and resolved catalog. */
40
+ async function probe() {
41
+ if (!workspace.workspaceId) return
42
+ const id = workspace.requireId()
43
+ try {
44
+ const [tier, srcs, merged] = await Promise.all([
45
+ api.listFragments('workspace', id),
46
+ api.listFragmentSources('workspace', id).catch(() => [] as FragmentSource[]),
47
+ api.getResolvedFragments(id),
48
+ ])
49
+ fragments.value = tier
50
+ sources.value = srcs
51
+ resolved.value = merged
52
+ available.value = true
53
+ } catch {
54
+ available.value = false
55
+ fragments.value = []
56
+ sources.value = []
57
+ resolved.value = []
58
+ }
59
+ }
60
+
61
+ async function refreshResolved() {
62
+ resolved.value = await api.getResolvedFragments(workspace.requireId())
63
+ }
64
+
65
+ async function create(input: CreatePromptFragmentInput) {
66
+ loading.value = true
67
+ try {
68
+ await api.createFragment('workspace', workspace.requireId(), input)
69
+ await Promise.all([reloadTier(), refreshResolved()])
70
+ } finally {
71
+ loading.value = false
72
+ }
73
+ }
74
+
75
+ async function update(fragmentId: string, patch: UpdatePromptFragmentInput) {
76
+ await api.updateFragment('workspace', workspace.requireId(), fragmentId, patch)
77
+ await Promise.all([reloadTier(), refreshResolved()])
78
+ }
79
+
80
+ /** Tombstone a fragment at the workspace tier (suppresses an inherited one). */
81
+ async function remove(fragmentId: string) {
82
+ await api.deleteFragment('workspace', workspace.requireId(), fragmentId)
83
+ await Promise.all([reloadTier(), refreshResolved()])
84
+ }
85
+
86
+ async function reloadTier() {
87
+ fragments.value = await api.listFragments('workspace', workspace.requireId())
88
+ }
89
+
90
+ async function linkSource(input: LinkFragmentSourceInput) {
91
+ const source = await api.linkFragmentSource('workspace', workspace.requireId(), input)
92
+ sources.value = [source, ...sources.value]
93
+ return source
94
+ }
95
+
96
+ async function unlinkSource(sourceId: string) {
97
+ await api.unlinkFragmentSource('workspace', workspace.requireId(), sourceId)
98
+ sources.value = sources.value.filter((s) => s.id !== sourceId)
99
+ await refreshResolved()
100
+ }
101
+
102
+ /** Resync a source's Markdown into the catalog, then refresh views. */
103
+ async function syncSource(sourceId: string) {
104
+ loading.value = true
105
+ try {
106
+ const result = await api.syncFragmentSource('workspace', workspace.requireId(), sourceId)
107
+ delete sourceChanges.value[sourceId]
108
+ await Promise.all([reloadSources(), refreshResolved()])
109
+ return result
110
+ } finally {
111
+ loading.value = false
112
+ }
113
+ }
114
+
115
+ /** Cheap "check for changes" for a source; caches the changed count. */
116
+ async function checkSource(sourceId: string) {
117
+ const status = await api.fragmentSourceStatus('workspace', workspace.requireId(), sourceId)
118
+ sourceChanges.value = {
119
+ ...sourceChanges.value,
120
+ [sourceId]: status.changed ? status.changedCount : 0,
121
+ }
122
+ return status
123
+ }
124
+
125
+ async function reloadSources() {
126
+ sources.value = await api.listFragmentSources('workspace', workspace.requireId())
127
+ }
128
+
129
+ return {
130
+ available,
131
+ fragments,
132
+ resolved,
133
+ sources,
134
+ sourceChanges,
135
+ loading,
136
+ builtinCount,
137
+ probe,
138
+ refreshResolved,
139
+ create,
140
+ update,
141
+ remove,
142
+ linkSource,
143
+ unlinkSource,
144
+ syncSource,
145
+ checkSource,
146
+ }
147
+ })
@@ -0,0 +1,40 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref, computed } from 'vue'
3
+ import type { BlockType, PromptFragment } from '~/types/domain'
4
+
5
+ /**
6
+ * The best-practice prompt fragment catalog. It is build-static reference data on
7
+ * the backend (`GET /prompt-fragments`), workspace-independent, so this store
8
+ * fetches it once and caches it for the per-block picker in the inspector.
9
+ */
10
+ export const useFragmentsStore = defineStore('fragments', () => {
11
+ const api = useApi()
12
+ const fragments = ref<PromptFragment[]>([])
13
+ const loaded = ref(false)
14
+
15
+ /** Fetch the catalog once; subsequent calls are no-ops. */
16
+ async function ensureLoaded() {
17
+ if (loaded.value) return
18
+ fragments.value = await api.getPromptFragments()
19
+ loaded.value = true
20
+ }
21
+
22
+ const byId = computed(() => {
23
+ const map = new Map<string, PromptFragment>()
24
+ for (const f of fragments.value) map.set(f.id, f)
25
+ return map
26
+ })
27
+
28
+ function getFragment(id: string) {
29
+ return byId.value.get(id)
30
+ }
31
+
32
+ /** Fragments suitable for a block type (those with no `blockTypes` apply to all). */
33
+ function forBlockType(type: BlockType) {
34
+ return fragments.value.filter(
35
+ (f) => !f.appliesTo?.blockTypes || f.appliesTo.blockTypes.includes(type),
36
+ )
37
+ }
38
+
39
+ return { fragments, loaded, ensureLoaded, byId, getFragment, forBlockType }
40
+ })