@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
package/lib/docs.js ADDED
@@ -0,0 +1,72 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+ const { marked } = require('marked')
4
+ const packagePaths = require('./package-paths')
5
+
6
+ // Docs are served, not copied.
7
+ //
8
+ // Eight guides copied into a host repo are stale within a release, and the
9
+ // stale copy is the one people read - it is sitting in their editor, and the
10
+ // current one is not. Serving them from the viewer means the documentation a
11
+ // team reads always describes the version they have installed.
12
+ //
13
+ // Skills are the deliberate exception: an agent reads those as repo files, so
14
+ // `hippocampus skills install` copies them, with hashes so an upgrade can tell
15
+ // a stale copy from an edited one.
16
+
17
+ const TITLE_OVERRIDES = {
18
+ 'hippocampus-for-designers-and-researchers': 'Hippocampus for designers and researchers',
19
+ 'gp-connect-real-data-to-production': 'GP Connect: real data to production'
20
+ }
21
+
22
+ function slugOf (filename) {
23
+ return filename.replace(/\.md$/, '')
24
+ }
25
+
26
+ function titleOf (filename, contents) {
27
+ const slug = slugOf(filename)
28
+ if (TITLE_OVERRIDES[slug]) return TITLE_OVERRIDES[slug]
29
+ const heading = contents.match(/^#\s+(.+)$/m)
30
+ if (heading) return heading[1].trim()
31
+ return slug.replace(/-/g, ' ').replace(/^./, (c) => c.toUpperCase())
32
+ }
33
+
34
+ function summaryOf (contents) {
35
+ const body = contents.replace(/^#\s+.+$/m, '').trim()
36
+ const paragraph = body.split(/\n\s*\n/).find((block) => block.trim() && !block.trim().startsWith('#'))
37
+ if (!paragraph) return ''
38
+ const text = paragraph.replace(/\s+/g, ' ').replace(/[*_`[\]]/g, '').trim()
39
+ return text.length > 200 ? `${text.slice(0, 197)}...` : text
40
+ }
41
+
42
+ function listDocs () {
43
+ if (!fs.existsSync(packagePaths.docsDir)) return []
44
+ return fs.readdirSync(packagePaths.docsDir)
45
+ .filter((name) => name.endsWith('.md'))
46
+ .sort()
47
+ .map((name) => {
48
+ const contents = fs.readFileSync(path.join(packagePaths.docsDir, name), 'utf8')
49
+ return { slug: slugOf(name), title: titleOf(name, contents), summary: summaryOf(contents) }
50
+ })
51
+ }
52
+
53
+ function readDoc (slug) {
54
+ // The slug comes off a URL, so it is checked against the directory listing
55
+ // rather than joined onto a path and hoped for.
56
+ if (!/^[a-z0-9-]+$/.test(slug)) return null
57
+ const filePath = path.join(packagePaths.docsDir, `${slug}.md`)
58
+ if (!fs.existsSync(filePath)) return null
59
+
60
+ const contents = fs.readFileSync(filePath, 'utf8')
61
+ return {
62
+ slug,
63
+ title: titleOf(`${slug}.md`, contents),
64
+ html: marked(contents, { mangle: false, headerIds: true })
65
+ }
66
+ }
67
+
68
+ function packageVersion () {
69
+ return require('../package.json').version
70
+ }
71
+
72
+ module.exports = { listDocs, readDoc, packageVersion }
@@ -0,0 +1,151 @@
1
+ const crypto = require('crypto')
2
+ const fs = require('fs')
3
+
4
+ // Editing somebody else's app.js is the most dangerous thing this package does,
5
+ // so the rules are narrow on purpose:
6
+ //
7
+ // - every edit lives between markers, so it can be found and removed
8
+ // - the end marker carries a hash of the body, so a block somebody has
9
+ // customised is reported and left alone rather than silently overwritten
10
+ // - an insertion that does not match exactly once does nothing, and prints
11
+ // the lines to paste instead
12
+ //
13
+ // Half-editing a file leaves the owner worse off than not editing it and
14
+ // telling them what to type.
15
+
16
+ const START = (id) => `${id}:start v1 - managed by @hippo-digital/hippocampus`
17
+ const END = (id, hash) => `${id}:end ${hash}`
18
+
19
+ function hashBody (body) {
20
+ return crypto.createHash('sha256').update(body.trim()).digest('hex').slice(0, 12)
21
+ }
22
+
23
+ function markers (id, comment) {
24
+ const open = comment === 'js' ? '// >>> ' : '{# >>> '
25
+ const close = comment === 'js' ? '' : ' #}'
26
+ return {
27
+ start: `${open}${START(id)}${close}`,
28
+ endPrefix: comment === 'js' ? '// <<< ' : '{# <<< ',
29
+ endSuffix: close
30
+ }
31
+ }
32
+
33
+ function findBlock (source, id, comment = 'js') {
34
+ const { start, endPrefix } = markers(id, comment)
35
+ const startIndex = source.indexOf(start)
36
+ if (startIndex === -1) return null
37
+
38
+ const endMarkerIndex = source.indexOf(`${endPrefix}${id}:end`, startIndex)
39
+ if (endMarkerIndex === -1) {
40
+ return { startIndex, endIndex: -1, body: null, declaredHash: null, unterminated: true }
41
+ }
42
+
43
+ const endLineEnd = source.indexOf('\n', endMarkerIndex)
44
+ const endIndex = endLineEnd === -1 ? source.length : endLineEnd + 1
45
+ const body = source.slice(startIndex + start.length, endMarkerIndex)
46
+ const endLine = source.slice(endMarkerIndex, endLineEnd === -1 ? source.length : endLineEnd)
47
+ const declaredHash = (endLine.match(/:end\s+([0-9a-f]{12})/) || [])[1] || null
48
+
49
+ return { startIndex, endIndex, body, declaredHash, unterminated: false }
50
+ }
51
+
52
+ /**
53
+ * Result is one of:
54
+ * { action: 'inserted' | 'updated' | 'unchanged', source }
55
+ * { action: 'customised' | 'unterminated', reason } - nothing written
56
+ */
57
+ function upsertBlock (source, { id, body, comment = 'js', anchor }) {
58
+ const { start, endPrefix, endSuffix } = markers(id, comment)
59
+ const hash = hashBody(body)
60
+ const block = `${start}\n${body.trim()}\n${endPrefix}${id}:end ${hash}${endSuffix}\n`
61
+
62
+ const existing = findBlock(source, id, comment)
63
+
64
+ if (existing && existing.unterminated) {
65
+ return { action: 'unterminated', reason: `The ${id} block in this file has no end marker. Fix or remove it by hand.` }
66
+ }
67
+
68
+ if (existing) {
69
+ const currentHash = hashBody(existing.body)
70
+ if (existing.declaredHash && existing.declaredHash !== currentHash) {
71
+ return {
72
+ action: 'customised',
73
+ reason: `The ${id} block in this file has been edited since it was written. Leaving it alone.`
74
+ }
75
+ }
76
+ if (currentHash === hash) return { action: 'unchanged', source }
77
+ return {
78
+ action: 'updated',
79
+ source: source.slice(0, existing.startIndex) + block + source.slice(existing.endIndex)
80
+ }
81
+ }
82
+
83
+ const insertAt = anchor ? anchor(source) : null
84
+ if (insertAt === null || insertAt === undefined) {
85
+ return { action: 'no-anchor', reason: 'Could not find a safe place to insert this block.' }
86
+ }
87
+
88
+ return {
89
+ action: 'inserted',
90
+ source: source.slice(0, insertAt) + block + source.slice(insertAt)
91
+ }
92
+ }
93
+
94
+ function removeBlock (source, id, comment = 'js') {
95
+ const existing = findBlock(source, id, comment)
96
+ if (!existing || existing.unterminated) return { action: 'absent', source }
97
+ return {
98
+ action: 'removed',
99
+ source: source.slice(0, existing.startIndex) + source.slice(existing.endIndex)
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Adds an entry to a simple array of string literals - the kit's viewsPath and
105
+ * entryPoints. Applied only when the pattern matches exactly once and the array
106
+ * contains nothing but string literals. Anything cleverer than that is somebody
107
+ * else's code doing something we do not understand, and we should not be
108
+ * rewriting it.
109
+ */
110
+ function addToStringArray (source, { name, entry }) {
111
+ if (source.includes(entry)) return { action: 'unchanged', source }
112
+
113
+ const pattern = new RegExp(`(const\\s+${name}\\s*=\\s*\\[)([^\\]]*)\\]`, 'g')
114
+ const matches = [...source.matchAll(pattern)]
115
+ if (matches.length !== 1) {
116
+ return { action: 'no-anchor', reason: `Expected exactly one \`const ${name} = [...]\`, found ${matches.length}.` }
117
+ }
118
+
119
+ const [full, open, inner] = matches[0]
120
+ const items = inner.split(',').map((item) => item.trim()).filter(Boolean)
121
+ const allStringLiterals = items.every((item) => /^(['"]).*\1$/.test(item) || item.startsWith('//'))
122
+ if (!allStringLiterals) {
123
+ return { action: 'no-anchor', reason: `\`${name}\` is not a plain array of strings, so it is not safe to edit automatically.` }
124
+ }
125
+
126
+ const indent = (inner.match(/\n(\s+)/) || [, ' '])[1]
127
+ const replacement = `${open}${inner.replace(/\s*$/, '')},\n${indent}${entry}\n]`
128
+ return { action: 'updated', source: source.replace(full, replacement) }
129
+ }
130
+
131
+ function backup (filePath) {
132
+ const backupPath = `${filePath}.hippocampus-backup`
133
+ if (!fs.existsSync(backupPath)) fs.copyFileSync(filePath, backupPath)
134
+ return backupPath
135
+ }
136
+
137
+ function diff (before, after, filePath) {
138
+ const beforeLines = before.split('\n')
139
+ const afterLines = after.split('\n')
140
+ const out = [`--- ${filePath}`, `+++ ${filePath}`]
141
+ let i = 0
142
+ let j = 0
143
+ while (i < beforeLines.length || j < afterLines.length) {
144
+ if (beforeLines[i] === afterLines[j]) { i++; j++; continue }
145
+ if (afterLines.includes(beforeLines[i]) === false && i < beforeLines.length) { out.push(`-${beforeLines[i]}`); i++; continue }
146
+ out.push(`+${afterLines[j]}`); j++
147
+ }
148
+ return out.join('\n')
149
+ }
150
+
151
+ module.exports = { upsertBlock, removeBlock, addToStringArray, findBlock, hashBody, backup, diff }
@@ -0,0 +1,133 @@
1
+ const { recordSchemas } = require('./schema')
2
+
3
+ // Parsing answers "is this loadable?". Linting answers the question a team
4
+ // actually has: "what is thin, and what is missing?" Keeping them apart is what
5
+ // lets a freshly initialised project boot with nothing in it while still being
6
+ // told, every time, what it has not filled in yet.
7
+ //
8
+ // error - referential integrity. The graph is broken.
9
+ // warning - the old min-1 rules, rephrased as onboarding prompts, plus
10
+ // anything that looks like a mistake rather than a gap.
11
+ // info - coverage. Nothing is wrong; something is thin.
12
+ //
13
+ // The info tier is the point. "51 insights have no need link" was something
14
+ // somebody happened to notice once; it should be a line of standing output.
15
+
16
+ const ONBOARDING = {
17
+ users: 'No users yet. Start with who the service is for - everything else hangs off a user.',
18
+ needs: 'No user needs yet. A need says who wants what, and why.',
19
+ scenarios: 'No scenarios yet. A scenario is the situation a user is in when a need bites.',
20
+ journeys: 'No journeys yet. A journey is how a scenario plays out, step by step.',
21
+ screens: 'No screens yet. A screen is a route this prototype serves, linked to the needs it meets.',
22
+ evidence: 'No evidence yet. Evidence is what makes a need a finding rather than an opinion.',
23
+ decisions: 'No decisions yet. A decision records an option that was weighed and what happened to it.',
24
+ assumptions: 'No assumptions yet. An assumption is a belief the design rests on that nobody has tested.'
25
+ }
26
+
27
+ function lintKnowledge (knowledge) {
28
+ const warnings = []
29
+ const infos = []
30
+ const index = knowledge.index || {}
31
+
32
+ for (const [collection, message] of Object.entries(ONBOARDING)) {
33
+ if (!(knowledge[collection] || []).length) {
34
+ warnings.push({ rule: 'empty-collection', collection, message })
35
+ }
36
+ }
37
+
38
+ // Passthrough schemas mean a misspelled key is kept rather than rejected.
39
+ // That is the right trade for forward compatibility, but somebody has to say
40
+ // "you wrote titel" or the field silently does nothing forever.
41
+ for (const [collection, schema] of Object.entries(recordSchemas)) {
42
+ const known = new Set(Object.keys(schema.shape))
43
+ for (const record of knowledge[collection] || []) {
44
+ for (const key of Object.keys(record)) {
45
+ if (known.has(key)) continue
46
+ warnings.push({
47
+ rule: 'unrecognised-field',
48
+ collection,
49
+ id: record.id,
50
+ message: `${collection} ${record.id}: unrecognised field "${key}" - kept, but check the spelling`
51
+ })
52
+ }
53
+ }
54
+ }
55
+
56
+ const legacyActionLinks = (knowledge.relationships || []).filter((r) => r.type === 'leads-to-action')
57
+ if (legacyActionLinks.length) {
58
+ warnings.push({
59
+ rule: 'legacy-relationship-type',
60
+ message:
61
+ `${legacyActionLinks.length} relationships still use leads-to-action (insight -> action). ` +
62
+ 'Dependents point at what they rely on, so these should be responds-to-insight ' +
63
+ '(action -> insight) or traversal from a screen stops at the insight.'
64
+ })
65
+ }
66
+
67
+ const needsWithoutEvidence = (knowledge.needs || []).filter((need) => !(need.evidenceIds || []).length)
68
+ if (needsWithoutEvidence.length) {
69
+ infos.push({
70
+ rule: 'need-without-evidence',
71
+ count: needsWithoutEvidence.length,
72
+ ids: needsWithoutEvidence.map((need) => need.id),
73
+ message: `${needsWithoutEvidence.length} of ${knowledge.needs.length} needs cite no evidence`
74
+ })
75
+ }
76
+
77
+ const needLinked = new Set()
78
+ for (const relationship of knowledge.relationships || []) {
79
+ if (index.insights && index.insights[relationship.from] && index.needs && index.needs[relationship.to]) {
80
+ needLinked.add(relationship.from)
81
+ }
82
+ }
83
+ const unlinkedInsights = (knowledge.insights || []).filter((insight) => !needLinked.has(insight.id))
84
+ if (unlinkedInsights.length) {
85
+ infos.push({
86
+ rule: 'insight-without-need',
87
+ count: unlinkedInsights.length,
88
+ ids: unlinkedInsights.map((insight) => insight.id),
89
+ message:
90
+ `${unlinkedInsights.length} of ${knowledge.insights.length} insights link to no user need - ` +
91
+ 'usually a gap in the needs layer rather than a bad insight'
92
+ })
93
+ }
94
+
95
+ const roundsWithInsights = new Set((knowledge.insights || []).map((insight) => insight.roundId))
96
+ const emptyRounds = (knowledge.researchRounds || []).filter((round) => !roundsWithInsights.has(round.id))
97
+ if (emptyRounds.length) {
98
+ infos.push({
99
+ rule: 'round-without-insights',
100
+ count: emptyRounds.length,
101
+ ids: emptyRounds.map((round) => round.id),
102
+ message: `${emptyRounds.length} of ${knowledge.researchRounds.length} research rounds produced no insights`
103
+ })
104
+ }
105
+
106
+ const screensWithoutNeeds = (knowledge.screens || []).filter((screen) => !(screen.needIds || []).length)
107
+ if (screensWithoutNeeds.length) {
108
+ infos.push({
109
+ rule: 'screen-without-need',
110
+ count: screensWithoutNeeds.length,
111
+ ids: screensWithoutNeeds.map((screen) => screen.id),
112
+ message: `${screensWithoutNeeds.length} of ${knowledge.screens.length} screens trace back to no user need`
113
+ })
114
+ }
115
+
116
+ const defaultPriority = (knowledge.needs || []).filter((need) => need.priority === 'medium')
117
+ if (defaultPriority.length && knowledge.needs.length) {
118
+ const share = Math.round((defaultPriority.length / knowledge.needs.length) * 100)
119
+ if (share >= 50) {
120
+ infos.push({
121
+ rule: 'priority-unset',
122
+ count: defaultPriority.length,
123
+ message:
124
+ `${defaultPriority.length} of ${knowledge.needs.length} needs (${share}%) sit at priority "medium" - ` +
125
+ 'likely the import default rather than a decision, so priority cannot sequence design work yet'
126
+ })
127
+ }
128
+ }
129
+
130
+ return { warnings, infos }
131
+ }
132
+
133
+ module.exports = { lintKnowledge }