@elevasis/core 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.
Files changed (50) hide show
  1. package/dist/index.d.ts +3117 -2166
  2. package/dist/index.js +574 -16
  3. package/dist/knowledge/index.d.ts +122 -7
  4. package/dist/organization-model/index.d.ts +3117 -2166
  5. package/dist/organization-model/index.js +574 -16
  6. package/dist/test-utils/index.d.ts +135 -45
  7. package/dist/test-utils/index.js +122 -14
  8. package/package.json +3 -3
  9. package/src/_gen/__tests__/__snapshots__/contracts.md.snap +139 -101
  10. package/src/execution/engine/llm/adapters/__tests__/openrouter.integration.test.ts +10 -10
  11. package/src/execution/engine/workflow/types.ts +5 -7
  12. package/src/knowledge/__tests__/queries.test.ts +960 -546
  13. package/src/knowledge/format.ts +322 -100
  14. package/src/knowledge/index.ts +18 -5
  15. package/src/knowledge/queries.ts +1004 -239
  16. package/src/organization-model/__tests__/deprecate-helpers.test.ts +71 -0
  17. package/src/organization-model/__tests__/domains/resources.test.ts +19 -8
  18. package/src/organization-model/__tests__/domains/topology.test.ts +188 -0
  19. package/src/organization-model/__tests__/graph.test.ts +98 -7
  20. package/src/organization-model/__tests__/resolve.test.ts +9 -7
  21. package/src/organization-model/__tests__/scaffolders.test.ts +93 -0
  22. package/src/organization-model/__tests__/schema.test.ts +14 -4
  23. package/src/organization-model/defaults.ts +5 -3
  24. package/src/organization-model/domains/resources.ts +63 -20
  25. package/src/organization-model/domains/topology.ts +261 -0
  26. package/src/organization-model/graph/build.ts +63 -15
  27. package/src/organization-model/graph/schema.ts +4 -3
  28. package/src/organization-model/graph/types.ts +5 -4
  29. package/src/organization-model/helpers.ts +76 -9
  30. package/src/organization-model/icons.ts +1 -0
  31. package/src/organization-model/index.ts +7 -5
  32. package/src/organization-model/ontology.ts +2 -5
  33. package/src/organization-model/organization-model.mdx +16 -11
  34. package/src/organization-model/published.ts +51 -15
  35. package/src/organization-model/scaffolders/helpers.ts +84 -0
  36. package/src/organization-model/scaffolders/index.ts +19 -0
  37. package/src/organization-model/scaffolders/scaffoldKnowledgeNode.ts +48 -0
  38. package/src/organization-model/scaffolders/scaffoldOntologyRecord.ts +38 -0
  39. package/src/organization-model/scaffolders/scaffoldResource.ts +59 -0
  40. package/src/organization-model/scaffolders/scaffoldSystem.ts +110 -0
  41. package/src/organization-model/scaffolders/types.ts +81 -0
  42. package/src/organization-model/schema.ts +51 -11
  43. package/src/organization-model/types.ts +25 -11
  44. package/src/platform/constants/versions.ts +1 -1
  45. package/src/platform/registry/__tests__/validation.test.ts +199 -14
  46. package/src/platform/registry/resource-registry.ts +11 -11
  47. package/src/platform/registry/validation.ts +226 -34
  48. package/src/reference/_generated/contracts.md +139 -101
  49. package/src/reference/glossary.md +74 -72
  50. package/src/supabase/database.types.ts +3156 -3153
@@ -1,16 +1,18 @@
1
- import type { OrganizationGraph } from '../organization-model/graph/types'
1
+ import type { OrganizationGraph } from '../organization-model/graph/types'
2
2
  import type { OrgKnowledgeKind, OrgKnowledgeNode } from '../organization-model/domains/knowledge'
3
3
  import { OntologyIdSchema, ontologyGraphNodeId } from '../organization-model/ontology'
4
-
5
- // ---------------------------------------------------------------------------
6
- // Internal helpers
7
- // ---------------------------------------------------------------------------
8
-
9
- /**
10
- * Graph node ID prefix for knowledge nodes.
11
- * Graph node IDs follow the convention: `knowledge:<om-node-id>`
12
- * e.g. `knowledge:knowledge.outreach-playbook`
13
- */
4
+ import type { OrganizationModel } from '../organization-model/types'
5
+ import { listAllSystems } from '../organization-model/helpers'
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Internal helpers
9
+ // ---------------------------------------------------------------------------
10
+
11
+ /**
12
+ * Graph node ID prefix for knowledge nodes.
13
+ * Graph node IDs follow the convention: `knowledge:<om-node-id>`
14
+ * e.g. `knowledge:knowledge.outreach-playbook`
15
+ */
14
16
  function toGraphNodeId(omNodeId: string): string {
15
17
  return `knowledge:${omNodeId}`
16
18
  }
@@ -33,58 +35,87 @@ function toTargetGraphNodeId(nodeId: string): string {
33
35
 
34
36
  return `system:${nodeId}`
35
37
  }
