Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

pinto is a lightweight, local-first Scrum backlog and Kanban board for the terminal. It keeps Product Backlog Items (PBIs), Sprints, and workflow state in plain text so that the board remains easy to inspect, version, and recover with Git.

The project deliberately has a small vocabulary:

  • Product Backlog — the ordered list of work.
  • Sprint — a time-boxed selection of PBIs with a goal.
  • Kanban workflow — the columns that describe a PBI’s current state.

pinto does not require a server, account, or database service. A new board can be initialized in the directory where the work is kept, and the same CLI can manage it from the first item through completion.

This book is the task-oriented guide for users and contributors. Detailed design decisions, JSON contracts, and migration notes remain in the repository reference documents:

Installation

Using a released package

Install the latest published 0.4.3 binary with Cargo:

cargo install pinto-cli --version 0.4.3
pinto --version

The crates.io package is named pinto-cli; it installs the pinto binary.

Rust 1.89 or newer is required to build the current project. If a release is not yet available on crates.io, install from a checkout instead.

Installing from source

Clone the repository and install the binary from its workspace:

git clone https://github.com/moriturus/pinto
cd pinto
cargo install --path . --locked
pinto --version

The source install uses the committed Cargo.lock; --locked makes dependency resolution fail instead of silently changing that lockfile.

Contributor setup

Contributors use mise to install the Rust toolchain, mdBook, and the project quality tools:

mise install
mise run check

The check task runs the Rust tests, lint, Rust API documentation, the mdBook build, and formatting checks. To preview this book while editing it, run:

mdbook serve

The generated site is written to target/book/, which is a build artifact and is not part of the source tree.

Quick start

The following workflow creates a board, adds a PBI, inspects it, and moves it through the default workflow. Run it from the directory that should own the board.

1. Initialize a board

pinto init

Initialization is idempotent. It creates the local .pinto/ board and its default workflow when they do not exist.

2. Add a PBI

pinto add "Implement the Markdown parser" --points 3 --label backend

The command prints the newly assigned ID, for example T-1. Keep that ID for the following commands.

3. Inspect the backlog

pinto list
pinto show T-1

Use --long for status, points, assignee, and timestamps. Add --acceptance-criteria when you want the Markdown checklist progress, or use --json when a script needs machine-readable output:

pinto list --status todo --long
pinto list --status todo --long --acceptance-criteria
pinto show T-1 --json

4. Move the work

The last operand to move is the destination column:

pinto move T-1 in-progress
pinto move T-1 review
pinto move T-1 done

The configured workflow determines which columns are valid. The default columns are todo, in-progress, review, and done.

5. View the board

pinto board

For an interactive view, use pinto kanban. The Kanban view uses the same board and services as the non-interactive commands, so changes remain visible to both interfaces.

CLI reference

Run pinto --help or pinto <command> --help for the complete, versioned option list. The commands below cover the normal Scrum workflow.

Selecting a board

Board commands search the current directory first and then its ancestors for .pinto/config.toml, so they can run from a repository subdirectory. The search stops after checking a directory that contains .git (the documented repository boundary) or at the filesystem root. From the board root, behavior is unchanged.

Use --dir PATH for scripts and agents when the board is not the nearest one; PATH may name either the project directory or its .pinto directory. PINTO_DIR provides the same override when the flag is omitted:

pinto --dir /work/project list --json
PINTO_DIR=/work/project pinto list --json

If no board is found, pinto reports the search and these override options. The init command still initializes the current directory unless an explicit --dir or PINTO_DIR target is supplied.

External commands

Pinto’s built-in commands are provided by the single pinto binary. An unknown command is delegated Git-style to an executable named pinto-<segment> using the external command contract described in docs/plugin-contract.md: the directory beside the running binary wins over PATH, empty PATH entries never mean the current directory, arguments are forwarded as argv, and PINTO_DIR plus the host and contract versions are provided to the child process. A built-in command name is always resolved by pinto itself and is never shadowed by a same-name executable on PATH.

Board and PBI commands

Use pinto doctor to check board integrity after hand edits, interrupted migrations, or copied records. Add --fix to apply only safe mechanical repairs. The command reports references, relationship cycles, duplicate IDs, issued-ID history, workflow states, rank anomalies, tasks/archive filename collisions, Sprints that break their domain invariants (a blank title, a one-sided or inverted period, an active Sprint without a Goal, or a Goal outcome recorded against a blank Goal), and action PBIs whose Retro/Review source points at a Sprint or record that no longer exists (active and archived alike), each with a location and repair direction. The Sprint checks inspect the raw stored values — the same states import rejects — so a hand-edited Goal outcome paired with a blank Goal is reported rather than silently cleared. On the SQLite backend, the typed row mapper applies the same blank-Goal outcome normalization as File and Git for normal reads, while the doctor scan uses a raw Sprint-row reader so the corruption remains reportable. The typed mapper rejects structural corruption (a blank title or a one-sided or inverted period) as a “corrupt SQLite data” read error before the scan, so those states surface as a non-zero read error there instead of a per-record finding; File and Git report them as records. Both modes inspect the board once up front; --fix re-inspects only after it applied a repair.

CommandPurpose
pinto initInitialize a board in the current directory.
pinto add <title>Add a PBI; use --label <label>... to set one or more labels, or optionally set points, Sprint, body, or a template.
pinto split <id> <title>...Split a PBI into new PBIs; optionally make the source their parent or dependency and choose the body.
pinto listList active PBIs, with status, assignee, label, Sprint, search, stale-duration, root-only, long, and JSON filters. Use --archived to select archived PBIs.
pinto nextShow ranked unstarted PBIs whose dependencies are complete.
pinto show <id>...Display one or more active PBI details. Use --archived to display archived details.
pinto restore <id>Restore an archived PBI to the active task store without changing its ID or content.
pinto move <id>... <status>Transition one or more PBIs to a workflow column.
pinto reorder <id>Reorder a PBI within its sibling group (same parent and column).
pinto edit <id>Update PBI fields; --label <label>... replaces its labels. With no field, open the configured editor.
pinto remove <id>...Archive PBIs; use the rm alias and --force only for permanent removal.
pinto boardRender PBIs grouped by workflow column, optionally filtering by assignee or showing root PBIs only.
pinto export --jsonExport the complete active board, configuration, and shared DoD as one consistent JSON snapshot; it waits for writers.
pinto doctorCheck board integrity; use --fix for safe mechanical repairs.
pinto kanbanOpen the interactive Kanban board.

Examples:

pinto add "Implement the parser" --label backend cli
pinto list --status todo in-progress --long
pinto list --status todo --long --acceptance-criteria
pinto list --label backend frontend --all-labels
pinto list --assignee alice --json
pinto list --search "parser"
pinto list --stale 7d --status todo --json
pinto list --archived --json
pinto list --roots-only --status todo --json
pinto next
pinto next --count 3 --sprint S-1 --json
pinto board --status in-progress review
pinto board --assignee alice --json
pinto board --roots-only --status todo --long
pinto export --json
pinto reorder T-1 --top
pinto edit T-1 --title "Implement the Markdown parser" --label backend cli
pinto show T-1 --archived
pinto restore T-1
pinto split T-1 "Cart page" "Payment step" --child
pinto split T-1 "Payment spike" --dependency --body "Evaluate providers."

Split a PBI

pinto split <source> <title>... derives one new PBI per title from an existing PBI. The source item is kept; each new PBI is appended to the backlog in the first workflow column.

Choose at most one relationship between the source and the new PBIs:

  • --child makes the source the parent of each new PBI.
  • --dependency makes the source depend on each new PBI (the new work must be completed first).

Choose at most one body; the default copies the source body:

  • --body <text> uses the supplied text.
  • --template <name> uses .pinto/templates/item/<name>.md.
  • --empty starts each new PBI with an empty body.

The same operation is available inside the Kanban board with the s key.

Multi-record recovery

split and import --force are single operation-level mutations. Pinto prepares the complete record set before writing it and keeps a pre-operation recovery point. If a record or metadata write fails, File, Git, and SQLite restore the board to the state that existed before the command and report that the operation can be retried.

SQLite applies the PBI, relationship, and Sprint portion of each operation in one database transaction. The shared configuration, DoD, and issued-ID history are covered by the surrounding recovery protocol because they are stored outside the database.

The Git backend has one additional boundary: if the final Git commit fails after the board files were written, Pinto leaves the complete change in the worktree so it is recoverable. Run git status, fix the reported hook or Git problem, then retry the command or commit the durable .pinto changes manually. Do not discard the worktree before inspecting it.

If automatic restoration itself fails, the error retains the pre-operation snapshot in a temporary directory and prints its path. Stop other writers, preserve .pinto/.lock, restore the retained snapshot into .pinto/, inspect the board, and retry only after the board is coherent again.

# A record-write failure reports that the board was restored; retry the command.
cargo run --manifest-path ../../../Cargo.toml -- split T-1 "Retry the slice"

# After a Git commit failure, inspect and repair the durable board change.
git status --short
cargo run --manifest-path ../../../Cargo.toml -- import --force snapshot.json

Consistent board reads

list, show, board, next, and the other ordinary read commands do not take the board-wide write lock. This keeps them non-blocking, but they do not provide snapshot isolation when a write operation is running; separate resources read by one command may come from different versions of the board.

For shell scripts, agents, and other automation that must correlate PBIs, Sprints, configuration, and the shared Definition of Done, use pinto export --json. Export waits for a writer, acquires the board lock before opening configuration and storage, and holds it while assembling one complete snapshot.

