@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,230 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Import design actions and decisions from the round tracker workbook.
4
+ *
5
+ * The design process is: analyse insights, agree design actions with the team -
6
+ * which stay deliberately open and can carry several directions - then decide on
7
+ * one, which proceeds into production.
8
+ *
9
+ * The knowledge model already had the shape for that and was not being used for
10
+ * it. A design action is the open brief, with `insightIds` recording what
11
+ * prompted it. Each option weighed against that action is a decision record, and
12
+ * `DecisionSchema` already carries proposed / accepted / rejected / superseded -
13
+ * so the options ledger, and which one won, is expressible without a new record
14
+ * type. What was considered and turned down survives, which is the part that
15
+ * otherwise lives only in someone's memory.
16
+ *
17
+ * node scripts/import-design-actions.js --workbook <path> [--dry-run]
18
+ */
19
+
20
+ const fs = require('fs')
21
+ const path = require('path')
22
+ const { openWorkbook, readSheetGrid } = require('./lib/xlsx-grid')
23
+ const { KnowledgeSchema } = require('../lib/schema')
24
+ const { createContextOrExit } = require('../lib/context')
25
+ const ctx = createContextOrExit()
26
+ const { knowledgeSourceDir } = ctx.paths
27
+
28
+ const ACTION_STATUS = ['proposed', 'in-progress', 'done', 'parked']
29
+ const DECISION_STATUS = ['proposed', 'accepted', 'rejected', 'superseded']
30
+
31
+ // Row 2 of every sheet is a worked example. It is there to show the format, and
32
+ // deleting it is optional, so it is skipped rather than imported.
33
+ const EXAMPLE_IDS = /^(R17-05|R17-10|DA-R17-|DEC-R17-)/i
34
+
35
+ function main () {
36
+ const args = parseArgs(process.argv.slice(2))
37
+ if (!args.workbook) throw new Error('Usage: npm run import:design-actions -- --workbook path/to/tracker.xlsx [--dry-run]')
38
+
39
+ const workbook = openWorkbook(path.resolve(args.workbook))
40
+ const actionRows = rows(workbook, 'Design Actions')
41
+ const decisionRows = rows(workbook, 'Decisions')
42
+
43
+ const read = (f) => JSON.parse(fs.readFileSync(path.join(knowledgeSourceDir, f), 'utf8'))
44
+ const insightIds = new Set(read('insights.json').map((i) => i.id))
45
+ const existingActions = read('design-actions.json')
46
+ const existingDecisions = read('decisions.json')
47
+ const relationships = read('relationships.json')
48
+
49
+ const problems = []
50
+ const actions = []
51
+ const actionIdByTracker = new Map()
52
+
53
+ for (const row of actionRows) {
54
+ const tracker = row['ID']
55
+ if (!tracker || EXAMPLE_IDS.test(tracker)) continue
56
+ const id = kebab(tracker)
57
+ actionIdByTracker.set(tracker.toLowerCase(), id)
58
+
59
+ const status = normalise(row['Status'], ACTION_STATUS)
60
+ if (!status) problems.push(`Design action ${tracker}: status "${row['Status'] || ''}" is not one of ${ACTION_STATUS.join(', ')}`)
61
+
62
+ const linked = splitIds(row['Insight IDs']).map((ref) => resolveInsight(ref, insightIds))
63
+ const missing = splitIds(row['Insight IDs']).filter((ref) => !resolveInsight(ref, insightIds))
64
+ if (missing.length) problems.push(`Design action ${tracker}: insight IDs not found in the knowledge base: ${missing.join(', ')}`)
65
+ if (!linked.filter(Boolean).length) problems.push(`Design action ${tracker}: needs at least one insight ID that exists`)
66
+
67
+ actions.push({
68
+ id,
69
+ title: text(row['Title']) || text(row['Action']).slice(0, 110),
70
+ status: status || 'proposed',
71
+ action: text(row['Action']),
72
+ rationale: [text(row['Rationale']), row['Owner'] && `Owner: ${text(row['Owner'])}.`, row['Dependency'] && `Dependency: ${text(row['Dependency'])}.`]
73
+ .filter(Boolean).join(' ') || `Recorded on tracker row ${tracker}.`,
74
+ insightIds: [...new Set(linked.filter(Boolean))]
75
+ })
76
+ }
77
+
78
+ const decisions = []
79
+ for (const row of decisionRows) {
80
+ const tracker = row['ID']
81
+ if (!tracker || EXAMPLE_IDS.test(tracker)) continue
82
+
83
+ const status = normalise(row['Status'], DECISION_STATUS)
84
+ if (!status) problems.push(`Decision ${tracker}: status "${row['Status'] || ''}" is not one of ${DECISION_STATUS.join(', ')}`)
85
+
86
+ const actionRef = text(row['Design action ID'])
87
+ const actionId = actionIdByTracker.get(actionRef.toLowerCase()) ||
88
+ (existingActions.some((a) => a.id === kebab(actionRef)) ? kebab(actionRef) : null)
89
+ if (actionRef && !actionId) problems.push(`Decision ${tracker}: design action "${actionRef}" not found in this workbook or in the knowledge base`)
90
+
91
+ decisions.push({
92
+ record: {
93
+ id: kebab(tracker),
94
+ title: text(row['Option']) || text(row['Decision']).slice(0, 110),
95
+ status: status || 'proposed',
96
+ decision: text(row['Decision']),
97
+ consequence: [text(row['Consequence']), row['Date agreed'] && `Agreed ${text(row['Date agreed'])}.`, row['Decided by'] && `Decided by ${text(row['Decided by'])}.`]
98
+ .filter(Boolean).join(' ') || 'Consequence not yet recorded.'
99
+ },
100
+ actionId
101
+ })
102
+ }
103
+
104
+ // One accepted option per action, or the ledger is ambiguous.
105
+ const acceptedByAction = {}
106
+ for (const d of decisions) {
107
+ if (d.record.status !== 'accepted' || !d.actionId) continue
108
+ acceptedByAction[d.actionId] = (acceptedByAction[d.actionId] || 0) + 1
109
+ }
110
+ for (const [actionId, count] of Object.entries(acceptedByAction)) {
111
+ if (count > 1) problems.push(`Design action ${actionId}: ${count} decisions are marked accepted. Only one option can be the one that proceeds.`)
112
+ }
113
+
114
+ for (const list of [[actions, existingActions, 'design action'], [decisions.map((d) => d.record), existingDecisions, 'decision']]) {
115
+ const [incoming, current, label] = list
116
+ const ids = new Set(current.map((x) => x.id))
117
+ for (const item of incoming) if (ids.has(item.id)) problems.push(`${label} ${item.id} already exists in source data`)
118
+ }
119
+
120
+ const links = []
121
+ for (const action of actions) {
122
+ for (const insightId of action.insightIds) {
123
+ links.push({ id: `rel-${action.id}-responds-to-${insightId}`, from: action.id, type: 'responds-to-insight', to: insightId })
124
+ }
125
+ }
126
+ for (const d of decisions) {
127
+ if (!d.actionId) continue
128
+ links.push({ id: `rel-${d.record.id}-resolves-${d.actionId}`, from: d.record.id, type: 'resolves-action', to: d.actionId })
129
+ }
130
+
131
+ console.log(`Design actions: ${actions.length} (${ACTION_STATUS.map((s) => `${s} ${actions.filter((a) => a.status === s).length}`).join(', ')})`)
132
+ console.log(`Decisions: ${decisions.length} (${DECISION_STATUS.map((s) => `${s} ${decisions.filter((d) => d.record.status === s).length}`).join(', ')})`)
133
+ console.log(`Relationships: ${links.length}`)
134
+
135
+ if (problems.length) {
136
+ console.error(`\n${problems.length} problem${problems.length === 1 ? '' : 's'} to fix in the workbook:`)
137
+ for (const p of problems) console.error(`- ${p}`)
138
+ process.exit(1)
139
+ }
140
+
141
+ if (args.dryRun) {
142
+ console.log('\nDry run. Source files were not changed.')
143
+ for (const a of actions) {
144
+ console.log(`\n ${a.id} [${a.status}] from ${a.insightIds.length} insight(s)`)
145
+ console.log(` ${a.action.slice(0, 120)}`)
146
+ for (const d of decisions.filter((x) => x.actionId === a.id)) {
147
+ console.log(` - ${d.record.status.padEnd(10)} ${d.record.title.slice(0, 90)}`)
148
+ }
149
+ }
150
+ return
151
+ }
152
+
153
+ const proposed = {
154
+ 'design-actions.json': [...existingActions, ...actions],
155
+ 'decisions.json': [...existingDecisions, ...decisions.map((d) => d.record)],
156
+ 'relationships.json': [...new Map([...relationships, ...links].map((r) => [r.id, r])).values()]
157
+ }
158
+
159
+ // Validate the whole knowledge base before writing, not after, so a bad
160
+ // workbook cannot leave source data broken.
161
+ // Files beginning with _ are the knowledge base's own metadata, not one of its
162
+ // collections - _meta.json carries the schema version. KnowledgeSchema is
163
+ // strict, so feeding it one fails the parse and blocks every import.
164
+ const preview = Object.fromEntries(fs.readdirSync(knowledgeSourceDir)
165
+ .filter((f) => f.endsWith('.json') && !f.startsWith('_'))
166
+ .map((f) => [key(f), proposed[f] || JSON.parse(fs.readFileSync(path.join(knowledgeSourceDir, f), 'utf8'))]))
167
+ const result = KnowledgeSchema.safeParse(preview)
168
+ if (!result.success) {
169
+ console.error('\nThe import would make the knowledge base invalid:')
170
+ for (const issue of result.error.issues.slice(0, 10)) console.error(`- ${issue.path.join('.')}: ${issue.message}`)
171
+ process.exit(1)
172
+ }
173
+
174
+ for (const [file, data] of Object.entries(proposed)) {
175
+ fs.writeFileSync(path.join(knowledgeSourceDir, file), `${JSON.stringify(data, null, 2)}\n`)
176
+ }
177
+ console.log(`\nImported from ${path.relative(ctx.root, path.resolve(args.workbook))}. Run npm run validate:knowledge to confirm.`)
178
+ }
179
+
180
+ function rows (workbook, sheetName) {
181
+ if (!workbook.sheetFiles.has(sheetName)) {
182
+ throw new Error(`The workbook has no "${sheetName}" sheet. It has: ${[...workbook.sheetFiles.keys()].join(', ')}. Start from hippocampus/templates/ncrs-insight-design-action-tracker.xlsx.`)
183
+ }
184
+ const grid = readSheetGrid(workbook, sheetName)
185
+ const header = grid.find((r) => r && r.some(Boolean)) || []
186
+ const headerRow = grid.indexOf(header)
187
+ const out = []
188
+ for (let i = headerRow + 1; i < grid.length; i++) {
189
+ const row = grid[i]
190
+ if (!row || !row.some(Boolean)) continue
191
+ out.push(Object.fromEntries(header.map((h, c) => [String(h || '').trim(), row[c] || ''])))
192
+ }
193
+ return out
194
+ }
195
+
196
+ const text = (v) => String(v ?? '').replace(/\s+/g, ' ').trim()
197
+ const splitIds = (v) => text(v).split(/[,;]/).map((s) => s.trim()).filter(Boolean)
198
+
199
+ /** Tracker IDs are written R16-15; insight IDs are round-16-insight-15. */
200
+ function resolveInsight (ref, insightIds) {
201
+ const direct = kebab(ref)
202
+ if (insightIds.has(direct)) return direct
203
+ const m = ref.match(/^R-?\s*(\d+)\s*-\s*(.+)$/i)
204
+ if (m) {
205
+ const candidate = `round-${m[1]}-insight-${kebab(m[2])}`
206
+ if (insightIds.has(candidate)) return candidate
207
+ }
208
+ return [...insightIds].find((id) => id.endsWith(`-${kebab(ref)}`)) || null
209
+ }
210
+
211
+ function normalise (value, allowed) {
212
+ const v = text(value).toLowerCase().replace(/\s+/g, '-')
213
+ return allowed.includes(v) ? v : null
214
+ }
215
+
216
+ const key = (file) => file.replace('.json', '').replace(/-([a-z])/g, (_, c) => c.toUpperCase())
217
+ const kebab = (v) => text(v).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
218
+
219
+ function parseArgs (argv) {
220
+ const parsed = {}
221
+ for (let i = 0; i < argv.length; i++) {
222
+ if (!argv[i].startsWith('--')) continue
223
+ const [k, v] = argv[i].slice(2).split('=', 2)
224
+ const name = k.replace(/-([a-z])/g, (_, c) => c.toUpperCase())
225
+ parsed[name] = v !== undefined ? v : (argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : true)
226
+ }
227
+ return parsed
228
+ }
229
+
230
+ main()
@@ -0,0 +1,427 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+ const AdmZip = require('adm-zip')
4
+ const { PDFParse } = require('pdf-parse')
5
+ const { XMLParser } = require('fast-xml-parser')
6
+ const { ProjectContextImportDraftSchema } = require('../lib/schema')
7
+ const { createContextOrExit } = require('../lib/context')
8
+ const ctx = createContextOrExit()
9
+ const { projectImportsDir } = ctx.paths
10
+
11
+ async function main () {
12
+ const args = parseArgs(process.argv.slice(2))
13
+ if (!args.document || !args.contextId || !args.title) {
14
+ throw new Error('Usage: npm run import:project-context -- --document path/to/overview.pdf --context-id context-id --title "Project overview"')
15
+ }
16
+
17
+ const sections = await extractSections(args.document)
18
+ const draft = buildDraft(args, sections)
19
+ validateDraft(draft)
20
+
21
+ const outputDir = args.outputDir ? path.resolve(args.outputDir) : projectImportsDir
22
+ fs.mkdirSync(outputDir, { recursive: true })
23
+
24
+ const outputPath = path.join(outputDir, `${args.contextId}.json`)
25
+ const reviewPath = path.join(outputDir, `${args.contextId}.review.md`)
26
+ fs.writeFileSync(outputPath, `${JSON.stringify(draft, null, 2)}\n`)
27
+ fs.writeFileSync(reviewPath, buildReviewMarkdown(draft))
28
+
29
+ console.log(`Imported ${sections.length} ${sectionLabel(args.document).toLowerCase()}`)
30
+ reportFit(draft)
31
+ console.log(`Draft written to ${outputPath}`)
32
+ console.log(`Review written to ${reviewPath}`)
33
+ }
34
+
35
+ function parseArgs (args) {
36
+ const parsed = {}
37
+ for (let index = 0; index < args.length; index++) {
38
+ const arg = args[index]
39
+ if (!arg.startsWith('--')) continue
40
+ const [rawKey, value] = arg.slice(2).split('=', 2)
41
+ const key = camelCase(rawKey)
42
+ if (value !== undefined) {
43
+ parsed[key] = value
44
+ } else if (args[index + 1] && !args[index + 1].startsWith('--')) {
45
+ parsed[key] = args[index + 1]
46
+ index++
47
+ } else {
48
+ parsed[key] = true
49
+ }
50
+ }
51
+ return parsed
52
+ }
53
+
54
+ async function extractSections (documentPath) {
55
+ const extension = path.extname(documentPath).toLowerCase()
56
+ if (extension === '.pptx') return extractPowerPointSlides(documentPath)
57
+ if (extension === '.pdf') return extractPdfPages(documentPath)
58
+ throw new Error(`Unsupported project context artefact extension ${extension || '(none)'}`)
59
+ }
60
+
61
+ function extractPowerPointSlides (deckPath) {
62
+ const zip = new AdmZip(deckPath)
63
+ const parser = new XMLParser({
64
+ ignoreAttributes: false,
65
+ removeNSPrefix: true
66
+ })
67
+
68
+ return zip.getEntries()
69
+ .filter((entry) => /^ppt\/slides\/slide\d+\.xml$/.test(entry.entryName))
70
+ .sort((a, b) => slideNumber(a.entryName) - slideNumber(b.entryName))
71
+ .map((entry) => {
72
+ const xml = entry.getData().toString('utf8')
73
+ const parsed = parser.parse(xml)
74
+ const text = collectText(parsed).map(normaliseText).filter(Boolean)
75
+ return {
76
+ number: slideNumber(entry.entryName),
77
+ title: text[0] || `Slide ${slideNumber(entry.entryName)}`,
78
+ text
79
+ }
80
+ })
81
+ }
82
+
83
+ async function extractPdfPages (pdfPath) {
84
+ const parser = new PDFParse({ data: fs.readFileSync(pdfPath) })
85
+
86
+ try {
87
+ const result = await parser.getText({ pageJoiner: '' })
88
+ return result.pages.map((page) => {
89
+ const text = page.text.split(/\n+/).map(normaliseText).filter(Boolean)
90
+ return {
91
+ number: page.num,
92
+ title: text[0] || `Page ${page.num}`,
93
+ text
94
+ }
95
+ })
96
+ } finally {
97
+ await parser.destroy()
98
+ }
99
+ }
100
+
101
+ function buildDraft (args, sections) {
102
+ const sourceDocument = path.relative(ctx.root, path.resolve(args.document))
103
+ const lines = sections.flatMap((section) => section.text)
104
+ const context = {
105
+ id: args.contextId,
106
+ title: args.title,
107
+ summary: args.summary || firstUsefulLine(lines) || `Imported project context from ${path.basename(args.document)}.`,
108
+ purpose: extractPurpose(lines),
109
+ vision: extractStartingAt(lines, 'Our vision'),
110
+ problemStatement: extractStartingAt(lines, 'Users find'),
111
+ serviceProposition: extractServiceProposition(lines),
112
+ users: extractUsers(lines),
113
+ channels: extractChannels(lines),
114
+ policies: extractPolicies(lines),
115
+ constraints: extractConstraints(lines),
116
+ risks: extractRisks(lines),
117
+ milestones: extractMilestones(lines),
118
+ facts: extractFacts(lines),
119
+ sourceArtefacts: [
120
+ {
121
+ id: kebab(`${args.contextId} source`),
122
+ title: args.title,
123
+ sourcePath: sourceDocument,
124
+ sourceType: sourceType(args.document),
125
+ importedAt: new Date().toISOString()
126
+ }
127
+ ]
128
+ }
129
+
130
+ return {
131
+ sourceDocument,
132
+ importedAt: new Date().toISOString(),
133
+ warnings: assessFit(context),
134
+ projectContext: context,
135
+ sections
136
+ }
137
+ }
138
+
139
+ function extractPurpose (lines) {
140
+ const purposeLines = lines.filter((line) => {
141
+ const lower = line.toLowerCase()
142
+ return lower.includes('statutory obligation') ||
143
+ lower.includes('dhsc are responsible') ||
144
+ lower.includes('digital service provided by dhsc')
145
+ })
146
+ return sentence(purposeLines)
147
+ }
148
+
149
+ function extractServiceProposition (lines) {
150
+ const propositionLines = lines.filter((line) => {
151
+ const lower = line.toLowerCase()
152
+ return lower.includes('similar but enhanced service') ||
153
+ lower.includes('robust, fast, reliable') ||
154
+ lower.includes('user friendly webform') ||
155
+ lower.includes('audit trail') ||
156
+ lower.includes('case management')
157
+ })
158
+ return sentence(propositionLines)
159
+ }
160
+
161
+ function extractStartingAt (lines, prefix) {
162
+ const index = lines.findIndex((line) => line.toLowerCase().startsWith(prefix.toLowerCase()))
163
+ if (index === -1) return undefined
164
+ return sentence(lines.slice(index, index + 3))
165
+ }
166
+
167
+ function extractUsers (lines) {
168
+ const start = lines.findIndex((line) => /this service is for/i.test(line))
169
+ const end = lines.findIndex((line, index) => index > start && /under the terms/i.test(line))
170
+ const userLines = start === -1 ? [] : lines.slice(start + 1, end === -1 ? start + 30 : end)
171
+ return unique(userLines.filter(isUsefulUserLine).map((line) => ({
172
+ id: kebab(line),
173
+ label: line,
174
+ group: userGroup(line)
175
+ })))
176
+ }
177
+
178
+ function extractChannels (lines) {
179
+ return ['API', 'Paper', 'Webform'].flatMap((label) => {
180
+ const index = lines.findIndex((line) => line.toLowerCase() === label.toLowerCase())
181
+ if (index === -1) return []
182
+ const detail = lines.slice(index + 1, index + 4).filter((line) => !['API', 'Paper', 'Webform'].includes(line)).join(' ')
183
+ const usage = lines.find((line) => line.toLowerCase().includes(`${label.toLowerCase()},`)) ||
184
+ lines.find((line) => line.toLowerCase().includes(`${label.toLowerCase()} `) && line.includes('%'))
185
+ return [{
186
+ id: kebab(label),
187
+ label,
188
+ description: detail || undefined,
189
+ usage
190
+ }]
191
+ })
192
+ }
193
+
194
+ function extractPolicies (lines) {
195
+ return unique(lines.filter((line) => {
196
+ const lower = line.toLowerCase()
197
+ return lower.includes('abortion act') ||
198
+ lower.includes('abortion regulations') ||
199
+ lower.includes('amendment regulations') ||
200
+ lower.includes('legal requirement') ||
201
+ lower.includes('statutory obligation')
202
+ }).map((line) => ({
203
+ id: kebab(line).slice(0, 80).replace(/-+$/g, ''),
204
+ title: line,
205
+ summary: line
206
+ })))
207
+ }
208
+
209
+ function extractConstraints (lines) {
210
+ return unique(lines.filter((line) => {
211
+ const lower = line.toLowerCase()
212
+ return lower.includes('contract') ||
213
+ lower.includes('cannot') ||
214
+ lower.includes('not engaged') ||
215
+ lower.includes('legal requirement')
216
+ }).map((line) => ({
217
+ id: kebab(line).slice(0, 80).replace(/-+$/g, ''),
218
+ label: line,
219
+ detail: line
220
+ })))
221
+ }
222
+
223
+ function extractRisks (lines) {
224
+ return unique(lines.filter((line) => {
225
+ const lower = line.toLowerCase()
226
+ return lower.includes('risk') ||
227
+ lower.includes('low engagement') ||
228
+ lower.includes('low tech maturity') ||
229
+ lower.includes('unreliable') ||
230
+ lower.includes('prone to errors')
231
+ }).map((line) => ({
232
+ id: kebab(line).slice(0, 80).replace(/-+$/g, ''),
233
+ label: line,
234
+ detail: line,
235
+ severity: lowerSeverity(line)
236
+ })))
237
+ }
238
+
239
+ function extractMilestones (lines) {
240
+ return unique(lines.filter((line) => {
241
+ const lower = line.toLowerCase()
242
+ return lower.includes('may 2027') ||
243
+ lower.includes('alpha') ||
244
+ lower.includes('re-procure')
245
+ }).map((line) => ({
246
+ id: kebab(line).slice(0, 80).replace(/-+$/g, ''),
247
+ label: line,
248
+ detail: line
249
+ })))
250
+ }
251
+
252
+ function extractFacts (lines) {
253
+ const facts = []
254
+ for (const line of lines) {
255
+ if (/\d/.test(line) && (line.includes('%') || /users|forms|submissions/i.test(line))) {
256
+ facts.push({
257
+ id: kebab(line).slice(0, 80).replace(/-+$/g, ''),
258
+ label: line,
259
+ value: line
260
+ })
261
+ }
262
+ }
263
+ return unique(facts)
264
+ }
265
+
266
+ function assessFit (context) {
267
+ const warnings = []
268
+ if (!context.purpose) warnings.push('No clear purpose was detected. Add one before promotion if the overview contains it.')
269
+ if (!context.problemStatement) warnings.push('No problem statement was detected. Review the source text and add it if present.')
270
+ if (!context.users.length) warnings.push('No project-level users were detected.')
271
+ if (!context.channels.length) warnings.push('No service channels were detected.')
272
+ if (!context.policies.length) warnings.push('No policy or regulation context was detected.')
273
+ return warnings
274
+ }
275
+
276
+ function validateDraft (draft) {
277
+ const result = ProjectContextImportDraftSchema.safeParse(draft)
278
+ if (result.success) return
279
+
280
+ const issues = result.error.issues.map((issue) => {
281
+ const fieldPath = issue.path.length ? issue.path.join('.') : 'draft'
282
+ return `${fieldPath}: ${issue.message}`
283
+ })
284
+ throw new Error(`Imported draft does not match ProjectContextImportDraftSchema:\n- ${issues.join('\n- ')}`)
285
+ }
286
+
287
+ function reportFit (draft) {
288
+ const warnings = draft.warnings || []
289
+ const fit = warnings.length ? 'needs review' : 'good'
290
+ console.log(`Import fit: ${fit}`)
291
+ if (warnings.length) {
292
+ console.log('Import warnings:')
293
+ for (const warning of warnings) console.log(`- ${warning}`)
294
+ }
295
+ }
296
+
297
+ function buildReviewMarkdown (draft) {
298
+ const context = draft.projectContext
299
+ const lines = [
300
+ `# ${context.title}`,
301
+ '',
302
+ `- **Context ID:** ${context.id}`,
303
+ `- **Source:** ${draft.sourceDocument}`,
304
+ `- **Imported:** ${draft.importedAt}`,
305
+ '',
306
+ '## Import Fit',
307
+ ''
308
+ ]
309
+
310
+ if (draft.warnings && draft.warnings.length) {
311
+ lines.push('Needs review.')
312
+ lines.push('')
313
+ for (const warning of draft.warnings) lines.push(`- ${warning}`)
314
+ } else {
315
+ lines.push('Good. No fit warnings detected.')
316
+ }
317
+
318
+ lines.push('')
319
+ lines.push('## Overview')
320
+ lines.push('')
321
+ if (context.purpose) lines.push(`- **Purpose:** ${context.purpose}`)
322
+ if (context.vision) lines.push(`- **Vision:** ${context.vision}`)
323
+ if (context.problemStatement) lines.push(`- **Problem statement:** ${context.problemStatement}`)
324
+ if (context.serviceProposition) lines.push(`- **Service proposition:** ${context.serviceProposition}`)
325
+ lines.push('')
326
+ lines.push('## Counts')
327
+ lines.push('')
328
+ lines.push(`- Users: ${context.users.length}`)
329
+ lines.push(`- Channels: ${context.channels.length}`)
330
+ lines.push(`- Policies: ${context.policies.length}`)
331
+ lines.push(`- Constraints: ${context.constraints.length}`)
332
+ lines.push(`- Risks: ${context.risks.length}`)
333
+ lines.push(`- Milestones: ${context.milestones.length}`)
334
+ lines.push(`- Facts: ${context.facts.length}`)
335
+ lines.push('')
336
+ lines.push('## Extracted Text')
337
+ lines.push('')
338
+
339
+ for (const section of draft.sections) {
340
+ lines.push(`### ${section.title}`)
341
+ lines.push('')
342
+ for (const text of section.text) lines.push(`- ${text}`)
343
+ lines.push('')
344
+ }
345
+
346
+ return `${lines.join('\n')}\n`
347
+ }
348
+
349
+ function slideNumber (entryName) {
350
+ return Number(entryName.match(/slide(\d+)\.xml$/)[1])
351
+ }
352
+
353
+ function collectText (value, includePrimitive = false) {
354
+ if (value === null || value === undefined) return []
355
+ if (typeof value === 'string' || typeof value === 'number') return includePrimitive ? [String(value)] : []
356
+ if (Array.isArray(value)) return value.flatMap((child) => collectText(child, includePrimitive))
357
+ if (typeof value !== 'object') return []
358
+
359
+ const text = []
360
+ for (const [key, child] of Object.entries(value)) {
361
+ if (key === 't') text.push(...collectText(child, true))
362
+ else text.push(...collectText(child, false))
363
+ }
364
+ return text
365
+ }
366
+
367
+ function isUsefulUserLine (line) {
368
+ if (!line || line.length < 3) return false
369
+ if (/^\d/.test(line)) return false
370
+ if (/purpose|problem|vision|channels|usage/i.test(line)) return false
371
+ return true
372
+ }
373
+
374
+ function userGroup (line) {
375
+ if (/dhsc|analyst|researcher|data entry|medical advisor/i.test(line)) return 'Internal'
376
+ if (/healthcare|policy|education|media|police/i.test(line)) return 'Data consumer'
377
+ return 'External'
378
+ }
379
+
380
+ function lowerSeverity (line) {
381
+ if (/low engagement|low tech|risk/i.test(line)) return 'medium'
382
+ return undefined
383
+ }
384
+
385
+ function firstUsefulLine (lines) {
386
+ return lines.find((line) => line.length > 30)
387
+ }
388
+
389
+ function sentence (lines) {
390
+ const value = lines.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim()
391
+ return value || undefined
392
+ }
393
+
394
+ function sourceType (filePath) {
395
+ const extension = path.extname(filePath).toLowerCase()
396
+ if (extension === '.pdf') return 'pdf document'
397
+ if (extension === '.pptx') return 'presentation deck'
398
+ return extension.replace('.', '') || 'document'
399
+ }
400
+
401
+ function sectionLabel (filePath) {
402
+ return path.extname(filePath).toLowerCase() === '.pdf' ? 'pages' : 'slides'
403
+ }
404
+
405
+ function normaliseText (value) {
406
+ return value.replace(/\s+/g, ' ').trim()
407
+ }
408
+
409
+ function unique (items) {
410
+ return Object.values(Object.fromEntries(items.map((item) => [item.id, item])))
411
+ }
412
+
413
+ function kebab (value) {
414
+ return value
415
+ .toLowerCase()
416
+ .replace(/[^a-z0-9]+/g, '-')
417
+ .replace(/^-+|-+$/g, '')
418
+ }
419
+
420
+ function camelCase (value) {
421
+ return value.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())
422
+ }
423
+
424
+ main().catch((error) => {
425
+ console.error(error.message)
426
+ process.exit(1)
427
+ })