certainty-units 0.2.0

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.
@@ -0,0 +1,130 @@
1
+ // Linear adapter — maps Linear issues to CU items via GraphQL
2
+
3
+ import {
4
+ applyMap, mergeFieldMap,
5
+ extractAcceptanceCriteria, validationFromLabels, tierFromLabels,
6
+ } from './util.js'
7
+
8
+ const LINEAR_API = 'https://api.linear.app/graphql'
9
+
10
+ const ISSUES_QUERY = `
11
+ query Issues($teamId: ID!, $after: String) {
12
+ issues(
13
+ filter: { team: { id: { eq: $teamId } } }
14
+ first: 100
15
+ after: $after
16
+ ) {
17
+ pageInfo { hasNextPage endCursor }
18
+ nodes {
19
+ id
20
+ identifier
21
+ title
22
+ description
23
+ priority
24
+ estimate
25
+ state { name type }
26
+ assignee { name }
27
+ labels { nodes { name } }
28
+ comments { nodes { id } }
29
+ createdAt
30
+ updatedAt
31
+ url
32
+ }
33
+ }
34
+ }
35
+ `
36
+
37
+ async function graphql(apiKey, query, variables = {}) {
38
+ const res = await fetch(LINEAR_API, {
39
+ method: 'POST',
40
+ headers: {
41
+ 'Content-Type': 'application/json',
42
+ Authorization: apiKey,
43
+ },
44
+ body: JSON.stringify({ query, variables }),
45
+ })
46
+ if (!res.ok) throw new Error(`Linear API ${res.status}: ${await res.text()}`)
47
+ const json = await res.json()
48
+ if (json.errors) throw new Error(`Linear GraphQL: ${json.errors.map(e => e.message).join(', ')}`)
49
+ return json.data
50
+ }
51
+
52
+ async function fetchAllIssues(apiKey, teamId) {
53
+ const issues = []
54
+ let after = null
55
+ do {
56
+ const data = await graphql(apiKey, ISSUES_QUERY, { teamId, after })
57
+ issues.push(...data.issues.nodes)
58
+ after = data.issues.pageInfo.hasNextPage ? data.issues.pageInfo.endCursor : null
59
+ } while (after)
60
+ return issues
61
+ }
62
+
63
+ // Default field mapping — overridable via certainty.config.yaml fieldMap
64
+ const DEFAULT_MAP = {
65
+ validation_status: {
66
+ field: 'state.type',
67
+ map: {
68
+ completed: 'validated',
69
+ started: 'assumed',
70
+ triage: 'needs_clarification',
71
+ backlog: 'unvalidated',
72
+ cancelled: 'unvalidated',
73
+ },
74
+ default: 'unvalidated',
75
+ },
76
+ workflow_status: {
77
+ field: 'state.type',
78
+ map: {
79
+ completed: 'done',
80
+ started: 'in_progress',
81
+ triage: 'todo',
82
+ backlog: 'todo',
83
+ cancelled: 'todo',
84
+ },
85
+ default: 'todo',
86
+ },
87
+ cu_tier: {
88
+ field: 'priority',
89
+ map: { 1: 'advanced', 2: 'intermediate', 3: 'basic', 4: 'basic' },
90
+ default: null,
91
+ },
92
+ }
93
+
94
+ export async function fetchItems(config) {
95
+ const { apiKey, teamId, fieldMap: userFieldMap } = config
96
+ if (!apiKey) throw new Error('linear.apiKey is required')
97
+ if (!teamId) throw new Error('linear.teamId is required')
98
+
99
+ const issues = await fetchAllIssues(apiKey, teamId)
100
+ const fieldMap = mergeFieldMap(DEFAULT_MAP, userFieldMap)
101
+
102
+ return issues.map(issue => {
103
+ const labels = issue.labels?.nodes?.map(l => l.name) ?? []
104
+ const description = issue.description || ''
105
+
106
+ return {
107
+ id: issue.id,
108
+ external_id: issue.identifier,
109
+ title: issue.title,
110
+ description,
111
+ url: issue.url,
112
+ // an explicit validation label beats inferring validation from workflow state
113
+ validation_status: validationFromLabels(labels) ?? applyMap(issue, fieldMap.validation_status),
114
+ workflow_status: applyMap(issue, fieldMap.workflow_status),
115
+ cu_tier: tierFromLabels(labels) ?? applyMap(issue, fieldMap.cu_tier),
116
+ cu_value: issue.estimate ?? 1,
117
+ evidence: description,
118
+ acceptance_criteria: extractAcceptanceCriteria(description),
119
+ novelty_rating: null,
120
+ complexity_rating: null,
121
+ dependency_rating: null,
122
+ citation_count: issue.comments?.nodes?.length ?? 0,
123
+ assignee: issue.assignee?.name ?? null,
124
+ labels,
125
+ created_at: issue.createdAt,
126
+ updated_at: issue.updatedAt,
127
+ source: 'linear',
128
+ }
129
+ })
130
+ }
@@ -0,0 +1,143 @@
1
+ // Notion adapter — maps a Notion database to CU items via Notion API
2
+
3
+ import { mergeFieldMap, validationFromLabels, tierFromLabels } from './util.js'
4
+
5
+ const NOTION_API = 'https://api.notion.com/v1'
6
+ const NOTION_VERSION = '2022-06-28'
7
+
8
+ async function notionFetch(apiKey, path, body = null) {
9
+ const res = await fetch(`${NOTION_API}${path}`, {
10
+ method: body ? 'POST' : 'GET',
11
+ headers: {
12
+ Authorization: `Bearer ${apiKey}`,
13
+ 'Notion-Version': NOTION_VERSION,
14
+ 'Content-Type': 'application/json',
15
+ },
16
+ body: body ? JSON.stringify(body) : undefined,
17
+ })
18
+ if (!res.ok) throw new Error(`Notion API ${res.status}: ${await res.text()}`)
19
+ return res.json()
20
+ }
21
+
22
+ async function fetchAllPages(apiKey, databaseId) {
23
+ const pages = []
24
+ let cursor = null
25
+
26
+ do {
27
+ const body = { page_size: 100 }
28
+ if (cursor) body.start_cursor = cursor
29
+ const data = await notionFetch(apiKey, `/databases/${databaseId}/query`, body)
30
+ pages.push(...data.results)
31
+ cursor = data.has_more ? data.next_cursor : null
32
+ } while (cursor)
33
+
34
+ return pages
35
+ }
36
+
37
+ // Extract plain text from a Notion rich_text array
38
+ function richText(arr) {
39
+ return arr?.map(r => r.plain_text).join('') ?? ''
40
+ }
41
+
42
+ // Extract value from a Notion property by type
43
+ function extractProp(prop) {
44
+ if (!prop) return null
45
+ switch (prop.type) {
46
+ case 'title': return richText(prop.title)
47
+ case 'rich_text': return richText(prop.rich_text)
48
+ case 'select': return prop.select?.name ?? null
49
+ case 'status': return prop.status?.name ?? null
50
+ case 'multi_select': return prop.multi_select?.map(s => s.name) ?? []
51
+ case 'number': return prop.number
52
+ case 'checkbox': return prop.checkbox
53
+ case 'date': return prop.date?.start ?? null
54
+ case 'people': return prop.people?.map(p => p.name).join(', ') ?? null
55
+ case 'url': return prop.url
56
+ default: return null
57
+ }
58
+ }
59
+
60
+ // Default field map — teams override column names via config
61
+ const DEFAULT_MAP = {
62
+ validation_status: {
63
+ field: 'Status', // Notion column name
64
+ map: {
65
+ Done: 'validated',
66
+ 'In Progress': 'assumed',
67
+ 'To Do': 'unvalidated',
68
+ Backlog: 'unvalidated',
69
+ },
70
+ default: 'unvalidated',
71
+ },
72
+ workflow_status: {
73
+ field: 'Status',
74
+ map: {
75
+ Done: 'done',
76
+ 'In Progress': 'in_progress',
77
+ 'To Do': 'todo',
78
+ Backlog: 'todo',
79
+ },
80
+ default: 'todo',
81
+ },
82
+ cu_tier: {
83
+ field: 'Priority',
84
+ map: {
85
+ High: 'advanced',
86
+ Medium: 'intermediate',
87
+ Low: 'basic',
88
+ },
89
+ default: null,
90
+ },
91
+ title_field: { field: 'Name' },
92
+ assignee_field: { field: 'Assignee' },
93
+ estimate_field: { field: 'Estimate' },
94
+ labels_field: { field: 'Tags' }, // multi_select
95
+ description_field: { field: 'Description' }, // rich_text
96
+ acceptance_field: { field: 'Acceptance Criteria' }, // rich_text
97
+ }
98
+
99
+ export async function fetchItems(config) {
100
+ const { apiKey, databaseId } = config
101
+ if (!apiKey) throw new Error('notion.apiKey is required')
102
+ if (!databaseId) throw new Error('notion.databaseId is required')
103
+
104
+ const pages = await fetchAllPages(apiKey, databaseId)
105
+ const fm = mergeFieldMap(DEFAULT_MAP, config.fieldMap)
106
+
107
+ return pages.map(page => {
108
+ const props = page.properties
109
+ const statusRaw = extractProp(props[fm.validation_status.field])
110
+ const labelsRaw = extractProp(props[fm.labels_field?.field])
111
+ const labels = Array.isArray(labelsRaw) ? labelsRaw : []
112
+ const description = extractProp(props[fm.description_field?.field]) ?? ''
113
+ const acceptance = extractProp(props[fm.acceptance_field?.field])
114
+
115
+ return {
116
+ id: page.id,
117
+ external_id: page.id.slice(0, 8),
118
+ title: extractProp(props[fm.title_field.field]) ?? 'Untitled',
119
+ description,
120
+ url: page.url,
121
+ // an explicit validation tag beats inferring validation from workflow state
122
+ validation_status: validationFromLabels(labels)
123
+ ?? fm.validation_status.map[statusRaw] ?? fm.validation_status.default,
124
+ workflow_status: fm.workflow_status.map[statusRaw] ?? fm.workflow_status.default,
125
+ cu_tier: tierFromLabels(labels) ?? (() => {
126
+ const raw = extractProp(props[fm.cu_tier.field])
127
+ return fm.cu_tier.map[raw] ?? fm.cu_tier.default
128
+ })(),
129
+ cu_value: extractProp(props[fm.estimate_field?.field]) ?? 1,
130
+ evidence: description,
131
+ acceptance_criteria: typeof acceptance === 'string' && acceptance.trim() ? acceptance : null,
132
+ novelty_rating: null,
133
+ complexity_rating: null,
134
+ dependency_rating: null,
135
+ citation_count: 0,
136
+ assignee: extractProp(props[fm.assignee_field?.field]),
137
+ labels,
138
+ created_at: page.created_time,
139
+ updated_at: page.last_edited_time,
140
+ source: 'notion',
141
+ }
142
+ })
143
+ }
@@ -0,0 +1,90 @@
1
+ // Shared adapter helpers: field mapping + signal extraction from real work items.
2
+
3
+ export function getField(obj, path) {
4
+ return path.split('.').reduce((o, k) => o?.[k], obj)
5
+ }
6
+
7
+ export function applyMap(issue, fieldDef) {
8
+ const raw = getField(issue, fieldDef.field)
9
+ return fieldDef.map[raw] ?? fieldDef.default
10
+ }
11
+
12
+ export function mergeFieldMap(defaults, userMap = {}) {
13
+ const merged = structuredClone(defaults)
14
+ for (const [field, def] of Object.entries(userMap)) {
15
+ if (typeof def === 'object' && def !== null && def.map) {
16
+ merged[field] = {
17
+ field: def.field ?? merged[field]?.field,
18
+ map: { ...merged[field]?.map, ...def.map },
19
+ default: def.default ?? merged[field]?.default ?? null,
20
+ }
21
+ } else {
22
+ merged[field] = def
23
+ }
24
+ }
25
+ return merged
26
+ }
27
+
28
+ // Pull an acceptance-criteria block out of a free-text description.
29
+ // Recognizes a heading containing "acceptance criteria" (markdown #, bold, or
30
+ // plain line ending in ":"), or falls back to markdown checklist items.
31
+ export function extractAcceptanceCriteria(text) {
32
+ if (!text?.trim()) return null
33
+
34
+ const lines = text.split('\n')
35
+ const headingRe = /^\s*(?:#{1,6}\s*|\*\*)?\s*acceptance criteria\b/i
36
+
37
+ const start = lines.findIndex(l => headingRe.test(l))
38
+ if (start !== -1) {
39
+ const block = []
40
+ for (let i = start + 1; i < lines.length; i++) {
41
+ // stop at the next heading
42
+ if (/^\s*#{1,6}\s/.test(lines[i]) || /^\s*\*\*[^*]+\*\*\s*$/.test(lines[i])) break
43
+ block.push(lines[i])
44
+ }
45
+ const body = block.join('\n').trim()
46
+ if (body) return body
47
+ }
48
+
49
+ // fallback: markdown checklist anywhere in the description
50
+ const checklist = lines.filter(l => /^\s*[-*]\s*\[[ xX]\]/.test(l))
51
+ if (checklist.length) return checklist.join('\n')
52
+
53
+ return null
54
+ }
55
+
56
+ // Labels are the cheapest place for a team to record validation explicitly.
57
+ // A label like "validated" beats inferring validation from workflow state.
58
+ const VALIDATION_LABELS = {
59
+ 'validated': 'validated',
60
+ 'validation:validated': 'validated',
61
+ 'assumed': 'assumed',
62
+ 'assumption': 'assumed',
63
+ 'validation:assumed': 'assumed',
64
+ 'needs-clarification': 'needs_clarification',
65
+ 'needs clarification': 'needs_clarification',
66
+ 'unvalidated': 'unvalidated',
67
+ }
68
+
69
+ export function validationFromLabels(labels = []) {
70
+ for (const label of labels) {
71
+ const hit = VALIDATION_LABELS[String(label).toLowerCase().trim()]
72
+ if (hit) return hit
73
+ }
74
+ return null
75
+ }
76
+
77
+ // cu:basic / cu:intermediate / cu:advanced labels set the tier explicitly.
78
+ const TIER_LABELS = {
79
+ 'cu:basic': 'basic',
80
+ 'cu:intermediate': 'intermediate',
81
+ 'cu:advanced': 'advanced',
82
+ }
83
+
84
+ export function tierFromLabels(labels = []) {
85
+ for (const label of labels) {
86
+ const hit = TIER_LABELS[String(label).toLowerCase().trim()]
87
+ if (hit) return hit
88
+ }
89
+ return null
90
+ }
@@ -0,0 +1,152 @@
1
+ // Certainty Score engine — ported from Propozel (MIT)
2
+ // Items must conform to the CU shape: { validation_status, workflow_status,
3
+ // cu_tier, cu_value, evidence, acceptance_criteria, novelty_rating,
4
+ // complexity_rating, dependency_rating, certainty_score }
5
+
6
+ export const DEFAULT_CU_CONFIG = {
7
+ signal_weight: 0.6,
8
+ signals: {
9
+ validation: 40,
10
+ workflow: 20,
11
+ citations: 15,
12
+ acceptance: 10,
13
+ tier: 10,
14
+ evidence: 5,
15
+ },
16
+ dimension_weight: 0.4,
17
+ dimensions: {
18
+ novelty: 0.45,
19
+ complexity: 0.35,
20
+ dependencies: 0.20,
21
+ },
22
+ }
23
+
24
+ const VALIDATION_SCORE = {
25
+ validated: 40,
26
+ assumed: 10,
27
+ needs_clarification: 5,
28
+ unvalidated: 0,
29
+ }
30
+
31
+ const WORKFLOW_SCORE = {
32
+ done: 20,
33
+ in_progress: 10,
34
+ review: 15,
35
+ todo: 0,
36
+ blocked: 0,
37
+ }
38
+
39
+ // Per-signal breakdown: points earned, max possible, why, and what would earn the rest.
40
+ // This is the single source of truth for the signal score.
41
+ export function computeScoreBreakdown(item, citationCount = 0) {
42
+ const validation = VALIDATION_SCORE[item.validation_status] || 0
43
+ const workflow = WORKFLOW_SCORE[item.workflow_status] || 0
44
+ const citations = Math.min(citationCount * 5, 15)
45
+ const acceptance = item.acceptance_criteria?.trim() ? 10 : 0
46
+ const tier = item.cu_tier ? 10 : 0
47
+ const evidence = item.evidence?.trim() ? 5 : 0
48
+
49
+ return [
50
+ {
51
+ signal: 'validation', points: validation, max: 40,
52
+ reason: item.validation_status || 'unvalidated',
53
+ hint: 'validate the assumption (label it, or link evidence)',
54
+ },
55
+ {
56
+ signal: 'workflow', points: workflow, max: 20,
57
+ reason: item.workflow_status || 'todo',
58
+ hint: 'progress the item through the workflow',
59
+ },
60
+ {
61
+ signal: 'discussion', points: citations, max: 15,
62
+ reason: `${citationCount} comment${citationCount === 1 ? '' : 's'}`,
63
+ hint: 'discuss it — undebated items hide risk',
64
+ },
65
+ {
66
+ signal: 'acceptance criteria', points: acceptance, max: 10,
67
+ reason: acceptance ? 'defined' : 'none found',
68
+ hint: 'add an "Acceptance Criteria" section or checklist',
69
+ },
70
+ {
71
+ signal: 'CU tier', points: tier, max: 10,
72
+ reason: item.cu_tier || 'not set',
73
+ hint: 'size it (basic / intermediate / advanced)',
74
+ },
75
+ {
76
+ signal: 'evidence', points: evidence, max: 5,
77
+ reason: evidence ? 'has description' : 'empty description',
78
+ hint: 'write down what is known',
79
+ },
80
+ ]
81
+ }
82
+
83
+ export function computeSignalScore(item, citationCount = 0) {
84
+ const total = computeScoreBreakdown(item, citationCount)
85
+ .reduce((sum, s) => sum + s.points, 0)
86
+ return Math.min(total, 100)
87
+ }
88
+
89
+ // Largest missing signals first — "what would raise certainty on this item"
90
+ export function biggestGaps(item, citationCount = 0, limit = 3) {
91
+ return computeScoreBreakdown(item, citationCount)
92
+ .map(s => ({ ...s, gap: s.max - s.points }))
93
+ .filter(s => s.gap > 0)
94
+ .sort((a, b) => b.gap - a.gap)
95
+ .slice(0, limit)
96
+ }
97
+
98
+ export function computeDimensionScore(item, config = DEFAULT_CU_CONFIG) {
99
+ const n = item.novelty_rating || 3
100
+ const c = item.complexity_rating || 3
101
+ const d = item.dependency_rating || 3
102
+ const w = config.dimensions
103
+ const weighted = n * w.novelty + c * w.complexity + d * w.dependencies
104
+ return Math.round(weighted * 20)
105
+ }
106
+
107
+ export function computeCertaintyScore(item, citationCount = 0, config = DEFAULT_CU_CONFIG) {
108
+ const signalScore = computeSignalScore(item, citationCount)
109
+ const dimensionScore = computeDimensionScore(item, config)
110
+ const hasDimensions = item.novelty_rating || item.complexity_rating || item.dependency_rating
111
+ if (!hasDimensions) return signalScore
112
+ return Math.min(
113
+ Math.round(signalScore * config.signal_weight + dimensionScore * config.dimension_weight),
114
+ 100
115
+ )
116
+ }
117
+
118
+ export function certaintyLevel(score) {
119
+ if (score >= 80) return 'high'
120
+ if (score >= 50) return 'medium'
121
+ if (score >= 20) return 'low'
122
+ return 'uncertain'
123
+ }
124
+
125
+ export const CU_TIERS = [
126
+ { value: 'basic', label: 'Basic', multiplier: 1 },
127
+ { value: 'intermediate', label: 'Intermediate', multiplier: 3 },
128
+ { value: 'advanced', label: 'Advanced', multiplier: 6 },
129
+ ]
130
+
131
+ export function computeCUMetrics(items) {
132
+ const total = items.length
133
+ const withCU = items.filter(i => i.cu_tier)
134
+ const completed = withCU.filter(i => i.workflow_status === 'done')
135
+ const accepted = completed.filter(i => i.validation_status === 'validated')
136
+ const totalCUValue = withCU.reduce((sum, i) => sum + (i.cu_value || 1), 0)
137
+ const completedCUValue = completed.reduce((sum, i) => sum + (i.cu_value || 1), 0)
138
+ const scores = items.map(i => i.certainty_score || 0)
139
+ const avgCertainty = total ? Math.round(scores.reduce((a, b) => a + b, 0) / total) : 0
140
+ return {
141
+ totalItems: total,
142
+ cuItems: withCU.length,
143
+ totalCUValue,
144
+ completedCUValue,
145
+ completionRate: withCU.length ? Math.round((completed.length / withCU.length) * 100) : 0,
146
+ integrityScore: completed.length ? Math.round((accepted.length / completed.length) * 100) : 0,
147
+ avgCertaintyScore: avgCertainty,
148
+ velocity: completedCUValue,
149
+ uphill: items.filter(i => (i.certainty_score || 0) < 50).length,
150
+ downhill: items.filter(i => (i.certainty_score || 0) >= 50).length,
151
+ }
152
+ }