For add and edit, multiple label values may follow one --label; repeating the option once per value remains equivalent. The list and board forms are label filters and keep their documented OR/AND behavior.

Display order

Priority is hierarchical. Every view — list, board, kanban, and their --json output — flattens the same parent/child forest in one canonical order:

  1. Top-level PBIs come first, in ascending rank (with a (prefix, number) ID tie-break so equal ranks never reorder between views).
  2. Each parent is immediately followed by its whole subtree; a parent’s children are ordered among themselves by rank.

So rank orders siblings, and the tree decides the overall priority: a child never floats above an unrelated, higher-priority PBI just because its raw rank string happens to be lower. Deprioritise a parent and its entire subtree moves with it.

  • pinto list flattens the whole forest. A filtered-out or absent parent promotes its children to the top level, so the tree is cut cleanly at the filter boundary.
  • pinto board and pinto kanban build the same forest per column. A child whose parent lives in another column is shown at the top level of its own column (positioned by its own rank).
  • The completion column (done_column in config.toml) orders its top-level and sibling groups by completion time (done_at) descending by default, so the most recently finished PBI leads; the subtree grouping still applies.
  • pinto board --sort rank | done | created sets the root/sibling order explicitly (add --reverse to invert it); the hierarchy is always preserved. pinto kanban uses the defaults and has no sort toggle.

Because rank is sibling-local, pinto show and the Kanban details popup print it as a sibling ordinal: #2 under <parent-id> for a child (2nd among that parent’s children) or #2 for a top-level PBI.

Root-only views

Use --roots-only with list or board to show only PBIs whose persisted parent field is unset. Child PBIs are omitted, while root PBIs with or without children remain visible. Without the option, the existing hierarchical output is unchanged.

The option composes with compatible filters and output modes, for example:

pinto list --roots-only --status todo --label backend --search parser --json
pinto board --roots-only --status todo --sort rank --reverse --long

The check uses the stored parent link, not just the current result set. Thus a child is still omitted when its parent is hidden by a status, Sprint, label, or search filter.

The parent-child demo contains a reproducible hierarchy for trying these commands.

Assignee filters

Use --assignee <name> (or -u <name>) with list or board to keep only PBIs whose persisted assignee exactly matches the requested name. The filter composes with status, Sprint, label, and search filters, and applies before hierarchical ordering or board-column grouping. It also works with --json; omitting it leaves the existing result set and order unchanged.

The status-filter demo includes assigned PBIs across multiple workflow columns.

Stale PBIs

pinto list --stale <duration> matches PBIs whose updated timestamp is at or before the query time minus the supplied duration. Use a positive integer with a single unit: s for seconds, m for minutes, h for hours, d for days, or w for weeks. For example, 7d finds PBIs unchanged for at least seven days. The filter composes with the other list filters and with long or JSON output, and it performs no writes.

The stale-filter demo contains a small board for trying the command.

Archived PBIs

pinto rm archives a PBI in .pinto/archive/ by default. Archived records are excluded from normal list, board, and show views. Select them explicitly when reviewing recovery candidates:

pinto list --archived
pinto show T-1 --archived
pinto restore T-1

Restore preserves the archived Markdown, ID, rank, and relationships. It checks the active task store first and refuses an ID collision without overwriting either record.

Actionable candidates

Use pinto next to find work that can start immediately. An item is unstarted when it is in the first configured workflow column, and it is actionable when every declared dependency exists and is in done_column. Items already in progress, in review, or in the completion column are not returned; a missing or unfinished dependency keeps an item blocked.

The command is read-only and follows the canonical backlog order. --count (or -n) limits the number of candidates and defaults to 1; --sprint (or -S) restricts the exact Sprint ID; --json emits the same PBI object array used by list --json:

pinto next
pinto next --count 3
pinto next --sprint S-1 --json

The next demo contains blocked, ready, completed, and already-started examples.

Acceptance Criteria progress

Pinto derives a completed/total value from Markdown task-list checkboxes in the PBI body. The value appears in pinto show and the Kanban details popup. Add --acceptance-criteria (or -A) to list --long or board --long to include it as a column. No progress field is persisted and the body is not rewritten.

When a move enters the configured done_column, an item with unchecked task-list boxes produces a warning on stderr but the transition remains successful. An item with no task-list boxes does not produce this warning. See the Acceptance Criteria demo for a runnable example.

A move keeps the item’s rank, so its relative position travels with it into the new column. The one exception is a rank that already exists in the destination column: to keep ranks unique within a column, the item is re-pegged to the column’s tail instead.

pinto reorder (and Kanban K / J) moves a PBI only within its sibling group--top / --bottom go to the front/back of that group, and --before / --after take a sibling as reference. Reordering relative to a non-sibling is refused; move a PBI between groups with edit --parent. Moving a parent carries its whole subtree.

Relations and Sprints

Use dependency commands to record ordering constraints between PBIs:

pinto dep add T-2 T-1
pinto dep rm T-2 T-1

Git commit links are managed separately:

pinto link add T-1 abc1234
pinto link sync

The Sprint commands create and manage time-boxed work:

pinto sprint new S-1 "Sprint 1" --goal "Ship the parser" --start 2026-07-01 --end 2026-07-14
pinto sprint edit S-1 --goal "Ship the parser" --start 2026-07-01 --end 2026-07-14
pinto sprint edit S-1 --goal-achieved true     # record the retrospective outcome
pinto sprint edit S-1 --goal-achieved false    # update it when the assessment changes
pinto sprint edit S-1 --clear-goal-achieved    # return to unevaluated
pinto sprint start S-1
pinto sprint add S-1 T-1
pinto sprint add S-1 --status todo --limit 3
pinto sprint add S-1 --status todo             # omit --limit to assign all matches
pinto sprint list
pinto sprint retro new S-1 --body "What went well\nWhat to improve"
pinto sprint retro action S-1 "Make deployment checks explicit" --points 3 --label follow-up
pinto sprint retro show S-1 --json
pinto sprint retro edit S-1 --body "Updated retrospective notes"
pinto sprint retro list --json
pinto sprint review new S-1 --body "What was demonstrated\nWhat remains"
pinto sprint review action S-1 "Document the release" --template follow-up --assignee alice
pinto sprint review show S-1 --json
pinto sprint review edit S-1 --body "Updated review notes"
pinto sprint review list --json
pinto sprint close S-1 --rollover S-2          # move unfinished PBIs to S-2
# pinto sprint close S-1 --release             # alternative: clear their Sprint assignment
pinto sprint remove S-1                         # refuses if Retro/Review records exist
pinto sprint rm S-1 --delete-records             # explicitly delete matching records too

Reports include pinto sprint burndown, pinto sprint velocity, pinto sprint capacity, pinto sprint goal, and pinto cycletime.

pinto sprint goal reports the explicit boolean outcome for the most recent five Sprints and calculates achieved evaluated / all evaluated as a percentage. A Sprint with no recorded outcome is shown as unevaluated and is excluded from the denominator; writes clear an outcome whenever the Goal is blank. Use --recent N to select a different number of Sprints and --json for the machine-readable fields goal_achieved, evaluated_sprints, achieved_sprints, and achievement_rate. The rate is null (human output: n/a) when no Sprint Goal has been evaluated.

Removing a Sprint releases its assigned PBIs and clears the source link of any action PBI that was promoted from the removed Sprint’s Retro or Review, so no PBI keeps a reference to a Sprint or record that no longer exists. If a matching Sprint Retro or Review exists, pinto sprint remove and its rm alias refuse before mutation; pass --delete-records to explicitly delete those matching records in the same operation. Unrelated records remain.

After a successful pinto sprint start or pinto sprint add, pinto prints a non-blocking warning to stderr when the Sprint’s estimated assigned points exceed either its configured capacity-hours value or the average completed points from its five most recent closed predecessor Sprints. Unestimated PBIs do not contribute to the point total, equality is within the threshold, and no warning is emitted when the corresponding comparison is unavailable.

Use pinto sprint edit to add a goal or change a planned period before starting a Sprint. Removing a Sprint releases its assigned PBIs without deleting them. Assign new PBIs only to planned or active Sprints; use pinto sprint unassign to correct an assignment that remains after a Sprint closes. Close changes only unfinished PBIs. --rollover and --release are mutually exclusive, while omitting both retains assignments. Completed PBIs remain untouched.

pinto sprint retro manages at most one Markdown Retro per Sprint. The record is stored as .pinto/retro/<SPRINT-ID>.md, independent of the Sprint state, so it can be created for a planned, active, or closed Sprint. Use pinto sprint retro new <SPRINT-ID> --template <NAME> to load .pinto/templates/retro/<NAME>.md; adding --edit opens the standard editor with that template as the initial body. The direct creation form pinto sprint retro <SPRINT-ID> is also accepted. The default show view adds a generated Sprint Context section containing the parent goal, state, schedule, and available delivery reports; --plain shows only the authored Markdown.

pinto sprint review manages at most one Markdown Review per Sprint. The record is stored as .pinto/review/<SPRINT-ID>.md, independent of the parent Sprint state, so it can be created for a planned, active, or closed Sprint. Use pinto sprint review new <SPRINT-ID> --template <NAME> to load .pinto/templates/review/<NAME>.md; adding --edit opens the standard editor with that template as the initial body. The direct creation form pinto sprint review <SPRINT-ID> is also accepted. Review show exposes the same generated parent-Sprint context without adding a Review state of its own; unavailable metrics are shown as unavailable rather than zero.

