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.
| Command | Purpose |
|---|---|
pinto init | Initialize 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 list | List active PBIs, with status, assignee, label, Sprint, search, stale-duration, root-only, long, and JSON filters. Use --archived to select archived PBIs. |
pinto next | Show 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 board | Render PBIs grouped by workflow column, optionally filtering by assignee or showing root PBIs only. |
pinto export --json | Export the complete active board, configuration, and shared DoD as one consistent JSON snapshot; it waits for writers. |
pinto doctor | Check board integrity; use --fix for safe mechanical repairs. |
pinto kanban | Open 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:
--childmakes the source the parent of each new PBI.--dependencymakes 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.--emptystarts 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:
- Top-level PBIs come first, in ascending
rank(with a(prefix, number)ID tie-break so equal ranks never reorder between views). - 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 listflattens 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 boardandpinto kanbanbuild 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 ownrank).- The completion column (
done_columninconfig.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 | createdsets the root/sibling order explicitly (add--reverseto invert it); the hierarchy is always preserved.pinto kanbanuses 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.
Navigate and edit
The board separates selecting a card from moving it: lowercase keys move the cursor, uppercase (Shift) keys move the selected item. Defaults are:
| Action | Keys |
|---|---|
| Select column / row | h j k l or arrow keys |
| Move item across columns | H / L (Shift+Left / Shift+Right) |
| Reorder item within a column | K / J (Shift+Up / Shift+Down) |
| Expand or collapse a parent | Space / Enter |
| Add a PBI | a |
| Edit the selected PBI | e |
| Add / remove a dependency | d / D |
| Set or clear the parent | p |
| Split the selected PBI | s |
| Open the details popup | v |
| Substring / regex search | / / Ctrl+? |
| Clear an active filter | Esc |
| Toggle a maximized column | m |
| Reload the board | r |
| Help window | ? |
| Quit | q or Esc |
| Quit into the shell | Q |
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 thanShift+hfor shifted letters. - Named keys:
Enter,Esc,Tab,Backspace,Delete,Insert,Home,End,PageUp,PageDown, the arrowsLeft/Right/Up/Down, and function keysF1–F12. - Modifiers:
Ctrl,Alt,Shift,Cmd,Meta, andHyper— for exampleCtrl+aorAlt+Shift+Left. Write the literal plus key asPlus.
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.sqlite3database, available only in builds with the optionalsqlitefeature. 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 = truerenders bodies as styled Markdown; setfalsefor raw text.timezoneformats human-readable timestamps. Uselocal,UTC, or a fixed±HH:MMoffset 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:
alicerunspinto addtwice and allocatesT-2andT-3.bobrunspinto addonce and allocatesT-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.mdwith different content, so Git reports anadd/addconflict on that path. issued_idsconflict — 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
- Union
.pinto/issued_ids; never drop an issued number. - Preserve both versions of a conflicting task under distinct filenames.
- Run
pinto doctor --fixto renumber duplicates and repair history gaps. - Run
pinto rebalancewhen the renumbered copies collide on rank, then re-runpinto doctoruntil the board is healthy. - Confirm the item list with
pinto listbefore 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 withgit 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 previousundo’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
gitsubprocess 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:
- Red — write a focused test that fails for the missing behavior.
- Green — implement the smallest change that makes the test pass.
- 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 boundary | Focused verification |
|---|---|
| terminal sizing failure | A fake frame driver returns a size error before drawing or event polling. |
| drawing failure | A fake frame driver returns the drawing error before event polling. |
| event polling failure | A successful frame is followed by a polling error before event reading. |
| event-reading failure | A successful frame is followed by an event-reading error. |
| panic unwinding | The terminal guard restores its lifecycle state while a panic unwinds. |
| narrow terminals | A zero-width terminal still keeps the selected column visible. |
| repeated resize events | Consecutive 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 versioncannot reach the engine: start Docker Desktop or the Docker service, then retryact -l. -
act -lshows noreleaseorcheckjob: 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 asjdx/mise-actionneed it. -
Cannot find: unzip,gzip, or an archive/cache compatibility error: putC:\Program Files\Git\usr\binonPATH. Git for Windows supplies the archive tools required by the actions used here; the old GnuWin32unzippackage cannot unpack some currentmisearchives. -
PSSecurityExceptionor a message that script execution is disabled: allow scripts only for the current PowerShell process, then invokeact:$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-hostedThis avoids changing the machine or user execution policy permanently.
-
Use
act -n push -j releaseto validate the containerized workflow without creating a job container, and add--verbosewhen a step’s exit status needs more context. In the Windows-self-hostedmapping, host shell steps still execute, so use the command only when running those steps is acceptable. -
--matrixfilters existing matrix values; it does not create a new runner platform. The Windows command above therefore requires thewindows-latestentry 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:
| Job | Workflow | Toolchain | Scope |
|---|---|---|---|
msrv | ci.yml | Rust 1.89.0 | Default and all-feature build/test compatibility |
check | ci.yml | Pinned Rust 1.97.0 | Full mise run check quality gate on each primary OS |
current-stable | ci.yml | Latest stable channel | Forward-compatibility test suite with all features |
release | release.yml | Pinned Rust 1.97.0 | Release 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 stablemainBook./pinto/develop/contains the development Book./pinto/X.Y.Z/contains the Book for theX.Y.Zrelease 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