@hippo-digital/hippocampus 1.0.0-rc.1

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 (116) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/LICENSE +21 -0
  3. package/README.md +153 -0
  4. package/assets/dist/hippocampus.css +1 -0
  5. package/assets/hippocampus.scss +896 -0
  6. package/assets/javascript/blueprint.js +123 -0
  7. package/bin/hippocampus.js +106 -0
  8. package/docs/agent-roadmap.md +217 -0
  9. package/docs/agent-workflow.md +56 -0
  10. package/docs/agentic-patterns-to-port.md +273 -0
  11. package/docs/colour-pairings.md +41 -0
  12. package/docs/components.md +52 -0
  13. package/docs/copilot-ncrs-research-curator-agent.md +298 -0
  14. package/docs/custom-agents-usage.md +95 -0
  15. package/docs/design-actions-and-decisions.md +95 -0
  16. package/docs/gp-connect-real-data-to-production.md +66 -0
  17. package/docs/hippocampus-for-designers-and-researchers.md +261 -0
  18. package/docs/hippocampus-technical.md +373 -0
  19. package/docs/prompt-architecture-guardrails.md +39 -0
  20. package/docs/repeatable-research-round-ingestion.md +239 -0
  21. package/docs/research-knowledge-graph.md +552 -0
  22. package/docs/research-source-file-conventions.md +121 -0
  23. package/docs/source-model.md +252 -0
  24. package/index.js +47 -0
  25. package/lib/config.js +46 -0
  26. package/lib/context.js +59 -0
  27. package/lib/create-router.js +187 -0
  28. package/lib/discover-projects.js +45 -0
  29. package/lib/docs.js +72 -0
  30. package/lib/host-edit.js +151 -0
  31. package/lib/lint-knowledge.js +133 -0
  32. package/lib/load-knowledge.js +629 -0
  33. package/lib/package-paths.js +32 -0
  34. package/lib/paths.js +33 -0
  35. package/lib/resolve-root.js +84 -0
  36. package/lib/schema-version.js +96 -0
  37. package/lib/schema.js +346 -0
  38. package/package.json +70 -0
  39. package/scripts/audit-provenance.js +147 -0
  40. package/scripts/doctor.js +114 -0
  41. package/scripts/eject.js +51 -0
  42. package/scripts/import-blueprint-spreadsheet.js +242 -0
  43. package/scripts/import-design-actions.js +230 -0
  44. package/scripts/import-project-context.js +427 -0
  45. package/scripts/import-research-batch.js +160 -0
  46. package/scripts/import-research-deck.js +448 -0
  47. package/scripts/index-source-artefacts.js +261 -0
  48. package/scripts/init.js +232 -0
  49. package/scripts/install-skills.js +160 -0
  50. package/scripts/lib/artefact-text.js +347 -0
  51. package/scripts/lib/xlsx-grid.js +114 -0
  52. package/scripts/link-insights-to-needs.js +209 -0
  53. package/scripts/migrate-knowledge.js +73 -0
  54. package/scripts/promote-project-context-draft.js +125 -0
  55. package/scripts/promote-research-draft.js +236 -0
  56. package/scripts/smoke-routes.js +157 -0
  57. package/scripts/sync-project-context.js +236 -0
  58. package/scripts/sync-research-artefacts.js +294 -0
  59. package/scripts/triage-research-corpus.js +274 -0
  60. package/scripts/validate-knowledge.js +49 -0
  61. package/scripts/validate-skills.js +247 -0
  62. package/skills/COVERAGE-MATRIX.md +39 -0
  63. package/skills/README.md +77 -0
  64. package/skills/SKILL-FORMAT.md +100 -0
  65. package/skills/add-prototype-screen.skill.md +54 -0
  66. package/skills/add-scenario.skill.md +47 -0
  67. package/skills/add-user-needs.skill.md +51 -0
  68. package/skills/audit-knowledge-source.skill.md +47 -0
  69. package/skills/capture-route-review-pack.skill.md +52 -0
  70. package/skills/create-journey-from-scenario.skill.md +50 -0
  71. package/skills/deliver-service-slice.skill.md +65 -0
  72. package/skills/examples/README.md +15 -0
  73. package/skills/examples/non-copilot-research-ingestion.md +68 -0
  74. package/skills/examples/non-copilot-service-slice.md +64 -0
  75. package/skills/generate-service-slice.skill.md +57 -0
  76. package/skills/ingest-project-context.skill.md +58 -0
  77. package/skills/ingest-research-round.skill.md +60 -0
  78. package/skills/map-research-to-graph.skill.md +58 -0
  79. package/skills/record-accessibility-review.skill.md +52 -0
  80. package/skills/record-design-decision.skill.md +53 -0
  81. package/skills/review-research-import-draft.skill.md +45 -0
  82. package/skills/skills.json +464 -0
  83. package/skills/skills.schema.json +111 -0
  84. package/skills/structure-project-context-draft.skill.md +51 -0
  85. package/skills/structure-research-draft.skill.md +51 -0
  86. package/skills/triage-research-corpus.skill.md +51 -0
  87. package/skills/write-delivery-summary.skill.md +51 -0
  88. package/skills/write-route-review-summary.skill.md +55 -0
  89. package/views/hippocampus/_layout.html +45 -0
  90. package/views/hippocampus/blueprint-classic.html +39 -0
  91. package/views/hippocampus/blueprint.html +52 -0
  92. package/views/hippocampus/components/_blueprint-cell.njk +42 -0
  93. package/views/hippocampus/components/_blueprint-controls.njk +20 -0
  94. package/views/hippocampus/components/_blueprint-minimap.njk +14 -0
  95. package/views/hippocampus/components/_empty-state.njk +12 -0
  96. package/views/hippocampus/components/_journey-step.njk +21 -0
  97. package/views/hippocampus/components/_list.njk +23 -0
  98. package/views/hippocampus/components/_summary-card.njk +15 -0
  99. package/views/hippocampus/doc.html +11 -0
  100. package/views/hippocampus/docs.html +19 -0
  101. package/views/hippocampus/evidence-impact.html +53 -0
  102. package/views/hippocampus/graph.html +48 -0
  103. package/views/hippocampus/handover-empty.html +12 -0
  104. package/views/hippocampus/handover.html +56 -0
  105. package/views/hippocampus/index.html +46 -0
  106. package/views/hippocampus/journey-flow.html +31 -0
  107. package/views/hippocampus/journey.html +23 -0
  108. package/views/hippocampus/not-found.html +10 -0
  109. package/views/hippocampus/project.html +102 -0
  110. package/views/hippocampus/research.html +68 -0
  111. package/views/hippocampus/route-review.html +83 -0
  112. package/views/hippocampus/scenarios.html +56 -0
  113. package/views/hippocampus/trace.html +113 -0
  114. package/views/hippocampus/user-needs.html +32 -0
  115. package/views/hippocampus/user.html +157 -0
  116. package/views/hippocampus/users.html +32 -0
