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