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:
| Command | Sees the task history? |
|---|---|
git branch -a | no |
git status, git log, git diff | no |
| your IDE’s branch list | no |
| your CI provider | no — refs/tasks/main is not a branch |
git log --all, git show-ref, git worktree list | yes |
.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.
| Thing | Where it lives |
|---|---|
| Task history, locally | refs/yman/local |
| What origin last told us | refs/yman/remote |
| The ref on the server | refs/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
| Code | Meaning |
|---|---|
0 | success |
1 | error — the message says what happened |
2 | usage error, from the argument parser |
3 | a sync merge is waiting to be resolved |
4 | no 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.
| Scheme | Ids look like | Best when |
|---|---|---|
seq (default) | 1, 2, 14 | Small team, short ids worth typing |
author | iv-1, an-3 | Several people adding tasks offline a lot |
random | t-7f3a | Ids 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
| Key | Values | Set by |
|---|---|---|
yman.refresh | lazy (default), manual | yman init --refresh |
yman.autosync | off (default), push | yman init --autosync |
yman.author | letters, digits, _ or - | yman init --author |
Two environment variables, easy to confuse:
| Variable | Used for |
|---|---|
YMAN_AUTHOR | fallback id prefix for the author scheme, after yman.author |
YMAN_ACTOR | display 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.refresh | What happens |
|---|---|
lazy (default) | Every read or write command fast-forwards .yman onto whatever refs/yman/remote already holds |
manual | Only 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,linksorrelatedrelative 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.
addslows as the project ages. An id is never reused, which means reading every id the history ever assigned before minting one. Measured withscripts/synthetic-project.shon 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 layoutsrc/refs.rsdoes not recognise falls back torev-parse. On that same repositoryyman lscosts 1 process and 18 ms,yman show1 and 6 ms,yman add4. 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-nstill 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
.ymanworktree 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:
| Path | What it holds |
|---|---|
src/git.rs | the git runner; every invocation goes through it |
src/repo.rs | repository discovery, .yman state, preflight checks |
src/task.rs | folder names, slugs, t.md/m.yml, load and save |
src/yml.rs | the m.yml reader and writer, hand-written |
src/ids.rs | id generation and what counts as taken |
src/refresh.rs | the fast-forward policy |
src/commands/ | one module per subcommand |
scripts/spike-symref.sh | checks the git behaviour the .yman worktree depends on |
scripts/synthetic-project.sh | builds a large repository and measures what commands cost on it |
scripts/book.sh, book.toml, docs/SUMMARY.md | the 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:
- Branch
release/X.Y.Zfrommain. - 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. - Bump
versioninCargo.toml;cargo buildupdatesCargo.lock. - Run the full gate (
cargo fmt --check, clippy,cargo test,scripts/spike-symref.sh,scripts/book.sh) andscripts/synthetic-project.sh. - Commit as
chore: release X.Y.Zand merge intomainwith--no-ff. - Tag the merge:
git tag -a vX.Y.Z -m "yman X.Y.Z", then pushmainand the tag. cargo publish --dry-run, thencargo 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
| Flag | Effect |
|---|---|
--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 |
--offline | do not fetch and do not push |
--hooks | also 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 toCOMMON/info/exclude;- the fetch refspec
+refs/tasks/main:refs/yman/remoteappended toremote.origin.fetch, when there is anorigin; yman.refresh,yman.autosyncwhen--autosyncwas given, andyman.authorwhen--authorwas given;merge.ymanmeta.nameandmerge.ymanmeta.driver, pointing at the running binary by absolute path. This pairs with the**/m.yml merge=ymanmetaline in.gitattributes, which travels in the history while the config does not — which is why every clone must runiniteven 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/localalready exists → keep it;- otherwise
refs/tasks/mainwas fetched fromorigin→ 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.refresh | Effect |
|---|---|
lazy (default) | every non-exempt command fast-forwards first; a failure is a warning:, never fatal |
manual | only 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
| Variable | Effect |
|---|---|
YMAN_ACTOR | the 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_AUTHOR | prefix for the author id scheme when yman.author is unset |
VISUAL, EDITOR | the 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
- 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. - Show the rows as a markdown table,
P | ID | Status | Title | Tags, in the orderlsprinted them. A! <dir> broken <error>row goes under the table as-is, not dropped. - Work out what can be taken next. Prefer the best priority; within it, a
todotask that is unassigned (or assigned to you) over one somebody else already has in progress.lsrows carry no assignee: take it from theshowin step 4, or list the unassigned ones withyman ls --assignee -. Skip ablockedtask and one whose body or relations say it waits on another open task. An epic (tagepic) is closed last: offer its open steps,yman plan <id> -n 3, instead — its rows are enough; noshowper step. - Read only the top one to three candidates with
yman show <id> -n 0, for their “Where”, “Done when” and relations. Do notshowevery task. - 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:
| Flag | Meaning |
|---|---|
-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, --all | include closed tasks, hidden by default (or name one with -s) |
-n, --limit <N> | first N rows, applied after sorting |
-l, --long | each 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>(onaddorset, repeatable): it relates to the step it waits on as well as the epic and sits in statusblocked, in one commit.blockedhas to be instatuses.list; without it--waits-onfails, so relate with--relateand sayWaits 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 adone -mthat sums up the result. Closing it earlier still succeeds but warnswarning: 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 rewritesrelatedand never the prose, so an id in a body is a hint for people, not a relation.
Never do this
| Do not | Why | Instead |
|---|---|---|
yman edit <id> | always opens $VISUAL/$EDITOR/vi | yman set <id> --body "<text>" or --body-file - |
yman add -e | opens the editor before the first commit | yman add "<title>" -m "<body>" |
yman comment -e | opens the editor | yman comment <id> -m "<text>", or yman comment <id> < note.md |
yman comment <id> without -m | reads stdin until EOF; an open pipe hangs | yman comment <id> -m "<text>" |
yman rm <id> without -f | prompts on a terminal | yman rm <id> -f |
yman tags rm <tag> without -f | prompts on a terminal | yman 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.
| Code | Meaning |
|---|---|
0 | ok |
1 | error; stderr says what |
2 | usage error, from the argument parser, before anything ran |
3 | a sync merge is unresolved |
4 | no 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):
- Discover the repository. Failure is
not inside a git repository. - Preflight, for every command except
init,hooksandgit:.ymanmust be a linked worktree of this repo; its HEAD must be the symbolic refrefs/yman/local; for mutating commands, an unresolved sync merge stops the command with exit 3; thenconfig.tomlmust load and validate. The merge check comes first so that a merge which conflicted inconfig.tomlstill exits 3, andsyncis let through an unloadable config while a merge is pending —--abortand--continueare how that state ends. - Lazy refresh, when
yman.refreshis unset orlazy, for every command exceptinit,sync,refresh,status,hooksandgit. A failure here is downgraded to awarning:on stderr and never takes the real command down. - Autosync, after a mutating command succeeded, when
yman.autosyncispushand there is anorigin: one push ofrefs/yman/localtorefs/tasks/main, skipped whenLOCALalready equalsREMOTE. It never fetches or merges. A push reportsnote: pushed N task commit(s); a push rejected because origin moved (the race insyncstep 8) printswarning: origin has new task commits; run: yman sync; any other failure, a push origin refused included, prints git’s stderr and thenwarning: 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.
| Command | Subject |
|---|---|
init | yman: init (empty root commit, only when refs/yman/local does not exist yet), then yman: init (<scheme>) |
add | task({id}): add "{title}" |
edit | task({id}): edit |
set, start, done, move, cancel, reopen, prio | task({id}): set {pairs} |
rm | task({id}): remove "{title}" |
attach | task({id}): attach {name}[, {name}…] |
detach | task({id}): detach {name} |
comment | task({id}): comment |
tags rename | yman: tags rename {old} -> {new} ({n} tasks) |
tags rm | yman: tags remove {tag} ({n} tasks) |
sync snapshot | yman: snapshot local changes |
sync renumber | yman: renumber 2->3, 7->8 (sync collision) |
sync merge | yman: merge origin refs/tasks/main |
sync rejoin | yman: rejoin files left under a moved folder |
sync drop | yman: 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
-ncounts 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-napplies.
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:
- A changed priority, slug, or terminal boundary renames or moves the
folder with
git mv. Crossing the boundary also printsnote: task folder is now <rel>on stderr — stdout stays data, but someone who hadcd’d into the folder needs to hear that it moved. updatedis touched;m.ymlis rewritten;t.mdtoo when the title or the body moved. With-m, the text is appended tod.mdexactly ascommentwould, with the same actor.- 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:
| Command | Status | When it refuses |
|---|---|---|
move <id>... <status> | the one you name | unknown status, as for set |
cancel <id>... | statuses.cancel | the key is unset |
reopen <id>... | statuses.default | the 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
epicthat open tasks still relate to getswarning: <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
blockedwhoserelatedcarries a closed id, and which now relates to nothing open except tasks taggedepic, getsnote: <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:
- Either ref missing, or both equal → nothing to do.
LOCALis not an ancestor ofREMOTE→ skip. Onlyyman refreshreports it, asnote: local has unpushed commits; run: yman sync; the lazy path stays silent.yman statusshows the same situation asahead N, behind Mwith→ run: yman sync.- The worktree is dirty → skip. Only
yman refreshreports it, asnote: .yman has uncommitted changes, refresh skipped; the lazy path stays silent. - Otherwise fast-forward (
merge --ff-only) and reportnote: refreshed N new commit(s)unless quiet.
yman refresh additionally prints up to date when there was nothing to do.
yman.refresh | Effect |
|---|---|
lazy (default) | every non-exempt command fast-forwards first |
manual | only 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
- A merge already in progress → exit 3.
- A dirty worktree is committed as
yman: snapshot local changes, reportingsnapshotted N local change(s). - Fetch
+refs/tasks/main:refs/yman/remote, with--no-write-fetch-headso the main repository’sFETCH_HEADstays the user’s. A stderr mentioningcouldn't find remote refmeans origin simply has no tasks yet; if we had aREMOTEbefore, it is deleted and the disappearance is reported. Any other failure is fatal, with git’s stderr passed through. - No
REMOTE→ straight to the push. - No merge base → fatal: the histories are unrelated, and the message spells out the re-init recipe.
base == remote→ nothing to merge.base == local→merge --ff-only.- Otherwise renumber collisions (below), then
merge --no-edit --no-verifywithmerge.directoryRenames=true.m.ymlgoes 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 printnote: unmerged files:, then each file indented by two spaces, and exit 3. A clean merge is followed by the rejoin below. - Push, unless
--no-push: resolveLOCALonce and push that commit,<sha>:refs/tasks/main, with--porcelain, and read git’s verdict from therefs/tasks/mainline 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 asfailed to update ref. A race loops back to step 3, at most three attempts, then fails withorigin keeps moving. Any other!—pre-receive hook declined, a protected or denied ref, and the ambiguousfailed to update refandfailed to lock, which can mean a moved ref but also a server-side fault — is a refusal: it is not retried, and fails withpush failedafter git’s stderr and its! [remote rejected] refs/yman/local -> refs/tasks/main (<reason>)line. - 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, ofLOCALand ofREMOTE— and an id created locally is oneLOCALhas 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’supdatedis 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.ymanto the commit before the renumber and fails withrenumber aborted; .yman restored. - Each move prints
renumbered {old} -> {new} (id taken on origin)once the batch is committed. relatedreferences to the old id are rewritten to the new one, in the same commit as the moves, andnote: rewrote N reference(s) to renumbered idsreports 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
| Term | Meaning | Source |
|---|---|---|
ROOT | main repo toplevel | git rev-parse --path-format=absolute --show-toplevel --git-common-dir, first line |
COMMON | main repo common git dir, absolute | the same call, second line |
YDIR | ROOT/.yman | |
WT_GITDIR | private git dir of the .yman worktree | read from YDIR/.git |
LOCAL | refs/yman/local — task history head | |
REMOTE | refs/yman/remote — remote-tracking ref | |
REMOTE_REF | refs/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:
git commitinside the worktree movesrefs/yman/local, and HEAD stays symbolic. Noupdate-reffixup 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 refrefs/yman/local.git branch -anever mentions the ref. Enforced.git merge --ff-onlyworks against a ref built withcommit-tree. Enforced.- The main worktree’s
git statusstays 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:
| Location | Value | Purpose |
|---|---|---|
COMMON/info/exclude | .yman/ | keeps the code worktree’s git status clean |
remote.origin.fetch (appended) | +refs/tasks/main:refs/yman/remote | a plain git fetch carries task commits; only added when origin exists |
yman.refresh | lazy | manual | see commands.md §5 |
yman.autosync | off | push | only when --autosync is given; unset means off; see commands.md §1 |
yman.author | prefix string | only meaningful for the author id scheme |
merge.ymanmeta.name | yman m.yml field-wise merge | shown 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=ymanmeta | committed, 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,0highest, so a plainls .yman/sorts urgent work first. Only the priority sorts numerically: the listing is lexical, so within one priority5.10.xcomes before5.2.x.yman lsorders ids properly (commands.md §3); zero-padding them here would change the id contract. idnever contains..FolderName::parsesplits 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.slugparses as idvwith slugi-1.slug.seqandrandomcannot produce one, and theauthorprefix is validated before an id is minted (§8).- The folder name is the source of truth for priority and id.
m.ymldoes not repeat them, and changing either is agit mvso 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:
lsprints 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.ymlis the source of truth, not the path. A task physically underdone/whosem.ymlsaystodois listed astodo, and the nextsetmoves 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)
- Unicode lowercase.
- Alphanumerics (
char::is_alphanumeric) survive; every other character becomes a separator. - Separator runs collapse to one
-; leading and trailing-are trimmed. - Truncate to
max_bytesat a char boundary, then trim a trailing-again. - 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
slugifylowercases. Attachment names andauthorprefixes are not lowercased, though.attachcompares names ignoring case, so it never createsA.pngbesidea.png, andattach --force a.pngover an existingA.pngreplaces 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 (IVandiv) 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 unlesscore.precomposeunicodeis set, which git on macOS enables by default in new repositories. - Windows names. A terminal status called
con,nul,auxor another reserved device name passes validation but cannot be a directory there, and so can an attachment of that name. Otherwiseattachrefuses 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,createdandupdateddefaults to empty. - Timestamps are RFC 3339, UTC, second precision (
task::now()zeroes the nanoseconds), serialized with aZsuffix. attachments[].pathis alwaysf/<name>.tagsentries 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-writtentags: [UI]loads as it stands, andls -tandyman tagsfold 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--jsonoutput. Comments, and unknown keys inside anattachmentsitem, 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.ymlis read and written by hand insrc/yml.rs; there is no YAML crate. The writer emits exactly the shape above: block sequences at column zero,[]for an empty list,nullfor an absentassignee, 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 \Pincluded), folded blocks (>-) and sequences indented under their key are all accepted. A key repeated at the top level or inside anattachmentsitem keeps its last occurrence, which is the half of a conflict a person usually means to keep. Anassigneethat is blank or-reads as absent, so a hand edit to''and the-an olderset -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 withunterminated 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
statusoutsideconfig.statuses.listis reported, never a hard error, so editing the config cannot brick existing tasks. An emptystatus— 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:
| Field | Rule |
|---|---|
updated | the later of the two; never conflicts |
created | the earlier of the two; never conflicts |
status, assignee | the side that moved wins; both moved differently is a conflict |
tags, links, related, attachments, unknown blocks | merged 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, andshowstrips 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:
| Key | Meaning | When unset |
|---|---|---|
statuses.start | yman start | list[1] |
statuses.done | yman done | list.last() |
statuses.cancel | yman cancel | the command refuses |
statuses.terminal | closed: 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.
| Scheme | Shape | Next id |
|---|---|---|
seq | 1, 2, 14 | highest all-digit id taken, plus one |
author | iv-1, an-3 | highest {prefix}-N taken, plus one |
random | t-7f3a | t- 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
| Stream | Carries |
|---|---|
| stdout | data only — task listings, show, path, log, status, summaries |
| stderr | error: , 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
| Code | Meaning | Produced by |
|---|---|---|
0 | success | |
1 | error | any anyhow error reaching main |
2 | usage error | the argument parser, before anything runs |
3 | a sync merge is unresolved | the MergePending marker error |
4 | no task has the given id | the 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.
| Situation | Message |
|---|---|
| not in a repository | not 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 out | refs/yman/local is already checked out in another worktree of this repo; only one .yman per clone is supported |
| unknown task | task <id> not found |
| task present but unloadable | task <id> is broken: <dir>: <why> |
| duplicate folders for one id | duplicate task id <id>: <relA>, <relB> — paths relative to .yman, so the two may differ only in their status directory |
| bad status | unknown status "<s>"; allowed: <list joined by ", "> |
| unsupported config version | invalid .yman/config.toml: unsupported version <n> (this yman understands 1 and 2) |
| a status role on a version 1 config | invalid .yman/config.toml: statuses.<key> needs version = 2; bump version in .yman/config.toml |
| a status role outside the list | invalid .yman/config.toml: statuses.<start|done|cancel> "<s>" is not in statuses.list |
a blank statuses.list entry | invalid .yman/config.toml: statuses.list has an empty entry |
a statuses.list entry with edge whitespace or a control character | invalid .yman/config.toml: statuses.list entry "<s>" has edge whitespace or a control character — <s> has control characters escaped |
a repeated statuses.list entry | invalid .yman/config.toml: statuses.list lists "<s>" twice |
| a terminal entry outside the list | invalid .yman/config.toml: statuses.terminal entry "<s>" is not in statuses.list |
| a terminal entry unusable as a directory | invalid .yman/config.toml: statuses.terminal entry "<s>" is not a usable directory name; use letters, digits, "_" and "-" |
| a repeated terminal entry | invalid .yman/config.toml: statuses.terminal lists "<s>" twice |
| terminal entries differing only in case | invalid .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 done | invalid .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 terminal | invalid .yman/config.toml: statuses.done "<s>" is not in statuses.terminal |
| the cancel status is not terminal | invalid .yman/config.toml: statuses.cancel "<s>" is not in statuses.terminal |
default or the start status is terminal | invalid .yman/config.toml: statuses.terminal must not contain statuses.default "<s>" / … must not contain the start status "<s>" |
| empty title | title 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 tag | tag 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 value | related id must not be empty |
set --relate or --waits-on naming the task itself | task <id> cannot relate to itself |
ids.scheme = "random" with every id of random_len taken | random id space exhausted (random_len = <n>); raise ids.random_len |
seq or author scheme past the largest id | id space exhausted: <max> is the largest id there can be |
| unknown attachment | no 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 name | attachment "<name>" given more than once |
--name with several files | --name only works with a single file |
| attachment source unusable | cannot 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 hold | attachment 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 folder | folder already exists: <dir> |
empty comment (comment, or -m on set and the verbs) | empty comment |
--body-file cannot be read | cannot read <path>: <why> — <why> is the OS error text |
cancel with no cancel status | no 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 task | task <id> is not closed (status "<s>"); closed statuses: <terminal joined by ", "> |
rm or tags rm with no terminal and no -f | refusing to remove without -f |
| editor could not be started | cannot run editor "<program>": <why> |
editor failed during edit | editor exited with status N; file left as is (or editor was killed by a signal; …) |
editor failed during add -e | editor exited with status N; task not added (or editor was killed by a signal; …) — the new folder is removed |
editor failed during comment -e | editor 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 terminal | no terminal for vi; set $EDITOR, or use -m / --body-file |
rm or tags rm prompt declined | aborted |
t.md unparsable | t.md must start with "# Title" |
t.md unparsable after an edit | t.md invalid after edit: <why>; fix the file then run: yman edit <id> |
t.md unparsable after add -e | t.md invalid after edit: <why>; task not added — the new folder is removed |
| invalid config | invalid .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 origin | no "origin" remote; tasks are local only. Connect one: yman init --remote <url> |
| unrelated histories | task 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 unresolvable | author prefix unknown; run: git config yman.author <prefix> (or set YMAN_AUTHOR) |
| author prefix not a legal id prefix | author prefix "<p>" from <source> must be letters, digits, "_" or "-" — <source> is yman.author, $YMAN_AUTHOR or user.name initials |
| git identity missing | git identity missing; run: git config --global user.name "…" && git config --global user.email "…" |
| git binary absent | git not found in PATH |
| any other git failure | git <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-way | renumber aborted; .yman restored: <why> — .yman is back at the commit before the renumber |
| push rejected while finishing a merge | origin moved while finishing the merge; run: yman sync |
| network step failed | fetch 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 failed | fetch 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 string | core.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 write | cannot create <dir>: <why> / cannot write man pages to <dir>: <why> |
m.yml merge driver could not fall back | git merge-file failed, with git’s stderr printed above — reaches the user through git’s own merge output |
Exit-code-3 messages
| Situation | Message |
|---|---|
| mutating command during a merge | sync merge in progress; resolve conflicts then run: yman sync --continue (or: yman sync --abort) |
sync during a merge | merge in progress; resolve then: yman sync --continue (or --abort) |
| merge produced conflicts | conflicts in N file(s); edit them, remove markers, then: yman sync --continue (or: yman sync --abort) |
--continue with markers left | still unmerged: <files> |
--continue with a conflict that has no markers | still 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 task | conflict markers or invalid task in <dir>: <why> |
--continue with an unloadable config.toml | invalid .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:
| Message | When |
|---|---|
warning: id scheme is "<s>" (from config.toml); --id-scheme ignored | init on an existing history with a conflicting flag |
warning: origin already points at <url>; --remote ignored | init --remote <url> where origin already exists with a different URL; the same URL is accepted silently |
warning: remote already had tasks; adopted remote state | two clones initialized the tracker at once |
warning: not published to origin (<why>); run: yman sync | a 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 it | the ref was deleted server-side |
warning: <name> is N MiB; git is not great at large binaries | attaching 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 sync | yman.autosync = push and the push after a change was rejected; nothing is merged, the change stays committed |
warning: autosync failed: <why>; run: yman sync | yman.autosync = push and the push after a change failed (offline, auth); the command still succeeds |
warning: task <id> does not exist here; relating anyway | add --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 exists | sync 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/local | init repaired a .yman HEAD that had lost its symbolic ref |
note: .yman has uncommitted changes, refresh skipped | yman 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 sync | yman refresh found local ahead of or diverged from the remote |
note: rewrote N reference(s) to renumbered ids | a 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 gone | a 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
statusvalue that is no longer inconfig.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
setmoves it. - A fetch that fails only because origin has no
refs/tasks/mainyet. - A task folder that fails to load: it becomes a broken entry that
lsshows 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 taggeduser-requestandneeds-agent, quoting the original, so the agent that picks one up re-checks it before working.
0.7.0 - 2026-10-13
Added
Cargo.tomldeclaresrust-version = "1.88", the first release with let-chains, which the code uses.
Changed
yman guideand the skill say thatyman plan’s rows are enough to pick the next step, so agents stop runningshowon every listed step.- Titles and tags refuse the Unicode line and paragraph separators and the
bidi override and isolate characters, which could make
lsoutput 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 inwarning: refresh failed: …) has its continuation lines indented by two spaces and blank lines dropped, andsync’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
rmandtags rmprompts moved from stdout to stderr,refreshed: N new commit(s)becamenote: refreshed N new commit(s), the hook advice is anote:and an unreadable hook awarning:, andsyncintroduces its list of conflicting files withnote: unmerged files:. yman add --sectionswalks the task history for taken ids once per run instead of once per section.
Removed
- The
cannot attach <path>: no file nameandstatuses.terminal marks every status terminalerrors, which could never fire: thenot a regular filecheck and the rule thatstatuses.defaultis not terminal refuse first. - The
no editor configured; set $EDITORerror, which could never fire: the editor resolver always falls back tovior refuses earlier.
Fixed
- A fresh
yman initwhose 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 warnsnot published to origin (fetch failed); run: yman sync. add --sectionsreads a line like```x``` inlineas text, as CommonMark does, instead of opening a fence and refusing the file withunclosed code fence.attach --force A.pngovera.pngon a case-insensitive checkout (macOS, Windows) recordsA.pngin git, matchingm.yml, instead of leaving the index ata.pngand every Linux clone with a dangling attachment.attachrefuses Windows reserved device names (CON,NUL,COM1,LPT1… with or without an extension, any case), which Git for Windows cannot check out, with the existingis not portableerror.- 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 ownupdate-ref,fetchandpushno longer run the user’sreference-transactionhook.yman hooksrefuses an emptycore.hooksPath, which turns git’s hooks off, instead of installing into.git/hooksand reportinginstalled, and no longer trims a whitespace-only one. - A comment line after an unknown
|+block inm.ymlis 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.ymlvalue with a line of only Unicode whitespace (a U+00A0, say, fromset --link) is written double-quoted, so it no longer reads back empty or chomped. - A blank or
-assigneeinm.ymlreads as unassigned (ls --assignee -lists it,--jsonprintsnull), andset -a -/add -a -unassign instead of storing a literal-. rm,setand 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 -einto a closed status under an archive config also removes the status directory it created, and a copy failing part way throughyman attach(a full disk) restoresf/as it was, including files--forcehad already replaced, instead of leaving the earlier copies behind. - A stopped
syncno 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.mdwith CRLF line endings no longer keeps a stray\r\nat the top of its body, and a UTF-8 BOM no longer breakst.md(must start with "# Title") or hidesm.yml’s first key. - The comment count in
lsandls --jsonleaves out unparsabled.mdchunks. yman tags renamereplaces every spelling of the old tag on a task, not only the first ([UI, ui]renamed toxgave[x, ui]).yman tags --json rename …andyman sync --abort --no-pushare refused as usage errors instead of silently ignoring the extra flag.- Shell completions offer file paths for
--body-fileand--sections. rmon one clone and a first comment or attachment on the same task on the other no longer merge into a folder holding onlyd.md(orf/) that no command could load: sync drops it withnote: dropped <rel>: task <id> was removed on one side; ….yman syncno longer dies after the merge withNot a valid object name HEAD^2when 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 withpathspec … did not match any fileson a removed task’s folder thatrmleft holding only an ignored file such as*.swp; there is nothing to drop there, and the sync goes through.yman detachmatches the attachment name ignoring case, asattachdoes:detach 3 A.pngremoves ana.png(under its stored spelling) instead of failing withno attachment "A.png" on task 3.yman initwhose first push fails no longer stops before--hooksand the summary: the tracker exists locally, so it finishes and warnsnot published to origin (<why>); run: yman sync.yman initnames the history it found broken (refs/yman/localorrefs/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=absoluteback instead of honouring it. yman syncpushes the commitrefs/yman/localheld 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
.ymanto the commit before it (renumber aborted; .yman restored) instead of leaving half-moved folders for the next sync to snapshot. yman sync --continuereports how many commits the merge pulled instead ofpulled 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 syncandyman initno longer overwrite the main repository’sFETCH_HEAD.- Run from a git hook or alias, yman strips every variable
git rev-parse --local-env-varslists, plusGIT_NAMESPACE, instead of five of them, andyman gitstrips the same set, so it acts on.ymanrather than on the repository the hook was running for. Ayman gitwhose git is killed by a signal exits128 + signalinstead of 1. yman attachneeds--forcewhenever the task already lists the name, even when its file is gone, and compares names ignoring case:--force A.pngovera.pngreplaces 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 --sectionsrefuses 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.ymlround 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 \Pare read; and an unknown|+block keeps its trailing blank lines on rewrite..yman/config.tomlrefuses a blank, edge-whitespace or repeatedstatuses.listentry. A blank status used to letadd -s ''commit a task whosem.ymlread back as broken.--relateand--waits-ontrim their values and refuse a blank one, andsetrefuses a task relating to (or waiting on) itself.- Id generation reports an exhausted id space instead of looping forever
(
ids.scheme = "random"with everyrandom_lenid taken) or overflowing (seq/authorpastu64::MAX). - Ids are sorted by a total order: plain numbers, then
prefix-Nby 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 aprefix-Nid such asz-1now sorts before a random id such ast-7f3a. yman refreshon a dirty.ymanprints one note,note: .yman has uncommitted changes, refresh skipped, instead of following it with a secondnote: worktree has uncommitted changes.yman ls --helpdescribes-aas including closed tasks (any terminal status) rather than “the final status”, which predates terminal sets.yman set -anow trims its value and treats a blank one as unassigned, asyman add -aalways has; it used to store" bob "or" "verbatim.--helpforedit,add -eandcomment -enames$VISUALbefore$EDITOR, the order the editor is looked up in, andyman --helplists every editor-free way to give a body (add -m,set --body-filewere missing). The agent manual and the skill listplanamong the commands that take--jsonand say thatlsandplancarrybodyonly with-l; the docs add theautosynckey ofstatus --json, theyman.autosyncsettinginitwrites, and thegit worktree add failederror.
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 withfailed printing to stdout: Broken pipeon stderr.- A multi-id verb that fails part-way (
yman done 32 99) now prints the close report — epic warning,no longer waitsnotes — for the ids it did commit before returning the error; it used to drop it.yman rmof an open blocker now prints theno longer waitsnote for a blocked task it freed, as closing the blocker would.yman attachchecks every source before copying any, so a missing second file or two sources with the same name (nowattachment "<name>" given more than once) no longer leave an untracked copy of the first one underf/. yman hooksnow finds the hooks directory the way git does, throughrev-parse --git-path hooks. Acore.hooksPathof~/.githooksused to be taken literally, so the hooks landed in<project>/~/.githookswhere 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:installoverwrote 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) andhooks statusreports it asunreadable.- The user’s
post-commit,post-merge,post-checkoutandpost-rewritehooks no longer run for yman’s own commits, merges and fast-forwards in.yman, nor for thegit worktree addthat creates it.--no-verifyonly skipped the pre-hooks, so a husky, lefthook or LFS hook that wrote files left.ymandirty: refresh then backed off for good and the next snapshot committed the junk. Every git call in.ymannow runs withcore.hooksPath=/dev/null; hooks in the project itself,yman hooksincluded, fire as before. - A push that origin refuses — a
pre-receivehook declining it, a protected or denied ref — is no longer mistaken for origin having moved.syncused to retry it three times and end inorigin keeps moving, autosync used to sayorigin has new task commits, andinitthen failed resetting to arefs/yman/remotethat did not exist. The push now runs with--porcelainand onlyfetch first,non-fast-forward,incorrect old value providedandreference already existscount as the race; anything else fails at once withpush failedand git’s reason on stderr. Aninitwhose 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 -eandeditwithinvalid title "<t>"; titles must not contain line breaks or other control characters;editleaves 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 int.mdand to break the commit subject over several lines. - An
add -ewhose editor fails, or whose editedt.mddoes not parse, no longer leaves an untracked task folder behind forlsto list and the nextsyncto publish. The folder is removed and the error saystask not added. comment -eno longer writes a fixedyman-comment-<id>.mdin the temp dir. On a shared/tmpa 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 sayscomment not addedinstead offile left as is.- The docs no longer call
comment <id>with stdin safe for scripts: without-mit reads stdin to end of file whenever stdin is not a terminal, so a caller that leaves stdin open blocks.docs/commands.md,yman guideand the skill now say to pass-mor 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 mvas a rename, not an add. - A sync merge that conflicts in
config.tomlno longer locks out every command:sync --abortandsync --continuestill run, mutating commands exit 3 as documented instead of 1, and--continuerefuses a resolution that does not load. yman initre-attaches a detached or misplaced.ymanHEAD torefs/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, andinitthere no longer nests a second worktree at.yman/.yman. yman initfrom a linked worktree of the project refuses to create a second.ymanon the same ref; git does not guard a--detachcheckout.- Under a localized git,
initandsyncagainst an origin with no tasks no longer fail withfetch failed, and re-applying an identical change is no longer an error. Every git child now runs withLC_ALL=C. sync --continueno longer takes a conflict git could not mark up (a binary attachment, modify/delete, or anm.ymlwhose merge driver path is gone) as resolved, which kept ours and dropped theirs without a word. Such a file must be staged by hand, andsyncnow prints git’s stderr from the failed merge, so a stale driver’sNo such file or directoryis 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 likels, withwaits on <ids>on a blocked step.-a,-n,-land--jsonas onls.--waits-on <id>onaddandset: relate to a blocker and set statusblockedin one commit. Needsblockedinstatuses.list.- Closing a task (
done,cancel,move/set --status) notes on stderr each blocked task that now waits on nothing open, with theyman movethat frees it. Nothing is moved automatically. add --sections <path|->: one task per# Titlesection 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
epicwhile 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.tomlgained the registry metadata and anexcludelist, and the release steps in the README end withcargo publish.
Changed
- The README says where a
cargo installleaves 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 whoserelatedlist 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 showprintsrelated by:, the tasks whoserelatedcarries this one, closed tasks included, andshow --jsonalways carriesrelated_by.relatedstays one-way and nothing is stored;showreads everym.ymlonce to find them.- The agent manual (
yman guide, the skill and the README’s “Scripts and agents”) describes plans: an epic task, steps that--relateit, priority for order, andblockedplus a relation to the blocker for a step that waits.ls --relatedandrelated 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 intests/cli.rsrose from 60 to 72 to fit the section. yman init --autosync push(git configyman.autosync): every command that changes a task pushes it to origin once it has committed, and reportsnote: pushed N task commit(s)on stderr. It never fetches or merges; when origin has moved it warnsorigin has new task commits; run: yman sync, and a failed push is a warning, never the command’s exit code.yman statusshowsautosync: pushwhen it is on, andstatus --jsonalways carries"autosync".- The agent manual (
yman guide, the skill and the README’s “Scripts and agents”) now says when to runyman 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 describedsyncbut never asked for it, so agents left task history unpushed. Theclaim-taskandclose-taskskill 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.shbuilds it locally and fails on broken links or anchors.
Fixed
docs/commands.mdanddocs/errors.mdwere checked against the code and corrected where they had drifted: the mutating-command list, theinitcommits,refreshnotes, exit codes afteryman gitand forno merge in progress, and messages the tables were missing.docs/storage.mdanddocs/setup.mdwere corrected the same way: what the spike script actually enforces, howt.mdandm.ymlare 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 assigneelsdoes not print,rm -fand the duplicate-id recovery are in all of them, and the README no longer saysinitadds one config line or thatsyncis the only command using the network. yman editretitling a closed task in a version 2 repository no longer fails halfway withpathspec ... did not match any files; the task is renamed inside its status directory instead of being moved to the top of.yman.yman syncrenumbering 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.ymanwhilem.ymlstill 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, andyman 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 initin a repository with nooriginsets up a local-only tracker instead of refusing.statusreports it,syncsays to connect a remote, andyman init --remote <url>does so later.
Fixed
showprints 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.mditself 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 -lprints each task’s body, indented under its row;--jsongainsbodyonly under-l.
Fixed
synckeeps 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, thegitpassthrough and the optional git hooks. move,cancelandreopen; 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
lshides every terminal status. - The
tagscommand, which renames and removes a tag across all tasks; tags are validated and lowercased, andls -tmatches case-insensitively. --jsonoutput forshowandstatus.yman guide, and a description for every flag.- Bodies set without an editor on
addandset,set -mappending a comment in the same commit, andeditrefusing thevifallback without a terminal. addaccepts an assignee, links and related tasks.show -ncaps the discussion;lsfilters and limits without reading the whole backlog.commentandattachtake the author fromYMAN_ACTOR.- Exit 4 when a task id does not exist.
syncmergesm.ymlfield 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
synckeeps 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/.gitignoreare staged. - The
d.mdentry header is guarded on write and on read. - Unknown
m.ymlkeys survive a rewrite. - An author prefix that would break folder names is rejected.
statuses.doneis 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.