36
-
37
- /**
38
- * Resolve the sourceId of every knowledge node in the graph, keyed by graph
39
- * node ID. The `sourceId` on a knowledge graph node is the OM node id
40
- * (e.g. `knowledge.outreach-playbook`).
41
- */
42
- function buildKnowledgeSourceIdMap(graph: OrganizationGraph): Map<string, string> {
43
- const map = new Map<string, string>()
44
- for (const node of graph.nodes) {
45
- if (node.kind === 'knowledge' && node.sourceId) {
46
- map.set(node.id, node.sourceId)
47
- }
48
- }
49
- return map
50
- }
51
-
52
- // ---------------------------------------------------------------------------
53
- // Query functions
54
- // ---------------------------------------------------------------------------
55
-
56
- /**
57
- * Returns all knowledge nodes whose `governs` edges point to the given
58
- * systemId (graph node ID: `system:<systemId>`).
59
- *
60
- * @param graph - The built OrganizationGraph.
61
- * @param systemId - The dotted system id (e.g. `sales.crm`).
62
- */
38
+
39
+ /**
40
+ * Resolve the sourceId of every knowledge node in the graph, keyed by graph
41
+ * node ID. The `sourceId` on a knowledge graph node is the OM node id
42
+ * (e.g. `knowledge.outreach-playbook`).
43
+ */
44
+ function buildKnowledgeSourceIdMap(graph: OrganizationGraph): Map<string, string> {
45
+ const map = new Map<string, string>()
46
+ for (const node of graph.nodes) {
47
+ if (node.kind === 'knowledge' && node.sourceId) {
48
+ map.set(node.id, node.sourceId)
49
+ }
50
+ }
51
+ return map
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Query functions
56
+ // ---------------------------------------------------------------------------
57
+
58
+ /**
59
+ * Returns all knowledge nodes whose `governs` edges point to the given
60
+ * systemId (graph node ID: `system:<systemId>`).
61
+ *
62
+ * @param graph - The built OrganizationGraph.
63
+ * @param systemId - The dotted system id (e.g. `sales.crm`).
64
+ */
63
65
  export function bySystem(
64
- graph: OrganizationGraph,
65
- systemId: string,
66
- knowledgeNodes: OrgKnowledgeNode[]
67
- ): OrgKnowledgeNode[] {
68
- const targetGraphNodeId = `system:${systemId}`
69
-
70
- // Find all knowledge graph node IDs that have a `governs` edge to the target
71
- const governingKnowledgeNodeIds = new Set<string>()
72
- for (const edge of graph.edges) {
73
- if (edge.kind === 'governs' && edge.targetId === targetGraphNodeId && edge.sourceId.startsWith('knowledge:')) {
74
- governingKnowledgeNodeIds.add(edge.sourceId)
75
- }
76
- }
77
-
78
- // Build a lookup from graph-node-id -> OM sourceId
79
- const sourceIdMap = buildKnowledgeSourceIdMap(graph)
80
-
81
- // Collect the OM node ids that correspond to those graph nodes
82
- const matchingOmIds = new Set<string>()
83
- for (const graphNodeId of governingKnowledgeNodeIds) {
84
- const omId = sourceIdMap.get(graphNodeId)
85
- if (omId) matchingOmIds.add(omId)
86
- }
87
-
66
+ graph: OrganizationGraph,
67
+ systemId: string,
68
+ knowledgeNodes: OrgKnowledgeNode[]
69
+ ): OrgKnowledgeNode[] {
70
+ const targetGraphNodeId = `system:${systemId}`
71
+
72
+ // Find all knowledge graph node IDs that have a `governs` edge to the target
73
+ const governingKnowledgeNodeIds = new Set<string>()
74
+ for (const edge of graph.edges) {
75
+ if (edge.kind === 'governs' && edge.targetId === targetGraphNodeId && edge.sourceId.startsWith('knowledge:')) {
76
+ governingKnowledgeNodeIds.add(edge.sourceId)
77
+ }
78
+ }
79
+
80
+ // Build a lookup from graph-node-id -> OM sourceId
81
+ const sourceIdMap = buildKnowledgeSourceIdMap(graph)
82
+
83
+ // Collect the OM node ids that correspond to those graph nodes
84
+ const matchingOmIds = new Set<string>()
85
+ for (const graphNodeId of governingKnowledgeNodeIds) {
86
+ const omId = sourceIdMap.get(graphNodeId)
87
+ if (omId) matchingOmIds.add(omId)
88
+ }
89
+
90
+ return knowledgeNodes.filter((n) => matchingOmIds.has(n.id))
91
+ }
92
+
93
+ export function bySystemTree(
94
+ graph: OrganizationGraph,
95
+ systemId: string,
96
+ knowledgeNodes: OrgKnowledgeNode[]
97
+ ): OrgKnowledgeNode[] {
98
+ const prefix = `${systemId}.`
99
+ const targetGraphNodeIds = new Set(
100
+ graph.nodes
101
+ .filter((node) => node.id === `system:${systemId}` || node.id.startsWith(`system:${prefix}`))
102
+ .map((node) => node.id)
103
+ )
104
+
105
+ const governingKnowledgeNodeIds = new Set<string>()
106
+ for (const edge of graph.edges) {
107
+ if (edge.kind === 'governs' && targetGraphNodeIds.has(edge.targetId) && edge.sourceId.startsWith('knowledge:')) {
108
+ governingKnowledgeNodeIds.add(edge.sourceId)
109
+ }
110
+ }
111
+
112
+ const sourceIdMap = buildKnowledgeSourceIdMap(graph)
113
+ const matchingOmIds = new Set<string>()
114
+ for (const graphNodeId of governingKnowledgeNodeIds) {
115
+ const omId = sourceIdMap.get(graphNodeId)
116
+ if (omId) matchingOmIds.add(omId)
117
+ }
118
+
88
119
  return knowledgeNodes.filter((n) => matchingOmIds.has(n.id))
89
120
  }
90
121
 
@@ -111,149 +142,149 @@ export function byOntology(
111
142
 
112
143
  return knowledgeNodes.filter((n) => matchingOmIds.has(n.id))
113
144
  }
114
-
115
- /**
116
- * Returns all knowledge nodes whose `kind` matches the given kind.
117
- *
118
- * @param graph - The built OrganizationGraph (unused structurally; kept for API symmetry).
119
- * @param kind - The knowledge kind (`'playbook' | 'strategy' | 'reference'`).
120
- * @param knowledgeNodes - Flat array of OrgKnowledgeNode from the OrganizationModel.
121
- */
122
- export function byKind(
123
- _graph: OrganizationGraph,
124
- kind: OrgKnowledgeKind,
125
- knowledgeNodes: OrgKnowledgeNode[]
126
- ): OrgKnowledgeNode[] {
127
- return knowledgeNodes.filter((n) => n.kind === kind)
128
- }
129
-
130
- /**
131
- * Returns all knowledge nodes where `ownerIds` includes the given `ownerId`.
132
- *
133
- * @param _graph - The built OrganizationGraph (unused; kept for API symmetry).
134
- * @param ownerId - The owner id string (matches values in `node.ownerIds`).
135
- * @param knowledgeNodes - Flat array of OrgKnowledgeNode from the OrganizationModel.
136
- */
137
- export function byOwner(
138
- _graph: OrganizationGraph,
139
- ownerId: string,
140
- knowledgeNodes: OrgKnowledgeNode[]
141
- ): OrgKnowledgeNode[] {
142
- return knowledgeNodes.filter((n) => n.ownerIds.includes(ownerId))
143
- }
144
-
145
- /**
146
- * Returns the IDs of the graph nodes that the given knowledge node governs
147
- * (outgoing `governs` edges from the knowledge graph node).
148
- *
149
- * The graph node ID for a knowledge OM node with id `X` is `knowledge:X`.
150
- *
151
- * @param graph - The built OrganizationGraph.
152
- * @param nodeId - The OM knowledge node id (e.g. `knowledge.outreach-playbook`).
153
- * Also accepts the graph node ID format (`knowledge:knowledge.outreach-playbook`).
154
- * @returns Array of target graph node IDs (e.g. `['system:sales.crm', ...]`).
155
- */
156
- export function governs(graph: OrganizationGraph, nodeId: string): string[] {
157
- const graphNodeId = nodeId.startsWith('knowledge:') ? nodeId : toGraphNodeId(nodeId)
158
-
159
- const results: string[] = []
160
- for (const edge of graph.edges) {
161
- if (edge.kind === 'governs' && edge.sourceId === graphNodeId) {
162
- results.push(edge.targetId)
163
- }
164
- }
165
- return results
166
- }
167
-
168
- /**
169
- * Returns the IDs of the knowledge graph nodes that govern the given node
170
- * (incoming `governs` edges pointing to `nodeId`).
171
- *
172
- * @param graph - The built OrganizationGraph.
173
- * @param nodeId - The target graph node ID (e.g. `system:sales.crm`) or a
174
- * bare system id (e.g. `sales.crm` — will be prefixed with `system:`).
175
- * @returns Array of source graph node IDs (e.g. `['knowledge:knowledge.outreach-playbook', ...]`).
176
- */
145
+
146
+ /**
147
+ * Returns all knowledge nodes whose `kind` matches the given kind.
148
+ *
149
+ * @param graph - The built OrganizationGraph (unused structurally; kept for API symmetry).
150
+ * @param kind - The knowledge kind (`'playbook' | 'strategy' | 'reference'`).
151
+ * @param knowledgeNodes - Flat array of OrgKnowledgeNode from the OrganizationModel.
152
+ */
153
+ export function byKind(
154
+ _graph: OrganizationGraph,
155
+ kind: OrgKnowledgeKind,
156
+ knowledgeNodes: OrgKnowledgeNode[]
157
+ ): OrgKnowledgeNode[] {
158
+ return knowledgeNodes.filter((n) => n.kind === kind)
159
+ }
160
+
161
+ /**
162
+ * Returns all knowledge nodes where `ownerIds` includes the given `ownerId`.
163
+ *
164
+ * @param _graph - The built OrganizationGraph (unused; kept for API symmetry).
165
+ * @param ownerId - The owner id string (matches values in `node.ownerIds`).
166
+ * @param knowledgeNodes - Flat array of OrgKnowledgeNode from the OrganizationModel.
167
+ */
168
+ export function byOwner(
169
+ _graph: OrganizationGraph,
170
+ ownerId: string,
171
+ knowledgeNodes: OrgKnowledgeNode[]
172
+ ): OrgKnowledgeNode[] {
173
+ return knowledgeNodes.filter((n) => n.ownerIds.includes(ownerId))
174
+ }
175
+
176
+ /**
177
+ * Returns the IDs of the graph nodes that the given knowledge node governs
178
+ * (outgoing `governs` edges from the knowledge graph node).
179
+ *
180
+ * The graph node ID for a knowledge OM node with id `X` is `knowledge:X`.
181
+ *
182
+ * @param graph - The built OrganizationGraph.
183
+ * @param nodeId - The OM knowledge node id (e.g. `knowledge.outreach-playbook`).
184
+ * Also accepts the graph node ID format (`knowledge:knowledge.outreach-playbook`).
185
+ * @returns Array of target graph node IDs (e.g. `['system:sales.crm', ...]`).
186
+ */
187
+ export function governs(graph: OrganizationGraph, nodeId: string): string[] {
188
+ const graphNodeId = nodeId.startsWith('knowledge:') ? nodeId : toGraphNodeId(nodeId)
189
+
190
+ const results: string[] = []
191
+ for (const edge of graph.edges) {
192
+ if (edge.kind === 'governs' && edge.sourceId === graphNodeId) {
193
+ results.push(edge.targetId)
194
+ }
195
+ }
196
+ return results
197
+ }
198
+
199
+ /**
200
+ * Returns the IDs of the knowledge graph nodes that govern the given node
201
+ * (incoming `governs` edges pointing to `nodeId`).
202
+ *
203
+ * @param graph - The built OrganizationGraph.
204
+ * @param nodeId - The target graph node ID (e.g. `system:sales.crm`) or a
205
+ * bare system id (e.g. `sales.crm` — will be prefixed with `system:`).
206
+ * @returns Array of source graph node IDs (e.g. `['knowledge:knowledge.outreach-playbook', ...]`).
207
+ */
177
208
  export function governedBy(graph: OrganizationGraph, nodeId: string): string[] {
178
209
  // Accept prefixed graph IDs, bare system IDs, and bare ontology IDs.
179
210
  const targetId = toTargetGraphNodeId(nodeId)
180
-
181
- const results: string[] = []
182
- for (const edge of graph.edges) {
183
- if (edge.kind === 'governs' && edge.targetId === targetId) {
184
- results.push(edge.sourceId)
185
- }
186
- }
187
- return results
188
- }
189
-
190
- // ---------------------------------------------------------------------------
191
- // Path parser
192
- // ---------------------------------------------------------------------------
193
-
194
- /** The recognized mount axes for Knowledge Map paths. */
211
+
212
+ const results: string[] = []
213
+ for (const edge of graph.edges) {
214
+ if (edge.kind === 'governs' && edge.targetId === targetId) {
215
+ results.push(edge.sourceId)
216
+ }
217
+ }
218
+ return results
219
+ }
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // Path parser
223
+ // ---------------------------------------------------------------------------
224
+
225
+ /** The recognized mount axes for Knowledge Map paths. */
195
226
  export type KnowledgeMount = 'by-system' | 'by-ontology' | 'by-kind' | 'by-owner' | 'graph' | 'node'