Use pinto sprint retro action <SPRINT-ID> <TITLE> or pinto sprint review action <SPRINT-ID> <TITLE> to promote one recorded action into an ordinary PBI. The command accepts the normal PBI creation options such as --body, --template, --points, --label, --assignee, --sprint, --parent, and --depends-on. The PBI stores a machine-readable source link with the child-record kind and Sprint ID. Retro and Review detail views show linked active PBIs and their current normal workflow statuses; use list, show, edit, move, and remove on the PBI to manage progress. The source record remains Markdown without a parallel state machine.

Velocity totals, averages, and changes count only PBIs completed by the actual close time. Close-time unfinished points and item counts are displayed separately as spillover and never added to velocity, even if retained work reaches Done later.

Definition of Done

A single Definition of Done is shared by every PBI. Display, set, or clear it:

pinto dod                          # show the current shared DoD
pinto dod set "- [ ] Tests pass and docs updated"
pinto dod clear

The DoD body is stored verbatim, so pass a multi-line checklist with a real newline in the quoted string. Because the text often starts with a hyphen, it is taken as a literal value rather than an option.

Maintenance

These commands keep storage tidy and are not part of the daily loop:

pinto rebalance --dry-run          # preview oversized sibling scopes and shorter ranks
pinto rebalance                    # rewrite only scopes that need it
pinto migrate --to git             # switch the storage backend
pinto import snapshot.json         # restore a board from an export --json snapshot
pinto import --force snapshot.json # replace an existing non-empty board
pinto undo                         # revert the most recent completed mutation (git backend)

pinto import is the inverse of pinto export --json: it rebuilds the active and archived PBIs, Sprints, configuration, and shared DoD from a snapshot (a file, or - for standard input). Importing into a board that already holds active PBIs, archived PBIs, or Sprints is refused unless --force is given, and --force mirrors the snapshot by clearing the archive as well. A snapshot that would produce a board doctor flags — a duplicate Sprint, a Sprint that breaks its domain invariants (a blank title, a one-sided or inverted period, an active Sprint without a Goal, or a Goal outcome without a Goal), a duplicate or orphaned Retro or Review, a duplicate PBI ID across the active and archived collections, a parent, depends_on, sprint, or action source reference missing from the snapshot, a PBI with an empty title or a status outside the configured workflow columns, a parent or depends_on cycle, or a rank reused within an active PBI’s (status, parent) scope — is rejected before any write, so an invalid snapshot can never replace a valid board or be reported as a successful import. The reported item count sums the active and archived PBIs restored. See JSON output for the round-trip contract.

Undoing the last mutation

pinto undo reverts the most recent completed board mutation. It is a guided, one-level recovery for a mistaken move, edit, or rm --force, and it only works on the git backend, where each mutation is recorded as a pinto: <verb> <id> commit:

pinto undo   # git revert HEAD, recorded as a new "Revert ..." commit

Undo creates a new commit that reverses the last one (it never rewrites history), so the undo itself is reviewable with git diff and can be undone in turn. It refuses when the latest commit was not made by pinto — for example a user commit stacked on top of the board — and points at git log -- .pinto so you can revert the right commit by hand.

On the historyless backends (file, sqlite) there is nothing to revert, so pinto undo fails with exit code 1 and explains the recovery options: restore from a backup or version-control checkout, or switch to [storage] backend = "git" to enable undo for future mutations. The rationale and per-backend contract live in Undoing a mutation.

Automation and shell integration

automate accepts a validated JSON plan. Preview a plan before applying any writes, and use JSON output when another tool needs execution results:

pinto automate --schema
pinto automate --plan plan.json --dry-run --json
pinto automate --plan plan.json --json

--schema prints the machine-readable JSON Schema without requiring an initialized board or an execution plan. It describes the required non-empty commands array, rejects unknown top-level fields and recursive or interactive commands, and leaves each command’s full argument grammar to the normal CLI parser. Plans can be supplied inline, from a file, or from standard input. pinto shell starts an interactive command shell, and pinto completion <shell> generates completion scripts for supported shells.

An earlier successful add or split command can expose its created IDs to later commands with a complete item-ID placeholder:

{
  "commands": [
    ["add", "Parent"],
    ["split", "@command[0].created_ids[0]", "Slice A", "Slice B"],
    ["edit", "@command[1].created_ids[0]", "--title", "Renamed slice"]
  ]
}

Both indexes are zero-based. The command index refers to the earlier plan command, and the output index refers to its created_ids array. Placeholders are substituted as argv values, never passed through a shell, and are accepted only in item-ID positions: add parent/dependencies, split source, show, move, reorder, edit ID/parent, remove, restore, dep, link, and sprint add/unassign. Unknown, future, malformed, or out-of-range references fail the dependent command and skip the remaining plan. Dry-run resolves references in the isolated preview board; IDs in a dry-run report are preview values.

To pass a placeholder-looking string literally in an ordinary argument such as --body, prefix the marker with a second @: write @@command[0].created_ids[0]. Pinto removes one @ immediately before executing the command. The escaped form is literal text, while an unescaped placeholder-like string outside an item-ID position remains invalid.

The dry-run snapshot holds the board write lock, so a concurrent writer cannot be mixed into the preview. Use pinto export --json for the same consistency boundary when an automation consumer needs a complete active-board read. It works from both normal repositories and linked worktrees: only .pinto is copied, and a temporary owner-private Git repository is initialized when the source project has Git metadata. The source .git object store is never copied, and the temporary workspace is cleaned up after success or failure.

--json reports producer IDs in created_ids, resolved update targets in updated_ids, and every resolved item-ID argument in resolved_ids. Apply results contain authoritative IDs from the real board; dry-run results are explicitly marked with dry_run: true and must not be used as apply IDs.

Machine-readable output

Read commands support --json:

pinto list --json
pinto show T-1 T-2 --json
pinto board --json
pinto next --json
pinto sprint list --json
pinto export --json

Prefer this format over parsing human-oriented tables. IDs, statuses, ranks, relations, and timestamps keep the same meaning as the regular output; timestamps are RFC 3339 values in UTC.

Cookbook

This chapter collects goal-oriented recipes for everyday pinto work. Every recipe states its prerequisites, the exact command, and how to verify the result. All of them run in a clean temporary directory, so you can replay the whole chapter without touching an existing board:

mkdir -p /tmp/pinto-cookbook && cd /tmp/pinto-cookbook
pinto init

The recipes call the installed pinto binary. Inside the repository you can substitute cargo run -- for pinto, as described in Dogfooding. A ready-made board for the pipeline recipes lives in demos/single/cookbook.

Backlog basics

Seed a small backlog

Prerequisites: an initialized board (pinto init).

pinto add "Design the login form" --points 3 --label ui auth
pinto add "Implement the login API" --points 5 --label api auth
pinto add "Write onboarding docs" --points 2 --label docs
pinto add "Fix the session timeout bug" --points 1 --label bug --label auth
pinto add "Refactor the storage layer" --points 8 --label refactor

One --label accepts all following label values until the next option. The repeated form used by the session-timeout item is equivalent and remains supported.

Verify: each command prints the assigned ID (Created T-1 … through T-5). The remaining recipes assume these five PBIs.

List and inspect

Prerequisites: the seeded backlog above.

pinto list --status todo
pinto show T-1
pinto list --long
pinto list --json

Verify: pinto list prints one line per PBI — ID, status, title, points in parentheses, labels in brackets. --long adds dates and other columns; its noninteractive output has no header row. --json emits machine-readable output for scripts.

Move work through the workflow

Prerequisites: the seeded backlog above.

pinto move T-1 in-progress
pinto move T-2 review
pinto board

Verify: pinto board shows T-1 under in-progress and T-2 under review. The last operand to pinto move is the destination column, exactly like Unix mv.

Sprint recipes

Create a sprint and assign work in bulk

Prerequisites: the seeded backlog with T-3, T-4, and T-5 still in todo.

pinto sprint new S-1 "Sprint 1" --goal "Ship the login flow" \
  --start 2026-07-13 --end 2026-07-27
pinto sprint add S-1 --status todo --limit 2
pinto sprint start S-1

Verify: the bulk assignment picks the two highest-ranked todo PBIs (Assigned T-3 to sprint S-1, Assigned T-4 to sprint S-1). Omit --limit to assign every match, or pass a single ID (pinto sprint add S-1 T-5) instead of --status. pinto sprint list now reports S-1 as active.

The close-out recipe completes one Sprint PBI, rolls the unfinished PBI into the next Sprint while closing, then runs the reports; see Close out and report.

Unix text-stream recipes

pinto prints plain text on purpose, so the standard Unix toolbox composes with it. Two properties make the default pinto list output easy to process:

  • columns are separated by runs of spaces, so tr -s ' ' normalizes a line to single-space-separated fields;
  • the ID is always the first field and the status the second, while the title may contain spaces.

All recipes below stick to POSIX options and behave the same with the GNU and BSD userlands, including the BSD tools shipped with macOS. Portability notes are called out per recipe — for example, in-place editing differs between GNU sed -i and BSD sed -i '', so the recipes always write to standard output instead. They assume the board built in the previous sections.

1. Extract IDs with cut

Prerequisites: the seeded backlog.

pinto list --status todo | cut -d' ' -f1

Verify: only the ID column remains:

T-3
T-4
T-5

The ID never contains a space, so cutting the first space-delimited field is safe even though later columns are padded with multiple spaces.

2. Filter by label with grep

Prerequisites: the seeded backlog.

pinto list | grep -E '\[[^]]*auth'

