@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,236 @@
|
|
|
1
|
+
const crypto = require('crypto')
|
|
2
|
+
const fs = require('fs')
|
|
3
|
+
const path = require('path')
|
|
4
|
+
const readline = require('readline')
|
|
5
|
+
const { spawnSync } = require('child_process')
|
|
6
|
+
const { createContextOrExit } = require('../lib/context')
|
|
7
|
+
const ctx = createContextOrExit()
|
|
8
|
+
const { projectInboxDir, projectImportsDir, manifestsDir, projectManifestPath } = ctx.paths
|
|
9
|
+
|
|
10
|
+
const supportedExtensions = new Set(['.pptx', '.pdf'])
|
|
11
|
+
|
|
12
|
+
async function main () {
|
|
13
|
+
const args = parseArgs(process.argv.slice(2))
|
|
14
|
+
const sourceDir = path.resolve(args.source || projectInboxDir)
|
|
15
|
+
|
|
16
|
+
fs.mkdirSync(sourceDir, { recursive: true })
|
|
17
|
+
fs.mkdirSync(projectImportsDir, { recursive: true })
|
|
18
|
+
fs.mkdirSync(manifestsDir, { recursive: true })
|
|
19
|
+
|
|
20
|
+
const manifest = readManifest()
|
|
21
|
+
const files = listFiles(sourceDir)
|
|
22
|
+
const supported = files.filter((file) => supportedExtensions.has(path.extname(file).toLowerCase()))
|
|
23
|
+
const unsupported = files.filter((file) => !supportedExtensions.has(path.extname(file).toLowerCase()))
|
|
24
|
+
const knownHashes = new Map(manifest.artefacts.map((item) => [item.sha256, item]))
|
|
25
|
+
const existingIds = new Set(manifest.artefacts.map((item) => item.id))
|
|
26
|
+
const imported = []
|
|
27
|
+
const alreadyKnown = []
|
|
28
|
+
|
|
29
|
+
for (const file of supported) {
|
|
30
|
+
const sha256 = hashFile(file)
|
|
31
|
+
const known = knownHashes.get(sha256)
|
|
32
|
+
if (known) {
|
|
33
|
+
known.sourcePath = relative(file)
|
|
34
|
+
alreadyKnown.push(known)
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const id = uniqueId(kebab(path.basename(file, path.extname(file))), existingIds)
|
|
39
|
+
existingIds.add(id)
|
|
40
|
+
const draftPath = path.join(projectImportsDir, `${id}.json`)
|
|
41
|
+
const reviewPath = path.join(projectImportsDir, `${id}.review.md`)
|
|
42
|
+
|
|
43
|
+
if (!args.dryRun) {
|
|
44
|
+
const result = spawnSync(process.execPath, [
|
|
45
|
+
path.join(ctx.package.scriptsDir, 'import-project-context.js'),
|
|
46
|
+
`--document=${file}`,
|
|
47
|
+
`--context-id=${id}`,
|
|
48
|
+
`--title=${path.basename(file, path.extname(file))}`,
|
|
49
|
+
`--output-dir=${projectImportsDir}`
|
|
50
|
+
], {
|
|
51
|
+
cwd: ctx.root,
|
|
52
|
+
stdio: 'inherit'
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
if (result.status !== 0) {
|
|
56
|
+
manifest.artefacts.push({
|
|
57
|
+
id,
|
|
58
|
+
sourcePath: relative(file),
|
|
59
|
+
sha256,
|
|
60
|
+
type: path.extname(file).slice(1).toLowerCase(),
|
|
61
|
+
status: 'failed',
|
|
62
|
+
importedAt: new Date().toISOString()
|
|
63
|
+
})
|
|
64
|
+
writeManifest(manifest)
|
|
65
|
+
throw new Error(`Import failed for ${relative(file)}`)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const item = {
|
|
70
|
+
id,
|
|
71
|
+
sourcePath: relative(file),
|
|
72
|
+
sha256,
|
|
73
|
+
type: path.extname(file).slice(1).toLowerCase(),
|
|
74
|
+
status: args.dryRun ? 'new' : 'imported',
|
|
75
|
+
draftPath: relative(draftPath),
|
|
76
|
+
reviewPath: relative(reviewPath),
|
|
77
|
+
importedAt: args.dryRun ? null : new Date().toISOString(),
|
|
78
|
+
promotedAt: null
|
|
79
|
+
}
|
|
80
|
+
manifest.artefacts.push(item)
|
|
81
|
+
imported.push(item)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const promotable = manifest.artefacts.filter((item) => {
|
|
85
|
+
if (item.status === 'promoted') return false
|
|
86
|
+
if (!item.draftPath) return false
|
|
87
|
+
return fs.existsSync(path.join(ctx.root, item.draftPath))
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
manifest.updatedAt = new Date().toISOString()
|
|
91
|
+
if (!args.dryRun) writeManifest(manifest)
|
|
92
|
+
|
|
93
|
+
report({ sourceDir, files, supported, unsupported, imported, alreadyKnown, promotable, dryRun: args.dryRun })
|
|
94
|
+
|
|
95
|
+
if (args.dryRun || args.noPromote || !promotable.length) return
|
|
96
|
+
|
|
97
|
+
const shouldPromote = args.yes || await confirm('Promote imported, unpromoted project context drafts now?')
|
|
98
|
+
if (!shouldPromote) return
|
|
99
|
+
|
|
100
|
+
for (const item of promotable) {
|
|
101
|
+
const result = spawnSync('npm', [
|
|
102
|
+
'run',
|
|
103
|
+
'promote:project-context-draft',
|
|
104
|
+
'--',
|
|
105
|
+
`--draft=${item.draftPath}`
|
|
106
|
+
], {
|
|
107
|
+
cwd: ctx.root,
|
|
108
|
+
stdio: 'inherit'
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
if (result.status !== 0) {
|
|
112
|
+
item.status = 'failed'
|
|
113
|
+
item.failedAt = new Date().toISOString()
|
|
114
|
+
writeManifest(manifest)
|
|
115
|
+
throw new Error(`Promotion failed for ${item.draftPath}`)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
item.status = 'promoted'
|
|
119
|
+
item.promotedAt = new Date().toISOString()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
manifest.updatedAt = new Date().toISOString()
|
|
123
|
+
writeManifest(manifest)
|
|
124
|
+
console.log(`Promotion manifest updated at ${relative(projectManifestPath)}`)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function parseArgs (args) {
|
|
128
|
+
const parsed = {}
|
|
129
|
+
for (let index = 0; index < args.length; index++) {
|
|
130
|
+
const arg = args[index]
|
|
131
|
+
if (!arg.startsWith('--')) continue
|
|
132
|
+
const [rawKey, value] = arg.slice(2).split('=', 2)
|
|
133
|
+
const key = camelCase(rawKey)
|
|
134
|
+
if (value !== undefined) {
|
|
135
|
+
parsed[key] = value
|
|
136
|
+
} else if (args[index + 1] && !args[index + 1].startsWith('--')) {
|
|
137
|
+
parsed[key] = args[index + 1]
|
|
138
|
+
index++
|
|
139
|
+
} else {
|
|
140
|
+
parsed[key] = true
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return parsed
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function readManifest () {
|
|
147
|
+
if (!fs.existsSync(projectManifestPath)) {
|
|
148
|
+
return {
|
|
149
|
+
version: 1,
|
|
150
|
+
updatedAt: null,
|
|
151
|
+
artefacts: []
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return JSON.parse(fs.readFileSync(projectManifestPath, 'utf8'))
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function writeManifest (manifest) {
|
|
158
|
+
fs.writeFileSync(projectManifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function listFiles (dir) {
|
|
162
|
+
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
163
|
+
if (entry.name.startsWith('.')) return []
|
|
164
|
+
const fullPath = path.join(dir, entry.name)
|
|
165
|
+
if (entry.isDirectory()) return listFiles(fullPath)
|
|
166
|
+
if (entry.isFile()) return [fullPath]
|
|
167
|
+
return []
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function hashFile (file) {
|
|
172
|
+
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex')
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function report ({ sourceDir, files, supported, unsupported, imported, alreadyKnown, promotable, dryRun }) {
|
|
176
|
+
console.log(`Project context inbox: ${relative(sourceDir)}`)
|
|
177
|
+
console.log(`Files found: ${files.length}`)
|
|
178
|
+
console.log(`Supported files: ${supported.length}`)
|
|
179
|
+
console.log(`New imports${dryRun ? ' found' : ' created'}: ${imported.length}`)
|
|
180
|
+
console.log(`Already known: ${alreadyKnown.length}`)
|
|
181
|
+
console.log(`Unsupported files: ${unsupported.length}`)
|
|
182
|
+
console.log(`Unpromoted drafts: ${promotable.length}`)
|
|
183
|
+
|
|
184
|
+
if (imported.length) {
|
|
185
|
+
console.log('New project context drafts:')
|
|
186
|
+
for (const item of imported) console.log(`- ${item.id}: ${item.reviewPath}`)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (promotable.length) {
|
|
190
|
+
console.log('Promotable drafts:')
|
|
191
|
+
for (const item of promotable) console.log(`- ${item.id}: ${item.draftPath}`)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function confirm (question) {
|
|
196
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return Promise.resolve(false)
|
|
197
|
+
|
|
198
|
+
const rl = readline.createInterface({
|
|
199
|
+
input: process.stdin,
|
|
200
|
+
output: process.stdout
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
return new Promise((resolve) => {
|
|
204
|
+
rl.question(`${question} [y/N] `, (answer) => {
|
|
205
|
+
rl.close()
|
|
206
|
+
resolve(/^y(es)?$/i.test(answer.trim()))
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function uniqueId (baseId, existingIds) {
|
|
212
|
+
if (!existingIds.has(baseId)) return baseId
|
|
213
|
+
let index = 2
|
|
214
|
+
while (existingIds.has(`${baseId}-${index}`)) index++
|
|
215
|
+
return `${baseId}-${index}`
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function kebab (value) {
|
|
219
|
+
return value
|
|
220
|
+
.toLowerCase()
|
|
221
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
222
|
+
.replace(/^-+|-+$/g, '')
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function camelCase (value) {
|
|
226
|
+
return value.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function relative (file) {
|
|
230
|
+
return path.relative(ctx.root, file)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
main().catch((error) => {
|
|
234
|
+
console.error(error.message)
|
|
235
|
+
process.exit(1)
|
|
236
|
+
})
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
const crypto = require('crypto')
|
|
2
|
+
const fs = require('fs')
|
|
3
|
+
const path = require('path')
|
|
4
|
+
const readline = require('readline')
|
|
5
|
+
const { spawnSync } = require('child_process')
|
|
6
|
+
const { createContextOrExit } = require('../lib/context')
|
|
7
|
+
const ctx = createContextOrExit()
|
|
8
|
+
const { researchInboxDir, researchImportsDir, researchManifestsDir, researchManifestPath } = ctx.paths
|
|
9
|
+
const { extensions } = require('./lib/artefact-text')
|
|
10
|
+
|
|
11
|
+
// Word and Excel are where most of this team's research actually lives:
|
|
12
|
+
// discussion guides, scenario maps, personas, insight and design-action
|
|
13
|
+
// trackers. Limiting the inbox to decks and PDFs skipped most of the corpus.
|
|
14
|
+
const supportedExtensions = new Set(Object.keys(extensions))
|
|
15
|
+
|
|
16
|
+
async function main () {
|
|
17
|
+
const args = parseArgs(process.argv.slice(2))
|
|
18
|
+
const sourceDir = path.resolve(args.source || researchInboxDir)
|
|
19
|
+
const date = args.date || new Date().toISOString().slice(0, 10)
|
|
20
|
+
|
|
21
|
+
fs.mkdirSync(sourceDir, { recursive: true })
|
|
22
|
+
fs.mkdirSync(researchImportsDir, { recursive: true })
|
|
23
|
+
fs.mkdirSync(researchManifestsDir, { recursive: true })
|
|
24
|
+
|
|
25
|
+
const manifest = readManifest()
|
|
26
|
+
const files = listFiles(sourceDir)
|
|
27
|
+
const supported = files.filter((file) => supportedExtensions.has(path.extname(file).toLowerCase()))
|
|
28
|
+
const unsupported = files.filter((file) => !supportedExtensions.has(path.extname(file).toLowerCase()))
|
|
29
|
+
const knownHashes = new Map(manifest.artefacts.map((item) => [item.sha256, item]))
|
|
30
|
+
const existingIds = new Set(manifest.artefacts.map((item) => item.id))
|
|
31
|
+
const imported = []
|
|
32
|
+
const alreadyKnown = []
|
|
33
|
+
|
|
34
|
+
for (const file of supported) {
|
|
35
|
+
const sha256 = hashFile(file)
|
|
36
|
+
const known = knownHashes.get(sha256)
|
|
37
|
+
if (known) {
|
|
38
|
+
known.sourcePath = relative(file)
|
|
39
|
+
alreadyKnown.push(known)
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const id = uniqueId(kebab(path.basename(file, path.extname(file))), existingIds)
|
|
44
|
+
existingIds.add(id)
|
|
45
|
+
const round = roundFor(file, sourceDir, args)
|
|
46
|
+
const draftPath = path.join(researchImportsDir, `${id}.json`)
|
|
47
|
+
const reviewPath = path.join(researchImportsDir, `${id}.review.md`)
|
|
48
|
+
|
|
49
|
+
if (!args.dryRun) {
|
|
50
|
+
const result = spawnSync(process.execPath, [
|
|
51
|
+
path.join(ctx.package.scriptsDir, 'import-research-deck.js'),
|
|
52
|
+
`--deck=${file}`,
|
|
53
|
+
`--round-id=${round.id}`,
|
|
54
|
+
`--artefact-id=${id}`,
|
|
55
|
+
`--title=${round.title}`,
|
|
56
|
+
`--date=${dateFor(file, date) || date}`,
|
|
57
|
+
`--date-derived=${dateFor(file, null) ? 'true' : 'false'}`,
|
|
58
|
+
`--output-dir=${researchImportsDir}`
|
|
59
|
+
], {
|
|
60
|
+
cwd: ctx.root,
|
|
61
|
+
stdio: 'inherit'
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
if (result.status !== 0) {
|
|
65
|
+
manifest.artefacts.push({
|
|
66
|
+
id,
|
|
67
|
+
sourcePath: relative(file),
|
|
68
|
+
sha256,
|
|
69
|
+
type: path.extname(file).slice(1).toLowerCase(),
|
|
70
|
+
status: 'failed',
|
|
71
|
+
importedAt: new Date().toISOString()
|
|
72
|
+
})
|
|
73
|
+
writeManifest(manifest)
|
|
74
|
+
throw new Error(`Import failed for ${relative(file)}`)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const item = {
|
|
79
|
+
id,
|
|
80
|
+
roundId: round.id,
|
|
81
|
+
sourcePath: relative(file),
|
|
82
|
+
sha256,
|
|
83
|
+
type: path.extname(file).slice(1).toLowerCase(),
|
|
84
|
+
status: args.dryRun ? 'new' : 'imported',
|
|
85
|
+
draftPath: relative(draftPath),
|
|
86
|
+
reviewPath: relative(reviewPath),
|
|
87
|
+
importedAt: args.dryRun ? null : new Date().toISOString(),
|
|
88
|
+
promotedAt: null
|
|
89
|
+
}
|
|
90
|
+
manifest.artefacts.push(item)
|
|
91
|
+
imported.push(item)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const promotable = manifest.artefacts.filter((item) => {
|
|
95
|
+
if (item.status === 'promoted') return false
|
|
96
|
+
if (!item.draftPath) return false
|
|
97
|
+
return fs.existsSync(path.join(ctx.root, item.draftPath))
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
manifest.updatedAt = new Date().toISOString()
|
|
101
|
+
if (!args.dryRun) writeManifest(manifest)
|
|
102
|
+
|
|
103
|
+
report({ sourceDir, files, supported, unsupported, imported, alreadyKnown, promotable, dryRun: args.dryRun })
|
|
104
|
+
|
|
105
|
+
if (args.dryRun || args.noPromote || !promotable.length) return
|
|
106
|
+
|
|
107
|
+
const shouldPromote = args.yes || await confirm('Promote imported, unpromoted research drafts now?')
|
|
108
|
+
if (!shouldPromote) return
|
|
109
|
+
|
|
110
|
+
for (const item of promotable) {
|
|
111
|
+
const result = spawnSync('npm', [
|
|
112
|
+
'run',
|
|
113
|
+
'promote:research-draft',
|
|
114
|
+
'--',
|
|
115
|
+
`--draft=${item.draftPath}`
|
|
116
|
+
], {
|
|
117
|
+
cwd: ctx.root,
|
|
118
|
+
stdio: 'inherit'
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
if (result.status !== 0) {
|
|
122
|
+
item.status = 'failed'
|
|
123
|
+
item.failedAt = new Date().toISOString()
|
|
124
|
+
writeManifest(manifest)
|
|
125
|
+
throw new Error(`Promotion failed for ${item.draftPath}`)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
item.status = 'promoted'
|
|
129
|
+
item.promotedAt = new Date().toISOString()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
manifest.updatedAt = new Date().toISOString()
|
|
133
|
+
writeManifest(manifest)
|
|
134
|
+
console.log(`Promotion manifest updated at ${relative(researchManifestPath)}`)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* A research round is a piece of fieldwork, not a file. Twelve scenario maps from
|
|
139
|
+
* round 16 belong to one round; naming the round after each file would invent
|
|
140
|
+
* twelve of them.
|
|
141
|
+
*
|
|
142
|
+
* This team already encodes the round in the folder name - "Round 16 - Patient
|
|
143
|
+
* Search in FHIR" - so that is what is read, with an explicit --round-id winning
|
|
144
|
+
* and the filename left as the last resort for a loose artefact.
|
|
145
|
+
*/
|
|
146
|
+
function roundFor (file, sourceDir, args) {
|
|
147
|
+
if (args.roundId) return { id: kebab(args.roundId), title: args.roundTitle || args.roundId }
|
|
148
|
+
|
|
149
|
+
// The round number is the stable key. Two artefacts filed at different depths,
|
|
150
|
+
// or one loose file that only mentions "Round 12" in its name, still land in the
|
|
151
|
+
// same round rather than inventing a second one.
|
|
152
|
+
const segments = path.relative(sourceDir, file).split(path.sep)
|
|
153
|
+
const folder = segments.slice(0, -1).find((segment) => /^round\s*\d+\b/i.test(segment))
|
|
154
|
+
if (folder) {
|
|
155
|
+
const number = folder.match(/^round\s*(\d+)/i)[1]
|
|
156
|
+
return { id: `round-${number}`, title: folder.replace(/\s*[-\u2013]\s*/, ' \u2013 ').trim() }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const named = path.basename(file, path.extname(file)).match(/\bround\s*(\d+)\b/i)
|
|
160
|
+
if (named) return { id: `round-${named[1]}`, title: `Round ${named[1]}` }
|
|
161
|
+
|
|
162
|
+
const base = path.basename(file, path.extname(file))
|
|
163
|
+
return { id: kebab(base), title: base }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Copying a shared drive resets every modification date, so file mtimes are not
|
|
168
|
+
* evidence dates. A date written into the file or folder name is, and a research
|
|
169
|
+
* round has to carry a real one.
|
|
170
|
+
*/
|
|
171
|
+
function dateFor (file, fallback) {
|
|
172
|
+
const haystack = file.replace(/\\/g, '/')
|
|
173
|
+
const iso = haystack.match(/(20\d{2})[-_](0[1-9]|1[0-2])[-_](0[1-9]|[12]\d|3[01])/)
|
|
174
|
+
if (iso) return `${iso[1]}-${iso[2]}-${iso[3]}`
|
|
175
|
+
|
|
176
|
+
const compact = haystack.match(/\b(20\d{2})(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\b/)
|
|
177
|
+
if (compact) return `${compact[1]}-${compact[2]}-${compact[3]}`
|
|
178
|
+
|
|
179
|
+
const month = haystack.match(/\b(20\d{2})[-_](0[1-9]|1[0-2])\b/)
|
|
180
|
+
if (month) return `${month[1]}-${month[2]}-01`
|
|
181
|
+
|
|
182
|
+
return fallback
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function parseArgs (args) {
|
|
186
|
+
const parsed = {}
|
|
187
|
+
for (let index = 0; index < args.length; index++) {
|
|
188
|
+
const arg = args[index]
|
|
189
|
+
if (!arg.startsWith('--')) continue
|
|
190
|
+
const [rawKey, value] = arg.slice(2).split('=', 2)
|
|
191
|
+
const key = camelCase(rawKey)
|
|
192
|
+
if (value !== undefined) {
|
|
193
|
+
parsed[key] = value
|
|
194
|
+
} else if (args[index + 1] && !args[index + 1].startsWith('--')) {
|
|
195
|
+
parsed[key] = args[index + 1]
|
|
196
|
+
index++
|
|
197
|
+
} else {
|
|
198
|
+
parsed[key] = true
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return parsed
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function readManifest () {
|
|
205
|
+
if (!fs.existsSync(researchManifestPath)) {
|
|
206
|
+
return {
|
|
207
|
+
version: 1,
|
|
208
|
+
updatedAt: null,
|
|
209
|
+
artefacts: []
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return JSON.parse(fs.readFileSync(researchManifestPath, 'utf8'))
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function writeManifest (manifest) {
|
|
216
|
+
fs.writeFileSync(researchManifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function listFiles (dir) {
|
|
220
|
+
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
221
|
+
if (entry.name.startsWith('.')) return []
|
|
222
|
+
const fullPath = path.join(dir, entry.name)
|
|
223
|
+
if (entry.isDirectory()) return listFiles(fullPath)
|
|
224
|
+
if (entry.isFile()) return [fullPath]
|
|
225
|
+
return []
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function hashFile (file) {
|
|
230
|
+
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex')
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function report ({ sourceDir, files, supported, unsupported, imported, alreadyKnown, promotable, dryRun }) {
|
|
234
|
+
console.log(`Research inbox: ${relative(sourceDir)}`)
|
|
235
|
+
console.log(`Files found: ${files.length}`)
|
|
236
|
+
console.log(`Supported files: ${supported.length}`)
|
|
237
|
+
console.log(`New imports${dryRun ? ' found' : ' created'}: ${imported.length}`)
|
|
238
|
+
console.log(`Already known: ${alreadyKnown.length}`)
|
|
239
|
+
console.log(`Unsupported files: ${unsupported.length}`)
|
|
240
|
+
console.log(`Unpromoted drafts: ${promotable.length}`)
|
|
241
|
+
|
|
242
|
+
if (imported.length) {
|
|
243
|
+
console.log('New research drafts:')
|
|
244
|
+
for (const item of imported) console.log(`- ${item.id}: ${item.reviewPath}`)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (promotable.length) {
|
|
248
|
+
console.log('Promotable drafts:')
|
|
249
|
+
for (const item of promotable) console.log(`- ${item.id}: ${item.draftPath}`)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function confirm (question) {
|
|
254
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return Promise.resolve(false)
|
|
255
|
+
|
|
256
|
+
const rl = readline.createInterface({
|
|
257
|
+
input: process.stdin,
|
|
258
|
+
output: process.stdout
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
return new Promise((resolve) => {
|
|
262
|
+
rl.question(`${question} [y/N] `, (answer) => {
|
|
263
|
+
rl.close()
|
|
264
|
+
resolve(/^y(es)?$/i.test(answer.trim()))
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function uniqueId (baseId, existingIds) {
|
|
270
|
+
if (!existingIds.has(baseId)) return baseId
|
|
271
|
+
let index = 2
|
|
272
|
+
while (existingIds.has(`${baseId}-${index}`)) index++
|
|
273
|
+
return `${baseId}-${index}`
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function kebab (value) {
|
|
277
|
+
return value
|
|
278
|
+
.toLowerCase()
|
|
279
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
280
|
+
.replace(/^-+|-+$/g, '')
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function camelCase (value) {
|
|
284
|
+
return value.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function relative (file) {
|
|
288
|
+
return path.relative(ctx.root, file)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
main().catch((error) => {
|
|
292
|
+
console.error(error.message)
|
|
293
|
+
process.exit(1)
|
|
294
|
+
})
|