@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,261 @@
1
+ #!/usr/bin/env node
2
+ const crypto = require('crypto')
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+ const { createContextOrExit } = require('../lib/context')
6
+
7
+ // A durable record of what was ingested, and from where.
8
+ //
9
+ // The working assumption is that the shared drive is the system of record: a
10
+ // file is downloaded into the inbox, its knowledge is extracted, and the copy is
11
+ // deleted. That only works if a record in the knowledge base can still be
12
+ // traced to a named original afterwards - and a path like
13
+ // `hippocampus/source-artefacts/personas/x.docx` cannot, because it names the
14
+ // copy that is supposed to be deleted.
15
+ //
16
+ // So this walks the artefacts while they are still here and writes down the
17
+ // things that cannot be recovered once they are gone: the exact filename, the
18
+ // size, and the sha256 that proves a file found later is the same one. Origins
19
+ // are read from the manifests and the ingestion notes where they were recorded,
20
+ // and left explicitly unknown where they were not - a guessed provenance is
21
+ // worse than an admitted gap.
22
+
23
+ const argv = process.argv.slice(2)
24
+ const dryRun = argv.includes('--dry-run')
25
+ const ctx = createContextOrExit({ argv })
26
+
27
+ function main () {
28
+ const artefactsRoot = ctx.paths.sourceArtefactsDir
29
+ if (!fs.existsSync(artefactsRoot)) {
30
+ console.error(`No source artefacts directory at ${artefactsRoot}`)
31
+ process.exit(1)
32
+ }
33
+
34
+ const files = walk(artefactsRoot).sort()
35
+ const origins = readStatedOrigins(artefactsRoot, files)
36
+ const staged = readManifestEntries()
37
+ const citations = readCitations()
38
+
39
+ const entries = files.map((absolute) => {
40
+ const relative = path.relative(artefactsRoot, absolute).split(path.sep).join('/')
41
+ const contents = fs.readFileSync(absolute)
42
+ const sha256 = crypto.createHash('sha256').update(contents).digest('hex')
43
+ const stagedEntry = staged.bySha.get(sha256) || staged.byName.get(path.basename(relative))
44
+
45
+ // An ingestion note was written here; it has no upstream original, and
46
+ // counting it as one more artefact with an unknown origin overstates the gap.
47
+ const authoredHere = relative.endsWith('-ingestion.md')
48
+
49
+ return {
50
+ path: relative,
51
+ fileName: path.basename(relative),
52
+ authoredHere,
53
+ type: path.extname(relative).replace('.', '').toLowerCase() || 'none',
54
+ bytes: contents.length,
55
+ sha256,
56
+ roundId: stagedEntry?.roundId || roundFromPath(relative),
57
+ origin: origins.get(relative) || (stagedEntry
58
+ ? { stated: stagedEntry.sourcePath, statedIn: 'hippocampus/manifests/research.json' }
59
+ : { stated: null, statedIn: null }),
60
+ ingestedAs: stagedEntry?.id || null,
61
+ citedBy: citations.get(relative) || []
62
+ }
63
+ })
64
+
65
+ const index = {
66
+ version: 1,
67
+ generator: 'hippocampus source index',
68
+ indexedAt: new Date().toISOString(),
69
+ note: 'Written while the artefacts were still present. The shared drive is the ' +
70
+ 'system of record; these files are working copies and may have been deleted ' +
71
+ 'since. Match an original by fileName, and confirm it with sha256.',
72
+ artefactCount: entries.filter((e) => !e.authoredHere).length,
73
+ noteCount: entries.filter((e) => e.authoredHere).length,
74
+ withStatedOrigin: entries.filter((e) => !e.authoredHere && e.origin.stated).length,
75
+ missingOrigin: entries.filter((e) => !e.authoredHere && !e.origin.stated).map((e) => e.path),
76
+ cited: entries.filter((e) => e.citedBy.length).length,
77
+ artefacts: entries
78
+ }
79
+
80
+ const jsonPath = path.join(ctx.paths.manifestsDir, 'source-index.json')
81
+ const mdPath = path.join(ctx.paths.manifestsDir, 'source-index.md')
82
+
83
+ console.log(`${index.artefactCount} artefacts indexed (plus ${index.noteCount} ingestion notes written here)`)
84
+ console.log(` ${index.withStatedOrigin} with a recorded origin, ${index.missingOrigin.length} without`)
85
+ console.log(` ${index.cited} cited by a knowledge record`)
86
+ if (index.missingOrigin.length) {
87
+ console.log('\nNo origin recorded for these - worth writing down before the copies go:')
88
+ for (const missing of index.missingOrigin) console.log(` ${missing}`)
89
+ }
90
+
91
+ if (dryRun) {
92
+ console.log(`\nwould write ${path.relative(ctx.root, jsonPath)} and ${path.relative(ctx.root, mdPath)}`)
93
+ return
94
+ }
95
+
96
+ fs.mkdirSync(ctx.paths.manifestsDir, { recursive: true })
97
+ fs.writeFileSync(jsonPath, JSON.stringify(index, null, 2) + '\n')
98
+ fs.writeFileSync(mdPath, renderMarkdown(index))
99
+ console.log(`\nwrote ${path.relative(ctx.root, jsonPath)}`)
100
+ console.log(`wrote ${path.relative(ctx.root, mdPath)}`)
101
+ }
102
+
103
+ function walk (dir, out = []) {
104
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
105
+ const full = path.join(dir, entry.name)
106
+ if (entry.isDirectory()) walk(full, out)
107
+ else if (!entry.name.startsWith('.')) out.push(full)
108
+ }
109
+ return out
110
+ }
111
+
112
+ function roundFromPath (relative) {
113
+ const match = relative.match(/(?:^|\/)round-(\d+)(?:\/|$)/i)
114
+ return match ? `round-${match[1]}` : null
115
+ }
116
+
117
+ /**
118
+ * Ingestion notes record where material came from, in prose. They are read two
119
+ * ways, most specific first.
120
+ *
121
+ * Notes tend to be written as "`<origin>` to `hippocampus/source-artefacts/...`",
122
+ * so a quoted destination is paired with the quoted origin before it. That
123
+ * catches a file named individually even when it landed in a different folder
124
+ * from the note - the first attempt attributed notes only to files beside them,
125
+ * which missed exactly that case.
126
+ *
127
+ * Anything still unclaimed falls back to the note in its own directory. Every
128
+ * origin records which note asserted it, so the claim can be checked rather
129
+ * than trusted.
130
+ */
131
+ function readStatedOrigins (artefactsRoot, files) {
132
+ const origins = new Map()
133
+ const notes = files.filter((f) => f.endsWith('-ingestion.md'))
134
+ const relativeOf = (f) => path.relative(artefactsRoot, f).split(path.sep).join('/')
135
+
136
+ for (const note of notes) {
137
+ const text = fs.readFileSync(note, 'utf8')
138
+ const noteRel = path.relative(ctx.root, note).split(path.sep).join('/')
139
+ const quoted = [...text.matchAll(/`([^`]+)`/g)].map((m) => m[1])
140
+
141
+ let lastOrigin = null
142
+ for (const value of quoted) {
143
+ const destination = value.match(/source-artefacts\/(.+)$/)
144
+ if (!destination) {
145
+ if (/[/\\]/.test(value) && !value.startsWith('hippocampus/')) lastOrigin = value
146
+ continue
147
+ }
148
+ if (!lastOrigin) continue
149
+ const target = destination[1]
150
+ // A destination may be written with a placeholder, e.g. <user-id>; those
151
+ // stand for the folder rather than one file.
152
+ if (/[<>*]/.test(target)) continue
153
+ if (!origins.has(target)) origins.set(target, { stated: lastOrigin, statedIn: noteRel })
154
+ }
155
+ }
156
+
157
+ for (const note of notes) {
158
+ const text = fs.readFileSync(note, 'utf8')
159
+ const section = text.match(/^##\s+Sources?(?:\s+copied)?\s*$([\s\S]*?)(?=^##\s|\Z)/m)
160
+ const body = section ? section[1] : text.slice(0, 1200)
161
+ const quoted = [...body.matchAll(/`([^`]+)`/g)].map((m) => m[1])
162
+ const stated = quoted.find((q) => !q.startsWith('hippocampus/') && /[/\\]/.test(q))
163
+ if (!stated) continue
164
+
165
+ const noteDir = path.dirname(note)
166
+ const noteRel = path.relative(ctx.root, note).split(path.sep).join('/')
167
+ for (const file of files) {
168
+ if (path.dirname(file) !== noteDir || file === note) continue
169
+ const rel = relativeOf(file)
170
+ if (!origins.has(rel)) origins.set(rel, { stated, statedIn: noteRel })
171
+ }
172
+ }
173
+ return origins
174
+ }
175
+
176
+ function readManifestEntries () {
177
+ const bySha = new Map()
178
+ const byName = new Map()
179
+ for (const file of ['research.json', 'project.json']) {
180
+ const manifestPath = path.join(ctx.paths.manifestsDir, file)
181
+ if (!fs.existsSync(manifestPath)) continue
182
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
183
+ for (const entry of manifest.artefacts || []) {
184
+ if (entry.sha256) bySha.set(entry.sha256, entry)
185
+ if (entry.sourcePath) byName.set(path.basename(entry.sourcePath), entry)
186
+ }
187
+ }
188
+ return { bySha, byName }
189
+ }
190
+
191
+ /** Which knowledge records name each artefact, so a deletion is a known cost. */
192
+ function readCitations () {
193
+ const citations = new Map()
194
+ const dir = ctx.paths.knowledgeSourceDir
195
+ if (!fs.existsSync(dir)) return citations
196
+
197
+ for (const file of fs.readdirSync(dir)) {
198
+ if (!file.endsWith('.json') || file.startsWith('_')) continue
199
+ const data = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'))
200
+ for (const record of Array.isArray(data) ? data : [data]) {
201
+ const sourcePath = record && record.sourcePath
202
+ if (!sourcePath) continue
203
+ // Filenames contain spaces; stopping at the first one silently drops the
204
+ // citation and makes an artefact look unused.
205
+ const match = sourcePath.match(/source-artefacts\/([^,]+)/)
206
+ if (!match) continue
207
+ const key = match[1].trim()
208
+ const list = citations.get(key) || []
209
+ list.push(record.id)
210
+ citations.set(key, list)
211
+ }
212
+ }
213
+ return citations
214
+ }
215
+
216
+ function renderMarkdown (index) {
217
+ const lines = [
218
+ '# Source artefact index',
219
+ '',
220
+ index.note.replace(/(.{1,78})(\s|$)/g, '$1\n').trim(),
221
+ '',
222
+ `Indexed ${index.indexedAt.slice(0, 10)} — ${index.artefactCount} artefacts, ` +
223
+ `${index.withStatedOrigin} with a recorded origin, ${index.cited} cited by a knowledge record.` +
224
+ (index.missingOrigin.length
225
+ ? ` **${index.missingOrigin.length} have no recorded origin** and should get one before the copies are deleted.`
226
+ : ''),
227
+ '',
228
+ '| File | Type | Size | Round | Recorded origin | Cited by |',
229
+ '| --- | --- | ---: | --- | --- | ---: |'
230
+ ]
231
+
232
+ for (const a of index.artefacts) {
233
+ if (a.authoredHere) continue
234
+ lines.push([
235
+ '',
236
+ `\`${a.fileName}\``,
237
+ a.type,
238
+ `${Math.max(1, Math.round(a.bytes / 1024))} KB`,
239
+ a.roundId || '—',
240
+ a.origin.stated ? `\`${a.origin.stated}\`` : '**not recorded**',
241
+ a.citedBy.length || '—',
242
+ ''
243
+ ].join(' | ').trim())
244
+ }
245
+
246
+ lines.push('', '## Checksums', '',
247
+ 'sha256, so a file found on the drive later can be confirmed as the one that',
248
+ 'was ingested rather than an edited descendant.', '', '```')
249
+ for (const a of index.artefacts) {
250
+ if (!a.authoredHere) lines.push(`${a.sha256} ${a.path}`)
251
+ }
252
+ lines.push('```', '')
253
+ return lines.join('\n')
254
+ }
255
+
256
+ try {
257
+ main()
258
+ } catch (error) {
259
+ console.error(error.message)
260
+ process.exit(1)
261
+ }
@@ -0,0 +1,232 @@
1
+ #!/usr/bin/env node
2
+ const fs = require('fs')
3
+ const path = require('path')
4
+ const { resolveRoot, CONFIG_FILENAME } = require('../lib/resolve-root')
5
+ const { CURRENT_SCHEMA_VERSION } = require('../lib/schema-version')
6
+ const hostEdit = require('../lib/host-edit')
7
+
8
+ // Set a host project up to use Hippocampus: write the config and an empty
9
+ // knowledge base, then make the two host edits - both inside marked blocks, both
10
+ // reversible with `hippocampus eject`.
11
+ //
12
+ // --dry-run print what would change and write nothing
13
+ // --no-wire write the data, print the host edits to paste by hand
14
+ // --private-artefacts gitignore the copied research material
15
+
16
+ const argv = process.argv.slice(2)
17
+ const dryRun = argv.includes('--dry-run')
18
+ const noWire = argv.includes('--no-wire')
19
+ const privateArtefacts = argv.includes('--private-artefacts')
20
+
21
+ const ROUTES_BODY = `const { createHippocampus } = require('@hippo-digital/hippocampus')
22
+ router.use(createHippocampus().router)`
23
+
24
+ const APP_VIEWS_ENTRY = "require('@hippo-digital/hippocampus').createHippocampus().viewsPath"
25
+
26
+ const planned = []
27
+ const manual = []
28
+
29
+ function plan (description, write) {
30
+ planned.push({ description, write })
31
+ }
32
+
33
+ function main () {
34
+ const { root, source } = resolveRoot({ argv })
35
+ console.log(`Project root: ${root}`)
36
+ console.log(`Resolved from: ${source}\n`)
37
+
38
+ planConfig(root)
39
+ planKnowledgeBase(root)
40
+ planArtefactGovernance(root)
41
+ if (!noWire) {
42
+ planHostEdit(path.join(root, 'app/routes.js'), wireRoutes)
43
+ planHostEdit(path.join(root, 'app.js'), wireApp)
44
+ } else {
45
+ manual.push(manualInstructions())
46
+ }
47
+
48
+ if (!planned.length && !manual.length) {
49
+ console.log('Nothing to do - this project is already set up.')
50
+ return
51
+ }
52
+
53
+ for (const step of planned) {
54
+ console.log(`${dryRun ? 'would write' : 'wrote'} ${step.description}`)
55
+ if (!dryRun) step.write()
56
+ }
57
+
58
+ for (const note of manual) {
59
+ console.log(`\n${note}`)
60
+ }
61
+
62
+ if (!dryRun) {
63
+ console.log('\nNext: npx hippocampus doctor')
64
+ }
65
+ }
66
+
67
+ function planConfig (root) {
68
+ const configPath = path.join(root, CONFIG_FILENAME)
69
+ if (fs.existsSync(configPath)) return
70
+ plan(CONFIG_FILENAME, () => fs.writeFileSync(configPath, JSON.stringify({
71
+ configVersion: 1,
72
+ dataDir: 'hippocampus',
73
+ artefactsDir: 'artefacts',
74
+ viewer: { basePath: '/hippocampus' }
75
+ }, null, 2) + '\n'))
76
+ }
77
+
78
+ function planKnowledgeBase (root) {
79
+ const sourceDir = path.join(root, 'hippocampus/source')
80
+ const projectPath = path.join(sourceDir, 'project.json')
81
+ if (fs.existsSync(projectPath)) return
82
+
83
+ // IDs are kebab-case throughout the schema, and a directory name is not
84
+ // required to be. Deriving one that fails validation the first time somebody
85
+ // runs `validate` is a bad first impression for the sake of two lines.
86
+ const dirName = path.basename(root)
87
+ const id = kebab(dirName) || 'prototype'
88
+ const name = dirName
89
+ plan('hippocampus/source/project.json and an empty knowledge base', () => {
90
+ for (const dir of ['source', 'inbox/project', 'inbox/research', 'imports/project', 'imports/research', 'manifests']) {
91
+ fs.mkdirSync(path.join(root, 'hippocampus', dir), { recursive: true })
92
+ }
93
+ fs.writeFileSync(projectPath, JSON.stringify({
94
+ id: `${id}-knowledge`,
95
+ name,
96
+ description: 'Describe what this service is for. Everything in the knowledge base hangs off this.',
97
+ phase: 'discovery',
98
+ serviceArea: 'unset',
99
+ prototypeSlice: 'unset'
100
+ }, null, 2) + '\n')
101
+ fs.writeFileSync(path.join(sourceDir, '_meta.json'), JSON.stringify({
102
+ schemaVersion: CURRENT_SCHEMA_VERSION,
103
+ generator: 'hippocampus init',
104
+ updatedAt: new Date().toISOString()
105
+ }, null, 2) + '\n')
106
+ })
107
+ }
108
+
109
+ // source-artefacts holds the research material a round was built from -
110
+ // transcripts, scenario maps, quotes with participant identifiers attached.
111
+ // Putting that in a prototype repo's history is a decision for the team, and
112
+ // git history is not somewhere you can quietly take something back out of. So
113
+ // this package neither commits it by default nor ignores it by default: it says
114
+ // what is there and what the options are.
115
+ const ARTEFACTS_IGNORE_LINE = 'hippocampus/source-artefacts/'
116
+
117
+ function planArtefactGovernance (root) {
118
+ const artefactsDir = path.join(root, 'hippocampus/source-artefacts')
119
+ const gitignorePath = path.join(root, '.gitignore')
120
+ const current = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, 'utf8') : ''
121
+ const alreadyIgnored = current.includes(ARTEFACTS_IGNORE_LINE)
122
+
123
+ if (privateArtefacts) {
124
+ if (alreadyIgnored) return
125
+ plan(`.gitignore (${ARTEFACTS_IGNORE_LINE})`, () => {
126
+ const prefix = current.trim() ? `${current.replace(/\n*$/, '\n')}\n` : ''
127
+ fs.writeFileSync(gitignorePath, prefix +
128
+ '# Research material copied into the knowledge base: transcripts and\n' +
129
+ '# participant quotes. Kept out of this repo by `hippocampus init --private-artefacts`.\n' +
130
+ `${ARTEFACTS_IGNORE_LINE}\n`)
131
+ })
132
+ return
133
+ }
134
+
135
+ if (alreadyIgnored) return
136
+
137
+ const count = countFiles(artefactsDir)
138
+ if (!count) return
139
+
140
+ manual.push([
141
+ `${count} files in hippocampus/source-artefacts/ are not gitignored.`,
142
+ '',
143
+ 'That directory holds the research material rounds were built from -',
144
+ 'transcripts, scenario maps and quotes with participants attached. Committing',
145
+ 'it puts that in this repo\'s history, which is not somewhere you can quietly',
146
+ 'take it back out of.',
147
+ '',
148
+ 'If it should stay out of git:',
149
+ ' npx hippocampus init --private-artefacts',
150
+ '',
151
+ 'If your team has decided it belongs here, nothing to do.'
152
+ ].join('\n'))
153
+ }
154
+
155
+ function countFiles (dir) {
156
+ if (!fs.existsSync(dir)) return 0
157
+ let total = 0
158
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
159
+ total += entry.isDirectory() ? countFiles(path.join(dir, entry.name)) : 1
160
+ }
161
+ return total
162
+ }
163
+
164
+ function planHostEdit (filePath, edit) {
165
+ if (!fs.existsSync(filePath)) {
166
+ manual.push(`${path.basename(filePath)} not found, so it was not edited.\n${manualInstructions()}`)
167
+ return
168
+ }
169
+
170
+ const before = fs.readFileSync(filePath, 'utf8')
171
+ const result = edit(before)
172
+
173
+ if (result.action === 'unchanged') return
174
+
175
+ if (!result.source) {
176
+ manual.push(`${path.relative(process.cwd(), filePath)}: ${result.reason}\n${manualInstructions()}`)
177
+ return
178
+ }
179
+
180
+ if (dryRun) {
181
+ console.log(`\nwould change ${path.relative(process.cwd(), filePath)}:`)
182
+ console.log(hostEdit.diff(before, result.source, path.relative(process.cwd(), filePath)))
183
+ return
184
+ }
185
+
186
+ plan(`${path.relative(process.cwd(), filePath)} (${result.action}, backed up)`, () => {
187
+ hostEdit.backup(filePath)
188
+ fs.writeFileSync(filePath, result.source)
189
+ })
190
+ }
191
+
192
+ function wireRoutes (source) {
193
+ return hostEdit.upsertBlock(source, {
194
+ id: 'hippocampus',
195
+ body: ROUTES_BODY,
196
+ anchor: (src) => {
197
+ const exports = src.lastIndexOf('module.exports')
198
+ return exports === -1 ? src.length : exports
199
+ }
200
+ })
201
+ }
202
+
203
+ function wireApp (source) {
204
+ return hostEdit.addToStringArray(source, { name: 'viewsPath', entry: APP_VIEWS_ENTRY })
205
+ }
206
+
207
+ function kebab (value) {
208
+ return String(value)
209
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
210
+ .toLowerCase()
211
+ .replace(/[^a-z0-9]+/g, '-')
212
+ .replace(/^-+|-+$/g, '')
213
+ }
214
+
215
+ function manualInstructions () {
216
+ return [
217
+ 'Add these by hand:',
218
+ '',
219
+ ' app/routes.js',
220
+ ` ${ROUTES_BODY.split('\n').join('\n ')}`,
221
+ '',
222
+ ' app.js - add to the viewsPath array',
223
+ ` ${APP_VIEWS_ENTRY}`
224
+ ].join('\n')
225
+ }
226
+
227
+ try {
228
+ main()
229
+ } catch (error) {
230
+ console.error(error.message)
231
+ process.exit(1)
232
+ }
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env node
2
+ const crypto = require('crypto')
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+ const { resolveRoot } = require('../lib/resolve-root')
6
+ const packagePaths = require('../lib/package-paths')
7
+
8
+ // Skills are copied into the host, unlike docs, because an agent reads them as
9
+ // repo files. Two rules follow from that:
10
+ //
11
+ // 1. A host may already have its own skills. The catalog is MERGED, never
12
+ // replaced - overwriting somebody's skills.json deletes their work, and it
13
+ // is the kind of thing that only shows up on a repo that already has some.
14
+ // 2. Copies go stale, so each installed file records the hash of what was
15
+ // written. On a later run, a file that still matches is updated silently
16
+ // and one the team has edited is named and left alone.
17
+ //
18
+ // Shared files a host is entitled to own - the README, the format contract, the
19
+ // schema - are written only when absent.
20
+
21
+ const argv = process.argv.slice(2)
22
+ const dryRun = argv.includes('--dry-run')
23
+ const force = argv.includes('--force')
24
+ const MANIFEST = '.hippocampus-skills.json'
25
+ const NEVER_OVERWRITE = new Set(['README.md', 'SKILL-FORMAT.md', 'skills.schema.json', 'COVERAGE-MATRIX.md'])
26
+
27
+ function hash (text) {
28
+ return crypto.createHash('sha256').update(text).digest('hex').slice(0, 16)
29
+ }
30
+
31
+ function main () {
32
+ const { root } = resolveRoot({ argv })
33
+ const targetDir = path.join(root, '.github/skills')
34
+ const manifestPath = path.join(targetDir, MANIFEST)
35
+ const manifest = fs.existsSync(manifestPath)
36
+ ? JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
37
+ : { files: {}, skillIds: [] }
38
+ manifest.files = manifest.files || {}
39
+ manifest.skillIds = manifest.skillIds || []
40
+
41
+ console.log(`Project root: ${root}`)
42
+ console.log(`Installing to: ${path.relative(root, targetDir)}\n`)
43
+
44
+ const written = []
45
+ const skipped = []
46
+ const unchanged = []
47
+ const kept = []
48
+
49
+ for (const name of fs.readdirSync(packagePaths.skillsDir)) {
50
+ if (name === 'skills.json' || !/\.(skill\.md|md|json)$/.test(name)) continue
51
+
52
+ const to = path.join(targetDir, name)
53
+ const content = fs.readFileSync(path.join(packagePaths.skillsDir, name), 'utf8')
54
+ const incoming = hash(content)
55
+
56
+ if (fs.existsSync(to)) {
57
+ const current = hash(fs.readFileSync(to, 'utf8'))
58
+ if (current === incoming) { unchanged.push(name); continue }
59
+ if (NEVER_OVERWRITE.has(name)) { kept.push(name); continue }
60
+ const installed = manifest.files[name]
61
+ if (installed && current !== installed && !force) { skipped.push(name); continue }
62
+ } else if (NEVER_OVERWRITE.has(name) && name !== 'SKILL-FORMAT.md' && name !== 'skills.schema.json') {
63
+ // A README the host does not have is worth providing; one it does is not.
64
+ }
65
+
66
+ written.push(name)
67
+ if (!dryRun) {
68
+ fs.mkdirSync(targetDir, { recursive: true })
69
+ fs.writeFileSync(to, content)
70
+ }
71
+ manifest.files[name] = incoming
72
+ }
73
+
74
+ const merged = mergeCatalog(targetDir, manifest)
75
+
76
+ if (!dryRun && (written.length || merged.added.length)) {
77
+ manifest.version = require('../package.json').version
78
+ manifest.updatedAt = new Date().toISOString()
79
+ fs.mkdirSync(targetDir, { recursive: true })
80
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n')
81
+ }
82
+
83
+ console.log(`${dryRun ? 'would install' : 'installed'}: ${written.length} files`)
84
+ console.log(`${dryRun ? 'would add' : 'added'} to the catalog: ${merged.added.length} skills` +
85
+ (merged.existing ? ` (alongside ${merged.existing} already there)` : ''))
86
+ if (merged.refreshed) console.log(`refreshed in the catalog: ${merged.refreshed} entries`)
87
+ if (unchanged.length) console.log(`already current: ${unchanged.length}`)
88
+ if (kept.length) console.log(`left as yours: ${kept.join(', ')}`)
89
+ if (skipped.length) {
90
+ console.log(`\nleft alone because they have been edited here (${skipped.length}):`)
91
+ for (const name of skipped) console.log(` ${name}`)
92
+ console.log('\nRe-install them with --force once you have kept whatever you changed.')
93
+ }
94
+ }
95
+
96
+ /**
97
+ * A host's skills.json is theirs. Package skills are added by id; the host's own
98
+ * entries are never touched.
99
+ *
100
+ * An entry we installed is refreshed when the skill file beside it is still
101
+ * byte-for-byte ours - because the host's validator compares the two field by
102
+ * field, so a file that matches us with an entry that does not is simply a
103
+ * broken catalog. Keying this off "did we write the file this run?" was the
104
+ * first attempt and it was wrong: an upgrade that rewrites files and then fails
105
+ * part-way leaves exactly that mismatch, and the next run has to be able to
106
+ * finish the job.
107
+ *
108
+ * If the file has been edited locally it is left alone, and so is its entry.
109
+ */
110
+ function mergeCatalog (targetDir, manifest) {
111
+ const catalogPath = path.join(targetDir, 'skills.json')
112
+ const incoming = JSON.parse(fs.readFileSync(path.join(packagePaths.skillsDir, 'skills.json'), 'utf8'))
113
+
114
+ if (!fs.existsSync(catalogPath)) {
115
+ if (!dryRun) {
116
+ fs.mkdirSync(targetDir, { recursive: true })
117
+ fs.writeFileSync(catalogPath, JSON.stringify(incoming, null, 2) + '\n')
118
+ }
119
+ manifest.skillIds = incoming.skills.map((s) => s.id)
120
+ return { added: incoming.skills, existing: 0, refreshed: 0 }
121
+ }
122
+
123
+ const host = JSON.parse(fs.readFileSync(catalogPath, 'utf8'))
124
+ const present = new Set(host.skills.map((s) => s.id))
125
+ const added = incoming.skills.filter((s) => !present.has(s.id))
126
+
127
+ const ours = new Set(manifest.skillIds || [])
128
+ const incomingById = new Map(incoming.skills.map((s) => [s.id, s]))
129
+ let refreshed = 0
130
+ const updated = host.skills.map((entry) => {
131
+ const incomingEntry = incomingById.get(entry.id)
132
+ if (!incomingEntry || !ours.has(entry.id)) return entry
133
+ if (!fileIsStillOurs(targetDir, incomingEntry.file)) return entry
134
+ if (JSON.stringify(entry) === JSON.stringify(incomingEntry)) return entry
135
+ refreshed++
136
+ return incomingEntry
137
+ })
138
+
139
+ if ((added.length || refreshed) && !dryRun) {
140
+ host.skills = [...updated, ...added]
141
+ fs.writeFileSync(catalogPath, JSON.stringify(host, null, 2) + '\n')
142
+ }
143
+ manifest.skillIds = [...new Set([...manifest.skillIds, ...incoming.skills.map((s) => s.id)])]
144
+
145
+ return { added, existing: present.size, refreshed }
146
+ }
147
+
148
+ function fileIsStillOurs (targetDir, fileName) {
149
+ const hostFile = path.join(targetDir, fileName)
150
+ const packageFile = path.join(packagePaths.skillsDir, fileName)
151
+ if (!fs.existsSync(hostFile) || !fs.existsSync(packageFile)) return false
152
+ return fs.readFileSync(hostFile, 'utf8') === fs.readFileSync(packageFile, 'utf8')
153
+ }
154
+
155
+ try {
156
+ main()
157
+ } catch (error) {
158
+ console.error(error.message)
159
+ process.exit(1)
160
+ }