@cat-factory/app 0.199.0 → 0.200.2

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 (39) hide show
  1. package/app/components/foundational/FoundationalContractSummary.vue +36 -0
  2. package/app/components/foundational/FoundationalServiceCatalogList.vue +170 -0
  3. package/app/components/foundational/FoundationalServiceManager.vue +111 -0
  4. package/app/components/foundational/FoundationalServicePanel.vue +37 -0
  5. package/app/components/foundational/FoundationalServiceRegistry.vue +339 -0
  6. package/app/components/foundational/FoundationalServiceSources.vue +398 -0
  7. package/app/components/foundational/FoundationalSuppressions.vue +75 -0
  8. package/app/components/layout/AccountFoundationalSettings.vue +25 -0
  9. package/app/components/layout/AccountSkillSettings.vue +1 -1
  10. package/app/components/settings/AccountSettingsPanel.vue +17 -1
  11. package/app/components/skills/SkillLibraryManager.vue +1 -1
  12. package/app/composables/api/foundationalServices.ts +131 -0
  13. package/app/composables/api/skills.ts +1 -1
  14. package/app/composables/useApi.ts +2 -0
  15. package/app/composables/useNavContributions.ts +1 -0
  16. package/app/composables/usePipelineErrorToast.ts +8 -0
  17. package/app/modular/nav-contributions.spec.ts +6 -0
  18. package/app/modular/nav-contributions.ts +26 -0
  19. package/app/pages/index.vue +4 -0
  20. package/app/stores/foundationalServices.spec.ts +121 -0
  21. package/app/stores/foundationalServices.ts +276 -0
  22. package/app/stores/skillLibrary.ts +1 -1
  23. package/app/stores/skills.spec.ts +1 -1
  24. package/app/stores/skills.ts +1 -1
  25. package/app/stores/ui/modals.ts +12 -0
  26. package/app/types/domain.ts +1 -0
  27. package/app/types/foundationalServices.ts +32 -0
  28. package/app/types/skills.ts +1 -1
  29. package/i18n/locales/de.json +146 -6
  30. package/i18n/locales/en.json +146 -6
  31. package/i18n/locales/es.json +146 -6
  32. package/i18n/locales/fr.json +146 -6
  33. package/i18n/locales/he.json +146 -6
  34. package/i18n/locales/it.json +146 -6
  35. package/i18n/locales/ja.json +146 -6
  36. package/i18n/locales/pl.json +146 -6
  37. package/i18n/locales/tr.json +146 -6
  38. package/i18n/locales/uk.json +146 -6
  39. package/package.json +2 -2