Verify: only the three PBIs labeled auth are printed. The pattern anchors on the label list in brackets, so a title that merely mentions “auth” does not match. grep -E (extended regular expressions) is POSIX and works with both GNU and BSD grep.

3. Count PBIs per status with sort and uniq

Prerequisites: the seeded backlog.

pinto list | tr -s ' ' | cut -d' ' -f2 | sort | uniq -c

Verify: a frequency table of the status column:

   1 in-progress
   1 review
   3 todo

uniq only merges adjacent lines, so the sort before it is required.

4. Count matches with wc

Prerequisites: the seeded backlog.

pinto list --status todo | wc -l

Verify: prints 3. BSD wc pads the number with leading spaces; pipe through tr -d ' ' if a script needs the bare digits.

5. Take the top of the backlog with head

Prerequisites: the seeded backlog.

pinto list --status todo | head -n 2

Verify: the two highest-ranked todo PBIs, in backlog rank order — the same two that pinto sprint add S-1 --status todo --limit 2 would assign.

6. Take the last records with tail

Prerequisites: the seeded backlog.

pinto list --long | tail -n 2

Verify: only the last two data rows (T-4 and T-5 with the seed data) remain. Because pinto’s noninteractive --long output is data-only, tail -n 2 selects records rather than skipping a header. When an upstream command does emit a header, tail -n +2 (“start at line 2”) is POSIX and portable, unlike the historical tail +2 form.

7. Normalize aligned columns with tr

Prerequisites: the seeded backlog.

pinto list | tr -s ' '

Verify: every run of spaces collapses to a single space:

T-1 in-progress Design the login form (3) [ui, auth]

This is the standard first step before cut, join, or any tool that expects a single-character field delimiter.

8. Render a Markdown checklist with sed

Prerequisites: the seeded backlog.

pinto list --status todo | sed -E 's/^(T-[0-9]+)[[:space:]]+[[:alnum:]-]+[[:space:]]+/- [ ] \1 /'

Verify: a paste-ready checklist for a standup note:

- [ ] T-3 Write onboarding docs  (2)  [docs]
- [ ] T-4 Fix the session timeout bug  (1)  [bug, auth]
- [ ] T-5 Refactor the storage layer  (8)  [refactor]

sed -E is supported by both GNU and BSD sed. Avoid sed -i in shared scripts: GNU accepts sed -i, BSD requires sed -i ''.

9. Collect IDs onto one line with paste

Prerequisites: the seeded backlog.

pinto list --status todo | cut -d' ' -f1 | paste -sd' ' -

Verify: prints T-3 T-4 T-5 on a single line, ready to splice into another command. The trailing - operand is required by BSD paste to read standard input; GNU paste accepts it too, so always write it.

10. Join sprint assignments with statuses using join

Prerequisites: the sprint recipes above (T-3 and T-4 assigned to S-1).

pinto list | tr -s ' ' | cut -d' ' -f1,2 | sort > status.txt
pinto list --sprint S-1 | cut -d' ' -f1 | sort | join - status.txt

Verify: each sprint item paired with its current board status:

T-3 todo
T-4 todo

join needs both inputs sorted on the join field; the - reads the sprint IDs from standard input while status.txt supplies the second column.

11. Feed a pipeline back into pinto

Prerequisites: the seeded backlog, with T-3 and T-4 still in todo.

pinto move $(pinto list --status todo | head -n 2 | cut -d' ' -f1) in-progress

Verify: pinto confirms each transition (Moved T-3 to in-progress, Moved T-4 to in-progress), and pinto list --status in-progress shows the moved PBIs. This composes recipes 1 and 5: the pipeline selects the top of the backlog and the command substitution feeds the IDs back into pinto move.

12. Sum story points with paste and bc

Prerequisites: every listed PBI has story points; bc (POSIX) is installed.

pinto list --sprint S-1 | tr -s ' ' | sed -E 's/.*\(([0-9]+)\).*/\1/' | paste -sd+ - | bc

Verify: prints the total committed points for S-1 (3 with the seed data: 2 + 1). sed isolates the points, paste -sd+ - folds them into an arithmetic expression (2+1), and bc evaluates it.

Close out and report

Prerequisites: the sprint recipes above; recipe 11 already moved T-3 and T-4 to in-progress.

pinto sprint new S-2 "Next Sprint"
pinto move T-3 done
pinto sprint close S-1 --rollover S-2
pinto sprint velocity
pinto sprint burndown S-1
pinto cycletime --sprint S-1

Verify: pinto sprint velocity reports 2 completed points for S-1 and separately reports 1 spillover point in 1 item. T-4 is now assigned to S-2; its point is not included in the velocity average or change. burndown draws a chart over the planned period, and cycletime lists lead and cycle times for completed PBIs. Use --release instead of --rollover S-2 when unfinished work should return to the unassigned backlog.

Kanban (TUI)

pinto kanban opens an interactive board in the terminal. It reads and writes the same .pinto/ board as the non-interactive commands, so a move made in the TUI is immediately visible to pinto list and pinto board, and vice versa.

pinto kanban

Start with a focused view

Startup flags narrow what the board shows without changing stored data:

pinto kanban --column in-progress review   # show only these columns
pinto kanban --maximize --column review     # open maximized on one column
pinto kanban --sprint S-1                    # show cards assigned to one Sprint
pinto kanban --label ui backend             # match either label
pinto kanban --label ui backend --all-labels # require both labels
pinto kanban --search parser                # filter cards by substring
pinto kanban --sprint S-1 --label ui --column in-progress --search '^T-1\d' --regex
                                             # compose all startup filters

Explicit --column values override the [tui] hidden_columns setting for that run. --sprint matches the assigned Sprint ID exactly. --label uses the same OR matching as pinto board; add --all-labels for AND matching. --regex requires --search. All startup filters are read-only and remain active when the TUI reloads after an edit, move, or reorder.

The board separates selecting a card from moving it: lowercase keys move the cursor, uppercase (Shift) keys move the selected item. Defaults are:

ActionKeys
Select column / rowh j k l or arrow keys
Move item across columnsH / L (Shift+Left / Shift+Right)
Reorder item within a columnK / J (Shift+Up / Shift+Down)
Expand or collapse a parentSpace / Enter
Add a PBIa
Edit the selected PBIe
Add / remove a dependencyd / D
Set or clear the parentp
Split the selected PBIs
Open the details popupv
Substring / regex search/ / Ctrl+?
Clear an active filterEsc
Toggle a maximized columnm
Reload the boardr
Help window?
Quitq or Esc
Quit into the shellQ

Press ? inside the board to open the built-in help window, which always lists the bindings that are actually in effect.

Pressing s starts a split of the selected PBI: enter a title, choose the relationship to the source (Enter for none, c for a child, d for a dependency), then choose the body (Enter copies the source, e empties it, t types explicit text, m names an item template). The same operation is available from the command line as pinto split.

Cards follow the same hierarchical display order as pinto list and pinto board: top-level cards by rank, each parent followed by its subtree, with siblings ordered by rank. Expanding a parent reveals its children directly beneath it, so a child may sit ahead of a standalone card that outranks it — that is the point, since the parent’s priority carries its whole subtree. The completion column leads with the most recently finished card (done_at descending).

Customize behavior

The [tui] section of .pinto/config.toml adjusts the shared parts of the interactive board:

[tui]
confirm_quit = true                 # ask before leaving the board
hidden_columns = ["done"]           # hide columns unless --column overrides

Unknown column names in hidden_columns are rejected at load time, so a typo surfaces immediately rather than silently hiding nothing.

Personal keybindings

Keybindings are personal preferences and are stored outside the board. Create $XDG_CONFIG_HOME/pinto/config.toml or, when XDG_CONFIG_HOME is unset, use $HOME/.config/pinto/config.toml on Unix-like systems or %APPDATA%/pinto/config.toml on Windows. Put the existing [tui.key_bindings] table there. Each action takes an array of one or more key expressions, and an action may keep several bindings at once:

[tui.key_bindings]
quit = ["q", "Esc"]                 # keep the defaults
add = ["a", "n"]                    # add a second key for "add"
move_left = ["Shift+Left"]          # replace the default for this action
help = ["?", "F1"]

Only the actions you list are overridden; every other action keeps its default keys. The action names are the snake_case forms shown by the built-in help window (quit, shell, select_left, move_left, reorder_up, add, split, edit, dependency_add, parent, maximize, search, regex_search, details, help, and so on).

A key expression is a key name, optionally prefixed with +-separated modifiers:

  • Printable keys are the character itself: q, /, ?. Use an uppercase letter (H) rather than Shift+h for shifted letters.
  • Named keys: Enter, Esc, Tab, Backspace, Delete, Insert, Home, End, PageUp, PageDown, the arrows Left / Right / Up / Down, and function keys F1F12.
  • Modifiers: Ctrl, Alt, Shift, Cmd, Meta, and Hyper — for example Ctrl+a or Alt+Shift+Left. Write the literal plus key as Plus.

Terminal protocols do not represent every modified key identically. The matcher accepts crossterm’s control-byte forms, including lowercase control letters, Ctrl+? as Backspace, ? with Control, or Ctrl+7 (the Ghostty/Zellij Ctrl+Shift+/ encoding), and the other ASCII control punctuation aliases. Non-Control modifier bits are preserved. A legacy terminal may make Ctrl+? indistinguishable from Backspace, but the plain ? help binding remains separate.

Invalid expressions (an empty name, an unknown modifier, or Shift+ on a printable character) are reported when the user configuration loads, so a bad binding is caught before the TUI starts rather than failing silently.

