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.
package/src/cli.js ADDED
@@ -0,0 +1,224 @@
1
+ #!/usr/bin/env node
2
+ import { program } from 'commander'
3
+ import { writeFileSync } from 'fs'
4
+ import chalk from 'chalk'
5
+ import { loadConfig, CONFIG_TEMPLATE } from './config.js'
6
+ import { computeCertaintyScore, computeScoreBreakdown, biggestGaps } from './certainty.js'
7
+ import { generateHTML, generateMarkdown } from './report.js'
8
+ import { loadHistory, appendSnapshot, diffAgainstLast } from './history.js'
9
+
10
+ // Lazy-load adapters by source name
11
+ async function loadAdapter(source) {
12
+ const adapters = {
13
+ linear: './adapters/linear.js',
14
+ jira: './adapters/jira.js',
15
+ notion: './adapters/notion.js',
16
+ github: './adapters/github.js',
17
+ }
18
+ const path = adapters[source]
19
+ if (!path) throw new Error(`Unknown source: "${source}". Supported: ${Object.keys(adapters).join(', ')}`)
20
+ return import(path)
21
+ }
22
+
23
+ program
24
+ .name('certainty-units')
25
+ .description('Certainty scoring for project work items')
26
+ .version('0.1.0')
27
+
28
+ program
29
+ .command('init')
30
+ .description('Create a certainty.config.yaml template in the current directory')
31
+ .action(() => {
32
+ writeFileSync('certainty.config.yaml', CONFIG_TEMPLATE)
33
+ console.log(chalk.green('✓') + ' Created certainty.config.yaml')
34
+ console.log(' Edit it with your API keys, then run: certainty-units sync')
35
+ })
36
+
37
+ program
38
+ .command('sync')
39
+ .description('Fetch items from your tool and compute certainty scores')
40
+ .option('-c, --config <path>', 'path to config file', 'certainty.config.yaml')
41
+ .option('--source <name>', 'override source from config')
42
+ .option('--json', 'also write cu-data.json')
43
+ .option('--fail-below <score>', 'exit with code 1 if average certainty is below this (CI gate)')
44
+ .option('--slack-webhook <url>', 'post the summary to a Slack incoming webhook')
45
+ .option('--no-history', 'skip reading/writing the snapshot history file')
46
+ .action(async (opts) => {
47
+ let config
48
+ try {
49
+ config = loadConfig(opts.config)
50
+ } catch (e) {
51
+ console.error(chalk.red('Error: ') + e.message)
52
+ process.exit(1)
53
+ }
54
+
55
+ const source = opts.source ?? config.source
56
+ console.log(chalk.dim(`Fetching from ${source}…`))
57
+
58
+ let adapter
59
+ try {
60
+ adapter = await loadAdapter(source)
61
+ } catch (e) {
62
+ console.error(chalk.red('Error: ') + e.message)
63
+ process.exit(1)
64
+ }
65
+
66
+ let items
67
+ try {
68
+ items = await adapter.fetchItems(config[source] ?? config)
69
+ } catch (e) {
70
+ console.error(chalk.red(`Failed to fetch from ${source}: `) + e.message)
71
+ process.exit(1)
72
+ }
73
+
74
+ console.log(chalk.dim(` ${items.length} items fetched. Computing certainty scores…`))
75
+
76
+ for (const item of items) {
77
+ item.certainty_score = computeCertaintyScore(item, item.citation_count ?? 0)
78
+ item.certainty_breakdown = computeScoreBreakdown(item, item.citation_count ?? 0)
79
+ }
80
+
81
+ const projectName = config.project ?? 'Project'
82
+ const out = config.output ?? {}
83
+
84
+ // summary numbers (needed before history/slack)
85
+ const scores = items.map(i => i.certainty_score)
86
+ const avg = scores.length ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length) : 0
87
+
88
+ // compare against the previous sync, then record this one
89
+ let diff = null
90
+ if (opts.history) {
91
+ const historyPath = out.history ?? '.cu-history.json'
92
+ const history = loadHistory(historyPath)
93
+ diff = diffAgainstLast(history, items, avg)
94
+ appendSnapshot(historyPath, history, items, avg)
95
+ }
96
+
97
+ const htmlPath = out.html ?? 'cu-report.html'
98
+ writeFileSync(htmlPath, generateHTML(items, projectName))
99
+ console.log(chalk.green('✓') + ` ${htmlPath}`)
100
+
101
+ if (out.markdown) {
102
+ writeFileSync(out.markdown, generateMarkdown(items, projectName))
103
+ console.log(chalk.green('✓') + ` ${out.markdown}`)
104
+ }
105
+
106
+ if (opts.json) {
107
+ writeFileSync('cu-data.json', JSON.stringify(items, null, 2))
108
+ console.log(chalk.green('✓') + ' cu-data.json')
109
+ }
110
+
111
+ // Print quick summary to terminal
112
+ const high = scores.filter(s => s >= 80).length
113
+ const medium = scores.filter(s => s >= 50 && s < 80).length
114
+ const low = scores.filter(s => s >= 20 && s < 50).length
115
+ const unc = scores.filter(s => s < 20).length
116
+
117
+ console.log()
118
+ console.log(chalk.bold(`${items.length} items · avg certainty ${avg}%`))
119
+ console.log(
120
+ chalk.green(` high ${high}`) + ' ' +
121
+ chalk.blue(`medium ${medium}`) + ' ' +
122
+ chalk.yellow(`low ${low}`) + ' ' +
123
+ chalk.red(`uncertain ${unc}`)
124
+ )
125
+
126
+ // What moved since the previous sync
127
+ if (diff) {
128
+ const arrow = diff.avgDelta > 0 ? chalk.green(`▲ +${diff.avgDelta}`)
129
+ : diff.avgDelta < 0 ? chalk.red(`▼ ${diff.avgDelta}`)
130
+ : chalk.dim('unchanged')
131
+ console.log()
132
+ console.log(chalk.bold(`Since last sync (${diff.since.slice(0, 10)}):`) +
133
+ ` avg ${diff.avgBefore}% → ${diff.avgNow}% ${arrow}` +
134
+ (diff.newItems ? chalk.dim(` · ${diff.newItems} new item${diff.newItems === 1 ? '' : 's'}`) : ''))
135
+ for (const m of diff.drops.slice(0, 3)) {
136
+ console.log(chalk.red(` ▼ ${m.prev} → ${m.now}`) + ` ${chalk.dim(m.item.external_id)} ${m.item.title.slice(0, 55)}`)
137
+ }
138
+ for (const m of diff.rises.slice(0, 3)) {
139
+ console.log(chalk.green(` ▲ ${m.prev} → ${m.now}`) + ` ${chalk.dim(m.item.external_id)} ${m.item.title.slice(0, 55)}`)
140
+ }
141
+ }
142
+
143
+ // Instant insight: the least certain open items, and what would fix them
144
+ const risky = items
145
+ .filter(i => i.workflow_status !== 'done' && (i.certainty_score ?? 0) < 50)
146
+ .sort((a, b) => (a.certainty_score ?? 0) - (b.certainty_score ?? 0))
147
+ .slice(0, 5)
148
+
149
+ if (risky.length) {
150
+ console.log()
151
+ console.log(chalk.bold('Least certain open items:'))
152
+ for (const item of risky) {
153
+ const gaps = biggestGaps(item, item.citation_count ?? 0)
154
+ .map(g => `${g.signal} +${g.gap}`)
155
+ .join(', ')
156
+ console.log(
157
+ ` ${chalk.red(String(item.certainty_score).padStart(3) + '%')} ` +
158
+ `${chalk.dim(item.external_id)} ${item.title.slice(0, 60)}`
159
+ )
160
+ console.log(chalk.dim(` missing: ${gaps}`))
161
+ }
162
+ }
163
+
164
+ // Post summary to Slack
165
+ const webhook = opts.slackWebhook ?? out.slackWebhook
166
+ if (webhook) {
167
+ const deltaText = diff
168
+ ? ` (${diff.avgDelta > 0 ? '+' : ''}${diff.avgDelta} since ${diff.since.slice(0, 10)})`
169
+ : ''
170
+ const lines = [
171
+ `*Certainty Units — ${projectName}*`,
172
+ `${items.length} items · avg certainty ${avg}%${deltaText}`,
173
+ `high ${high} · medium ${medium} · low ${low} · uncertain ${unc}`,
174
+ ]
175
+ if (risky.length) {
176
+ lines.push('', '*Least certain open items:*')
177
+ for (const item of risky) {
178
+ const gaps = biggestGaps(item, item.citation_count ?? 0).map(g => `${g.signal} +${g.gap}`).join(', ')
179
+ lines.push(`• <${item.url}|${item.external_id}> ${item.certainty_score}% — ${item.title.slice(0, 60)} _(missing: ${gaps})_`)
180
+ }
181
+ }
182
+ try {
183
+ const res = await fetch(webhook, {
184
+ method: 'POST',
185
+ headers: { 'Content-Type': 'application/json' },
186
+ body: JSON.stringify({ text: lines.join('\n') }),
187
+ })
188
+ if (!res.ok) throw new Error(`${res.status} ${await res.text()}`)
189
+ console.log(chalk.green('✓') + ' posted summary to Slack')
190
+ } catch (e) {
191
+ console.error(chalk.red('Slack webhook failed: ') + e.message)
192
+ }
193
+ }
194
+
195
+ // CI gate
196
+ if (opts.failBelow !== undefined) {
197
+ const threshold = Number(opts.failBelow)
198
+ if (Number.isNaN(threshold)) {
199
+ console.error(chalk.red('Error: ') + '--fail-below must be a number')
200
+ process.exit(1)
201
+ }
202
+ if (avg < threshold) {
203
+ console.error(chalk.red(`\n✗ average certainty ${avg}% is below the ${threshold}% gate`))
204
+ process.exit(1)
205
+ }
206
+ console.log(chalk.green(`\n✓ average certainty ${avg}% meets the ${threshold}% gate`))
207
+ }
208
+ })
209
+
210
+ program
211
+ .command('validate')
212
+ .description('Check config file for required fields without making API calls')
213
+ .option('-c, --config <path>', 'path to config file', 'certainty.config.yaml')
214
+ .action((opts) => {
215
+ try {
216
+ const config = loadConfig(opts.config)
217
+ console.log(chalk.green('✓') + ` Config is valid (source: ${config.source})`)
218
+ } catch (e) {
219
+ console.error(chalk.red('Invalid config: ') + e.message)
220
+ process.exit(1)
221
+ }
222
+ })
223
+
224
+ program.parse()
package/src/config.js ADDED
@@ -0,0 +1,75 @@
1
+ import { readFileSync, existsSync } from 'fs'
2
+ import { resolve } from 'path'
3
+ import yaml from 'js-yaml'
4
+
5
+ const CONFIG_FILE = 'certainty.config.yaml'
6
+
7
+ export function loadConfig(filePath) {
8
+ const path = resolve(filePath ?? CONFIG_FILE)
9
+ if (!existsSync(path)) {
10
+ throw new Error(`Config file not found: ${path}\nRun: certainty-units init`)
11
+ }
12
+ const raw = readFileSync(path, 'utf8')
13
+ const config = yaml.load(raw)
14
+
15
+ // Expand env vars like ${MY_VAR}
16
+ const json = JSON.stringify(config)
17
+ const expanded = json.replace(/\$\{([^}]+)\}/g, (_, key) => {
18
+ const val = process.env[key]
19
+ if (!val) throw new Error(`Missing env var: ${key}`)
20
+ return val
21
+ })
22
+ return JSON.parse(expanded)
23
+ }
24
+
25
+ export const CONFIG_TEMPLATE = `# certainty-units configuration
26
+ # Docs: https://github.com/2ngnhan/certainty-units
27
+
28
+ project: My Project
29
+
30
+ # Choose one source: linear | jira | notion | github
31
+ source: linear
32
+
33
+ linear:
34
+ apiKey: \${LINEAR_API_KEY}
35
+ teamId: your-team-id # from Linear team URL
36
+
37
+ # Optional: override field mappings
38
+ # fieldMap:
39
+ # validation_status:
40
+ # field: state.type
41
+ # map:
42
+ # completed: validated
43
+ # started: assumed
44
+ # cu_tier:
45
+ # field: estimate
46
+ # map:
47
+ # 1: basic
48
+ # 3: intermediate
49
+ # 8: advanced
50
+
51
+ # jira:
52
+ # host: yourteam.atlassian.net
53
+ # email: you@example.com
54
+ # apiToken: \${JIRA_API_TOKEN}
55
+ # projectKey: MYPROJECT
56
+
57
+ # github:
58
+ # repo: owner/name
59
+ # token: \${GITHUB_TOKEN} # optional for public repos
60
+
61
+ # notion:
62
+ # apiKey: \${NOTION_API_KEY}
63
+ # databaseId: your-database-id
64
+ # fieldMap:
65
+ # title_field: { field: Name }
66
+ # validation_status:
67
+ # field: Status
68
+ # map:
69
+ # Done: validated
70
+ # "In Progress": assumed
71
+
72
+ output:
73
+ html: cu-report.html
74
+ markdown: cu-report.md # optional — remove to skip
75
+ `
package/src/history.js ADDED
@@ -0,0 +1,55 @@
1
+ // Snapshot history — persists per-item scores across syncs so a run can
2
+ // report what moved, not just where things stand.
3
+
4
+ import { readFileSync, writeFileSync, existsSync } from 'fs'
5
+
6
+ const MAX_SNAPSHOTS = 104 // two years of weekly syncs
7
+
8
+ export function loadHistory(path) {
9
+ if (!existsSync(path)) return []
10
+ try {
11
+ const parsed = JSON.parse(readFileSync(path, 'utf8'))
12
+ return Array.isArray(parsed) ? parsed : []
13
+ } catch {
14
+ return [] // corrupt history should never block a sync
15
+ }
16
+ }
17
+
18
+ export function appendSnapshot(path, history, items, avg) {
19
+ const scores = {}
20
+ for (const item of items) scores[item.external_id] = item.certainty_score ?? 0
21
+ const snapshot = {
22
+ date: new Date().toISOString(),
23
+ avg,
24
+ count: items.length,
25
+ scores,
26
+ }
27
+ const next = [...history, snapshot].slice(-MAX_SNAPSHOTS)
28
+ writeFileSync(path, JSON.stringify(next, null, 2))
29
+ return snapshot
30
+ }
31
+
32
+ // Compare current items against the most recent snapshot.
33
+ // Returns null when there is no previous snapshot to compare against.
34
+ export function diffAgainstLast(history, items, avg) {
35
+ const last = history[history.length - 1]
36
+ if (!last) return null
37
+
38
+ const moved = []
39
+ for (const item of items) {
40
+ const prev = last.scores[item.external_id]
41
+ if (prev === undefined) continue
42
+ const delta = (item.certainty_score ?? 0) - prev
43
+ if (delta !== 0) moved.push({ item, prev, now: item.certainty_score ?? 0, delta })
44
+ }
45
+
46
+ return {
47
+ since: last.date,
48
+ avgBefore: last.avg,
49
+ avgNow: avg,
50
+ avgDelta: avg - last.avg,
51
+ drops: moved.filter(m => m.delta < 0).sort((a, b) => a.delta - b.delta),
52
+ rises: moved.filter(m => m.delta > 0).sort((a, b) => b.delta - a.delta),
53
+ newItems: items.filter(i => last.scores[i.external_id] === undefined).length,
54
+ }
55
+ }
package/src/report.js ADDED
@@ -0,0 +1,200 @@
1
+ import { certaintyLevel, computeCUMetrics, biggestGaps } from './certainty.js'
2
+
3
+ function esc(str) {
4
+ return String(str ?? '')
5
+ .replaceAll('&', '&amp;')
6
+ .replaceAll('<', '&lt;')
7
+ .replaceAll('>', '&gt;')
8
+ .replaceAll('"', '&quot;')
9
+ }
10
+
11
+ function levelColor(score) {
12
+ if (score >= 80) return '#24a148'
13
+ if (score >= 50) return '#0f62fe'
14
+ if (score >= 20) return '#f1c21b'
15
+ return '#da1e28'
16
+ }
17
+
18
+ function bar(score, breakdown) {
19
+ const color = levelColor(score)
20
+ const tooltip = breakdown
21
+ ? esc(breakdown.map(s => `${s.signal}: ${s.points}/${s.max} (${s.reason})`).join('\n'))
22
+ : ''
23
+ return `<div style="display:flex;align-items:center;gap:8px"${tooltip ? ` title="${tooltip}"` : ''}>
24
+ <div style="flex:1;background:#e0e0e0;border-radius:2px;height:6px">
25
+ <div style="width:${score}%;background:${color};height:6px;border-radius:2px"></div>
26
+ </div>
27
+ <span style="font-size:12px;min-width:32px;text-align:right;color:${color};font-weight:600">${score}</span>
28
+ </div>`
29
+ }
30
+
31
+ function metricBox(label, value, sub = '') {
32
+ return `<div style="background:#f4f4f4;border-radius:4px;padding:16px 20px;min-width:130px">
33
+ <div style="font-size:28px;font-weight:700;color:#161616">${value}</div>
34
+ <div style="font-size:12px;color:#525252;margin-top:2px">${label}</div>
35
+ ${sub ? `<div style="font-size:11px;color:#8d8d8d;margin-top:2px">${sub}</div>` : ''}
36
+ </div>`
37
+ }
38
+
39
+ function itemRow(item) {
40
+ const score = item.certainty_score ?? 0
41
+ const level = certaintyLevel(score)
42
+ const tierBadge = item.cu_tier
43
+ ? `<span style="font-size:10px;background:#e0e0e0;padding:2px 6px;border-radius:10px;margin-left:6px">${item.cu_tier}</span>`
44
+ : ''
45
+ return `<tr>
46
+ <td style="padding:10px 12px;border-bottom:1px solid #e0e0e0">
47
+ <a href="${esc(item.url) || '#'}" target="_blank" style="color:#0f62fe;text-decoration:none;font-size:13px">${esc(item.external_id)}</a>
48
+ </td>
49
+ <td style="padding:10px 12px;border-bottom:1px solid #e0e0e0;font-size:13px;max-width:320px">
50
+ ${esc(item.title)}${tierBadge}
51
+ </td>
52
+ <td style="padding:10px 12px;border-bottom:1px solid #e0e0e0;font-size:12px;color:#525252">${esc(item.workflow_status)}</td>
53
+ <td style="padding:10px 12px;border-bottom:1px solid #e0e0e0;font-size:12px;color:#525252">${esc(item.validation_status)}</td>
54
+ <td style="padding:10px 12px;border-bottom:1px solid #e0e0e0;width:160px">${bar(score, item.certainty_breakdown)}</td>
55
+ <td style="padding:10px 12px;border-bottom:1px solid #e0e0e0;font-size:12px;color:#525252;text-align:center">
56
+ <span style="color:${levelColor(score)};font-weight:600">${level}</span>
57
+ </td>
58
+ </tr>`
59
+ }
60
+
61
+ function needsAttention(items) {
62
+ const risky = items
63
+ .filter(i => i.workflow_status !== 'done' && (i.certainty_score ?? 0) < 50)
64
+ .sort((a, b) => (a.certainty_score ?? 0) - (b.certainty_score ?? 0))
65
+ .slice(0, 5)
66
+ if (!risky.length) return ''
67
+
68
+ const rows = risky.map(i => {
69
+ const gaps = biggestGaps(i, i.citation_count ?? 0)
70
+ .map(g => `<span style="white-space:nowrap">${esc(g.hint)} <b>+${g.gap}</b></span>`)
71
+ .join(' &middot; ')
72
+ return `<div style="display:flex;gap:12px;align-items:baseline;padding:8px 0;border-bottom:1px solid #e0e0e0">
73
+ <span style="color:${levelColor(i.certainty_score ?? 0)};font-weight:700;min-width:40px">${i.certainty_score ?? 0}%</span>
74
+ <a href="${esc(i.url) || '#'}" target="_blank" style="color:#0f62fe;text-decoration:none;font-size:13px">${esc(i.external_id)}</a>
75
+ <span style="font-size:13px;flex:1">${esc(i.title)}</span>
76
+ <span style="font-size:12px;color:#525252">${gaps}</span>
77
+ </div>`
78
+ })
79
+
80
+ return `<h2>Needs attention &mdash; least certain open items</h2>
81
+ <div style="background:#fff;border:1px solid #e0e0e0;border-radius:4px;padding:8px 16px;margin-bottom:32px">
82
+ ${rows.join('\n')}
83
+ </div>`
84
+ }
85
+
86
+ export function generateHTML(items, projectName = 'Project') {
87
+ const metrics = computeCUMetrics(items)
88
+ const sorted = [...items].sort((a, b) => (b.certainty_score ?? 0) - (a.certainty_score ?? 0))
89
+ const now = new Date().toLocaleString()
90
+
91
+ return `<!DOCTYPE html>
92
+ <html lang="en">
93
+ <head>
94
+ <meta charset="UTF-8">
95
+ <meta name="viewport" content="width=device-width,initial-scale=1">
96
+ <title>Certainty Units — ${projectName}</title>
97
+ <style>
98
+ * { box-sizing: border-box; margin: 0; padding: 0 }
99
+ body { font-family: 'IBM Plex Sans', system-ui, sans-serif; background: #fff; color: #161616 }
100
+ header { background: #161616; color: #fff; padding: 24px 32px; display: flex; justify-content: space-between; align-items: center }
101
+ header h1 { font-size: 20px; font-weight: 400; letter-spacing: 0.16px }
102
+ header .meta { font-size: 12px; color: #8d8d8d }
103
+ main { max-width: 1200px; margin: 0 auto; padding: 32px }
104
+ .metrics { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 32px }
105
+ h2 { font-size: 16px; font-weight: 600; margin-bottom: 16px; color: #161616 }
106
+ table { width: 100%; border-collapse: collapse }
107
+ th { text-align: left; padding: 8px 12px; font-size: 12px; color: #525252; font-weight: 600; border-bottom: 2px solid #e0e0e0; background: #f4f4f4 }
108
+ tr:hover td { background: #f4f4f4 }
109
+ .hill { display: flex; gap: 4px; align-items: flex-end; height: 48px; margin-bottom: 8px }
110
+ .hill-bar { flex: 1; background: #0f62fe; border-radius: 2px 2px 0 0; opacity: 0.7 }
111
+ footer { text-align: center; padding: 24px; font-size: 12px; color: #8d8d8d }
112
+ footer a { color: #8d8d8d }
113
+ </style>
114
+ </head>
115
+ <body>
116
+ <header>
117
+ <h1>Certainty Units &mdash; ${projectName}</h1>
118
+ <span class="meta">Generated ${now}</span>
119
+ </header>
120
+ <main>
121
+ <div class="metrics">
122
+ ${metricBox('Total items', metrics.totalItems)}
123
+ ${metricBox('With CU tier', metrics.cuItems)}
124
+ ${metricBox('Avg certainty', metrics.avgCertaintyScore + '%')}
125
+ ${metricBox('Completion rate', metrics.completionRate + '%', `${metrics.completedCUValue} / ${metrics.totalCUValue} CU`)}
126
+ ${metricBox('Integrity score', metrics.integrityScore + '%', 'validated / completed')}
127
+ ${metricBox('Uphill', metrics.uphill, 'discovery zone')}
128
+ ${metricBox('Downhill', metrics.downhill, 'execution zone')}
129
+ </div>
130
+
131
+ ${needsAttention(items)}
132
+
133
+ <h2>Items by certainty score</h2>
134
+ <table>
135
+ <thead>
136
+ <tr>
137
+ <th>ID</th>
138
+ <th>Title</th>
139
+ <th>Status</th>
140
+ <th>Validation</th>
141
+ <th>Certainty</th>
142
+ <th>Level</th>
143
+ </tr>
144
+ </thead>
145
+ <tbody>
146
+ ${sorted.map(itemRow).join('\n')}
147
+ </tbody>
148
+ </table>
149
+ </main>
150
+ <footer>
151
+ Generated by <a href="https://github.com/2ngnhan/certainty-units" target="_blank">certainty-units</a> &mdash; open source
152
+ </footer>
153
+ </body>
154
+ </html>`
155
+ }
156
+
157
+ export function generateMarkdown(items, projectName = 'Project') {
158
+ const m = computeCUMetrics(items)
159
+ const now = new Date().toISOString().slice(0, 10)
160
+ const sorted = [...items].sort((a, b) => (b.certainty_score ?? 0) - (a.certainty_score ?? 0))
161
+
162
+ const rows = sorted.slice(0, 30).map(i => {
163
+ const score = i.certainty_score ?? 0
164
+ const level = certaintyLevel(score)
165
+ return `| ${i.external_id} | ${i.title.slice(0, 50)} | ${i.workflow_status} | ${score} | ${level} |`
166
+ })
167
+
168
+ const risky = items
169
+ .filter(i => i.workflow_status !== 'done' && (i.certainty_score ?? 0) < 50)
170
+ .sort((a, b) => (a.certainty_score ?? 0) - (b.certainty_score ?? 0))
171
+ .slice(0, 5)
172
+ const attention = risky.map(i => {
173
+ const gaps = biggestGaps(i, i.citation_count ?? 0).map(g => `${g.hint} (+${g.gap})`).join(' · ')
174
+ return `- **${i.certainty_score ?? 0}%** [${i.external_id}](${i.url || '#'}) ${i.title.slice(0, 60)}\n ${gaps}`
175
+ })
176
+
177
+ return `# Certainty Units Report — ${projectName}
178
+ _${now}_
179
+ ${attention.length ? `\n## Needs attention\n\n${attention.join('\n')}\n` : ''}
180
+ ## Summary
181
+
182
+ | Metric | Value |
183
+ |--------|-------|
184
+ | Total items | ${m.totalItems} |
185
+ | Avg certainty | ${m.avgCertaintyScore}% |
186
+ | Completion rate | ${m.completionRate}% |
187
+ | Integrity score | ${m.integrityScore}% |
188
+ | Uphill (discovery) | ${m.uphill} |
189
+ | Downhill (execution) | ${m.downhill} |
190
+
191
+ ## Items (top 30)
192
+
193
+ | ID | Title | Status | Score | Level |
194
+ |----|-------|--------|-------|-------|
195
+ ${rows.join('\n')}
196
+
197
+ ---
198
+ Generated by [certainty-units](https://github.com/2ngnhan/certainty-units)
199
+ `
200
+ }