Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

yman

A task tracker that lives next to the code, travels through the project’s own git remote, and never shows up in git branch, your IDE’s branch picker, or CI.

No server, no account, no extra remote. Tasks are plain Markdown and YAML in your repository — but in a namespace ordinary git use never walks into.

$ yman init
$ yman add "Fix login" -p 2 -t auth
added 1  2.1.fix-login
$ yman start 1
1: status todo -> doing
$ yman comment 1 -m "reproduced on staging"
$ yman sync
synced  pulled 0, pushed 3, renumbered 0   refs/yman/local @ 3f2a1c9

Your teammate, in their own clone:

$ yman init
$ yman ls
2  1  doing  Fix login  auth    1

Install

Requires Rust 1.88 or newer (rust-version in Cargo.toml) and a git binary, 2.42 or newer. Nothing checks the git version at run time; CI runs the suite against the git that GitHub’s ubuntu-latest image ships.

cargo install yman-tracker      # from crates.io
cargo install --path .          # from a checkout

The binary is called yman. It must be on your PATH for the optional git hooks to find it.

Shell completions and man pages come from the binary itself, for example:

yman completions bash > ~/.local/share/bash-completion/completions/yman
yman completions zsh  > ~/.zfunc/_yman        # a directory on your $fpath
yman completions fish > ~/.config/fish/completions/yman.fish
yman man --dir ~/.local/share/man/man1

Then, in any repository with an origin you can push to:

yman init

That is the only setup step. It creates .yman/, adds a fetch refspec, the refresh policy and the m.yml merge driver to the repository config, and excludes .yman/ from the code worktree. Everyone else on the project runs the same command and gets the existing tasks.

A repository with no origin gets a local-only tracker: everything except yman sync works, and yman init --remote <url> connects it later. The next sync publishes the tasks you made in the meantime.


Why it stays out of the way

A task tracker inside a repository usually means a branch, and a branch means a CI run, an entry in every branch picker, and a merge conflict waiting to happen.

yman stores the task history under refs/yman/local — a ref outside refs/heads/. Git is perfectly happy to keep commits there; almost nothing goes looking for them:

CommandSees the task history?
git branch -ano
git status, git log, git diffno
your IDE’s branch listno
your CI providerno — refs/tasks/main is not a branch
git log --all, git show-ref, git worktree listyes

.yman/ is a linked git worktree of the same repository whose HEAD is that symbolic ref, so your code worktree keeps a clean git status no matter how many tasks you write.

ThingWhere it lives
Task history, locallyrefs/yman/local
What origin last told usrefs/yman/remote
The ref on the serverrefs/tasks/main — never a branch
Working copy of the tasks.yman/, listed in .git/info/exclude

The setting that matters here is the fetch refspec yman init appends to the repository config:

remote.origin.fetch = +refs/tasks/main:refs/yman/remote

so an ordinary git fetch or git pull brings task commits along at no cost. (init also sets yman.refresh and registers the m.yml merge driver as merge.ymanmeta.*; docs/setup.md lists everything it touches.) Pushing is always explicit — git push origin refs/yman/local:refs/tasks/main. remote.origin.push is never set, so your plain git push keeps doing exactly what it did before.


How tasks are stored

One task is one folder:

.yman/
  config.toml              # committed; shared by everyone on the project
  .gitignore
  .gitattributes           # **/d.md merge=union, **/m.yml merge=ymanmeta
  2.14.fix-login/
    t.md                   # "# Title" plus a free-form Markdown body
    m.yml                  # status, tags, assignee, timestamps, attachments…
    d.md                   # discussion, append-only  (only once commented)
    f/                     # attachments               (only once attached)
      screenshot.png
  done/                    # version 2 only: closed tasks, out of the way
    5.9.old-thing/

The folder name is the data

{priority}.{id}.{slug} is the source of truth for the priority and the id — m.yml does not repeat them. Changing either is a git mv, and yman log <id> follows the rename, so history is never lost to a retitling.

Priority runs 0–9 with 0 highest, which means a plain ls .yman/ already sorts the most urgent work to the top. The slug is the title lowercased with every non-alphanumeric run collapsed to -; Unicode survives intact (1.1.первая-задача).

t.md

# Fix login

Anything you like down here. Headings, code fences, checklists — it is
just Markdown, and it is written back verbatim.

The first # line is the title. A file without one is a broken task: yman ls prints it with a ! marker and the parse error instead of failing, and commands that target it say so.

m.yml

status: doing
tags:
- auth
- bug
assignee: Ivan
created: 2026-09-16T10:00:00Z
updated: 2026-09-16T10:12:30Z
attachments:
- path: f/screenshot.png
  name: screenshot.png
  added: 2026-09-16T10:05:00Z
  by: Ivan
links:
- https://example.com/issues/12
related:
- '5'

Timestamps are RFC 3339, UTC, second precision. Keys yman does not know are dropped when it rewrites the file.

d.md

Append-only, one entry per comment:

## 2026-09-16T10:10:00Z — Ivan

reproduced on staging

.gitattributes marks **/d.md as merge=union, so two people commenting on the same task at the same time merge cleanly instead of conflicting. That holds when one of them retitled or closed the task meanwhile, too: a comment or an attachment written against the old folder name follows the task into the new one.

m.yml gets a merge driver of its own, merge=ymanmeta, which merges the file one field at a time instead of one line at a time: a status change on one clone and a tag change on another no longer collide just because they sit on adjacent lines. Lists go one entry further and merge per entry, so two clones each adding a tag keep both. Two edits to the same field — or the same tag, or the same attachment — still conflict, and land in the file as the usual markers.


Command reference

Creating and reading

yman add <title> [-p 0-9] [-s <status> | --waits-on <id>...] [-t <tag>]...
         [-m <body> | --body-file <path>] [-e]
         [-a <who>] [--link <url>]... [--relate <id>]...
yman add --sections <path|-> [same flags, minus the title, -m, --body-file, -e]

Creates a task and commits it. -t, --link, --relate and --waits-on repeat; --waits-on <id> is --relate <id> plus -s blocked. Relating to an id this clone does not have warns on stderr and commits anyway — another clone may not have synced yet. --body-file - reads the body from stdin. -e opens $VISUAL or $EDITOR on the new t.md first, starting from the -m body when both are given — whatever title you type there wins, and the folder is named after it. --sections plan.md creates one task per # Title section of a markdown file instead, each with the text up to the next heading as its body (a # line inside a code fence does not count) and every other flag applied to each — the steps of a plan in one call, one commit apiece.

yman ls [-s <status>]... [-t <tag>]... [-a] [--assignee <who>] [-p 0-9]
        [--related <id>] [-q <text>] [-n <N>] [-l] [--json]
yman tags [--json]
yman tags rename <old> <new>
yman tags rm <tag> [-f]

Lists tasks sorted by priority, then status order, then id. Tasks in a closed status are hidden unless you pass -a or name that status with -s. -t requires all the tags given and matches case-insensitively. --assignee - means unassigned, --related <id> keeps the tasks that relate to that id, -q is a case-insensitive search over title and body, -n caps the rows after sorting, and -l prints each task’s body indented under its row. Column headers appear only when stdout is a terminal, so yman ls | grep stays predictable. yman tags is the inventory of the tags in use, one row per tag with the number of tasks carrying it — closed tasks included, since the question is what exists rather than what is open. yman tags rename and yman tags rm edit that vocabulary across every task carrying the tag, in one commit; rm confirms first, like yman rm. --json prints one object per task — including its links and related ids — plus {dir, error} for anything broken; -l adds each task’s body.

P  ID  STATUS  TITLE       TAGS      F  C
2  1   doing   Fix login   auth,bug  1  3
5  2   todo    Write docs

F is the attachment count, C the comment count; both blank at zero.

yman show <id> [-n N] [--json]   # everything about one task; -n keeps the last N comments
yman plan <id> [-a] [-n N] [-l] [--json]   # a task and the tasks relating to it
yman path <id>     # just the absolute path:  cd $(yman path 14)
yman log [<id>] [-n N]

yman show lists the task’s own related ids and, as related by:, the tasks that relate to it (related_by in --json), so an epic sees its steps without being edited. yman plan <id> reads a whole plan at once: the task, a count per status over every task relating to it, and the open ones sorted like ls, a blocked one followed by waits on <ids> — what it relates to that is still open, its epic (tag epic) aside. yman log <id> follows the task across every rename it has been through.

Changing things

yman set <id> [--status S] [--priority 0-9] [--title T]
              [--assignee A | --no-assignee]
              [--tag X]... [--untag X]...
              [--link URL]... [--unlink URL]...
              [--relate ID]... [--unrelate ID]... [--waits-on ID]...
              [--body <text> | --body-file <path>] [-m <comment>]
yman start <id>...          # = set --status <start status>
yman done  <id>...          # = set --status <done status>
yman move <id>... <status>  # = set --status <status>
yman cancel <id>...         # = set --status <cancel status>   (version 2)
yman reopen <id>...         # a closed task back to the default status
yman prio  <id>... <0-9>    # = set --priority
                            # every one of these also takes -m <comment>
yman edit  <id>          # $VISUAL, else $EDITOR, else vi (vi only on a terminal)
yman rm    <id> [-f]

List fields keep their insertion order. Adding a value that is already there is not a change and commits nothing; removing one that was never there is quietly accepted. A set that changes nothing prints no changes and leaves the history alone. -m appends a comment in the same commit, so yman done 14 -m "fixed in 3f2a" closes and explains in one step. --body rewrites the description without an editor; --body "" clears it. The verbs take several ids — yman done 14 15 16 — and commit each task on its own, stopping at the first error.

--waits-on <id> marks a dependency in one step: it relates to the task waited on and sets status blocked, which has to be in statuses.list. Closing a task then says what it freed, on stderr — a blocked task left waiting on nothing open gets note: 34 no longer waits on anything open: yman move 34 todo. Nothing moves by itself; tasks tagged epic are containers and never count as something to wait on. Closing an epic with open steps still goes through, but warns: warning: 30 closed with open tasks relating to it: 35, 36.

yman rm asks for confirmation on a terminal and refuses outright without -f when there is no terminal to ask at. It also drops the removed id from every other task’s related list in the same commit, reporting the count on stderr.

related is one-way — relating 14 to 15 says nothing about 15 — and only the related field is maintained: an id written into the text of t.md or d.md is prose, and is never checked or rewritten.

Attachments and discussion

yman attach <id> <file>... [--name <n>] [--force]
yman detach <id> <name>
yman comment <id> [-m <text>] [-e]

--name renames a single file on the way in. --force replaces an attachment that already exists. Files over 5 MiB get a warning, never a refusal. With neither -m nor -e, yman comment reads the comment from stdin until end of file when stdin is not a terminal — so git log -1 | yman comment 14 works, but a caller whose stdin is a pipe it never closes waits forever; typed at a terminal, it fails with empty comment. -e edits a private temp file that is removed afterwards, whether or not the comment was added.

Plumbing

yman init [--remote <url>] [--offline] [--id-scheme random|seq|author]
          [--author <prefix>] [--hooks] [--refresh lazy|manual]
          [--autosync off|push]
yman status [--json]     # where everything stands; never changes anything
yman refresh [--quiet]   # fast-forward onto already-fetched task commits
yman sync [--continue] [--abort] [--no-push]
yman hooks install|remove|status
yman git [--] <args>...  # raw git, run inside .yman
yman guide               # the short manual for scripts and agents; works anywhere
yman completions <shell> # bash, zsh, fish, elvish or powershell; works anywhere
yman man [--dir <dir>]   # the man page in roff, or every page written to <dir>
$ yman status
.yman  refs/yman/local @ 3f2a1c9   (refresh: lazy, hooks: installed)
remote: refs/tasks/main @ 9b8c7d6   ahead 2, behind 1   → run: yman sync
worktree: clean
tasks:   todo 4, doing 1, done 7

yman init sets the tracker up, or joins one another clone already published; its flags and every change it makes are in docs/setup.md.

yman status --json reports the same facts — refs, ahead/behind, the merge state and the per-status counts — as one object, for a script that would otherwise parse those four lines.

Exit codes

CodeMeaning
0success
1error — the message says what happened
2usage error, from the argument parser
3a sync merge is waiting to be resolved
4no task has that id

Errors go to stderr prefixed error: , warnings warning: , notes note: . Everything a script would want to read goes to stdout.

Scripts and agents

yman guide prints docs/agents.md, a one-page manual for a reader that pays per token: which calls to make, what ls columns mean, the exit codes, and how to recover from a stuck sync. It starts with a block to paste into a project’s CLAUDE.md. The short version: filter with ls -n, skim bodies with ls -l -n, read with show -n, close with done <id> -m, run yman sync before a status change and after it (the after is automatic under yman.autosync = push), never open an editor, always give comment its text with -m (without it the comment is read from stdin until end of file, which blocks on a pipe left open), and set YMAN_ACTOR so the work is attributed to the agent. Big work is a plan: an epic task plus steps that --relate it, read from the epic with yman plan <id> (progress and open steps in one call, enough to pick the next step without a show per step) and created in one go with add --sections plan.md --relate <id>, so the epic is never edited; a step that waits sits in blocked and relates to what it waits on.

For a harness that loads skills, skills/yman/ is the fuller version of the same rules as a Claude Code skill — the task-list procedure, the commands never to run and why, and recovery from each exit-3 case — so it arrives without anyone pasting anything. Copy the directory into any project that tracks its work with yman; it assumes nothing about where the yman source tree is. cargo install puts only the binary on PATH, but the crate ships the skill, unpacked under the registry cache:

src=$(ls -d ~/.cargo/registry/src/*/yman-tracker-$(yman --version | cut -d' ' -f2)/skills/yman)
cp -r "$src" .claude/skills/        # this project
cp -r "$src" ~/.claude/skills/      # every project

skills/yman-task/ sits next to it and installs the same way: /yman-task <text> files the user’s own words as tasks tagged user-request and needs-agent, with the original quoted, and leaves them unassigned so the next agent re-checks each claim against the code before claiming it.

Setting a tracker up from scratch is docs/setup.md.


Configuration

.yman/config.toml is committed, so a project agrees on these once:

version = 1

[ids]
scheme = "seq"        # "random" | "seq" | "author"
random_len = 4        # hex chars, random scheme only

[statuses]
list = ["todo", "doing", "done"]
default = "todo"

[priorities]
default = 5           # 0..=9

[slug]
max_bytes = 200       # hard cap 240

statuses.list is yours to change — the order is meaningful. yman start moves a task to the second entry, yman done to the last, and yman ls hides the last one by default. A status that is no longer in the list never breaks a task; it is reported, not rejected.

Those last three are derived from position, which means inserting a status quietly changes what yman start does. At version = 2 you can name them instead, and say which statuses count as closed:

version = 2

[statuses]
list = ["todo", "doing", "blocked", "done", "cancelled"]
default = "todo"
start = "doing"
done = "done"
cancel = "cancelled"
terminal = ["done", "cancelled"]

ls then hides every terminal status, not just the last one. All four keys are optional; leaving them out reproduces the positional behaviour exactly.

Version 2 also files closed tasks one directory down, at .yman/done/5.1.fix-login/, so the top level only ever holds open work. yman done moves the folder there in the same commit as the status change, and setting the task back to an open status moves it out again and takes the emptied directory with it. m.yml stays the source of truth: a task in the wrong directory is still listed correctly, and the next set puts it where it belongs.

Raising the version is a hand edit. Existing closed tasks are not moved for you — yman done <id> on each one does it, and reports folder <old> -> <new>.

Two people closing the same task to different statuses is the one case that gets harder: git keeps both folders, and yman sync names the pair and asks you to delete one. yman sync --continue refuses until you do.

Choosing an id scheme

Pick this at yman init; it is stored in config.toml and cannot be changed per-clone afterwards.

SchemeIds look likeBest when
seq (default)1, 2, 14Small team, short ids worth typing
authoriv-1, an-3Several people adding tasks offline a lot
randomt-7f3aIds must never collide, at any cost

seq and author can mint the same id on two machines at once; yman sync sorts that out (see below). random cannot collide in practice, at the price of ids nobody remembers.

The author prefix comes from git config yman.author, else $YMAN_AUTHOR, else the initials of your user.name.

Repository-local settings

KeyValuesSet by
yman.refreshlazy (default), manualyman init --refresh
yman.autosyncoff (default), pushyman init --autosync
yman.authorletters, digits, _ or -yman init --author

Two environment variables, easy to confuse:

VariableUsed for
YMAN_AUTHORfallback id prefix for the author scheme, after yman.author
YMAN_ACTORdisplay name on comments and attachments, before user.name

YMAN_ACTOR is meant for an agent or a script working under a person’s git account. The git committer is never changed by it.


Sharing work: sync, refresh, hooks

yman sync is the command that talks to the network (yman init does too, once, unless given --offline, and so does every change under yman.autosync = push, below). It snapshots any uncommitted edits in .yman, fetches, merges, and pushes:

$ yman sync
snapshotted 1 local change(s)
synced  pulled 3, pushed 2, renumbered 0   refs/yman/local @ 3f2a1c9

Publishing your own changes can happen without a sync too. With yman init --autosync push (stored as yman.autosync), every command that changes a task pushes it to origin once it has committed, and says so on stderr with note: pushed N task commit(s). It only pushes: when someone else pushed first it warns origin has new task commits; run: yman sync and leaves the merge to you, and when the network is down it warns and the command still succeeds. This is the setting to turn on when agents work the backlog, since they close tasks and rarely remember to sync.

Getting other people’s tasks does not need a sync at all, because they arrive with any ordinary fetch:

yman.refreshWhat happens
lazy (default)Every read or write command fast-forwards .yman onto whatever refs/yman/remote already holds
manualOnly yman refresh and yman sync move .yman

A refresh is always a fast-forward, never touches the network, and backs off silently when .yman has uncommitted changes or local commits you have not pushed. yman refresh and yman status explain why when it does.

yman hooks install adds post-merge and post-checkout hooks that run yman refresh --quiet, so a plain git pull updates your tasks as a side effect. Hooks somebody else wrote are reported and left alone, never overwritten or deleted — yman tells you the one line to add instead; a hook it cannot read is refused the same way. core.hooksPath is honoured, ~/ included.


When two people collide

Same id, two machines

With seq or author, two people working offline will both mint id 2. On sync, whoever is behind renumbers their own new task, because the other side’s id is already published:

$ yman sync
renumbered 2 -> 3  (id taken on origin)
note: rewrote 1 reference(s) to renumbered ids
synced  pulled 1, pushed 3, renumbered 1   refs/yman/local @ 1f7d06f

References to the old id in other tasks’ related lists follow the move, in the same commit as the rename.

Same field, two edits

Two people changing the status of the same task conflict like any other git conflict. Sync stops with exit code 3 and tells you which files:

$ yman sync
  5.1.fix-login/m.yml
error: conflicts in 1 file(s); edit them, remove markers, then: yman sync --continue  (or: yman sync --abort)

Edit the files and remove the markers — staging is yman’s job, not yours — then:

yman sync --continue     # or: yman sync --abort

The one exception is a file sync lists as (no conflict markers: …): a binary attachment, an edit to what the other side deleted, or an m.yml whose merge driver could not run. There is nothing to edit, so pick a side with yman git checkout --ours|--theirs -- <file> and yman git add <file> before continuing.

--continue refuses while any conflict marker is still in place, and checks that every task the merge touched still parses before it commits. Until the merge is settled, commands that would change a task exit 3 rather than build on a half-merged state. yman status shows the same information at any time.

Comments never conflict: merge=union keeps both sides. Edits to different fields of one task’s m.yml do not conflict either, and neither do two clones adding different tags: merge=ymanmeta merges that file field by field, and its lists entry by entry.

The exception is closing one task to two different statuses at version 2. Each side moves the folder somewhere else, git keeps both, and yman sync says so:

$ yman sync
note: task 1 was closed to two different statuses; keep one of cancelled/5.1.fix-login, done/5.1.fix-login
error: conflicts in 3 file(s); edit them, remove markers, then: yman sync --continue

$ rm -rf .yman/cancelled/5.1.fix-login
$ yman sync --continue

--continue refuses while both folders are there, so the tracker cannot end up with two folders for one id.


Troubleshooting

git clean -fdx deleted .yman/. Nothing is lost — the history lives in refs/yman/local, which is not in the worktree. Run yman init again and everything comes back, including tasks you had never pushed.

.yman is not initialized; run: yman init. Either this clone has never run init, or .yman/ was removed. Same fix.

refs/yman/local is already checked out in another worktree. One .yman per clone is supported. Remove the other worktree, or use a separate clone.

git identity missing. yman commits like any other tool; set user.name and user.email.

yman path prints a different path than it used to. At version 2 a closed task lives in .yman/<status>/. Shell bookmarks to the old location break; yman path <id> always knows where it is now.

A task shows as broken. Its t.md lost its # Title, or its m.yml will not parse — usually a hand-edit or a merge resolved carelessly. yman ls shows the error; fix the file and the task comes back. Nothing else is affected.

Your tasks are not on origin. Nothing ran yman sync: yman status shows ahead N. Run yman sync, or yman init --autosync push so every change publishes itself.

Tasks are not appearing after someone else pushed. You have not fetched (yman refresh never uses the network on its own), or yman.refresh is manual, or your .yman has uncommitted changes. yman status says which.


Limits and future work

  • A merge can reorder a task’s tags, links or related relative to the order they were typed in: entries new on either side are appended sorted by key, because that is the only ordering both clones agree on.
  • Attachments go straight into git; there is no git-lfs integration.
  • add slows as the project ages. An id is never reused, which means reading every id the history ever assigned before minting one. Measured with scripts/synthetic-project.sh on a Ryzen 7 4700U, git 2.55.0, release build: 32 ms on an empty tracker, 157 ms at 1000 tasks over 1081 commits — a linear 18 ms + 0.13 ms per existing task.
  • A ref is resolved by reading its file, not by spawning git; only a layout src/refs.rs does not recognise falls back to rev-parse. On that same repository yman ls costs 1 process and 18 ms, yman show 1 and 6 ms, yman add 4. A clone holding unpushed commits pays one more, for the ancestry question no file answers.
  • yman log <id> finds a task’s history by a pathspec on its id, so a task with fewer commits than -n still walks the whole history: 125 ms for a 17-commit task on the same repository, down from 400 ms when it rebuilt the rename graph. There is no index to make that cheaper.
  • One .yman worktree per clone.
  • Windows is untested. Deep task paths may need core.longpaths.
  • No colour, no TUI, no web UI, no GitHub Issues bridge.

Development

cargo test                                  # unit tests + the two-clone suite
cargo clippy --all-targets -- -D warnings
sh scripts/spike-symref.sh                  # the git invariant this rests on
sh scripts/synthetic-project.sh             # a 1000-task repository, driven (~3 min)
sh scripts/book.sh                          # the documentation site, into target/book

The integration suite builds a bare remote and two clones in a temp directory and drives both through init, add, sync, id collisions, conflicts and hooks. Nothing touches the network or your real git configuration.

scripts/synthetic-project.sh covers what a suite of short-lived fixtures cannot: it builds four clones and a thousand tasks over a couple of thousand commits, churns them, closes them, crosses them in and out of the archive directories, and then reports what add, ls and log cost on the result. The repository is regenerated rather than committed — it lands in target/synthetic, is kept so you can cd in and poke at it, and is replaced by the next --force run. Run it before a release; it is deliberately not part of CI.

skills/yman/evals/run.sh tests the agent-facing side: it hands a headless agent one prompt per case against a throwaway tracker, then grades the repo it leaves behind and the commands it reached for — so a run catches the skill drifting from the CLI, not just the CLI breaking. Its ten cases run from claiming and closing a task to plan, --waits-on, add --sections, the task-list procedure and recovering from an exit-3 merge. It spends real tokens, so it is not part of CI either; see skills/yman/evals/.

Layout:

PathWhat it holds
src/git.rsthe git runner; every invocation goes through it
src/repo.rsrepository discovery, .yman state, preflight checks
src/task.rsfolder names, slugs, t.md/m.yml, load and save
src/yml.rsthe m.yml reader and writer, hand-written
src/ids.rsid generation and what counts as taken
src/refresh.rsthe fast-forward policy
src/commands/one module per subcommand
scripts/spike-symref.shchecks the git behaviour the .yman worktree depends on
scripts/synthetic-project.shbuilds a large repository and measures what commands cost on it
scripts/book.sh, book.toml, docs/SUMMARY.mdthe documentation site
skills/yman/the agent skill, and the evals that keep it honest
skills/yman-task/the skill that files the user’s words as unverified tasks
docs/the normative specification

docs/ holds the normative, as-built specification — storage.md for the ref and on-disk contract, commands.md for per-command semantics, errors.md for exit codes and the pinned messages, and setup.md for getting from no .yman to a working tracker — plus agents.md, the short manual yman guide prints. They describe what the code does, so a disagreement between them and the code is a bug in one or the other.

The same files are published as a documentation site at https://brillliand.github.io/yman-tracker/. scripts/book.sh copies them under target/book-src, rewrites the links that only work in the repository layout, builds with mdBook and fails on any local link or anchor that leads nowhere. It needs mdbook on PATH (cargo install mdbook, or a release binary); mdbook serve previews the result after a build, though edits to the sources need the script run again. .github/workflows/ci.yml runs it as part of the gate, and .github/workflows/pages.yml builds it on every push and deploys it from main. Adding a page means adding it to docs/SUMMARY.md; the script fails when a docs/*.md is missing from it. docs/SUMMARY.md is mdBook’s table of contents rather than a page: its links to README.md, skill.md and CHANGELOG.md name the copies the script makes under target/book-src, so they do not resolve when GitHub renders the file.

Releasing

Versions follow semver and are tagged vX.Y.Z. CHANGELOG.md collects user-visible changes under [Unreleased] as they land; a release freezes that section. To cut X.Y.Z:

  1. Branch release/X.Y.Z from main.
  2. In CHANGELOG.md, rename [Unreleased] to [X.Y.Z] - YYYY-MM-DD, open a fresh empty [Unreleased] above it, and update the compare links at the bottom.
  3. Bump version in Cargo.toml; cargo build updates Cargo.lock.
  4. Run the full gate (cargo fmt --check, clippy, cargo test, scripts/spike-symref.sh, scripts/book.sh) and scripts/synthetic-project.sh.
  5. Commit as chore: release X.Y.Z and merge into main with --no-ff.
  6. Tag the merge: git tag -a vX.Y.Z -m "yman X.Y.Z", then push main and the tag.
  7. cargo publish --dry-run, then cargo publish. A published version can be yanked but never replaced, so the dry run is the last look.

The package on crates.io is yman-tracker and the binary yman. exclude in Cargo.toml keeps the agent configuration, the CI workflow and the skill evals out of the tarball; cargo package --list shows what ships.


License

MIT. See LICENSE.

Setup

Normative, as-built. Getting from “no .yman” to a tracker that reads, writes and syncs. Companion documents: commands.md for per-command semantics, storage.md for the ref and on-disk contract, errors.md for the pinned messages.

1. Prerequisites

Git 2.42 or newer; yman does not check the version, and the test suite runs against the git of CI’s ubuntu-latest image. Building needs Rust 1.88 (rust-version in Cargo.toml).

yman runs git as a subprocess; without it, every command fails with git not found in PATH except guide, completions and man, which are answered before any git process runs. The current directory must be inside the project’s git repository — any subdirectory will do, including a task folder inside .yman (cd $(yman path 14)), which resolves to the project around it — or the command fails with not inside a git repository. There is no -C <dir> flag and no global configuration file outside the repository.

Until .yman exists, every command except init, hooks, git, guide, completions and man fails with .yman is not initialized; run: yman init (commands.md §1; merge-driver is exempt too, but git calls that, not people).

2. yman init

yman init                     # uses "origin"; with none, the tracker stays local
yman init --remote <url>      # no origin yet: create one pointing there
yman init --offline           # no network at all
FlagEffect
--id-scheme <random|seq|author>how ids are minted; default seq. Stored in config.toml, so it is only read when the history is being created — see below
--author <PFX>prefix for the author scheme; stored as git config yman.author
--remote <URL>create origin from this URL when the repo has none
--offlinedo not fetch and do not push
--hooksalso run hooks install
--refresh <lazy|manual>the refresh policy, stored as git config yman.refresh; default lazy
--autosync <off|push>push after every change, stored as git config yman.autosync; unset means off

Remote resolution happens first. With both an origin and --remote, the existing origin wins. --remote is ignored, with a warning when its URL differs from origin’s and silently when it is the same. With neither, init sets up a local-only tracker and says so on stderr: note: no "origin" remote; tasks stay local until you run: yman init --remote <url>. It behaves as if --offline were given, and adds no fetch refspec, since there is no remote.origin.fetch to add it to. Every command except sync works as usual (sync --abort still runs, since it needs no remote); status reports remote: none (no "origin"; tasks are local only).

To connect a local-only tracker later, run yman init --remote <url>. It takes the repair path below, creates origin and the refspec, and the next yman sync publishes the local history. That works when the remote has no refs/tasks/main yet. If another clone already published a tracker there, the two histories are unrelated and sync refuses with the re-init message in errors.md. Adding origin by hand with git remote add works as well, though a plain git fetch will not carry task commits until yman init has added the refspec.

--offline means no network, so init --offline cannot see that origin may already hold a tracker. In a clone whose origin has refs/tasks/main, it creates a second, unrelated history, and the first yman sync refuses with the re-init message in errors.md. Recover the way that message says, rm -rf .yman && git update-ref -d refs/yman/local && yman init, before adding tasks; tasks already added to the offline history have to be re-added by hand. Use --offline only for a tracker that starts here.

init is idempotent. Run against an existing .yman worktree it takes the repair path — re-adds the exclude entry and the fetch refspec, re-registers the merge driver, applies --refresh, --autosync, --author and --hooks if given — and reports already initialized .yman. When .yman’s HEAD is no longer the symbolic ref refs/yman/local (the state preflight answers with run: yman init), the repair path puts it back first and says note: re-attached .yman HEAD to refs/yman/local — but only when nothing is lost: HEAD on the ref’s commit, or clean and on an ancestor of it, which is checked out forward. A HEAD with commits or edits the ref lacks is refused with .yman HEAD has commits or changes not on refs/yman/local; resolve them with yman git, then rerun: yman init, because attaching it would let the next snapshot revert the ref. A .yman that is a plain directory or a standalone repository is never touched; the command says so and stops.

3. What init changes

In the main repo, all local and none of it committed:

  • .yman/ added to COMMON/info/exclude;
  • the fetch refspec +refs/tasks/main:refs/yman/remote appended to remote.origin.fetch, when there is an origin;
  • yman.refresh, yman.autosync when --autosync was given, and yman.author when --author was given;
  • merge.ymanmeta.name and merge.ymanmeta.driver, pointing at the running binary by absolute path. This pairs with the **/m.yml merge=ymanmeta line in .gitattributes, which travels in the history while the config does not — which is why every clone must run init even when the history already exists.

In .yman — only when the history is being created — config.toml, .gitignore and .gitattributes, committed as yman: init (<scheme>) on top of an empty root commit yman: init, and pushed unless the run is --offline or the tracker is local-only. If that push loses the race to another clone creating the tracker at the same time (the reasons are those of sync, commands.md step 8), init refetches and adopts the other history with warning: remote already had tasks; adopted remote state. Should the refetch find no refs/tasks/main after all, there is nothing to adopt: it pushes again, at most three attempts in all. Giving up after those, any other push failure, or a refetch after a lost race that fails (for the last two git’s stderr is printed first), does not fail init: the tracker exists locally, so --hooks and the summary still run, then warning: not published to origin (<why>); run: yman sync follows on stderr, <why> being origin keeps moving, push failed or fetch failed, and the exit code is 0.

4. There is no clone command

.yman is a linked worktree of the project repo, not a second repository, so cloning the project does not bring the tasks with it. init covers both cases:

  • refs/yman/local already exists → keep it;
  • otherwise refs/tasks/main was fetched from origin → adopt it (update-ref refs/yman/local refs/yman/remote). This is the clone path;
  • otherwise create an empty root commit and write a fresh tracker.

Because the second case reuses a history that already has an id scheme, --id-scheme is ignored there, with warning: id scheme is "<s>" (from config.toml); --id-scheme ignored. A remote whose refs/tasks/main is not a yman history fails with refs/tasks/main on origin is not a yman history (missing or invalid config.toml); a kept or repaired local history with no loadable config.toml fails the same way, naming refs/yman/local instead.

One .yman per clone. A second attempt — typically init run from a linked worktree of the project, where the toplevel differs — fails with refs/yman/local is already checked out in another worktree of this repo before anything is changed; see storage.md §2.

5. Staying up to date

yman sync is the command that uses the network day to day: fetch, merge, push. The only other one is init, which fetches and pushes unless given --offline — and, when yman.autosync = push, every changing command, which pushes (never fetches or merges) once it has committed (commands.md §1). --no-push stops before publishing; --continue and --abort finish or drop a conflicted merge (commands.md §6, and exit code 3 in errors.md).

yman refresh never uses the network — it fast-forwards .yman onto a refs/yman/remote that some earlier fetch already brought in. The policy decides who calls it (commands.md §5):

yman.refreshEffect
lazy (default)every non-exempt command fast-forwards first; a failure is a warning:, never fatal
manualonly yman refresh and yman sync move .yman

yman hooks install (or init --hooks) writes post-merge and post-checkout hooks that run yman refresh --quiet, so an ordinary git pull of the project also picks up the tasks it fetched. hooks status reports installed / foreign / absent, and a hook yman did not write is never overwritten.

6. Identity

VariableEffect
YMAN_ACTORthe name recorded as a comment’s author and an attachment’s by. Empty or unset falls back to git config user.name, then unknown. It does not change the git committer
YMAN_AUTHORprefix for the author id scheme when yman.author is unset
VISUAL, EDITORthe editor edit, add -e and comment -e open. Scripts and agents should use -m or --body-file instead and never set these

7. Checking the result

yman status      # ref heads, ahead/behind, refresh policy, hooks, task counts
yman ls          # the tasks themselves

status changes nothing and refreshes nothing, so it is the safe first call on a tracker in an unknown state — including one with an unresolved merge, which it names file by file.

yman for scripts and agents: the short manual, for a reader that pays per token

Paste into the project’s CLAUDE.md

yman ls -n 10 --assignee -                        # pick: unassigned, best priority first
yman show <id> -n 3                               # read: header, body, last 3 comments
yman set <id> --status doing -a <me> -m "on it"   # claim: status, assignee, note, one commit
yman done <id> -m "what changed" && yman sync     # close, explain, publish
yman add "Title" -m "body" -a <me> --relate <id>  # follow-up, linked to its parent
`yman sync` before a status change (re-`show`; stop if taken) and after (unless `autosync: push`).
Never `edit` or `-e` (opens an editor); change a body with `yman set <id> --body "..."`.
`rm` and `tags rm` need `-f` off a terminal, or refuse: `refusing to remove without -f`.
Task list asked for: `yman ls -l -n 20`, render as a table (P ID Status Title Tags),
`show <id> -n 0` the best 1-3 (it has the assignee; `ls` does not), recommend one.

Reading

ls prints one row per task, header only on a terminal; -l adds the bodies:

P  ID  STATUS  TITLE  TAGS  F  C

P is priority 0 (highest) to 9, F attachments, C comments; zero is blank and trailing empty columns are dropped. Filters AND together, -t and -q comparing lowercase: -s <status> -t <tag> --assignee <who|-> -p <0-9> --related <id> -q <text>; -n <N> caps rows after sorting; closed tasks need -a or -s <closed status>.

show <id> prints a header (related by: = tasks relating to it), the body, then attachments: and discussion:; empty sections omitted, -n <N> keeps the last N. tags lists every tag in use with its task count, closed tasks included. ls, plan, show, status, tags take --json: empty is []/null; ls/plan add body with -l.

Writing

start, done, cancel, reopen, move <id>... <status> and prio <id>... <0-9> take several ids, one commit each, and -m <text> to comment in the same commit. set <id> changes any field: --status --priority --title -a/--assignee --no-assignee --tag/--untag --link/--unlink --relate/--unrelate --body <text> --body-file <path|-> -m <text>. Every mutation is its own commit; nothing to save. comment <id> -m <text> adds a note; without -m it reads stdin to EOF, so pass -m.

Plans

An epic is an ordinary task (tag epic); its steps are add ... --relate <epic>, or all at once add --sections <file|-> --relate <epic>: one per # Title, body below. plan <epic> prints it, a count per status and the open steps sorted like ls (-a all, -n 3 what is next, -l, --json); that picks the next step, no show needed. Order with priority. A step that waits gets --waits-on <blocker> on add or set: relate plus status blocked (needs it in statuses.list); plan shows waits on <ids>. Closing the last blocker prints note: <id> no longer waits on anything open: yman move <id> todo on stderr; run it. Close the epic last, when plan lists none (closing it earlier warns with the open ids).

Streams and exit codes

stdout carries data only. Errors (error: ...), warnings and notes go to stderr.

0  ok
1  error, stderr says what
2  usage error, from the argument parser
3  a sync merge is unresolved
4  no task has that id

On 3: yman status names the unmerged files under .yman/; remove the markers (delete a folder a duplicate task id names; yman git add the side to keep of a file with no markers), then yman sync --continue or --abort; mutating exits 3 till then.

Cost and attribution

Plain output is cheapest; keep --json for jq. Cap with -n (show <id> -n 0 drops the discussion); one verb with -m beats a verb then comment. YMAN_ACTOR=<name> names you on comments and attachments; the git committer is unchanged.

yman for agents

Tasks live in .yman/, a git worktree on a ref outside refs/heads/. Every mutation is its own commit, so there is nothing to save, stage or flush. All commands run from anywhere inside the project’s git repository.

Hot path

yman ls -n 10 --assignee -                        # pick: unassigned, best priority first
yman sync                                         # before any status change: take in other clones' work
yman show <id> -n 3                               # read: header, body, last 3 comments (current after the sync)
yman set <id> --status doing -a <me> -m "on it"   # claim: status, assignee, note, one commit
yman done <id> -m "what changed"                  # close and explain in one call
yman add "Title" -m "body" -a <me> --relate <id>  # follow-up, linked to its parent
yman sync                                         # publish: after the claim, and after the close

Syncing around status changes

Every change is a local commit; nobody else sees it until it reaches origin, and you do not see theirs until you sync.

Before any status change — set --status, start, done, move, cancel, reopen — run yman sync, then re-read the task with yman show <id> -n 0. If somebody else now has it in progress, or it is already closed, stop and say so instead of overwriting their change. This holds even with autosync: push, which only pushes and never pulls.

After claiming a task and after closing it, run yman sync again, batching any changes in between, and never finish with yman status showing ahead N. The exception: when yman status prints autosync: push, each change already pushes itself (note: pushed N task commit(s) on stderr), so there is nothing to run. If that push warns origin has new task commits; run: yman sync, do what it says.

Exit 3 from sync is a merge to resolve, below; resolve it before changing anything. no "origin" remote; tasks are local only means there is nobody to sync with: carry on without it.

When asked for the task list

  1. Run yman ls -l -n 20: open tasks with their bodies, already sorted by priority, status order and id. Raise the cap on a small tracker if the list was cut off; never run it uncapped on a large one.
  2. Show the rows as a markdown table, P | ID | Status | Title | Tags, in the order ls printed them. A ! <dir> broken <error> row goes under the table as-is, not dropped.
  3. Work out what can be taken next. Prefer the best priority; within it, a todo task that is unassigned (or assigned to you) over one somebody else already has in progress. ls rows carry no assignee: take it from the show in step 4, or list the unassigned ones with yman ls --assignee -. Skip a blocked task and one whose body or relations say it waits on another open task. An epic (tag epic) is closed last: offer its open steps, yman plan <id> -n 3, instead — its rows are enough; no show per step.
  4. Read only the top one to three candidates with yman show <id> -n 0, for their “Where”, “Done when” and relations. Do not show every task.
  5. Finish with a short recommendation: which id to take, why, and roughly how big it is.

Reading

ls prints one row per task and runs no git of its own (only the lazy refresh that precedes most commands may):

P  ID  STATUS  TITLE  TAGS  F  C

P is priority, 0 (highest) to 9. F is the attachment count, C the comment count; a zero renders blank, not 0, and trailing empty columns are dropped. The header line appears only when stdout is a terminal, so a piped ls is rows only. An empty result prints nothing at all. A folder yman cannot parse is always listed, as ! <dir> broken <error>.

Filters AND together; -s and -t are repeatable, and -t and -q compare lowercase:

FlagMeaning
-s, --status <STATUS>only these statuses; naming a closed status includes it
-t, --tag <TAG>tasks carrying all the given tags
--assignee <WHO>- means unassigned
-p, --priority <N>0 to 9
--related <ID>tasks whose related list carries ID (the steps of a plan); unknown ID lists nothing
-q, --grep <TEXT>case-insensitive substring of title or body, not a regex
-a, --allinclude closed tasks, hidden by default (or name one with -s)
-n, --limit <N>first N rows, applied after sorting
-l, --longeach task’s body under its row, indented four spaces; body in --json

Sorting is by priority, then status order, then id — so -n keeps the most important rows, not arbitrary ones.

Other readers: show <id> (one id; -n <N> keeps the last N discussion entries, -n 0 drops the discussion entirely; related by: lists the tasks whose related carries this id, closed ones included, related_by in --json; an attachment whose file was deleted outside yman is listed marked (missing), "missing":true in --json), path <id> (prints the absolute folder path and nothing else, so cd "$(yman path 14)" works), log [<id>] (-n, default 20; with an id it follows the task across renames), tags (every tag with its task count, closed tasks included) and status.

--json exists on ls, plan, show, status and tags only. There is no global --json, no -C <dir>, no --verbose. JSON keeps empty keys: empty lists are [], an absent assignee is null. The one exception is body, which ls and plan include only with -l; show always has it.

Writing

add "<title>" takes -p, -s, -t (repeatable), -m <text> for the body, --body-file <path|->, -a <who>, --link <url> and --relate <id> (both repeatable). It prints added <id> <dir>.

set <id> changes any field and needs at least one change flag, or the parser exits 2: --status, --priority, --title (re-slugs the folder), -a/--assignee, --no-assignee, --tag/--untag, --link/--unlink, --relate/--unrelate, --body <text> (empty string clears it) or --body-file <path|->, and -m <text> to comment in the same commit. List fields are sets: adding a tag that is already there, or removing one that is not, is silently a no-op. When nothing actually changed it prints no changes and commits nothing — that is success, not failure.

start, done, cancel, reopen, move <id>... <status> and prio <id>... <0-9> all take several ids and all take -m <text>. Each id is its own commit, processed in order.

comment <id> -m "<text>" appends to the discussion. Always pass -m: with no -m and stdin not a terminal it reads the comment from stdin until end of file, so a shell whose stdin is an open pipe that never closes hangs there. Reading stdin is only safe when it has a definite end, as in yman comment 14 < note.md or git log -1 | yman comment 14. attach <id> <file>... (--name <name> for a single file, --force to overwrite; over 5 MiB warns but never refuses) and detach <id> <name> handle files. tags rename <old> <new> and tags rm <tag> -f rewrite every task carrying a tag in one commit.

Prefer one verb carrying -m over a verb followed by a separate comment: half the calls, half the commits.

Plans

A plan is an ordinary task, the epic, plus steps that relate to it. related is one-way and untyped: direction and status carry the meaning, and plan, ls --related and show’s related by: read it from the epic’s end, so adding a step never edits the epic.

yman add "Ship sync v2" -t epic -p 2 -m "Goal: ... Done when: yman plan <id> lists no steps"
yman add "Fetch before merge" -p 2 --relate 30 -m "Where: ... Done when: ..."   # 30 is the epic
yman add --sections - --relate 30 -p 2 <<'EOF'   # many steps, one task per `# Title`
# Fetch before merge
Where: ... Done when: ...
# Push after merge
Where: ... Done when: ...
EOF
yman plan 30 -n 3              # what is next: counts per status, open steps, best priority first
yman plan 30 -a                # progress: every step with its status
yman show 30 -n 0              # the goal and its body

plan prints the epic, status: <s> steps: <N> todo 2, doing 1, ... (every step, closed included), then one row per open step: P ID STATUS TITLE, plus waits on <ids> on a blocked step. --json has counts and steps[].waits_on. That output is the whole answer to “what is next in this plan”: each open step’s id, priority, status, title and what it waits on are already there, so do not show the steps it lists. show a step only when its body is needed for the work itself, and plan <id> -l prints the bodies too.

  • Order steps with priority. A step that must wait gets --waits-on <id> (on add or set, repeatable): it relates to the step it waits on as well as the epic and sits in status blocked, in one commit. blocked has to be in statuses.list; without it --waits-on fails, so relate with --relate and say Waits on <id> in the body, which the task-list rule already honours.
  • Closing a step reports on stderr what it freed: note: 34 no longer waits on anything open: yman move 34 todo. Run that move (with -m "unblocked"); nothing moves by itself. An epic tag means “container”, so the epic is never counted as something to wait on.
  • Close the epic last, when yman plan <epic> lists no steps, with a done -m that sums up the result. Closing it earlier still succeeds but warns warning: 30 closed with open tasks relating to it: 35, 36; reopen it or finish those. Nest plans by relating an epic to a bigger one.
  • The link is always --relate: a renumber rewrites related and never the prose, so an id in a body is a hint for people, not a relation.

Never do this

Do notWhyInstead
yman edit <id>always opens $VISUAL/$EDITOR/viyman set <id> --body "<text>" or --body-file -
yman add -eopens the editor before the first commityman add "<title>" -m "<body>"
yman comment -eopens the editoryman comment <id> -m "<text>", or yman comment <id> < note.md
yman comment <id> without -mreads stdin until EOF; an open pipe hangsyman comment <id> -m "<text>"
yman rm <id> without -fprompts on a terminalyman rm <id> -f
yman tags rm <tag> without -fprompts on a terminalyman tags rm <tag> -f

Off a terminal, a removal without -f fails fast with refusing to remove without -f, and an editor path with neither $VISUAL nor $EDITOR set fails with no terminal for vi; set $EDITOR, or use -m / --body-file. Both mean “use the non-interactive form”, not “retry”. With $VISUAL or $EDITOR set, the editor path does not fail: that editor is started even without a terminal, and the command may hang waiting on it.

Streams and exit codes

stdout carries data only — listings, show, path, log, status, and the one-line summaries. error:, warning:, note: and pass-through git output go to stderr. There is no colour.

CodeMeaning
0ok
1error; stderr says what
2usage error, from the argument parser, before anything ran
3a sync merge is unresolved
4no task has that id (task <id> not found)

Two consequences worth planning for:

  • While a merge is unresolved, every mutating command exits 3, not just sync. Reading still works.
  • A multi-id verb that exits 4 stopped at the first missing id. The ids before it are already committed; do not re-run the whole list blindly.

Recovering from exit 3

yman status                  # names the unmerged files under .yman/
# edit them, remove the conflict markers
yman sync --continue         # or: yman sync --abort

--continue can also stop with duplicate task id <id>: <a>, <b>; delete one folder, then: yman sync --continue (still exit 3): both sides closed the same task, to two different statuses. There are no markers to remove; delete the folder you do not want and continue.

A file listed with (no conflict markers: binary, modify/delete, or the merge driver failed) holds only one side, so --continue keeps refusing it with still unmerged: <file>; no markers to remove in <file>, …. Pick a side and stage it yourself, then continue:

yman git checkout --theirs -- <file>   # or --ours; paths are relative to .yman
yman git add <file>                    # or: yman git rm <file> to keep a deletion
yman sync --continue

yman sync is the command that touches the network day to day: it fetches, merges and pushes. --no-push merges without publishing. init also fetches and pushes unless given --offline, and with yman.autosync = push every changing command pushes (never merges) after it commits.

Attribution

Export YMAN_ACTOR=<agent name> and comments and attachments are recorded as yours. It does not change the git committer, which stays whatever git says it is.

Cost

Plain output is the cheapest thing yman prints. --json repeats every key on every row, so reach for it only when the output goes into jq. Cap with -n, use show <id> -n 0 when you only need the header and body, and never run ls without -n or a filter on a large tracker. show reads every task once for related by; plan <id> costs the same and lists more.

If .yman does not exist

Every command except init, hooks, git, guide, completions and man fails with .yman is not initialized; run: yman init. The common case is one command:

yman init --remote <url>     # omit --remote when the repo already has an origin
yman init                    # no origin at all: a local-only tracker; sync refuses until --remote

If origin already carries a task history, init adopts it — there is no separate clone step. The full runbook is docs/setup.md in the yman-tracker repository.

yman guide prints a condensed version of this skill straight from the binary, works in any directory, and needs no repository.

Command semantics

Normative, as-built. Companion documents: storage.md, errors.md.

1. What runs before a command body

main does three things in order before the body, and one after it (src/main.rs):

  1. Discover the repository. Failure is not inside a git repository.
  2. Preflight, for every command except init, hooks and git: .yman must be a linked worktree of this repo; its HEAD must be the symbolic ref refs/yman/local; for mutating commands, an unresolved sync merge stops the command with exit 3; then config.toml must load and validate. The merge check comes first so that a merge which conflicted in config.toml still exits 3, and sync is let through an unloadable config while a merge is pending — --abort and --continue are how that state ends.
  3. Lazy refresh, when yman.refresh is unset or lazy, for every command except init, sync, refresh, status, hooks and git. A failure here is downgraded to a warning: on stderr and never takes the real command down.
  4. Autosync, after a mutating command succeeded, when yman.autosync is push and there is an origin: one push of refs/yman/local to refs/tasks/main, skipped when LOCAL already equals REMOTE. It never fetches or merges. A push reports note: pushed N task commit(s); a push rejected because origin moved (the race in sync step 8) prints warning: origin has new task commits; run: yman sync; any other failure, a push origin refused included, prints git’s stderr and then warning: autosync failed: <why>; run: yman sync. None of these change the exit code: the change is already committed.

Mutating commands, for the purposes of step 2: add, edit, set, start, done, move, cancel, reopen, prio, rm, attach, detach, comment, tags rename and tags rm. Bare yman tags is a listing and is not mutating. sync is excluded because it handles MERGE_HEAD itself. The same list decides when step 4 runs.

guide, completions and man run none of these steps. They are documentation compiled into the binary and are answered before the repository is looked for, so they work from any directory. merge-driver skips all of them as well: git calls it from inside a merge with three temp files, and preflight would refuse on the MERGE_HEAD that is always present then.

2. Commit messages

Every mutation commits immediately. The user’s own hooks (possibly under core.hooksPath, and knowing nothing about .yman) cannot interfere: every git that yman runs in .yman gets -c core.hooksPath=/dev/null, so no hook is found for its commits, merges and fast-forwards — post-commit, post-merge, post-checkout and post-rewrite included, which --no-verify (also passed) would not stop. The git worktree add that creates .yman runs from the main repo and fires post-checkout inside it, so that one call gets the same override. A hook that writes files would otherwise leave .yman dirty, and refresh would back off until the next snapshot committed the junk. The main repo’s runner keeps the user’s hooks, so they — and yman hooks — still fire for the user’s own git commands. Hooks a newer git (2.54+) defines in configuration (hook.<name>.command) do not look in core.hooksPath, so the same calls also pass -c hook.<name>.enabled=false for every such name, read once per command with git config --get-regexp and only when a .yman call is made. The update-ref, fetch and push yman runs in the main repo on its own refs get the same treatment, so a reference-transaction hook never sees refs/yman/* or refs/tasks/main. GPG signing is deliberately left to the user’s configuration.

CommandSubject
inityman: init (empty root commit, only when refs/yman/local does not exist yet), then yman: init (<scheme>)
addtask({id}): add "{title}"
edittask({id}): edit
set, start, done, move, cancel, reopen, priotask({id}): set {pairs}
rmtask({id}): remove "{title}"
attachtask({id}): attach {name}[, {name}…]
detachtask({id}): detach {name}
commenttask({id}): comment
tags renameyman: tags rename {old} -> {new} ({n} tasks)
tags rmyman: tags remove {tag} ({n} tasks)
sync snapshotyman: snapshot local changes
sync renumberyman: renumber 2->3, 7->8 (sync collision)
sync mergeyman: merge origin refs/tasks/main
sync rejoinyman: rejoin files left under a moved folder
sync dropyman: drop files left under a removed task

In both tags subjects {n} tasks is 1 task when there is exactly one.

{title} has " replaced by '. {pairs} is space-joined, e.g. status=todo->doing priority=5->2 title tags=+ui,-auth comment — a changed title contributes the bare word title, a move with no field change contributes folder, list fields contribute +added,-removed, a changed body contributes the bare word body, and -m contributes the bare word comment.

Git::commit treats “nothing to commit” as success: re-applying an identical change inside the same second stages nothing, and the tree already says what the caller asked for.

3. Reading

ls

Touches no git itself; only the lazy refresh of §1 may. Filters, then sorts by (priority, status index, id), where the status index is the position in config.statuses.list. Ids order as plain numbers first (numerically), then prefix-N ids by prefix and numeric tail, then everything else lexically.

Default filtering hides tasks in any terminal status — statuses.terminal, or the done status when that key is unset; -a includes them, and naming one with -s includes it too. -t requires all the tags given, compared lowercase on both sides — the tag yman writes is lowercase anyway, and one written into m.yml by hand is still found. An invalid tag at -t is the same error add gives. --assignee WHO keeps one assignee (- keeps the unassigned, which includes an m.yml value that is blank or -), -p N one priority, --related ID the tasks whose own related list carries that id (exactly as --relate wrote it; an id nothing relates to is an empty listing, not a warning), and -q TEXT the tasks whose title or body contains the text, compared lowercase; all of these AND together with the status and tag filters. -n N keeps the first N rows after sorting. None of the filters touches git or reads anything ls did not already read. Broken folders are listed regardless of filters. Column headers are printed only when stdout is a terminal. Trailing empty columns are omitted, and zero counts render blank rather than 0.

-l prints each task’s body under its row, every line indented by four spaces and blank lines left empty, so a row is still the only line starting in column 0. A task with no body gets no extra lines. The body is the one ls already read for -q, so -l costs no git either.

--json emits one object per task — {id, priority, status, title, tags, assignee, links, related, created, updated, attachments, comments, dir} — and {dir, error} for broken folders. With -l each task object also carries body, the same trimmed text show --json gives; without it the key is absent. dir is relative to .yman and names the status directory for a closed task (done/5.1.fix-login). attachments and comments are counts here, not lists, and the attachment count comes from m.yml alone: an entry whose file has been deleted outside yman still counts, because ls stats nothing under f/. Use show to see which one is gone. The writer is src/json.rs: the escaping is hand-rolled and there is no serde_json dependency.

show

Prints the header block, then the body, then attachments and discussion. Empty sections are omitted entirely, including links, related and related by.

related by: is the other end of the one-way related field: the ids of every task whose related carries this one, in id order, closed tasks included. It is computed by reading every m.yml in the tracker — the one place show pays for a full scan — and is never stored. A broken folder cannot relate to anything and is skipped, as in rm.

An attachment whose file is gone — deleted outside yman, since detach takes the entry with it — is still listed, with (missing) after the (added … by …) parenthesis. The test is a plain existence check on f/<name>, one per attachment and only in show; it never errors and never rewrites m.yml. detach drops such an entry as usual (§4).

The discussion is shown in timestamp order, not file order: merge=union places the other side’s lines after ours in a conflicting hunk, so two comments written concurrently on two clones can sit in d.md out of time order. Only comments whose timestamp parses as RFC 3339 are reordered, among their own positions; a chunk d.md could not parse, and a comment with an unreadable timestamp, keep their place in the file. Comments from the same second keep file order. The file itself is never rewritten.

-n N keeps only the last N discussion entries after that ordering (a chunk d.md could not parse counts as one) under the header discussion (last N of M):; -n 0 drops the section. When N is not smaller than the number of entries the output is identical to the default, header included.

--json emits one object — {id, priority, status, title, tags, assignee, links, related, related_by, created, updated, dir, body, attachments, discussion, discussion_total}. attachments is [{name, added, by, missing}], where missing is the same existence check the text form marks, always present as true or false; discussion is [{ts, author, text}], with an unparsable chunk appearing as {raw}; discussion_total is the count before -n trimmed anything. Unlike the text form nothing is omitted: an empty section is an empty array and an absent assignee is null, so a script never has to branch on a missing key. dir is relative to .yman, as in ls --json, where the text form’s folder: line is relative to the repository root (.yman/2.1.fix-login).

plan

plan <id> reads a plan in one call: the task, how far its steps have got, and what is left. A step is any task whose related carries the id — the set ls --related <id> filters and show’s related by: lists. It is built from the same single read of every m.yml that show makes; broken folders are skipped.

30  Ship sync v2
status: doing   steps: 7   todo 2, doing 1, blocked 1, done 3
2  32  todo     Rewrite merge driver
3  34  blocked  Drop old ref            waits on 32

The second line counts every step, closed ones included, per status in statuses.list order; a status the config does not list comes last, and a zero count is left out. With no steps it reads steps: 0 and no rows follow. Rows are the open steps, sorted as ls sorts (P ID STATUS TITLE, no header line); -a adds the closed ones and -n N keeps the first N after sorting. A step in status blocked gets a last column, waits on <ids>: the tasks in its own related that exist here, are open, and are not tagged epic — so the epic a step belongs to never reads as something it waits on. -l prints each step’s body under its row, indented as in ls -l.

--json emits one object — {id, priority, status, title, counts, steps}, where counts maps each non-zero status to its number ({} when there are no steps) and steps is [{id, priority, status, title, tags, assignee, waits_on, dir}] in row order; waits_on is [] for a step that is not blocked, and -l adds body. An unknown id is exit 4, as everywhere.

status

Reports and never changes anything: the local ref and its short head, the remote ref with ahead/behind when it has been fetched, the refresh policy, the autosync setting (the text form names it only when it is not off), whether the hooks are installed, the number of uncommitted changes under .yman, an unresolved merge with its unmerged files, and the task counts per configured status.

--json emits one object — {local: {ref, head}, remote: {ref, fetched, head, ahead, behind, origin}, refresh, autosync, hooks, worktree: {dirty}, merge: {in_progress, unmerged}, tasks: {by_status, other, broken}}. Before the first fetch fetched is false, head is null and both counters are 0. origin is false for a local-only tracker, whose text form prints remote: none (no "origin"; tasks are local only) instead of not fetched yet. The per-status counts are nested under by_status so a status named other or broken cannot collide with the two totals beside it.

tags

An inventory of the tags in use: one row per tag with the number of tasks carrying it, sorted by name. Touches no git, and counts every task on disk — closed ones included, unlike ls, because the question is what tags exist, not what is open. Tags are folded to lowercase before counting, so a hand-written UI lands in the same row as ui, and a task that spells one tag two ways counts once. Column headers follow the ls rule: terminal only.

TAG   N
auth  1
ui    3

--json emits [{tag, tasks}] in the same order. It belongs to the listing: given with rename or rm it is a usage error, exit 2. Folders that would not load are skipped with warning: skipped <n> unreadable task folder(s): <rels> on stderr; ls is the command that lists them properly.

tags rename and tags rm edit the vocabulary itself; they are under §4.

path

Prints the absolute folder path and nothing else, so cd $(yman path 14) works. The lazy refresh still runs, silently.

log

-n defaults to 20. With an id, the log is limited to the pathspecs :(glob)[0-9].<id>.*/** and :(glob)*/[0-9].<id>.*/**. The id is in the folder name through a priority change, a retitle and a move into or out of a status directory, so those keep the task’s full history without consulting git’s rename detection, and -n stops the walk once enough commits are found.

A sync renumber is the one move that changes the id, and log reads it from the commit subject (yman: renumber 2->3, …):

  • For a task that arrived at its id by a renumber, the log continues from the renumber’s parent under the old id, and -n counts across both.
  • For a task that kept an id another task was renumbered away from, every such renumber commit is excluded with ^<commit>, together with its ancestry: the moved task was not in the merge base, so all it did under the id is behind the renumber, and none of the kept task’s history is. An excluded revision makes git walk the whole range before -n applies.

guide

Prints agents.md — the short manual for scripts and agents — to stdout, byte for byte, via include_str!. Needs no repository. The top-level yman --help ends by pointing at it; the per-command help pages do not.

completions

yman completions <bash|zsh|fish|elvish|powershell> prints a completion script for that shell to stdout, generated by clap_complete from the same src/cli.rs definition the binary parses with, so it cannot drift from the CLI. The hidden merge-driver is left out. An unknown shell is a usage error, exit 2. Needs no repository.

man

yman man prints the yman(1) page in roff to stdout, generated by clap_mangen from the same definition; its SUBCOMMANDS section refers to yman-<name>(1). yman man --dir <DIR> creates DIR if needed and writes yman.1 plus one page per visible subcommand, nested ones included (yman-tags-rename.1), and prints nothing. Needs no repository.

4. Writing

init

Creates the tracker: the ref, the .yman worktree, the repository configuration and the first commits. Its flags, what it changes and the local-only case are specified in setup.md §2 and setup.md §3.

add

Mints an id (see storage.md §8), validates the status against the config, normalizes the tags, dedupes them together with the --links and --related ids while keeping their order, then writes t.md and m.yml with created == updated. A tag is trimmed, lowercased, and refused when it is empty or carries whitespace, a comma or a control character — the comma separates the ls TAGS column and the flow sequence m.yml accepts, and whitespace makes -t unusable without quoting. Tags are checked before the editor opens, before --body-file reads stdin and before an id is minted, so a typo costs neither an id nor a half-written folder. -t UI -t ui is one tag. The title is trimmed and refused, at the same point, when it is empty (title must not be empty) or still contains a control character — a newline, a carriage return, a tab, an escape, anything char::is_control matches, and also the Unicode line and paragraph separators U+2028 and U+2029 and the bidi controls U+202A-U+202E and U+2066-U+2069 — with invalid title "<t>"; titles must not contain line breaks or other control characters, <t> being the trimmed title with those characters escaped (\n, \t, \u{1b}, \u{202e}). Tags apply the same rule and show a refused tag escaped the same way. A newline would otherwise leave t.md with a one-line title and the rest as body, and split the commit subject. set --title and every --sections heading apply the same rule with the same message. -a/--assignee is trimmed; blank or - means unassigned (- is the sentinel ls --assignee - uses, so nobody can be assigned under that name). --relate and --waits-on values are trimmed and a blank one is refused (related id must not be empty); set also refuses the task’s own id. --relate to an id no folder here carries warns exactly as set --relate does, before the id is minted. --waits-on ID (repeatable) is --relate ID plus -s blocked, and excludes -s; it refuses, before anything else is read, when blocked is not in statuses.list (see set). The body comes from -m, or from --body-file PATH (- reads stdin); the two exclude each other and -e. With -e, the editor opens before the first commit, so a title typed there renames the folder by plain rename — the placeholder never enters git history. If the editor cannot be started, exits non-zero or is killed, or leaves a t.md that no longer parses or whose title the rule above refuses, the new folder is removed, along with a status directory created for it (done/ under an archive config), and nothing is committed: editor exited with status N; task not added, or t.md invalid after edit: <why>; task not added. The id is not spent, and what was typed in the editor is lost with the folder.

--sections PATH (- reads stdin) creates one task per section of a markdown file instead of one from a title; it excludes the title, -m, --body-file and -e, and every other flag applies to each task. A line that is # or starts with # opens a section and is its title, trimmed; the body is everything up to the next such line, with blank lines at either end dropped. Inside a fenced block — opened by a line starting, after any indentation, with three or more backticks or ~ (a backtick run whose info string holds a backtick is text, as in CommonMark; unlike CommonMark, four or more spaces of indentation do not make it indented code, a deliberate simplification), closed only by a run of the same character at least as long with nothing after it — a # line is body text, and ## and deeper headings are always body text. CRLF reads like LF. The whole file is read and parsed before the first id is minted, and these refuse it, <source> being the path or stdin:

  • <source>: line <n>: text before the first "# " heading — anything but blank lines above the first heading;
  • <source>: line <n>: empty title;
  • <source>: no "# " heading, so no tasks;
  • <source>: line <n>: unclosed code fence — <n> is the opening line.

Then every title is checked against the control-character rule above, still before the first id is minted, so a bad heading anywhere in the file creates no task at all; the refusal names the title, not a line.

Tasks are then created in file order, one commit and one added <id> <dir> line each, exactly as separate add calls would make them, except that the history is walked for taken ids once for the whole file rather than per task; the first failure stops the run with the tasks before it committed. Under the seq scheme ids follow file order, so a plan listed with equal priorities reads in that order; under the other schemes, order steps with prio afterwards.

edit

Opens $VISUAL, else $EDITOR, else vi, split on whitespace so EDITOR="code --wait" works. The vi fallback is taken only when stdin is a terminal; otherwise the command fails at once with no terminal for vi; set $EDITOR, or use -m / --body-file rather than leaving a vi waiting on a pipe. The same rule covers add -e and comment -e. A non-zero editor exit aborts and leaves the file alone (under add -e it removes the new folder instead, under comment -e its temp file; see add and comment). If the file no longer parses, or its title carries a control character (the rule under add), the command fails with t.md invalid after edit: <why>; fix the file then run: yman edit <id>, commits nothing and does not revert the user’s text; the next sync snapshots it. When nothing changed, it prints no changes and commits nothing. A changed title re-slugs the folder with git mv.

set (and start, done, move, cancel, reopen, prio)

At least one flag is required, enforced by the argument parser. Changes are applied in memory first, then:

  1. A changed priority, slug, or terminal boundary renames or moves the folder with git mv. Crossing the boundary also prints note: task folder is now <rel> on stderr — stdout stays data, but someone who had cd’d into the folder needs to hear that it moved.
  2. updated is touched; m.yml is rewritten; t.md too when the title or the body moved. With -m, the text is appended to d.md exactly as comment would, with the same actor.
  3. One commit, one printed line per change (14: status todo -> doing, 14: commented).

List fields (--tag/--untag, --link/--unlink, --relate/--unrelate) are set semantics with insertion order preserved: adding a value already present is not a change, and removing one that was never there is quietly accepted. Both --tag and --untag normalize their values the way add does, and both refuse an invalid one — nothing yman wrote can look like that, so such a value is a typo rather than something waiting to be removed. --title is trimmed and refused exactly as add refuses a title — empty, or carrying a control character — before anything is written. -a/--assignee is trimmed the same way add trims it, and a blank or - value clears the assignee like --no-assignee. When nothing at all changed, set prints no changes and commits nothing. -m always counts as a change; an empty message is the empty comment error.

--relate to an id no folder here carries prints warning: task <id> does not exist here; relating anyway on stderr and commits regardless. It is not a refusal because a concurrent add on another clone can legitimately race it, and the id becomes real the moment that clone’s work arrives. Re-adding an id the task already relates to is not a change and warns about nothing.

--waits-on ID (repeatable) says the task waits on another: it is --relate ID plus --status blocked, in the same commit and with the same printed lines, and the argument parser refuses it together with --status. blocked is a status name, not a config role; when statuses.list lacks it the flag fails with --waits-on needs a "blocked" status; add it to statuses.list in .yman/config.toml before anything is written.

related is one-way: relating 1 to 2 says nothing about 2. yman maintains the field in exactly three places — here, the renumber rewrite in §6, and rm below — and nowhere else. In particular, an id written into the prose of t.md or d.md is text; it is never checked and never rewritten.

--body TEXT and --body-file PATH (- for stdin) replace the body of t.md; they exclude each other. The comparison ignores leading and trailing newlines, as t.md is rendered that way, so re-applying the same text is no changes. --body "" clears the body. The line is <id>: body updated and the token body; a body change alone never renames the folder. A source that cannot be read fails with cannot read <path>: <why> before anything is written.

start and done resolve to statuses.start and statuses.done, falling back to list[1] and list.last() when those are unset.

The folder’s location is recomputed on every set, before the “nothing changed” exit. Setting a task’s status to the one it already has therefore relocates a task that is in the wrong place — a hand edit, a resolved merge, or a repository that raised its version with closed tasks already on disk — and reports it as folder <old> -> <new>, with folder as the commit-subject token. There is no separate repair command.

The remaining verbs are set --status with the status looked up for you:

CommandStatusWhen it refuses
move <id>... <status>the one you nameunknown status, as for set
cancel <id>...statuses.cancelthe key is unset
reopen <id>...statuses.defaultthe task is not in a terminal status

Every verb, and prio <id>... <0-9>, takes several ids and -m <text> like set. Ids are processed in the order given, one commit each, printing the same lines set would. The first failure stops the run with that error; the tasks before it are already committed, and what their closes freed is reported (below) before the error. set, edit, show, path and rm take exactly one id.

cancel has no fallback on purpose: picking one of several closed statuses by position is the guesswork the named roles exist to remove.

What a close changed. When set or a verb moves a task from an open status into a terminal one, yman reads every task once more and reports on stderr, after the last id of the call (so done 32 33 judges a task waiting on both after both closed) — or, when an id fails, after the last one that completed (done 32 99 reports what closing 32 freed, then fails on 99). Exit status and stdout are unaffected. If the tasks cannot be read back, warning: committed, but could not read the tasks it affects: <why> replaces the report and the exit code stays 0: the change is already committed.

  • A closed task tagged epic that open tasks still relate to gets warning: <id> closed with open tasks relating to it: <ids> (id order). The close is already committed; the warning is there so a plan is not closed by accident with work left. Closing it frees nothing: its steps relate to it as a container, not a blocker.
  • Otherwise, a task in status blocked whose related carries a closed id, and which now relates to nothing open except tasks tagged epic, gets note: <id> no longer waits on anything open: yman move <id> <default status>, one per task in id order. Nothing is moved.

A follow-up that relates to an ordinary task is not a step and is never reported. A set that changes nothing, or that leaves the task closed, reports nothing.

tags rename / tags rm

The two edits that only make sense across every task at once. Both normalize their arguments the way add does, match stored tags folded, and touch only m.yml — a tag never names the folder, so nothing is renamed or moved.

tags rename <old> <new> replaces old in place, keeping its position in the list; a task carrying old in several spellings (UI and ui, by hand) ends with new once, where the first was. A task already carrying new loses old rather than gaining a duplicate, so its line reads <id>: tags -<old> where the others read <id>: tags +<new> -<old>. old and new that normalize to the same value is no changes.

tags rm <tag> drops it. On a terminal it prompts remove tag "<tag>" from <n> tasks? [y/N] (1 task for one) on stderr — asked only once the count is known, since confirming a removal without knowing it touches forty tasks is not consent. Anything but y/Y aborts. Without a terminal and without -f it refuses with refusing to remove without -f, the same wording as rm.

Both are one commit for every task they touch: renaming a tag is a single decision, and a half-applied rename would leave the vocabulary in a state nobody chose. No match is no changes and no commit, as in set. A folder that does not load cannot be rewritten either, so it is reported on stderr with the same warning: skipped … the listing prints rather than silently left out of the count.

rm

On a terminal, prompts remove task 14 "Fix login"? [y/N] on stderr; anything but y/Y aborts. Without a terminal and without -f, it refuses rather than assume consent.

Every other task’s related loses the removed id in the same commit as the removal, so a task that is gone and a reference still naming it are never two states of the repository anyone can observe. The count goes to stderr as note: dropped N reference(s) to <id>, and is omitted when there was nothing to drop. A folder that does not load cannot be rewritten, so it is reported with the same warning: skipped N unreadable task folder(s): … the listing prints — a reference inside one survives the removal.

Removing an open task that is not tagged epic frees its waiters the way closing it would: after removed <id>, a task in status blocked that lost the reference and now relates to nothing open except epics gets note: <id> no longer waits on anything open: yman move <id> <default status> (see “What a close changed” under set). Nothing is moved. Removing a closed task or an epic frees nothing, as closing one does not. As with a close, if the tasks cannot be read back the same warning replaces the note and the exit code stays 0.

attach / detach

Each source must be an existing, readable regular file. --name applies to a single file only, and must not contain a path separator. Every name, given or taken from the source, must also be portable: no : * ? " < > | or control character, no trailing . or space, not empty, not a reserved device name (CON, PRN, AUX, NUL, COM1-COM9, LPT1-LPT9, any case, with or without an extension) — the names a Windows checkout cannot hold. Names compare ignoring case throughout, since a.png and A.png are one file on macOS and Windows. Two sources that would land under the same name are refused with attachment "<name>" given more than once, --force or not. Every source is checked before any is copied, so a refusal leaves nothing behind: no file under f/, no m.yml entry, no commit. A copy that fails anyway (a full disk) is rolled back: files already copied are removed, files --force was replacing are restored, and m.yml, written only after every copy has landed, is left as it was. The by recorded in m.yml is the actor (see comment). An existing attachment of the same name needs --force — an m.yml entry, even one whose file is gone, or a file under f/. With --force the entry is replaced in place, and a file whose name differs only in case is removed, so the task keeps one of each. Files over 5 MiB produce a warning, never a refusal. An attachment listed in m.yml whose file is already gone can still be detached — the entry is simply dropped. detach finds the entry ignoring case (an exact match wins if a task holds both spellings) and removes, reports and commits it under the name stored in m.yml, so detach 3 A.png drops an a.png. Each copied file is staged with git add -f, so a name matching .yman/.gitignore (*.swp, *~, .#*, *.orig) is committed like any other: naming a file to attach overrides the ignore rule.

comment

Text comes from -m, or from the editor ($VISUAL, else $EDITOR) with -e, or — when neither is given and stdin is not a terminal — from stdin, read until end of file. That last form waits for EOF: a caller whose stdin is an open pipe it never closes (a harness, a CI step) blocks, so a non-interactive caller passes -m, or gives stdin a definite end (< note.md, a pipe from a command that exits, < /dev/null, which is then an empty comment). At a terminal with neither flag the command fails with empty comment without reading. Empty after trimming is an error.

Under -e the editor is resolved first (the rule under edit), then handed a new, empty file in the temp dir (on unix $TMPDIR, else /tmp) named yman-comment-<id>-<pid>-<nanos>-<n>.md. It is created exclusively, mode 0600 on unix: an existing path, a symlink included, is never opened, truncated or followed — another name is tried instead. The file is removed on every path: after the comment is committed, when the editor fails (editor exited with status N; comment not added), and when the text is empty. A refused editor creates no file at all. The author is $YMAN_ACTOR (trimmed, non-empty), else git config user.name, else unknown. This is the display name written into d.md only; the git committer is whatever git resolves.

5. Refresh

refresh(quiet) never touches the network. In order:

  1. Either ref missing, or both equal → nothing to do.
  2. LOCAL is not an ancestor of REMOTE → skip. Only yman refresh reports it, as note: local has unpushed commits; run: yman sync; the lazy path stays silent. yman status shows the same situation as ahead N, behind M with → run: yman sync.
  3. The worktree is dirty → skip. Only yman refresh reports it, as note: .yman has uncommitted changes, refresh skipped; the lazy path stays silent.
  4. Otherwise fast-forward (merge --ff-only) and report note: refreshed N new commit(s) unless quiet.

yman refresh additionally prints up to date when there was nothing to do.

yman.refreshEffect
lazy (default)every non-exempt command fast-forwards first
manualonly yman refresh and yman sync move .yman

On the lazy path the policy is consulted between steps 2 and 3, not before step 1: it can only change the outcome for a repository that is actually behind, and reading it costs a git process. A repository that is up to date or holds unpushed commits reaches the same answer without it.

Hooks

yman hooks install writes post-merge and post-checkout, each carrying the marker line # yman-hook v1:

#!/bin/sh
# yman-hook v1
command -v yman >/dev/null 2>&1 && yman refresh --quiet
exit 0

The target directory is core.hooksPath when set, as git resolves it with rev-parse --git-path hooks (~/ expanded, a relative value relative to the toplevel), and the install warning names the resolved path; otherwise COMMON/hooks. An empty core.hooksPath turns git’s hooks off, so install, remove and status refuse with core.hooksPath is empty, which turns git hooks off; unset it or point it at a directory, and yman status reports hooks not installed; a whitespace-only value is a directory name like any other and is not trimmed. A hook that exists without the marker is never touched: the command prints the one line to add and exits non-zero after processing both hooks. A hook yman cannot read — permission denied, content that is not UTF-8, a dangling symlink — is refused the same way, since it cannot tell whose it is: install prints warning: hook <name>: cannot read <path>: <why>; not replacing it and counts it among the hooks not installed, and nothing is written through a dangling link. A symlink to a readable file is judged by its target. remove deletes only marked files (a marked symlink loses the link, not its target); status reports installed / foreign / unreadable / absent, and both say why a hook is unreadable with warning: cannot read <path>: <why> on stderr. The hooks fire for the user’s own git pull and git checkout in the project, never for yman’s own merges in .yman, which run without hooks (see §2).

6. Sync

One of two commands that use the network; the other is init, which fetches and pushes unless given --offline (see setup.md §2). Preflight runs with mutating = false; sync inspects MERGE_HEAD itself. Except for --abort, it first requires an origin: a local-only tracker (see setup.md §2) fails with no "origin" remote; tasks are local only. Connect one: yman init --remote <url>, exit 1.

Normal path

  1. A merge already in progress → exit 3.
  2. A dirty worktree is committed as yman: snapshot local changes, reporting snapshotted N local change(s).
  3. Fetch +refs/tasks/main:refs/yman/remote, with --no-write-fetch-head so the main repository’s FETCH_HEAD stays the user’s. A stderr mentioning couldn't find remote ref means origin simply has no tasks yet; if we had a REMOTE before, it is deleted and the disappearance is reported. Any other failure is fatal, with git’s stderr passed through.
  4. No REMOTE → straight to the push.
  5. No merge base → fatal: the histories are unrelated, and the message spells out the re-init recipe.
  6. base == remote → nothing to merge. base == local → merge --ff-only.
  7. Otherwise renumber collisions (below), then merge --no-edit --no-verify with merge.directoryRenames=true. m.yml goes through the field-wise merge driver (storage.md), so edits to different fields of one task settle on their own; anything it cannot settle falls back to the text merge. Conflicts print note: unmerged files:, then each file indented by two spaces, and exit 3. A clean merge is followed by the rejoin below.
  8. Push, unless --no-push: resolve LOCAL once and push that commit, <sha>:refs/tasks/main, with --porcelain, and read git’s verdict from the refs/tasks/main line on stdout. Four reasons on a ! line mean origin moved after our fetch — the race: [rejected] (fetch first), [rejected] (non-fast-forward), and the two ways receive-pack’s own compare-and-swap fails because the ref changed between its advertisement and its update, [remote rejected] (incorrect old value provided) and, for a push that creates the ref, [remote rejected] (reference already exists). Older git reports both as failed to update ref. A race loops back to step 3, at most three attempts, then fails with origin keeps moving. Any other ! — pre-receive hook declined, a protected or denied ref, and the ambiguous failed to update ref and failed to lock, which can mean a moved ref but also a server-side fault — is a refusal: it is not retried, and fails with push failed after git’s stderr and its ! [remote rejected] refs/yman/local -> refs/tasks/main (<reason>) line.
  9. Report synced pulled N, pushed N, renumbered N refs/yman/local @ <sha>.

After a successful push, REMOTE is set to the commit that was pushed, so status and refresh agree with what origin now holds. A commit another command made while the push was in flight stays ahead of REMOTE and goes out with the next push.

Collision renumbering

seq and author mint the same id on two machines working offline. The side that is behind moves, because the other side’s id is already published.

  • Candidates are ids of tasks created locally since the merge base that also exist on the remote. All three sets are read from folder names in ls-tree -d -r — of the base, of LOCAL and of REMOTE — and an id created locally is one LOCAL has that the base does not. Nothing here consults git’s rename detection: adding a comment or an attachment to an existing task does not mint an id, and neither does a retitle or a close, both of which move the folder while the id stays in its name.
  • The replacement comes from the same scheme, avoiding everything on disk, on the remote, and everything ever assigned on either ref. For author, the original prefix is kept.
  • Each rename is a git mv, the task’s updated is touched, and one commit covers the batch. Every replacement id is chosen and every task loaded before the first move; a failure part-way through the moves resets .yman to the commit before the renumber and fails with renumber aborted; .yman restored.
  • Each move prints renumbered {old} -> {new} (id taken on origin) once the batch is committed.
  • related references to the old id are rewritten to the new one, in the same commit as the moves, and note: rewrote N reference(s) to renumbered ids reports how many changed. A folder that does not load is left alone. The rewrite covers the renumbering clone’s own tree, which is the pre-merge one, and that is every reference that can mean the moved task: a candidate is absent from the merge base, so it never reached origin and no other clone has seen it. A reference on the remote side, including one arriving in the same merge, names the remote’s task, which keeps its id.

Rejoining a moved folder

A retitle or a close moves a task’s folder, and a file the other clone added under the old name belongs in the new one. merge.directoryRenames=true has git move it — but only when something was also added directly in that folder, so a first comment’s d.md follows and an attachment on its own, f/<name>, does not. The merge then succeeds with the task split across two folders.

So after the merge, any folder that shares its id with exactly one folder carrying an m.yml, and has none itself, is emptied into that folder with git mv, printing note: moved N file(s) left under <old> into <rel>. A file whose destination already exists stays put, with warning: <path> not moved; <dest> already exists. After a clean merge the moves are committed as yman: rejoin files left under a moved folder; under --continue they go into the merge commit, ahead of its checks.

Dropping a removed task’s new files

rm on one clone and a first comment or an attachment on the same task on the other merge without a conflict into a folder that holds only the new files: no t.md, no m.yml, nothing any command can load. (When the other side also changed m.yml, git raises a modify/delete conflict instead and the merge stops as usual.) After the merge, a task folder with neither file whose id the merge base had and one side no longer has is removed with git rm, printing note: dropped <rel>: task <id> was removed on one side; N file(s) added on the other are gone. A folder broken by hand, whose id both sides still have, is left alone and listed as broken. Untracked and ignored files stay on disk, and with them the folder; one holding nothing tracked at all — what rm leaves behind around a *.swp — has nothing to remove from git and is passed over without a note. The parents compared are the merge commit’s own, even when a rejoin commit has already moved HEAD past it. After a clean merge the removal is committed as yman: drop files left under a removed task; under --continue it goes into the merge commit, ahead of its checks.

--continue and --abort

Both require a merge in progress.

--continue treats a file as unresolved while it still carries conflict markers — staging is yman’s job, since the message tells the user to edit and rerun, not to run git add. The exception is a conflict git never marked up: a binary file, a change on one side to what the other deleted, or an m.yml whose merge driver could not run. The file on disk is then simply ours, and taking it would drop theirs. When the merge stops, yman records in WT_GITDIR/YMAN_MARKED which unmerged files carried markers; an unmerged file that has both sides’ versions (or a base and one side) and is not on that list stays unmerged until the user stages a version, and --continue says so: still unmerged: <files>; no markers to remove in <files>, stage the version to keep: yman git checkout --ours|--theirs -- <file> && yman git add <file>. sync prints git’s stderr from the failed merge, so a driver’s No such file or directory is not lost. It then checks that config.toml loads, that every task folder the merge touched still loads and is marker-free, that no id has two folders, stages everything, commits with --no-edit (through the same commit path as every other yman commit, so a missing identity gets the same hint), and pushes. Its summary counts as pulled the commits the merge brought in (HEAD..MERGE_HEAD); renumbered is always 0, since any renumber was done, and printed, by the sync that stopped. The marker scan skips symlinks.

The folder list comes from both HEAD..MERGE_HEAD and the unmerged paths, because a rename conflict names paths that survive in neither tree.

A task closed to two different statuses

Closing one task to done on one clone and cancelled on another renames it two ways, and git resolves that by keeping both folders. Both load, and neither holds a conflict marker, so every other check here passes — the duplicate-id check is the only thing between that state and a pushed task no command can touch afterwards. It reports

duplicate task id 1: cancelled/5.1.fix-login, done/5.1.fix-login; delete one folder, then: yman sync --continue

with exit 3, and sync names the same pair on stderr when the merge fails:

note: task 1 was closed to two different statuses; keep one of cancelled/5.1.fix-login, done/5.1.fix-login

The note fires only for folders that carry the task (m.yml) under two different closed statuses. A file the other side added under a folder this side retitled or closed can be left behind under the old name while a merge is stopped; that remnant is not a split close, and --continue moves it into the folder that carries the task.

Delete the folder you do not want, then rerun yman sync --continue.

--abort runs git merge --abort and reports merge aborted. It pushes nothing, so --no-push beside it is a usage error, exit 2, as is --continue.

7. git passthrough

yman git -- <args> runs git inside .yman with inherited stdio and propagates the exit code; a git killed by a signal exits 128 + signal, as a shell would report it. It deliberately bypasses the Git wrapper, so the user’s own flags — including colour — behave normally, and so do the user’s hooks: unlike yman’s own calls, a yman git commit fires post-commit. The environment is the exception: the repository-local variables the wrapper strips (GIT_DIR, GIT_WORK_TREE, the rest of git rev-parse --local-env-vars, and GIT_NAMESPACE) are stripped here too, so yman git run from a hook or alias acts on .yman, not on the repository the hook was running for.

Storage contract

Normative, as-built. Describes where yman keeps data and what shape it is in. Companion documents: commands.md, errors.md.

1. Names and paths

TermMeaningSource
ROOTmain repo toplevelgit rev-parse --path-format=absolute --show-toplevel --git-common-dir, first line
COMMONmain repo common git dir, absolutethe same call, second line
YDIRROOT/.yman
WT_GITDIRprivate git dir of the .yman worktreeread from YDIR/.git
LOCALrefs/yman/local — task history head
REMOTErefs/yman/remote — remote-tracking ref
REMOTE_REFrefs/tasks/main — the ref name on origin

Two git runners exist, both over the same object store and refs (src/repo.rs):

  • in main — git -C ROOT …: refs, fetch, push, config, worktree management.
  • in wt — git -C YDIR …: anything touching task files — add, mv, rm, commit, merge, status, diff, log.

WT_GITDIR is read, never constructed

Git derives a linked worktree’s private directory name from the basename and sanitizes it, so .yman becomes COMMON/worktrees/-yman — with a leading hyphen, not the literal name. repo::read_gitdir_file follows the gitdir: line in YDIR/.git to the real path; ydir_state then classifies .yman as a worktree only when that path lives under COMMON/worktrees/ and contains a gitdir file. Building the path from the directory name breaks every command with .yman is not initialized.

2. The ref namespace

Task commits live on LOCAL, outside refs/heads/. .yman is a linked worktree whose HEAD is the symbolic ref refs/yman/local, created as:

git worktree add --detach .yman refs/yman/local
git -C .yman symbolic-ref HEAD refs/yman/local

scripts/spike-symref.sh exercises the properties this depends on, and is re-runnable:

  1. git commit inside the worktree moves refs/yman/local, and HEAD stays symbolic. No update-ref fixup is needed. Enforced: the script fails when the ref did not advance, when it disagrees with the worktree’s HEAD, and when HEAD is no longer the symbolic ref refs/yman/local.
  2. git branch -a never mentions the ref. Enforced.
  3. git merge --ff-only works against a ref built with commit-tree. Enforced.
  4. The main worktree’s git status stays clean. Enforced.

The consequence for everything else: git status, git log, git diff, branch pickers and CI never see task history, while git log --all, git show-ref and git worktree list do.

Only one .yman per clone is supported. The worktree is added with --detach, and git only guards branches against a second checkout, so init does not rely on git refusing: before creating .yman it reads git worktree list --porcelain, and when any worktree is on branch refs/yman/local it stops with refs/yman/local is already checked out in another worktree of this repo; …. That is the case from a linked worktree of the project, whose toplevel differs from the main one.

Discovery runs rev-parse --show-toplevel, which inside .yman answers .yman itself. When that toplevel is named .yman and its .git file points under COMMON/worktrees/, the project root is taken to be its parent, so commands run from a task folder work and init there takes the repair path rather than nesting a second worktree.

Refs are read from disk, with git as the fallback

Resolving a ref is a file read, and yman does it as one (src/refs.rs): COMMON/<ref> first, then a scan of COMMON/packed-refs, chasing a ref: line up to five hops. WT_GITDIR/HEAD is read the same way for the symbolic HEAD check. Both files are replaced by rename, so a concurrent update is read as either the old value or the new one, never a torn one, and a loose ref takes precedence over the packed copy exactly as it does for git.

Anything the reader does not recognise — the reftable backend (git 2.45+, detected as COMMON/reftable/), an unreadable file, a value that is not a full hex object id — is not a failure: the caller falls back to git rev-parse or git symbolic-ref, which is always authoritative. An unfamiliar repository is therefore slower, never wrong. The write paths (sync, init, status) keep asking git directly, because they read refs that git itself has just moved.

The visible consequence is the process count: yman ls and yman show in an up-to-date repository spawn one git, the rev-parse above. A repository holding unpushed commits spawns a second for the ancestry question, which no file answers. tests/cli.rs pins both numbers.

3. Repository configuration

yman init writes exactly these, all idempotently:

LocationValuePurpose
COMMON/info/exclude.yman/keeps the code worktree’s git status clean
remote.origin.fetch (appended)+refs/tasks/main:refs/yman/remotea plain git fetch carries task commits; only added when origin exists
yman.refreshlazy | manualsee commands.md §5
yman.autosyncoff | pushonly when --autosync is given; unset means off; see commands.md §1
yman.authorprefix stringonly meaningful for the author id scheme
merge.ymanmeta.nameyman m.yml field-wise mergeshown by git when the driver runs
merge.ymanmeta.driver'<yman>' merge-driver %O %A %B — the binary’s path single-quoted for sh, a ' in it written '\''see Merging m.yml
.yman/.gitattributes**/d.md merge=union, **/m.yml merge=ymanmetacommitted, so every clone inherits it

remote.origin.push is never set: that would hijack the user’s plain git push. Publishing is always an explicit refspec, <sha>:refs/tasks/main with <sha> the commit refs/yman/local held when the push began, pushed with --porcelain so that a push origin rejected because the ref moved (retried) is told apart from one it refused (not retried) by git’s per-ref line rather than by its stderr — see commands.md step 8.

Every git that yman runs for itself gets -c core.quotePath=false and -c color.ui=never on its command line and LC_ALL=C in its environment (src/git.rs): several decisions read git’s own English messages — couldn't find remote ref for an origin with no tasks yet, nothing to commit for a change that is already in the tree — and a localized git would turn each of them into a failure. The one exception is yman git, a raw passthrough that keeps the user’s locale and flags.

The runner in wt adds -c core.hooksPath=/dev/null, and so does the single git worktree add that creates YDIR: no hook file exists below a device file, so the user’s post-commit, post-merge, post-checkout and post-rewrite never run with YDIR as their working directory (--no-verify only covers the pre-hooks). /dev/null rather than an empty directory under WT_GITDIR because it needs no setup and works before WT_GITDIR exists. The runner in main keeps the user’s hooks, including the ones yman hooks install writes.

When the repository has no origin and --remote <url> is given, init creates the remote — fetch and push address origin by name, so reporting the URL without creating it would lead nowhere.

4. Directory layout

ROOT/
  .git/
    config                      # + fetch refspec, yman.refresh, yman.autosync,
                                #   yman.author, merge.ymanmeta.*
    info/exclude                # + ".yman/"
    refs/yman/{local,remote}    # remote only once fetched; absent when local-only
    worktrees/-yman/            # git-managed; HEAD, index, MERGE_HEAD
    hooks/{post-merge,post-checkout}   # optional, `yman hooks install`;
                                #   core.hooksPath instead when set
  .yman/
    .git                        # file: "gitdir: <COMMON>/worktrees/-yman"
    .gitignore                  # *.swp  *~  .#*  *.orig
    .gitattributes              # **/d.md merge=union, **/m.yml merge=ymanmeta
    config.toml
    2.14.fix-login/
      t.md
      m.yml
      d.md                      # only once commented
      f/                        # only once something is attached
        screenshot.png

5. Task folder names

Grammar, anchored: ^([0-9])\.([^./\\]+)\.(.+)$ → priority: u8, id: String, slug: String. Parsed by hand in task::FolderName::parse; there is no regex dependency.

  • Priority 0–9, 0 highest, so a plain ls .yman/ sorts urgent work first. Only the priority sorts numerically: the listing is lexical, so within one priority 5.10.x comes before 5.2.x. yman ls orders ids properly (commands.md §3); zero-padding them here would change the id contract.
  • id never contains .. FolderName::parse splits at the first dot after the priority and only rejects / and \ outright, so a dotted id would be silently mis-read rather than refused — 5.v.i-1.slug parses as id v with slug i-1.slug. seq and random cannot produce one, and the author prefix is validated before an id is minted (§8).
  • The folder name is the source of truth for priority and id. m.yml does not repeat them, and changing either is a git mv so history follows.
  • Entries in .yman/ that are not directories, or whose names do not match, are ignored entirely (config.toml, .git, dotfiles) — except that a directory whose name is a legal status name is descended into, because closed tasks live one level down (below).
  • A directory that matches but fails to load is a broken task: ls prints it with a ! marker and the parse error; commands that target it fail.
  • Two folders with the same id is an error — duplicate task id … — and can only result from a badly resolved merge. The message names paths relative to .yman, not folder names, because the two copies can differ only in which status directory they sit in.

Closed tasks live one level down

A task in a terminal status (§7) is stored at .yman/{status}/{priority}.{id}.{slug}/ instead of at the top level. Only version = 2 does this; a version 1 repository keeps every task flat.

  • m.yml is the source of truth, not the path. A task physically under done/ whose m.yml says todo is listed as todo, and the next set moves it where it belongs. This is what keeps a config change from bricking a repository, the same promise §6 makes about unknown status values.
  • The second level is found by grammar — any directory whose name is a legal status name is descended into — not by consulting statuses.terminal. A task archived under a status later removed from the list must still be found.
  • Nothing is nested deeper than one level.

slugify(title, max_bytes)

  1. Unicode lowercase.
  2. Alphanumerics (char::is_alphanumeric) survive; every other character becomes a separator.
  3. Separator runs collapse to one -; leading and trailing - are trimmed.
  4. Truncate to max_bytes at a char boundary, then trim a trailing - again.
  5. An empty result becomes task.

Non-ASCII survives: "Первая задача" → первая-задача.

Case-insensitive and normalizing filesystems

Only Linux is tested. The guards in the code are for terminal statuses (two that differ only in ASCII case are rejected, §7, because they would be one directory) and attachment names (below). Everything else below is known to behave differently off Linux and is not checked:

  • Case-insensitive filesystems (the macOS and Windows defaults). Slugs cannot collide, because slugify lowercases. Attachment names and author prefixes are not lowercased, though. attach compares names ignoring case, so it never creates A.png beside a.png, and attach --force a.png over an existing A.png replaces the file, the entry and the index path (the old spelling is dropped from the index first, so a case-only change reaches every clone). A hand edit or a merge can still bring two such files together. Two prefixes that differ only in case (IV and iv) mint ids that name distinct folders on Linux and can fold into one elsewhere.
  • Unicode normalization. HFS+ stores names in NFD; APFS, the macOS default since 2017, keeps whatever form it is given. A Cyrillic slug minted on Linux (NFC) and git mv’d on an HFS+ volume can come back as a different byte sequence unless core.precomposeunicode is set, which git on macOS enables by default in new repositories.
  • Windows names. A terminal status called con, nul, aux or another reserved device name passes validation but cannot be a directory there, and so can an attachment of that name. Otherwise attach refuses what Windows cannot hold: :, *, ?, ", <, >, |, control characters and a trailing . or space.

6. File formats

t.md

# Fix login

Free-form body. May be empty.

The title is the first non-blank line, which after trimming must be #, whitespace, then a non-empty title (so # Title is accepted). Blank lines before it are skipped; anything else non-blank is a parse error (t.md must start with "# Title"). A leading UTF-8 BOM is skipped, here and in m.yml. The body is everything after the title line with one leading blank line (\n or \r\n) stripped. On write, leading and trailing newlines of the body are dropped and exactly one \n ends the file; otherwise the body is kept as is — #, --- and code fences inside it are preserved.

m.yml

status: doing
tags:
- auth
- bug
assignee: Ivan
created: 2026-09-16T10:00:00Z
updated: 2026-09-16T10:12:30Z
attachments:
- path: f/screenshot.png
  name: screenshot.png
  added: 2026-09-16T10:05:00Z
  by: Ivan
links:
- https://example.com/issues/12
related:
- '5'
  • Every field but status, created and updated defaults to empty.
  • Timestamps are RFC 3339, UTC, second precision (task::now() zeroes the nanoseconds), serialized with a Z suffix.
  • attachments[].path is always f/<name>.
  • tags entries written by yman are trimmed, lowercased and free of whitespace, commas and control characters (see commands.md §4). The reader enforces none of that: a hand-written tags: [UI] loads as it stands, and ls -t and yman tags fold case so it still matches and still counts.
  • Unknown top-level keys survive a rewrite. The reader keeps the lines a key owns verbatim and the writer replays them after related, so a field a newer yman writes — or one added by hand — is not destroyed by an older binary. They are preserved, not interpreted: nothing reads them and none of them appear in --json output. Comments, and unknown keys inside an attachments item, are still dropped. Trailing empty lines after an unknown block are dropped too, except under a keep header (|+, >+), where the empty lines directly after the value are part of it and kept, up to the first column-0 comment line. An indented # line inside the block is content and kept.
  • m.yml is read and written by hand in src/yml.rs; there is no YAML crate. The writer emits exactly the shape above: block sequences at column zero, [] for an empty list, null for an absent assignee, and a scalar plain unless that would not read back as the same string. It is single-quoted when empty, when it would read back as a number, a boolean or null, when it has leading or trailing whitespace (any Unicode whitespace, such as U+00A0, since the reader trims all of it), starts with --- or ..., starts with a YAML indicator (#,[]{}&*!|>'"%@ and the backtick, or -, ?, : followed by a space or nothing), or contains : , # or a trailing :. It is double-quoted when it holds an unprintable character. A value containing newlines becomes a literal block (|-, |, |+), or a double-quoted scalar when a line ends in a space or tab, or holds nothing but whitespace (U+00A0 included), which a block would lose or read back empty. The preserved unknown blocks follow, which is why a hand-edited file that interleaved one comes back with it moved to the end.
  • The reader is deliberately wider than the writer, because people edit this file and resolve merge conflicts in it: comments (after a quoted scalar too, status: 'todo' # x), flow sequences (tags: [a, b]), single- and double-quoted scalars with the full libyaml escape set (\a \b \v \f \N \_ \L \P included), folded blocks (>-) and sequences indented under their key are all accepted. A key repeated at the top level or inside an attachments item keeps its last occurrence, which is the half of a conflict a person usually means to keep. An assignee that is blank or - reads as absent, so a hand edit to '' and the - an older set -a - stored both mean unassigned.
  • A flow sequence is split on every , before its items are unquoted, so a quoted item cannot contain a comma: tags: ['a,b'] is rejected with unterminated single-quoted string: 'a (and ["a,b"] with its double-quoted counterpart). The writer never produces flow form, so this only bites a hand edit; write such an item as a block sequence (- 'a,b') instead.
  • A status outside config.statuses.list is reported, never a hard error, so editing the config cannot brick existing tasks. An empty status — bare, null, or whitespace — is a different thing: it reads as a missing field, so the task is reported broken rather than loaded with no status at all.

Merging m.yml

.gitattributes marks **/m.yml as merge=ymanmeta, and yman init points that name at yman merge-driver %O %A %B in the clone’s own config. The attribute travels in the history; the merge.ymanmeta.* config does not, so every clone registers it for itself. A clone that never did falls back to git’s text merge, which is what m.yml got before the driver existed — an unregistered driver cannot corrupt a file.

A stale one is different. The registered path is current_exe at the time of init, and an upgrade can remove it (a Homebrew Cellar, a Nix store, target/debug). git then cannot run the driver, records the conflict, and leaves ours in the file with no markers. sync prints git’s own complaint and lists the file as (no conflict markers: binary, modify/delete, or the merge driver failed), and --continue will not take it as resolved — see commands.md §6. Rerunning yman init registers the current binary again.

Why it exists: the writer emits keys in one fixed order, so a status change on one clone and a tag change on another land on adjacent lines and the text merge conflicts. updated makes it worse — it changes on every edit on both sides, so it is a permanently-differing line wedged between created and attachments, dragging its neighbours into conflicts they do not deserve.

The driver merges field by field:

FieldRule
updatedthe later of the two; never conflicts
createdthe earlier of the two; never conflicts
status, assigneethe side that moved wins; both moved differently is a conflict
tags, links, related, attachments, unknown blocksmerged entry by entry (below)

A list is merged one entry at a time, so a tag added on each clone keeps both. Each entry is the same three-way decision as a scalar, over “is it there, and with what value”: added on one side stays, removed on one side goes, and only an entry changed differently on both sides conflicts. Entries are identified by themselves for tags, links and related, by path for attachments, and by the top-level key for an unknown block — so two clones attaching different files under one name, or writing the same unknown key with different text, do conflict.

The result’s order is the one thing the driver cannot take from either side. Git hands %A and %B over the other way round on the other clone, so anything derived from “ours first” would give the two clones two orderings of one set, and they would conflict on that at the next sync. So: entries that were already in the merge base keep the base’s order, and everything new is appended sorted by key. A merge can therefore reorder a task’s tags relative to the order the user typed them in — only on a merge, and only among newly added entries. A duplicate entry in a hand-edited list collapses to its first occurrence.

The driver is all-or-nothing. One conflicting field abandons the whole field-wise merge and hands the three files to git merge-file, so what the user resolves by hand is byte for byte what they would have seen without the driver: <<<<<<< markers in m.yml, yman sync exiting 3, yman sync --continue after. The two sides are labelled ours and theirs rather than HEAD and the ref name: the driver is handed three temporary files and never learns what is being merged into what. The same happens when any of the three versions does not parse — a half-resolved file, or the empty %O git passes when the two sides share no ancestor.

d.md

Append-only. One entry is exactly:

## 2026-09-16T10:10:00Z — Ivan

comment text, possibly several lines

i.e. ## {rfc3339} — {author}, blank line, text, blank line. The separator is an em dash.

A new entry starts only at a line of exactly that shape: ## , a timestamp with no whitespace in it, —, an author. Any other line beginning with ## is prose and stays inside the entry it was written in, so quoting a markdown heading in a comment does not inflate the comment count ls reports. Unparsable chunks are kept as raw text, so a hand-edited or union-merged file never makes show fail; they are not comments, and the count leaves them out.

Two things the writer does to keep that true:

  • Body lines whose first non-blank character is # are indented by one space. Markdown still renders them as headings, and show strips the space back off, but a comment can no longer contain something that reads as an entry header.
  • Before appending, it checks the file already ends with a blank line and supplies whatever is missing. Every entry we write ends in \n\n; a hand-edited tail may not, and a header glued onto the previous line would degrade that whole chunk to raw text.

.gitattributes marks **/d.md as merge=union, so concurrent comments merge without a conflict. The pattern is **, not *, because a * does not cross a / and a closed task’s discussion is one level deeper.

A retitle or a close moves the folder, and a comment written on another clone against the old name still lands in the new one: an existing d.md follows the rename, and a first comment’s new d.md follows the directory rename, which sync asks git to apply rather than stop on. Attachments need one more step; see commands.md.

A comment or attachment added on one clone to a task the other clone removed does not conflict either: git sees a delete and an unrelated add, and the merge leaves a folder holding only the new files, with no t.md or m.yml. The removal wins, the same rule m.yml follows (“removed on one side goes”), and ids are never reused, so sync drops the folder with a note rather than raising a conflict nobody could resolve into a loadable task — see commands.md.

7. config.toml

Committed inside .yman/, so a project agrees on it once.

version = 1

[ids]
scheme = "seq"        # "random" | "seq" | "author"
random_len = 4        # hex chars, random scheme only

[statuses]
list = ["todo", "doing", "done"]
default = "todo"
# version 2 only, all optional:
# start = "doing"           # yman start;  default list[1]
# done = "done"             # yman done;   default list.last()
# cancel = "cancelled"      # yman cancel; no default
# terminal = ["done"]       # closed statuses, hidden by ls

[priorities]
default = 5           # 0..=9

[slug]
max_bytes = 200       # hard cap 240

Validated on every load; each failure is reported as invalid .yman/config.toml: <why>:

Rule
version is 1 or 2
statuses.list.len() >= 2
every statuses.list entry is non-blank, has no edge whitespace or control character, and appears once
statuses.list contains statuses.default
statuses.start, .done, .cancel, .terminal need version = 2
statuses.start, .done, .cancel are in statuses.list
every statuses.terminal entry is in statuses.list
every statuses.terminal entry is a usable directory name
statuses.terminal has no repeat, and none differing only in ASCII case
statuses.terminal contains the done status, and the cancel status when set
statuses.done is named whenever statuses.terminal lists more than one status
statuses.terminal excludes statuses.default and the start status (version = 2 only)
priorities.default <= 9
slug.max_bytes in 1..=240
ids.random_len in 2..=16

[priorities] and [slug] may be omitted entirely.

Status roles

Three roles name the statuses the verbs move to, and one list says which statuses count as closed:

KeyMeaningWhen unset
statuses.startyman startlist[1]
statuses.doneyman donelist.last()
statuses.cancelyman cancelthe command refuses
statuses.terminalclosed: hidden by ls[done status]

Naming them is what stops a reordered list from silently changing what yman start means. For the same reason statuses.done is required as soon as statuses.terminal names more than one status: with one closed status the done ∈ terminal rule pins it down, but with two, list.last() decides what yman done means and can get it wrong without failing. Leaving them unset reproduces the positional behaviour exactly, so an untouched file keeps working.

A terminal status doubles as a directory name (see §5), so it is restricted to letters, digits, _ and -, at most 64 bytes, and must not start with -. That grammar also keeps a status from colliding with config.toml, .git or a task folder, since all of those contain a .. Two terminal statuses differing only in ASCII case are rejected because they are one directory on macOS and Windows.

Two rules — terminal excluding default and excluding the start status — apply only at version = 2; excluding default already keeps one status open. A version 1 list of ["todo", "done"] derives start == done == the terminal status and works fine; enforcing the rules against it would reject a config that ships today.

Opting in to version 2

yman init writes version = 1, so a new repository stays readable by older binaries. Raising the version is a hand edit, and it is what turns archiving on:

version = 2

[statuses]
list = ["todo", "doing", "blocked", "done", "cancelled"]
default = "todo"
start = "doing"
done = "done"
cancel = "cancelled"
terminal = ["done", "cancelled"]

From that point an older yman refuses the repository outright with unsupported version 2 (this yman understands 1) — which is the intended failure, because it also would not find the archived tasks.

8. Id schemes

Chosen at init, stored in config.toml, identical for everyone on the project.

SchemeShapeNext id
seq1, 2, 14highest all-digit id taken, plus one
authoriv-1, an-3highest {prefix}-N taken, plus one
randomt-7f3at- plus random_len hex chars, redrawn until free

A scheme with no id left fails instead of looping or wrapping: random once every random_len-character id is taken, seq and author past 18446744073709551615.

“Taken” means the union of the ids on disk and every id that ever had a file added under it across LOCAL and REMOTE (git log --no-renames --diff-filter=A --name-only -z). Deleting a task therefore never frees its id — a later add cannot reuse it and confuse the history. Rename detection is off because a sync renumber is a git mv, which would otherwise read as a rename and hide the id it lands on; -z keeps paths unquoted whatever characters they hold.

The author prefix resolves in order: git config yman.author, $YMAN_AUTHOR, then the initials of user.name (first letter of each word, lowercased, ASCII letters only). With none of those available, add fails rather than guess.

The prefix must match [A-Za-z0-9_-]+, and whichever source supplies it is checked every time it is read. A setting that is empty or all whitespace counts as unset and falls through to the next source; anything else that fails the grammar fails add, naming the value and the source it came from. The reason is §5: the prefix lands in the folder name, where a . is read as the id separator. yman init --author stores the value without checking it, so a bad prefix surfaces at the first add. Ids minted before the rule are left alone — a collision renumber re-derives the prefix from the id it finds, so an old task stays renumberable.

Errors, exit codes and output streams

Normative, as-built. Companion documents: storage.md, commands.md.

Streams

StreamCarries
stdoutdata only — task listings, show, path, log, status, summaries
stderrerror: , warning: , note: , and git’s own output when passed through

This split is a contract, not a style choice: cd $(yman path 14) and yman ls --json \| jq must stay usable. Nothing decorative goes to stdout, and there is no colour anywhere.

The rm and tags rm confirmation prompts are on stderr too, since they are not data. Every stderr line yman writes starts with error: , warning: , note: , or two spaces when it continues the line above: the command to add under note: hook <name> exists; add this line to it:, each file under the note: unmerged files: that sync prints when a merge stops, git’s stderr quoted inside a message (git <sub> failed: …, warning: refresh failed: …) and the ! [remote rejected] … line sync prints for a refused push. Git’s own output, printed verbatim above a failure, is the one exception.

Exit codes

CodeMeaningProduced by
0success
1errorany anyhow error reaching main
2usage errorthe argument parser, before anything runs
3a sync merge is unresolvedthe MergePending marker error
4no task has the given idthe NotFound marker error, from task::find

yman git is outside this table: it exits with git’s own code, which can be 1, 128 or anything else git uses, or 128 + signal when git was killed, so a 3 or 4 after it means nothing to yman.

A write to a closed pipe is not an error either: yman ls | head -1 ends with yman killed by SIGPIPE once head has gone, silently, the way git and coreutils end. A shell reports that as status 141 (128 + 13), and only for the command on the left of the pipe; command substitution such as cd $(yman path 14) reads everything and never sees it.

Messages that carry code 3, beyond the preflight and merge ones: a sync --continue that would leave two folders for one id reports duplicate task id <id>: <relA>, <relB>; delete one folder, then: yman sync --continue.

Code 4 is only task <id> not found. A folder that exists but is broken or duplicated is a repository problem, not a missing task, and stays at code 1. A verb given several ids exits 4 at the first missing one, with the tasks before it already committed.

Codes 3 and 4 travel as distinct error types (src/errors.rs) and are recognised in main by downcasting. Returning the same text as a plain anyhow error would silently degrade it to code 1, which scripts cannot distinguish from a genuine failure.

Every error prints as error: {message}, using anyhow’s {:#} so the whole context chain is shown.

Pinned messages

These strings are part of the interface. Several are asserted verbatim in tests/cli.rs; changing one means changing the test and this table in the same commit.

SituationMessage
not in a repositorynot inside a git repository
.yman missing.yman is not initialized; run: yman init
.yman HEAD wrong.yman worktree is not on refs/yman/local; run: yman init
init cannot re-attach HEAD without losing work.yman HEAD has commits or changes not on refs/yman/local; resolve them with yman git, then rerun: yman init
.yman is someone else’s directory.yman exists and is not a yman worktree; move it aside and rerun
.yman is a standalone repo.yman is a standalone git repository, not a worktree; move it aside and rerun
ref already checked outrefs/yman/local is already checked out in another worktree of this repo; only one .yman per clone is supported
unknown tasktask <id> not found
task present but unloadabletask <id> is broken: <dir>: <why>
duplicate folders for one idduplicate task id <id>: <relA>, <relB> — paths relative to .yman, so the two may differ only in their status directory
bad statusunknown status "<s>"; allowed: <list joined by ", ">
unsupported config versioninvalid .yman/config.toml: unsupported version <n> (this yman understands 1 and 2)
a status role on a version 1 configinvalid .yman/config.toml: statuses.<key> needs version = 2; bump version in .yman/config.toml
a status role outside the listinvalid .yman/config.toml: statuses.<start|done|cancel> "<s>" is not in statuses.list
a blank statuses.list entryinvalid .yman/config.toml: statuses.list has an empty entry
a statuses.list entry with edge whitespace or a control characterinvalid .yman/config.toml: statuses.list entry "<s>" has edge whitespace or a control character — <s> has control characters escaped
a repeated statuses.list entryinvalid .yman/config.toml: statuses.list lists "<s>" twice
a terminal entry outside the listinvalid .yman/config.toml: statuses.terminal entry "<s>" is not in statuses.list
a terminal entry unusable as a directoryinvalid .yman/config.toml: statuses.terminal entry "<s>" is not a usable directory name; use letters, digits, "_" and "-"
a repeated terminal entryinvalid .yman/config.toml: statuses.terminal lists "<s>" twice
terminal entries differing only in caseinvalid .yman/config.toml: statuses.terminal entries "<a>" and "<b>" differ only in case; they would collide on a case-insensitive filesystem
several terminal statuses and no named doneinvalid .yman/config.toml: statuses.terminal lists more than one closed status, so statuses.done must say which one \yman done` means`
the done status is not terminalinvalid .yman/config.toml: statuses.done "<s>" is not in statuses.terminal
the cancel status is not terminalinvalid .yman/config.toml: statuses.cancel "<s>" is not in statuses.terminal
default or the start status is terminalinvalid .yman/config.toml: statuses.terminal must not contain statuses.default "<s>" / … must not contain the start status "<s>"
empty titletitle must not be empty
title with a line break or another control character (the Unicode line and paragraph separators U+2028 and U+2029 and the bidi controls U+202A-U+202E and U+2066-U+2069 count as control characters here) (add, set --title, each add --sections heading, the title left by add -e or edit; tab included)invalid title "<t>"; titles must not contain line breaks or other control characters — <t> is the trimmed title with control characters escaped (\n, \t, \u{1b}); under add -e it arrives as t.md invalid after edit: invalid title …; task not added, under edit as t.md invalid after edit: invalid title …; fix the file then run: yman edit <id>
empty tagtag must not be empty
tag with a separator or a control character (the Unicode line and paragraph separators U+2028 and U+2029 and the bidi controls U+202A-U+202E and U+2066-U+2069 count as control characters here)invalid tag "<t>"; tags must not contain whitespace, commas or control characters — <t> is the trimmed value with control characters escaped
blank --relate or --waits-on valuerelated id must not be empty
set --relate or --waits-on naming the task itselftask <id> cannot relate to itself
ids.scheme = "random" with every id of random_len takenrandom id space exhausted (random_len = <n>); raise ids.random_len
seq or author scheme past the largest idid space exhausted: <max> is the largest id there can be
unknown attachmentno attachment "<name>" on task <id>
attachment name already used, ignoring case (an m.yml entry or a file under f/)attachment "<name>" already exists on task <id>; use --force
two sources with the same attachment nameattachment "<name>" given more than once
--name with several files--name only works with a single file
attachment source unusablecannot attach <path>: <why> / … not a regular file
attachment name with a separator, or . / ..attachment name "<name>" must not contain a path separator
attachment name a Windows checkout cannot holdattachment name "<name>" is not portable: <why> — <why> is contains '<c>' (one of :*?"<>|), contains a control character, ends with '.', ends with a space, it is a reserved device name or it is empty; <name> has control characters escaped
add onto an existing folderfolder already exists: <dir>
empty comment (comment, or -m on set and the verbs)empty comment
--body-file cannot be readcannot read <path>: <why> — <why> is the OS error text
cancel with no cancel statusno cancel status configured; set statuses.cancel in .yman/config.toml
add --sections text above the first heading<source>: line <n>: text before the first "# " heading — <source> is the path, or stdin for -
add --sections heading with no title<source>: line <n>: empty title
add --sections with no heading at all<source>: no "# " heading, so no tasks
add --sections with a code fence never closed<source>: line <n>: unclosed code fence — <n> is the line that opened it
--waits-on with no blocked in statuses.list--waits-on needs a "blocked" status; add it to statuses.list in .yman/config.toml
reopen on an open tasktask <id> is not closed (status "<s>"); closed statuses: <terminal joined by ", ">
rm or tags rm with no terminal and no -frefusing to remove without -f
editor could not be startedcannot run editor "<program>": <why>
editor failed during editeditor exited with status N; file left as is (or editor was killed by a signal; …)
editor failed during add -eeditor exited with status N; task not added (or editor was killed by a signal; …) — the new folder is removed
editor failed during comment -eeditor exited with status N; comment not added (or editor was killed by a signal; …) — the temp file is removed, nothing is committed
neither $VISUAL nor $EDITOR set, stdin not a terminalno terminal for vi; set $EDITOR, or use -m / --body-file
rm or tags rm prompt declinedaborted
t.md unparsablet.md must start with "# Title"
t.md unparsable after an editt.md invalid after edit: <why>; fix the file then run: yman edit <id>
t.md unparsable after add -et.md invalid after edit: <why>; task not added — the new folder is removed
invalid configinvalid .yman/config.toml: <why>
the history init checks out has no loadable config.toml<source> is not a yman history (missing or invalid config.toml): <why> — <source> is refs/tasks/main on origin when init adopted origin’s tasks, refs/yman/local when it found or repaired a local history
sync with no originno "origin" remote; tasks are local only. Connect one: yman init --remote <url>
unrelated historiestask history unrelated to origin refs/tasks/main; re-init from remote: rm -rf .yman && git update-ref -d refs/yman/local && yman init
author prefix unresolvableauthor prefix unknown; run: git config yman.author <prefix> (or set YMAN_AUTHOR)
author prefix not a legal id prefixauthor prefix "<p>" from <source> must be letters, digits, "_" or "-" — <source> is yman.author, $YMAN_AUTHOR or user.name initials
git identity missinggit identity missing; run: git config --global user.name "…" && git config --global user.email "…"
git binary absentgit not found in PATH
any other git failuregit <subcommand> failed: <stderr>, each further line of git’s stderr indented by two spaces, empty lines dropped
push lost the race to a moving origin three times (sync)origin keeps moving; retry yman sync
a sync renumber failed part-wayrenumber aborted; .yman restored: <why> — .yman is back at the commit before the renumber
push rejected while finishing a mergeorigin moved while finishing the merge; run: yman sync
network step failedfetch failed / push failed / merge failed, with git’s stderr printed above; a push origin refused (any rejection but the race, commands.md step 8) also prints git’s ! [remote rejected] refs/yman/local -> refs/tasks/main (<reason>) line, and is not retried
init fetch failedfetch failed (see above); use --offline to skip
init could not create .yman (any git worktree add failure but the second-checkout case above)git worktree add failed, with git’s stderr printed above
hooks install, remove or status with core.hooksPath set to the empty stringcore.hooksPath is empty, which turns git hooks off; unset it or point it at a directory
hooks install met a hook it does not own<n> hook(s) not installed: <names>, after note: hook <name> exists; add this line to it: and the line itself on stderr for each
hooks install met a hook it cannot read (permission denied, not UTF-8, a dangling symlink)the same <n> hook(s) not installed: <names>, after warning: hook <name>: cannot read <path>: <why>; not replacing it on stderr for each; <why> is not valid UTF-8, dangling symlink or the OS error
man --dir cannot writecannot create <dir>: <why> / cannot write man pages to <dir>: <why>
m.yml merge driver could not fall backgit merge-file failed, with git’s stderr printed above — reaches the user through git’s own merge output

Exit-code-3 messages

SituationMessage
mutating command during a mergesync merge in progress; resolve conflicts then run: yman sync --continue (or: yman sync --abort)
sync during a mergemerge in progress; resolve then: yman sync --continue (or --abort)
merge produced conflictsconflicts in N file(s); edit them, remove markers, then: yman sync --continue (or: yman sync --abort)
--continue with markers leftstill unmerged: <files>
--continue with a conflict that has no markersstill unmerged: <files>; no markers to remove in <files>, stage the version to keep: yman git checkout --ours|--theirs -- <file> && yman git add <file>
--continue with an unloadable taskconflict markers or invalid task in <dir>: <why>
--continue with an unloadable config.tomlinvalid .yman/config.toml: <why>

sync --continue or --abort with nothing to finish reports no merge in progress with exit code 1, not 3: there is no merge pending.

Warnings and notes

Never fatal, always stderr:

MessageWhen
warning: id scheme is "<s>" (from config.toml); --id-scheme ignoredinit on an existing history with a conflicting flag
warning: origin already points at <url>; --remote ignoredinit --remote <url> where origin already exists with a different URL; the same URL is accepted silently
warning: remote already had tasks; adopted remote statetwo clones initialized the tracker at once
warning: not published to origin (<why>); run: yman synca fresh init created the tracker but its push failed (push failed, after git’s stderr), lost the race three times (origin keeps moving) or lost it and then failed the refetch (fetch failed, after git’s stderr); hooks and the summary still follow, exit 0
warning: refs/tasks/main disappeared from origin; will recreate itthe ref was deleted server-side
warning: <name> is N MiB; git is not great at large binariesattaching a file over 5 MiB
warning: installing into core.hooksPath=<p>hooks redirected away from .git/hooks (a whitespace-only value included, untrimmed); <p> is the directory as git resolves it (~/ expanded, a relative value joined onto the toplevel)
warning: cannot read <path>: <why>hooks status or hooks remove met a hook it cannot read; it reports the hook as unreadable and leaves it alone
warning: refresh failed: <why>the lazy refresh before a command failed; the command still runs
warning: origin has new task commits; run: yman syncyman.autosync = push and the push after a change was rejected; nothing is merged, the change stays committed
warning: autosync failed: <why>; run: yman syncyman.autosync = push and the push after a change failed (offline, auth); the command still succeeds
warning: task <id> does not exist here; relating anywayadd --relate/--waits-on or set --relate/--waits-on naming an id no folder here carries
warning: <id> closed with open tasks relating to it: <ids>a close (as for the note below) of a task tagged epic while tasks relating to it are still open; the close is committed
warning: skipped N unreadable task folder(s): <rels>tags, tags rename, tags rm and rm walking every task
warning: <path> not moved; <dest> already existssync rejoining a split folder found the same file on both sides
note: added remote "origin" -> <url>init --remote created the remote
note: no "origin" remote; tasks stay local until you run: yman init --remote <url>init in a repo with no origin and no --remote
note: re-attached .yman HEAD to refs/yman/localinit repaired a .yman HEAD that had lost its symbolic ref
note: .yman has uncommitted changes, refresh skippedyman refresh backed off from a dirty .yman
note: pushed N task commit(s)yman.autosync = push published a change
note: local has unpushed commits; run: yman syncyman refresh found local ahead of or diverged from the remote
note: rewrote N reference(s) to renumbered idsa collision renumber moved ids other tasks related to
note: moved N file(s) left under <old> into <rel>a merge left files under a folder the other side had moved
note: dropped <rel>: task <id> was removed on one side; N file(s) added on the other are gonea merge left a comment or attachment under a task the other side had removed
note: dropped N reference(s) to <id>rm cleared the removed task out of other tasks’ related
note: task <id> was closed to two different statuses; keep one of <relA>, <relB>a merge moved one task into two different closed-status directories
note: task folder is now <rel>a status change moved the folder into or out of a status directory
note: <id> no longer waits on anything open: yman move <id> <default status>a close (done, cancel, move/set --status into a terminal status), or rm of an open task not tagged epic, left a blocked task relating to it with nothing open to wait on; nothing is moved
warning: committed, but could not read the tasks it affects: <why>rm, set or a status verb committed, then could not read the tasks back for its report; exit 0

Deliberate non-errors

  • Committing when nothing is staged — an identical re-application is a no-op.
  • Removing a tag, link or relation that was never there.
  • A status value that is no longer in config.statuses.list: reported, so a config change cannot brick existing tasks.
  • A task folder sitting somewhere other than where its status says it belongs: it is listed correctly, and the next set moves it.
  • A fetch that fails only because origin has no refs/tasks/main yet.
  • A task folder that fails to load: it becomes a broken entry that ls shows and other commands refuse individually, rather than an error that hides every other task.

Changelog

All notable changes to yman are recorded here. The format follows Keep a Changelog, and versions follow Semantic Versioning. How a release is cut is described under Releasing in the README.

Unreleased

Added

  • skills/yman-task/, a second agent skill: /yman-task <text> files the user’s own words as tasks tagged user-request and needs-agent, quoting the original, so the agent that picks one up re-checks it before working.

0.7.0 - 2026-10-13

Added

  • Cargo.toml declares rust-version = "1.88", the first release with let-chains, which the code uses.

Changed

  • yman guide and the skill say that yman plan’s rows are enough to pick the next step, so agents stop running show on every listed step.
  • Titles and tags refuse the Unicode line and paragraph separators and the bidi override and isolate characters, which could make ls output misleading, with the existing control-character errors; a refused tag is shown escaped.
  • Git’s multi-line stderr quoted in git <sub> failed: … (and so in warning: refresh failed: …) has its continuation lines indented by two spaces and blank lines dropped, and sync’s push-refusal line is indented two spaces, so every stderr line yman writes carries a prefix or continues one.
  • Every stderr line now carries its prefix or continues one: the rm and tags rm prompts moved from stdout to stderr, refreshed: N new commit(s) became note: refreshed N new commit(s), the hook advice is a note: and an unreadable hook a warning:, and sync introduces its list of conflicting files with note: unmerged files:.
  • yman add --sections walks the task history for taken ids once per run instead of once per section.

Removed

  • The cannot attach <path>: no file name and statuses.terminal marks every status terminal errors, which could never fire: the not a regular file check and the rule that statuses.default is not terminal refuse first.
  • The no editor configured; set $EDITOR error, which could never fire: the editor resolver always falls back to vi or refuses earlier.

Fixed

  • A fresh yman init whose push lost the race and whose refetch then failed no longer exits 1 with the tracker half set up: it installs hooks, prints the summary and warns not published to origin (fetch failed); run: yman sync.
  • add --sections reads a line like ```x``` inline as text, as CommonMark does, instead of opening a fence and refusing the file with unclosed code fence.
  • attach --force A.png over a.png on a case-insensitive checkout (macOS, Windows) records A.png in git, matching m.yml, instead of leaving the index at a.png and every Linux clone with a dangling attachment.
  • attach refuses Windows reserved device names (CON, NUL, COM1, LPT1… with or without an extension, any case), which Git for Windows cannot check out, with the existing is not portable error.
  • Hooks defined in git configuration (hook.<name>.command, git 2.54+) no longer fire for yman’s commits, merges and checkouts in .yman, and yman’s own update-ref, fetch and push no longer run the user’s reference-transaction hook. yman hooks refuses an empty core.hooksPath, which turns git’s hooks off, instead of installing into .git/hooks and reporting installed, and no longer trims a whitespace-only one.
  • A comment line after an unknown |+ block in m.yml is dropped like every other comment, instead of surviving when a blank line followed it. An indented # line inside an unknown key’s block is content and is kept.
  • An m.yml value with a line of only Unicode whitespace (a U+00A0, say, from set --link) is written double-quoted, so it no longer reads back empty or chomped.
  • A blank or - assignee in m.yml reads as unassigned (ls --assignee - lists it, --json prints null), and set -a - / add -a - unassign instead of storing a literal -.
  • rm, set and the status verbs no longer exit non-zero after their commit has landed when reading the tasks back for the “no longer waits” report fails; that is now a warning, so a script that retries on failure does not apply the change twice.
  • An aborted yman add -e into a closed status under an archive config also removes the status directory it created, and a copy failing part way through yman attach (a full disk) restores f/ as it was, including files --force had already replaced, instead of leaving the earlier copies behind.
  • A stopped sync no longer says a task “was closed to two different statuses” when the second folder is only a file left under the task’s old name after a retitle or close; the note now needs two folders that carry the task under two different closed statuses.
  • A t.md with CRLF line endings no longer keeps a stray \r\n at the top of its body, and a UTF-8 BOM no longer breaks t.md (must start with "# Title") or hides m.yml’s first key.
  • The comment count in ls and ls --json leaves out unparsable d.md chunks.
  • yman tags rename replaces every spelling of the old tag on a task, not only the first ([UI, ui] renamed to x gave [x, ui]).
  • yman tags --json rename … and yman sync --abort --no-push are refused as usage errors instead of silently ignoring the extra flag.
  • Shell completions offer file paths for --body-file and --sections.
  • rm on one clone and a first comment or attachment on the same task on the other no longer merge into a folder holding only d.md (or f/) that no command could load: sync drops it with note: dropped <rel>: task <id> was removed on one side; ….
  • yman sync no longer dies after the merge with Not a valid object name HEAD^2 when one merge needs both a rejoin and a remnant drop, which left the unloadable remnant for the next sync to push. Nor does it die with pathspec … did not match any files on a removed task’s folder that rm left holding only an ignored file such as *.swp; there is nothing to drop there, and the sync goes through.
  • yman detach matches the attachment name ignoring case, as attach does: detach 3 A.png removes an a.png (under its stored spelling) instead of failing with no attachment "A.png" on task 3.
  • yman init whose first push fails no longer stops before --hooks and the summary: the tracker exists locally, so it finishes and warns not published to origin (<why>); run: yman sync.
  • yman init names the history it found broken (refs/yman/local or refs/tasks/main on origin) instead of always blaming origin.
  • The merge driver’s path is single-quoted in git config, so a binary under a path containing ", $, a backtick or backslashes still runs.
  • Repository discovery works with git older than 2.31, which echoes --path-format=absolute back instead of honouring it.
  • yman sync pushes the commit refs/yman/local held when the push began, by sha, and records that commit as published. A commit made by another command during the push used to be marked published without reaching origin, and autosync then never pushed it.
  • A sync renumber that fails part-way resets .yman to the commit before it (renumber aborted; .yman restored) instead of leaving half-moved folders for the next sync to snapshot.
  • yman sync --continue reports how many commits the merge pulled instead of pulled 0, gives the git-identity hint when committing fails for want of one, and no longer follows symlinks while scanning for conflict markers (a committed symlink loop recursed until it failed).
  • yman sync and yman init no longer overwrite the main repository’s FETCH_HEAD.
  • Run from a git hook or alias, yman strips every variable git rev-parse --local-env-vars lists, plus GIT_NAMESPACE, instead of five of them, and yman git strips the same set, so it acts on .yman rather than on the repository the hook was running for. A yman git whose git is killed by a signal exits 128 + signal instead of 1.
  • yman attach needs --force whenever the task already lists the name, even when its file is gone, and compares names ignoring case: --force A.png over a.png replaces the file and the entry rather than adding a second entry. Names a Windows checkout cannot hold (:*?"<>|, control characters, a trailing . or space) are refused.
  • yman add --sections refuses a code fence that is never closed (<source>: line <n>: unclosed code fence) instead of swallowing every later heading, and a fence line with an info string (```sh) inside an open block no longer closes it. Fences follow CommonMark: a longer fence holds shorter ones.
  • m.yml round trips: a value with Unicode whitespace at an edge (U+00A0) is quoted so it reads back intact; a comment after a quoted scalar (status: 'todo' # x) no longer fails as unterminated; the libyaml escapes \a \b \v \f \N \_ \L \P are read; and an unknown |+ block keeps its trailing blank lines on rewrite.
  • .yman/config.toml refuses a blank, edge-whitespace or repeated statuses.list entry. A blank status used to let add -s '' commit a task whose m.yml read back as broken.
  • --relate and --waits-on trim their values and refuse a blank one, and set refuses a task relating to (or waiting on) itself.
  • Id generation reports an exhausted id space instead of looping forever (ids.scheme = "random" with every random_len id taken) or overflowing (seq/author past u64::MAX).
  • Ids are sorted by a total order: plain numbers, then prefix-N by prefix and number, then the rest lexically. The old pairwise rules could form a cycle (9 < 10 < 5-a < 9), on which Rust’s sort may panic. The one visible change is that a prefix-N id such as z-1 now sorts before a random id such as t-7f3a.
  • yman refresh on a dirty .yman prints one note, note: .yman has uncommitted changes, refresh skipped, instead of following it with a second note: worktree has uncommitted changes.
  • yman ls --help describes -a as including closed tasks (any terminal status) rather than “the final status”, which predates terminal sets.
  • yman set -a now trims its value and treats a blank one as unassigned, as yman add -a always has; it used to store " bob " or " " verbatim.
  • --help for edit, add -e and comment -e names $VISUAL before $EDITOR, the order the editor is looked up in, and yman --help lists every editor-free way to give a body (add -m, set --body-file were missing). The agent manual and the skill list plan among the commands that take --json and say that ls and plan carry body only with -l; the docs add the autosync key of status --json, the yman.autosync setting init writes, and the git worktree add failed error.

0.6.1 - 2026-10-04

Fixed

  • yman ls | head -1, and any other output piped into a reader that quits early, now ends quietly the way git and coreutils do (killed by SIGPIPE, shell status 141) instead of panicking with failed printing to stdout: Broken pipe on stderr.
  • A multi-id verb that fails part-way (yman done 32 99) now prints the close report — epic warning, no longer waits notes — for the ids it did commit before returning the error; it used to drop it. yman rm of an open blocker now prints the no longer waits note for a blocked task it freed, as closing the blocker would. yman attach checks every source before copying any, so a missing second file or two sources with the same name (now attachment "<name>" given more than once) no longer leave an untracked copy of the first one under f/.
  • yman hooks now finds the hooks directory the way git does, through rev-parse --git-path hooks. A core.hooksPath of ~/.githooks used to be taken literally, so the hooks landed in <project>/~/.githooks where git never runs them. A hook yman cannot read — permission denied, content that is not UTF-8, a dangling symlink — used to count as absent: install overwrote it, or wrote through the link and created its target. It is now refused like any foreign hook (hook <name>: cannot read <path>: <why>; not replacing it) and hooks status reports it as unreadable.
  • The user’s post-commit, post-merge, post-checkout and post-rewrite hooks no longer run for yman’s own commits, merges and fast-forwards in .yman, nor for the git worktree add that creates it. --no-verify only skipped the pre-hooks, so a husky, lefthook or LFS hook that wrote files left .yman dirty: refresh then backed off for good and the next snapshot committed the junk. Every git call in .yman now runs with core.hooksPath=/dev/null; hooks in the project itself, yman hooks included, fire as before.
  • A push that origin refuses — a pre-receive hook declining it, a protected or denied ref — is no longer mistaken for origin having moved. sync used to retry it three times and end in origin keeps moving, autosync used to say origin has new task commits, and init then failed resetting to a refs/yman/remote that did not exist. The push now runs with --porcelain and only fetch first, non-fast-forward, incorrect old value provided and reference already exists count as the race; anything else fails at once with push failed and git’s reason on stderr. An init whose push loses the race but finds nothing to adopt on refetch pushes again instead of resetting.
  • A title containing a newline, a carriage return, a tab or another control character is refused by add, set --title, add --sections, add -e and edit with invalid title "<t>"; titles must not contain line breaks or other control characters; edit leaves the text on disk to be fixed, as it does for a file that does not parse. A newline used to be split into a title and a body line in t.md and to break the commit subject over several lines.
  • An add -e whose editor fails, or whose edited t.md does not parse, no longer leaves an untracked task folder behind for ls to list and the next sync to publish. The folder is removed and the error says task not added.
  • comment -e no longer writes a fixed yman-comment-<id>.md in the temp dir. On a shared /tmp a symlink planted at that path made it truncate another file, and two runs on one id shared the file. The temp file now gets a fresh name, is created exclusively with mode 0600 and never through a link, and is removed on every path; a refused editor creates none. A failing editor says comment not added instead of file left as is.
  • The docs no longer call comment <id> with stdin safe for scripts: without -m it reads stdin to end of file whenever stdin is not a terminal, so a caller that leaves stdin open blocks. docs/commands.md, yman guide and the skill now say to pass -m or redirect stdin.
  • An id a sync renumber moved a task to is no longer handed out again after that task is removed. The history scan read the renumber’s git mv as a rename, not an add.
  • A sync merge that conflicts in config.toml no longer locks out every command: sync --abort and sync --continue still run, mutating commands exit 3 as documented instead of 1, and --continue refuses a resolution that does not load.
  • yman init re-attaches a detached or misplaced .yman HEAD to refs/yman/local, as the preflight error advises, instead of failing with that same error. It refuses when HEAD has commits or edits the ref lacks.
  • Commands run from inside .yman — cd $(yman path 14) — find the project instead of failing with .yman is not initialized, and init there no longer nests a second worktree at .yman/.yman.
  • yman init from a linked worktree of the project refuses to create a second .yman on the same ref; git does not guard a --detach checkout.
  • Under a localized git, init and sync against an origin with no tasks no longer fail with fetch failed, and re-applying an identical change is no longer an error. Every git child now runs with LC_ALL=C.
  • sync --continue no longer takes a conflict git could not mark up (a binary attachment, modify/delete, or an m.yml whose merge driver path is gone) as resolved, which kept ours and dropped theirs without a word. Such a file must be staged by hand, and sync now prints git’s stderr from the failed merge, so a stale driver’s No such file or directory is visible.

0.6.0 - 2026-10-04

Added

  • yman plan <id>: a task, a per-status count over every task relating to it, and the open ones sorted like ls, with waits on <ids> on a blocked step. -a, -n, -l and --json as on ls.
  • --waits-on <id> on add and set: relate to a blocker and set status blocked in one commit. Needs blocked in statuses.list.
  • Closing a task (done, cancel, move/set --status) notes on stderr each blocked task that now waits on nothing open, with the yman move that frees it. Nothing is moved automatically.
  • add --sections <path|->: one task per # Title section of a markdown file, body up to the next heading, every other flag applied to each. A heading inside a code fence is body text; a bad file fails before any id is minted.
  • Closing a task tagged epic while tasks relating to it are still open warns on stderr and names them; the close still goes through.

0.5.1 - 2026-10-01

Added

  • Published to crates.io as yman-tracker: cargo install yman-tracker. Cargo.toml gained the registry metadata and an exclude list, and the release steps in the README end with cargo publish.

Changed

  • The README says where a cargo install leaves the skill (the crate’s unpacked source under ~/.cargo/registry/src/) and how to copy it into a project or into ~/.claude/skills/.

0.5.0 - 2026-10-01

Added

  • yman ls --related <id> keeps the tasks whose related list carries that id, so the steps of a plan can be listed from their epic without editing it. It ANDs with the other filters; an id nothing relates to lists nothing.
  • yman show prints related by:, the tasks whose related carries this one, closed tasks included, and show --json always carries related_by. related stays one-way and nothing is stored; show reads every m.yml once to find them.
  • The agent manual (yman guide, the skill and the README’s “Scripts and agents”) describes plans: an epic task, steps that --relate it, priority for order, and blocked plus a relation to the blocker for a step that waits. ls --related and related by: read the one-way relation from either end, so an epic is never edited when a step is added. The guide’s line cap in tests/cli.rs rose from 60 to 72 to fit the section.
  • yman init --autosync push (git config yman.autosync): every command that changes a task pushes it to origin once it has committed, and reports note: pushed N task commit(s) on stderr. It never fetches or merges; when origin has moved it warns origin has new task commits; run: yman sync, and a failed push is a warning, never the command’s exit code. yman status shows autosync: push when it is on, and status --json always carries "autosync".
  • The agent manual (yman guide, the skill and the README’s “Scripts and agents”) now says when to run yman sync: before any status change, so an agent sees another clone’s claim or close before overwriting it, and after claiming and closing, to publish. Before, it described sync but never asked for it, so agents left task history unpushed. The claim-task and close-task skill evals check both.
  • A documentation site, built with mdBook from the README, docs/ and the changelog and published to GitHub Pages: https://brillliand.github.io/yman-tracker/. scripts/book.sh builds it locally and fails on broken links or anchors.

Fixed

  • docs/commands.md and docs/errors.md were checked against the code and corrected where they had drifted: the mutating-command list, the init commits, refresh notes, exit codes after yman git and for no merge in progress, and messages the tables were missing.
  • docs/storage.md and docs/setup.md were corrected the same way: what the spike script actually enforces, how t.md and m.yml are written, where hooks go, which commands use the network, and the filesystem notes added in the previous entry, which overstated one gap and missed the Windows ones.
  • The three copies of the agent manual (yman guide, the skill and the README’s “Scripts and agents”) agree again: the task-list rule no longer asks for an assignee ls does not print, rm -f and the duplicate-id recovery are in all of them, and the README no longer says init adds one config line or that sync is the only command using the network.
  • yman edit retitling a closed task in a version 2 repository no longer fails halfway with pathspec ... did not match any files; the task is renamed inside its status directory instead of being moved to the top of .yman.
  • yman sync renumbering a colliding task that is closed, in a version 2 repository, keeps it in its status directory. It used to move the task to the top of .yman while m.yml still said it was closed.

Changed

  • The README now calls Windows untested, and docs/storage.md §5 records the known case-insensitive and Unicode-normalization pitfalls on macOS and Windows.

0.4.0 - 2026-09-23

Added

  • yman completions <shell> prints a completion script for bash, zsh, fish, elvish or PowerShell, and yman man [--dir <dir>] prints the man page or writes every page to a directory. Both are generated from the CLI definition itself and work outside a repository.
  • yman init in a repository with no origin sets up a local-only tracker instead of refusing. status reports it, sync says to connect a remote, and yman init --remote <url> does so later.

Fixed

  • show prints the discussion in timestamp order, so two comments written concurrently on two clones no longer appear out of order after a sync. Unparsable chunks keep their place; d.md itself is unchanged.

Changed

  • The yman skill formats a task list as a table and suggests what to take next.

0.3.0 - 2026-09-23

Added

  • yman ls -l prints each task’s body, indented under its row; --json gains body only under -l.

Fixed

  • sync keeps a moved task’s new files with the task: a first comment or an attachment added on another clone under the old folder name follows a retitle or a close instead of stopping with exit 3 or being orphaned.

Performance

  • yman log <id> finds a task’s history by a pathspec on its id rather than by rebuilding the rename graph, and no longer shows unrelated tasks’ commits when git paired two folders as a rename. About 400 ms down to 125 ms on the synthetic 1000-task repository.

0.2.0 - 2026-09-23

First tagged release.

Added

  • The tracker itself: task history in refs/yman/local, checked out at .yman/; init, add, ls, show, edit, set, rm, comment, attach, log, status, path, sync, refresh, the git passthrough and the optional git hooks.
  • move, cancel and reopen; state verbs take several ids.
  • Named status roles and a terminal set in the config; closed tasks are archived into a per-status folder, and ls hides every terminal status.
  • The tags command, which renames and removes a tag across all tasks; tags are validated and lowercased, and ls -t matches case-insensitively.
  • --json output for show and status.
  • yman guide, and a description for every flag.
  • Bodies set without an editor on add and set, set -m appending a comment in the same commit, and edit refusing the vi fallback without a terminal.
  • add accepts an assignee, links and related tasks.
  • show -n caps the discussion; ls filters and limits without reading the whole backlog.
  • comment and attach take the author from YMAN_ACTOR.
  • Exit 4 when a task id does not exist.
  • sync merges m.yml field by field and union-merges its list fields, and rewrites related references when it renumbers a task.
  • The yman agent skill and its evals, and CI running the quality gate.

Fixed

  • sync keeps a task’s id through a retitle, only renumbers ids minted locally, and follows tasks into status directories.
  • Related references stay consistent.
  • An attachment whose file is gone is marked as such.
  • Attachments matching .yman/.gitignore are staged.
  • The d.md entry header is guarded on write and on read.
  • Unknown m.yml keys survive a rewrite.
  • An author prefix that would break folder names is rejected.
  • statuses.done is required once more than one status is closed.

Performance

  • Refs are read from disk instead of by spawning git.
  • A task is looked up from folder names alone.