@cat-factory/app 0.24.0 → 0.25.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.
@@ -4,10 +4,11 @@
4
4
  // + resync), and review the merged catalog (built-in ∪ account ∪ workspace) an
5
5
  // agent is selected from per run. Workspace-tier focused; the resolved view shows
6
6
  // every tier so the inheritance is visible.
7
- import type { ResolvedFragment } from '~/types/domain'
7
+ import type { DocumentSourceKind, ResolvedFragment } from '~/types/domain'
8
8
 
9
9
  const ui = useUiStore()
10
10
  const library = useFragmentLibraryStore()
11
+ const documents = useDocumentsStore()
11
12
  const toast = useToast()
12
13
 
13
14
  const open = computed({
@@ -18,10 +19,13 @@ const open = computed({
18
19
  })
19
20
 
20
21
  watch(open, (isOpen) => {
21
- if (isOpen) void library.probe()
22
+ if (isOpen) {
23
+ void library.probe()
24
+ void documents.probe()
25
+ }
22
26
  })
23
27
 
24
- type Tab = 'catalog' | 'authored' | 'sources'
28
+ type Tab = 'catalog' | 'authored' | 'documents' | 'sources'
25
29
  const tab = ref<Tab>('catalog')
26
30
 
27
31
  const tierLabel: Record<ResolvedFragment['tier'], string> = {
@@ -80,6 +84,42 @@ async function removeFragment(id: string) {
80
84
  }
81
85
  }
82
86
 
87
+ // ---- document-backed (living) fragments -----------------------------------
88
+ // Link a Confluence/Notion page or GitHub file as a fragment that is re-resolved
89
+ // from the source at run time (a living source of truth, not a frozen snapshot).
90
+ const docDraft = ref({ source: '' as DocumentSourceKind | '', ref: '', tags: '' })
91
+ const docDraftValid = computed(() => docDraft.value.source && docDraft.value.ref.trim())
92
+
93
+ /** The board's existing document-backed fragments (workspace tier). */
94
+ const documentFragments = computed(() => library.fragments.filter((f) => f.documentRef))
95
+
96
+ async function linkDocumentFragment() {
97
+ if (!docDraftValid.value) return
98
+ try {
99
+ await library.createDocumentFragment({
100
+ source: docDraft.value.source as DocumentSourceKind,
101
+ ref: docDraft.value.ref.trim(),
102
+ tags: docDraft.value.tags
103
+ .split(',')
104
+ .map((t) => t.trim())
105
+ .filter(Boolean),
106
+ })
107
+ docDraft.value = { source: '', ref: '', tags: '' }
108
+ toast.add({ title: 'Document linked as a living fragment', icon: 'i-lucide-link' })
109
+ } catch (e) {
110
+ notifyError('Could not link document', e)
111
+ }
112
+ }
113
+
114
+ async function refreshFragment(id: string) {
115
+ try {
116
+ await library.refreshDocumentFragment(id)
117
+ toast.add({ title: 'Fragment re-resolved from source', icon: 'i-lucide-refresh-cw' })
118
+ } catch (e) {
119
+ notifyError('Could not refresh fragment', e)
120
+ }
121
+ }
122
+
83
123
  // ---- repo sources ----------------------------------------------------------
84
124
  const sourceDraft = ref({ repoOwner: '', repoName: '', dirPath: '', gitRef: '' })
85
125
  const sourceValid = computed(
@@ -150,7 +190,7 @@ async function unlinkSource(id: string) {
150
190
 
151
191
  <div class="flex gap-2">
152
192
  <UButton
153
- v-for="t in ['catalog', 'authored', 'sources'] as Tab[]"
193
+ v-for="t in ['catalog', 'authored', 'documents', 'sources'] as Tab[]"
154
194
  :key="t"
155
195
  :color="tab === t ? 'primary' : 'neutral'"
156
196
  :variant="tab === t ? 'solid' : 'ghost'"
@@ -162,7 +202,9 @@ async function unlinkSource(id: string) {
162
202
  ? 'Resolved catalog'
163
203
  : t === 'authored'
164
204
  ? 'This board'
165
- : 'Repo sources'
205
+ : t === 'documents'
206
+ ? 'Documents'
207
+ : 'Repo sources'
166
208
  }}
167
209
  </UButton>
168
210
  </div>
@@ -183,6 +225,15 @@ async function unlinkSource(id: string) {
183
225
  <UBadge size="xs" :color="tierColor[f.tier]" variant="subtle">
184
226
  {{ tierLabel[f.tier] }}
185
227
  </UBadge>
228
+ <UBadge
229
+ v-if="f.documentRef"
230
+ size="xs"
231
+ color="success"
232
+ variant="subtle"
233
+ icon="i-lucide-radio"
234
+ >
235
+ Live · {{ f.documentRef.source }}
236
+ </UBadge>
186
237
  <span class="ml-auto font-mono text-[11px] text-slate-500">{{ f.id }}</span>
187
238
  </div>
188
239
  <p class="mt-1 text-sm text-slate-400">{{ f.summary }}</p>
@@ -249,6 +300,91 @@ async function unlinkSource(id: string) {
249
300
  </div>
250
301
  </div>
251
302
 
303
+ <!-- Document-backed (living) fragments -->
304
+ <div v-else-if="tab === 'documents'" class="flex flex-col gap-3">
305
+ <p class="text-xs text-slate-500">
306
+ Link a Confluence/Notion page or a GitHub file as a best-practice fragment. Its guidance
307
+ is re-resolved from the source at run time — edit the doc and the next agent run follows
308
+ the new version (no re-import).
309
+ </p>
310
+
311
+ <div
312
+ v-for="f in documentFragments"
313
+ :key="f.id"
314
+ class="flex items-start gap-2 rounded-md border border-slate-800 bg-slate-900/60 p-3"
315
+ >
316
+ <UIcon name="i-lucide-radio" class="mt-0.5 h-4 w-4 text-emerald-400" />
317
+ <div class="min-w-0">
318
+ <div class="flex items-center gap-2">
319
+ <span class="font-medium text-slate-100">{{ f.title }}</span>
320
+ <UBadge size="xs" color="success" variant="subtle">
321
+ {{ f.documentRef?.source }}
322
+ </UBadge>
323
+ </div>
324
+ <p class="text-sm text-slate-400">{{ f.summary }}</p>
325
+ <p v-if="f.resolvedAt" class="text-[11px] text-slate-500">
326
+ last resolved {{ new Date(f.resolvedAt).toLocaleString() }}
327
+ </p>
328
+ </div>
329
+ <div class="ml-auto flex gap-1">
330
+ <UButton
331
+ icon="i-lucide-refresh-cw"
332
+ size="xs"
333
+ variant="ghost"
334
+ :loading="library.loading"
335
+ title="Re-resolve from source now"
336
+ @click="refreshFragment(f.id)"
337
+ />
338
+ <UButton
339
+ icon="i-lucide-trash-2"
340
+ size="xs"
341
+ color="error"
342
+ variant="ghost"
343
+ @click="removeFragment(f.id)"
344
+ />
345
+ </div>
346
+ </div>
347
+ <p v-if="!documentFragments.length" class="text-sm text-slate-500">
348
+ No document-backed fragments yet. Link one below.
349
+ </p>
350
+
351
+ <div class="rounded-md border border-slate-800 p-3">
352
+ <p class="mb-2 text-sm font-medium">Link a document</p>
353
+ <div v-if="!documents.connectedSources.length" class="text-sm text-slate-500">
354
+ Connect a document source (Confluence, Notion or GitHub) under Integrations first.
355
+ </div>
356
+ <div v-else class="flex flex-col gap-2">
357
+ <div class="flex flex-wrap gap-2">
358
+ <UButton
359
+ v-for="s in documents.connectedSources"
360
+ :key="s.source"
361
+ size="xs"
362
+ :color="docDraft.source === s.source ? 'primary' : 'neutral'"
363
+ :variant="docDraft.source === s.source ? 'solid' : 'outline'"
364
+ @click="docDraft.source = s.source"
365
+ >
366
+ {{ s.label }}
367
+ </UButton>
368
+ </div>
369
+ <UInput
370
+ v-model="docDraft.ref"
371
+ placeholder="Page id or URL (e.g. a Confluence/Notion page or GitHub file URL)"
372
+ />
373
+ <UInput v-model="docDraft.tags" placeholder="Tags, comma-separated (optional)" />
374
+ <UButton
375
+ icon="i-lucide-link"
376
+ size="sm"
377
+ :disabled="!docDraftValid"
378
+ :loading="library.loading"
379
+ class="self-start"
380
+ @click="linkDocumentFragment"
381
+ >
382
+ Link as living fragment
383
+ </UButton>
384
+ </div>
385
+ </div>
386
+ </div>
387
+
252
388
  <!-- Repo sources -->
253
389
  <div v-else class="flex flex-col gap-3">
254
390
  <div
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ CreateDocumentFragmentInput,
2
3
  CreatePromptFragmentInput,
3
4
  FragmentOwnerKind,
4
5
  FragmentSource,
@@ -45,6 +46,20 @@ export function fragmentsApi({ http, ws, scope }: ApiContext) {
45
46
  method: 'DELETE',
46
47
  }),
47
48
 
49
+ // Link an external document (Confluence/Notion/GitHub) as a living fragment.
50
+ createDocumentFragment: (
51
+ kind: FragmentOwnerKind,
52
+ id: string,
53
+ body: CreateDocumentFragmentInput,
54
+ ) => http<PromptFragment>(`${scope(kind, id)}/document-fragments`, { method: 'POST', body }),
55
+
56
+ // Force an immediate live re-resolve of a document-backed fragment.
57
+ refreshFragment: (kind: FragmentOwnerKind, id: string, fragmentId: string) =>
58
+ http<PromptFragment>(
59
+ `${scope(kind, id)}/prompt-fragments/${encodeURIComponent(fragmentId)}/refresh`,
60
+ { method: 'POST' },
61
+ ),
62
+
48
63
  // Repo sources of guideline Markdown.
49
64
  listFragmentSources: (kind: FragmentOwnerKind, id: string) =>
50
65
  http<FragmentSource[]>(`${scope(kind, id)}/fragment-sources`),
@@ -1,6 +1,7 @@
1
1
  import { defineStore } from 'pinia'
2
2
  import { computed, ref } from 'vue'
3
3
  import type {
4
+ CreateDocumentFragmentInput,
4
5
  CreatePromptFragmentInput,
5
6
  FragmentSource,
6
7
  LinkFragmentSourceInput,
@@ -77,6 +78,28 @@ export const useFragmentLibraryStore = defineStore('fragmentLibrary', () => {
77
78
  await Promise.all([reloadTier(), refreshResolved()])
78
79
  }
79
80
 
81
+ /** Link an external document as a living (dynamically-resolved) fragment. */
82
+ async function createDocumentFragment(input: CreateDocumentFragmentInput) {
83
+ loading.value = true
84
+ try {
85
+ await api.createDocumentFragment('workspace', workspace.requireId(), input)
86
+ await Promise.all([reloadTier(), refreshResolved()])
87
+ } finally {
88
+ loading.value = false
89
+ }
90
+ }
91
+
92
+ /** Force an immediate live re-resolve of a document-backed fragment. */
93
+ async function refreshDocumentFragment(fragmentId: string) {
94
+ loading.value = true
95
+ try {
96
+ await api.refreshFragment('workspace', workspace.requireId(), fragmentId)
97
+ await Promise.all([reloadTier(), refreshResolved()])
98
+ } finally {
99
+ loading.value = false
100
+ }
101
+ }
102
+
80
103
  /** Tombstone a fragment at the workspace tier (suppresses an inherited one). */
81
104
  async function remove(fragmentId: string) {
82
105
  await api.deleteFragment('workspace', workspace.requireId(), fragmentId)
@@ -137,6 +160,8 @@ export const useFragmentLibraryStore = defineStore('fragmentLibrary', () => {
137
160
  probe,
138
161
  refreshResolved,
139
162
  create,
163
+ createDocumentFragment,
164
+ refreshDocumentFragment,
140
165
  update,
141
166
  remove,
142
167
  linkSource,
@@ -5,6 +5,7 @@
5
5
  // ---------------------------------------------------------------------------
6
6
 
7
7
  import type { AgentKind, BlockType } from './domain'
8
+ import type { DocumentSourceKind } from './documents'
8
9
  import type { PromptFragment } from './models'
9
10
 
10
11
  /** Which scope owns a managed fragment / source. */
@@ -28,6 +29,21 @@ export interface CreatePromptFragmentInput {
28
29
  /** Partial patch for editing a fragment at a tier. */
29
30
  export type UpdatePromptFragmentInput = Partial<CreatePromptFragmentInput>
30
31
 
32
+ /**
33
+ * Inputs for linking an external document (Confluence/Notion page or GitHub file)
34
+ * as a LIVING fragment at a tier. Title/summary/body are derived from the fetched
35
+ * document, not supplied here. `viaWorkspaceId` is only needed at the account tier.
36
+ */
37
+ export interface CreateDocumentFragmentInput {
38
+ source: DocumentSourceKind
39
+ ref: string
40
+ id?: string
41
+ category?: string
42
+ tags?: string[]
43
+ appliesTo?: { blockTypes?: BlockType[]; agentKinds?: AgentKind[] }
44
+ viaWorkspaceId?: string
45
+ }
46
+
31
47
  /** A fragment after the three tiers are merged for a workspace. */
32
48
  export interface ResolvedFragment extends PromptFragment {
33
49
  tier: FragmentTier
@@ -4,6 +4,7 @@
4
4
  // ---------------------------------------------------------------------------
5
5
 
6
6
  import type { AgentKind, BlockType } from './domain'
7
+ import type { DocumentSourceKind } from './documents'
7
8
 
8
9
  /** Subscription vendors whose pooled tokens drive the Claude Code / Codex harnesses. */
9
10
  export type SubscriptionVendor = 'claude' | 'codex' | 'glm' | 'kimi' | 'deepseek'
@@ -154,4 +155,14 @@ export interface PromptFragment {
154
155
  path: string
155
156
  sha: string
156
157
  }
158
+ /**
159
+ * Provenance when the body is a LIVING external document (Confluence/Notion/
160
+ * GitHub). The body is re-resolved from the source at run time, not frozen.
161
+ */
162
+ documentRef?: {
163
+ source: DocumentSourceKind
164
+ externalId: string
165
+ }
166
+ /** When the document-backed body was last resolved from the source (epoch ms). */
167
+ resolvedAt?: number
157
168
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",