@@ -0,0 +1,629 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+ const { KnowledgeSchema } = require('./schema')
4
+ const { readMeta, migrateInMemory } = require('./schema-version')
5
+
6
+ const files = {
7
+ project: 'project.json',
8
+ projectContext: 'project-context.json',
9
+ users: 'users.json',
10
+ needs: 'needs.json',
11
+ scenarios: 'scenarios.json',
12
+ journeys: 'journeys.json',
13
+ screens: 'screens.json',
14
+ evidence: 'evidence.json',
15
+ decisions: 'decisions.json',
16
+ assumptions: 'assumptions.json',
17
+ researchRounds: 'research-rounds.json',
18
+ participants: 'participants.json',
19
+ insights: 'insights.json',
20
+ designActions: 'design-actions.json',
21
+ relationships: 'relationships.json'
22
+ }
23
+
24
+ function createKnowledgeStore (paths) {
25
+ function readJson (filename) {
26
+ return JSON.parse(fs.readFileSync(path.join(paths.knowledgeSourceDir, filename), 'utf8'))
27
+ }
28
+
29
+ // A collection file that does not exist yet is an empty collection, not a
30
+ // crash. A freshly initialised project has project.json and little else, and
31
+ // every command it might run should still work.
32
+ function readCollection (filename) {
33
+ const filePath = path.join(paths.knowledgeSourceDir, filename)
34
+ if (!fs.existsSync(filePath)) return []
35
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'))
36
+ }
37
+
38
+ // A project that has been initialised but never had its context imported gets a
39
+ // stub derived from project.json rather than a missing-file error. The viewer
40
+ // then shows an almost-empty context page, which is the truth.
41
+ function readProjectContext (filename, project) {
42
+ const filePath = path.join(paths.knowledgeSourceDir, filename)
43
+ if (fs.existsSync(filePath)) return JSON.parse(fs.readFileSync(filePath, 'utf8'))
44
+ return {
45
+ id: `${project.id}-context`,
46
+ title: project.name,
47
+ summary: project.description
48
+ }
49
+ }
50
+
51
+ function loadKnowledgeData () {
52
+ const knowledge = {}
53
+ for (const [key, filename] of Object.entries(files)) {
54
+ if (key === 'project') knowledge[key] = readJson(filename)
55
+ else if (key === 'projectContext') knowledge[key] = readProjectContext(filename, knowledge.project)
56
+ else knowledge[key] = readCollection(filename)
57
+ }
58
+ return knowledge
59
+ }
60
+
61
+ function getKnowledge () {
62
+ const meta = readMeta(paths.knowledgeSourceDir)
63
+ const { data, version, warnings } = migrateInMemory(loadKnowledgeData(), meta)
64
+ const knowledge = KnowledgeSchema.parse(data)
65
+ knowledge.meta = { schemaVersion: version, implicit: meta.implicit, warnings }
66
+ knowledge.index = buildIndex(knowledge)
67
+ return knowledge
68
+ }
69
+
70
+ function buildIndex (knowledge) {
71
+ const index = {
72
+ users: byId(knowledge.users),
73
+ needs: byId(knowledge.needs),
74
+ scenarios: byId(knowledge.scenarios),
75
+ journeys: byId(knowledge.journeys),
76
+ screens: byId(knowledge.screens),
77
+ evidence: byId(knowledge.evidence),
78
+ decisions: byId(knowledge.decisions),
79
+ assumptions: byId(knowledge.assumptions),
80
+ researchRounds: byId(knowledge.researchRounds),
81
+ participants: byId(knowledge.participants),
82
+ insights: byId(knowledge.insights),
83
+ designActions: byId(knowledge.designActions),
84
+ relationships: byId(knowledge.relationships)
85
+ }
86
+ index.entities = buildEntityIndex(knowledge)
87
+ return index
88
+ }
89
+
90
+ function byId (items) {
91
+ return Object.fromEntries(items.map((item) => [item.id, item]))
92
+ }
93
+
94
+ function buildEntityIndex (knowledge) {
95
+ const entityCollections = {
96
+ users: { type: 'User', labelKey: 'label' },
97
+ needs: { type: 'Need', labelKey: 'statement' },
98
+ scenarios: { type: 'Scenario', labelKey: 'title' },
99
+ journeys: { type: 'Journey', labelKey: 'title' },
100
+ screens: { type: 'Screen', labelKey: 'title' },
101
+ evidence: { type: 'Evidence', labelKey: 'title' },
102
+ decisions: { type: 'Decision', labelKey: 'title' },
103
+ assumptions: { type: 'Assumption', labelKey: 'statement' },
104
+ researchRounds: { type: 'Research round', labelKey: 'title' },
105
+ participants: { type: 'Participant', labelKey: 'label' },
106
+ insights: { type: 'Insight', labelKey: 'title' },
107
+ designActions: { type: 'Design action', labelKey: 'title' }
108
+ }
109
+
110
+ const entities = {}
111
+ for (const [collection, config] of Object.entries(entityCollections)) {
112
+ for (const item of knowledge[collection] || []) {
113
+ entities[item.id] = {
114
+ id: item.id,
115
+ type: config.type,
116
+ label: item[config.labelKey],
117
+ collection,
118
+ item
119
+ }
120
+ }
121
+ }
122
+ return entities
123
+ }
124
+
125
+ function getJourney (journeyId) {
126
+ const knowledge = getKnowledge()
127
+ return knowledge.index.journeys[journeyId]
128
+ }
129
+
130
+ function getUserKnowledgeGraph (userId) {
131
+ const knowledge = getKnowledge()
132
+ const user = knowledge.index.users[userId]
133
+ if (!user) return null
134
+
135
+ const needs = knowledge.needs.filter((need) => need.userId === user.id)
136
+ const scenarios = knowledge.scenarios.filter((scenario) => scenario.userId === user.id)
137
+ const journeys = knowledge.journeys.filter((journey) => {
138
+ const scenario = knowledge.index.scenarios[journey.scenarioId]
139
+ return scenario && scenario.userId === user.id
140
+ })
141
+
142
+ const needIds = new Set(needs.map((need) => need.id))
143
+ const evidenceIds = new Set(needs.flatMap((need) => need.evidenceIds || []))
144
+ const screenIds = new Set()
145
+ const decisionIds = new Set()
146
+
147
+ for (const journey of journeys) {
148
+ for (const step of journey.steps || []) {
149
+ for (const needId of step.needIds || []) needIds.add(needId)
150
+ for (const evidenceId of step.evidenceIds || []) evidenceIds.add(evidenceId)
151
+ for (const screenId of step.screenIds || []) screenIds.add(screenId)
152
+ for (const decisionId of step.decisionIds || []) decisionIds.add(decisionId)
153
+ }
154
+ }
155
+
156
+ for (const scenario of scenarios) {
157
+ for (const needId of scenario.needIds || []) needIds.add(needId)
158
+ }
159
+
160
+ for (const needId of needIds) {
161
+ const need = knowledge.index.needs[needId]
162
+ if (!need) continue
163
+ for (const evidenceId of need.evidenceIds || []) evidenceIds.add(evidenceId)
164
+ }
165
+
166
+ const linkedNeeds = sortByLabel([...needIds].map((id) => knowledge.index.needs[id]).filter(Boolean), 'statement')
167
+ const linkedEvidence = sortByLabel([...evidenceIds].map((id) => knowledge.index.evidence[id]).filter(Boolean), 'title')
168
+ const screens = sortByLabel([...screenIds].map((id) => knowledge.index.screens[id]).filter(Boolean), 'title')
169
+ const decisions = sortByLabel([...decisionIds].map((id) => knowledge.index.decisions[id]).filter(Boolean), 'title')
170
+
171
+ return {
172
+ user,
173
+ scenarios,
174
+ needs: linkedNeeds,
175
+ evidence: linkedEvidence,
176
+ journeys,
177
+ screens,
178
+ decisions,
179
+ assumptions: knowledge.assumptions,
180
+ graph: buildUserGraph({ user, scenarios, needs: linkedNeeds, evidence: linkedEvidence, journeys, screens, decisions })
181
+ }
182
+ }
183
+
184
+ function getGraphExplorer () {
185
+ const knowledge = getKnowledge()
186
+ const relationships = enrichRelationships(knowledge, knowledge.relationships)
187
+ const entityIds = new Set(relationships.flatMap((relationship) => [relationship.from, relationship.to]))
188
+ const nodes = [...entityIds].map((id) => knowledge.index.entities[id]).filter(Boolean)
189
+ const relationshipTypes = [...new Set(relationships.map((relationship) => relationship.type))].sort()
190
+
191
+ return {
192
+ nodes: sortByLabel(nodes, 'label'),
193
+ relationships,
194
+ relationshipTypes
195
+ }
196
+ }
197
+
198
+ function getTraceExplorer (entityType, entityId) {
199
+ const knowledge = getKnowledge()
200
+ const relationships = enrichRelationships(knowledge, knowledge.relationships)
201
+ const nodes = sortByTypeThenLabel(Object.values(knowledge.index.entities))
202
+ const entityTypes = [...new Set(nodes.map((node) => node.type))].sort()
203
+ const filteredNodes = entityType ? nodes.filter((node) => node.type === entityType) : []
204
+ const selectedCandidate = entityId ? knowledge.index.entities[entityId] : null
205
+ const selected = selectedCandidate && (!entityType || selectedCandidate.type === entityType) ? selectedCandidate : null
206
+
207
+ if (!selected) {
208
+ return {
209
+ nodes,
210
+ entityTypes,
211
+ entityType,
212
+ filteredNodes,
213
+ selected: null,
214
+ upstream: [],
215
+ downstream: []
216
+ }
217
+ }
218
+
219
+ return {
220
+ nodes,
221
+ entityTypes,
222
+ entityType: selected.type,
223
+ filteredNodes: nodes.filter((node) => node.type === selected.type),
224
+ selected: decorateTraceEntity(selected),
225
+ upstream: buildTraceBranches(knowledge, relationships, selected.id, 'upstream'),
226
+ downstream: buildTraceBranches(knowledge, relationships, selected.id, 'downstream')
227
+ }
228
+ }
229
+
230
+ function getEvidenceImpact (evidenceId) {
231
+ const knowledge = getKnowledge()
232
+ const evidence = knowledge.index.evidence[evidenceId]
233
+ if (!evidence) return null
234
+
235
+ const relationships = enrichRelationships(knowledge, knowledge.relationships)
236
+ const dependents = findDependents(evidenceId, relationships)
237
+ const impactedEntities = [...dependents].map((id) => knowledge.index.entities[id]).filter(Boolean)
238
+
239
+ return {
240
+ evidence,
241
+ directRelationships: relationships.filter((relationship) => relationship.from === evidenceId || relationship.to === evidenceId),
242
+ impactedEntities: sortByTypeThenLabel(impactedEntities),
243
+ relationships: relationships.filter((relationship) => dependents.has(relationship.from) || dependents.has(relationship.to))
244
+ }
245
+ }
246
+
247
+ function getResearchViewModel () {
248
+ const knowledge = getKnowledge()
249
+
250
+ return {
251
+ rounds: knowledge.researchRounds.map((round) => ({
252
+ ...round,
253
+ participants: knowledge.participants.filter((participant) => participant.roundIds.includes(round.id)),
254
+ insights: knowledge.insights.filter((insight) => insight.roundId === round.id),
255
+ designActions: knowledge.designActions.filter((action) => {
256
+ const roundInsightIds = new Set(knowledge.insights.filter((insight) => insight.roundId === round.id).map((insight) => insight.id))
257
+ return action.insightIds.some((insightId) => roundInsightIds.has(insightId))
258
+ })
259
+ })),
260
+ participants: knowledge.participants,
261
+ insights: knowledge.insights,
262
+ designActions: knowledge.designActions
263
+ }
264
+ }
265
+
266
+ function enrichRelationships (knowledge, relationships) {
267
+ return relationships
268
+ .map((relationship) => {
269
+ const fromEntity = knowledge.index.entities[relationship.from]
270
+ const toEntity = knowledge.index.entities[relationship.to]
271
+ if (!fromEntity || !toEntity) return null
272
+ return {
273
+ ...relationship,
274
+ label: relationshipLabel(relationship.type),
275
+ fromLabel: fromEntity.label,
276
+ fromType: fromEntity.type,
277
+ toLabel: toEntity.label,
278
+ toType: toEntity.type
279
+ }
280
+ })
281
+ .filter(Boolean)
282
+ }
283
+
284
+ function findDependents (entityId, relationships) {
285
+ const dependents = new Set()
286
+ const queue = [entityId]
287
+
288
+ while (queue.length) {
289
+ const current = queue.shift()
290
+ for (const relationship of relationships) {
291
+ if (relationship.to !== current || dependents.has(relationship.from)) continue
292
+ dependents.add(relationship.from)
293
+ queue.push(relationship.from)
294
+ }
295
+ }
296
+
297
+ return dependents
298
+ }
299
+
300
+ function buildTraceBranches (knowledge, relationships, entityId, direction, visited = new Set([entityId]), depth = 0) {
301
+ if (depth >= 6) return []
302
+
303
+ const relevantRelationships = relationships.filter((relationship) => {
304
+ if (direction === 'upstream') return relationship.to === entityId
305
+ return relationship.from === entityId
306
+ })
307
+
308
+ return relevantRelationships.map((relationship) => {
309
+ const nextId = direction === 'upstream' ? relationship.from : relationship.to
310
+ const entity = knowledge.index.entities[nextId]
311
+ const seen = visited.has(nextId)
312
+ const nextVisited = new Set(visited)
313
+ nextVisited.add(nextId)
314
+
315
+ return {
316
+ relationship,
317
+ entity: decorateTraceEntity(entity),
318
+ seen,
319
+ children: seen ? [] : buildTraceBranches(knowledge, relationships, nextId, direction, nextVisited, depth + 1)
320
+ }
321
+ }).filter((branch) => branch.entity).sort(sortTraceBranches)
322
+ }
323
+
324
+ function decorateTraceEntity (entity) {
325
+ return {
326
+ ...entity,
327
+ date: getEntityDate(entity),
328
+ typeSlug: entity.type.toLowerCase().replace(/\s+/g, '-')
329
+ }
330
+ }
331
+
332
+ function getEntityDate (entity) {
333
+ const item = entity.item || {}
334
+ return item.date || item.createdAt || item.updatedAt || item.importedAt || item.promotedAt || null
335
+ }
336
+
337
+ function sortTraceBranches (a, b) {
338
+ return `${a.entity.date || '9999'}:${a.entity.label}`.localeCompare(`${b.entity.date || '9999'}:${b.entity.label}`)
339
+ }
340
+
341
+ function buildUserGraph ({ user, scenarios, needs, evidence, journeys, screens, decisions }) {
342
+ const nodes = [
343
+ { id: user.id, label: user.label, type: 'User' },
344
+ ...scenarios.map((scenario) => ({ id: scenario.id, label: scenario.title, type: 'Scenario' })),
345
+ ...needs.map((need) => ({ id: need.id, label: need.statement, type: 'Need' })),
346
+ ...evidence.map((item) => ({ id: item.id, label: item.title, type: 'Evidence' })),
347
+ ...journeys.map((journey) => ({ id: journey.id, label: journey.title, type: 'Journey' })),
348
+ ...screens.map((screen) => ({ id: screen.id, label: screen.title, type: 'Screen' })),
349
+ ...decisions.map((decision) => ({ id: decision.id, label: decision.title, type: 'Decision' }))
350
+ ]
351
+
352
+ const edges = []
353
+ for (const scenario of scenarios) {
354
+ edges.push({ from: user.id, to: scenario.id, label: 'has scenario' })
355
+ for (const needId of scenario.needIds || []) edges.push({ from: scenario.id, to: needId, label: 'tests need' })
356
+ }
357
+
358
+ for (const need of needs) {
359
+ edges.push({ from: user.id, to: need.id, label: 'has need' })
360
+ for (const evidenceId of need.evidenceIds || []) edges.push({ from: need.id, to: evidenceId, label: 'supported by' })
361
+ }
362
+
363
+ for (const journey of journeys) {
364
+ edges.push({ from: knowledgeScenarioId(journey), to: journey.id, label: 'drives journey' })
365
+ for (const step of journey.steps || []) {
366
+ for (const needId of step.needIds || []) edges.push({ from: journey.id, to: needId, label: 'covers' })
367
+ for (const screenId of step.screenIds || []) edges.push({ from: journey.id, to: screenId, label: 'renders screen' })
368
+ for (const evidenceId of step.evidenceIds || []) edges.push({ from: journey.id, to: evidenceId, label: 'uses evidence' })
369
+ for (const decisionId of step.decisionIds || []) edges.push({ from: journey.id, to: decisionId, label: 'records decision' })
370
+ }
371
+ }
372
+
373
+ const graphNodes = uniqueById(nodes)
374
+ const nodeLabels = Object.fromEntries(graphNodes.map((node) => [node.id, node.label]))
375
+
376
+ return {
377
+ nodes: graphNodes,
378
+ edges: uniqueEdges(edges)
379
+ .filter((edge) => nodeLabels[edge.from] && nodeLabels[edge.to])
380
+ .map((edge) => ({
381
+ ...edge,
382
+ fromLabel: nodeLabels[edge.from],
383
+ toLabel: nodeLabels[edge.to]
384
+ }))
385
+ }
386
+ }
387
+
388
+ function knowledgeScenarioId (journey) {
389
+ return journey.scenarioId
390
+ }
391
+
392
+ function buildBlueprintViewModel (journeyId) {
393
+ const knowledge = getKnowledge()
394
+ const journey = knowledge.index.journeys[journeyId]
395
+ if (!journey) return null
396
+
397
+ const scenario = knowledge.index.scenarios[journey.scenarioId]
398
+ const steps = journey.steps.map((step) => ({
399
+ id: step.id,
400
+ label: step.label,
401
+ channel: step.channel,
402
+ userAction: step.userAction,
403
+ frontstage: step.frontstage,
404
+ backstage: step.backstage,
405
+ supportProcesses: step.supportProcesses,
406
+ dataDependencies: step.dataDependencies,
407
+ securityAndGovernance: step.securityAndGovernance,
408
+ painPoints: step.painPoints,
409
+ exceptionHandling: step.exceptionHandling,
410
+ futureState: step.futureState,
411
+ screens: (step.screenIds || []).map((id) => knowledge.index.screens[id]).filter(Boolean),
412
+ evidence: (step.evidenceIds || []).map((id) => knowledge.index.evidence[id]).filter(Boolean),
413
+ decisions: (step.decisionIds || []).map((id) => knowledge.index.decisions[id]).filter(Boolean),
414
+ userNeeds: (step.needIds || []).map((id) => knowledge.index.needs[id]).filter(Boolean)
415
+ }))
416
+
417
+ return {
418
+ meta: {
419
+ serviceTitle: knowledge.project.name,
420
+ serviceDescription: knowledge.project.description,
421
+ journeyTitle: journey.title,
422
+ scenarioTitle: scenario ? scenario.title : ''
423
+ },
424
+ journey,
425
+ scenario,
426
+ steps,
427
+ lanes: [
428
+ { key: 'screens', label: 'Prototype screens', boundaryAfter: false, lenses: ['executive'] },
429
+ { key: 'userAction', label: 'User action', boundaryAfter: false, lenses: ['executive', 'technical'] },
430
+ { key: 'frontstage', label: 'Frontstage', boundaryAfter: 'Line of visibility', lenses: ['executive', 'technical'] },
431
+ { key: 'backstage', label: 'Backstage', boundaryAfter: false, lenses: ['technical'] },
432
+ { key: 'supportProcesses', label: 'Support processes', boundaryAfter: 'Line of internal interaction', lenses: ['technical'] },
433
+ { key: 'dataDependencies', label: 'Data dependencies', boundaryAfter: false, lenses: ['technical', 'risk'] },
434
+ { key: 'securityAndGovernance', label: 'Security and governance', boundaryAfter: 'Governance boundary', lenses: ['risk'] },
435
+ { key: 'painPoints', label: 'Pain points', boundaryAfter: false, lenses: ['executive', 'risk'] },
436
+ { key: 'exceptionHandling', label: 'Exceptions', boundaryAfter: false, lenses: ['risk'] },
437
+ { key: 'futureState', label: 'Future state / proposed changes', boundaryAfter: false, lenses: ['executive'] },
438
+ { key: 'evidence', label: 'Evidence and decisions', boundaryAfter: false, lenses: ['executive'] }
439
+ ]
440
+ }
441
+ }
442
+
443
+ // An honest "we don't know" placeholder used when promoting the NHS Notify
444
+ // blueprint (see decision-promote-representative-subset) - not a real
445
+ // exception, so it shouldn't render as a branching path in the flow diagram.
446
+ const NO_EXCEPTION_CAPTURED_TEXT = 'No exception path was captured for this step in the source blueprint'
447
+
448
+ function buildJourneyFlowDiagram (journeyId) {
449
+ const knowledge = getKnowledge()
450
+ const journey = knowledge.index.journeys[journeyId]
451
+ if (!journey) return null
452
+
453
+ const stepNodeId = (step) => `step_${mermaidId(step.id)}`
454
+ const lines = ['flowchart TD']
455
+
456
+ journey.steps.forEach((step) => {
457
+ lines.push(` ${stepNodeId(step)}["${escapeMermaidLabel(step.label)}"]`)
458
+ })
459
+
460
+ for (let index = 0; index < journey.steps.length - 1; index++) {
461
+ lines.push(` ${stepNodeId(journey.steps[index])} --> ${stepNodeId(journey.steps[index + 1])}`)
462
+ }
463
+
464
+ const exceptionNodeIds = []
465
+ journey.steps.forEach((step) => {
466
+ const realExceptions = (step.exceptionHandling || []).filter((text) => text !== NO_EXCEPTION_CAPTURED_TEXT)
467
+ realExceptions.forEach((text, index) => {
468
+ const exceptionId = `exc_${mermaidId(step.id)}_${index}`
469
+ exceptionNodeIds.push(exceptionId)
470
+ lines.push(` ${exceptionId}["${escapeMermaidLabel(truncateForDiagram(text))}"]`)
471
+ lines.push(` ${stepNodeId(step)} -.->|exception| ${exceptionId}`)
472
+ })
473
+ })
474
+
475
+ if (exceptionNodeIds.length) {
476
+ lines.push(' classDef exception fill:#fdf3e6,stroke:#8e5300,color:#8e5300')
477
+ lines.push(` class ${exceptionNodeIds.join(',')} exception`)
478
+ }
479
+
480
+ return {
481
+ meta: { journeyTitle: journey.title },
482
+ journey,
483
+ mermaidSource: lines.join('\n')
484
+ }
485
+ }
486
+
487
+ function mermaidId (value) {
488
+ return value.replace(/[^a-zA-Z0-9]/g, '_')
489
+ }
490
+
491
+ function escapeMermaidLabel (value) {
492
+ return value.replace(/"/g, "'").replace(/\s+/g, ' ').trim()
493
+ }
494
+
495
+ function truncateForDiagram (value, maxLength = 90) {
496
+ if (value.length <= maxLength) return value
497
+ return `${value.slice(0, maxLength - 1).trim()}…`
498
+ }
499
+
500
+ function validateKnowledge () {
501
+ const errors = []
502
+ let knowledge
503
+
504
+ try {
505
+ knowledge = loadKnowledgeData()
506
+ } catch (error) {
507
+ return { valid: false, errors: [`Could not load knowledge data: ${error.message}`] }
508
+ }
509
+
510
+ const schemaResult = KnowledgeSchema.safeParse(knowledge)
511
+ if (!schemaResult.success) {
512
+ return {
513
+ valid: false,
514
+ errors: schemaResult.error.issues.map((issue) => {
515
+ const fieldPath = issue.path.length ? issue.path.join('.') : 'knowledge'
516
+ return `${fieldPath}: ${issue.message}`
517
+ })
518
+ }
519
+ }
520
+
521
+ knowledge = schemaResult.data
522
+ knowledge.index = buildIndex(knowledge)
523
+
524
+ const index = knowledge.index
525
+
526
+ for (const collectionName of Object.keys(index)) {
527
+ const source = knowledge[collectionName]
528
+ if (!Array.isArray(source)) continue
529
+ const ids = new Set()
530
+ for (const item of source) {
531
+ if (!item.id) errors.push(`${collectionName}: item missing id`)
532
+ if (ids.has(item.id)) errors.push(`${collectionName}: duplicate id ${item.id}`)
533
+ ids.add(item.id)
534
+ }
535
+ }
536
+
537
+ for (const need of knowledge.needs) {
538
+ requireRef(errors, 'need', need.id, 'userId', need.userId, index.users)
539
+ for (const evidenceId of need.evidenceIds || []) {
540
+ requireRef(errors, 'need', need.id, 'evidenceIds', evidenceId, index.evidence)
541
+ }
542
+ }
543
+
544
+ for (const scenario of knowledge.scenarios) {
545
+ requireRef(errors, 'scenario', scenario.id, 'userId', scenario.userId, index.users)
546
+ for (const needId of scenario.needIds || []) {
547
+ requireRef(errors, 'scenario', scenario.id, 'needIds', needId, index.needs)
548
+ }
549
+ }
550
+
551
+ for (const journey of knowledge.journeys) {
552
+ requireRef(errors, 'journey', journey.id, 'scenarioId', journey.scenarioId, index.scenarios)
553
+ for (const step of journey.steps || []) {
554
+ for (const needId of step.needIds || []) requireRef(errors, 'journey step', step.id, 'needIds', needId, index.needs)
555
+ for (const screenId of step.screenIds || []) requireRef(errors, 'journey step', step.id, 'screenIds', screenId, index.screens)
556
+ for (const evidenceId of step.evidenceIds || []) requireRef(errors, 'journey step', step.id, 'evidenceIds', evidenceId, index.evidence)
557
+ for (const decisionId of step.decisionIds || []) requireRef(errors, 'journey step', step.id, 'decisionIds', decisionId, index.decisions)
558
+ }
559
+ }
560
+
561
+ for (const screen of knowledge.screens) {
562
+ for (const needId of screen.needIds || []) requireRef(errors, 'screen', screen.id, 'needIds', needId, index.needs)
563
+ }
564
+
565
+ for (const participant of knowledge.participants) {
566
+ for (const roundId of participant.roundIds || []) requireRef(errors, 'participant', participant.id, 'roundIds', roundId, index.researchRounds)
567
+ }
568
+
569
+ for (const insight of knowledge.insights) {
570
+ requireRef(errors, 'insight', insight.id, 'roundId', insight.roundId, index.researchRounds)
571
+ for (const participantId of insight.participantIds || []) requireRef(errors, 'insight', insight.id, 'participantIds', participantId, index.participants)
572
+ for (const evidenceId of insight.evidenceIds || []) requireRef(errors, 'insight', insight.id, 'evidenceIds', evidenceId, index.evidence)
573
+ }
574
+
575
+ for (const action of knowledge.designActions) {
576
+ for (const insightId of action.insightIds || []) requireRef(errors, 'design action', action.id, 'insightIds', insightId, index.insights)
577
+ }
578
+
579
+ for (const relationship of knowledge.relationships) {
580
+ requireRef(errors, 'relationship', relationship.id, 'from', relationship.from, index.entities)
581
+ requireRef(errors, 'relationship', relationship.id, 'to', relationship.to, index.entities)
582
+ }
583
+
584
+ return { valid: errors.length === 0, errors }
585
+ }
586
+
587
+ function requireRef (errors, type, id, field, value, lookup) {
588
+ if (!lookup[value]) errors.push(`${type} ${id}: ${field} references missing id ${value}`)
589
+ }
590
+
591
+ function sortByLabel (items, key) {
592
+ return items.sort((a, b) => a[key].localeCompare(b[key]))
593
+ }
594
+
595
+ function sortByTypeThenLabel (items) {
596
+ return items.sort((a, b) => `${a.type}:${a.label}`.localeCompare(`${b.type}:${b.label}`))
597
+ }
598
+
599
+ function relationshipLabel (type) {
600
+ return type.replace(/-/g, ' ')
601
+ }
602
+
603
+ function uniqueById (items) {
604
+ return Object.values(Object.fromEntries(items.map((item) => [item.id, item])))
605
+ }
606
+
607
+ function uniqueEdges (edges) {
608
+ return Object.values(Object.fromEntries(edges.map((edge) => [`${edge.from}:${edge.label}:${edge.to}`, edge])))
609
+ }
610
+
611
+ return {
612
+ getKnowledge,
613
+ getJourney,
614
+ getUserKnowledgeGraph,
615
+ getGraphExplorer,
616
+ getTraceExplorer,
617
+ getEvidenceImpact,
618
+ getResearchViewModel,
619
+ buildBlueprintViewModel,
620
+ buildJourneyFlowDiagram,
621
+ validateKnowledge
622
+ }
623
+ }
624
+
625
+ // No default store. A store is always built from an explicitly resolved root,
626
+ // so nothing can quietly read the wrong project.
627
+ module.exports = {
628
+ createKnowledgeStore
629
+ }
@@ -0,0 +1,32 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+
4
+ // Paths to files the PACKAGE owns - views, stylesheets, client JS, the scripts
5
+ // one script spawns another by. Deliberately named nothing like the host paths
6
+ // in paths.js: conflating "where the library lives" with "where the project
7
+ // lives" is the bug this whole refactor exists to remove, and two similar names
8
+ // would invite it straight back.
9
+
10
+ function findPackageRoot (from) {
11
+ let dir = path.resolve(from)
12
+ for (;;) {
13
+ if (fs.existsSync(path.join(dir, 'package.json'))) return dir
14
+ const parent = path.dirname(dir)
15
+ if (parent === dir) {
16
+ throw new Error(`Could not find the Hippocampus package root above ${from}`)
17
+ }
18
+ dir = parent
19
+ }
20
+ }
21
+
22
+ const packageRoot = findPackageRoot(__dirname)
23
+
24
+ module.exports = {
25
+ packageRoot,
26
+ viewsDir: path.join(packageRoot, 'views'),
27
+ assetsDir: path.join(packageRoot, 'assets'),
28
+ scriptsDir: path.join(packageRoot, 'scripts'),
29
+ templatesDir: path.join(packageRoot, 'templates'),
30
+ skillsDir: path.join(packageRoot, 'skills'),
31
+ docsDir: path.join(packageRoot, 'docs')
32
+ }
package/lib/paths.js ADDED
@@ -0,0 +1,33 @@
1
+ const path = require('path')
2
+
3
+ // Paths to files the HOST project owns. Every one of them is derived from a
4
+ // root that was resolved explicitly - there is deliberately no default export
5
+ // and no module-level projectRoot, because a baked-in default is silently
6
+ // wrong the moment this library is installed into somebody else's project.
7
+ // Package-owned paths live in package-paths.js under different names.
8
+
9
+ function createPaths ({ root, dataDir = 'hippocampus', artefactsDir = 'artefacts' }) {
10
+ if (!root) throw new Error('createPaths requires a resolved root - see resolve-root.js')
11
+
12
+ const hippocampusDir = path.isAbsolute(dataDir) ? dataDir : path.join(root, dataDir)
13
+ const manifestsDir = path.join(hippocampusDir, 'manifests')
14
+
15
+ return {
16
+ root,
17
+ hippocampusDir,
18
+ artefactsDir: path.isAbsolute(artefactsDir) ? artefactsDir : path.join(root, artefactsDir),
19
+ knowledgeSourceDir: path.join(hippocampusDir, 'source'),
20
+ sourceArtefactsDir: path.join(hippocampusDir, 'source-artefacts'),
21
+ templatesDir: path.join(hippocampusDir, 'templates'),
22
+ projectInboxDir: path.join(hippocampusDir, 'inbox/project'),
23
+ projectImportsDir: path.join(hippocampusDir, 'imports/project'),
24
+ researchInboxDir: path.join(hippocampusDir, 'inbox/research'),
25
+ researchImportsDir: path.join(hippocampusDir, 'imports/research'),
26
+ manifestsDir,
27
+ researchManifestsDir: manifestsDir,
28
+ projectManifestPath: path.join(manifestsDir, 'project.json'),
29
+ researchManifestPath: path.join(manifestsDir, 'research.json')
30
+ }
31
+ }
32
+
33
+ module.exports = { createPaths }