@@ -0,0 +1,131 @@
1
+ import {
2
+ createFoundationalServiceContract,
3
+ deleteFoundationalServiceContract,
4
+ foundationalServiceSourceStatusContract,
5
+ getFoundationalServiceContractsContract,
6
+ linkFoundationalServiceSourceContract,
7
+ listFoundationalServiceSourcesContract,
8
+ listFoundationalServiceSuppressionsContract,
9
+ listFoundationalServicesContract,
10
+ resolvedFoundationalServicesContract,
11
+ restoreFoundationalServiceContract,
12
+ suppressFoundationalServiceContract,
13
+ syncFoundationalServiceSourceContract,
14
+ unlinkFoundationalServiceSourceContract,
15
+ updateFoundationalServiceContract,
16
+ } from '@cat-factory/contracts'
17
+ import type {
18
+ CreateFoundationalServiceInput,
19
+ FoundationalServiceOwnerKind,
20
+ LinkFoundationalServiceSourceInput,
21
+ UpdateFoundationalServiceInput,
22
+ } from '~/types/domain'
23
+ import type { ApiContext } from './context'
24
+
25
+ /**
26
+ * The foundational-services catalog (backend/docs/adr/0031-foundational-services.md) — the shared
27
+ * capabilities an organisation already runs, which an Architect designs against instead of
28
+ * proposing a rebuild.
29
+ *
30
+ * Tiered exactly like the prompt-fragment library (`account` ⊕ `workspace`), so every route
31
+ * reuses the same `scope(kind, id)` prefix. Three of them are workspace-only, because they are
32
+ * about a tier having something ABOVE it: the merged catalog, and the suppress/restore pair
33
+ * that opts a board out of an inherited account service.
34
+ */
35
+ export function foundationalServicesApi({ send, ws, scope }: ApiContext) {
36
+ return {
37
+ // ---- one tier's registered services (raw — not merged) ----------------
38
+ listFoundationalServices: (kind: FoundationalServiceOwnerKind, id: string) =>
39
+ send(listFoundationalServicesContract, { pathPrefix: scope(kind, id) }),
40
+
41
+ createFoundationalService: (
42
+ kind: FoundationalServiceOwnerKind,
43
+ id: string,
44
+ body: CreateFoundationalServiceInput,
45
+ ) => send(createFoundationalServiceContract, { pathPrefix: scope(kind, id), body }),
46
+
47
+ updateFoundationalService: (
48
+ kind: FoundationalServiceOwnerKind,
49
+ id: string,
50
+ serviceId: string,
51
+ body: UpdateFoundationalServiceInput,
52
+ ) =>
53
+ send(updateFoundationalServiceContract, {
54
+ pathPrefix: scope(kind, id),
55
+ pathParams: { serviceId },
56
+ body,
57
+ }),
58
+
59
+ deleteFoundationalService: (
60
+ kind: FoundationalServiceOwnerKind,
61
+ id: string,
62
+ serviceId: string,
63
+ ) =>
64
+ send(deleteFoundationalServiceContract, {
65
+ pathPrefix: scope(kind, id),
66
+ pathParams: { serviceId },
67
+ }),
68
+
69
+ // ---- the merged catalog an agent actually sees (workspace only) -------
70
+ getResolvedFoundationalServices: (workspaceId: string) =>
71
+ send(resolvedFoundationalServicesContract, { pathPrefix: ws(workspaceId) }),
72
+
73
+ /**
74
+ * The LAZY contract read: one service's full documents, resolved through the same tier merge
75
+ * a dispatch uses. Deliberately not folded into the catalog list — a document routinely runs
76
+ * to hundreds of kilobytes, and this surface exists so a human can check what a consumer
77
+ * would be handed, one service at a time.
78
+ */
79
+ getFoundationalServiceContracts: (workspaceId: string, serviceId: string) =>
80
+ send(getFoundationalServiceContractsContract, {
81
+ pathPrefix: ws(workspaceId),
82
+ pathParams: { serviceId },
83
+ }),
84
+
85
+ // ---- opting a board out of an inherited account service ---------------
86
+ // The LIST is what makes the pair usable: a suppressed id is by construction absent from the
87
+ // merged catalog, so nothing else can tell the surface what to offer a restore for.
88
+ listFoundationalServiceSuppressions: (workspaceId: string) =>
89
+ send(listFoundationalServiceSuppressionsContract, { pathPrefix: ws(workspaceId) }),
90
+
91
+ suppressFoundationalService: (workspaceId: string, serviceId: string) =>
92
+ send(suppressFoundationalServiceContract, {
93
+ pathPrefix: ws(workspaceId),
94
+ pathParams: { serviceId },
95
+ }),
96
+
97
+ restoreFoundationalService: (workspaceId: string, serviceId: string) =>
98
+ send(restoreFoundationalServiceContract, {
99
+ pathPrefix: ws(workspaceId),
100
+ pathParams: { serviceId },
101
+ }),
102
+
103
+ // ---- repo sources of service definitions + contract files ------------
104
+ listFoundationalSources: (kind: FoundationalServiceOwnerKind, id: string) =>
105
+ send(listFoundationalServiceSourcesContract, { pathPrefix: scope(kind, id) }),
106
+
107
+ linkFoundationalSource: (
108
+ kind: FoundationalServiceOwnerKind,
109
+ id: string,
110
+ body: LinkFoundationalServiceSourceInput,
111
+ ) => send(linkFoundationalServiceSourceContract, { pathPrefix: scope(kind, id), body }),
112
+
113
+ unlinkFoundationalSource: (kind: FoundationalServiceOwnerKind, id: string, sourceId: string) =>
114
+ send(unlinkFoundationalServiceSourceContract, {
115
+ pathPrefix: scope(kind, id),
116
+ pathParams: { id: sourceId },
117
+ }),
118
+
119
+ foundationalSourceStatus: (kind: FoundationalServiceOwnerKind, id: string, sourceId: string) =>
120
+ send(foundationalServiceSourceStatusContract, {
121
+ pathPrefix: scope(kind, id),
122
+ pathParams: { id: sourceId },
123
+ }),
124
+
125
+ syncFoundationalSource: (kind: FoundationalServiceOwnerKind, id: string, sourceId: string) =>
126
+ send(syncFoundationalServiceSourceContract, {
127
+ pathPrefix: scope(kind, id),
128
+ pathParams: { id: sourceId },
129
+ }),
130
+ }
131
+ }
@@ -10,7 +10,7 @@ import type { LinkSkillSourceInput } from '~/types/domain'
10
10
  import type { ApiContext } from './context'
