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