Skip to content

tasks-as-code

A git-native backlog your AI coding agent cannot drift away from.

CI Release Python License

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_progress

Why this exists

An 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. --json gives agents a stable contract rather than scraped console output.
  • Drift is detectable. tasc validate rejects duplicate ids, unknown dependencies and self-dependencies. tasc stale finds 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.

Install

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.

Quickstart

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.

Task format

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-28

Ids 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.

Commands

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.

Using it with an AI agent

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.

Several agents at once

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 taken

Claiming 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 agents

An 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.

Making a task mandatory

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.0

It 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.

Linking commits to the tracker

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 timeout

The 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 timeout

tasc 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

Configuration

.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: Epic

Jira 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/changelog

Related projects

This 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.

Status

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 --json output 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.

Contributing

Issues and pull requests are welcome. Start with CONTRIBUTING.md; please also read the Code of Conduct.

License

Dual-licensed under either MIT or Apache-2.0, at your option.

About

No description, website, or topics provided.

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages