@nickmeriano/task
v0.7.1
Published
A task manager that lives in your repo — plain-text tickets in .task/, a zero-dependency CLI for humans and agents, and a live kanban/table UI via `task serve`.
Maintainers
Readme
@nickmeriano/task
A task manager that lives in your repo.
Tasks and their status are coupled to the code — so keep them next to it.
State is plain text in .task/ at your project root — one markdown file per
ticket and per comment: no accounts, no connectors, no drift between the board
and the branch. Ticket changes show up as readable diffs in PRs, and two
branches editing different tickets merge cleanly. Built for the way projects
work now — you and your AI agents share one backlog, and the board updates
live while agents move tickets from another terminal.
npx @nickmeriano/task initinit creates .task/ — commit it, that's the point.
Teach your agent
The package ships an agent skill at skill/SKILL.md with the conventions that
make a shared board work: check it before starting, move tasks as work actually
progresses, comment at milestones, file what you discover instead of letting it
evaporate.
npm i -D @nickmeriano/task # pnpm add -D / yarn add -D all work
npx skills add ./node_modules/@nickmeriano/task/skillA local path rather than a URL, on purpose: you get the skill that your
installed version ships, it works offline, and nothing is fetched from a third
party. skills is one manager among several — any other
works the same way, pointed at that directory.
It deliberately isn't installed for you. Where skills live differs by agent and by repo layout, and a task manager guessing at that puts a copy somewhere nothing reads — which is exactly what happened the first time this tried.
Use
task add "Wire up webhook retries" --tags api --milestone launch
task list # open tasks, board order
task list --needs-human # what's blocked on a person
task start 1 # → in_progress
task done 1 # → done
task link 2 --blocked-by 1 # dependency, visible from both tasks
task update 1 --pr https://github.com/you/repo/pull/42
task comment 1 "shipped in #42" --author claude
task boards # every board in the repo, with prefixes
task archive --all # move finished tickets to .task/archive/
task serve # opens the kanban + table UI, live
task publish # same board, at a URL you can open on a phoneClaiming — so two workers never pick up the same ticket
When agents (or agents and you) work one backlog, picking up a ticket needs
a lock. task claim is that lock, built out of things git already guarantees:
task claim TAS-21 # claim it: branch + status flip + push
task list --claimable # the queue: what a worker may pick up next
task claim --release TAS-21 # abandon a claim cleanly- The claim is the work branch.
task claim TAS-21branchestask/claim/tas-21off origin's default branch, flips the ticket toin_progressas the branch's first commit, and pushes. The branch existing on origin is the claim — nothing side-band to clean up, because the branch was needed anyway and dies at merge. - The namespace is yours.
task/claim/is only the default —"claimPrefix"in.task/config.jsonmoves the whole namespace, and it lives in the committed config on purpose: every worker and every clone must agree on what "claimed" looks like, or there is no lock. If your workers are Claude Code cloud sessions, set"claimPrefix": "claude/task/"— that runtime can pushclaude/-prefixed branches without extra ceremony. Pick the value before the first claim; renaming later strands in-flight claims. - Atomic by construction. The push only succeeds if the branch doesn't
exist yet (a compare-and-swap on the ref), so two concurrent claimers of the
same ticket get exactly one winner. Exit codes are the contract:
0claimed,1already claimed (pick the next ticket),2not claimable — nottodo, blocked,--needs-human, or a dirty working tree. - The lifecycle rides the branch. Claim =
in_progress; before the PR is marked ready,task done+task update --pron the branch; the merge lands code anddonetogether, and the branch auto-deletes. Between claim and merge the default branch still saystodo— the branch is the truth about in-flight work, andgit ls-remote origin 'task/claim/*'(or your configured namespace) lists all of it. - Claim before you work — humans too. The same verb covers manual work:
run
task claim <id>before starting a ticket yourself and any scheduled agent will skip it.task list --claimableshowstodotickets in position order minus blocked, needs-human, and already-claimed — the top entry is what an autonomous worker takes next, which makes column order your priority queue.
From anywhere
task serve is localhost, which is the right answer right up until you're not
at that machine. task publish gives the same board a URL:
task publish # private — only people who can see the repo on GitHub
task publish --public # anyone with the link
task unpublish # take the URL down againIt opens a browser once to install a GitHub App on that repository, and prints
task.nickmeriano.com/<owner>/<repo>. After that it's just a URL — commit and
push, and the board updates, including when an agent in a cloud session is the
one committing.
- Read-only, by construction. The App asks GitHub for
Contents: Read-only, so the hosted board cannot write to your repo even if it wanted to. Editing still happens where it always did: the CLI, an agent, ortask serve. - Private by default — including for public repos. Your code being open
source doesn't mean you meant to publish a live feed of what you're working on
next.
--publicis opt-in, and re-running with the other flag flips it. - Nothing is copied. Tasks are read from
.task/on your default branch, per request. The URL is a window onto your repo, not a second database — andtask publishwrites nothing to.task/, so there is no state to commit and no token in your repo. - No new account. You sign in with GitHub, and who can see the board is whoever can see the repository — there are no seats, invites or member settings, because there's no permission model separate from GitHub's.
- Reversible.
task unpublishremoves the board and the URL 404s immediately; your tasks are untouched, because they were only ever read from.task/. Changing your mind about visibility doesn't need that at all —task publish --privateis enough. - Any branch. The board renders your default branch, but
?ref=<branch>renders any other — and the header grows a branch switcher when there's more than one. That's PR review for board changes: see the board as the PR would leave it, before merging. Locally none of this is needed —task serveshows your working tree, so previewing a branch isgit checkout.
Since the preview URL is predictable, a small workflow in your repo can comment it on every PR that touches board state — the App itself stays read-only and never writes to your repository:
# .github/workflows/board-preview.yml
name: Board preview
on:
pull_request:
paths: [".task/**", "**/.task/**"]
permissions:
pull-requests: write
jobs:
comment:
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7
with:
script: |
const branch = context.payload.pull_request.head.ref
const url = `https://task.nickmeriano.com/${context.repo.owner}/${context.repo.repo}?ref=${encodeURIComponent(branch)}`
const marker = "<!-- board-preview -->"
const body = `${marker}\n📋 [Preview the board at \`${branch}\`](${url})`
const comments = await github.paginate(github.rest.issues.listComments, { ...context.repo, issue_number: context.issue.number })
const existing = comments.find((c) => c.body?.startsWith(marker))
if (existing) await github.rest.issues.updateComment({ ...context.repo, comment_id: existing.id, body })
else await github.rest.issues.createComment({ ...context.repo, issue_number: context.issue.number, body })- Ids are
PREFIX-12or just12; the prefix is the first three letters of the project name ("phone" →PHO-1), or whatevertask init --prefixsays. - Ids route by prefix: a bare
12means the nearest board, whileTAS-12reaches the TAS board from anywhere in the monorepo. An unknown or ambiguous prefix fails loudly instead of guessing.task boardslists every board — prefix, name, path, open count — with a*on the one commands target from the current directory. task archive <id>moves a done/canceled ticket's directory to.task/archive/— off the board and out of every hot path, so boards stay fast as history accumulates. Archived tickets stay readable (task show,task list --archived), keep their comments, and their numbers stay reserved forever.task archive --allsweeps everything finished;task unarchive <id>puts one back.- Statuses:
backlogtodoin_progressdonecanceled. - A task carries tags, at most one milestone, and a
--needs-humanflag for work an agent can't finish alone.--tag a,bmatches either tag. task link A --blocked-by Bmarks a dependency. It's one relation seen from both ends — B's page says it blocks A — and the board badges A as blocked until B is done or canceled.--pr <url>attaches a pull request to a task (--prsreplaces the list); attached PRs show in the task's rail and as a count on its card. The elegant loop is an agent convention, not a webhook: put the id in the PR title ([TAS-6] …) and runtask update TAS-6 --pr <url>when opening the PR — the shipped skill tells agents to do exactly that.- Every task has Copy link — the icon next to its title, or right-click / long-press any card or row — so handing a ticket to an agent is one paste.
- Comments are attributed from
git config user.namewith no setup — override with--authoror$TASK_AUTHOR, and check withtask whoami. - Every command takes
--json— that's the agent interface. - Commands work from any subdirectory; the CLI walks up to find
.task/the way git finds.git. task serveopens your browser on the board (--no-opendoesn't) and takes the next free port when 4400 is busy, so a server you left running elsewhere can't stop you —--strict-portfails on a taken port instead.task publishworks out the repo from your git remotes; pass--repo owner/nameif the guess is wrong (a fork with two remotes, say).task serveis the one command that also looks down: run it at a monorepo root and it serves every nested board from one server, with a board switcher in the header. Run it inside a package and you get just that board. Every other command stays scoped to the nearest.task/.- Boards from before 0.6 stored their state as a committed SQLite database;
those keep working as-is, and
task migratemoves one onto text files (the database stays on disk as an ignored backup).
How it's built
- Zero dependencies. Storage is markdown files with a tiny frontmatter
block — reviewable in a PR, mergeable by git, editable by hand. Comments are
one file each (append-only merges cleanly), and the
blocks/blocked-byrelation is stored on one side only, so its two views can't disagree. Legacy boards read through Node's built-innode:sqlite(Node ≥ 22.13) — nothing to compile, sonpxstarts in about a second. - Realtime is a file watch.
task serveis onenode:httpprocess: the prebuilt UI, a JSON API, and a server-sent-events stream that pings whenever anything writes to.task/— this UI, another terminal, an agent mid-run. - The UI is deliberately simple. Five fixed statuses, a kanban board with drag and drop, a table, a detail panel. Built on the mojo design system.
Docs: task.nickmeriano.com
