@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,274 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Triage a research folder before anything is imported.
4
+ *
5
+ * `research:sync` answers "did the import run?". It cannot answer the question
6
+ * a team actually asks first: out of several hundred files in a shared drive,
7
+ * which ones are worth turning into knowledge, and what would each become?
8
+ *
9
+ * This script reads every artefact, scores the signals that matter to the
10
+ * Hippocampus schema, and writes a ranked review pack. It never writes to
11
+ * hippocampus/source and never promotes anything.
12
+ *
13
+ * node scripts/triage-research-corpus.js --source "/path/to/folder" --topic ncrs-ur
14
+ */
15
+
16
+ const fs = require('fs')
17
+ const path = require('path')
18
+ const { extractArtefact, supports, sourceTypeFor } = require('./lib/artefact-text')
19
+ const { createContextOrExit } = require('../lib/context')
20
+ const ctx = createContextOrExit()
21
+ const { researchInboxDir } = ctx.paths
22
+
23
+ const MEDIA = new Set(['.mp4', '.mov', '.png', '.jpg', '.jpeg', '.webp', '.gif', '.svg', '.msg', '.mht', '.msi', '.json', '.html', '.download', '.dotx'])
24
+
25
+ /** Signals that predict which Hippocampus record a file can become. */
26
+ const SIGNALS = {
27
+ participant: /\bP\d{1,3}\b/g,
28
+ quote: /“[^”]{40,}”/g,
29
+ insight: /\b(insight|finding|theme|what we learned|key takeaway|pain point|problem statement)\b/gi,
30
+ action: /\b(design action|recommendation|next action|opportunity|we should|we will)\b/gi,
31
+ need: /\b(user need|i need to|so that i can|as an? [a-z ]{3,30}, i need)\b/gi,
32
+ assumption: /\b(assumption|we assume|riskiest assumption|hypothesis)\b/gi,
33
+ scenario: /\b(scenario|use case|journey|workflow|trigger)\b/gi,
34
+ persona: /\b(persona|goals|frustrations|motivations|responsibilities)\b/gi,
35
+ method: /\b(discussion guide|research plan|screener|consent|recruitment|interview guide)\b/gi
36
+ }
37
+
38
+ /**
39
+ * Weighted because a direct participant quote is worth far more to the graph
40
+ * than another occurrence of the word "theme".
41
+ */
42
+ const WEIGHTS = { quote: 4, participantsUnique: 3, insight: 3, need: 3, action: 2, assumption: 2, persona: 1, scenario: 1 }
43
+
44
+ async function main () {
45
+ const args = parseArgs(process.argv.slice(2))
46
+ const sourceDir = path.resolve(args.source || researchInboxDir)
47
+ const date = args.date || new Date().toISOString().slice(0, 10)
48
+ const topic = args.topic || kebab(path.basename(sourceDir))
49
+
50
+ if (!fs.existsSync(sourceDir)) throw new Error(`Source folder not found: ${sourceDir}`)
51
+
52
+ const files = listFiles(sourceDir)
53
+ const results = []
54
+
55
+ for (const file of files) {
56
+ results.push(await triageFile(file, sourceDir))
57
+ }
58
+
59
+ results.sort((a, b) => b.score - a.score)
60
+
61
+ const outputDir = path.join(ctx.paths.artefactsDir, 'research-triage')
62
+ fs.mkdirSync(outputDir, { recursive: true })
63
+ const reportPath = path.join(outputDir, `${date}-${topic}.md`)
64
+ const dataPath = path.join(outputDir, `${date}-${topic}.json`)
65
+
66
+ fs.writeFileSync(reportPath, buildReport({ sourceDir, date, topic, results }))
67
+ fs.writeFileSync(dataPath, `${JSON.stringify({ sourceDir, date, topic, results }, null, 2)}\n`)
68
+
69
+ reportToConsole(results)
70
+ console.log(`\nTriage report written to ${path.relative(ctx.root, reportPath)}`)
71
+ console.log(`Triage data written to ${path.relative(ctx.root, dataPath)}`)
72
+ }
73
+
74
+ async function triageFile (file, sourceDir) {
75
+ const rel = path.relative(sourceDir, file)
76
+ const extension = path.extname(file).toLowerCase()
77
+ const base = {
78
+ path: rel,
79
+ name: path.basename(file),
80
+ extension,
81
+ bytes: fs.statSync(file).size,
82
+ round: roundFrom(rel),
83
+ stage: stageFrom(rel),
84
+ importableToday: ['.pptx', '.pdf'].includes(extension),
85
+ score: 0
86
+ }
87
+
88
+ if (!supports(file)) {
89
+ return { ...base, status: MEDIA.has(extension) ? 'media (register as evidence, no text)' : 'unsupported', target: 'evidence artefact only' }
90
+ }
91
+
92
+ let extracted
93
+ try {
94
+ extracted = await extractArtefact(file)
95
+ } catch (error) {
96
+ return { ...base, status: `extract failed: ${error.message}`, target: 'needs manual conversion' }
97
+ }
98
+
99
+ const lines = extracted.sections.flatMap((section) => [...section.text, ...section.notes])
100
+ const blob = lines.join('\n')
101
+ // Spreadsheet rows repeat their column header on every cell so the structuring
102
+ // step keeps the field name. That is right for the draft and wrong for a volume
103
+ // measure, so headers are stripped back out before counting.
104
+ const measurable = blob.replace(/(^|\| )[^:|]{1,60}: /gm, '$1')
105
+ const words = measurable.trim() ? measurable.trim().split(/\s+/).length : 0
106
+ const counts = countSignals(blob)
107
+ const score = Math.round(
108
+ Object.entries(WEIGHTS).reduce((total, [key, weight]) => total + (counts[key] || 0) * weight, 0) +
109
+ Math.min(words, 4000) / 200
110
+ )
111
+
112
+ return {
113
+ ...base,
114
+ status: words < 30 ? 'low yield (likely image-only)' : 'text extracted',
115
+ sourceType: extracted.sourceType,
116
+ sections: extracted.sections.length,
117
+ words,
118
+ notesWords: extracted.sections.reduce((total, section) => total + section.notes.join(' ').split(/\s+/).filter(Boolean).length, 0),
119
+ signals: counts,
120
+ target: classify(rel, base.name, counts),
121
+ score
122
+ }
123
+ }
124
+
125
+ function countSignals (blob) {
126
+ const counts = {}
127
+ for (const [key, pattern] of Object.entries(SIGNALS)) {
128
+ counts[key] = (blob.match(pattern) || []).length
129
+ }
130
+ counts.participantsUnique = new Set(blob.match(SIGNALS.participant) || []).size
131
+ return counts
132
+ }
133
+
134
+ /** Maps a file to the Hippocampus records it could realistically become. */
135
+ function classify (rel, name, signals) {
136
+ if (/detailed scenario map/i.test(name)) return 'scenarios (locationSearch) + participants'
137
+ if (/persona/i.test(rel) && signals.persona >= 3) return 'users + needs'
138
+ if (/insight|design action|tracker/i.test(name) && /\.xlsx$/i.test(name)) return 'insights + design-actions + relationships'
139
+ if (/transcript/i.test(name)) return 'evidence (quotes) + participants'
140
+ if (/urfinder|findings|playback|show and tell|summary of key/i.test(name)) return 'research-round + insights'
141
+ if (/interviewed list|participant log|user panel/i.test(rel)) return 'participants'
142
+ if (/assumption/i.test(name)) return 'assumptions'
143
+ if (signals.method >= 2 && signals.quote < 3) return 'method artefact (context only)'
144
+ if (signals.quote >= 5 || signals.insight >= 5) return 'insights + evidence'
145
+ if (/\.url$/i.test(name)) return 'evidence (external link)'
146
+ return 'low signal - review manually'
147
+ }
148
+
149
+ function roundFrom (rel) {
150
+ const match = rel.match(/Round\s*(\d+)/i)
151
+ return match ? `round-${match[1]}` : ''
152
+ }
153
+
154
+ /** The 01/02/03/04 folder convention is reliable provenance - use it. */
155
+ function stageFrom (rel) {
156
+ const segment = rel.split(path.sep).find((part) => /^0\d[\s_-]/.test(part))
157
+ if (!segment) return ''
158
+ if (/plan/i.test(segment)) return 'planning'
159
+ if (/doing/i.test(segment)) return 'fieldwork'
160
+ if (/analys/i.test(segment)) return 'analysis'
161
+ if (/shar/i.test(segment)) return 'sharing'
162
+ if (/design action/i.test(segment)) return 'design actions'
163
+ return segment.trim()
164
+ }
165
+
166
+ function buildReport ({ sourceDir, date, topic, results }) {
167
+ const text = results.filter((r) => r.status === 'text extracted')
168
+ const tiers = {
169
+ 'Tier 1 - promote first': text.filter((r) => r.score >= 120),
170
+ 'Tier 2 - strong supporting material': text.filter((r) => r.score >= 40 && r.score < 120),
171
+ 'Tier 3 - context only': text.filter((r) => r.score < 40)
172
+ }
173
+
174
+ const lines = [
175
+ `# Research corpus triage: ${topic}`,
176
+ '',
177
+ `- **Source:** ${sourceDir}`,
178
+ `- **Triaged:** ${date}`,
179
+ `- **Files scanned:** ${results.length}`,
180
+ `- **Text extracted:** ${text.length}`,
181
+ `- **Words available:** ${text.reduce((total, r) => total + r.words, 0).toLocaleString()}`,
182
+ `- **Importable by \`research:sync\` today:** ${results.filter((r) => r.importableToday).length}`,
183
+ '',
184
+ 'This is a review pack, not an import. Nothing in `hippocampus/source/` has changed.',
185
+ '',
186
+ '## Coverage gap',
187
+ '',
188
+ '| Extension | Files | Words | Supported by research:sync |',
189
+ '| --- | ---: | ---: | --- |'
190
+ ]
191
+
192
+ const byExtension = new Map()
193
+ for (const result of results) {
194
+ const entry = byExtension.get(result.extension) || { files: 0, words: 0 }
195
+ entry.files += 1
196
+ entry.words += result.words || 0
197
+ byExtension.set(result.extension, entry)
198
+ }
199
+ for (const [extension, entry] of [...byExtension].sort((a, b) => b[1].files - a[1].files)) {
200
+ lines.push(`| \`${extension || '(none)'}\` | ${entry.files} | ${entry.words.toLocaleString()} | ${['.pptx', '.pdf'].includes(extension) ? 'yes' : 'no'} |`)
201
+ }
202
+
203
+ const narrative = text.filter((r) => ['.docx', '.pptx', '.pdf', '.txt', '.md'].includes(r.extension))
204
+ lines.push(
205
+ '',
206
+ `Word totals are skewed by operational spreadsheets and data extracts. Research narrative — documents, decks and reports — accounts for ${narrative.reduce((total, r) => total + r.words, 0).toLocaleString()} words across ${narrative.length} files, and that is the material insights come from.`
207
+ )
208
+
209
+ for (const [tier, items] of Object.entries(tiers)) {
210
+ lines.push('', `## ${tier} (${items.length})`, '')
211
+ if (!items.length) { lines.push('_None._'); continue }
212
+ lines.push('| Score | Words | Quotes | P# | Becomes | File |', '| ---: | ---: | ---: | ---: | --- | --- |')
213
+ for (const item of items.slice(0, 40)) {
214
+ lines.push(`| ${item.score} | ${item.words} | ${item.signals.quote} | ${item.signals.participantsUnique} | ${item.target} | \`${item.path}\` |`)
215
+ }
216
+ if (items.length > 40) lines.push(`| | | | | | _...and ${items.length - 40} more, see the JSON sidecar_ |`)
217
+ }
218
+
219
+ const problems = results.filter((r) => r.status !== 'text extracted')
220
+ lines.push('', `## Needs handling before import (${problems.length})`, '')
221
+ if (!problems.length) {
222
+ lines.push('_None._')
223
+ } else {
224
+ lines.push('| Issue | File |', '| --- | --- |')
225
+ for (const item of problems.slice(0, 60)) lines.push(`| ${item.status} | \`${item.path}\` |`)
226
+ if (problems.length > 60) lines.push(`| _...and ${problems.length - 60} more_ | |`)
227
+ }
228
+
229
+ return `${lines.join('\n')}\n`
230
+ }
231
+
232
+ function reportToConsole (results) {
233
+ const text = results.filter((r) => r.status === 'text extracted')
234
+ console.log(`Files scanned: ${results.length}`)
235
+ console.log(`Text extracted: ${text.length}`)
236
+ console.log(`Importable by research:sync today: ${results.filter((r) => r.importableToday).length}`)
237
+ console.log('\nTop contenders:')
238
+ for (const item of text.slice(0, 15)) {
239
+ console.log(`- ${String(item.score).padStart(5)} ${item.target.padEnd(46)} ${item.path}`)
240
+ }
241
+ }
242
+
243
+ function listFiles (dir) {
244
+ return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
245
+ if (entry.name.startsWith('.') || entry.name.startsWith('~$')) return []
246
+ const full = path.join(dir, entry.name)
247
+ if (entry.isDirectory()) return listFiles(full)
248
+ if (entry.isFile()) return [full]
249
+ return []
250
+ })
251
+ }
252
+
253
+ function parseArgs (args) {
254
+ const parsed = {}
255
+ for (let index = 0; index < args.length; index++) {
256
+ const arg = args[index]
257
+ if (!arg.startsWith('--')) continue
258
+ const [rawKey, value] = arg.slice(2).split('=', 2)
259
+ const key = rawKey.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())
260
+ if (value !== undefined) parsed[key] = value
261
+ else if (args[index + 1] && !args[index + 1].startsWith('--')) { parsed[key] = args[index + 1]; index++ }
262
+ else parsed[key] = true
263
+ }
264
+ return parsed
265
+ }
266
+
267
+ function kebab (value) {
268
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
269
+ }
270
+
271
+ main().catch((error) => {
272
+ console.error(error.message)
273
+ process.exit(1)
274
+ })
@@ -0,0 +1,49 @@
1
+ const { createContextOrExit } = require('../lib/context')
2
+ const { lintKnowledge } = require('../lib/lint-knowledge')
3
+
4
+ const ctx = createContextOrExit()
5
+ const strict = process.argv.includes('--strict')
6
+ const quiet = process.argv.includes('--quiet')
7
+
8
+ function main () {
9
+ let failed = false
10
+
11
+ for (const project of ctx.requireProjects()) {
12
+ const result = project.store.validateKnowledge()
13
+
14
+ if (!result.valid) {
15
+ failed = true
16
+ console.error(`${project.name}: knowledge data is invalid`)
17
+ for (const error of result.errors) console.error(` error ${error}`)
18
+ continue
19
+ }
20
+
21
+ const knowledge = project.store.getKnowledge()
22
+ const { warnings, infos } = lintKnowledge(knowledge)
23
+
24
+ for (const warning of knowledge.meta?.warnings || []) {
25
+ console.warn(` warning ${warning}`)
26
+ }
27
+
28
+ console.log(`${project.name}: knowledge data is valid`)
29
+
30
+ for (const warning of warnings) console.warn(` warning ${warning.message}`)
31
+ if (!quiet) {
32
+ for (const info of infos) console.log(` info ${info.message}`)
33
+ }
34
+
35
+ // Warnings are gaps, not breakage: a project three days old has plenty and
36
+ // should still pass. --strict is for the pipeline that has decided it is
37
+ // past that stage.
38
+ if (strict && warnings.length) failed = true
39
+ }
40
+
41
+ process.exit(failed ? 1 : 0)
42
+ }
43
+
44
+ try {
45
+ main()
46
+ } catch (error) {
47
+ console.error(error.message)
48
+ process.exit(1)
49
+ }
@@ -0,0 +1,247 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+ const { z } = require('zod')
4
+
5
+ const packagePaths = require('../lib/package-paths')
6
+
7
+ // Skills live in the package; the paths inside them (reads:, and the
8
+ // `.github/skills/<id>.skill.md` references in guidance) describe a HOST
9
+ // project, because that is where an agent reads them. So the two roots are
10
+ // separate: skills are validated from the package, their paths against a host.
11
+ // --host defaults to the demo app, which is an ordinary consumer.
12
+ const rootDir = packagePaths.packageRoot
13
+ const skillsDir = packagePaths.skillsDir
14
+ const catalogPath = path.join(skillsDir, 'skills.json')
15
+
16
+ function readHostRoot () {
17
+ const flag = process.argv.indexOf('--host')
18
+ if (flag !== -1 && process.argv[flag + 1]) return path.resolve(process.argv[flag + 1])
19
+ return path.resolve(rootDir, '../../apps/demo')
20
+ }
21
+
22
+ const hostRoot = readHostRoot()
23
+ const requiredBodyHeadings = [
24
+ '## Use when',
25
+ '## Workflow',
26
+ '## Guardrails',
27
+ '## Finish'
28
+ ]
29
+ const kebabCasePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
30
+
31
+ const catalogEntrySchema = z.object({
32
+ id: z.string().regex(kebabCasePattern),
33
+ title: z.string().min(1),
34
+ purpose: z.string().min(1),
35
+ file: z.string().regex(/^[^\0]+\.skill\.md$/),
36
+ owners: z.array(z.string().regex(kebabCasePattern)).min(1),
37
+ composes: z.array(z.string().regex(kebabCasePattern)).min(1).optional(),
38
+ legacyPrompt: z.string().regex(/^[^\0]+\.prompt\.md$/).optional(),
39
+ runs: z.array(z.string().min(1)).min(1),
40
+ outputs: z.array(z.string().min(1)).min(1),
41
+ acceptance: z.array(z.string().min(1)).min(1)
42
+ }).strict()
43
+
44
+ const catalogSchema = z.object({
45
+ $schema: z.string(),
46
+ version: z.number().int().positive(),
47
+ format: z.string().min(1),
48
+ root: z.string().min(1),
49
+ // agentsRoot named a directory of agent cards in the old repo layout. The
50
+ // package ships skills, not agents - a host names its own agents - so it is
51
+ // optional now rather than required.
52
+ agentsRoot: z.string().min(1).optional(),
53
+ skills: z.array(catalogEntrySchema).min(1)
54
+ }).strict()
55
+
56
+ const frontMatterSchema = z.object({
57
+ id: z.string().regex(kebabCasePattern),
58
+ title: z.string().min(1),
59
+ purpose: z.string().min(1),
60
+ owners: z.array(z.string().regex(kebabCasePattern)).min(1),
61
+ legacy_prompt: z.string().regex(/^[^\0]+\.prompt\.md$/).optional(),
62
+ composes: z.array(z.string().regex(kebabCasePattern)).min(1).optional(),
63
+ reads: z.array(z.string().min(1)).min(1),
64
+ edits: z.array(z.string().min(1)).min(1),
65
+ runs: z.array(z.string().min(1)).min(1),
66
+ outputs: z.array(z.string().min(1)).min(1),
67
+ guardrails: z.array(z.string().min(1)).min(1),
68
+ acceptance: z.array(z.string().min(1)).min(1)
69
+ }).strict()
70
+
71
+ function main () {
72
+ const catalog = catalogSchema.parse(readJson(catalogPath))
73
+ const errors = []
74
+ const skillFiles = fs.readdirSync(skillsDir)
75
+ .filter((name) => name.endsWith('.skill.md'))
76
+ .sort()
77
+ const catalogFiles = new Set(catalog.skills.map((skill) => skill.file))
78
+ const catalogIds = new Set(catalog.skills.map((skill) => skill.id))
79
+
80
+ for (const skillFile of skillFiles) {
81
+ if (!catalogFiles.has(skillFile)) {
82
+ errors.push(`Skill file is not listed in skills.json: ${path.join('.github/skills', skillFile)}`)
83
+ }
84
+ }
85
+
86
+ for (const entry of catalog.skills) {
87
+ const skillPath = path.join(skillsDir, entry.file)
88
+ if (!fs.existsSync(skillPath)) {
89
+ errors.push(`Catalog entry points to missing skill file: ${path.join('.github/skills', entry.file)}`)
90
+ continue
91
+ }
92
+
93
+ if (entry.legacyPrompt && !fs.existsSync(path.join(hostRoot, entry.legacyPrompt))) {
94
+ errors.push(`Catalog entry points to missing legacy prompt: ${entry.legacyPrompt}`)
95
+ }
96
+
97
+ for (const composedId of entry.composes || []) {
98
+ if (!catalogIds.has(composedId)) {
99
+ errors.push(`Catalog entry ${entry.id} composes unknown skill: ${composedId}`)
100
+ }
101
+ }
102
+
103
+ validateSkillFile(skillPath, entry, catalogIds, errors)
104
+ }
105
+
106
+ if (errors.length) {
107
+ console.error('Skill catalog is invalid')
108
+ for (const error of errors) {
109
+ console.error(`- ${error}`)
110
+ }
111
+ process.exit(1)
112
+ }
113
+
114
+ console.log(`Skill catalog is valid (${catalog.skills.length} skills)`)
115
+ }
116
+
117
+ function validateSkillFile (skillPath, catalogEntry, catalogIds, errors) {
118
+ let parsed
119
+
120
+ try {
121
+ parsed = parseFrontMatter(fs.readFileSync(skillPath, 'utf8'), skillPath)
122
+ } catch (error) {
123
+ errors.push(error.message)
124
+ return
125
+ }
126
+
127
+ const { frontMatter, body } = parsed
128
+
129
+ const result = frontMatterSchema.safeParse(frontMatter)
130
+
131
+ if (!result.success) {
132
+ for (const issue of result.error.issues) {
133
+ errors.push(`${path.relative(rootDir, skillPath)} front matter: ${issue.path.join('.')} ${issue.message}`.trim())
134
+ }
135
+ return
136
+ }
137
+
138
+ const metadata = result.data
139
+
140
+ if (metadata.legacy_prompt && !fs.existsSync(path.join(hostRoot, metadata.legacy_prompt))) {
141
+ errors.push(`${path.relative(rootDir, skillPath)} points to missing legacy prompt: ${metadata.legacy_prompt}`)
142
+ }
143
+
144
+ for (const composedId of metadata.composes || []) {
145
+ if (!catalogIds.has(composedId)) {
146
+ errors.push(`${path.relative(rootDir, skillPath)} composes unknown skill: ${composedId}`)
147
+ }
148
+ }
149
+
150
+ for (const heading of requiredBodyHeadings) {
151
+ if (!body.includes(heading)) {
152
+ errors.push(`${path.relative(rootDir, skillPath)} is missing body heading: ${heading}`)
153
+ }
154
+ }
155
+
156
+ // A skill's reads: are paths in the project an agent is working on, so they
157
+ // resolve against the host. A path inside the installed package resolves
158
+ // against the package itself - in a real host that is a directory under
159
+ // node_modules, and in this workspace it is the package we are validating.
160
+ const INSTALLED = 'node_modules/@hippo-digital/hippocampus/'
161
+ for (const readPath of metadata.reads) {
162
+ if (readPath.includes('[')) continue
163
+ if (readPath.startsWith(INSTALLED)) {
164
+ const withinPackage = readPath.slice(INSTALLED.length)
165
+ if (fs.existsSync(path.join(rootDir, withinPackage))) continue
166
+ errors.push(`${path.relative(rootDir, skillPath)} reads a path the package does not ship: ${withinPackage}`)
167
+ continue
168
+ }
169
+ if (fs.existsSync(path.join(hostRoot, readPath))) continue
170
+ if (fs.existsSync(path.join(rootDir, readPath))) continue
171
+ errors.push(`${path.relative(rootDir, skillPath)} reads missing path: ${readPath}`)
172
+ }
173
+
174
+ compareField('id', catalogEntry.id, metadata.id, skillPath, errors)
175
+ compareField('title', catalogEntry.title, metadata.title, skillPath, errors)
176
+ compareField('purpose', catalogEntry.purpose, metadata.purpose, skillPath, errors)
177
+ compareField('owners', catalogEntry.owners, metadata.owners, skillPath, errors)
178
+ compareField('runs', catalogEntry.runs, metadata.runs, skillPath, errors)
179
+ compareField('outputs', catalogEntry.outputs, metadata.outputs, skillPath, errors)
180
+ compareField('acceptance', catalogEntry.acceptance, metadata.acceptance, skillPath, errors)
181
+ compareField('legacy prompt', catalogEntry.legacyPrompt || null, metadata.legacy_prompt || null, skillPath, errors)
182
+ compareField('composes', catalogEntry.composes || [], metadata.composes || [], skillPath, errors)
183
+ }
184
+
185
+ function compareField (label, expected, actual, skillPath, errors) {
186
+ if (JSON.stringify(expected) !== JSON.stringify(actual)) {
187
+ errors.push(`${path.relative(rootDir, skillPath)} does not match catalog for ${label}`)
188
+ }
189
+ }
190
+
191
+ function readJson (filePath) {
192
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'))
193
+ }
194
+
195
+ function parseFrontMatter (text, filePath) {
196
+ if (typeof text !== 'string') {
197
+ throw new Error(`${path.relative(rootDir, filePath)} could not be read as UTF-8 text`)
198
+ }
199
+
200
+ if (!text.startsWith('---\n')) {
201
+ throw new Error(`${path.relative(rootDir, filePath)} is missing opening front matter fence`)
202
+ }
203
+
204
+ const closingFenceIndex = text.indexOf('\n---\n', 4)
205
+ if (closingFenceIndex === -1) {
206
+ throw new Error(`${path.relative(rootDir, filePath)} is missing closing front matter fence`)
207
+ }
208
+
209
+ const frontMatterBlock = text.slice(4, closingFenceIndex)
210
+ const body = text.slice(closingFenceIndex + 5)
211
+ const frontMatter = {}
212
+ let currentKey = null
213
+
214
+ for (const line of frontMatterBlock.split('\n')) {
215
+ if (!line.trim()) continue
216
+
217
+ const keyMatch = line.match(/^([a-z_]+):(?:\s+(.*))?$/)
218
+ if (keyMatch) {
219
+ const [, key, value] = keyMatch
220
+ currentKey = key
221
+ if (value) {
222
+ frontMatter[key] = value.trim()
223
+ } else {
224
+ frontMatter[key] = []
225
+ }
226
+ continue
227
+ }
228
+
229
+ const itemMatch = line.match(/^\s*-\s+(.*)$/)
230
+ if (itemMatch && currentKey) {
231
+ if (!Array.isArray(frontMatter[currentKey])) {
232
+ throw new Error(`${path.relative(rootDir, filePath)} has a list item under non-list key: ${currentKey}`)
233
+ }
234
+ frontMatter[currentKey].push(itemMatch[1].trim())
235
+ continue
236
+ }
237
+
238
+ throw new Error(`${path.relative(rootDir, filePath)} has unsupported front matter line: ${line}`)
239
+ }
240
+
241
+ return { frontMatter, body }
242
+ }
243
+
244
+ main()
245
+
246
+
247
+
@@ -0,0 +1,39 @@
1
+ # Prompt to Skill Coverage Matrix
2
+
3
+ This matrix shows how the archived prompt pack now maps to the active skill catalog and role agents.
4
+
5
+ ## Direct prompt replacements
6
+
7
+ | Archived prompt | Current skill | Preferred role agents | Coverage | Notes |
8
+ | --- | --- | --- | --- | --- |
9
+ | `generate-service-slice.prompt.md` | `generate-service-slice`, `deliver-service-slice` | `knowledge-builder`, `workflow-orchestrator` | direct + bundled | Low-level slice creation plus an end-to-end orchestration entrypoint. |
10
+ | `add-user-needs.prompt.md` | `add-user-needs`, `deliver-service-slice` | `knowledge-builder`, `workflow-orchestrator` | direct + bundled | Kept as a focused source-edit skill and included in the service-slice bundle. |
11
+ | `add-scenario.prompt.md` | `add-scenario`, `deliver-service-slice` | `knowledge-builder`, `workflow-orchestrator` | direct + bundled | Scenario authoring remains separate and is also part of service-slice delivery. |
12
+ | `create-journey-from-scenario.prompt.md` | `create-journey-from-scenario`, `deliver-service-slice` | `knowledge-builder`, `workflow-orchestrator` | direct + bundled | Journey authoring stays focused but now has an orchestration parent. |
13
+ | `add-prototype-screen.prompt.md` | `add-prototype-screen`, `deliver-service-slice` | `prototype-builder`, `workflow-orchestrator` | direct + bundled | Screen work remains usable standalone and inside slice delivery. |
14
+ | `audit-knowledge-source.prompt.md` | `audit-knowledge-source`, `write-delivery-summary` | `knowledge-builder`, `workflow-orchestrator` | direct + adjacent | The audit stays direct; delivery summaries turn audit findings into handover artefacts. |
15
+ | `hippocampus-structure-project-context.prompt.md` | `structure-project-context-draft`, `ingest-project-context` | `ingestion-curator`, `workflow-orchestrator` | direct + bundled | The original draft-fixing prompt now has a no-promote orchestration wrapper. |
16
+ | `hippocampus-structure-research.prompt.md` | `structure-research-draft`, `ingest-research-round` | `ingestion-curator`, `workflow-orchestrator` | direct + bundled | Draft structuring stays focused and is bundled into the full research-ingestion path. |
17
+ | `hippocampus-review-import-draft.prompt.md` | `review-research-import-draft`, `ingest-research-round` | `ingestion-curator`, `workflow-orchestrator` | direct + bundled | Review remains explicit and also acts as a gate inside the orchestration flow. |
18
+ | `hippocampus-map-research-to-graph.prompt.md` | `map-research-to-graph`, `ingest-research-round` | `ingestion-curator`, `knowledge-builder`, `workflow-orchestrator` | direct + bundled | Graph-linking remains focused but is now part of the normal research-ingestion path. |
19
+
20
+ ## Workflow bundles added after migration
21
+
22
+ | Workflow intent | Current orchestration skill | Preferred role agents | Uses |
23
+ | --- | --- | --- | --- |
24
+ | Research round from inbox to review-ready draft | `ingest-research-round` | `ingestion-curator`, `workflow-orchestrator` | `structure-research-draft`, `review-research-import-draft`, `map-research-to-graph` |
25
+ | Project-context artefact from inbox to review-ready draft | `ingest-project-context` | `ingestion-curator`, `workflow-orchestrator` | `structure-project-context-draft` |
26
+ | Service-slice delivery from source records to routes and summary | `deliver-service-slice` | `workflow-orchestrator`, `knowledge-builder`, `prototype-builder` | `generate-service-slice`, `add-user-needs`, `add-scenario`, `create-journey-from-scenario`, `add-prototype-screen`, `audit-knowledge-source` |
27
+ | Handover note for multi-step agent work | `write-delivery-summary` | `workflow-orchestrator` | validation, smoke, and dry-run results plus changed-file summary |
28
+ | Screenshot or route-note review pack for changed pages | `capture-route-review-pack` | `screenshot-runner`, `workflow-orchestrator` | smoke-tested route list plus dated manifest and captures/notes |
29
+ | Accessibility note for changed pages | `record-accessibility-review` | `accessibility-tester`, `workflow-orchestrator` | smoke-tested route list plus blockers/improvements note |
30
+ | Combined route review handover note | `write-route-review-summary` | `workflow-orchestrator`, `screenshot-runner`, `accessibility-tester` | screenshot pack, accessibility note, and delivery summary |
31
+
32
+ ## How to use this matrix
33
+
34
+ - Start with the role agent in `.github/agents/`.
35
+ - Use the direct skill when you need one focused change.
36
+ - Use the orchestration skill when the work spans multiple low-level skills or handoff steps.
37
+ - Treat `.github/legacy/prompts/` as historical context, not the active workflow source.
38
+
39
+