11
11
 
12
12
  /**
13
- * The repo-sourced Claude Skills library (docs/initiatives/repo-skills.md). Skills live in ONE
13
+ * The repo-sourced Claude Skills library (ADR 0024). Skills live in ONE
14
14
  * tier — the account, shared across its workspaces — so every route is account-scoped
15
15
  * (`/accounts/:accountId/...`), unlike the two-tier fragment library.
16
16
  */
@@ -16,6 +16,7 @@ import { forkDecisionApi } from './api/forkDecision'
16
16
  import { judgeApi } from './api/judge'
17
17
  import { prReviewApi } from './api/prReview'
18
18
  import { fragmentsApi } from './api/fragments'
19
+ import { foundationalServicesApi } from './api/foundationalServices'
19
20
  import { skillsApi } from './api/skills'
20
21
  import { githubApi } from './api/github'
21
22
  import { vcsApi } from './api/vcs'
@@ -154,6 +155,7 @@ export function useApi() {
154
155
  ...environmentsApi(ctx),
155
156
  ...recurringApi(ctx),
156
157
  ...sandboxApi(ctx),
158
+ ...foundationalServicesApi(ctx),
157
159
  ...githubApi(ctx),
158
160
  ...vcsApi(ctx),
159
161
  ...slackApi(ctx),
@@ -49,6 +49,7 @@ export function useNavContributions() {
49
49
  kaizen: () => ui.openKaizen(),
50
50
  infrastructure: () => ui.openInfrastructure(),
51
51
  fragmentLibrary: () => ui.openFragmentLibrary(),
52
+ foundationalServices: () => ui.openFoundationalServices(),
52
53
  mergeThresholds: () => ui.openWorkspaceSettings('merge'),
53
54
  workspaceSettings: () => ui.openWorkspaceSettings(),
54
55
  modelConfiguration: () => ui.openModelConfig(),
@@ -224,6 +224,14 @@ const CONFLICT_INFO: Record<Exclude<ConflictReason, BespokeConflictReason>, Conf
224
224
  titleKey: 'errors.conflict.title.foundational_service_exists',
225
225
  descriptionKey: 'errors.conflict.description.foundational_service_exists',
226
226
  },
227
+ binary_output_service_invalid: {
228
+ titleKey: 'errors.conflict.title.binary_output_service_invalid',
229
+ descriptionKey: 'errors.conflict.description.binary_output_service_invalid',
230
+ },
231
+ foundational_service_not_inherited: {
232
+ titleKey: 'errors.conflict.title.foundational_service_not_inherited',
233
+ descriptionKey: 'errors.conflict.description.foundational_service_not_inherited',
234
+ },
227
235
  pipeline_schedule_intake_unconfigured: {
228
236
  titleKey: 'errors.conflict.title.pipeline_schedule_intake_unconfigured',
229
237
  descriptionKey: 'errors.conflict.description.pipeline_schedule_intake_unconfigured',
@@ -124,6 +124,7 @@ describe('navSlotFilter', () => {
124
124
  expect(kept).not.toContain('bootstrap-repo')
125
125
  expect(kept).not.toContain('operator-dashboard')
126
126
  expect(kept).not.toContain('reports')
127
+ expect(kept).not.toContain('foundational-services')
127
128
  })
128
129
 
129
130
  it('states, per advanced item, whether basic mode still reaches its capability', () => {
@@ -166,6 +167,10 @@ describe('navSlotFilter', () => {
166
167
  kind: 'out-of-tier',
167
168
  why: 'deployment-wide spend/activity rollup - an operator job, not a delivery one',
168
169
  },
170
+ 'foundational-services': {
171
+ kind: 'out-of-tier',
172
+ why: 'org-wide platform inventory, set up once - a board delivers fine with none',
173
+ },
169
174
  }
170
175
  const advanced = NAV_CONTRIBUTIONS.filter((i) => i.advanced).map((i) => i.id)
171
176
  expect(advanced.sort()).toEqual(Object.keys(REASON).sort())
@@ -344,6 +349,7 @@ describe('nav grouping helpers', () => {
344
349
  'keyboard-shortcuts',
345
350
  'ui-mode',
346
351
  'tutorial',
352
+ 'foundational-services',
347
353
  ])
348
354
  })
349
355
 
@@ -168,6 +168,7 @@ export const NAV_ACTIONS = [
168
168
  'kaizen',
169
169
  'infrastructure',
170
170
  'fragmentLibrary',
171
+ 'foundationalServices',
171
172
  'mergeThresholds',
172
173
  'workspaceSettings',
173
174
  'modelConfiguration',
@@ -365,6 +366,31 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
365
366
  keywordsKey: 'layout.commandBar.keywords.fragments',
366
367
  },
367
368
  },
369
+ {
370
+ // Registering the shared capabilities the ORGANISATION already runs, so a design consumes
371
+ // them instead of proposing a rebuild. Advanced: the everyday delivery loop (plan a task,
372
+ // run it, review and merge it) never touches this — it is org-wide platform configuration,
373
+ // set up once by whoever knows the estate, and a board can deliver its whole backlog with an
374
+ // empty catalog. Beside the fragment library in `workspaceContext`, because both answer
375
+ // "what standing context does an agent get?".
376
+ id: 'foundational-services',
377
+ labelKey: 'nav.foundationalServices',
378
+ icon: 'i-lucide-boxes',
379
+ surfaces: S('sidebar', 'command'),
380
+ advanced: true,
381
+ gate: (g) => g.canManageSettings,
382
+ action: 'foundationalServices',
383
+ testId: 'nav-foundational-services',
384
+ sidebar: { group: 'workspaceContext', order: 20 },
385
+ command: {
386
+ // Appended after the pre-existing workspace commands rather than interleaved beside the
387
+ // fragment library, so the palette order people already know is unchanged.
388
+ group: 'workspace',
389
+ order: 110,
390
+ labelKey: 'layout.commandBar.cmd.foundationalServices',
391
+ keywordsKey: 'layout.commandBar.keywords.foundationalServices',
392
+ },
393
+ },
368
394
  {
369
395
  id: 'merge-thresholds',
370
396
  labelKey: 'layout.commandBar.cmd.mergeThresholds',
@@ -73,6 +73,9 @@ const SlackPanel = defineAsyncComponent(() => import('~/components/slack/SlackPa
73
73
  const FragmentLibraryPanel = defineAsyncComponent(
74
74
  () => import('~/components/fragments/FragmentLibraryPanel.vue'),
75
75
  )
76
+ const FoundationalServicePanel = defineAsyncComponent(
77
+ () => import('~/components/foundational/FoundationalServicePanel.vue'),
78
+ )
76
79
  // Startup advisory for invalid / outdated pipelines — only mounted while open (auto-opened
77
80
  // at most once per session by the watcher below), so it stays out of the initial bundle.
78
81
  const PipelineHealthModal = defineAsyncComponent(
@@ -487,6 +490,7 @@ watch(
487
490
  <GitHubPanel v-if="ui.githubOpen" />
488
491
  <SlackPanel v-if="ui.slackOpen" />
489
492
  <FragmentLibraryPanel v-if="ui.fragmentLibraryOpen" />
493
+ <FoundationalServicePanel v-if="ui.foundationalServicesOpen" />
490
494
  <PipelineHealthModal v-if="ui.pipelineHealthOpen" />
491
495
  <RiskPolicyHealthModal v-if="ui.riskPolicyHealthOpen" />
492
496
  <ModelPresetHealthModal v-if="ui.modelPresetHealthOpen" />
@@ -0,0 +1,121 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import { useFoundationalServicesStore } from '~/stores/foundationalServices'
3
+ import { useWorkspaceStore } from '~/stores/workspace'
4
+
5
+ // The two store rules that the surface's correctness rests on, and that neither the backend
6
+ // tests nor a component test would catch:
7
+ //
8
+ // - opening the catalog must transfer NO contract document. The whole two-table split exists so
9
+ // a catalog read costs identity + operation names, and a store that eagerly hydrated bodies
10
+ // would quietly undo it on the one surface a human uses to check what an agent sees.
11
+ // - a suppress/restore must refresh the OPT-OUT list as well as the catalog. The two are
12
+ // complements — an entry leaves one exactly as it enters the other — so refreshing only the
13
+ // catalog leaves the way BACK stale, which is the failure the pair exists to prevent.
14
+
15
+ const SERVICE = {
16
+ id: 'file-storage',
17
+ ownerKind: 'account' as const,
18
+ name: 'File Storage',
19
+ summary: 'Stores uploads.',
20
+ description: '',
21
+ capabilities: [],
22
+ contracts: [
23
+ {
24
+ contractId: 'openapi',
25
+ format: 'openapi',
26
+ title: 'HTTP API',
27
+ size: 42,
28
+ path: null,
29
+ operations: ['GET /files'],
30
+ omittedOperations: 0,
31
+ },
32
+ ],
33
+ sourceId: null,
34
+ sourcePath: null,
35
+ pinnedCommit: null,
36
+ createdAt: 1,
37
+ updatedAt: 1,
38
+ }
39
+
40
+ function api(over: Record<string, unknown> = {}) {
41
+ return {
42
+ listFoundationalServices: vi.fn(() => Promise.resolve([])),
43
+ getResolvedFoundationalServices: vi.fn(() =>
44
+ Promise.resolve([{ ...SERVICE, tier: 'account' }]),
45
+ ),
46
+ listFoundationalServiceSuppressions: vi.fn(() => Promise.resolve([])),
47
+ listFoundationalSources: vi.fn(() => Promise.resolve([])),
48
+ getFoundationalServiceContracts: vi.fn(() =>
49
+ Promise.resolve([{ ...SERVICE.contracts[0], body: 'openapi: 3.0.3' }]),
50
+ ),
51
+ suppressFoundationalService: vi.fn(() => Promise.resolve(undefined)),
52
+ restoreFoundationalService: vi.fn(() => Promise.resolve(undefined)),
53
+ ...over,
54
+ }
55
+ }
56
+
57
+ describe('foundational-services store', () => {
58
+ beforeEach(() => {
59
+ useWorkspaceStore().workspaceId = 'ws1'
60
+ })
61
+
62
+ it('probes the catalog without fetching a single contract document', async () => {
63
+ const client = api()
64
+ vi.stubGlobal('useApi', () => client)
65
+ const store = useFoundationalServicesStore()
66
+ await store.probe()
67
+
68
+ expect(store.resolved).toHaveLength(1)
69
+ // The manifest rode the catalog read — the body did not.
70
+ expect(store.resolved[0]?.contracts[0]?.operations).toEqual(['GET /files'])
71
+ expect(client.getFoundationalServiceContracts).not.toHaveBeenCalled()
72
+ expect(store.contractBodies).toEqual({})
73
+ })
74
+
75
+ it('fetches a document only on demand, then serves it from the session cache', async () => {
76
+ const client = api()
77
+ vi.stubGlobal('useApi', () => client)
78
+ const store = useFoundationalServicesStore()
79
+ await store.probe()
80
+
81
+ await store.contractsFor('file-storage')
82
+ await store.contractsFor('file-storage')
83
+ expect(client.getFoundationalServiceContracts).toHaveBeenCalledTimes(1)
84
+ expect(store.contractBodies['file-storage']?.[0]?.body).toBe('openapi: 3.0.3')
85
+ })
86
+
87
+ it('resets the repo-source flag too when a re-probe finds the catalog gone', async () => {
88
+ // `sourcesAvailable` gates an affordance rather than content, so a probe that leaves it at a
89
+ // previous `true` would offer repo-source linking against an owner whose catalog is now
90
+ // unreachable. Every view the probe owns has to come back down together.
91
+ const client = api()
92
+ vi.stubGlobal('useApi', () => client)
93
+ const store = useFoundationalServicesStore()
94
+ await store.probe()
95
+ expect(store.sourcesAvailable).toBe(true)
96
+
97
+ client.listFoundationalServices.mockRejectedValueOnce(new Error('503'))
98
+ useWorkspaceStore().workspaceId = 'ws2'
99
+ await store.probe()
100
+
101
+ expect(store.available).toBe(false)
102
+ expect(store.sourcesAvailable).toBe(false)
103
+ expect(store.sources).toEqual([])
104
+ })
105
+
106
+ it('refreshes the opt-out list alongside the catalog on suppress and restore', async () => {
107
+ const client = api()
108
+ vi.stubGlobal('useApi', () => client)
109
+ const store = useFoundationalServicesStore()
110
+ await store.probe()
111
+ const afterProbe = client.listFoundationalServiceSuppressions.mock.calls.length
112
+
113
+ await store.suppress('file-storage')
114
+ await store.restore('file-storage')
115
+
116
+ // Once per write: a suppression that only refreshed the catalog would leave the restore
117
+ // control missing for the very service just hidden.
118
+ expect(client.listFoundationalServiceSuppressions.mock.calls.length).toBe(afterProbe + 2)
119
+ expect(client.getResolvedFoundationalServices.mock.calls.length).toBe(afterProbe + 2)
120
+ })
121
+ })
@@ -0,0 +1,276 @@
1
+ import { defineStore } from 'pinia'
2
+ import { computed, ref } from 'vue'
3
+ import type {
4
+ ApiContractDocument,
5
+ CreateFoundationalServiceInput,
6
+ FoundationalService,
7
+ FoundationalServiceOwnerKind,
8
+ FoundationalServiceSource,
9
+ FoundationalServiceSuppression,
10
+ FoundationalServiceSyncResult,
11
+ LinkFoundationalServiceSourceInput,
12
+ ResolvedFoundationalService,
13
+ UpdateFoundationalServiceInput,
14
+ } from '~/types/domain'
15
+ import { useSingleFlightProbe } from '~/composables/useSingleFlightProbe'
16
+ import { useWorkspaceStore } from '~/stores/workspace'
17
+
18
+ /**
19
+ * The foundational-services catalog for one owner — a board (`workspace`) or an `account`
20
+ * (backend/docs/adr/0031-foundational-services.md). Holds that owner's own (raw) tier of registered
21
+ * services, its linked repo sources, and — at the **workspace** tier only — the merged catalog an
22
+ * Architect actually sees (account ⊕ workspace, workspace winning).
23
+ *
24
+ * Three things about this store follow the feature's design rather than the fragment library's:
25
+ *
26
+ * - **Contract documents are never held here.** The catalog reads carry a contract MANIFEST
27
+ * (id, format, size, operation names) and no bodies, because that is the whole reason a design
28
+ * prompt scales with the number of an org's services rather than the size of its specs. A body
29
+ * is fetched on demand, per service, by {@link contractsFor}, and cached only for the session.
30
+ * - **`suppress` / `restore` are workspace-only**, and they are not "delete". Suppression opts a
31
+ * board out of an INHERITED account service and destroys nothing; deleting removes the board's
32
+ * own registration and its uploaded documents.
33
+ * - **`sourcesAvailable` is the finer gate**, exactly as in the skill library: the catalog works
34
+ * without the GitHub integration (contracts can be uploaded directly), while the repo-source
35
+ * routes 503 without it.
36
+ */
37
+ function foundationalServicesSetup(
38
+ kind: FoundationalServiceOwnerKind,
39
+ resolveOwnerId: () => string | null,
40
+ ) {
41
+ const api = useApi()
42
+
43
+ /** The merged/resolved catalog only exists at the workspace tier. */
44
+ const hasResolved = kind === 'workspace'
45
+
46
+ /** null = not probed yet; true/false = the catalog is on/off for this deployment. */
47
+ const available = ref<boolean | null>(null)
48
+ /** false when the GitHub integration is off: the catalog works, repo sources do not. */
49
+ const sourcesAvailable = ref(true)
50
+ /** This owner's own registered services (its tier, raw). */
51
+ const services = ref<FoundationalService[]>([])
52
+ /** The merged catalog an Architect sees (workspace tier only; empty otherwise). */
53
+ const resolved = ref<ResolvedFoundationalService[]>([])
54
+ /**
55
+ * What this board is opted OUT of (workspace tier only; empty otherwise). Its own read because
56
+ * a suppressed id is by construction absent from {@link resolved} — without it, suppression
57
+ * would be a one-way door.
58
+ */
59
+ const suppressions = ref<FoundationalServiceSuppression[]>([])
60
+ /** Linked repo sources for this owner. */
61
+ const sources = ref<FoundationalServiceSource[]>([])
62
+ /** Per-source "changes available" flag from the last status check. */
63
+ const sourceChanges = ref<Record<string, boolean>>({})
64
+ /**
65
+ * Session cache of fetched contract DOCUMENTS, keyed by service id. Populated only by an
66
+ * explicit {@link contractsFor}, so opening the surface never transfers a document body — the
67
+ * property the whole two-table split exists to guarantee.
68
+ */
69
+ const contractBodies = ref<Record<string, ApiContractDocument[]>>({})
70
+
71
+ /** How many entries of the merged catalog this board inherits rather than owns. */
72
+ const inheritedCount = computed(
73
+ () => resolved.value.filter((entry) => entry.tier === 'account').length,
74
+ )
75
+
76
+ function requireOwnerId(): string {
77
+ const id = resolveOwnerId()
78
+ if (!id) throw new Error('No foundational-services owner')
79
+ return id
80
+ }
81
+
82
+ function requireWorkspaceId(): string {
83
+ if (!hasResolved) throw new Error('Foundational-service inheritance is workspace-scoped')
84
+ return requireOwnerId()
85
+ }
86
+
87
+ /** Probe the feature + load this owner's tier, sources and (ws) the merged catalog. */
88
+ async function runProbe() {
89
+ const id = resolveOwnerId()
90
+ if (!id) return
91
+ try {
92
+ const [tier, merged, opted] = await Promise.all([
93
+ api.listFoundationalServices(kind, id),
94
+ hasResolved
95
+ ? api.getResolvedFoundationalServices(id)
96
+ : Promise.resolve([] as ResolvedFoundationalService[]),
97
+ hasResolved
98
+ ? api.listFoundationalServiceSuppressions(id)
99
+ : Promise.resolve([] as FoundationalServiceSuppression[]),
100
+ ])
101
+ services.value = tier
102
+ resolved.value = merged
103
+ suppressions.value = opted
104
+ available.value = true
105
+ } catch {
106
+ // Reset EVERY view, `sourcesAvailable` included. Leaving it at its previous value would
107
+ // let a re-probe of an owner whose catalog is now unreachable keep claiming the repo-source
108
+ // half is wired, which is the one flag here that gates an affordance rather than content.
109
+ available.value = false
110
+ services.value = []
111
+ resolved.value = []
112
+ suppressions.value = []
113
+ sources.value = []
114
+ sourceChanges.value = {}
115
+ sourcesAvailable.value = false
116
+ return
117
+ }
118
+ // Repo sources need the GitHub integration; a 503 here hides only the linking UI — the
119
+ // catalog read above already succeeded, so the feature itself is on.
120
+ try {
121
+ sources.value = await api.listFoundationalSources(kind, id)
122
+ sourcesAvailable.value = true
123
+ } catch {
124
+ sources.value = []
125
+ sourcesAvailable.value = false
126
+ }
127
+ }
128
+ // Single-flight the probe keyed on the owner id, so a panel-open fan-out loads once per owner.
129
+ const { probe, ensureProbed } = useSingleFlightProbe(runProbe, () => resolveOwnerId())
130
+
131
+ async function reloadTier() {
132
+ services.value = await api.listFoundationalServices(kind, requireOwnerId())
133
+ }
134
+
135
+ async function refreshResolved() {
136
+ if (!hasResolved) return
137
+ const id = requireOwnerId()
138
+ // Both in one pass: a write that changes the merge routinely changes the opt-out list too
139
+ // (suppressing removes an entry from one and adds it to the other), and refreshing only the
140
+ // catalog would leave the way BACK stale — the exact state the pair exists to avoid.
141
+ const [merged, opted] = await Promise.all([
142
+ api.getResolvedFoundationalServices(id),
143
+ api.listFoundationalServiceSuppressions(id),
144
+ ])
145
+ resolved.value = merged
146
+ suppressions.value = opted
147
+ }
148
+
149
+ /** Every write invalidates both views: a tier edit changes what the merge resolves to. */
150
+ async function reload() {
151
+ await Promise.all([reloadTier(), refreshResolved()])
152
+ }
153
+
154
+ async function create(input: CreateFoundationalServiceInput) {
155
+ await api.createFoundationalService(kind, requireOwnerId(), input)
156
+ await reload()
157
+ }
158
+
159
+ async function update(serviceId: string, patch: UpdateFoundationalServiceInput) {
160
+ await api.updateFoundationalService(kind, requireOwnerId(), serviceId, patch)
161
+ // A contract replacement changes the stored bodies, so drop the cached copy rather than
162
+ // letting a stale document be shown as what a consumer would receive.
163
+ delete contractBodies.value[serviceId]
164
+ await reload()
165
+ }
166
+
167
+ /** Remove this tier's OWN registration (and its uploaded documents). */
168
+ async function remove(serviceId: string) {
169
+ await api.deleteFoundationalService(kind, requireOwnerId(), serviceId)
170
+ delete contractBodies.value[serviceId]
171
+ await reload()
172
+ }
173
+
174
+ /** Opt this board out of an inherited ACCOUNT service. Destroys nothing; reversible. */
175
+ async function suppress(serviceId: string) {
176
+ await api.suppressFoundationalService(requireWorkspaceId(), serviceId)
177
+ await reload()
178
+ }
179
+
180
+ /** Lift a suppression, so the board inherits the account service again. */
181
+ async function restore(serviceId: string) {
182
+ await api.restoreFoundationalService(requireWorkspaceId(), serviceId)
183
+ await reload()
184
+ }
185
+
186
+ /**
187
+ * Fetch (and cache for the session) one service's full contract documents — the same lazy read
188
+ * a consumer dispatch makes, so what a human inspects here is what an agent would be handed.
189
+ */
190
+ async function contractsFor(serviceId: string): Promise<ApiContractDocument[]> {
191
+ const cached = contractBodies.value[serviceId]
192
+ if (cached) return cached
193
+ const documents = await api.getFoundationalServiceContracts(requireWorkspaceId(), serviceId)
194
+ contractBodies.value = { ...contractBodies.value, [serviceId]: documents }
195
+ return documents
196
+ }
197
+
198
+ async function reloadSources() {
199
+ sources.value = await api.listFoundationalSources(kind, requireOwnerId())
200
+ }
201
+
202
+ async function linkSource(input: LinkFoundationalServiceSourceInput) {
203
+ const source = await api.linkFoundationalSource(kind, requireOwnerId(), input)
204
+ sources.value = [source, ...sources.value]
205
+ // Sync immediately so the linked repo's services land in the catalog rather than waiting for
206
+ // the autorefresh sweep — a freshly linked source that shows nothing reads as a broken link.
207
+ await syncSource(source.id)
208
+ return source
209
+ }
210
+
211
+ async function unlinkSource(sourceId: string) {
212
+ await api.unlinkFoundationalSource(kind, requireOwnerId(), sourceId)
213
+ sources.value = sources.value.filter((s) => s.id !== sourceId)
214
+ delete sourceChanges.value[sourceId]
215
+ await reload()
216
+ }
217
+
218
+ /** Resync a source's definitions into the catalog, then refresh both views. */
219
+ async function syncSource(sourceId: string): Promise<FoundationalServiceSyncResult> {
220
+ const result = await api.syncFoundationalSource(kind, requireOwnerId(), sourceId)
221
+ delete sourceChanges.value[sourceId]
222
+ await Promise.all([reloadSources(), reload()])
223
+ return result
224
+ }
225
+
226
+ /** The cheap commit-version "check for changes" for a source; caches the flag. */
227
+ async function checkSource(sourceId: string) {
228
+ const status = await api.foundationalSourceStatus(kind, requireOwnerId(), sourceId)
229
+ sourceChanges.value = { ...sourceChanges.value, [sourceId]: status.changed }
230
+ return status
231
+ }
232
+
233
+ return {
234
+ kind,
235
+ hasResolved,
236
+ available,
237
+ sourcesAvailable,
238
+ services,
239
+ resolved,
240
+ suppressions,
241
+ sources,
242
+ sourceChanges,
243
+ contractBodies,
244
+ inheritedCount,
245
+ probe,
246
+ ensureProbed,
247
+ create,
248
+ update,
249
+ remove,
250
+ suppress,
251
+ restore,
252
+ contractsFor,
253
+ linkSource,
254
+ unlinkSource,
255
+ syncSource,
256
+ checkSource,
257
+ }
258
+ }
259
+
260
+ /**
261
+ * The workspace-tier catalog for the **active** board — a singleton that resolves the owner
262
+ * lazily, so it follows board switches and is shared by the navbar and the board modal.
263
+ */
264
+ export const useFoundationalServicesStore = defineStore('foundationalServices', () =>
265
+ foundationalServicesSetup('workspace', () => useWorkspaceStore().workspaceId),
266
+ )
267
+
268
+ /**
269
+ * An owner-keyed catalog store, used for the **account** tier (and reusable for any explicit
270
+ * owner). Keyed by `(kind, ownerId)` so each account gets isolated state.
271
+ */
272
+ export function useFoundationalServices(kind: FoundationalServiceOwnerKind, ownerId: string) {
273
+ return defineStore(`foundationalServices:${kind}:${ownerId}`, () =>
274
+ foundationalServicesSetup(kind, () => ownerId),
275
+ )()
276
+ }
@@ -10,7 +10,7 @@ import { useSingleFlightProbe } from '~/composables/useSingleFlightProbe'
10
10
  import { useSkillsStore } from '~/stores/skills'
11
11
 
12
12
  /**
13
- * The repo-sourced Claude Skills library for one account (docs/initiatives/repo-skills.md),
13
+ * The repo-sourced Claude Skills library for one account (ADR 0024),
14
14
  * used by the account-settings management surface. Holds the account's synced skill catalog
15
15
  * (full detail) and its linked repo sources, and drives link / sync / status / unlink. Skills
16
16
  * are a single account tier (no workspace tier), so — unlike the fragment library — there is no