196
-
197
- /**
198
- * The result of parsing a Knowledge Map path string.
199
- *
200
- * Shape: `{ mount: KnowledgeMount, args: string[] }`
201
- *
202
- * Per-mount arg arrays:
227
+
228
+ /**
229
+ * The result of parsing a Knowledge Map path string.
230
+ *
231
+ * Shape: `{ mount: KnowledgeMount, args: string[] }`
232
+ *
233
+ * Per-mount arg arrays:
203
234
  * - `by-system`: `[systemId]` (e.g. `['sales.crm']`)
204
235
  * - `by-ontology`:`[ontologyId]` (e.g. `['sales.crm:object/deal']`)
205
- * - `by-kind`: `[kind]` (e.g. `['playbook']`)
206
- * - `by-owner`: `[ownerId]` (e.g. `['role.ops-lead']`)
207
- * - `graph`: `[nodeId, verb]` where verb is `'governs'` or `'governed-by'`
208
- * - `node`: `[nodeId]` (single node lookup, no sub-path)
209
- */
210
- export interface ParsedKnowledgePath {
211
- mount: KnowledgeMount
212
- args: string[]
213
- }
214
-
215
- /**
216
- * Parses a Knowledge Map path string into a `{ mount, args }` descriptor.
217
- *
218
- * Supported path patterns:
236
+ * - `by-kind`: `[kind]` (e.g. `['playbook']`)
237
+ * - `by-owner`: `[ownerId]` (e.g. `['role.ops-lead']`)
238
+ * - `graph`: `[nodeId, verb]` where verb is `'governs'` or `'governed-by'`
239
+ * - `node`: `[nodeId]` (single node lookup, no sub-path)
240
+ */
241
+ export interface ParsedKnowledgePath {
242
+ mount: KnowledgeMount
243
+ args: string[]
244
+ }
245
+
246
+ /**
247
+ * Parses a Knowledge Map path string into a `{ mount, args }` descriptor.
248
+ *
249
+ * Supported path patterns:
219
250
  * `/by-system/<systemId>` -> `{ mount: 'by-system', args: ['<systemId>'] }`
220
251
  * `/by-ontology/<ontologyId>` -> `{ mount: 'by-ontology', args: ['<ontologyId>'] }`
221
- * `/by-kind/<kind>` → `{ mount: 'by-kind', args: ['<kind>'] }`
222
- * `/by-owner/<ownerId>` → `{ mount: 'by-owner', args: ['<ownerId>'] }`
223
- * `/graph/<nodeId>/governs` → `{ mount: 'graph', args: ['<nodeId>', 'governs'] }`
224
- * `/graph/<nodeId>/governed-by` → `{ mount: 'graph', args: ['<nodeId>', 'governed-by'] }`
225
- * `/<nodeId>` → `{ mount: 'node', args: ['<nodeId>'] }`
226
- *
227
- * The path MUST start with `/`. Trailing slashes are stripped before parsing.
228
- *
229
- * @throws {Error} If the path is empty, missing a leading slash, or does not
230
- * match any supported mount pattern.
231
- */
232
- export function parsePath(pathString: string): ParsedKnowledgePath {
233
- if (!pathString || typeof pathString !== 'string') {
234
- throw new Error('parsePath: path must be a non-empty string')
235
- }
236
-
237
- if (!pathString.startsWith('/')) {
238
- throw new Error(`parsePath: path must start with "/", got: "${pathString}"`)
239
- }
240
-
241
- // Strip trailing slashes then split
242
- const normalized = pathString.replace(/\/+$/, '')
243
- const segments = normalized.split('/').filter((s) => s.length > 0)
244
-
245
- if (segments.length === 0) {
246
- throw new Error(`parsePath: path resolves to root with no mount: "${pathString}"`)
247
- }
248
-
249
- const [first, ...rest] = segments
250
-
251
- // /by-system/<systemId>
252
+ * `/by-kind/<kind>` → `{ mount: 'by-kind', args: ['<kind>'] }`
253
+ * `/by-owner/<ownerId>` → `{ mount: 'by-owner', args: ['<ownerId>'] }`
254
+ * `/graph/<nodeId>/governs` → `{ mount: 'graph', args: ['<nodeId>', 'governs'] }`
255
+ * `/graph/<nodeId>/governed-by` → `{ mount: 'graph', args: ['<nodeId>', 'governed-by'] }`
256
+ * `/<nodeId>` → `{ mount: 'node', args: ['<nodeId>'] }`
257
+ *
258
+ * The path MUST start with `/`. Trailing slashes are stripped before parsing.
259
+ *
260
+ * @throws {Error} If the path is empty, missing a leading slash, or does not
261
+ * match any supported mount pattern.
262
+ */
263
+ export function parsePath(pathString: string): ParsedKnowledgePath {
264
+ if (!pathString || typeof pathString !== 'string') {
265
+ throw new Error('parsePath: path must be a non-empty string')
266
+ }
267
+
268
+ if (!pathString.startsWith('/')) {
269
+ throw new Error(`parsePath: path must start with "/", got: "${pathString}"`)
270
+ }
271
+
272
+ // Strip trailing slashes then split
273
+ const normalized = pathString.replace(/\/+$/, '')
274
+ const segments = normalized.split('/').filter((s) => s.length > 0)
275
+
276
+ if (segments.length === 0) {
277
+ throw new Error(`parsePath: path resolves to root with no mount: "${pathString}"`)
278
+ }
279
+
280
+ const [first, ...rest] = segments
281
+
282
+ // /by-system/<systemId>
252
283
  if (first === 'by-system') {
253
- if (rest.length === 0) {
254
- throw new Error(`parsePath: /by-system requires a systemId argument, got: "${pathString}"`)
255
- }
256
- return { mount: 'by-system', args: [rest.join('/')] }
284
+ if (rest.length === 0) {
285
+ throw new Error(`parsePath: /by-system requires a systemId argument, got: "${pathString}"`)
286
+ }
287
+ return { mount: 'by-system', args: [rest.join('/')] }
257
288
  }
258
289
 
259
290
  // /by-ontology/<ontologyId>
@@ -263,46 +294,780 @@ export function parsePath(pathString: string): ParsedKnowledgePath {
263
294
  }
264
295
  return { mount: 'by-ontology', args: [rest.join('/')] }
265
296
  }
266
-
267
- // /by-kind/<kind>
268
- if (first === 'by-kind') {
269
- if (rest.length === 0) {
270
- throw new Error(`parsePath: /by-kind requires a kind argument, got: "${pathString}"`)
271
- }
272
- return { mount: 'by-kind', args: [rest[0]] }
273
- }
274
-
275
- // /by-owner/<ownerId>
276
- if (first === 'by-owner') {
277
- if (rest.length === 0) {
278
- throw new Error(`parsePath: /by-owner requires an ownerId argument, got: "${pathString}"`)
279
- }
280
- return { mount: 'by-owner', args: [rest.join('/')] }
281
- }
282
-
283
- // /graph/<nodeId>/governs or /graph/<nodeId>/governed-by
284
- if (first === 'graph') {
285
- if (rest.length < 2) {
286
- throw new Error(`parsePath: /graph requires <nodeId>/<verb> (governs|governed-by), got: "${pathString}"`)
287
- }
288
- const graphNodeId = rest.slice(0, -1).join('/')
289
- const verb = rest[rest.length - 1]
290
- if (verb !== 'governs' && verb !== 'governed-by') {
291
- throw new Error(
292
- `parsePath: /graph/<nodeId> verb must be "governs" or "governed-by", got: "${verb}" in "${pathString}"`
293
- )
294
- }
295
- return { mount: 'graph', args: [graphNodeId, verb] }
296
- }
297
-
298
- // /<nodeId> (single node)
299
- // first must not be a recognized mount prefix
300
- if (segments.length === 1) {
301
- return { mount: 'node', args: [first] }
302
- }
303
-
304
- throw new Error(
305
- `parsePath: unrecognized path pattern "${pathString}". Supported: /by-system/<id>, /by-kind/<kind>, /by-owner/<id>, /graph/<nodeId>/governs, /graph/<nodeId>/governed-by, /<nodeId>`
306
- )
307
- }
308
-
297
+
298
+ // /by-kind/<kind>
299
+ if (first === 'by-kind') {
300
+ if (rest.length === 0) {
301
+ throw new Error(`parsePath: /by-kind requires a kind argument, got: "${pathString}"`)
302
+ }
303
+ return { mount: 'by-kind', args: [rest[0]] }
304
+ }
305
+
306
+ // /by-owner/<ownerId>
307
+ if (first === 'by-owner') {
308
+ if (rest.length === 0) {
309
+ throw new Error(`parsePath: /by-owner requires an ownerId argument, got: "${pathString}"`)
310
+ }
311
+ return { mount: 'by-owner', args: [rest.join('/')] }
312
+ }
313
+
314
+ // /graph/<nodeId>/governs or /graph/<nodeId>/governed-by
315
+ if (first === 'graph') {
316
+ if (rest.length < 2) {
317
+ throw new Error(`parsePath: /graph requires <nodeId>/<verb> (governs|governed-by), got: "${pathString}"`)
318
+ }
319
+ const graphNodeId = rest.slice(0, -1).join('/')
320
+ const verb = rest[rest.length - 1]
321
+ if (verb !== 'governs' && verb !== 'governed-by') {
322
+ throw new Error(
323
+ `parsePath: /graph/<nodeId> verb must be "governs" or "governed-by", got: "${verb}" in "${pathString}"`
324
+ )
325
+ }
326
+ return { mount: 'graph', args: [graphNodeId, verb] }
327
+ }
328
+
329
+ // /<nodeId> (single node)
330
+ // first must not be a recognized mount prefix
331
+ if (segments.length === 1) {
332
+ return { mount: 'node', args: [first] }
333
+ }
334
+
335
+ throw new Error(
336
+ `parsePath: unrecognized path pattern "${pathString}". Supported: /by-system/<id>, /by-kind/<kind>, /by-owner/<id>, /graph/<nodeId>/governs, /graph/<nodeId>/governed-by, /<nodeId>`
337
+ )
338
+ }
339
+
340
+ // ---------------------------------------------------------------------------
341
+ // omSearch — universal keyword search across all OM surfaces
342
+ // ---------------------------------------------------------------------------
343
+
344
+ /**
345
+ * The kind of OM node that produced a search hit.
346
+ *
347
+ * Maps to the surface being searched:
348
+ * - `system` — recursive systems tree (model.systems)
349
+ * - `resource` — model.resources (workflows, agents, integrations, scripts)
350
+ * - `knowledge` — model.knowledge (playbooks, strategies, references)
351
+ * - `ontology` — system.ontology and model.ontology (objects, actions, events, catalogs, links, ...)
352
+ * - `role` — model.roles
353
+ * - `policy` — model.policies
354
+ */
355
+ export type OmSearchHitKind = 'system' | 'resource' | 'knowledge' | 'ontology' | 'role' | 'policy'
356
+
357
+ export interface OmSearchHit {
358
+ /** Surface that produced the hit. */
359
+ kind: OmSearchHitKind
360
+ /** Canonical id (system path, resource id, knowledge id, ontology id, role id, policy id). */
361
+ id: string
362
+ /** Display title (label/title/summary fallback). */
363
+ title: string
364
+ /** One-line description or summary (may be empty). */
365
+ summary: string
366
+ /** Ranking score; higher is better. */
367
+ score: number
368
+ /** Optional sub-kind for ontology / resource (`object`, `action`, `event`, `workflow`, ...). */
369
+ subKind?: string
370
+ }
371
+
372
+ export interface OmSearchOptions {
373
+ /** Max number of hits to return. Defaults to 10. Pass 0 for unlimited. */
374
+ limit?: number
375
+ /** Restrict to specific OM surfaces. Defaults to all kinds. */
376
+ kinds?: OmSearchHitKind[]
377
+ }
378
+
379
+ /**
380
+ * Universal keyword search across the entire Organization Model.
381
+ *
382
+ * Iterates over every surface (knowledge, systems, resources, ontology, roles, policies),
383
+ * scores each entry by term overlap, and returns the top hits ranked by score.
384
+ *
385
+ * Scoring (per query term, summed across terms):
386
+ * - title/label match: ×8
387
+ * - summary/description match: ×4
388
+ * - tags/responsibilities/meta match: ×3
389
+ * - body match: ×1
390
+ * Bonuses: exact id match (+50 for single-term queries), all terms appear in id (+5).
391
+ *
392
+ * Case-insensitive. Empty query returns no hits.
393
+ */
394
+ export function omSearch(model: OrganizationModel, query: string, options: OmSearchOptions = {}): OmSearchHit[] {
395
+ const limit = options.limit ?? 10
396
+ const kinds = options.kinds ? new Set(options.kinds) : null
397
+
398
+ const terms = tokenize(query)
399
+ if (terms.length === 0) return []
400
+
401
+ const entries = collectSearchable(model, kinds)
402
+
403
+ const scored: OmSearchHit[] = []
404
+ for (const entry of entries) {
405
+ const score = scoreEntry(entry, terms)
406
+ if (score > 0) {
407
+ scored.push({
408
+ kind: entry.kind,
409
+ id: entry.id,
410
+ title: entry.title,
411
+ summary: entry.summary,
412
+ score,
413
+ subKind: entry.subKind
414
+ })
415
+ }
416
+ }
417
+
418
+ scored.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id))
419
+
420
+ return limit > 0 ? scored.slice(0, limit) : scored
421
+ }
422
+
423
+ // ---------------------------------------------------------------------------
424
+ // omSearch internals
425
+ // ---------------------------------------------------------------------------
426
+
427
+ interface SearchableEntry {
428
+ kind: OmSearchHitKind
429
+ id: string
430
+ title: string
431
+ summary: string
432
+ subKind?: string
433
+ /** Title-tier fields. Weight ×8 per term match. */
434
+ high: string[]
435
+ /** Description-tier fields. Weight ×4 per term match. */
436
+ mid: string[]
437
+ /** Tag/meta-tier fields (kind, systemPath, responsibilities, owner). Weight ×3 per term match. */
438
+ meta: string[]
439
+ /** Body-tier fields (MDX body, etc). Weight ×1 per term match. */
440
+ low: string[]
441
+ }
442
+
443
+ function tokenize(query: string): string[] {
444
+ return query
445
+ .toLowerCase()
446
+ .split(/[\s,/]+/)
447
+ .map((t) => t.replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, ''))
448
+ .filter((t) => t.length >= 2)
449
+ }
450
+
451
+ /**
452
+ * Split a title/id into searchable tokens, breaking on whitespace AND structural
453
+ * separators (`.` `-` `_` `:`). Used for exact-name-match boosts.
454
+ */
455
+ function structuralTokens(text: string): Set<string> {
456
+ return new Set(
457
+ text
458
+ .toLowerCase()
459
+ .split(/[\s.\-_:/,;]+/)
460
+ .map((t) => t.replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, ''))
461
+ .filter((t) => t.length >= 2)
462
+ )
463
+ }
464
+
465
+ function setsEqual(a: Set<string>, b: Set<string>): boolean {
466
+ if (a.size !== b.size) return false
467
+ for (const item of a) if (!b.has(item)) return false
468
+ return true
469
+ }
470
+
471
+ function countOccurrences(haystack: string, needle: string): number {
472
+ if (!haystack || !needle) return 0
473
+ const lower = haystack.toLowerCase()
474
+ let count = 0
475
+ let idx = 0
476
+ while ((idx = lower.indexOf(needle, idx)) !== -1) {
477
+ count++
478
+ idx += needle.length
479
+ }
480
+ return count
481
+ }
482
+
483
+ /**
484
+ * Per-kind base boost applied when an entry scores at all. Reflects how
485
+ * "primary" each surface is in the OM: structural identifiers (systems,
486
+ * resources, roles) outrank descriptive content (knowledge bodies) on close
487
+ * ties. Knowledge nodes still dominate when nothing else matches.
488
+ */
489
+ const KIND_BOOST: Record<OmSearchHitKind, number> = {
490
+ system: 5,
491
+ resource: 3,
492
+ role: 3,
493
+ policy: 3,
494
+ ontology: 1,
495
+ knowledge: 0
496
+ }
497
+
498
+ function scoreEntry(entry: SearchableEntry, terms: string[]): number {
499
+ let total = 0
500
+ const idLower = entry.id.toLowerCase()
501
+
502
+ for (const term of terms) {
503
+ for (const text of entry.high) total += 8 * countOccurrences(text, term)
504
+ for (const text of entry.mid) total += 4 * countOccurrences(text, term)
505
+ for (const text of entry.meta) total += 3 * countOccurrences(text, term)
506
+ for (const text of entry.low) total += 1 * countOccurrences(text, term)
507
+ }
508
+
509
+ if (total === 0) return 0
510
+
511
+ const queryTokenSet = new Set(terms)
512
+
513
+ // Exact-id boost (single-term query): massive jump so /knowledge.outreach-playbook lands first.
514
+ if (terms.length === 1 && idLower === terms[0]) total += 50
515
+
516
+ // Exact-title-match boost: the query tokens exactly match the entity's title tokens.
517
+ // This precisely rewards "the user typed the entity's name" (e.g. "lead gen" -> "Lead Gen" system).
518
+ const titleTokens = structuralTokens(entry.title)
519
+ if (setsEqual(queryTokenSet, titleTokens)) total += 25
520
+
521
+ // Exact-last-id-segment match: query tokens equal the last dotted id segment's tokens.
522
+ // Helps "lead gen" match `sales.lead-gen` (last segment "lead-gen" -> {lead, gen}).
523
+ const lastSegment = idLower.split('.').pop() ?? idLower
524
+ if (setsEqual(queryTokenSet, structuralTokens(lastSegment))) total += 20
525
+
526
+ // All-terms-in-id boost: helpful when no exact match but id contains every term as substring.
527
+ if (terms.every((t) => idLower.includes(t))) total += 5
528
+
529
+ // Kind-aware structural preference.
530
+ total += KIND_BOOST[entry.kind]
531
+
532
+ return total
533
+ }
534
+
535
+ function collectSearchable(model: OrganizationModel, kinds: Set<OmSearchHitKind> | null): SearchableEntry[] {
536
+ const entries: SearchableEntry[] = []
537
+
538
+ const include = (kind: OmSearchHitKind): boolean => kinds === null || kinds.has(kind)
539
+
540
+ // Knowledge nodes
541
+ if (include('knowledge')) {
542
+ for (const node of Object.values(model.knowledge ?? {})) {
543
+ entries.push({
544
+ kind: 'knowledge',
545
+ id: node.id,
546
+ title: node.title,
547
+ summary: node.summary,
548
+ subKind: node.kind,
549
+ high: [node.id, node.title],
550
+ mid: [node.summary],
551
+ meta: [node.kind, ...(node.ownerIds ?? [])],
552
+ low: [node.body]
553
+ })
554
+ }
555
+ }
556
+
557
+ // Systems (recursive tree)
558
+ if (include('system')) {
559
+ for (const { path, system } of listAllSystems(model)) {
560
+ const title = system.label ?? system.title ?? path
561
+ const description = system.description ?? ''
562
+ entries.push({
563
+ kind: 'system',
564
+ id: path,
565
+ title,
566
+ summary: description,
567
+ subKind: system.kind,
568
+ high: [path, title],
569
+ mid: [description],
570
+ meta: [system.kind ?? '', system.lifecycle ?? '', system.responsibleRoleId ?? ''],
571
+ low: []
572
+ })
573
+
574
+ // Per-system ontology entries
575
+ if (include('ontology') && system.ontology) {
576
+ entries.push(...collectOntologyEntries(system.ontology))
577
+ }
578
+ }
579
+ } else if (include('ontology')) {
580
+ // If systems are excluded but ontology isn't, still walk systems to find ontology
581
+ for (const { system } of listAllSystems(model)) {
582
+ if (system.ontology) entries.push(...collectOntologyEntries(system.ontology))
583
+ }
584
+ }
585
+
586
+ // Top-level ontology (global scope)
587
+ if (include('ontology') && model.ontology) {
588
+ entries.push(...collectOntologyEntries(model.ontology))
589
+ }
590
+
591
+ // Resources
592
+ if (include('resource')) {
593
+ for (const resource of Object.values(model.resources ?? {})) {
594
+ const title = resource.title ?? resource.id
595
+ const description = resource.description ?? ''
596
+ entries.push({
597
+ kind: 'resource',
598
+ id: resource.id,
599
+ title,
600
+ summary: description,
601
+ subKind: resource.kind,
602
+ high: [resource.id, title],
603
+ mid: [description],
604
+ meta: [resource.kind, resource.systemPath, resource.status, resource.ownerRoleId ?? ''],
605
+ low: []
606
+ })
607
+ }
608
+ }
609
+
610
+ // Roles
611
+ if (include('role')) {
612
+ for (const role of Object.values(model.roles ?? {})) {
613
+ const responsibilities = role.responsibilities ?? []
614
+ entries.push({
615
+ kind: 'role',
616
+ id: role.id,
617
+ title: role.title,
618
+ summary: responsibilities[0] ?? '',
619
+ high: [role.id, role.title],
620
+ mid: [],
621
+ meta: responsibilities,
622
+ low: []
623
+ })
624
+ }
625
+ }
626
+
627
+ // Policies
628
+ if (include('policy')) {
629
+ for (const policy of Object.values(model.policies ?? {})) {
630
+ const description = policy.description ?? ''
631
+ entries.push({
632
+ kind: 'policy',
633
+ id: policy.id,
634
+ title: policy.label,
635
+ summary: description,
636
+ subKind: policy.trigger?.kind,
637
+ high: [policy.id, policy.label],
638
+ mid: [description],
639
+ meta: [policy.trigger?.kind ?? ''],
640
+ low: []
641
+ })
642
+ }
643
+ }
644
+
645
+ return entries
646
+ }
647
+
648
+ interface OntologyRecordLike {
649
+ id?: string
650
+ label?: string
651
+ description?: string
652
+ }
653
+
654
+ function collectOntologyEntries(scope: {
655
+ objectTypes?: Record<string, OntologyRecordLike>
656
+ linkTypes?: Record<string, OntologyRecordLike>
657
+ actionTypes?: Record<string, OntologyRecordLike>
658
+ catalogTypes?: Record<string, OntologyRecordLike>
659
+ eventTypes?: Record<string, OntologyRecordLike>
660
+ interfaceTypes?: Record<string, OntologyRecordLike>
661
+ valueTypes?: Record<string, OntologyRecordLike>
662
+ sharedProperties?: Record<string, OntologyRecordLike>
663
+ groups?: Record<string, OntologyRecordLike>
664
+ surfaces?: Record<string, OntologyRecordLike>
665
+ }): SearchableEntry[] {
666
+ const entries: SearchableEntry[] = []
667
+
668
+ const buckets: Array<[string, Record<string, OntologyRecordLike> | undefined]> = [
669
+ ['object', scope.objectTypes],
670
+ ['link', scope.linkTypes],
671
+ ['action', scope.actionTypes],
672
+ ['catalog', scope.catalogTypes],
673
+ ['event', scope.eventTypes],
674
+ ['interface', scope.interfaceTypes],
675
+ ['value-type', scope.valueTypes],
676
+ ['property', scope.sharedProperties],
677
+ ['group', scope.groups],
678
+ ['surface', scope.surfaces]
679
+ ]
680
+
681
+ for (const [subKind, records] of buckets) {
682
+ if (!records) continue
683
+ for (const [recordId, record] of Object.entries(records)) {
684
+ const id = record.id ?? recordId
685
+ const title = record.label ?? id
686
+ const description = record.description ?? ''
687
+ entries.push({
688
+ kind: 'ontology',
689
+ id,
690
+ title,
691
+ summary: description,
692
+ subKind,
693
+ high: [id, title],
694
+ mid: [description],
695
+ meta: [subKind],
696
+ low: []
697
+ })
698
+ }
699
+ }
700
+
701
+ return entries
702
+ }
703
+
704
+ // ---------------------------------------------------------------------------
705
+ // omDescribe — neighborhood view for any OM node ID
706
+ // ---------------------------------------------------------------------------
707
+
708
+ /**
709
+ * Structured neighborhood view of a single OM node.
710
+ *
711
+ * The `kind` discriminator selects the shape: `system` carries parent/children
712
+ * and resource summaries; `knowledge` carries title/summary/body and
713
+ * `governs` edges; etc. Use `formatOmDescribe()` to render as text or pass
714
+ * directly to `JSON.stringify` for machine-readable output.
715
+ */
716
+ export type OmDescribeResult =
717
+ | OmDescribeSystem
718
+ | OmDescribeResource
719
+ | OmDescribeKnowledge
720
+ | OmDescribeOntology
721
+ | OmDescribeRole
722
+ | OmDescribePolicy
723
+
724
+ export interface OmDescribeSystem {
725
+ kind: 'system'
726
+ id: string
727
+ label: string
728
+ description: string
729
+ systemKind?: string
730
+ lifecycle?: string
731
+ parentSystemId?: string
732
+ responsibleRoleId?: string
733
+ childSystemPaths: string[]
734
+ governingKnowledgeIds: string[]
735
+ resourceCountsByKind: Record<string, number>
736
+ resourceIdsByKind: Record<string, string[]>
737
+ ontologyCountsByKind: Record<string, number>
738
+ }
739
+
740
+ export interface OmDescribeResource {
741
+ kind: 'resource'
742
+ id: string
743
+ resourceKind: string
744
+ systemPath: string
745
+ title: string
746
+ description: string
747
+ status: string
748
+ ownerRoleId?: string
749
+ ontology?: {
750
+ primaryAction?: string
751
+ actions?: string[]
752
+ reads?: string[]
753
+ writes?: string[]
754
+ emits?: string[]
755
+ usesCatalogs?: string[]
756
+ }
757
+ codeRefPaths: string[]
758
+ }
759
+
760
+ export interface OmDescribeKnowledge {
761
+ kind: 'knowledge'
762
+ id: string
763
+ knowledgeKind: string
764
+ title: string
765
+ summary: string
766
+ bodyExcerpt: string
767
+ bodyLineCount: number
768
+ ownerIds: string[]
769
+ governs: string[]
770
+ updatedAt: string
771
+ }
772
+
773
+ export interface OmDescribeOntology {
774
+ kind: 'ontology'
775
+ id: string
776
+ ontologyKind: string
777
+ scope: string
778
+ localId: string
779
+ label: string
780
+ description: string
781
+ governingKnowledgeIds: string[]
782
+ }
783
+
784
+ export interface OmDescribeRole {
785
+ kind: 'role'
786
+ id: string
787
+ title: string
788
+ responsibilities: string[]
789
+ responsibleFor: string[]
790
+ reportsToId?: string
791
+ }
792
+
793
+ export interface OmDescribePolicy {
794
+ kind: 'policy'
795
+ id: string
796
+ label: string
797
+ description: string
798
+ triggerKind: string
799
+ appliesToSystemIds: string[]
800
+ appliesToActionIds: string[]
801
+ appliesToResourceIds: string[]
802
+ appliesToRoleIds: string[]
803
+ effectKinds: string[]
804
+ }
805
+
806
+ /**
807
+ * Detect the OM surface this ID belongs to. Returns `undefined` when the
808
+ * shape doesn't match any known pattern.
809
+ *
810
+ * Detection rules (highest priority first):
811
+ * - Contains `:<ontology-kind>/<local-id>` → ontology
812
+ * - Starts with `knowledge.` → knowledge
813
+ * - Starts with `role.` → role
814
+ * - Starts with `policy.` → policy
815
+ * - Resolves via `getSystem()` → system
816
+ * - Matches a key in `model.resources` → resource
817
+ */
818
+ function detectKind(model: OrganizationModel, id: string): OmSearchHitKind | undefined {
819
+ if (/:(object|action|event|catalog|link|interface|value-type|property|group|surface)\//.test(id)) {
820
+ return 'ontology'
821
+ }
822
+ if (id.startsWith('knowledge.')) return 'knowledge'
823
+ if (id.startsWith('role.')) return 'role'
824
+ if (id.startsWith('policy.')) return 'policy'
825
+ if (model.systems && getSystemByPath(model.systems, id)) return 'system'
826
+ if (model.resources?.[id]) return 'resource'
827
+ return undefined
828
+ }
829
+
830
+ function getSystemByPath(
831
+ systems: Record<string, { systems?: Record<string, unknown>; subsystems?: Record<string, unknown> }>,
832
+ path: string
833
+ ): unknown {
834
+ const segments = path.split('.')
835
+ let current: Record<string, unknown> = systems
836
+ for (const seg of segments) {
837
+ const node = current[seg]
838
+ if (node === undefined || typeof node !== 'object' || node === null) return undefined
839
+ const next = node as { systems?: Record<string, unknown>; subsystems?: Record<string, unknown> }
840
+ if (seg === segments[segments.length - 1]) return next
841
+ current = (next.systems ?? next.subsystems ?? {}) as Record<string, unknown>
842
+ }
843
+ return undefined
844
+ }
845
+
846
+ /**
847
+ * Build a structured neighborhood view of any OM node.
848
+ *
849
+ * Returns `undefined` when no entity matches the given id. The result is a
850
+ * JSON-safe discriminated union — pass to `formatOmDescribe()` for human
851
+ * output or `JSON.stringify` for machine consumption.
852
+ *
853
+ * @example
854
+ * omDescribe(model, 'sales.lead-gen')
855
+ * // -> { kind: 'system', id: 'sales.lead-gen', resourceCountsByKind: {...}, ... }
856
+ */
857
+ export function omDescribe(model: OrganizationModel, id: string): OmDescribeResult | undefined {
858
+ const kind = detectKind(model, id)
859
+ if (kind === undefined) return undefined
860
+
861
+ switch (kind) {
862
+ case 'system':
863
+ return describeSystem(model, id)
864
+ case 'resource':
865
+ return describeResource(model, id)
866
+ case 'knowledge':
867
+ return describeKnowledge(model, id)
868
+ case 'ontology':
869
+ return describeOntology(model, id)
870
+ case 'role':
871
+ return describeRole(model, id)
872
+ case 'policy':
873
+ return describePolicy(model, id)
874
+ }
875
+ }
876
+
877
+ function describeSystem(model: OrganizationModel, path: string): OmDescribeSystem | undefined {
878
+ const allSystems = listAllSystems(model)
879
+ const match = allSystems.find((s) => s.path === path)
880
+ if (!match) return undefined
881
+ const { system } = match
882
+
883
+ const prefix = path + '.'
884
+ const childSystemPaths = allSystems
885
+ .filter((s) => s.path.startsWith(prefix) && !s.path.slice(prefix.length).includes('.'))
886
+ .map((s) => s.path)
887
+
888
+ const resources = Object.values(model.resources ?? {}).filter((r) => r.systemPath === path)
889
+ const resourceCountsByKind: Record<string, number> = {}
890
+ const resourceIdsByKind: Record<string, string[]> = {}
891
+ for (const r of resources) {
892
+ resourceCountsByKind[r.kind] = (resourceCountsByKind[r.kind] ?? 0) + 1
893
+ if (!resourceIdsByKind[r.kind]) resourceIdsByKind[r.kind] = []
894
+ resourceIdsByKind[r.kind].push(r.id)
895
+ }
896
+
897
+ const governingKnowledgeIds: string[] = []
898
+ for (const node of Object.values(model.knowledge ?? {})) {
899
+ if ((node.links ?? []).some((link) => link.nodeId === `system:${path}`)) {
900
+ governingKnowledgeIds.push(node.id)
901
+ }
902
+ }
903
+
904
+ const ontologyCountsByKind: Record<string, number> = {}
905
+ const ontology = system.ontology
906
+ if (ontology) {
907
+ const buckets: Array<[string, Record<string, unknown> | undefined]> = [
908
+ ['object', ontology.objectTypes],
909
+ ['action', ontology.actionTypes],
910
+ ['event', ontology.eventTypes],
911
+ ['catalog', ontology.catalogTypes],
912
+ ['link', ontology.linkTypes],
913
+ ['interface', ontology.interfaceTypes],
914
+ ['value-type', ontology.valueTypes],
915
+ ['property', ontology.sharedProperties],
916
+ ['group', ontology.groups],
917
+ ['surface', ontology.surfaces]
918
+ ]
919
+ for (const [k, records] of buckets) {
920
+ const count = records ? Object.keys(records).length : 0
921
+ if (count > 0) ontologyCountsByKind[k] = count
922
+ }
923
+ }
924
+
925
+ return {
926
+ kind: 'system',
927
+ id: path,
928
+ label: system.label ?? system.title ?? path,
929
+ description: system.description ?? '',
930
+ systemKind: system.kind,
931
+ lifecycle: system.lifecycle,
932
+ parentSystemId: system.parentSystemId,
933
+ responsibleRoleId: system.responsibleRoleId,
934
+ childSystemPaths,
935
+ governingKnowledgeIds,
936
+ resourceCountsByKind,
937
+ resourceIdsByKind,
938
+ ontologyCountsByKind
939
+ }
940
+ }
941
+
942
+ function describeResource(model: OrganizationModel, id: string): OmDescribeResource | undefined {
943
+ const r = model.resources?.[id]
944
+ if (!r) return undefined
945
+ return {
946
+ kind: 'resource',
947
+ id: r.id,
948
+ resourceKind: r.kind,
949
+ systemPath: r.systemPath,
950
+ title: r.title ?? r.id,
951
+ description: r.description ?? '',
952
+ status: r.status,
953
+ ownerRoleId: r.ownerRoleId,
954
+ ontology: r.ontology
955
+ ? {
956
+ primaryAction: r.ontology.primaryAction,
957
+ actions: r.ontology.actions,
958
+ reads: r.ontology.reads,
959
+ writes: r.ontology.writes,
960
+ emits: r.ontology.emits,
961
+ usesCatalogs: r.ontology.usesCatalogs
962
+ }
963
+ : undefined,
964
+ codeRefPaths: (r.codeRefs ?? []).map((c) => c.path)
965
+ }
966
+ }
967
+
968
+ function describeKnowledge(model: OrganizationModel, id: string): OmDescribeKnowledge | undefined {
969
+ const node = model.knowledge?.[id]
970
+ if (!node) return undefined
971
+ const bodyLines = node.body.split('\n')
972
+ const bodyExcerpt = bodyLines.slice(0, 6).join('\n') + (bodyLines.length > 6 ? '\n...' : '')
973
+ return {
974
+ kind: 'knowledge',
975
+ id: node.id,
976
+ knowledgeKind: node.kind,
977
+ title: node.title,
978
+ summary: node.summary,
979
+ bodyExcerpt,
980
+ bodyLineCount: bodyLines.length,
981
+ ownerIds: node.ownerIds ?? [],
982
+ governs: (node.links ?? []).map((link) => link.nodeId),
983
+ updatedAt: node.updatedAt
984
+ }
985
+ }
986
+
987
+ function describeOntology(model: OrganizationModel, id: string): OmDescribeOntology | undefined {
988
+ const parsed = id.match(/^([^:]+):([a-z-]+)\/(.+)$/)
989
+ if (!parsed) return undefined
990
+ const [, scope, ontologyKind, localId] = parsed
991
+
992
+ // Walk all systems + top-level ontology to find the record.
993
+ const buckets: Array<[string, string]> = [
994
+ ['object', 'objectTypes'],
995
+ ['action', 'actionTypes'],
996
+ ['event', 'eventTypes'],
997
+ ['catalog', 'catalogTypes'],
998
+ ['link', 'linkTypes'],
999
+ ['interface', 'interfaceTypes'],
1000
+ ['value-type', 'valueTypes'],
1001
+ ['property', 'sharedProperties'],
1002
+ ['group', 'groups'],
1003
+ ['surface', 'surfaces']
1004
+ ]
1005
+ const bucketKey = buckets.find((b) => b[0] === ontologyKind)?.[1]
1006
+ if (!bucketKey) return undefined
1007
+
1008
+ type OntologyRecord = { id?: string; label?: string; description?: string }
1009
+ type OntologyScopeShape = Record<string, Record<string, OntologyRecord> | undefined>
1010
+
1011
+ function findIn(scopeObj: OntologyScopeShape | undefined): OntologyRecord | undefined {
1012
+ if (!scopeObj) return undefined
1013
+ const records = scopeObj[bucketKey!]
1014
+ return records?.[id]
1015
+ }
1016
+
1017
+ let record: OntologyRecord | undefined
1018
+ // Search per-system scopes first, then global
1019
+ for (const { system } of listAllSystems(model)) {
1020
+ record = findIn(system.ontology as OntologyScopeShape | undefined)
1021
+ if (record) break
1022
+ }
1023
+ if (!record) record = findIn(model.ontology as OntologyScopeShape | undefined)
1024
+ if (!record) return undefined
1025
+
1026
+ const governingKnowledgeIds: string[] = []
1027
+ for (const node of Object.values(model.knowledge ?? {})) {
1028
+ if ((node.links ?? []).some((link) => link.nodeId === `ontology:${id}`)) {
1029
+ governingKnowledgeIds.push(node.id)
1030
+ }
1031
+ }
1032
+
1033
+ return {
1034
+ kind: 'ontology',
1035
+ id,
1036
+ ontologyKind,
1037
+ scope,
1038
+ localId,
1039
+ label: record.label ?? id,
1040
+ description: record.description ?? '',
1041
+ governingKnowledgeIds
1042
+ }
1043
+ }
1044
+
1045
+ function describeRole(model: OrganizationModel, id: string): OmDescribeRole | undefined {
1046
+ const role = model.roles?.[id]
1047
+ if (!role) return undefined
1048
+ return {
1049
+ kind: 'role',
1050
+ id: role.id,
1051
+ title: role.title,
1052
+ responsibilities: role.responsibilities ?? [],
1053
+ responsibleFor: role.responsibleFor ?? [],
1054
+ reportsToId: role.reportsToId
1055
+ }
1056
+ }
1057
+
1058
+ function describePolicy(model: OrganizationModel, id: string): OmDescribePolicy | undefined {
1059
+ const policy = model.policies?.[id]
1060
+ if (!policy) return undefined
1061
+ return {
1062
+ kind: 'policy',
1063
+ id: policy.id,
1064
+ label: policy.label,
1065
+ description: policy.description ?? '',
1066
+ triggerKind: policy.trigger?.kind ?? 'unknown',
1067
+ appliesToSystemIds: policy.appliesTo?.systemIds ?? [],
1068
+ appliesToActionIds: policy.appliesTo?.actionIds ?? [],
1069
+ appliesToResourceIds: policy.appliesTo?.resourceIds ?? [],
1070
+ appliesToRoleIds: policy.appliesTo?.roleIds ?? [],
1071
+ effectKinds: (policy.actions ?? []).map((a) => a.kind)
1072
+ }
1073
+ }