Builds a defensible, evidence-backed record of one person's work from Jira, Confluence and GitHub — for performance reviews, promotion cases and CVs.
The design goal is that every sentence in the output can be traced to an artifact, and every number to a computed metric. A career document is only worth having if it survives a reader who was in the room.
raw/ untouched API payloads, append-only NDJSON
normalized/ one Artifact schema across all sources, with ownership tiers
enriched/ link graph, initiatives, metrics (deterministic, no LLM)
summaries/ per-artifact LLM summaries, cached on content hash
narrative/ story.md + verification_report.json
Each layer is a pure function of the one below it. Change a detection rule and
re-run build; nothing is refetched and no summary is regenerated.
careerkit/ is the only tool — everything runs through it.
Two things are specific to you and need setting up before the tool is useful:
- Identity — set via environment variables (see
.env.example):SUBJECT_NAME,SUBJECT_EMAIL,SUBJECT_ATLASSIAN_ACCOUNT_ID,SUBJECT_GITHUB_LOGIN,SUBJECT_START_DATE,SUBJECT_TIMEZONE,SUBJECT_WORKDAY_WINDOWS,GITHUB_ORG,COMPANY_NAME. - Internal system names —
careerkit/taxonomy.py's_SYSTEM_PATTERNS,_SYSTEM_PATH_PATTERNS, andSYSTEM_BY_REPOship with commented-out example entries. This is the one part of the tool that can't be generic: only you know what your team calls the things you built. Fill these in with your own systems, or leave them empty — everything else works fine without this, it just means system-level grouping in the narrative won't fire.
python -m careerkit.cli statuspython -m careerkit.cli buildpython -m careerkit.cli storyIndividual stages:
python -m careerkit.connectors.github --what prs reviews commitspython -m careerkit.connectors.confluencepython -m careerkit.normalize && python -m careerkit.enrichpython -m careerkit.summarize --workers 5python -m careerkit.narrate && python -m careerkit.verifyInspect the computed facts without calling an LLM:
python -m careerkit.narrate --facts-onlyAttribution is a tier derived from evidence, not a confidence score. The
narrative layer may only use the verbs a tier licenses, and verify fails the
build if it doesn't.
| Tier | Evidence | Permitted language |
|---|---|---|
OWNED |
Jira assignee, merged PR author, Confluence page creator, commit author | built, designed, shipped, owned, implemented, migrated |
DROVE |
ticket reporter, submitted PR review, authored-but-unmerged PR | drove, reviewed, specified, scoped, identified |
CONTRIBUTED |
substantive comment (≥80 chars, not an acknowledgement), published page edit | contributed to, helped, debugged alongside, advised on |
ADJACENT |
watcher, or name appears in someone else's artifact | excluded from all claims |
ADJACENT exists because it is the trap. In one archive this ran against,
roughly half the Confluence pages the subject's name showed up on were pages
someone else had written — team retrospectives, re-org notes — where the
subject was merely mentioned. A prompt that says "every item provided is your
work" turns that into a false authorship claim; ownership tiers catch it
instead of trusting the prompt's assertion.
careerkit.verify checks the generated narrative and exits non-zero on error:
- citation-not-found — a cited PR or ticket id that doesn't exist
- unverified-number — a quantity absent from both
metrics.jsonand every artifact's text. A+suffix ("400+") is always an error, since it asserts a magnitude. - number-quoted-from-source (info) — not a computed metric but present in artifact text, e.g. an SLO of 99.5%. Sourced, so worth a glance, not a defect.
- ownership-inflation — an authorship verb in a sentence citing a non-
OWNEDartifact - adjacent-artifact-mentioned — an
ADJACENTtitle appearing in the story
Run it against any markdown file:
python -m careerkit.verify --story path/to/draft.mdIdentity, repos and working hours are all environment-variable driven (see
"Customize before running" above and .env.example for the full list).
Secrets stay in .env (gitignored):
JIRA_BASE_URL=https://<org>.atlassian.net
CONFLUENCE_BASE_URL=https://<org>.atlassian.net/wiki
JIRA_EMAIL=you@example.com
JIRA_API_TOKEN=...
GitHub uses the gh CLI. Locally, gh auth login once and nothing else is
needed. Inside the container there's no keyring to authenticate against, so
set GITHUB_TOKEN in the secrets file instead — gh reads it natively with
no login step.
SUBJECT_TIMEZONEmatters. Jira renders timestamps in the requesting account's display timezone, not the subject's. Reading the hour field directly without converting can put the whole activity histogram in the wrong part of the day — in one run it moved the apparent peak by several hours and roughly tripled the reported "night work" percentage versus the subject's actual local time. Set this correctly or the out-of-hours metric will be wrong, possibly in a way that's flattering or unflattering by pure accident of timezone math.
Bump prompt_version in the config to invalidate every cached LLM summary.
The image bakes in no secrets and no data — both come in via mounts.
docker build -t career-archive .docker run --rm -it \
-v ~/career-archive-secrets/dotenv:/secrets/.env:ro \
-v ~/career-archive-data:/data \
career-archive statusdocker run --rm -it \
-v ~/career-archive-secrets/dotenv:/secrets/.env:ro \
-v ~/career-archive-data:/data \
career-archive all~/career-archive-secrets/dotenv is a normal .env-format file — name it
whatever you like on the host, since the mount target /secrets/.env is what
config.py actually reads. ~/career-archive-data becomes the container's
raw/normalized/enriched/summaries/narrative/output root (via
CAREERKIT_DATA_ROOT), so results persist across runs and are visible on the
host immediately.
Any cli.py stage works as the trailing argument: status, fetch, build,
story, all.
LLM provider inside the container. summarize/narrate default to
--provider claude-cli, which shells out to the claude CLI — but claude's
own login is a separate credential (OS keychain on macOS, not a plain file),
so it isn't available inside a fresh container. Two options:
- Pass
--provider anthropic(viacli.py story --provider anthropic) and setANTHROPIC_API_KEYin the secrets file — works headless with no extra setup. - Or run
summarize/narratebare-metal (outside Docker) whereclaudeis already logged in, and use the container only forfetch/build.
The archive contains colleagues' names and account ids alongside internal
project and client detail. output/, raw/, normalized/, enriched/,
summaries/ and narrative/ are all gitignored.
Note that this directory sits inside a company repository. Committing the scripts is fine; committing the data is not, and the narrative is personal material. Decide deliberately before the first commit.