Configuration

pinto init writes .pinto/config.toml with the defaults below. Every setting is optional to change; a fresh board works without editing anything. Edit the file directly and keep the change small and reviewable — it is the one file in .pinto/ that is meant to be hand-edited.

The CLI discovers this board from descendant directories by walking upward to the nearest .pinto/config.toml. It checks a repository root marked by .git and then stops there, or stops at the filesystem root. Use pinto --dir PATH or PINTO_DIR=PATH pinto ... to select a different project (the path may be the project directory or .pinto itself).

columns = ["todo", "in-progress", "review", "done"]
done_column = "done"

[project]
name = "pinto"
key = "T"

[tui]
confirm_quit = true

[storage]
backend = "file"

[wip]
enabled = true

[display]
markdown = true
timezone = "local"

[points]
aggregate_children = false

Workflow columns

columns is the ordered list of Kanban states, left to right. done_column names the completion column; the board sorts that column by completion time and records a done_at timestamp when a PBI enters it. done_column must be one of columns, and an unknown value is rejected when the config loads.

Configuration uses a strict schema: unknown keys are rejected with the TOML table and field path, so a typo such as [display].timezome does not silently fall back to a default. columns must contain at least one non-blank, unique name. Values in done_column, [tui].hidden_columns, and [wip.limits] must refer to configured columns.

Renaming or removing a column that still holds PBIs strands those items in a status the workflow no longer recognizes, so move work out of a column before retiring it.

Project identity

The [project] table sets the display name and the PBI ID prefix key. With key = "T", new items are numbered T-1, T-2, and so on. Changing key affects only IDs assigned afterward; existing IDs keep their original prefix. The key must contain only ASCII letters. Digits and - are reserved for the numeric ID portion and separator; _ is not accepted. The project name must not be empty or whitespace-only. Invalid settings stop the command before the selected storage backend is opened; fix the reported field in config.toml and retry.

Storage backend

[storage] backend selects where the board is persisted:

  • file (default) — one Markdown file per PBI under .pinto/.
  • git — the file layout plus one automatic commit for each complete write operation; pre-existing Git changes are kept out of that commit.
  • sqlite — a single .pinto/board.sqlite3 database, available only in builds with the optional sqlite feature. It is not enabled in the default build and does not provide the plain-text Git-diff view of the file and Git backends.

All backends expose the same CLI. Use pinto migrate --to <backend> to move an existing board between them. Build the SQLite variant explicitly with cargo build --features sqlite (or cargo run --features sqlite -- ...). Migration between file/Git and SQLite is a persistence-format change; keep a backup and follow the versioned-schema guidance in Stability decisions.

Write commands wait up to five seconds for another pinto process by default. The lock remains held through a Git-backed commit so one service operation stays atomic. For a slow filesystem or Git hook, set the process environment variable PINTO_LOCK_TIMEOUT_SECS to a larger non-negative integer before running the command.

WIP limits

[wip] enforces work-in-progress limits per column. It is enabled by default with no limits set, so nothing is restricted until you add one:

[wip]
enabled = true

[wip.limits]
in-progress = 3
review = 2

Exceeding a limit on pinto move prints a warning. Pass --no-wip-check to skip the check for a single move, or set enabled = false to disable the check for the whole board.

Display

[display] controls how PBI bodies and timestamps are shown by pinto show and the Kanban details popup:

  • markdown = true renders bodies as styled Markdown; set false for raw text.
  • timezone formats human-readable timestamps. Use local, UTC, or a fixed ±HH:MM offset such as +09:00. This affects display only — stored and JSON timestamps stay in UTC.

Parent PBI points (opt-in)

[points].aggregate_children is false by default. Set it to true when parent PBIs should display the sum of their active descendant leaves:

[points]
aggregate_children = true

When enabled, a parent’s stored points are replaced in read-only views while it has children. A nested parent is counted through its descendants only once, and an item in done_column contributes no points. Active descendants below a completed intermediate item remain eligible. If an active descendant leaf has no points, the affected parent is shown as unestimated (-) rather than using an incomplete sum. The stored Markdown frontmatter is never rewritten by this calculation.

Interactive Kanban

The [tui] table configures the shared parts of the interactive board — exit confirmation and hidden columns. Personal keybindings are kept outside the board; see Kanban (TUI) for the user configuration and key syntax.

Data format

Board layout

The default file backend stores the board below .pinto/ in the repository where pinto is run. The directory contains the configuration, individual PBI Markdown files, Sprint data, templates, and the issued_ids history. Each PBI is a separate file so that a Git diff shows the change to one item clearly.

The board is local-first: no account, server, or database service is required. File and Git backends are the plain-text compatibility boundary: their PBI and Sprint records remain human-readable and a Git diff can show each operation. SQLite is the explicit persistence exception. It exposes the same pinto operations through an opt-in, normalized database, but its versioned schema and migration rules replace the per-record text diff.

PBI files

A PBI file combines TOML frontmatter with a Markdown body:

+++
id = "T-1"
title = "Implement the parser"
status = "todo"
rank = "i"
created = "2026-01-01T00:00:00Z"
updated = "2026-01-01T00:00:00Z"
+++

Acceptance criteria and planning notes belong here.

The frontmatter carries structured fields such as the ID, title, status, rank, labels, relations, timestamps, and optional Sprint information. The body is user-authored Markdown and is preserved when the display locale changes.

An action PBI promoted from a Sprint Retro or Review has one additional machine-readable source link:

[source]
kind = "review"
sprint_id = "S-1"

kind is retro or review, and sprint_id identifies the parent Sprint and the corresponding child record. The link does not introduce another PBI or child-record state; the PBI’s normal status remains authoritative.

The filename stem is part of the record identity: tasks/T-1.md and archive/T-1.md must both contain id = "T-1". File reads validate active and archived items, as well as Sprint filenames, and stop on filename mismatches or duplicate logical IDs before a write or migration can overwrite existing data.

Statuses must be columns in the configured workflow. The rank is a fractional index used to keep ordering changes small. Completion and start timestamps are recorded when a PBI crosses the configured workflow boundaries.

Sprint files

A Sprint uses the same TOML-frontmatter/Markdown-body shape under .pinto/sprints/. Its title, state, planned dates, capacity settings, and timestamps are structured fields; its goal is the Markdown body. goal_achieved = true or goal_achieved = false records the explicit boolean retrospective result in frontmatter; a recorded result always accompanies a non-blank Goal, and writing a blank Goal clears the result. When the field is omitted, the Goal is unevaluated. Closing a Sprint writes closed_at plus spillover_points, spillover_items, and unestimated_spillover_items. Zero spillover values and an unset close time are omitted before close. These fields preserve retrospective context after unfinished PBIs are rolled over or released, while velocity continues to count completed work only.

Sprint Retro files

A Sprint Retro is stored separately under .pinto/retro/ as .pinto/retro/<SPRINT-ID>.md. Its ID is the parent Sprint ID, so the file name and the id frontmatter field must match. The body is free-form Markdown and the frontmatter records id, created, and updated:

+++
id = "S-1"
created = "2026-07-29T00:00:00Z"
updated = "2026-07-29T00:00:00Z"
+++

## What went well

The file and Git backends keep this record as plain text. The optional SQLite backend also keeps Retro files in this dedicated directory so the format stays visible and compatible with the Sprint CLI.

Sprint Review files

A Sprint Review is stored separately under .pinto/review/ as .pinto/review/<SPRINT-ID>.md. Its ID is the parent Sprint ID, so the file name and the id frontmatter field must match. The body is free-form Markdown and the frontmatter records id, created, and updated:

+++
id = "S-1"
created = "2026-07-29T00:00:00Z"
updated = "2026-07-29T00:00:00Z"
+++

## Demonstrated

The file, Git, and optional SQLite backends keep Review data separate from both the Sprint goal and Sprint Retro records.

The machine-readable show and list representations expose sprint_id as the explicit parent-Sprint reference for both child-record types. It currently matches the stable id, which is also the Sprint ID and filename stem. Complete export --json snapshots place these records in their retros and reviews collections; importing a snapshot restores the same IDs, parent links, times, and Markdown bodies.

Retro and Review show views generate parent-Sprint context at read time. The context is not written into either Markdown body: it includes the parent goal, state, schedule, close-time spillover, and any available capacity, velocity, burndown, or Cycle/Lead Time reports. Missing context is displayed as unavailable and represented as null in --json; a closed Sprint’s stored spillover remains available after unfinished PBIs are rolled over or released. The generated detail view also lists active PBIs whose [source] link points to that child record; their normal PBI statuses are read from the PBI files.

Removing a Sprint protects these one-to-one child records by default. The --delete-records option is required to remove the matching Retro and Review files with the Sprint; records for other Sprints are unaffected.

Configuration

.pinto/config.toml controls the shared workflow and presentation settings. The default workflow is:

columns = ["todo", "in-progress", "review", "done"]
done_column = "done"

It is the one file under .pinto/ intended for hand-editing. Beyond the workflow columns, it selects the storage backend, project identity, WIP limits, and display/timezone options. Personal interactive Kanban keybindings belong in $XDG_CONFIG_HOME/pinto/config.toml; they are not board data and are not included in board exports. See Configuration for every setting. Keep machine-readable JSON timestamps in UTC; the display timezone does not rewrite stored data.

Compatibility boundaries

