@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,147 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const fs = require('fs')
|
|
3
|
+
const path = require('path')
|
|
4
|
+
const { createContextOrExit } = require('../lib/context')
|
|
5
|
+
|
|
6
|
+
// "Where did this come from?" asked of every record, and answered honestly.
|
|
7
|
+
//
|
|
8
|
+
// Provenance in this schema lives in three places - evidence, research rounds
|
|
9
|
+
// and screens - and everything else reaches an original through them. So this
|
|
10
|
+
// walks those three, then asks which records reach nothing.
|
|
11
|
+
//
|
|
12
|
+
// A record with no source is not automatically a defect: an assumption is
|
|
13
|
+
// explicitly an assertion the design rests on, and saying so is provenance. The
|
|
14
|
+
// defect is a record that reads as a finding but cannot be traced, and a source
|
|
15
|
+
// path too vague to find the file it names.
|
|
16
|
+
|
|
17
|
+
const argv = process.argv.slice(2)
|
|
18
|
+
const strict = argv.includes('--strict')
|
|
19
|
+
const ctx = createContextOrExit({ argv })
|
|
20
|
+
|
|
21
|
+
// A path that names a real file someone could go and open, versus one that
|
|
22
|
+
// gestures at a folder. `Round 4 - ...` is the shape that fails: it looks like a
|
|
23
|
+
// citation and identifies nothing.
|
|
24
|
+
const VAGUE = [/\.\.\./, /^\s*$/, /\*/]
|
|
25
|
+
const ASSERTION_TYPES = /assumption|principle|decision|local repo reference/i
|
|
26
|
+
|
|
27
|
+
function main () {
|
|
28
|
+
const project = ctx.requireProjects()[0]
|
|
29
|
+
const knowledge = project.store.getKnowledge()
|
|
30
|
+
const index = readSourceIndex()
|
|
31
|
+
const findings = { unsourced: [], vague: [], unindexed: [], noOrigin: [], unreachable: [], asserted: [] }
|
|
32
|
+
|
|
33
|
+
for (const evidence of knowledge.evidence) {
|
|
34
|
+
const source = evidence.sourcePath
|
|
35
|
+
if (!source) {
|
|
36
|
+
const bucket = ASSERTION_TYPES.test(evidence.sourceType || '') ? 'asserted' : 'unsourced'
|
|
37
|
+
findings[bucket].push(`${evidence.id} (${evidence.sourceType || 'no type'})`)
|
|
38
|
+
continue
|
|
39
|
+
}
|
|
40
|
+
if (VAGUE.some((pattern) => pattern.test(source))) {
|
|
41
|
+
findings.vague.push(`${evidence.id}: ${source}`)
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
// Research filenames have spaces in them - stopping at the first one turns
|
|
45
|
+
// "R16 Location-only search research insights.xlsx" into "R16" and then
|
|
46
|
+
// reports it as missing. Take everything up to a comma or the end instead.
|
|
47
|
+
const artefact = source.match(/source-artefacts\/([^,]+)/)
|
|
48
|
+
if (!artefact) continue
|
|
49
|
+
const indexed = index.get(artefact[1].trim())
|
|
50
|
+
if (!indexed) findings.unindexed.push(`${evidence.id}: ${artefact[1]}`)
|
|
51
|
+
else if (!indexed.origin.stated) findings.noOrigin.push(`${evidence.id}: ${artefact[1]}`)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
for (const round of knowledge.researchRounds) {
|
|
55
|
+
if (!round.sourcePath) findings.unsourced.push(`${round.id} (research round)`)
|
|
56
|
+
else if (VAGUE.some((pattern) => pattern.test(round.sourcePath))) {
|
|
57
|
+
findings.vague.push(`${round.id}: ${round.sourcePath}`)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// A need or insight is a claim about users, and how well it is backed is a
|
|
62
|
+
// spectrum rather than a yes or no. An insight that cites a named file is
|
|
63
|
+
// strongly traced; one that only belongs to a research round is traced to a
|
|
64
|
+
// folder; one that reaches neither is an opinion in a database that looks
|
|
65
|
+
// like a finding. Reporting only the last of those flattered the data.
|
|
66
|
+
const evidenceIds = new Set(knowledge.evidence.map((e) => e.id))
|
|
67
|
+
const roundIds = new Set(knowledge.researchRounds.map((r) => r.id))
|
|
68
|
+
const reachesEvidence = new Set()
|
|
69
|
+
for (const collection of ['needs', 'insights']) {
|
|
70
|
+
for (const record of knowledge[collection]) {
|
|
71
|
+
if ((record.evidenceIds || []).some((id) => evidenceIds.has(id))) reachesEvidence.add(record.id)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
for (const relationship of knowledge.relationships) {
|
|
75
|
+
if (evidenceIds.has(relationship.to)) reachesEvidence.add(relationship.from)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
findings.strength = []
|
|
79
|
+
for (const collection of ['needs', 'insights']) {
|
|
80
|
+
const records = knowledge[collection]
|
|
81
|
+
const toFile = records.filter((r) => reachesEvidence.has(r.id))
|
|
82
|
+
const toRound = records.filter((r) => !reachesEvidence.has(r.id) && roundIds.has(r.roundId))
|
|
83
|
+
const toNothing = records.filter((r) => !reachesEvidence.has(r.id) && !roundIds.has(r.roundId))
|
|
84
|
+
findings.strength.push(
|
|
85
|
+
`${collection}: ${toFile.length} cite a named file, ${toRound.length} reach only their research round, ` +
|
|
86
|
+
`${toNothing.length} reach nothing`)
|
|
87
|
+
if (toNothing.length) {
|
|
88
|
+
findings.unreachable.push(`${toNothing.length} of ${records.length} ${collection} reach neither evidence nor a round`)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
report(findings, index)
|
|
93
|
+
|
|
94
|
+
const blocking = findings.unsourced.length + findings.vague.length +
|
|
95
|
+
findings.unindexed.length + findings.noOrigin.length
|
|
96
|
+
if (strict && blocking) process.exit(1)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function readSourceIndex () {
|
|
100
|
+
const indexPath = path.join(ctx.paths.manifestsDir, 'source-index.json')
|
|
101
|
+
if (!fs.existsSync(indexPath)) return new Map()
|
|
102
|
+
const index = JSON.parse(fs.readFileSync(indexPath, 'utf8'))
|
|
103
|
+
return new Map(index.artefacts.map((a) => [a.path, a]))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function report (findings, index) {
|
|
107
|
+
const section = (title, items, explanation) => {
|
|
108
|
+
if (!items.length) return
|
|
109
|
+
console.log(`\n${title} (${items.length})`)
|
|
110
|
+
console.log(` ${explanation}`)
|
|
111
|
+
for (const item of items.slice(0, 20)) console.log(` ${item}`)
|
|
112
|
+
if (items.length > 20) console.log(` ... and ${items.length - 20} more`)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
console.log(`Provenance audit — ${index.size} artefacts indexed`)
|
|
116
|
+
|
|
117
|
+
if (findings.strength && findings.strength.length) {
|
|
118
|
+
console.log('\nHow well claims are backed')
|
|
119
|
+
for (const line of findings.strength) console.log(` ${line}`)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
section('No source at all', findings.unsourced,
|
|
123
|
+
'Reads as a finding but names nothing. Needs a source or an honest sourceType.')
|
|
124
|
+
section('Source too vague to follow', findings.vague,
|
|
125
|
+
'Names a folder rather than a file, or contains a placeholder.')
|
|
126
|
+
section('Cites an artefact that is not indexed', findings.unindexed,
|
|
127
|
+
'Run `hippocampus source index` while the file is still present.')
|
|
128
|
+
section('Cites an artefact whose origin was never recorded', findings.noOrigin,
|
|
129
|
+
'The copy can be hashed but nobody wrote down where it came from.')
|
|
130
|
+
section('Claims that reach nothing at all', findings.unreachable,
|
|
131
|
+
'No evidence record and no research round. Nothing backs these.')
|
|
132
|
+
section('Asserted rather than sourced', findings.asserted,
|
|
133
|
+
'Assumptions and principles. Having no upstream file is correct for these.')
|
|
134
|
+
|
|
135
|
+
const blocking = findings.unsourced.length + findings.vague.length +
|
|
136
|
+
findings.unindexed.length + findings.noOrigin.length
|
|
137
|
+
console.log(blocking
|
|
138
|
+
? `\n${blocking} records cannot be traced to a named original.`
|
|
139
|
+
: '\nEvery record traces to a named original.')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
main()
|
|
144
|
+
} catch (error) {
|
|
145
|
+
console.error(error.message)
|
|
146
|
+
process.exit(1)
|
|
147
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const fs = require('fs')
|
|
3
|
+
const path = require('path')
|
|
4
|
+
const { spawnSync } = require('child_process')
|
|
5
|
+
const { resolveRoot, CONFIG_FILENAME } = require('../lib/resolve-root')
|
|
6
|
+
const { readConfig } = require('../lib/config')
|
|
7
|
+
const { createContext } = require('../lib/context')
|
|
8
|
+
const { lintKnowledge } = require('../lib/lint-knowledge')
|
|
9
|
+
const { findBlock } = require('../lib/host-edit')
|
|
10
|
+
const packagePaths = require('../lib/package-paths')
|
|
11
|
+
|
|
12
|
+
// What doctor is for: answering "why is it not working?" without guessing.
|
|
13
|
+
// The resolved root is the first line of output, because it is the answer to
|
|
14
|
+
// most of those questions.
|
|
15
|
+
|
|
16
|
+
const argv = process.argv.slice(2)
|
|
17
|
+
const problems = []
|
|
18
|
+
const notes = []
|
|
19
|
+
|
|
20
|
+
function main () {
|
|
21
|
+
const { root, source } = resolveRoot({ argv })
|
|
22
|
+
console.log(`Project root: ${root}`)
|
|
23
|
+
console.log(`Resolved from: ${source}`)
|
|
24
|
+
console.log(`Package: ${packagePaths.packageRoot}\n`)
|
|
25
|
+
|
|
26
|
+
const config = readConfig(root)
|
|
27
|
+
report('config', config.exists, `${CONFIG_FILENAME} present`,
|
|
28
|
+
`${CONFIG_FILENAME} missing - run \`npx hippocampus init\``)
|
|
29
|
+
|
|
30
|
+
const ctx = createContext({ argv })
|
|
31
|
+
let projects = []
|
|
32
|
+
try {
|
|
33
|
+
projects = ctx.projects()
|
|
34
|
+
} catch (error) {
|
|
35
|
+
problems.push(error.message)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
report('knowledge base', projects.length > 0,
|
|
39
|
+
`${projects.length} knowledge base${projects.length === 1 ? '' : 's'}: ${projects.map((p) => p.name).join(', ')}`,
|
|
40
|
+
'no knowledge base found - run `npx hippocampus init`')
|
|
41
|
+
|
|
42
|
+
checkHostFile(root, 'app/routes.js', 'hippocampus', 'the viewer is mounted')
|
|
43
|
+
checkViewsPath(root)
|
|
44
|
+
|
|
45
|
+
for (const project of projects) {
|
|
46
|
+
const validation = project.store.validateKnowledge()
|
|
47
|
+
report(`${project.name} integrity`, validation.valid,
|
|
48
|
+
'references intact',
|
|
49
|
+
`${validation.errors.length} broken references - run \`npx hippocampus validate\``)
|
|
50
|
+
|
|
51
|
+
if (validation.valid) {
|
|
52
|
+
const { warnings, infos } = lintKnowledge(project.store.getKnowledge())
|
|
53
|
+
if (warnings.length) notes.push(`${project.name}: ${warnings.length} warnings - run \`npx hippocampus validate\``)
|
|
54
|
+
if (infos.length) notes.push(`${project.name}: ${infos.length} coverage notes - run \`npx hippocampus validate\``)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!argv.includes('--no-smoke') && projects.length && !problems.length) {
|
|
59
|
+
const smoke = spawnSync(process.execPath, [path.join(packagePaths.scriptsDir, 'smoke-routes.js'), '--root', root],
|
|
60
|
+
{ encoding: 'utf8' })
|
|
61
|
+
report('routes', smoke.status === 0, 'every route serves',
|
|
62
|
+
(smoke.stderr || smoke.stdout || '').trim().split('\n').slice(0, 4).join('\n '))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const note of notes) console.log(` note ${note}`)
|
|
66
|
+
|
|
67
|
+
if (problems.length) {
|
|
68
|
+
console.log(`\n${problems.length} problem${problems.length === 1 ? '' : 's'} to fix.`)
|
|
69
|
+
process.exit(1)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
console.log('\nAll good.')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function report (label, ok, okMessage, problemMessage) {
|
|
76
|
+
if (ok) {
|
|
77
|
+
console.log(` ok ${label}: ${okMessage}`)
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
console.log(` FAIL ${label}: ${problemMessage}`)
|
|
81
|
+
problems.push(`${label}: ${problemMessage}`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function checkHostFile (root, relPath, id, description) {
|
|
85
|
+
const filePath = path.join(root, relPath)
|
|
86
|
+
if (!fs.existsSync(filePath)) {
|
|
87
|
+
report(relPath, false, '', 'not found')
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
const block = findBlock(fs.readFileSync(filePath, 'utf8'), id)
|
|
91
|
+
report(relPath, Boolean(block) && !block.unterminated, description,
|
|
92
|
+
block && block.unterminated
|
|
93
|
+
? 'the hippocampus block has no end marker - fix it by hand'
|
|
94
|
+
: 'the viewer is not mounted - run `npx hippocampus init`, or add the two lines it prints')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function checkViewsPath (root) {
|
|
98
|
+
const appPath = path.join(root, 'app.js')
|
|
99
|
+
if (!fs.existsSync(appPath)) {
|
|
100
|
+
report('app.js', false, '', 'not found')
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
const source = fs.readFileSync(appPath, 'utf8')
|
|
104
|
+
report('app.js', source.includes('@hippo-digital/hippocampus'),
|
|
105
|
+
'the package views are on the views path',
|
|
106
|
+
'viewsPath does not include the package views - add `require(\'@hippo-digital/hippocampus\').createHippocampus().viewsPath`')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
main()
|
|
111
|
+
} catch (error) {
|
|
112
|
+
console.error(error.message)
|
|
113
|
+
process.exit(1)
|
|
114
|
+
}
|
package/scripts/eject.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const fs = require('fs')
|
|
3
|
+
const path = require('path')
|
|
4
|
+
const { resolveRoot } = require('../lib/resolve-root')
|
|
5
|
+
const { removeBlock } = require('../lib/host-edit')
|
|
6
|
+
|
|
7
|
+
// Removes the host edits and nothing else. All data stays: the knowledge base
|
|
8
|
+
// is the team's work, not this package's, and a command that deletes it because
|
|
9
|
+
// somebody wanted to stop using the viewer would be indefensible.
|
|
10
|
+
|
|
11
|
+
const argv = process.argv.slice(2)
|
|
12
|
+
const dryRun = argv.includes('--dry-run')
|
|
13
|
+
|
|
14
|
+
function main () {
|
|
15
|
+
const { root } = resolveRoot({ argv })
|
|
16
|
+
console.log(`Project root: ${root}\n`)
|
|
17
|
+
|
|
18
|
+
let changed = 0
|
|
19
|
+
|
|
20
|
+
const routesPath = path.join(root, 'app/routes.js')
|
|
21
|
+
if (fs.existsSync(routesPath)) {
|
|
22
|
+
const before = fs.readFileSync(routesPath, 'utf8')
|
|
23
|
+
const result = removeBlock(before, 'hippocampus')
|
|
24
|
+
if (result.action === 'removed') {
|
|
25
|
+
changed++
|
|
26
|
+
console.log(`${dryRun ? 'would remove' : 'removed'} the hippocampus block from app/routes.js`)
|
|
27
|
+
if (!dryRun) fs.writeFileSync(routesPath, result.source)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const appPath = path.join(root, 'app.js')
|
|
32
|
+
if (fs.existsSync(appPath)) {
|
|
33
|
+
const source = fs.readFileSync(appPath, 'utf8')
|
|
34
|
+
if (source.includes('@hippo-digital/hippocampus')) {
|
|
35
|
+
console.log('\napp.js still references the package. Remove the viewsPath entry by hand:')
|
|
36
|
+
console.log(" require('@hippo-digital/hippocampus').createHippocampus().viewsPath")
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!changed) console.log('Nothing to remove.')
|
|
41
|
+
|
|
42
|
+
console.log('\nYour knowledge base, artefacts and config are untouched.')
|
|
43
|
+
console.log('Uninstall the package with: npm uninstall @hippo-digital/hippocampus')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
main()
|
|
48
|
+
} catch (error) {
|
|
49
|
+
console.error(error.message)
|
|
50
|
+
process.exit(1)
|
|
51
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const path = require('path')
|
|
3
|
+
const { openWorkbook, readSheetGrid } = require('./lib/xlsx-grid')
|
|
4
|
+
const { createContextOrExit } = require('../lib/context')
|
|
5
|
+
const ctx = createContextOrExit()
|
|
6
|
+
|
|
7
|
+
// Generic triage mapping from common service-blueprint swimlane vocabulary to
|
|
8
|
+
// where it belongs in Hippocampus's knowledge model (see
|
|
9
|
+
// docs/source-model.md). Anything that doesn't match falls through as
|
|
10
|
+
// "unmapped" and is flagged for a manual decision - this list intentionally
|
|
11
|
+
// holds only generic blueprint/service-design terms, not any one project's
|
|
12
|
+
// specific persona or team names, so the same rules apply to any spreadsheet.
|
|
13
|
+
const SWIMLANE_TRIAGE = [
|
|
14
|
+
// Open questions and WIP markers are checked first so they take priority
|
|
15
|
+
// over the more generic keyword matches below (e.g. "Do we need a row for
|
|
16
|
+
// future steps?" is a question about the row, not future-state content).
|
|
17
|
+
{ match: /^do we need/i, destination: 'assumption', note: 'An open question row, not step content.' },
|
|
18
|
+
{ match: /unhappy path/i, destination: 'assumption', note: 'Likely deserves its own scenario/journey once detailed, rather than a lane on this one.' },
|
|
19
|
+
|
|
20
|
+
{ match: /^user groups$/i, destination: 'users', note: 'One User record per distinct persona named in this row.' },
|
|
21
|
+
{ match: /^(external )?user steps/i, destination: 'journeyStep.userAction', note: 'What the user actually does at this step.' },
|
|
22
|
+
{ match: /exception journeys/i, destination: 'journeyStep.exceptionHandling', note: '' },
|
|
23
|
+
{ match: /^back ?office/i, destination: 'journeyStep.backstage', note: '' },
|
|
24
|
+
{ match: /future steps?$/i, destination: 'journeyStep.futureState', note: 'Proposed/target-state, not current fact.' },
|
|
25
|
+
{ match: /^pain points/i, destination: 'journeyStep.painPoints', note: '' },
|
|
26
|
+
{ match: /^data$/i, destination: 'journeyStep.dataDependencies', note: '' },
|
|
27
|
+
{ match: /^processes/i, destination: 'journeyStep.supportProcesses', note: '' },
|
|
28
|
+
{ match: /needs? linked to (jira|a ticket)/i, destination: 'needs', note: 'Need.statement + Need.reference (ticket ID).' },
|
|
29
|
+
{ match: /^backlog status/i, destination: 'projectContext.facts', note: '' },
|
|
30
|
+
{ match: /^notes$/i, destination: 'projectContext.facts', note: '' },
|
|
31
|
+
{ match: /^opportunities/i, destination: 'assumption', note: 'One Assumption per opportunity.' },
|
|
32
|
+
{ match: /^systems/i, destination: 'projectContext.channels', note: '' },
|
|
33
|
+
{ match: /^sources$/i, destination: 'evidence', note: '' }
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
async function main () {
|
|
37
|
+
const args = parseArgs(process.argv.slice(2))
|
|
38
|
+
if (!args.document || !args.projectDir) {
|
|
39
|
+
throw new Error('Usage: node scripts/import-blueprint-spreadsheet.js --document path/to/blueprint.xlsx --project-dir path/to/hippocampus-style-project [--output-dir dir]')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const documentPath = path.resolve(args.document)
|
|
43
|
+
const workbook = openWorkbook(documentPath)
|
|
44
|
+
|
|
45
|
+
const draft = {
|
|
46
|
+
sourceDocument: path.relative(ctx.root, documentPath),
|
|
47
|
+
importedAt: new Date().toISOString(),
|
|
48
|
+
note: 'Review artefact only - nothing here has been written to the project source yet. Decide, sheet by sheet and row by row, whether any identifying details should be genericized before promoting content into <project>/source/*.json.',
|
|
49
|
+
sheets: Object.fromEntries(
|
|
50
|
+
[...workbook.sheetFiles.keys()].map((sheetName) => [sheetName, extractSheet(workbook, sheetName)])
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const outputDir = args.outputDir ? path.resolve(args.outputDir) : path.join(path.resolve(args.projectDir), 'imports/project')
|
|
55
|
+
fs.mkdirSync(outputDir, { recursive: true })
|
|
56
|
+
|
|
57
|
+
const outputPath = path.join(outputDir, 'blueprint-extract.json')
|
|
58
|
+
const reviewPath = path.join(outputDir, 'blueprint-extract.review.md')
|
|
59
|
+
fs.writeFileSync(outputPath, `${JSON.stringify(draft, null, 2)}\n`)
|
|
60
|
+
fs.writeFileSync(reviewPath, buildReviewMarkdown(draft))
|
|
61
|
+
|
|
62
|
+
console.log(`Extracted ${Object.keys(draft.sheets).length} sheets`)
|
|
63
|
+
console.log(`Draft written to ${outputPath}`)
|
|
64
|
+
console.log(`Review written to ${reviewPath}`)
|
|
65
|
+
console.log('This is a review artefact, not source data - see docs/source-model.md before promoting anything from it.')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Extracts one sheet generically: try reading it as a step x swimlane
|
|
70
|
+
* blueprint (a header row of step names, then rows labelled in column 0,
|
|
71
|
+
* where a blank column 0 continues the previous label onto more rows). If no
|
|
72
|
+
* column-0 labels are found at all, the sheet doesn't follow that
|
|
73
|
+
* convention - fall back to a flat dump of every non-empty cell instead of
|
|
74
|
+
* silently dropping its content.
|
|
75
|
+
*/
|
|
76
|
+
function extractSheet (workbook, sheetName) {
|
|
77
|
+
const grid = readSheetGrid(workbook, sheetName)
|
|
78
|
+
const { steps, headerRowIndex } = buildSteps(grid)
|
|
79
|
+
const swimlanes = groupIntoSwimlanes(grid, steps, headerRowIndex + 2)
|
|
80
|
+
|
|
81
|
+
// A handful of stray column-0 values isn't enough evidence that a sheet
|
|
82
|
+
// follows the swimlane convention - most of its actual content needs to
|
|
83
|
+
// have landed inside a swimlane, or this isn't really a step x lane
|
|
84
|
+
// blueprint and forcing that shape on it would silently drop everything
|
|
85
|
+
// that came before the first (possibly late, possibly sparse) label.
|
|
86
|
+
const contentCells = flattenCells(grid.slice(headerRowIndex + 2)).length
|
|
87
|
+
const capturedCells = swimlanes.reduce(
|
|
88
|
+
(sum, swimlane) => sum + Object.values(swimlane.cellsByStep).reduce((n, v) => n + asList(v).length, 0),
|
|
89
|
+
0
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
if (swimlanes.length && contentCells > 0 && capturedCells / contentCells >= 0.5) {
|
|
93
|
+
return { shape: 'blueprint', steps: steps.map((step) => step.label), swimlanes }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { shape: 'flat', cells: flattenCells(grid) }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function buildSteps (grid) {
|
|
100
|
+
// The step-header row is whichever of the first few rows has the most
|
|
101
|
+
// populated columns - generic enough to not assume a fixed row number.
|
|
102
|
+
const candidateRows = grid.slice(0, 6).map((row, index) => ({ row: row || [], index }))
|
|
103
|
+
const headerRowIndex = candidateRows.reduce((best, candidate) =>
|
|
104
|
+
(candidate.row.filter(Boolean).length > (candidateRows[best]?.row.filter(Boolean).length || 0) ? candidate.index : best), 0)
|
|
105
|
+
|
|
106
|
+
const headerRow = grid[headerRowIndex] || []
|
|
107
|
+
const nextRow = grid[headerRowIndex + 1] || []
|
|
108
|
+
const steps = []
|
|
109
|
+
|
|
110
|
+
for (let col = 1; col < Math.max(headerRow.length, nextRow.length); col++) {
|
|
111
|
+
const label = nextRow[col] || headerRow[col]
|
|
112
|
+
if (label) steps.push({ column: col, label: String(label).trim() })
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { steps, headerRowIndex }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function groupIntoSwimlanes (grid, steps, startRow) {
|
|
119
|
+
const swimlanes = []
|
|
120
|
+
let current = null
|
|
121
|
+
|
|
122
|
+
for (let rowIndex = startRow; rowIndex < grid.length; rowIndex++) {
|
|
123
|
+
const row = grid[rowIndex]
|
|
124
|
+
if (!row) continue
|
|
125
|
+
|
|
126
|
+
if (row[0]) {
|
|
127
|
+
if (current) swimlanes.push(current)
|
|
128
|
+
const label = firstLine(String(row[0]))
|
|
129
|
+
const triage = triageFor(label)
|
|
130
|
+
current = { label, destination: triage.destination, triageNote: triage.note, cellsByStep: {} }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!current || !steps.length) continue
|
|
134
|
+
|
|
135
|
+
for (const step of steps) {
|
|
136
|
+
const value = row[step.column]
|
|
137
|
+
if (!value) continue
|
|
138
|
+
const existing = current.cellsByStep[step.label]
|
|
139
|
+
const entry = String(value).trim()
|
|
140
|
+
current.cellsByStep[step.label] = existing ? [...asList(existing), entry] : entry
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (current) swimlanes.push(current)
|
|
145
|
+
return swimlanes
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function flattenCells (grid) {
|
|
149
|
+
const cells = []
|
|
150
|
+
for (let rowIndex = 0; rowIndex < grid.length; rowIndex++) {
|
|
151
|
+
const row = grid[rowIndex]
|
|
152
|
+
if (!row) continue
|
|
153
|
+
for (let col = 0; col < row.length; col++) {
|
|
154
|
+
if (row[col]) cells.push({ row: rowIndex, column: col, text: String(row[col]).trim() })
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return cells
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function asList (value) {
|
|
161
|
+
return Array.isArray(value) ? value : [value]
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function triageFor (label) {
|
|
165
|
+
const match = SWIMLANE_TRIAGE.find((entry) => entry.match.test(label))
|
|
166
|
+
return match || { destination: 'unmapped', note: 'No triage rule matched this label - needs a manual decision before promoting into source.' }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function firstLine (value) {
|
|
170
|
+
return value.split('\n')[0].trim()
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function buildReviewMarkdown (draft) {
|
|
174
|
+
const lines = [
|
|
175
|
+
'# Blueprint spreadsheet import - review',
|
|
176
|
+
'',
|
|
177
|
+
`- **Source:** ${draft.sourceDocument}`,
|
|
178
|
+
`- **Imported:** ${draft.importedAt}`,
|
|
179
|
+
'',
|
|
180
|
+
`> ${draft.note}`,
|
|
181
|
+
''
|
|
182
|
+
]
|
|
183
|
+
|
|
184
|
+
for (const [sheetName, sheet] of Object.entries(draft.sheets)) {
|
|
185
|
+
lines.push(`## ${sheetName}`)
|
|
186
|
+
lines.push('')
|
|
187
|
+
|
|
188
|
+
if (sheet.shape === 'blueprint') {
|
|
189
|
+
lines.push(`Steps: ${sheet.steps.join(' -> ')}`)
|
|
190
|
+
lines.push('')
|
|
191
|
+
lines.push('| Swimlane | Destination | Note |')
|
|
192
|
+
lines.push('| --- | --- | --- |')
|
|
193
|
+
for (const swimlane of sheet.swimlanes) {
|
|
194
|
+
const flag = swimlane.destination === 'unmapped' ? '**UNMAPPED** ' : ''
|
|
195
|
+
lines.push(`| ${flag}${swimlane.label} | ${swimlane.destination} | ${swimlane.triageNote} |`)
|
|
196
|
+
}
|
|
197
|
+
} else {
|
|
198
|
+
lines.push('Does not follow the step x swimlane convention - captured as a flat list of cells for manual review.')
|
|
199
|
+
lines.push('')
|
|
200
|
+
lines.push(`Cells captured: ${sheet.cells.length}`)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
lines.push('')
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const unmapped = Object.values(draft.sheets)
|
|
207
|
+
.flatMap((sheet) => sheet.swimlanes || [])
|
|
208
|
+
.filter((swimlane) => swimlane.destination === 'unmapped')
|
|
209
|
+
|
|
210
|
+
if (unmapped.length) {
|
|
211
|
+
lines.push('## Needs a manual decision before promoting into source')
|
|
212
|
+
lines.push('')
|
|
213
|
+
for (const swimlane of unmapped) lines.push(`- ${swimlane.label}`)
|
|
214
|
+
lines.push('')
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return `${lines.join('\n')}\n`
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function parseArgs (args) {
|
|
221
|
+
const parsed = {}
|
|
222
|
+
for (let index = 0; index < args.length; index++) {
|
|
223
|
+
const arg = args[index]
|
|
224
|
+
if (!arg.startsWith('--')) continue
|
|
225
|
+
const [rawKey, value] = arg.slice(2).split('=', 2)
|
|
226
|
+
const key = rawKey.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())
|
|
227
|
+
if (value !== undefined) {
|
|
228
|
+
parsed[key] = value
|
|
229
|
+
} else if (args[index + 1] && !args[index + 1].startsWith('--')) {
|
|
230
|
+
parsed[key] = args[index + 1]
|
|
231
|
+
index++
|
|
232
|
+
} else {
|
|
233
|
+
parsed[key] = true
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return parsed
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
main().catch((error) => {
|
|
240
|
+
console.error(error.message)
|
|
241
|
+
process.exit(1)
|
|
242
|
+
})
|