Skip to content

Contributing

Getting set up

just setup      # uv sync + npm install
just dev        # backend + vite, state in .dev/, agents faked, UI on :5173

just dev puts fixtures/bin on PATH ahead of the real agent, where claude is a symlink to fixtures/fake-claude.sh — the same fake the test suite uses, so it cannot rot. A dev instance never spends tokens and never touches ~/.kraft.

Run just for the full list of recipes.

Tests

just test       # backend tests affected by your change (testmon)
just test-ui    # frontend unit tests
just e2e        # Playwright
just lint       # ruff check + format check
just fix        # autofix

Do not call pytest directly. just test goes through testmon's change-tracking; a raw invocation skips it and runs the full ~14 minute suite. A Claude Code hook blocks it for agent sessions.

Requirements: Python 3.14+, uv, Node 20+, git. claude is only needed for real agent runs, not for just dev or the tests.

Releasing

Kraft's version is the git tag. setuptools-scm derives it at build time, so there is no version = line to bump and no bump commit to forget.

That removes the loud failure and leaves a quiet one: if nobody ever makes a tag, nothing fails — main just accumulates untagged commits while the install instructions keep serving a release from months ago. So every pull request declares what it ships, and merging is what tags.

Put exactly one of these labels on your pull request:

Label Means
release::major a breaking change to the CLI, the API, or on-disk state
release::minor a new capability that does not break an existing one
release::patch a fix to something that already shipped
release::none nothing a user of Kraft receives

release::none is a first-class answer, and the expected one for documentation, comments, CI configuration and test-only changes. It is not an escape hatch — it is the declaration that this change ships nothing.

Nothing checks that the declared impact matches the diff. The label is a claim by its author; review is what tests it.

Pull requests from forks are not asked for a label — only people with write access can apply one. A maintainer labels the pull request before merging. An unlabelled merge reads as release::none and ships nothing.

What happens on merge

.github/workflows/release.yml reads the merged pull request's label, computes the next tag, builds the wheel, smoke-tests it, then pushes the tag, creates the GitHub Release with the wheel attached, and publishes to PyPI. The tag is created locally before the build (setuptools-scm reads the version from it) and pushed only after the smoke test passes, so a failed build leaves nothing behind.

Plugin manifest versions

plugins/kraft/.claude-plugin/plugin.json and plugins/kraft-lite/.claude-plugin/plugin.json carry their own version field, shown in /plugin list. You never edit this by hand and pull requests never touch it: release.yml stamps both files with dev/stamp_plugin_versions.py right after it tags a release, then opens and auto-merges a release::none pull request with the result. Doing this on a release, rather than asking every in-flight pull request to predict its own future version, is what a hand-stamped file could never do without conflicting with every other open pull request the moment a release lands.

Opening that pull request needs its own credential: the default GITHUB_TOKEN can't be used, because GitHub suppresses further workflow runs triggered by GITHUB_TOKEN, which would leave the PR's required status checks pending forever. release.yml mints a short-lived token from a GitHub App installed on this repo instead (RELEASE_BOT_APP_ID / RELEASE_BOT_PRIVATE_KEY), scoped to just contents and pull-request writes on kraft.

Branch hygiene

Two ways a branch you're reusing by hand can quietly cost you work:

Don't reuse a branch after its pull request merges. This repo merges by squash, so the branch's own commits never become ancestors of main — only their squashed equivalent does. Push more commits onto that same branch afterward and git diff against its old merge-base re-presents everything the squash already landed. Cut a fresh branch from origin/main instead.

Rebuilding a branch by git reset + cherry-pick can silently orphan a commit. Resetting a branch to an earlier point and re-picking commits drops anything you forgot to include — no warning, and the commit survives only as a dangling object until the next git gc. Before you reset, capture the tip:

old=$(git rev-parse mybranch)
git reset --hard <earlier-point>
git cherry-pick <sha1> <sha2> ...
git log --oneline "$old" --not mybranch   # non-empty means something is missing

User-facing docs

docsite/ is the published documentation site (mkdocs build, deployed to GitHub Pages by .github/workflows/docs.yml on every push to main) — not to be confused with docs/intent/ below, which nobody but a contributor reads. If your change touches any of these, update the matching page in the same pull request, not as a follow-up:

Source Docs page
A kraft subcommand or flag (src/kraft/cli/*.py) docsite/cli.md
A registry.yaml / policy.yaml / repos.yaml / access.yaml / intake.yaml field (src/kraft/config.py, policy.py) docsite/configuration.md
A chain template's node fields, or a new default chain docsite/concepts.md
A harness (src/kraft/harnesses/*.yaml, harness.py) docsite/harnesses.md
An MCP tool (src/kraft/mcp.py) docsite/agent-integration.md
A plugin skill (plugins/kraft/skills/*, plugins/kraft-lite/skills/*), or either plugin.json docsite/agent-integration.md, and that plugin's own README.md
access.yaml / remote-access behaviour docsite/remote-access.md, and SECURITY.md if it's security-relevant

Run uv run --group docs mkdocs build --strict before you push — it fails on a broken internal link or anchor, though not on a page that's merely gone stale prose-wise. A stale-but-still-linking page is exactly the kind of gap docs/intent/'s enforced-by: pinning doesn't catch either; there is no automated backstop for "this paragraph no longer describes the code," only for "this file/anchor no longer exists." Read the page you're touching, not just the code.

Design documents

Specs and implementation plans are not committed. .engineering/ and docs/superpowers/ are gitignored. design/ and docs/consolidated/ are gitignored too and live only in the maintainer's private archive — if you see them still tracked in the tree, they haven't been scrubbed from history yet. docs/intent/ is the exception: it states intended behaviour as pinned requirements and is maintained with the code.