Board configuration is a strict TOML schema and may gain keys between releases; an older binary can reject a newer .pinto/config.toml. Markdown PBI and Sprint records are the file-backed board data. File and Git backends are the plain-text compatibility boundary, while SQLite is the explicit persistence exception with its own versioned schema and migration rules. JSON is a machine-readable CLI output contract, not another persistence backend and not a configuration file. Personal keybindings are independent of all four board data formats.

Safe operations

Use pinto commands to add, transition, rank, edit, archive, and relate PBIs. The generated .pinto/issued_ids file preserves every issued item number so a permanently deleted ID is never assigned to a different PBI; do not remove it when changing storage backends. Do not maintain a second hand-edited backlog or edit task files as part of the normal workflow. Direct recovery is an exception for damaged data; validate the board with pinto list afterward.

For the full JSON contract and migration rationale, see JSON output and storage migration.

Team-scale best practices

pinto can support different working styles without making every user adopt the same amount of process. Choose the smallest set of features that gives the people doing the work enough shared structure. The recommendations below are guidelines, not hard limits: a team can adopt more structure as its coordination needs grow.

Individual development

For one developer or a personal project, use pinto without Scrum features. Keep an ordered Product Backlog and move PBIs through the Kanban workflow with the everyday commands:

pinto add "Write the release notes"
pinto list
pinto show T-1
pinto move T-1 in-progress

Skip Sprints, Sprint goals, and capacity planning when there is no team that needs those coordination tools. This keeps pinto’s local-first workflow useful for personal planning without adding ceremonies or bookkeeping that do not improve the work.

Small teams

For a small team working toward a shared product goal, use pinto with Scrum features. Keep the Product Backlog as the team’s ordered source of work, then use Sprints, Sprint goals, points, and the Kanban workflow to make the plan and current progress visible:

pinto sprint new S-1 "Sprint 1" --goal "Ship the first release"
pinto sprint add S-1 T-1
pinto sprint start S-1

This amount of structure gives a small team a shared planning and review rhythm while preserving pinto’s lightweight, local-first model. Configure only the workflow and Sprint practices the team actually uses.

Larger teams

For a larger team or a board changed by many contributors, use the Git backend in a dedicated repository for the shared pinto board:

[storage]
backend = "git"

Keep the board’s .pinto/ directory in that dedicated repository rather than mixing board commits with application source changes. The Git backend keeps pinto’s file-based board and CLI while creating one Git commit for each complete write operation. The dedicated repository gives a larger team a durable review, permission, and recovery boundary for Product Backlog, Sprint, and workflow changes. Use the merging shared boards guide when multiple clones need to combine board changes. The Git backend does not remove Scrum features; it adds the collaboration boundary that a larger team needs.

Merging shared boards

pinto stores each board under .pinto/ as plain text so it travels through Git like the rest of the repository. When two people (or two clones, or two branches) edit the same board in parallel, Git merges most changes cleanly. The one place that needs a runbook is new PBIs, because pinto hands out sequential IDs and two branches that start from the same commit can allocate the same number.

This chapter explains why those conflicts appear, how to resolve them without losing history, and how to confirm the merged board is healthy with pinto doctor.

A ready-to-run board that reproduces the whole scenario lives in demos/single/merge-conflict; its README.md walks through the same steps against a disposable clone.

Why parallel clones collide

pinto allocates the next ID by reading .pinto/issued_ids, an append-only list of every number it has ever issued:

T-1
T-2
T-3

Suppose two branches, alice and bob, both start from a commit whose latest item is T-1:

  • alice runs pinto add twice and allocates T-2 and T-3.
  • bob runs pinto add once and allocates T-2.

Both branches independently decided that the next free number was T-2, so merging them surfaces two kinds of conflict:

  • Task-file conflict — both branches created .pinto/tasks/T-2.md with different content, so Git reports an add/add conflict on that path.
  • issued_ids conflict — because the branches appended a different number of lines, Git reports a content conflict in .pinto/issued_ids.
$ git merge alice
Auto-merging .pinto/issued_ids
CONFLICT (content): Merge conflict in .pinto/issued_ids
Auto-merging .pinto/tasks/T-2.md
CONFLICT (add/add): Merge conflict in .pinto/tasks/T-2.md
Automatic merge failed; fix conflicts and then commit the result.

.pinto/tasks/T-3.md (Alice’s second item) merges cleanly because only one branch created it.

Resolve issued_ids by taking the union

issued_ids is a history, not a count: a permanently deleted ID must never be reissued to a different PBI. The correct resolution is therefore always the union of both sides — keep every number that either branch issued, sorted and deduplicated. Replace the conflict markers:

T-1
T-2
<<<<<<< HEAD
=======
T-3
>>>>>>> alice

with the union:

T-1
T-2
T-3

Then stage the file:

$ git add .pinto/issued_ids

Resolve the task-file conflict by re-homing one item

Two different items now claim T-2. Pick which one keeps the shared ID, resolve the file to that item’s content, and stage the rest of the merge:

$ git checkout --theirs .pinto/tasks/T-2.md   # keep Alice's item as T-2
$ git add .pinto/tasks/T-2.md .pinto/tasks/T-3.md
$ git commit

Re-home the displaced item under a fresh ID with pinto add. Because you already unioned issued_ids, pinto add allocates the next free number beyond it (T-4 here) and appends it to the history:

$ pinto add "Bob X"
$ pinto list
T-1  todo  Baseline
T-2  todo  Alice A
T-3  todo  Alice B
T-4  todo  Bob X

Prefer this pinto add re-homing over hand-editing task files: it keeps issued_ids, the filename, and the frontmatter ID in agreement automatically.

Verify the merged board with pinto doctor

After every merge, run pinto doctor. It scans for the exact damage a bad merge leaves behind — duplicate IDs, filename/ID mismatches, and issued_ids history gaps — and prints an explicit repair direction for each finding:

$ pinto doctor
Board is healthy.

If a naive resolution left two files sharing an ID, doctor reports a duplicate ID finding for each copy:

$ pinto doctor
Found 2 unresolved board issue(s).
[duplicate ID] .pinto/tasks/T-2-alice.md: item ID T-2 is also present at ...
Repair: run pinto doctor --fix to renumber duplicates, or resolve them manually

pinto doctor --fix renumbers the collision deterministically: the first copy (active tasks before archived items, then by path) keeps the shared ID, and each later copy is re-homed to a fresh ID above every issued number. The fix rewrites parent and depends_on references that point at a renumbered copy, appends the new IDs to issued_ids, and leaves the canonical record untouched:

$ pinto doctor --fix
Found 2 unresolved board issue(s).
Fixed: renumbered T-2 as T-5: .pinto/tasks/T-2-alice.md -> .pinto/tasks/T-5.md
[rank anomaly] .pinto/tasks/T-2.md: rank "j" duplicated in status "todo" parent scope ""
Repair: run pinto rebalance affected workflow scope
[rank anomaly] .pinto/tasks/T-5.md: rank "j" duplicated in status "todo" parent scope ""
Repair: run pinto rebalance affected workflow scope

Independent clones usually allocate the same rank alongside the same ID, so the two renumbered copies now share a rank in one scope. doctor will not choose their order for you; run pinto rebalance to spread the collision, then re-run pinto doctor:

$ pinto rebalance
Rebalanced 2/3 item(s) (max rank length 1 -> 1).
$ pinto doctor
Board is healthy.

Prefer this over hand surgery. If you would rather choose the surviving item yourself, keep one file and re-home the other with pinto add as above, then re-run pinto doctor until it prints Board is healthy.

If you accidentally dropped a line from issued_ids while resolving the conflict, doctor detects the gap and pinto doctor --fix backfills it — it only records IDs that already belong to existing items and never chooses between duplicates:

$ pinto doctor
Found 1 unresolved board issue(s).
[issued ID history] .pinto/tasks/T-4.md: item ID T-4 is missing from issued_ids
Repair: append the existing item ID to issued_ids or run pinto doctor --fix
$ pinto doctor --fix
Board is healthy.
Fixed: recorded T-4 in .pinto/issued_ids

Checklist

  1. Union .pinto/issued_ids; never drop an issued number.
  2. Preserve both versions of a conflicting task under distinct filenames.
  3. Run pinto doctor --fix to renumber duplicates and repair history gaps.
  4. Run pinto rebalance when the renumbered copies collide on rank, then re-run pinto doctor until the board is healthy.
  5. Confirm the item list with pinto list before pushing the merge.

Undoing a mutation

pinto undo reverts the most recent completed board mutation. This page is the feature decision record for that command: its scope, its per-backend behavior, and its compatibility impact.

Scope

Undo is a guided, one-level recovery. It targets the single most recent completed mutation — the kind of mistake that rm --force, a wrong move, or an unintended edit produces — and nothing deeper. Walking further back through the history stays a manual Git task, which keeps the command lightweight and its behavior predictable.

Undo is deliberately excluded from pinto automate plans. Reverting a mutation is a human corrective action; an agent plan should not reverse its own earlier commands.

Per-backend behavior

pinto only records history on the git backend, so recovery is backend-specific.

Git backend

Each board mutation is one pinto: <verb> <id> commit, so the most recent mutation is the current HEAD. pinto undo runs git revert --no-edit HEAD, which writes a new commit that reverses the change:

pinto undo
# Reverted the most recent board mutation: pinto: add T-3

Revert — not reset — was chosen on purpose:

  • It is non-destructive: history is preserved, so nothing is lost and the operation is safe on a shared board.
  • It is Git-friendly and reviewable: the undo lands as a Revert "pinto: …" commit whose effect you can inspect with git diff.
  • It is reversible: undoing the undo is just another revert.

