@hippo-digital/hippocampus 1.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/LICENSE +21 -0
  3. package/README.md +153 -0
  4. package/assets/dist/hippocampus.css +1 -0
  5. package/assets/hippocampus.scss +896 -0
  6. package/assets/javascript/blueprint.js +123 -0
  7. package/bin/hippocampus.js +106 -0
  8. package/docs/agent-roadmap.md +217 -0
  9. package/docs/agent-workflow.md +56 -0
  10. package/docs/agentic-patterns-to-port.md +273 -0
  11. package/docs/colour-pairings.md +41 -0
  12. package/docs/components.md +52 -0
  13. package/docs/copilot-ncrs-research-curator-agent.md +298 -0
  14. package/docs/custom-agents-usage.md +95 -0
  15. package/docs/design-actions-and-decisions.md +95 -0
  16. package/docs/gp-connect-real-data-to-production.md +66 -0
  17. package/docs/hippocampus-for-designers-and-researchers.md +261 -0
  18. package/docs/hippocampus-technical.md +373 -0
  19. package/docs/prompt-architecture-guardrails.md +39 -0
  20. package/docs/repeatable-research-round-ingestion.md +239 -0
  21. package/docs/research-knowledge-graph.md +552 -0
  22. package/docs/research-source-file-conventions.md +121 -0
  23. package/docs/source-model.md +252 -0
  24. package/index.js +47 -0
  25. package/lib/config.js +46 -0
  26. package/lib/context.js +59 -0
  27. package/lib/create-router.js +187 -0
  28. package/lib/discover-projects.js +45 -0
  29. package/lib/docs.js +72 -0
  30. package/lib/host-edit.js +151 -0
  31. package/lib/lint-knowledge.js +133 -0
  32. package/lib/load-knowledge.js +629 -0
  33. package/lib/package-paths.js +32 -0
  34. package/lib/paths.js +33 -0
  35. package/lib/resolve-root.js +84 -0
  36. package/lib/schema-version.js +96 -0
  37. package/lib/schema.js +346 -0
  38. package/package.json +70 -0
  39. package/scripts/audit-provenance.js +147 -0
  40. package/scripts/doctor.js +114 -0
  41. package/scripts/eject.js +51 -0
  42. package/scripts/import-blueprint-spreadsheet.js +242 -0
  43. package/scripts/import-design-actions.js +230 -0
  44. package/scripts/import-project-context.js +427 -0
  45. package/scripts/import-research-batch.js +160 -0
  46. package/scripts/import-research-deck.js +448 -0
  47. package/scripts/index-source-artefacts.js +261 -0
  48. package/scripts/init.js +232 -0
  49. package/scripts/install-skills.js +160 -0
  50. package/scripts/lib/artefact-text.js +347 -0
  51. package/scripts/lib/xlsx-grid.js +114 -0
  52. package/scripts/link-insights-to-needs.js +209 -0
  53. package/scripts/migrate-knowledge.js +73 -0
  54. package/scripts/promote-project-context-draft.js +125 -0
  55. package/scripts/promote-research-draft.js +236 -0
  56. package/scripts/smoke-routes.js +157 -0
  57. package/scripts/sync-project-context.js +236 -0
  58. package/scripts/sync-research-artefacts.js +294 -0
  59. package/scripts/triage-research-corpus.js +274 -0
  60. package/scripts/validate-knowledge.js +49 -0
  61. package/scripts/validate-skills.js +247 -0
  62. package/skills/COVERAGE-MATRIX.md +39 -0
  63. package/skills/README.md +77 -0
  64. package/skills/SKILL-FORMAT.md +100 -0
  65. package/skills/add-prototype-screen.skill.md +54 -0
  66. package/skills/add-scenario.skill.md +47 -0
  67. package/skills/add-user-needs.skill.md +51 -0
  68. package/skills/audit-knowledge-source.skill.md +47 -0
  69. package/skills/capture-route-review-pack.skill.md +52 -0
  70. package/skills/create-journey-from-scenario.skill.md +50 -0
  71. package/skills/deliver-service-slice.skill.md +65 -0
  72. package/skills/examples/README.md +15 -0
  73. package/skills/examples/non-copilot-research-ingestion.md +68 -0
  74. package/skills/examples/non-copilot-service-slice.md +64 -0
  75. package/skills/generate-service-slice.skill.md +57 -0
  76. package/skills/ingest-project-context.skill.md +58 -0
  77. package/skills/ingest-research-round.skill.md +60 -0
  78. package/skills/map-research-to-graph.skill.md +58 -0
  79. package/skills/record-accessibility-review.skill.md +52 -0
  80. package/skills/record-design-decision.skill.md +53 -0
  81. package/skills/review-research-import-draft.skill.md +45 -0
  82. package/skills/skills.json +464 -0
  83. package/skills/skills.schema.json +111 -0
  84. package/skills/structure-project-context-draft.skill.md +51 -0
  85. package/skills/structure-research-draft.skill.md +51 -0
  86. package/skills/triage-research-corpus.skill.md +51 -0
  87. package/skills/write-delivery-summary.skill.md +51 -0
  88. package/skills/write-route-review-summary.skill.md +55 -0
  89. package/views/hippocampus/_layout.html +45 -0
  90. package/views/hippocampus/blueprint-classic.html +39 -0
  91. package/views/hippocampus/blueprint.html +52 -0
  92. package/views/hippocampus/components/_blueprint-cell.njk +42 -0
  93. package/views/hippocampus/components/_blueprint-controls.njk +20 -0
  94. package/views/hippocampus/components/_blueprint-minimap.njk +14 -0
  95. package/views/hippocampus/components/_empty-state.njk +12 -0
  96. package/views/hippocampus/components/_journey-step.njk +21 -0
  97. package/views/hippocampus/components/_list.njk +23 -0
  98. package/views/hippocampus/components/_summary-card.njk +15 -0
  99. package/views/hippocampus/doc.html +11 -0
  100. package/views/hippocampus/docs.html +19 -0
  101. package/views/hippocampus/evidence-impact.html +53 -0
  102. package/views/hippocampus/graph.html +48 -0
  103. package/views/hippocampus/handover-empty.html +12 -0
  104. package/views/hippocampus/handover.html +56 -0
  105. package/views/hippocampus/index.html +46 -0
  106. package/views/hippocampus/journey-flow.html +31 -0
  107. package/views/hippocampus/journey.html +23 -0
  108. package/views/hippocampus/not-found.html +10 -0
  109. package/views/hippocampus/project.html +102 -0
  110. package/views/hippocampus/research.html +68 -0
  111. package/views/hippocampus/route-review.html +83 -0
  112. package/views/hippocampus/scenarios.html +56 -0
  113. package/views/hippocampus/trace.html +113 -0
  114. package/views/hippocampus/user-needs.html +32 -0
  115. package/views/hippocampus/user.html +157 -0
  116. package/views/hippocampus/users.html +32 -0
@@ -0,0 +1,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
+ })