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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nhan Nguyen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,184 @@
1
+ # certainty-units
2
+
3
+ Free, open-source certainty scoring for project work items.
4
+ Connects to **GitHub Issues, Linear, Jira, Notion** (Monday + ClickUp coming).
5
+ Outputs a static HTML dashboard + Markdown report — zero server required.
6
+
7
+ ```
8
+ npx certainty-units sync
9
+ ```
10
+
11
+ Teams that track success by counting effort (points, tickets closed) see *how much* is moving —
12
+ certainty-units shows *how sure you are* about what's moving, and what would make you surer.
13
+
14
+ ## Quick start
15
+
16
+ Fastest path — score any public GitHub repo, no API key needed:
17
+
18
+ ```bash
19
+ npx certainty-units init
20
+ # edit certainty.config.yaml:
21
+ # source: github
22
+ # github:
23
+ # repo: your-org/your-repo
24
+ npx certainty-units sync
25
+
26
+ # Output: cu-report.html (open in any browser)
27
+ ```
28
+
29
+ For Linear / Jira / Notion, set the API key env var and run the same command:
30
+
31
+ ```bash
32
+ LINEAR_API_KEY=lin_api_xxx npx certainty-units sync
33
+ ```
34
+
35
+ Every sync prints instant insights in the terminal:
36
+
37
+ ```
38
+ 278 items · avg certainty 56%
39
+ high 68 medium 116 low 54 uncertain 40
40
+
41
+ Since last sync (2026-06-27): avg 54% → 56% ▲ +2 · 12 new items
42
+ ▼ 45 → 20 ENG-142 Add SSO support
43
+ ▲ 30 → 80 ENG-98 Migrate billing
44
+
45
+ Least certain open items:
46
+ 5% #1099 always_allow permission policy not honored
47
+ missing: validation +40, workflow +20, discussion +15
48
+ ```
49
+
50
+ ## What it scores
51
+
52
+ Each work item gets a **Certainty Score (0–100)**:
53
+
54
+ | Signal | Max points | How it maps |
55
+ |--------|-----------|-------------|
56
+ | Validation status | 40 | `validated` → 40, `assumed` → 10 |
57
+ | Workflow progress | 20 | `done` → 20, `review` → 15, `in_progress` → 10 |
58
+ | Discussion | up to 15 | 5 pts per comment — undebated items hide risk |
59
+ | Acceptance criteria | 10 | found on the item (see below) |
60
+ | CU tier set | 10 | basic / intermediate / advanced |
61
+ | Evidence / description | 5 | non-empty description |
62
+
63
+ Scores map to levels: **high** (≥80) · **medium** (≥50) · **low** (≥20) · **uncertain** (<20)
64
+
65
+ Every score is auditable: hover the score bar in the HTML report for the per-signal
66
+ breakdown, and `cu-data.json` (via `--json`) includes `certainty_breakdown` per item.
67
+
68
+ ## Feed it real signals
69
+
70
+ The score is only as honest as its inputs, so the adapters read signals your team
71
+ already produces:
72
+
73
+ - **Acceptance criteria** are detected automatically from an `## Acceptance Criteria`
74
+ heading (or `**Acceptance Criteria**`, or an `Acceptance criteria:` line) in the
75
+ item description, or from a markdown checklist (`- [ ] …`). In Notion, use an
76
+ `Acceptance Criteria` column.
77
+ - **Validation labels** override workflow-derived validation. Label an item
78
+ `validated`, `assumed`, or `needs-clarification` (Notion: tags) to record validation
79
+ explicitly instead of letting "closed" imply "validated".
80
+ - **CU tier labels** — `cu:basic`, `cu:intermediate`, `cu:advanced` — set the tier
81
+ directly on any tool that has labels.
82
+
83
+ ## Track movement, not just state
84
+
85
+ Each sync snapshots per-item scores to `.cu-history.json` and the next sync reports
86
+ what moved: average trend, biggest drops and rises, new items. Commit the file (or
87
+ cache it in CI) to keep the trail. Disable with `--no-history`.
88
+
89
+ ## Use it as a CI gate
90
+
91
+ ```bash
92
+ certainty-units sync --fail-below 50 # exit 1 if avg certainty < 50%
93
+ ```
94
+
95
+ Put it in front of a sprint kickoff or release train to block on too much uncertainty.
96
+
97
+ ## Post to Slack
98
+
99
+ ```bash
100
+ certainty-units sync --slack-webhook https://hooks.slack.com/services/…
101
+ ```
102
+
103
+ Posts the summary and least-certain items to a channel (or set `output.slackWebhook`
104
+ in the config).
105
+
106
+ ## Supported sources
107
+
108
+ | Tool | Status | Auth |
109
+ |------|--------|------|
110
+ | GitHub Issues | ✅ | none for public repos, token for private |
111
+ | Linear | ✅ | API key |
112
+ | Jira | ✅ | API token |
113
+ | Notion | ✅ | API key |
114
+ | Monday.com | 🚧 coming | |
115
+ | ClickUp | 🚧 coming | |
116
+
117
+ ## Config reference
118
+
119
+ ```yaml
120
+ project: My Project
121
+ source: github # github | linear | jira | notion
122
+
123
+ github:
124
+ repo: owner/name
125
+ token: ${GITHUB_TOKEN} # optional for public repos
126
+ # state: all # all | open | closed
127
+ # limit: 500 # most recently updated issues to fetch
128
+
129
+ linear:
130
+ apiKey: ${LINEAR_API_KEY} # env var expansion supported
131
+ teamId: your-team-id
132
+
133
+ jira:
134
+ host: yourteam.atlassian.net
135
+ email: you@example.com
136
+ apiToken: ${JIRA_API_TOKEN}
137
+ projectKey: MYPROJECT
138
+
139
+ notion:
140
+ apiKey: ${NOTION_API_KEY}
141
+ databaseId: your-database-id
142
+
143
+ output:
144
+ html: cu-report.html
145
+ markdown: cu-report.md # optional
146
+ history: .cu-history.json # optional — score snapshots for deltas
147
+ # slackWebhook: https://hooks.slack.com/services/…
148
+ ```
149
+
150
+ ### Field mapping
151
+
152
+ Every tool uses different status names. Override the defaults:
153
+
154
+ ```yaml
155
+ linear:
156
+ apiKey: ${LINEAR_API_KEY}
157
+ teamId: abc123
158
+ fieldMap:
159
+ validation_status:
160
+ field: state.type # dot-path into the Linear issue object
161
+ map:
162
+ completed: validated
163
+ started: assumed
164
+ cu_tier:
165
+ field: estimate
166
+ map:
167
+ 1: basic
168
+ 3: intermediate
169
+ 8: advanced
170
+ ```
171
+
172
+ ## Automate with GitHub Actions
173
+
174
+ 1. Copy [examples/cu-report.yml](examples/cu-report.yml) to `.github/workflows/` in your repo
175
+ 2. Add your API key as a repo secret (`LINEAR_API_KEY`, or nothing for public GitHub repos)
176
+ 3. Enable GitHub Pages → the report publishes every Monday, with week-over-week deltas
177
+
178
+ ## License
179
+
180
+ MIT — free to use, modify, and embed.
181
+
182
+ ---
183
+
184
+ Built on the [Certainty Units](https://propozel.com) methodology.
@@ -0,0 +1,48 @@
1
+ name: Certainty Units Report
2
+
3
+ on:
4
+ schedule:
5
+ - cron: '0 8 * * 1' # every Monday at 8am UTC
6
+ workflow_dispatch: # also runnable manually
7
+
8
+ permissions:
9
+ contents: write
10
+
11
+ jobs:
12
+ report:
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+
17
+ - uses: actions/setup-node@v4
18
+ with:
19
+ node-version: '20'
20
+
21
+ # Keep score history across runs so each report shows what moved
22
+ - name: Restore sync history
23
+ uses: actions/cache@v4
24
+ with:
25
+ path: .cu-history.json
26
+ key: cu-history-${{ github.run_id }}
27
+ restore-keys: cu-history-
28
+
29
+ - name: Generate report
30
+ env:
31
+ LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
32
+ # JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
33
+ # NOTION_API_KEY: ${{ secrets.NOTION_API_KEY }}
34
+ # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # for source: github
35
+ run: |
36
+ npx certainty-units@latest sync --json
37
+ # add --slack-webhook ${{ secrets.SLACK_WEBHOOK }} to post the summary
38
+ # add --fail-below 50 to fail this job when avg certainty drops too low
39
+
40
+ # Publish only the report, never the repo checkout
41
+ - name: Stage report
42
+ run: mkdir -p public && cp cu-report.html public/index.html
43
+
44
+ - name: Publish to GitHub Pages
45
+ uses: peaceiris/actions-gh-pages@v4
46
+ with:
47
+ github_token: ${{ secrets.GITHUB_TOKEN }}
48
+ publish_dir: ./public
@@ -0,0 +1,15 @@
1
+ project: My GitHub Project
2
+ source: github
3
+
4
+ github:
5
+ repo: owner/name # e.g. vercel/next.js
6
+ # token: ${GITHUB_TOKEN} # uncomment for private repos / higher rate limits
7
+
8
+ # GitHub has no priority field — set CU tier with labels on your issues:
9
+ # cu:basic cu:intermediate cu:advanced
10
+ # And record validation explicitly with labels:
11
+ # validated assumed needs-clarification
12
+
13
+ output:
14
+ html: cu-report.html
15
+ markdown: cu-report.md
@@ -0,0 +1,23 @@
1
+ project: My Jira Project
2
+ source: jira
3
+
4
+ jira:
5
+ host: yourteam.atlassian.net
6
+ email: you@example.com
7
+ apiToken: ${JIRA_API_TOKEN} # create at id.atlassian.com/manage-profile/security/api-tokens
8
+ projectKey: MYPROJECT
9
+
10
+ # Uncomment to override default field mappings
11
+ # jql: 'project = MYPROJECT AND sprint in openSprints()' # custom JQL filter
12
+ # fieldMap:
13
+ # validation_status:
14
+ # field: fields.status.name
15
+ # map:
16
+ # "Done": validated
17
+ # "In Review": assumed
18
+ # "In Progress": assumed
19
+ # "To Do": unvalidated
20
+
21
+ output:
22
+ html: cu-report.html
23
+ markdown: cu-report.md
@@ -0,0 +1,25 @@
1
+ project: My Linear Project
2
+ source: linear
3
+
4
+ linear:
5
+ apiKey: ${LINEAR_API_KEY}
6
+ teamId: your-team-id # find at linear.app/settings → your team URL
7
+
8
+ # Uncomment and adjust to match your team's field conventions
9
+ # fieldMap:
10
+ # cu_tier:
11
+ # field: estimate # use story points as CU tier proxy
12
+ # map:
13
+ # 1: basic
14
+ # 3: intermediate
15
+ # 8: advanced
16
+ # validation_status:
17
+ # field: state.type
18
+ # map:
19
+ # completed: validated
20
+ # started: assumed
21
+ # triage: needs_clarification
22
+
23
+ output:
24
+ html: cu-report.html
25
+ markdown: cu-report.md
@@ -0,0 +1,28 @@
1
+ project: My Notion Project
2
+ source: notion
3
+
4
+ notion:
5
+ apiKey: ${NOTION_API_KEY} # create at notion.so/my-integrations
6
+ databaseId: your-database-id # from the database URL: notion.so/xxx/<database-id>?v=...
7
+
8
+ fieldMap:
9
+ title_field: { field: Name } # column that holds the item title
10
+ assignee_field: { field: Assignee }
11
+ estimate_field: { field: Estimate } # number column for CU value
12
+ validation_status:
13
+ field: Status # your Status column name
14
+ map:
15
+ Done: validated
16
+ "In Progress": assumed
17
+ "To Do": unvalidated
18
+ Backlog: unvalidated
19
+ cu_tier:
20
+ field: Priority # your Priority column name
21
+ map:
22
+ High: advanced
23
+ Medium: intermediate
24
+ Low: basic
25
+
26
+ output:
27
+ html: cu-report.html
28
+ markdown: cu-report.md
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "certainty-units",
3
+ "version": "0.2.0",
4
+ "description": "Certainty scoring for project work items — connects to Linear, Jira, Notion, GitHub Issues",
5
+ "type": "module",
6
+ "bin": {
7
+ "certainty-units": "./src/cli.js"
8
+ },
9
+ "files": [
10
+ "src",
11
+ "examples",
12
+ "LICENSE",
13
+ "README.md"
14
+ ],
15
+ "scripts": {
16
+ "test": "node --test"
17
+ },
18
+ "keywords": ["project-management", "certainty", "linear", "jira", "notion", "github", "agile", "hill-chart"],
19
+ "license": "MIT",
20
+ "author": "Nhan Nguyen <nhan@naucode.com>",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/2ngnhan/certainty-units.git"
24
+ },
25
+ "homepage": "https://github.com/2ngnhan/certainty-units#readme",
26
+ "bugs": "https://github.com/2ngnhan/certainty-units/issues",
27
+ "dependencies": {
28
+ "chalk": "^5.3.0",
29
+ "commander": "^12.0.0",
30
+ "js-yaml": "^4.1.0"
31
+ },
32
+ "engines": {
33
+ "node": ">=18"
34
+ }
35
+ }
@@ -0,0 +1,96 @@
1
+ // GitHub Issues adapter — maps repo issues to CU items via REST API.
2
+ // Works without a token on public repos (rate-limited); set github.token for
3
+ // private repos or higher limits.
4
+
5
+ import {
6
+ applyMap, mergeFieldMap,
7
+ extractAcceptanceCriteria, validationFromLabels, tierFromLabels,
8
+ } from './util.js'
9
+
10
+ const GITHUB_API = 'https://api.github.com'
11
+
12
+ async function githubFetch(url, token) {
13
+ const headers = {
14
+ Accept: 'application/vnd.github+json',
15
+ 'X-GitHub-Api-Version': '2022-11-28',
16
+ }
17
+ if (token) headers.Authorization = `Bearer ${token}`
18
+ const res = await fetch(url, { headers })
19
+ if (!res.ok) throw new Error(`GitHub API ${res.status}: ${await res.text()}`)
20
+ // cursor-based pagination: GitHub puts the next-page URL in the Link header
21
+ const next = res.headers.get('link')?.match(/<([^>]+)>;\s*rel="next"/)?.[1] ?? null
22
+ return { data: await res.json(), next }
23
+ }
24
+
25
+ // Most recently updated first, capped so a huge backlog doesn't hang the sync.
26
+ async function fetchAllIssues(repo, token, { state = 'all', limit = 500 } = {}) {
27
+ const issues = []
28
+ let url = `${GITHUB_API}/repos/${repo}/issues?state=${state}&sort=updated&direction=desc&per_page=100`
29
+ while (url && issues.length < limit) {
30
+ const { data, next } = await githubFetch(url, token)
31
+ // the issues endpoint also returns pull requests — skip them
32
+ issues.push(...data.filter(i => !i.pull_request))
33
+ url = next
34
+ }
35
+ return issues.slice(0, limit)
36
+ }
37
+
38
+ const DEFAULT_MAP = {
39
+ validation_status: {
40
+ field: 'state_reason',
41
+ map: {
42
+ completed: 'validated',
43
+ not_planned: 'unvalidated',
44
+ reopened: 'assumed',
45
+ },
46
+ default: 'unvalidated',
47
+ },
48
+ }
49
+
50
+ function workflowStatus(issue) {
51
+ if (issue.state === 'closed') return 'done'
52
+ return issue.assignee ? 'in_progress' : 'todo'
53
+ }
54
+
55
+ export async function fetchItems(config) {
56
+ const { repo, token, state, limit } = config
57
+ if (!repo || !repo.includes('/')) {
58
+ throw new Error('github.repo is required (e.g. owner/name)')
59
+ }
60
+
61
+ const effectiveLimit = limit ?? 500
62
+ const issues = await fetchAllIssues(repo, token, { state, limit: effectiveLimit })
63
+ if (issues.length >= effectiveLimit) {
64
+ console.error(` note: capped at ${issues.length} most recently updated issues (set github.limit to raise)`)
65
+ }
66
+ const fieldMap = mergeFieldMap(DEFAULT_MAP, config.fieldMap)
67
+
68
+ return issues.map(issue => {
69
+ const labels = issue.labels?.map(l => (typeof l === 'string' ? l : l.name)) ?? []
70
+ const body = issue.body || ''
71
+
72
+ return {
73
+ id: String(issue.id),
74
+ external_id: `#${issue.number}`,
75
+ title: issue.title,
76
+ description: body,
77
+ url: issue.html_url,
78
+ // an explicit validation label beats inferring validation from issue state
79
+ validation_status: validationFromLabels(labels) ?? applyMap(issue, fieldMap.validation_status),
80
+ workflow_status: workflowStatus(issue),
81
+ cu_tier: tierFromLabels(labels),
82
+ cu_value: 1,
83
+ evidence: body,
84
+ acceptance_criteria: extractAcceptanceCriteria(body),
85
+ novelty_rating: null,
86
+ complexity_rating: null,
87
+ dependency_rating: null,
88
+ citation_count: issue.comments ?? 0,
89
+ assignee: issue.assignee?.login ?? null,
90
+ labels,
91
+ created_at: issue.created_at,
92
+ updated_at: issue.updated_at,
93
+ source: 'github',
94
+ }
95
+ })
96
+ }
@@ -0,0 +1,122 @@
1
+ // Jira adapter — maps Jira issues to CU items via REST API
2
+
3
+ import {
4
+ applyMap, mergeFieldMap,
5
+ extractAcceptanceCriteria, validationFromLabels, tierFromLabels,
6
+ } from './util.js'
7
+
8
+ function base64(str) {
9
+ return Buffer.from(str).toString('base64')
10
+ }
11
+
12
+ async function jiraFetch(config, path) {
13
+ const { host, email, apiToken } = config
14
+ const url = `https://${host}/rest/api/3${path}`
15
+ const auth = base64(`${email}:${apiToken}`)
16
+ const res = await fetch(url, {
17
+ headers: { Authorization: `Basic ${auth}`, Accept: 'application/json' },
18
+ })
19
+ if (!res.ok) throw new Error(`Jira API ${res.status}: ${await res.text()}`)
20
+ return res.json()
21
+ }
22
+
23
+ async function fetchAllIssues(config) {
24
+ const { projectKey, jql: customJql } = config
25
+ const jql = customJql ?? `project = "${projectKey}" ORDER BY updated DESC`
26
+ const issues = []
27
+ let startAt = 0
28
+ const maxResults = 100
29
+
30
+ do {
31
+ const data = await jiraFetch(
32
+ config,
33
+ `/search?jql=${encodeURIComponent(jql)}&startAt=${startAt}&maxResults=${maxResults}&fields=summary,description,status,priority,assignee,labels,comment,created,updated,customfield_10016`
34
+ )
35
+ issues.push(...data.issues)
36
+ startAt += data.issues.length
37
+ if (startAt >= data.total) break
38
+ } while (true)
39
+
40
+ return issues
41
+ }
42
+
43
+ const DEFAULT_MAP = {
44
+ validation_status: {
45
+ field: 'fields.status.statusCategory.key',
46
+ map: {
47
+ done: 'validated',
48
+ 'in-progress': 'assumed',
49
+ new: 'unvalidated',
50
+ },
51
+ default: 'unvalidated',
52
+ },
53
+ workflow_status: {
54
+ field: 'fields.status.statusCategory.key',
55
+ map: {
56
+ done: 'done',
57
+ 'in-progress': 'in_progress',
58
+ new: 'todo',
59
+ },
60
+ default: 'todo',
61
+ },
62
+ cu_tier: {
63
+ field: 'fields.priority.name',
64
+ map: {
65
+ Highest: 'advanced',
66
+ High: 'advanced',
67
+ Medium: 'intermediate',
68
+ Low: 'basic',
69
+ Lowest: 'basic',
70
+ },
71
+ default: null,
72
+ },
73
+ }
74
+
75
+ export async function fetchItems(config) {
76
+ const { host, email, apiToken, projectKey } = config
77
+ if (!host) throw new Error('jira.host is required (e.g. yourteam.atlassian.net)')
78
+ if (!email) throw new Error('jira.email is required')
79
+ if (!apiToken) throw new Error('jira.apiToken is required')
80
+ if (!projectKey) throw new Error('jira.projectKey is required')
81
+
82
+ const issues = await fetchAllIssues(config)
83
+ const fieldMap = mergeFieldMap(DEFAULT_MAP, config.fieldMap)
84
+
85
+ return issues.map(issue => {
86
+ const f = issue.fields
87
+ // customfield_10016 is story points in most Jira configs
88
+ const storyPoints = f.customfield_10016
89
+
90
+ // one line per ADF block so heading/checklist detection works downstream
91
+ const descText = f.description?.content
92
+ ?.map(b => (b.content ?? []).filter(n => n.type === 'text').map(n => n.text).join(''))
93
+ ?.filter(Boolean)
94
+ ?.join('\n') ?? ''
95
+
96
+ const labels = f.labels ?? []
97
+
98
+ return {
99
+ id: issue.id,
100
+ external_id: issue.key,
101
+ title: f.summary,
102
+ description: descText,
103
+ url: `https://${host}/browse/${issue.key}`,
104
+ // an explicit validation label beats inferring validation from workflow state
105
+ validation_status: validationFromLabels(labels) ?? applyMap(issue, fieldMap.validation_status),
106
+ workflow_status: applyMap(issue, fieldMap.workflow_status),
107
+ cu_tier: tierFromLabels(labels) ?? applyMap(issue, fieldMap.cu_tier),
108
+ cu_value: storyPoints ?? 1,
109
+ evidence: descText,
110
+ acceptance_criteria: extractAcceptanceCriteria(descText),
111
+ novelty_rating: null,
112
+ complexity_rating: null,
113
+ dependency_rating: null,
114
+ citation_count: f.comment?.total ?? 0,
115
+ assignee: f.assignee?.displayName ?? null,
116
+ labels,
117
+ created_at: f.created,
118
+ updated_at: f.updated,
119
+ source: 'jira',
120
+ }
121
+ })
122
+ }