Undo refuses, without touching the repository, when there is nothing pinto can revert:

  • the repository has no commits yet, or
  • the latest commit is not a pinto board mutation (its subject does not start with pinto: ) — for example a user commit stacked on top of the board, or a previous undo’s own revert commit.

In the second case the message names the offending commit and points at git log -- .pinto and a manual git revert <sha>, so undo never silently reverses an unrelated commit.

Like every other mutation, undo runs under the board write lock, so it is serialized against concurrent writers.

File and SQLite backends

These backends keep no history, so there is nothing to revert. pinto undo fails fast with exit code 1 and an actionable message that names the current backend and lists the recovery options:

  • restore the affected files from a backup or a version-control checkout, or
  • switch to the git backend (pinto migrate --to git, or set [storage] backend = "git") to enable undo for future mutations.

Compatibility and persistence impact

The command is purely additive:

  • No data-format or schema change. Undo reuses the existing plain-text persistence and the established pinto: <verb> <id> commit convention.
  • No migration. Boards created before this command work unchanged; undo only reads existing history and appends a revert commit.
  • No new dependency. It runs through the same git subprocess helpers the git backend already uses.

Try it

The undo demo ships a reproducible git-backed board you can revert and inspect.

Dogfooding

pinto develops on a pinto board. When validating a change in this repository, run the current worktree through cargo run so the behavior under test is the behavior being developed.

Inspect the board

Use commands such as these from the repository root:

cargo run --quiet -- list --status todo --long --json
cargo run --quiet -- show <ID-from-list>
cargo run --quiet -- board
cargo run --quiet -- next --json

Replace <ID-from-list> and <ID> with IDs returned by the board commands.

Human-readable output is useful for a quick check; --json is useful when the result must be inspected without depending on table formatting.

These ordinary reads remain non-blocking because they do not acquire the board-wide write lock. They therefore do not provide snapshot isolation while a writer is active. When a shell or agent needs PBIs, Sprints, configuration, and the shared DoD from one consistent state, use:

cargo run -- export --json

The export waits for writers and holds the board lock while assembling the complete JSON snapshot.

These examples correspond to the installed pinto list, pinto show, and pinto board subcommands. The cargo run -- prefix is intentional while developing: it selects the executable built from the current checkout.

Update an item

Add, transition, rank, edit, and remove items through the CLI:

cargo run -- add "Document the workflow" --template default
cargo run -- move <ID> in-progress
cargo run -- reorder <ID> --top
cargo run -- show <ID>

For a transition, the installed form is pinto move <id> <status>; the dogfooding form above is cargo run -- move <id> <status>.

Use the default archive operation when an item was created by mistake. Reserve remove --force for an explicit permanent cleanup, and inspect the result with list, show, or board after every write.

For multiple planned writes, validate the plan first:

cargo run -- automate --plan plan.json --dry-run --json
cargo run -- automate --plan plan.json --json

