Roll back to 1.4
You will be on Kraft 1.4 again, with the database as 1.4 left it, and kraft --version prints kraft 1.4.0. A rollback needs that database, so take a backup before you upgrade. Step 1 checks that you have one, before it changes anything.
Before you start
- You have the database as 1.4 left it, schema 44: a backup you took before the upgrade (a
kraft-backup-<date>.dbfile in your home folder), or the copy Kraft made when you first left 1.4. That copy is not always the newest: a later upgrade adds a newer one that holds the schema before it, which 1.4 refuses. Step 1 finds the right one, and only reads. - When you upgraded to a 1.5.0 release candidate before 1.5.0rc12, Kraft made no copy, and only a backup you took yourself rolls back.
- You know the Python Kraft runs on, and whether you use vector search. Step 3 installs 1.4 with both.
- The
sqlite3command, for the schema checks in steps 1 and 4. Step 1 has a Python form for a machine without it. - You accept that the restore loses everything done on 2.0 since that backup: items filed, runs, approvals and their history.
- If Kraft runs as a service, you know how to stop a service.
Roll back from 2.0 to 1.4
Step 1 only checks, so if it finds no database you stop there and stay on 2.0.
Steps 2 to 4 put back the release and the database. Steps 5 to 12 undo what 2.0 changed in the config directory, one file at a time: What a rollback does to $KRAFT_HOME lists them in one table. Step 13 starts 1.4. A step you skip, or get wrong, shows up as a symptom: If 1.4 will not start or misbehaves lists them by what you see and names the step that fixes each.
The commands that name your Kraft home set home or run themselves, from $KRAFT_HOME or ~/.kraft (run from $KRAFT_RUN_DIR first, as Kraft does), so each block works in a new terminal.
- Check that you have a database 1.4 can read, before you stop or install
anything. Pick the database to restore: your own backup, or the copy Kraft
made when you first left 1.4. Both must hold schema 44.
To find Kraft's copy, follow Find the copy to restore: it is the newest one that prints 44. For your own backup, setbackupto it; the command prints its schema, and a backup taken after the upgrade does not print 44. Put the path of your backup in the first line, in place of the example:backup="$HOME/kraft-backup-20261002-093015.db" sqlite3 -readonly "$backup" 'PRAGMA user_version'44
Without thesqlite3command, Python prints the same:backup="$HOME/kraft-backup-20261002-093015.db" python3 -c "import sqlite3, sys; print(sqlite3.connect('file:' + sys.argv[1] + '?mode=ro', uri=True).execute('PRAGMA user_version').fetchone()[0])" "$backup"
Write down the path of the file you pick: step 4 uses it.
If no copy prints 44 and you have no backup of your own, stop here. Nothing has changed, and you stay on 2.0. Which releases made no copy is in Before you start. - Stop Kraft with
kraft admin stop.
If Kraft runs as a service, stop it as Stop a service says instead: the service manager starts a stopped server again, and the next steps must not run under a live one.
Go on only whenkraft admin stopprintskraft: stopped (pid N)orkraft: no server running. If it printskraft: pid N did not stop within 15sand exits 1, the server is still running: do not touch the database.kraft: stopped (pid 48213) - Install 1.4 with the Python you run Kraft on and the extra you have. On
Homebrew, which cannot install an older release, skip the command below and
see Pin a version. Leave out
[vector]if you don't use vector search:uv tool install --force --python 3.X "kraft-sdlc[vector]==1.4.0"
If you skipped step 2 and install an older version while the newer server still runs, the web UI says An older Kraft is installed and points here, rather than telling you to restart.
Check the install:kraft --versionkraft 1.4.0
If it prints another version, 1.4 is not what your shell runs. Read the error the install printed, and runcommand -v kraftto see whichkraftcomes first onPATH. Repeat this step before you go on. - Restore the database from step 1. Follow steps 2 and 3 of
Restore a backup, in that
order, with
backupset to the file from step 1. Each block sets its own variables, so they work in one terminal or two. Skip step 1 there, because step 2 here stopped Kraft. Skip its steps 4 and 5 too: steps 5 to 12 here handle the config directory, and step 13 starts Kraft.
Check the restored database:run="${KRAFT_RUN_DIR:-${KRAFT_HOME:-$HOME/.kraft}/run}" sqlite3 -readonly "$run/orchestrator.db" 'PRAGMA user_version'44
Without thesqlite3command, Python prints the same:run="${KRAFT_RUN_DIR:-${KRAFT_HOME:-$HOME/.kraft}/run}" python3 -c "import sqlite3, sys; print(sqlite3.connect('file:' + sys.argv[1] + '?mode=ro', uri=True).execute('PRAGMA user_version').fetchone()[0])" "$run/orchestrator.db"
If it prints another number, the restore put in a copy 1.4 refuses: repeat steps 1 and 4 with a copy that prints 44. If it prints an error, no database is in place: read the message the restore block printed, and run it again. A restore from the wrong copy also makes 1.4 refuse to start (database schema vN is newer than code vMinrun/logs/server.log). - Check the config directory. 1.4 reads
templatesunder$KRAFT_HOME, which is~/.kraftunless you set it. 2.0's first start renamed it toconfigand lefttemplatesas a link to it, which 1.4 follows. Run:home="${KRAFT_HOME:-$HOME/.kraft}" ls -ld "$home/templates"- If it prints a link (
templates -> config), there is nothing to do: go to step 6. - If it prints
No such file or directory, the link is gone: rename the directory back. When 1.4 finds notemplates/, it seeds a fresh default home beside yours, with no repos, a default policy and noaccess.yaml. The block moves nothing iftemplatesexists, becausemvwould putconfig/inside it:home="${KRAFT_HOME:-$HOME/.kraft}" if [ -e "$home/templates" ] || [ -L "$home/templates" ]; then echo "$home/templates exists: nothing moved" else mv "$home/config" "$home/templates" fi - If it prints a directory (
drwx..., no arrow), not a link, 2.0 found aconfig/you had made and could not merge the two (kraft admin doctor'sconfigrow named both). Move whattemplates/still holds intoconfig/, keeping the copy you want of each. Then settemplates/aside under a dated name and put a link in its place. The block moves nothing unlesstemplatesis a real folder, not a link, andconfigis a folder:home="${KRAFT_HOME:-$HOME/.kraft}" old="$home/templates.old-$(date +%Y%m%d-%H%M%S)" if [ -L "$home/templates" ] || [ ! -d "$home/templates" ]; then echo "$home/templates is not a real folder: nothing moved" elif [ ! -d "$home/config" ]; then echo "$home/config is not a folder: nothing moved" elif [ -e "$old" ]; then echo "$old is already there: run this again" else mv "$home/templates" "$old" && ln -s config "$home/templates" && ls -ld "$home/templates" fi
The last command prints the new link,templates -> config. Nothing is deleted:templates.old-<time>holds whatever you did not move intoconfig/. Delete that folder once 1.4 runs and Check it worked passes, and not before. - If you pointed
KRAFT_CONFIG_DIRat another directory, 2.0 never renamed it. Point 1.4'sKRAFT_TEMPLATES_DIRat it instead.
- If it prints a link (
- Write each schedule's
cronfield the way 1.4 reads it, in theschedules:list ofintake.yaml, before you move it. 1.4 reads only*or a comma list of numbers. Write a range or a step as that list (1-5as1,2,3,4,5,*/15as0,15,30,45) and a day of week7as0. If you skip this, see If 1.4 will not start or misbehaves. - Move the schedules to
policy.yaml. 1.4 reads them astriggers:inpolicy.yaml, not asschedules:inintake.yaml. The entries have the same shape, so cut the list fromintake.yamland paste it under atriggers:key inpolicy.yaml.
Add the key yourself: 2.0 removed it once every trigger had moved, and the# triggers:in a seeded file is a commented example, so pasting under that line breaks the file. If some triggers stayed inpolicy.yaml, paste under the key that is there. If you skip this, see If 1.4 will not start or misbehaves. - Delete the
schedules:key itself fromintake.yaml, even an emptyschedules: [], because 1.4 refuses anintake.yamlthat has one. If you skip this, auto-intake stays off: see If 1.4 will not start or misbehaves. - Delete
forge_poll_sfrompolicy.yaml, if you set it, because 1.4 rejects the file with it.max_concurrentinpolicy.yamlneeds nothing: 1.4 reads it there. If you skip this, see If 1.4 will not start or misbehaves. - Rename the chain key in
repos.yaml. 1.4 reads a repo's chain only asdefault_chain_templateand skipsdefault_chain. Rename eachdefault_chain:todefault_chain_template:.
Where an entry has both keys (2.0 can save the new one beside the old), putdefault_chain's value, the one 2.0 used, indefault_chain_template, then delete thedefault_chainline, so no entry ends with both. If you skip this, see If 1.4 will not start or misbehaves. - Rewrite the agent profiles in
harnesses.yaml. An agent profile saved on 2.0's Settings › Harnesses is written asproviders:, which 1.4 refuses, and with it the whole file. Rewrite each such agent profile in 1.4's shape, onemodel:map by provider and oneeffort:for all of them:# 2.0 strong: providers: claude: { model: sonnet, effort: high } codex: { model: gpt-5.6-terra, effort: high } # 1.4 strong: model: { claude: sonnet, codex: gpt-5.6-terra } effort: high
1.4 has oneeffortper agent profile: where the providers set different ones, pick one. An agent profile already in themodel:shape needs nothing. If you skip this, see If 1.4 will not start or misbehaves. - Restore the theme. 1.4's interface takes its colors from
palette, which 2.0 removed, so without the copy it shows its default colors. Copy the file 2.0 kept overtheme.yaml. If a 1.5.0 release candidate converted the theme first, use the candidate's file, named in Your theme, in place oftheme.yaml.pre-2.0.
The copy is your theme from the first upgrade past 1.4, and is never replaced. The block first moves the currenttheme.yamlaside astheme.yaml.before-rollback-<time>, so a theme you changed on 1.4 earlier is kept there, and copies over only when the source file exists. Put the candidate's file incopyif one converted the theme. Delete the dated file once 1.4 shows the theme you want, and not before:home="${KRAFT_HOME:-$HOME/.kraft}" copy="$home/templates/theme.yaml.pre-2.0" saved="$home/templates/theme.yaml.before-rollback-$(date +%Y%m%d-%H%M%S)" if [ ! -f "$copy" ]; then echo "$copy is not a file: nothing changed. Set copy to the right file, or edit theme.yaml by hand" elif [ -e "$saved" ]; then echo "$saved is already there: run this again" elif [ -e "$home/templates/theme.yaml" ] && ! mv "$home/templates/theme.yaml" "$saved"; then echo "could not move theme.yaml aside: nothing changed" else cp "$copy" "$home/templates/theme.yaml" && ls -l "$home/templates/theme.yaml" || echo "the copy failed: the theme you had is at $saved, if there was one" fi
With no copy, opentheme.yamland look forgroup_by: chain, which 2.0 writes when the board is grouped by chain, and change it togroup_by: template. Also look for aneditor:line, which 2.0 writes when you pick a Default editor in Settings › Appearance, and delete it. Or delete the file. 1.4 refuses both, and its board then loads no theme: see If 1.4 will not start or misbehaves. - Start Kraft with
kraft admin start --detach, or withkraft admin install-serviceif it ran as a service. If 1.4 does not start, see If 1.4 will not start or misbehaves.
Check it worked
Run:
kraft --version
kraft 1.4.0
Then run kraft admin templates lint and kraft admin doctor.
You know it worked when --version prints kraft 1.4.0, the board loads,
templates lint exits 0, and doctor shows no failing templates row (1.4's
name for the config directory row) or keys row.
If one of them fails, or the board misbehaves, find what you see in If 1.4 will not start or misbehaves.
If 1.4 will not start or misbehaves
| What you see | Why | What to do |
|---|---|---|
database schema vN is newer than code vM in run/logs/server.log | The database is not the one 1.4 left: a newer Kraft migrated it. | Restore a copy that prints 44: steps 1 and 4. |
Every new item is refused. For forge_poll_s, the log says policy config invalid, refusing work | policy.yaml has what 1.4 rejects: forge_poll_s, a cron range or step, or a triggers: pasted under the commented # triggers: line. | Steps 6, 7 and 9. |
Auto-intake stays off while kraft admin health says ok | intake.yaml still has a schedules: key. | Step 8. |
A cron with day of week 7 never fires | 1.4 loads 7 and never fires it. | Write it as 0: step 6. |
Doctor's keys row fails, and the repo files on default | repos.yaml has default_chain, which 1.4 skips. | Step 10. |
harnesses.yaml is refused as a whole | An agent profile is written as providers:. | Step 11. |
| The board shows default colors, or loads no theme | theme.yaml has surface, accent and colour_amount, group_by: chain, or an editor: line. | Step 12. |
| A fresh default home appears beside yours, with no repos | 1.4 found no templates/. | Step 5. |
What the restore leaves behind
Restoring the backup loses everything done on 2.0 since you took it: items filed,
runs, approvals and their history. It does not remove what those runs made
outside the database. Their worktrees under run/worktrees/, their branches, and
any merge requests they opened are still there, and 1.4 knows nothing about them.
kraft admin doctor'sworktreesrow lists a worktree with no work item. Delete the ones you do not need, and close any merge request yourself. 1.4's row also lists the worktrees of items you cancelled on 1.4, which its board does not show.- A worktree made on 2.0 is one whose id is not in the database you restored.
This lists the ids that are.
-readonlymakes a wrong path an error, not an empty database where Kraft is about to start:run="${KRAFT_RUN_DIR:-${KRAFT_HOME:-$HOME/.kraft}/run}" sqlite3 -readonly "$run/orchestrator.db" "select id from work_items"
Without thesqlite3command:run="${KRAFT_RUN_DIR:-${KRAFT_HOME:-$HOME/.kraft}/run}" python3 -c "import sqlite3, sys; [print(r[0]) for r in sqlite3.connect('file:' + sys.argv[1] + '?mode=ro', uri=True).execute('select id from work_items')]" "$run/orchestrator.db"
What a rollback does to $KRAFT_HOME
Starting a newer Kraft migrates run/orchestrator.db forward in place, and
there is no downgrade migration. An older Kraft refuses to start on a database a
newer one migrated (database schema vN is newer than code vM, in
run/logs/server.log).
So back up run/orchestrator.db
before you upgrade, and restore that backup when you roll back. If you have
none, restore the copy Kraft made before migrating: see
Find the copy to restore.
run/index.db needs nothing: an older Kraft rebuilds a search index it cannot
read.
A rollback does not undo what a newer version changed in the config directory.
What 2.0 moved, you move back by hand, and kraft admin templates lint after a
rollback checks the older version still accepts the chains. The steps above do it
file by file:
In config/ | What 2.0 did | What 1.4 needs | Step |
|---|---|---|---|
| The directory | Renamed templates/ to config/, left a templates link | templates/ | 5 |
intake.yaml and policy.yaml | Schedules moved to schedules: in intake.yaml; forge_poll_s may be set in policy.yaml | triggers: in policy.yaml, cron as numbers, no schedules: key, no forge_poll_s | 6 to 9 |
repos.yaml | default_chain | default_chain_template | 10 |
harnesses.yaml | Agent profiles written as providers: | A model: map and one effort: | 11 |
theme.yaml | surface, accent and colour_amount; group_by: chain when the board is grouped by chain; an editor: line when you pick a Default editor | palette, group_by: template and no editor: line | 12 |
Next steps
- Back up and restore the database: put the backup back, and find the copy Kraft made
- Run Kraft as a service: stop and start a service around the rollback
- Upgrade Kraft: pin a version, or upgrade again when you are ready
Upgrade from 0.x or a release candidate
Upgrade to 2.0 from a 1.5.0 release candidate, from an install made before the kraft-sdlc rename, or from a Kraft 0.x home that holds the old template configuration.
Kraft Lite
The Kraft Lite plugin, which runs a chain inside one agent session with no service, and its slash commands.