@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,73 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+ const { createContextOrExit } = require('../lib/context')
4
+ const {
5
+ CURRENT_SCHEMA_VERSION,
6
+ META_FILENAME,
7
+ readMeta,
8
+ migrateInMemory,
9
+ writeMeta
10
+ } = require('../lib/schema-version')
11
+
12
+ // The loader migrates in memory and warns on every read. This is the command
13
+ // that makes it permanent, writing the upgraded collections and stamping
14
+ // _meta.json, so the warning stops and the files on disk match the code.
15
+
16
+ const ctx = createContextOrExit()
17
+ const dryRun = process.argv.includes('--dry-run')
18
+
19
+ const collectionFiles = {
20
+ users: 'users.json',
21
+ needs: 'needs.json',
22
+ scenarios: 'scenarios.json',
23
+ journeys: 'journeys.json',
24
+ screens: 'screens.json',
25
+ evidence: 'evidence.json',
26
+ decisions: 'decisions.json',
27
+ assumptions: 'assumptions.json',
28
+ researchRounds: 'research-rounds.json',
29
+ participants: 'participants.json',
30
+ insights: 'insights.json',
31
+ designActions: 'design-actions.json',
32
+ relationships: 'relationships.json'
33
+ }
34
+
35
+ function main () {
36
+ for (const project of ctx.requireProjects()) {
37
+ const sourceDir = path.join(project.dir, 'source')
38
+ const meta = readMeta(sourceDir)
39
+
40
+ if (meta.schemaVersion === CURRENT_SCHEMA_VERSION && !meta.implicit) {
41
+ console.log(`${project.name}: already at schemaVersion ${CURRENT_SCHEMA_VERSION}`)
42
+ continue
43
+ }
44
+
45
+ const raw = {}
46
+ for (const [key, filename] of Object.entries(collectionFiles)) {
47
+ const filePath = path.join(sourceDir, filename)
48
+ raw[key] = fs.existsSync(filePath) ? JSON.parse(fs.readFileSync(filePath, 'utf8')) : []
49
+ }
50
+ raw.project = JSON.parse(fs.readFileSync(path.join(sourceDir, 'project.json'), 'utf8'))
51
+ raw.projectContext = JSON.parse(fs.readFileSync(path.join(sourceDir, 'project-context.json'), 'utf8'))
52
+
53
+ const { data, version } = migrateInMemory(raw, meta)
54
+
55
+ if (dryRun) {
56
+ console.log(`${project.name}: would write schemaVersion ${version} and stamp ${META_FILENAME}`)
57
+ continue
58
+ }
59
+
60
+ for (const [key, filename] of Object.entries(collectionFiles)) {
61
+ fs.writeFileSync(path.join(sourceDir, filename), JSON.stringify(data[key], null, 2) + '\n')
62
+ }
63
+ writeMeta(sourceDir, { generator: 'hippocampus migrate:knowledge' })
64
+ console.log(`${project.name}: migrated ${meta.schemaVersion} -> ${version}, ${META_FILENAME} stamped`)
65
+ }
66
+ }
67
+
68
+ try {
69
+ main()
70
+ } catch (error) {
71
+ console.error(error.message)
72
+ process.exit(1)
73
+ }
@@ -0,0 +1,125 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+ const { spawnSync } = require('child_process')
4
+ const { ProjectContextImportDraftSchema } = require('../lib/schema')
5
+ const { createContextOrExit } = require('../lib/context')
6
+ const ctx = createContextOrExit()
7
+ const { knowledgeSourceDir, projectImportsDir } = ctx.paths
8
+
9
+ const projectContextPath = path.join(knowledgeSourceDir, 'project-context.json')
10
+
11
+ function main () {
12
+ const args = parseArgs(process.argv.slice(2))
13
+ if (!args.draft) {
14
+ throw new Error(`Usage: npm run promote:project-context-draft -- --draft ${path.relative(ctx.root, path.join(projectImportsDir, 'context-id.json'))} [--dry-run]`)
15
+ }
16
+
17
+ const draft = validateDraft(readJson(path.resolve(args.draft)))
18
+ const current = readJson(projectContextPath)
19
+ const proposed = mergeProjectContext(current, draft.projectContext)
20
+
21
+ console.log([
22
+ `users: +${newCount(current.users, draft.projectContext.users)}`,
23
+ `channels: +${newCount(current.channels, draft.projectContext.channels)}`,
24
+ `policies: +${newCount(current.policies, draft.projectContext.policies)}`,
25
+ `constraints: +${newCount(current.constraints, draft.projectContext.constraints)}`,
26
+ `risks: +${newCount(current.risks, draft.projectContext.risks)}`,
27
+ `milestones: +${newCount(current.milestones, draft.projectContext.milestones)}`,
28
+ `facts: +${newCount(current.facts, draft.projectContext.facts)}`,
29
+ `sourceArtefacts: +${newCount(current.sourceArtefacts, draft.projectContext.sourceArtefacts)}`
30
+ ].join(', '))
31
+
32
+ if (args.dryRun) {
33
+ console.log('Dry run only. Source files were not changed.')
34
+ return
35
+ }
36
+
37
+ writeJson(projectContextPath, proposed)
38
+
39
+ const validation = spawnSync('npm', ['run', 'validate:knowledge'], {
40
+ cwd: ctx.root,
41
+ stdio: 'inherit'
42
+ })
43
+
44
+ if (validation.status !== 0) {
45
+ throw new Error('Promotion wrote project context, but knowledge validation failed. Review the promoted data.')
46
+ }
47
+
48
+ console.log('Project context draft promoted and knowledge validation passed.')
49
+ }
50
+
51
+ function validateDraft (draft) {
52
+ const result = ProjectContextImportDraftSchema.safeParse(draft)
53
+ if (result.success) return result.data
54
+
55
+ const issues = result.error.issues.map((issue) => {
56
+ const fieldPath = issue.path.length ? issue.path.join('.') : 'draft'
57
+ return `${fieldPath}: ${issue.message}`
58
+ })
59
+ throw new Error(`Project context draft does not match ProjectContextImportDraftSchema:\n- ${issues.join('\n- ')}`)
60
+ }
61
+
62
+ function mergeProjectContext (current, incoming) {
63
+ return {
64
+ ...current,
65
+ title: incoming.title || current.title,
66
+ summary: incoming.summary || current.summary,
67
+ purpose: incoming.purpose || current.purpose,
68
+ vision: incoming.vision || current.vision,
69
+ problemStatement: incoming.problemStatement || current.problemStatement,
70
+ serviceProposition: incoming.serviceProposition || current.serviceProposition,
71
+ users: mergeById(current.users, incoming.users),
72
+ channels: mergeById(current.channels, incoming.channels),
73
+ policies: mergeById(current.policies, incoming.policies),
74
+ constraints: mergeById(current.constraints, incoming.constraints),
75
+ risks: mergeById(current.risks, incoming.risks),
76
+ milestones: mergeById(current.milestones, incoming.milestones),
77
+ facts: mergeById(current.facts, incoming.facts),
78
+ sourceArtefacts: mergeById(current.sourceArtefacts, incoming.sourceArtefacts)
79
+ }
80
+ }
81
+
82
+ function mergeById (current, incoming) {
83
+ return Object.values(Object.fromEntries([
84
+ ...current.map((item) => [item.id, item]),
85
+ ...incoming.map((item) => [item.id, item])
86
+ ]))
87
+ }
88
+
89
+ function newCount (current, incoming) {
90
+ const existingIds = new Set(current.map((item) => item.id))
91
+ return incoming.filter((item) => !existingIds.has(item.id)).length
92
+ }
93
+
94
+ function parseArgs (args) {
95
+ const parsed = {}
96
+ for (let index = 0; index < args.length; index++) {
97
+ const arg = args[index]
98
+ if (!arg.startsWith('--')) continue
99
+ const [rawKey, value] = arg.slice(2).split('=', 2)
100
+ const key = camelCase(rawKey)
101
+ if (value !== undefined) {
102
+ parsed[key] = value
103
+ } else if (args[index + 1] && !args[index + 1].startsWith('--')) {
104
+ parsed[key] = args[index + 1]
105
+ index++
106
+ } else {
107
+ parsed[key] = true
108
+ }
109
+ }
110
+ return parsed
111
+ }
112
+
113
+ function readJson (filePath) {
114
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'))
115
+ }
116
+
117
+ function writeJson (filePath, data) {
118
+ fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`)
119
+ }
120
+
121
+ function camelCase (value) {
122
+ return value.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())
123
+ }
124
+
125
+ main()
@@ -0,0 +1,236 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+ const { spawnSync } = require('child_process')
4
+ const { ResearchImportDraftSchema } = require('../lib/schema')
5
+ const { createContextOrExit } = require('../lib/context')
6
+ const ctx = createContextOrExit()
7
+ const { knowledgeSourceDir, researchImportsDir } = ctx.paths
8
+
9
+ const dataDir = knowledgeSourceDir
10
+
11
+ const collections = {
12
+ researchRounds: 'research-rounds.json',
13
+ participants: 'participants.json',
14
+ insights: 'insights.json',
15
+ designActions: 'design-actions.json',
16
+ relationships: 'relationships.json'
17
+ }
18
+
19
+ function main () {
20
+ const args = parseArgs(process.argv.slice(2))
21
+ if (!args.draft) {
22
+ throw new Error(`Usage: npm run promote:research-draft -- --draft ${path.relative(ctx.root, path.join(researchImportsDir, 'round-id.json'))} [--dry-run]`)
23
+ }
24
+
25
+ const draft = validateDraft(readJson(path.resolve(args.draft)))
26
+ const current = readCurrentCollections()
27
+ const reused = reconcileSharedRecords(current, draft)
28
+ const proposed = mergeCollections(current, draft)
29
+ validateNoDuplicateIds(current, draft)
30
+ validateDraftReferences(proposed)
31
+
32
+ if (reused.length) console.log(`Reusing existing records from earlier artefacts in this round: ${reused.join(', ')}`)
33
+
34
+ const counts = Object.keys(collections).map((key) => `${key}: +${(draft[key] || []).length}`)
35
+ console.log(`Promotion checked: ${counts.join(', ')}`)
36
+
37
+ if (args.dryRun) {
38
+ console.log('Dry run only. Source files were not changed.')
39
+ return
40
+ }
41
+
42
+ for (const [key, filename] of Object.entries(collections)) {
43
+ writeJson(path.join(dataDir, filename), proposed[key])
44
+ }
45
+
46
+ const validation = spawnSync('npm', ['run', 'validate:knowledge'], {
47
+ cwd: ctx.root,
48
+ stdio: 'inherit'
49
+ })
50
+
51
+ if (validation.status !== 0) {
52
+ throw new Error('Promotion wrote source files, but knowledge validation failed. Review the promoted data.')
53
+ }
54
+
55
+ console.log('Research draft promoted and knowledge validation passed.')
56
+ }
57
+
58
+ /**
59
+ * A round is fieldwork, not a file, so several artefacts promote into the same
60
+ * round and meet the same people. Re-promoting an identical round record, or a
61
+ * participant already seen in a sibling artefact, is expected rather than a
62
+ * collision - the duplicate check should only fire on genuinely conflicting data.
63
+ *
64
+ * Returns the ids that were absorbed rather than added, so the run says so out loud.
65
+ */
66
+ function reconcileSharedRecords (current, draft) {
67
+ const reused = []
68
+
69
+ const existingRounds = new Map(current.researchRounds.map((round) => [round.id, round]))
70
+ draft.researchRounds = (draft.researchRounds || []).filter((round) => {
71
+ const existing = existingRounds.get(round.id)
72
+ if (!existing) return true
73
+ if (existing.title !== round.title || existing.date !== round.date) {
74
+ throw new Error(`research round ${round.id} already exists with a different title or date. Reconcile the draft against hippocampus/source/research-rounds.json before promoting.`)
75
+ }
76
+ reused.push(`round ${round.id}`)
77
+ return false
78
+ })
79
+
80
+ const existingParticipants = new Map(current.participants.map((participant) => [participant.id, participant]))
81
+ draft.participants = (draft.participants || []).filter((participant) => {
82
+ const existing = existingParticipants.get(participant.id)
83
+ if (!existing) return true
84
+ existing.roundIds = [...new Set([...existing.roundIds, ...participant.roundIds])]
85
+ // "Research participant" is the placeholder the importer falls back to, so a
86
+ // named role from any artefact in the round wins over it.
87
+ if (existing.role === 'Research participant' && participant.role !== 'Research participant') existing.role = participant.role
88
+ if (!existing.organisation && participant.organisation) existing.organisation = participant.organisation
89
+ reused.push(`participant ${participant.id}`)
90
+ return false
91
+ })
92
+
93
+ const draftIds = new Set([
94
+ ...draft.researchRounds.map((item) => item.id),
95
+ ...draft.participants.map((item) => item.id),
96
+ ...current.researchRounds.map((item) => item.id),
97
+ ...current.participants.map((item) => item.id)
98
+ ])
99
+ const relationshipIds = new Set(current.relationships.map((item) => item.id))
100
+ draft.relationships = (draft.relationships || []).filter((link) => {
101
+ if (!relationshipIds.has(link.id)) return true
102
+ reused.push(`relationship ${link.id}`)
103
+ return false
104
+ })
105
+ void draftIds
106
+
107
+ return reused
108
+ }
109
+
110
+ function validateDraft (draft) {
111
+ const result = ResearchImportDraftSchema.safeParse(draft)
112
+ if (result.success) return result.data
113
+
114
+ const issues = result.error.issues.map((issue) => {
115
+ const fieldPath = issue.path.length ? issue.path.join('.') : 'draft'
116
+ return `${fieldPath}: ${issue.message}`
117
+ })
118
+ throw new Error(`Research draft does not match ResearchImportDraftSchema:\n- ${issues.join('\n- ')}`)
119
+ }
120
+
121
+ function parseArgs (args) {
122
+ const parsed = {}
123
+ for (let index = 0; index < args.length; index++) {
124
+ const arg = args[index]
125
+ if (!arg.startsWith('--')) continue
126
+ const [rawKey, value] = arg.slice(2).split('=', 2)
127
+ const key = camelCase(rawKey)
128
+ if (value !== undefined) {
129
+ parsed[key] = value
130
+ } else if (args[index + 1] && !args[index + 1].startsWith('--')) {
131
+ parsed[key] = args[index + 1]
132
+ index++
133
+ } else {
134
+ parsed[key] = true
135
+ }
136
+ }
137
+ return parsed
138
+ }
139
+
140
+ function readCurrentCollections () {
141
+ return Object.fromEntries(
142
+ Object.entries(collections).map(([key, filename]) => [key, readJson(path.join(dataDir, filename))])
143
+ )
144
+ }
145
+
146
+ function readJson (filePath) {
147
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'))
148
+ }
149
+
150
+ function writeJson (filePath, data) {
151
+ fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`)
152
+ }
153
+
154
+ function mergeCollections (current, draft) {
155
+ return Object.fromEntries(
156
+ Object.keys(collections).map((key) => [key, [...current[key], ...(draft[key] || [])]])
157
+ )
158
+ }
159
+
160
+ function validateNoDuplicateIds (current, draft) {
161
+ for (const key of Object.keys(collections)) {
162
+ const existingIds = new Set(current[key].map((item) => item.id))
163
+ const draftIds = new Set()
164
+
165
+ for (const item of draft[key] || []) {
166
+ if (existingIds.has(item.id)) throw new Error(`${key}: ${item.id} already exists in source data`)
167
+ if (draftIds.has(item.id)) throw new Error(`${key}: ${item.id} appears more than once in the draft`)
168
+ draftIds.add(item.id)
169
+ }
170
+ }
171
+ }
172
+
173
+ function validateDraftReferences (proposed) {
174
+ const ids = {
175
+ researchRounds: idSet(proposed.researchRounds),
176
+ participants: idSet(proposed.participants),
177
+ insights: idSet(proposed.insights),
178
+ designActions: idSet(proposed.designActions),
179
+ relationships: idSet(proposed.relationships)
180
+ }
181
+
182
+ for (const participant of proposed.participants) {
183
+ for (const roundId of participant.roundIds || []) requireId(ids.researchRounds, 'participant', participant.id, 'roundIds', roundId)
184
+ }
185
+
186
+ for (const insight of proposed.insights) {
187
+ requireId(ids.researchRounds, 'insight', insight.id, 'roundId', insight.roundId)
188
+ for (const participantId of insight.participantIds || []) requireId(ids.participants, 'insight', insight.id, 'participantIds', participantId)
189
+ }
190
+
191
+ for (const action of proposed.designActions) {
192
+ for (const insightId of action.insightIds || []) requireId(ids.insights, 'design action', action.id, 'insightIds', insightId)
193
+ }
194
+
195
+ const entityIds = new Set([
196
+ ...ids.researchRounds,
197
+ ...ids.participants,
198
+ ...ids.insights,
199
+ ...ids.designActions,
200
+ ...readCanonicalEntityIds()
201
+ ])
202
+
203
+ for (const relationship of proposed.relationships) {
204
+ requireId(entityIds, 'relationship', relationship.id, 'from', relationship.from)
205
+ requireId(entityIds, 'relationship', relationship.id, 'to', relationship.to)
206
+ }
207
+ }
208
+
209
+ function readCanonicalEntityIds () {
210
+ const filenames = [
211
+ 'users.json',
212
+ 'needs.json',
213
+ 'scenarios.json',
214
+ 'journeys.json',
215
+ 'screens.json',
216
+ 'evidence.json',
217
+ 'decisions.json',
218
+ 'assumptions.json'
219
+ ]
220
+
221
+ return filenames.flatMap((filename) => readJson(path.join(dataDir, filename)).map((item) => item.id))
222
+ }
223
+
224
+ function idSet (items) {
225
+ return new Set(items.map((item) => item.id))
226
+ }
227
+
228
+ function requireId (ids, type, id, field, value) {
229
+ if (!ids.has(value)) throw new Error(`${type} ${id}: ${field} references missing id ${value}`)
230
+ }
231
+
232
+ function camelCase (value) {
233
+ return value.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())
234
+ }
235
+
236
+ main()
@@ -0,0 +1,157 @@
1
+ const { spawn } = require('child_process')
2
+ const { createContextOrExit } = require('../lib/context')
3
+ const { listDocs } = require('../lib/docs')
4
+ const ctx = createContextOrExit()
5
+
6
+ const port = process.env.SMOKE_PORT || 4587
7
+ const baseUrl = `http://127.0.0.1:${port}`
8
+ // A cold Prototype Kit boot compiles sass before it listens, which on a fresh
9
+ // install is a good deal slower than the 15s this used to allow - and the
10
+ // failure looked like "your routes are broken" rather than "it had not finished
11
+ // starting".
12
+ const timeoutMs = Number(process.env.SMOKE_TIMEOUT_MS || 60000)
13
+
14
+ async function main () {
15
+ // Kept rather than discarded, so a server that dies during boot can say why
16
+ // instead of timing out silently.
17
+ let serverOutput = ''
18
+ const server = spawn(process.execPath, ['app.js'], {
19
+ cwd: ctx.root,
20
+ env: { ...process.env, PORT: String(port) },
21
+ stdio: ['ignore', 'pipe', 'pipe']
22
+ })
23
+ server.stdout.on('data', (chunk) => { serverOutput += chunk })
24
+ server.stderr.on('data', (chunk) => { serverOutput += chunk })
25
+
26
+ try {
27
+ await waitForServer(server, () => serverOutput)
28
+ for (const project of ctx.requireProjects()) {
29
+ await smokeRoutes(buildRoutes(project.basePath, project.store.getKnowledge()))
30
+ }
31
+ console.log('Route smoke test passed')
32
+ } finally {
33
+ // The pipes have to go explicitly. Browsersync leaves descendants holding
34
+ // the other end, so killing the direct child is not enough - Node keeps the
35
+ // handles open and the process never exits, which looks exactly like a
36
+ // hung smoke test.
37
+ server.stdout.destroy()
38
+ server.stderr.destroy()
39
+ server.kill('SIGKILL')
40
+ }
41
+ }
42
+
43
+ function buildRoutes (basePath, knowledge) {
44
+ const routes = new Set([
45
+ basePath,
46
+ `${basePath}/project`,
47
+ `${basePath}/users`,
48
+ `${basePath}/user-needs`,
49
+ `${basePath}/scenarios`,
50
+ `${basePath}/research`,
51
+ `${basePath}/graph`,
52
+ `${basePath}/trace`,
53
+ `${basePath}/handover`,
54
+ `${basePath}/docs`,
55
+ // The viewer serves these itself. If they 404 the pages load unstyled and
56
+ // the blueprint stops working, which is exactly the kind of breakage a
57
+ // smoke test should catch rather than a person noticing later.
58
+ `${basePath}/assets/hippocampus.css`,
59
+ `${basePath}/assets/blueprint.js`
60
+ ])
61
+
62
+ for (const doc of listDocs()) {
63
+ routes.add(`${basePath}/docs/${doc.slug}`)
64
+ }
65
+
66
+ for (const journey of knowledge.journeys) {
67
+ routes.add(`${basePath}/journeys/${journey.id}`)
68
+ routes.add(`${basePath}/journeys/${journey.id}/blueprint`)
69
+ routes.add(`${basePath}/journeys/${journey.id}/blueprint/classic`)
70
+ routes.add(`${basePath}/journeys/${journey.id}/flow`)
71
+ routes.add(`${basePath}/journeys/${journey.id}/handover`)
72
+ }
73
+
74
+ for (const user of knowledge.users) {
75
+ routes.add(`${basePath}/users/${user.id}`)
76
+ }
77
+
78
+ for (const screen of knowledge.screens) {
79
+ // Screens marked external live in the real NCRS prototype, not here, so
80
+ // this app has nothing to serve at their route.
81
+ if (screen.external) continue
82
+ routes.add(screen.route)
83
+ }
84
+
85
+ for (const evidence of knowledge.evidence) {
86
+ routes.add(`${basePath}/evidence/${evidence.id}/impact`)
87
+ }
88
+
89
+ if (knowledge.relationships.length) {
90
+ routes.add(`${basePath}/trace?entity=${knowledge.relationships[0].from}`)
91
+ }
92
+
93
+ return [...routes]
94
+ }
95
+
96
+ async function waitForServer (server, output) {
97
+ const startedAt = Date.now()
98
+
99
+ while (Date.now() - startedAt < timeoutMs) {
100
+ if (server.exitCode !== null) {
101
+ throw new Error(
102
+ `The prototype exited with code ${server.exitCode} before it started listening.\n` +
103
+ indent(output())
104
+ )
105
+ }
106
+
107
+ try {
108
+ const response = await fetch(`${baseUrl}/hippocampus`)
109
+ if (response.ok) return
110
+ } catch (error) {
111
+ // Keep polling until the Prototype Kit has finished booting.
112
+ }
113
+
114
+ await wait(250)
115
+ }
116
+
117
+ throw new Error(
118
+ `The prototype did not start listening on ${baseUrl} within ${timeoutMs}ms.\n` +
119
+ 'This command starts your app with PORT set, so if app.js hardcodes a port\n' +
120
+ 'it will be running somewhere else. Otherwise raise SMOKE_TIMEOUT_MS.\n' +
121
+ indent(output())
122
+ )
123
+ }
124
+
125
+ async function smokeRoutes (routes) {
126
+ const failures = []
127
+
128
+ for (const route of routes) {
129
+ // `Connection: close` avoids a Node/undici keep-alive assertion bug
130
+ // (`assert(!this.paused)` in Parser.finish) seen with larger response
131
+ // bodies across many sequential fetches on Node 24.
132
+ const response = await fetch(`${baseUrl}${route}`, { headers: { Connection: 'close' } })
133
+ await response.arrayBuffer()
134
+ if (response.status !== 200) failures.push(`${route} returned ${response.status}`)
135
+ }
136
+
137
+ if (failures.length) {
138
+ throw new Error(`Route smoke test failed:\n- ${failures.join('\n- ')}`)
139
+ }
140
+ }
141
+
142
+ function indent (text) {
143
+ const trimmed = String(text || '').trim()
144
+ if (!trimmed) return ' (the prototype printed nothing)'
145
+ return trimmed.split('\n').slice(-12).map((line) => ` ${line}`).join('\n')
146
+ }
147
+
148
+ function wait (ms) {
149
+ return new Promise((resolve) => setTimeout(resolve, ms))
150
+ }
151
+
152
+ main()
153
+ .then(() => process.exit(0))
154
+ .catch((error) => {
155
+ console.error(error.message)
156
+ process.exit(1)
157
+ })