A git-native backlog your AI coding agent cannot drift away from.
Tasks live as YAML files in your repository. The tasc CLI decides what to work
on next — deterministically — and every read command speaks JSON, so an agent
consumes exactly what you read.
$ tasc next
╭──────────────────────────────────────────────────────────────╮
│ api-004 — Add retry to the payment webhook │
│ epic: api priority: High file: tasks/active/api.yaml │
│ │
│ Acceptance criteria: │
│ - Retries 3 times with backoff │
│ - Covered by a test │
╰──────────────────────────────────────────────────────────────╯
Start it: tasc mark api-004 in_progressAn agent asked "what should I do next?" will happily invent an answer. Give it a file it must read instead, and the answer stops being a guess.
- Deterministic selection. The same repository state always yields the same next task: ordered by priority, then id, with unmet dependencies excluded. Re-running the command cannot produce a different plan.
- No hidden state. Plain YAML, reviewed in pull requests, versioned with the code it describes. No database, no server, no account.
- JSON on every read command.
--jsongives agents a stable contract rather than scraped console output. - Drift is detectable.
tasc validaterejects duplicate ids, unknown dependencies and self-dependencies.tasc stalefinds work that was started and abandoned. Both exit non-zero, so CI can gate on them. - Token-efficient. Open work is grouped per epic in one file; closed work is one file per task, read only on demand. An agent loads the backlog it needs, not the whole history.
- Safe to run in parallel. Owners, epic scoping and deterministic shards give each agent its own slice of the backlog, so several can work at once without claiming the same task and without a coordinating server.
Installed from a git tag, not from a package index:
pipx install "git+https://github.com/danyapilovets/tasks-as-code@v1.4.0"
# plus one-way Jira Cloud sync
pipx install "tasks-as-code[jira] @ git+https://github.com/danyapilovets/tasks-as-code@v1.4.0"pipx keeps it in its own environment and on your PATH; pip install with the
same argument works if you would rather have it in the current environment.
Upgrade by installing a newer tag. Requires Python 3.10 or newer.
This is not published to PyPI. The tag is the release: it is what pipx,
pip and pre-commit all resolve, and it pins you to a specific commit rather
than to whatever a package index served that day.
cd your-repo
tasc init --name "Your Project"
tasc new api --summary "Add retry to the payment webhook" -p High
tasc next # what to work on
tasc mark api-001 in_progress # start it
tasc done api-001 --note "Retries 3x with backoff"That produces:
tasks/
├── active/
│ └── api.yaml # open work, grouped by epic
├── archive/
│ └── api-001.yaml # one file per closed task
├── done/
│ └── 2026-Q3.md # quarterly log of what shipped
└── INDEX.md # generated overview, git-ignored
.tasc.yaml # configuration
INDEX.md is regenerated by every new, mark and done, so tasc init adds
it to .gitignore: tracking a derived file means two branches conflict in almost
every pull request. Generate it locally with tasc reindex, and in CI if you want
a published view. Pass tasc init --track-index if you would rather commit it.
epic: api
description: Public HTTP surface.
tasks:
- id: api-001
summary: Add retry to the payment webhook
description: The provider returns 502 under load; retry with backoff.
type: Task # Task | Story | Bug | Epic
priority: High # Critical | High | Medium | Low
status: todo # todo | in_progress | blocked | done
owner: agent-a # optional; who is doing it
acceptance_criteria:
- Retries 3 times with exponential backoff
- Covered by a test
depends_on: [api-000]
updated: 2026-07-28Ids are <epic>-<number>, lowercase. Statuses and priorities are normalised on
read, so To Do, to-do, WIP and closed all resolve to the canonical value.
Unknown fields are preserved, so you can attach your own (points, links)
without the tool dropping them.
| Command | What it does |
|---|---|
tasc init |
Create the task tree and .tasc.yaml |
tasc next |
Show the next ready task, plus anything already in progress |
tasc list |
List tasks, filterable by --epic, --status and --owner |
tasc show <id> |
One task in full, including what blocks it |
tasc new <epic> |
Create a task with an auto-numbered id |
tasc mark <id> <status> |
Move to todo, in_progress or blocked |
tasc done <id> |
Close it: archive the file, append to the quarterly log |
tasc validate |
Check the schema and structure; non-zero on any problem |
tasc stale |
Report abandoned in-progress work; non-zero if any |
tasc check-ref |
Check that a message names a task that exists; non-zero if not |
tasc stamp <file> |
Write the task's issue key into a commit message |
tasc install-hook |
Install the commit-msg and prepare-commit-msg hooks |
tasc reindex |
Regenerate INDEX.md |
tasc sync |
Push to Jira Cloud (one-way); --check compares first |
Every read command accepts --json. Run tasc --help for all flags.
Point your agent at the CLI instead of at your memory. Add this to
AGENTS.md, CLAUDE.md, .cursor/rules/ or your prompt of choice:
Before writing code, run `tasc next --json` and work only on the task it
returns. Mark it with `tasc mark <id> in_progress` before you start and
`tasc done <id> --note "..."` when it is finished. Never invent a task id.Because selection is deterministic, two agents — or the same agent on Monday and
Thursday — reach the same conclusion from the same repository state. See
docs/ai-agents.md for the full JSON shapes.
Determinism has a sharp edge: agents asking at the same moment all get the same
task, because another agent's in_progress is invisible until it pushes. Split
the backlog instead of locking it.
tasc next --shard 1/3 # three agents, three disjoint slices
tasc next --epic api # one agent per area
tasc next --owner agent-a # unassigned work plus your own, never a rival's--shard i/n partitions by a CRC32 of the task id, so the slices are disjoint,
cover everything, and stay identical on every machine and every run — no
coordination needed at all. Owners are explicit instead:
tasc mark api-004 in_progress --owner agent-a # claim it
tasc list --owner none # what nobody has takenClaiming a task somebody else owns fails rather than silently stealing it.
The same split works for people. Since open tasks are one file per epic, teams on different epics touch different files and git has nothing to merge. Each person declares a lane once instead of repeating flags:
export TASC_OWNER=olena
export TASC_EPIC=bi # also TASC_SHARD, for agentsAn explicit flag wins over the environment, tasc next prints which filters it
applied, and tasc list ignores them so one command always shows everything. For
why this stops at git and does not become a server, see
docs/parallel-agents.md.
A backlog nobody is required to use describes intentions, not work. Two layers turn it into the actual entry point, and neither asks anyone to remember a rule.
Locally, a commit-msg hook rejects a commit that names no task:
tasc install-hook # writes commit-msg and prepare-commit-msg$ git commit -m "fix the thing"
No valid task reference:
- no task reference found — mention a task id such as 'api-004'The check is not a regex. It resolves the id against the backlog, so a
fabricated api-999 fails too — which is the failure mode of an agent that
needed an id and made one up. Text that merely looks like an id (utf-8,
sha-256) is ignored, and merges, reverts and fixups pass untouched.
In CI, where --no-verify cannot reach, one reusable workflow is the whole
setup:
name: Tasks
on: [pull_request]
jobs:
gate:
uses: danyapilovets/tasks-as-code/.github/workflows/task-gate.yml@v1.4.0It validates the task files and checks the pull request title — the text that
becomes the commit on a squash merge. Make it a required status check and the
rule holds for everyone, including agents. Add with: check-commits: true to
demand a reference in every commit, or with: require-status: in_progress done
to demand the task was actually started.
If you use pre-commit, this repository ships the hooks:
repos:
- repo: https://github.com/danyapilovets/tasks-as-code
rev: v1.4.0
hooks:
- id: tasc-stamp # needs: pre-commit install --hook-type prepare-commit-msg
- id: tasc-check-ref # needs: pre-commit install --hook-type commit-msg
- id: tasc-validate[skip-task] in the message bypasses the check for a genuine one-off. That
escape hatch is deliberate: a gate with no way out is a gate people disable.
docs/enforcement.md covers rollout on an existing
repository and what to do about bots.
A tracker links a commit to an issue by finding the issue key in the message, and
by nothing else. The key is also the one part of a task nobody remembers, so it is
filled in rather than typed: tasc sync writes the key onto the task, and the
prepare-commit-msg hook puts it into the subject line.
$ git commit -m "api-004: retry on timeout"
Stamped AI-42 onto the message
$ git log -1 --format=%s
AI-42 retry on timeoutThe message you write names the task the way you already do; the id is replaced by the key, because carrying both says the same thing twice. Where the message names no task, the single task in progress is used — two of them is not guessed at. A task with no issue yet is reported and left alone, and so is a message that already names its issue.
refs.subject_format sets the shape, and the whole point is that the tracker only
needs the key somewhere in the message:
refs:
subject_format: "({key}) - {subject}" # (AI-42) - retry on timeouttasc check-ref accepts either form, so the gate holds whichever one a message
takes. Both hooks and CI need the sync to have run: the key lives in the task file,
which is what makes writing a commit message an offline operation.
Whatever else you gate on, these three pay for themselves:
- run: tasc validate # fails on duplicate ids or unknown dependencies
- run: tasc stale # fails on work abandoned mid-flight
- run: tasc reindex # regenerate the index; publish it if you want a view.tasc.yaml, written by tasc init:
project_name: Your Project
tasks_dir: tasks
done_dir: null # defaults to <tasks_dir>/done
stale_after_days: 7
require_note: false # true refuses `tasc done` without --note
refs:
skip_markers: ["[skip-task]"] # message containing this bypasses check-ref
require_status: null # e.g. [in_progress, done]; null accepts any status
subject_format: "{key} {subject}" # shape `tasc stamp` writes, e.g. "({key}) - {subject}"
jira:
label_prefix: tasc
status_map:
todo: To Do
in_progress: In Progress
blocked: To Do
done: Done
status_accept:
done: [Archive] # weekly review; do not pull Archive back to Done
leave_statuses: [Archive] # nor back to In Progress if YAML is still open
type_map: {} # e.g. Task: Задача
priority_map: {} # e.g. High: Высокий
force_assignee: false # reapply the assignee on update, not only on create
comment_on_done: true # post the note from `tasc done --note`
link_dependencies: true # depends_on becomes an issue link
dependency_link_type: Blocks
epic_as_parent: false # also give tasks a parent epic in Jira
epic_type: EpicJira status, type and priority names differ per project and per language, which
is why they are configuration rather than constants. tasc sync --check compares
them against the project before anything is sent. See
docs/jira-sync.md.
done_dir lets a repository that already keeps release notes somewhere point at
them, so adopting tasc needs no git mv:
tasc init --tasks-dir tasks --done-dir docs/changelogThis is a crowded, healthy space. Pick the one that matches how you work:
- Backlog.md — Markdown tasks with a terminal and web Kanban board, plus MCP integration.
- Taskrail — Go CLI built around a single authoritative state file and first-class verification steps.
tasks-as-code is narrower on purpose: YAML tasks grouped by epic, deterministic
selection, JSON output, and one-way Jira sync. No board, no server, no daemon.
Version 1.3.0: 99% coverage, linted with ruff, exercised on Linux, macOS and Windows across Python 3.10 to 3.14.
Three things are covered by semantic versioning, because they are what you and your agents build against:
- the CLI surface — command names, flags and exit codes,
- the YAML task format, including how values are normalised,
- the
--jsonoutput shapes. New fields may appear; existing ones will not be renamed or removed without a major version.
Console output for humans is not a contract. Changes are recorded in CHANGELOG.md.
Issues and pull requests are welcome. Start with CONTRIBUTING.md; please also read the Code of Conduct.
Dual-licensed under either MIT or Apache-2.0, at your option.