@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.
- package/CHANGELOG.md +55 -0
- package/LICENSE +21 -0
- package/README.md +153 -0
- package/assets/dist/hippocampus.css +1 -0
- package/assets/hippocampus.scss +896 -0
- package/assets/javascript/blueprint.js +123 -0
- package/bin/hippocampus.js +106 -0
- package/docs/agent-roadmap.md +217 -0
- package/docs/agent-workflow.md +56 -0
- package/docs/agentic-patterns-to-port.md +273 -0
- package/docs/colour-pairings.md +41 -0
- package/docs/components.md +52 -0
- package/docs/copilot-ncrs-research-curator-agent.md +298 -0
- package/docs/custom-agents-usage.md +95 -0
- package/docs/design-actions-and-decisions.md +95 -0
- package/docs/gp-connect-real-data-to-production.md +66 -0
- package/docs/hippocampus-for-designers-and-researchers.md +261 -0
- package/docs/hippocampus-technical.md +373 -0
- package/docs/prompt-architecture-guardrails.md +39 -0
- package/docs/repeatable-research-round-ingestion.md +239 -0
- package/docs/research-knowledge-graph.md +552 -0
- package/docs/research-source-file-conventions.md +121 -0
- package/docs/source-model.md +252 -0
- package/index.js +47 -0
- package/lib/config.js +46 -0
- package/lib/context.js +59 -0
- package/lib/create-router.js +187 -0
- package/lib/discover-projects.js +45 -0
- package/lib/docs.js +72 -0
- package/lib/host-edit.js +151 -0
- package/lib/lint-knowledge.js +133 -0
- package/lib/load-knowledge.js +629 -0
- package/lib/package-paths.js +32 -0
- package/lib/paths.js +33 -0
- package/lib/resolve-root.js +84 -0
- package/lib/schema-version.js +96 -0
- package/lib/schema.js +346 -0
- package/package.json +70 -0
- package/scripts/audit-provenance.js +147 -0
- package/scripts/doctor.js +114 -0
- package/scripts/eject.js +51 -0
- package/scripts/import-blueprint-spreadsheet.js +242 -0
- package/scripts/import-design-actions.js +230 -0
- package/scripts/import-project-context.js +427 -0
- package/scripts/import-research-batch.js +160 -0
- package/scripts/import-research-deck.js +448 -0
- package/scripts/index-source-artefacts.js +261 -0
- package/scripts/init.js +232 -0
- package/scripts/install-skills.js +160 -0
- package/scripts/lib/artefact-text.js +347 -0
- package/scripts/lib/xlsx-grid.js +114 -0
- package/scripts/link-insights-to-needs.js +209 -0
- package/scripts/migrate-knowledge.js +73 -0
- package/scripts/promote-project-context-draft.js +125 -0
- package/scripts/promote-research-draft.js +236 -0
- package/scripts/smoke-routes.js +157 -0
- package/scripts/sync-project-context.js +236 -0
- package/scripts/sync-research-artefacts.js +294 -0
- package/scripts/triage-research-corpus.js +274 -0
- package/scripts/validate-knowledge.js +49 -0
- package/scripts/validate-skills.js +247 -0
- package/skills/COVERAGE-MATRIX.md +39 -0
- package/skills/README.md +77 -0
- package/skills/SKILL-FORMAT.md +100 -0
- package/skills/add-prototype-screen.skill.md +54 -0
- package/skills/add-scenario.skill.md +47 -0
- package/skills/add-user-needs.skill.md +51 -0
- package/skills/audit-knowledge-source.skill.md +47 -0
- package/skills/capture-route-review-pack.skill.md +52 -0
- package/skills/create-journey-from-scenario.skill.md +50 -0
- package/skills/deliver-service-slice.skill.md +65 -0
- package/skills/examples/README.md +15 -0
- package/skills/examples/non-copilot-research-ingestion.md +68 -0
- package/skills/examples/non-copilot-service-slice.md +64 -0
- package/skills/generate-service-slice.skill.md +57 -0
- package/skills/ingest-project-context.skill.md +58 -0
- package/skills/ingest-research-round.skill.md +60 -0
- package/skills/map-research-to-graph.skill.md +58 -0
- package/skills/record-accessibility-review.skill.md +52 -0
- package/skills/record-design-decision.skill.md +53 -0
- package/skills/review-research-import-draft.skill.md +45 -0
- package/skills/skills.json +464 -0
- package/skills/skills.schema.json +111 -0
- package/skills/structure-project-context-draft.skill.md +51 -0
- package/skills/structure-research-draft.skill.md +51 -0
- package/skills/triage-research-corpus.skill.md +51 -0
- package/skills/write-delivery-summary.skill.md +51 -0
- package/skills/write-route-review-summary.skill.md +55 -0
- package/views/hippocampus/_layout.html +45 -0
- package/views/hippocampus/blueprint-classic.html +39 -0
- package/views/hippocampus/blueprint.html +52 -0
- package/views/hippocampus/components/_blueprint-cell.njk +42 -0
- package/views/hippocampus/components/_blueprint-controls.njk +20 -0
- package/views/hippocampus/components/_blueprint-minimap.njk +14 -0
- package/views/hippocampus/components/_empty-state.njk +12 -0
- package/views/hippocampus/components/_journey-step.njk +21 -0
- package/views/hippocampus/components/_list.njk +23 -0
- package/views/hippocampus/components/_summary-card.njk +15 -0
- package/views/hippocampus/doc.html +11 -0
- package/views/hippocampus/docs.html +19 -0
- package/views/hippocampus/evidence-impact.html +53 -0
- package/views/hippocampus/graph.html +48 -0
- package/views/hippocampus/handover-empty.html +12 -0
- package/views/hippocampus/handover.html +56 -0
- package/views/hippocampus/index.html +46 -0
- package/views/hippocampus/journey-flow.html +31 -0
- package/views/hippocampus/journey.html +23 -0
- package/views/hippocampus/not-found.html +10 -0
- package/views/hippocampus/project.html +102 -0
- package/views/hippocampus/research.html +68 -0
- package/views/hippocampus/route-review.html +83 -0
- package/views/hippocampus/scenarios.html +56 -0
- package/views/hippocampus/trace.html +113 -0
- package/views/hippocampus/user-needs.html +32 -0
- package/views/hippocampus/user.html +157 -0
- 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 }
|