Do not edit .pinto/tasks/*.md directly during normal backlog work. The configuration file may be edited when changing board settings, but the CLI is the source of truth for item operations.

Development verification

After implementation and dogfooding, run the same quality gate used by CI:

mise run check
mise run release-check

The release gate repeats the check as needed and also runs the all-features coverage threshold, dependency audit, and dependency-policy checks. Coverage has no source exclusions, so storage boundaries and TUI lifecycle paths remain measured.

Contributing

Read the repository’s AGENTS.base.md, CONTRIBUTING.md, and design guide before making a design decision. The project favors a small, fast, Scrum-focused tool with plain-text, Git-friendly storage.

For a new command, backend, or report, record the Scrum-related need, why existing functionality is insufficient, and the dependency, persistence, and migration/compatibility impact in the issue or pull request. See the design decision record and stability decisions for the storage boundary.

AGENTS.base.md is the shared contributor and agent baseline. A developer may derive a local AGENTS.md overlay with cp AGENTS.base.md AGENTS.md and append personal tool or environment instructions there. Root-level AGENTS.md, CLAUDE.md, and .claude/ are ignored and must not be committed; project rules that everyone needs belong in the tracked baseline or this guide.

Development loop

Install the managed tools and run the quality gate:

mise install
mise run check

Follow TDD for behavior changes:

  1. Red — write a focused test that fails for the missing behavior.
  2. Green — implement the smallest change that makes the test pass.
  3. Refactor — improve structure while keeping the tests green.

Domain behavior belongs in unit-testable modules under src/; CLI input and output belong in integration tests under tests/. Documentation changes should also build the book locally:

mise run book
mdbook serve

The repeatable unit, integration, doctest, and fuzzing commands are collected in Testing and fuzzing.

See Reproducible builds and releases for the pinned toolchain policy, CI job responsibilities, and locked package verification.

Commit and maintainer review workflow

Keep changes in small, green commits: each commit should build and pass the focused tests that cover it. For cross-cutting work, separate data, service, CLI, and documentation changes where practical. If a change cannot be split without obscuring the behavior, explain the boundary in the pull request.

Before starting a large change, review its acceptance conditions and record the decisions that affect scope, persistence, migration, or release behavior. A destructive or release-related change must include its risk, verification, and recovery steps in the pull request. The primary maintainer records the final decision and any follow-up actions, then runs the strongest applicable checks. This documented fallback preserves traceability and keeps the change reviewable when maintainer capacity is limited.

Security work follows the security policy. The security maintainer owns private intake and coordinated disclosure, while the release maintainer owns versioning, changelog, package, tag, and publication checks. When responsibilities overlap, record the owner of each decision in the security or release record.

Before committing

Run mise run check after the final change. It runs all-feature tests, Clippy with warnings denied, Rust documentation with warnings denied, the mdBook build, and formatting checks. Review the complete diff for unrelated changes, keep dependencies minimal, and write actionable user-facing errors.

Backlog changes are part of the normal workflow: inspect and update the self-hosted .pinto/ board through pinto commands, then verify the result with pinto list or pinto board.

Pull requests

Use a focused branch and describe the motivation, implementation, tests, and documentation changes. Include a related issue or planning reference when one exists. Follow the pull request checklist and keep user-facing documentation in English; localized Fluent resources are the intentional exception.

Testing and fuzzing

Run the normal test layers from a checkout with the Rust toolchain selected by mise:

mise run test                 # unit, CLI, docs, i18n, and skill integration tests
cargo test --doc --locked     # public API examples
cargo test --test cli --locked # CLI and pseudo-terminal smoke tests
mise run check                # tests, Clippy, Rust docs, mdBook, and fmt

mise run coverage writes coverage.xml in Cobertura format and then checks the artifact’s root Cobertura line-rate with scripts/check-coverage.sh. The 0.95 threshold is therefore applied to the same metric that CI uploads, rather than to the different denominator used by the LLVM text summary.

Kanban runtime failure-path matrix

The Kanban runtime has a separate Cobertura guard for src.cli.kanban.runtime, because the aggregate line-rate can hide an under-tested terminal boundary. The focused suite exercises these paths without requiring a real TTY:

Failure or boundaryFocused verification
terminal sizing failureA fake frame driver returns a size error before drawing or event polling.
drawing failureA fake frame driver returns the drawing error before event polling.
event polling failureA successful frame is followed by a polling error before event reading.
event-reading failureA successful frame is followed by an event-reading error.
panic unwindingThe terminal guard restores its lifecycle state while a panic unwinds.
narrow terminalsA zero-width terminal still keeps the selected column visible.
repeated resize eventsConsecutive resize events recompute the horizontal viewport.

Run the matrix with:

cargo test --bin pinto --locked cli::kanban::runtime

The same runtime package must meet the 0.90 line-rate threshold in scripts/check-kanban-coverage.sh; the repository-wide 0.95 gate remains unchanged.

The macOS PTY lifecycle regression can be reproduced with:

cargo test --test cli kanban::pty_tests::shell_can_reenter_kanban_without_leaking_lifecycle_state -- --exact --nocapture

The CI failure observed on the macOS 26 arm64 runner occurred after the test had returned to the third pinto> prompt following two Kanban entries. The child process did not satisfy the test’s three-second exit deadline after Ctrl-D, while the same lifecycle passed on local macOS and Linux; the Windows check suite also passed. The test keeps the Ctrl-D and terminal-flag assertions, but uses the platform-specific SHELL_EXIT_WAIT deadline only for this final process-exit wait so PTY teardown latency is not mistaken for a lifecycle leak.

The Markdown frontmatter parser and automation-plan parser have libFuzzer targets under fuzz/. Install the fuzz runner once with nightly-compatible tooling:

rustup toolchain install nightly
cargo install cargo-fuzz --locked
cargo fuzz list
cargo fuzz run automation_plan_parse -- -max_total_time=300
cargo fuzz run markdown_frontmatter_parse -- -max_total_time=300

The weekly scheduled CI workflow runs both targets for five minutes and uploads failures from fuzz/artifacts. To reproduce a reported input locally, pass the uploaded crash file or corpus directory to the same target:

cargo fuzz run markdown_frontmatter_parse fuzz/artifacts/markdown_frontmatter_parse/crash-...

Keep the failing input when fixing a parser bug, then rerun the target with a short time limit and finish with mise run check. The fuzz targets treat parser errors as expected input outcomes; a panic or sanitizer failure is the failure signal.

Reproducing CI locally with nektos/act

Use nektos/act to run a selected GitHub Actions job before pushing. act needs a Docker-compatible engine for containerized Linux runners. The check job is in ci.yml, while the release job is in release.yml. The release job uses the GitHub-provided GITHUB_TOKEN with contents: write to create the release, so inspect it with a dry run locally instead of accidentally publishing a real release. Never commit a token or a secret file. If a future job needs a secret, provide it through act’s --secret-file or --secret options from a path that is outside the repository.

Install Docker Desktop or Docker Engine, install act using the official installation guide, and verify both tools before running a job:

docker version
act --version
act -l

macOS and Linux

The release job uses ubuntu-latest, so inspect only that job to reproduce the release build and package path without creating a release:

act -n push -j release

On Apple Silicon, add --container-architecture linux/amd64 if the selected Linux image is not available for the host architecture.

The selected job runs the same commands used by CI:

cargo build --release --all-features --locked
./scripts/verify-package.sh
cargo install --path . --locked --root "$PWD/.tmp/pinto"
./scripts/extract-release-notes.sh "$GITHUB_REF_NAME"

The live GitHub run executes the final gh release create step with the tag-matched notes and the workflow token. Local dry runs do not publish or require a repository token.

To run only the Linux leg of the quality-check matrix, select its matrix value explicitly:

act push -j check --matrix os:ubuntu-latest

act uses Docker containers for these Linux jobs. It is useful for fast feedback, but GitHub-hosted runner parity remains the responsibility of the real CI job. The Windows matrix leg remains a host-executed validation and is not emulated by a Linux Docker container.

Windows

On a Windows host, select only the Windows matrix entry and map the runner to the host instead of a Docker image:

act push -j check --matrix os:windows-latest -P windows-latest=-self-hosted

This runs the Windows leg on the local Windows machine. It is act’s self-hosted-host mode, not a Windows Docker guest, so the machine must already have a real Git checkout (including .git) and the native tools used by the workflow (git, Node.js, compatible unzip/tar/gzip, mise, Rust, and rustup) plus the Windows shell environment expected by the steps. Git for Windows provides these archive tools in usr\bin; make sure C:\Program Files\Git\usr\bin is on PATH. It does not run the Linux or macOS matrix entries. Use the macOS/Linux procedure above for the containerized Linux release job.

The Docker Desktop Linux engine can run the containerized Linux jobs, but it cannot run Windows containers in that mode. The command above deliberately does not require a Windows Docker engine: it executes the Windows leg directly on the Windows host.

Troubleshooting

  • docker version cannot reach the engine: start Docker Desktop or the Docker service, then retry act -l.

  • act -l shows no release or check job: run it from the repository root and confirm the workflow is under .github/workflows/.

  • path ... not located inside a git repository: use a real clone or checkout that contains .git; a copied source tree is not enough for act’s ref and revision detection.

  • Cannot find: node in PATH: install Node.js and open a new terminal before running the Windows job; JavaScript actions such as jdx/mise-action need it.

  • Cannot find: unzip, gzip, or an archive/cache compatibility error: put C:\Program Files\Git\usr\bin on PATH. Git for Windows supplies the archive tools required by the actions used here; the old GnuWin32 unzip package cannot unpack some current mise archives.

  • PSSecurityException or a message that script execution is disabled: allow scripts only for the current PowerShell process, then invoke act:

    $env:PSExecutionPolicyPreference = "Bypass"
    $env:Path = "C:\Program Files\Git\usr\bin;$env:Path"
    act push -j check --matrix os:windows-latest -P windows-latest=-self-hosted
    

    This avoids changing the machine or user execution policy permanently.

  • Use act -n push -j release to validate the containerized workflow without creating a job container, and add --verbose when a step’s exit status needs more context. In the Windows -self-hosted mapping, host shell steps still execute, so use the command only when running those steps is acceptable.

  • --matrix filters existing matrix values; it does not create a new runner platform. The Windows command above therefore requires the windows-latest entry already present in .github/workflows/ci.yml.

These commands select jobs at invocation time and do not modify the production workflow. GitHub CI remains the authoritative cross-platform check.

Reproducible builds and releases

The repository commits Cargo.lock and treats it as part of the source and release contract. Cargo commands that build, test, document, package, or install pinto must use --locked; an intentional dependency update is made with cargo update, followed by review of the lockfile diff.

Toolchain roles

Development and release commands use Rust 1.97.0, pinned in mise.toml. Cargo.toml continues to declare Rust 1.89 as the minimum supported version. CI keeps the responsibilities separate:

JobWorkflowToolchainScope
msrvci.ymlRust 1.89.0Default and all-feature build/test compatibility
checkci.ymlPinned Rust 1.97.0Full mise run check quality gate on each primary OS
current-stableci.ymlLatest stable channelForward-compatibility test suite with all features
releaserelease.ymlPinned Rust 1.97.0Release build, package, source-install, and GitHub Release creation

The all-feature MSRV checks and the pinned quality gate intentionally cover different support contracts. The latest-stable job does only the forward compatibility probe, so a moving toolchain does not define release artifacts.

Clean-checkout verification

From a clean checkout, install the pinned tools and run the same gates used by CI:

mise install
mise run check
cargo build --release --all-features --locked
cargo package --all-features --locked
cargo install --path . --locked --root "$PWD/.tmp/pinto"

mise run release-check adds coverage, dependency audit, dependency policy, release metadata, and the release build/package tasks to the quality gate. The release metadata task checks package versions in all committed lockfiles, published installation examples, the latest release tag, and the CHANGELOG. It also requires the SQLite schema v1 to v2 compatibility guidance to remain complete.

Release and security responsibilities

The release maintainer owns the version, lockfiles, changelog, package, release tag, and publication checks described below. The security maintainer owns private vulnerability intake, triage, reporter communication, and coordinated disclosure; see the security policy. A primary maintainer may hold both roles, but the responsibilities and review record remain explicit.

Release-related or security-related changes include a documented risk assessment, responsible maintainer, strongest applicable release and security checks, and follow-up actions. If one maintainer holds both roles, record that ownership explicitly. This documented fallback preserves traceability and does not waive the normal verification expectation.

Allowlisted package contents

The crate manifest uses root-anchored package.include entries for the manifest, source, locale resources, README, license, and the rank benchmark example. This allowlisted package excludes repository-only data such as .pinto, demos, tests, docs, and CI metadata.

Run ./scripts/verify-package.sh or mise run release-package to run cargo package --all-features --locked, compare non-source package paths with the committed package file list in release/package-files.txt, and verify every current src/** file is present in the archive, and run tests against the extracted packaged crate. The recursive source include is checked directly, so adding a Rust module does not create a stale snapshot failure; update the baseline when a deliberate non-source package path changes. CI also runs cargo install --path . --locked from the clean checkout as the source-install check.

Publishing a release

Choose the next version once and derive every command below from the manifest so the procedure never embeds a stale published version. After bumping the version in Cargo.toml, export it from cargo pkgid:

VERSION="$(cargo pkgid | sed 's/.*[@#]//')"

For each release, update the package version in Cargo.toml and both committed lockfiles, move the relevant entries from [Unreleased] into a dated CHANGELOG.md heading, and update the published-version installation examples to match $VERSION. For a breaking change while pinto remains in the 0.x series, increment the minor version, as the earlier CLI rename demonstrates.

Pre-tag verification

Before creating the tag, confirm the bumped tree is internally consistent and the tag is still available. These checks require the package version in Cargo.toml, both committed lockfiles, the dated CHANGELOG.md entry, and the installation examples to agree on $VERSION, and that the $VERSION tag does not already exist:

test "$(cargo pkgid | sed 's/.*[@#]//')" = "$VERSION"                    # Cargo.toml package version
for lock in $(git ls-files '*Cargo.lock'); do grep -Fq "version = \"$VERSION\"" "$lock" || echo "missing $VERSION in $lock"; done
grep -Fq "## [$VERSION]" CHANGELOG.md                                    # dated changelog entry
grep -Fq "cargo install pinto-cli --version $VERSION" README.md docs/book/src/installation.md
git tag --list "$VERSION" | grep -qx "$VERSION" \
  && { echo "tag $VERSION already exists"; false; } \
  || echo "tag $VERSION is available"

Once these pass, create the tag on the release commit so the release-metadata gate — which treats the tag as the publication source of truth — sees a consistent tree, then run the complete local gate and verify the package without uploading it:

git tag "$VERSION"
mise run release-check
cargo publish --dry-run --all-features --locked

The release gate must pass before a public release. A release is not ready while the package version, lockfiles, installation examples, CHANGELOG heading, and release tag disagree, or while the SQLite compatibility guidance is incomplete. Keep the next work items under the undated [Unreleased] heading until the release commit is tagged.

The tag-triggered release.yml workflow extracts the matching dated section from CHANGELOG.md with scripts/extract-release-notes.sh and runs gh release create with the GitHub-provided token. The workflow therefore creates the GitHub Release and its notes automatically after the build and package checks pass; no manual release-entry step is needed.

Published Book destinations

The Pages workflow builds one artifact from the stable main ref, the current develop ref, and every semantic-version tag before deploying it. This keeps the routes available together:

  • /pinto/ redirects to /pinto/latest/, which contains the stable main Book.
  • /pinto/develop/ contains the development Book.
  • /pinto/X.Y.Z/ contains the Book for the X.Y.Z release tag.

The root is a small static redirect entry point, while /latest/ is built directly from main; this avoids duplicate stable files and prevents the latest route from drifting. Historical version routes are rebuilt into the same artifact, so publishing a newer release does not remove older documentation.

After the release commit has passed CI and has been fast-forwarded to main, push the tag together with main. Publish the same locked package to crates.io only after the tag points at that commit:

git push origin main "$VERSION"
mise run release-publish