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/LICENSE +21 -0
- package/README.md +184 -0
- package/examples/cu-report.yml +48 -0
- package/examples/github.yaml +15 -0
- package/examples/jira.yaml +23 -0
- package/examples/linear.yaml +25 -0
- package/examples/notion.yaml +28 -0
- package/package.json +35 -0
- package/src/adapters/github.js +96 -0
- package/src/adapters/jira.js +122 -0
- package/src/adapters/linear.js +130 -0
- package/src/adapters/notion.js +143 -0
- package/src/adapters/util.js +90 -0
- package/src/certainty.js +152 -0
- package/src/cli.js +224 -0
- package/src/config.js +75 -0
- package/src/history.js +55 -0
- package/src/report.js +200 -0
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('&', '&')
|
|
6
|
+
.replaceAll('<', '<')
|
|
7
|
+
.replaceAll('>', '>')
|
|
8
|
+
.replaceAll('"', '"')
|
|
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(' · ')
|
|
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 — 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 — ${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> — 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
|
+
}
|