Work tracking that lives in the repository it tracks. A ticket is a Markdown
file with YAML frontmatter, committed next to the code, editable in vim and
reviewable in git diff. One Go library owns the format, and a git-ticket
binary exposes it as git ticket ….
Partly built. The library and the 28-command CLI are done and tagged
v0.5.0. That tag predates the release pipeline and carries no binaries; the next one publishes archives for Linux, macOS and Windows. See Status before you plan around this.
A project outlives an agent session. Over one ticket's life it may be touched by Claude Code, by Codex, by terva, by a shell script, and by a person with an editor open. They should all be working on the same files, and none of them should have to scrape another tool's terminal output to do it.
A session task list cannot do this: it dies with the session. A hosted tracker cannot either, because it is not in the clone, so it is not in the diff and not in the review.
.tickets/tickets/TKT-01K3ZZ2JH000GHB4EE6SNRE6MD.md:
---
schema: 1
id: TKT-01K3ZZ2JH000GHB4EE6SNRE6MD
title: Refresh fails when the clock jumps backward
type: bug
status: ready
status_reason: null
priority: high
due_on: null
labels:
- auth
assignees: []
milestone: null
parent: null
dependencies: []
blocks_on: none
references: []
claim: null
archive: null
created_at: 2026-08-31T12:01:00Z
updated_at: 2026-08-31T12:01:00Z
created_by:
id: human:sothr
name: Drew Short
updated_by:
id: human:sothr
name: Drew Short
extensions: {}
---
## Description
The token refresh compares wall-clock times and assumes the second is later.The filename is the ID and nothing else. Putting the title in the filename means
a title change renames the file, which breaks git log on the old path.
When an agent picks the ticket up, it writes a claim:
claim:
actor: agent:terva/session-123
branch: fix/clock-jump
worktree: /Users/sothr/wt/clock-jump
commit: 9f8e7d6c5b4a
claimed_at: 2026-09-01T09:00:00Z
expires_at: nullA ticket sits in the directory its status implies, so ls answers what is worth
looking at:
.tickets/
├── config.yml
├── README.md
├── epics.md generated index of the open epics
├── draft/ filed, not yet worth starting
├── tickets/ the working set: ready, in-progress, blocked, review
├── done/ finished recently, still worth reading
└── archive/ retired, swept out of done from time to time
That split is for a person, not the tool. list already answers with open work,
so a binary never needed it. Somebody reading the store in a forge web UI has a
directory as the only query they get for free, and tickets/ holding the working
set alone is what makes that answer worth having.
The directories key on status, which leaves type with no answer at all in a file
browser. epics.md is that answer for the one type worth browsing: a table of
the epics that are not done or archived, each linking to its file. check
reports it stale when it disagrees with the tickets and check --fix rewrites
it, so it is generated output rather than something to maintain.
The working statuses share one directory because a directory each would make
every ordinary transition a rename. A ticket moves three times: when somebody
decides it is worth starting, when it is finished, and when it is archived. The
status wins if the two ever disagree, so check reports a file in the wrong
place as location_mismatch and check --fix moves it, which is also how an
older store migrates.
A claim is advisory, never a lock. It records who is working, on what branch, from which commit. A claim made in a clone you have not fetched is evidence, not a reservation, and the tool says so rather than pretending to coordinate across machines it cannot see.
Staleness is a hash of the file bytes, not a field. Every read returns
revision, a SHA-256 of the file as it sits on disk. Nothing stores it. A
stored counter or an updated_at fails silently the moment a person edits the
file by hand, which is the case that matters most here, and a counter has no
correct value after two branches both increment it.
Hand-editing is a supported operation. Not tolerated, supported. That is why
the format is Markdown and accepts Markdown's merge behaviour instead of using a
database, and it is why git ticket check exists as the safety net. Conflict
markers in a ticket are reported as merge_conflict rather than as a YAML
syntax error, because that is what actually happened.
A merge driver is optional and worth installing. Two agents editing
different fields of the same ticket still collide in a line merge, because every
write touches updated_by. git ticket install-merge-driver resolves those
per field and marks only the ones that genuinely disagree.
Nothing touches the network. No fetch, push, merge, branch switch, or commit
happens as a side effect of a mutation. Publishing a ticket change is git commit and git push, the same as publishing code.
An unknown field is preserved, not dropped. A reader one minor version
behind keeps a field it does not understand and writes it back out. check
reports it, an ordinary read warns. Dropping it would corrupt the ticket for
everyone else sharing the repository.
git-bug stores records in Git objects rather than worktree files. That gets clean merges, at the cost of records no one can open in an editor. This project takes the opposite trade.
Beads is agent-first, with a Dolt database as the source of truth and JSONL as an export. The database is the part this project rejects: an export is not something you review in a pull request.
Backlog.md is the strongest Markdown workflow of the three, and the acceptance criteria and definition of done fields come from it. It puts the title in the filename, which this format does not.
On Linux, macOS, or WSL2, the install script fetches the latest release,
verifies its sha256 against checksums.txt, and puts the binary in
~/.local/bin (or a --prefix you name). It never sudos:
curl -fsSL https://raw.githubusercontent.com/terva-sh/git-ticket/main/install.sh | shLater upgrades come from the binary itself, with git ticket self-update.
A release also publishes a container image, so a pipeline or a one-off can run the tool without installing anything:
docker run --rm -v "$PWD:/w" -w /w ghcr.io/terva-sh/git-ticket:latest git ticket readyIt is Alpine carrying git-ticket, git, just, and an SSH client, with no
entrypoint and a shell as its command, so it also serves as a base image for a
pipeline that runs several steps. A release tags the full version, the minor,
and latest, so you can pin exactly or float; a prerelease publishes its own
version alone and never moves latest.
It runs as root, and git refuses to operate on a mounted repository owned by a
different user with detected dubious ownership. The image carries the
exception for that switched off: it bakes safe.directory=* into git's
config-environment slot 0 and leaves out the count that would activate it. Add
one variable to turn it on, or run the container as the owning uid:
docker run --rm -e GIT_CONFIG_COUNT=1 -v "$PWD:/w" -w /w \
ghcr.io/terva-sh/git-ticket:latest git ticket readyRead GIT_CONFIG_COUNT=1 on this image as "trust whatever repository is
mounted here". It works inside a CI container job, where an entrypoint script
would not, because the runner overrides the entrypoint and delivers each step
by exec. If you use GIT_CONFIG_* yourself, note that the image has taken
slot 0: setting your own GIT_CONFIG_KEY_0 overrides it, but setting only the
count gets you safe.directory.
A release carries archives for Linux, macOS and Windows, on amd64 and arm64.
Download one, unpack it, and put git-ticket on your PATH. The binary sits at
the root of the archive, and checksums.txt beside it verifies what you
downloaded. Nothing else is needed: no Go toolchain, no runtime. Native Windows
takes this path, with the zip.
With a Go toolchain, this gets you the same command:
go install github.com/terva-sh/git-ticket/cmd/git-ticket@latestThat lands git-ticket in your GOBIN, or GOPATH/bin without one. Git spells
a binary named git-ticket on PATH as git ticket.
From a clone, just install builds your working tree and writes it where the
install script above writes, so the two cannot leave you with a binary on
PATH that neither of them last touched. just install ~/go/bin names a
directory instead, which is how to keep the GOBIN convention. just build
puts the binary in the repository root and installs nothing. That root copy is
gitignored and nothing rebuilds it for you, so prefer just ready and just check, which depend on build.
just install-release builds a release rather than your working tree. It
compiles a tag in a throwaway clone and puts the binary where the install script
above would, so compiling from source and running the one-liner cannot leave two
binaries of different ages on your PATH for git ticket to choose between. It
refuses anything that is not a tag, and refuses to install a build that does not
report the tag it was asked for.
git ticket --version reports the tag and the commit the binary was built from,
and says so honestly: a build from a tree with no tag reachable answers devel
rather than inventing a version.
A ticket's whole life works today:
git ticket init --actor human:you
git ticket create --title "Refresh fails when the clock jumps backward" \
--type bug --priority high --label auth
git ticket list --status ready --type bug
git ticket search "clock jump" --type bug
git ticket ready # what could be started right now
git ticket ready --not-label live-test # the queue, minus work you cannot start today
git ticket files auth/verify.go # which tickets reference this path
git ticket show TKT-01K3ZZ2J # a unique prefix, with or without TKT-
git ticket update TKT-01K3ZZ2J --priority urgent --add-label crypto
git ticket ac TKT-01K3ZZ2J --add "The verifier accepts either key"
git ticket link TKT-01K3ZZ2J --depends-on TKT-01K4001C
git ticket link TKT-01K3ZZ2J --ref proposal:git-ticket --path docs/plan.md
git ticket deps TKT-01K3ZZ2J --transitive
git ticket status TKT-01K3ZZ2J ready
git ticket claim TKT-01K3ZZ2J # records your branch and HEAD
git ticket status TKT-01K3ZZ2J in-progress
git ticket plan TKT-01K3ZZ2J "1. Reproduce with a stepped clock
2. Widen the window
3. Pin the source"
git ticket note TKT-01K3ZZ2J "the skew is 40s, not the 5s we assumed"
git ticket ac TKT-01K3ZZ2J --check 1
git ticket summary TKT-01K3ZZ2J "Widened the window and pinned the clock source"
git ticket status TKT-01K3ZZ2J done
git ticket archive TKT-01K3ZZ2J --reason "shipped in v1.2"
git ticket note TKT-01K3ZZ2J --list # the note history, one line each
git ticket note TKT-01K3ZZ2J --show 2-4 # read a range of them in full
git ticket remove TKT-01K3ZZ2J # a ticket filed by mistake, before anybody worked it
git ticket series add LED # a second ID prefix this store may mint
git ticket canvas show # each pen on the board, the whole of its rule, and what it catches
git ticket canvas explain TKT-01K3ZZ2J # where one card belongs and why, with no canvas running
git ticket canvas pen add auth --title "Auth" --label auth --status ready --at 0,0 --size 1200,900 # a rule over labels, status, type and parent
git ticket canvas place TKT-01K3ZZ2J --at 40,60 # pin one card by hand; release hands it back to the rules
git ticket actor # who this store records writes as
git ticket actor add human:you --default # fills an empty roster without an editor
git ticket export TKT-01K3ZZ2J --out ./handoff # hand tickets to another project
git ticket import ./handoff # preview what an export carries
git ticket import ./handoff --adopt --same-owner # file it, keeping your own evidence
git ticket check --strict # safe in CI: offline, read-only
git ticket check --fix # repair the two findings that have one repair
git ticket doctor # what is untidy rather than invalid, and never fails a build
git ticket doctor --strict # exit 20 for questions, 21 for the objective ones
git ticket schema # the values and codes this binary enforces
git ticket config # what this store configured: labels, milestones, defaults
git ticket instructions # the agent workflow block, for an AGENTS.md
git ticket instructions --write # put it in AGENTS.md, or refresh it in place
git ticket install-merge-driver # per-field merges instead of line mergesrelease and unarchive undo the two that are undoable, and dod edits the
definition of done exactly as ac edits the acceptance criteria.
remove is the one that is not. It deletes the file, so use it for a ticket
filed by mistake and archive for work that happened. It refuses when another
ticket names this one in dependencies or parent, and when the ticket carries
notes, comments, a summary, a claim, or an archive record. Both refusals name
what they found, and --force overrides them and reports what it broke. Nothing
is staged either way, so until you commit the deletion Git still has the file.
git ticket actor lists who a store records writes as, and actor add ID fills
the roster. It exists for one awkward state: init with no --actor and no
terminal leaves an empty roster, which is right for a script, and a write naming
nobody is then refused because updated_by would say nothing. The repair used to
be a text editor.
Both forms work against that store, because they read and write config.yml
rather than a ticket. --default also sets defaults.actor; without it the
store resolves writes to whoever heads the roster and warns on every one, and
that warning's advice is to set defaults.actor, which would send you back to
the editor this command replaces.
There is no actor remove. An actor is recorded in created_by, updated_by,
every note and every claim, and that is history rather than vocabulary: removing
the roster entry cannot rewrite it, and leaving it means a display name quietly
empties on the next write to any of those tickets.
--actor takes an ID, and the display name written beside it comes from the
actors roster in config.yml rather than from the flag or from the file:
config.yml says |
the write records |
|---|---|
| the ID, declared with a name | that name |
the ID, declared with name: "" |
name: "" |
| nothing about the ID | name: "", and the ID still works |
An ID that is not in the roster is usable, which is deliberate: the roster
names who a store expects, not who may write. The case worth knowing is the
last row applied twice. A name already in a ticket file is not carried
forward. If a file records name: "Drew Short" for an ID the roster does not
declare, the next write by that ID replaces it with name: "", because the
roster is the only source for a name and it has nothing to say. Declaring the
ID is what makes the name stick.
--label includes and --not-label excludes, both repeatable, on list and on
ready. Repeating either one ORs, so --label a --label b is "carries a or b",
and exclusion is applied after inclusion, so a label named on both flags
excludes.
--not-label exists because inclusion cannot express "everything except". Naming
the set to keep means enumerating the vocabulary, which is a list that rots the
moment somebody coins a label, and which drops every ticket carrying no labels at
all. Those are exactly the tickets an everything-except query wants, so
--not-label keeps them: a ticket with no labels is excluded by nothing.
update takes as many flags as you like and applies them as one write. Either
all of them land or none do, so an update that fails partway leaves a ticket in
a state somebody typed rather than half of one. An empty value clears a field
and an absent flag leaves it alone, so --milestone "" and no --milestone at
all are different instructions.
Every field create sets, update can change, --type and --parent
included. A bug that turns out to be a chore, and a ticket that belongs under a
different epic, are fixable without opening the file.
The number ac --check N takes counts checkbox lines from one, not lines and
not array positions. A section can hold prose above its list and still number
its items 1, 2, 3, and editing a box leaves that prose alone.
ac and dod take --add, --check, --uncheck, and --remove, each
repeatable and all combinable in one call that lands as one write. Every index
means the item you read when you typed it, so
ac ID --check 3 --remove 1 --add "a fourth" ticks the third box you can see,
drops the first, and appends. Removals run highest first for that reason, and
naming one twice removes one item.
create seeds the same two sections with --ac and --dod, both repeatable,
so a ticket can be filed complete in one command rather than one call per
criterion.
link takes one of --depends-on or --ref, and --path goes with the
second, because a path with no reference names nothing. unlink is the reverse,
and removing something that is not there succeeds rather than complaining.
list answers with open work: every status except done and archived. Naming
a status brings it back, so list --status done still works, and --all drops
the exclusion. The rule is an exclusion rather than a list of the open statuses,
so a status added later is included without an edit. git ticket schema
publishes the open set as openStatuses rather than making a consumer hard-code
it.
schema and config split the vocabulary between them. schema is what this
binary enforces, identical in every store, so it answers before init and
outside a repository. config is what one store chose: its label and milestone
allowlists, its actors, the type and priority create falls back to, and the
lock timeout. It needs a store, because that is what it describes.
An allowlist comes back as {"values": [...], "enforced": true} rather than a
bare list. An empty allowlist permits everything, so a consumer handed [] and
reading it the obvious way would conclude the opposite. enforced says which
rule is in force. Both lists stay advisory either way: an unlisted label still
writes, and check reports label_unknown as a warning, which is where
check --strict fails a build.
search takes a regular expression and matches it against the title and the
body, and it takes every filter list takes, so you narrow by status or type
and search inside that. It does not take the open default, though. Search is how
you find what was already decided, and a decision lives in a done ticket.
ready answers one question: which open tickets are not blocked and have every
dependency closed. That is the queue, and it is what an agent asks for when it
wants work.
Every ticket also carries a readiness, derived from the whole store at read
time and never stored. It holds the verdict ready filters on plus what stands
in the way, so show prints a blocked by line and a consumer drawing a board
can grey out a card and say why without calling ready and then deps per row.
ready filters on that same verdict rather than restating the rule, so the two
cannot come to disagree.
Blocked means dependencies. A draft, and a ticket somebody else is holding, are both unready with nothing in the way but their own state. A dependency that resolves to nothing, or to two files claiming one ID, blocks rather than counting as satisfied, because a prerequisite nobody can point at is not one anybody met.
note, comment, plan, and summary write the text sections of plan section
9. note and comment append with a timestamp and an actor, so they read as a
log. plan and summary set. Each is one statement rather than a log: a plan
says how the work will go and a summary says where it landed, and a log of
either is what the first two already are.
--json splits that comment log back out as comments, one record with an
actor, a time, and the text. An entry runs from its stamp to the next one, so a
comment can have several paragraphs. Text with no stamp above it, which is what
somebody editing the file by hand leaves, comes back with a null actor rather
than being dropped.
files PATH goes the other way, from a path to the tickets that recorded a
reference to it. A reference is written by link --ref X --path P, so files
reports what agents wrote and is only as complete as they were. Nothing derives
it from Git history, so it is a hint for a reader rather than a fact about the
tree.
refs REF answers the same shape of question for the rest of the namespaces.
git ticket refs jira:PROJ-1234 finds the ticket tracking that work item, and
git ticket refs jira: lists everything in that namespace. The namespace is
compared without regard to case and the identifier exactly, so JIRA: and
jira: are one type while PROJ-1234 and proj-1234 stay two references,
because only the system that issued an identifier knows whether its case means
anything.
Use it instead of search when you want the ticket for a tracker item. A search
matches substrings across the whole body, so it also returns a ticket that only
mentions the number in passing. A ref with no namespace at all is still stored,
but nothing can look it up that way, and check says so with
reference_untyped.
Text that opens with a dash goes after a bare --, so
git ticket note TKT-01K3ZZ2J -- "--force was the wrong default" records the
note rather than failing on an unknown flag.
Prose longer than a line comes from a file instead. create takes
--description-file and --plan-file, update takes --description-file, and
plan, note, comment, and summary each take one --file. A path of -
reads stdin:
git ticket create --title "Rotate the signing key" --description-file notes.md
git ticket note TKT-01K3ZZ2J --file - <<'EOF'
The skew is 40s, not the 5s we assumed. It isn't the NTP config,
and `ntpq -p` agrees.
EOF
something-that-prints | git ticket summary TKT-01K3ZZ2J --file -The format is Markdown, and passing a paragraph as one shell word makes the shell part of the authoring surface: an apostrophe ends a single-quoted string and a backtick inside a double-quoted one runs a command. A file has neither problem. Giving both the text and its file is refused rather than resolved by precedence, and so is asking two sections to read stdin. Trailing whitespace is stripped, because a final newline terminates a text file's last line rather than being content.
Write subheadings in that prose as ###. A line opening with ## starts a new
section, per plan 5.2, so the text below it lands outside the section you meant.
The command warns on stderr and still writes.
deps walks dependencies, --transitive follows the chain, and --dependents
walks it backwards to what is waiting on this ticket. A dependency cycle is a
state a store can genuinely be in, so the walk terminates on one and reports
what it found; check is where you are told the cycle exists.
Anywhere an ID is taken, a unique prefix works, with or without the TKT-
part, and four characters is the minimum. A listing shortens each ID to the
fewest characters that still resolve, so what you read off the screen is what
you can type back.
A status moves along the table in plan 6.2 and refuses anything else, naming
where the ticket may go instead. Entering blocked needs --reason, and so
does reopening from done, because a ticket that silently un-finishes makes the
status mean nothing. The reason goes in status_reason for a query to read and
into Notes, which survives the next transition clearing the field.
A claim is advisory. It records who is working, on which branch, from which
commit, and it reserves nothing. Claiming a ticket somebody else holds is
claim_conflict; --force takes it and writes the displaced claim into Notes.
archive is its own command rather than a status because it also moves the file
to .tickets/archive/, and it records from_status. That is what stops an
archived ticket from silently blocking its dependents: a dependency is satisfied
by a ticket archived out of done, and by nothing else.
An archive --reason lands in the archive block and in Notes, the same two
places a status reason lands. unarchive deletes the block, so without the note
a ticket archived as "shipped in v1.2" and then restored would keep nothing that
says why it was ever closed out.
Every command takes --json and answers with one envelope on stdout. A failure
exits 1 and puts the reason in an error envelope with a stable code, so a
script switches on the code rather than parsing the message. --store PATH
names a store, GIT_TICKET_STORE does the same from the environment, and
without either the store is discovered upward to the Git root.
Every write takes --if-revision R and refuses if the ticket moved since you
read it, answering stale_revision. Nothing stores that revision: it is a
SHA-256 of the file's bytes, so it notices a hand edit too.
check validates every ticket against section 11 of the plan: broken
dependencies, cycles, duplicate IDs, a filename that no longer matches its ID,
conflict markers left by a merge. It separates errors from warnings, and
--strict makes a warning fail the run too. A store with findings still gets a
check-report, not an error, because the check ran fine and the answer is no:
{ "schemaVersion": 1, "kind": "check-report", "ok": false,
"errors": [{ "code": "dependency_missing", "file": ".tickets/tickets/TKT-….md",
"ticket": "TKT-…", "field": "dependencies" }],
"warnings": [] }ok is true exactly when the command exited zero, so CI gates on one field
whether or not it passed --strict.
check --fix repairs the two findings that have one correct repair, and
nothing else. A file named something other than its ID is renamed, and a file
in the wrong directory is moved to the one its status implies, because plan 6.3
already rules that the status wins. It moves the file and never rewrites the
ticket, since both findings are about where a file sits.
It is the only command that writes without being told which ticket to write, so
--dry-run shows the moves and makes none, and the report names every path it
touched in pathsChanged the way a mutation does.
Everything else is reported and left alone. A duplicate ID has to choose which
file is the real ticket, a missing dependency is repaired either by dropping the
edge or by creating what it names, and an unknown label is either a typo or a
gap in the allowlist. Each needs a person, so --fix does not guess. That also
covers a move onto a path already taken: the repair is dropped and the finding
stays.
doctor answers the other question. check says whether the store is valid:
a ticket under the wrong filename, a file in a directory its status does not
imply, an unresolved reference. Those are broken. A ticket can be perfectly
valid and still be one nobody can pick up, and nothing asked about that.
Hygiene is advice, so doctor never fails a build by default however untidy the
store is. --strict exits by a grade instead: 20 when only soft findings fired,
21 when a hard one did. A hard rule is checkable, and doctor states its
finding. A soft rule is a judgement doctor can prompt and cannot settle,
such as whether a ticket's labels are ordered with the most descriptive first,
so its finding reads as a question and can never fail a run. Keeping the two
apart is the point: a tool that reports a judgement in the same voice as a fact
teaches people to skim both.
Every rule ships on, at the level this tool thinks right, because a hygiene
command with no opinion is a linter you could have written yourself. A store
overrides any of them in config.yml beside the label and milestone allowlists:
doctor:
rules:
label_missing:
enabled: false
label_order:
params:
confidence: 0.9 # how dominant a leading dimension must be to count
sample: 10 # how many multi-label tickets before it counts at all
min: 3 # how many labels make the order a choice
visible: 3 # how many labels your board shows before hiding the restlabel_order reads your convention rather than asserting one. It takes the
dimension each ticket leads with, the part before the first / or :, finds
the one that leads most often, and reports only the tickets that disagree. A
store that always writes area/ before scope/ hears nothing until a ticket
breaks that habit, and a store that has not settled the question hears nothing
at all, because tickets disagreeing with each other is not a mistake when
nothing said they should agree.
Leading with undimensioned labels is a convention too, so a store that never prefixes still gets told about the one ticket that suddenly does.
Rule identifiers are a promise, since they are what a config file refers to, so
git ticket schema publishes them. They share one namespace with the finding
codes check reports, which is what lets a rule name a check code when it has
to say where its own boundary ends. An identifier configured here that the
binary does not know is reported rather than refused, so a misspelling is
visible and a store defining its own rules later is not foreclosed.
instructions prints a workflow block to paste into a project's AGENTS.md,
telling an agent how to find work, claim it, record what it learned, and finish.
git ticket instructions --write puts it there for you, and
git ticket init --instructions does the same for a new project. Without the
flag, init writes no such file, though it does say the block exists.
There are two forms of it, because the block written into a file is loaded in every session of every project that adopts this tool and most of its words are the reason behind a rule rather than the rule. An agent needs the rule every session and the reason only when a rule surprises it.
git ticket instructions # the long form, with the reason behind each rule
git ticket instructions --core # the short form, which is what --write installs
git ticket instructions --write # install the short form
git ticket instructions --write --full # install the long one insteadPrinting and writing default to different forms because the callers want
different things. Somebody typing the command is asking how to work here and
wants the reasoning; a setup step is filling a file read in every session
afterwards and wants it short. --core and --full override either default,
and passing both is refused rather than one of them winning quietly.
The short form drops the argument, not a step. It carries every rule and the
whole sequence in the order it is worked, and names git ticket instructions in
its second paragraph so the reasoning is one command away from whoever is
reading the file.
The block is fenced by <!-- git-ticket:begin --> and <!-- git-ticket:end -->,
so --write can replace it later and leave every other byte of your file alone.
Run it again after upgrading and the block catches up. A file with no markers is
appended to rather than refused, which is what a project that already has an
AGENTS.md needs, and it is refreshable from then on. A file already current is
not rewritten at all, so a no-op stays out of your diff.
One marker without its partner is the one case it refuses. There is no honest reading of where the block ends, and guessing would delete prose you wrote.
A test holds the block to the commands and flags this binary actually has, so it
cannot tell you to run something that does not exist. It caught the first draft
telling agents to run git ticket files ID --add PATH.
show prints the newest note in full and replaces the older ones with a line
naming how many there are, because a ticket worked for a week opens with its
whole history and an agent reading it pays for all of it. Nothing is deleted:
note --list is the index, note --show N takes one, a range like 2-4, or
all, and show --json still carries the notes whole.
export writes a ticket as a git patch series, and import is the receiving
half.
git ticket export TKT-01K3ZZ2J --out ./handoff
git ticket import ./handoff # preview, writes nothing
git ticket import ./handoff --adopt --from-store ledgerAn export is two files. The cover letter reads as a report, and the patch adds
the ticket files. A receiver who has never heard of this tool types
git am ./handoff/*.patch and gets ordinary Markdown, then git ticket init
adopts the directory those files landed in. If the export carries an ID prefix
this store has never declared, init names it and the git ticket series add
that declares it. The IDs are kept throughout, which is what that route is for.
import exists for the case git am cannot serve: an export whose series this
store does not declare, where the only repair is to file each ticket again under
a series this store knows. It previews by default and writes only with
--adopt, because an export is somebody else's content. Reconciliation drops
what your store never agreed to, so a label or milestone your allowlist does not
name is dropped and named rather than added quietly, and the adopted copy lands
check --strict clean. A store whose allowlist is empty has expressed no
opinion, per the advisory rule in 4.1, so it carries the sender's labels
unchanged and check is content either way.
Adopting unticks the acceptance criteria and lands the ticket in draft, which
is right when somebody else's work arrives and wrong when you are moving your
own ticket between your own stores. --same-owner says the two stores have one
owner: the ticks travel, done and archived travel, the original created_at
travels so the ticket keeps its real age, and a parent left behind is recorded
as an origin-parent: reference rather than dropped. It says nothing about
vocabulary, so labels and milestones are still reconciled exactly as before.
Nothing writes the sending store. import prints the summary and status
commands that close the origin and leaves you to run them there, because the
tool will not reach into another repository on your behalf.
schema prints what the binary enforces: the statuses, types, and priorities,
the transition table, every error code, and every check finding paired with its
severity. Each list is read from the code that enforces it, not copied into the
command, so a value the library accepts and the plan forgot still shows up.
Write a consumer against schema rather than against a hard-coded list, and it
keeps working when the sets grow. It reads no store, so it answers outside a
repository and before init.
completion prints a shell completion script for bash or zsh. Print it and
redirect it yourself, or let --install put it where the shell looks:
git ticket completion bash --install
git ticket completion zsh --install --dir ~/.zsh/completionsBoth invocation forms complete, git-ticket <TAB> and git ticket <TAB>. The
second one is why the file names matter and why --install picks them for you.
Git's bash completion asks its loader for a file named after the binary, and
zsh's _git scans $fpath for _git-* and calls the function of the same
name, so a script installed under any other name leaves the subcommand form
silently doing nothing while the direct form works.
bash needs no further setup. The file lands in
${XDG_DATA_HOME:-$HOME/.local/share}/bash-completion/completions/ and is
loaded on demand. zsh takes a --dir and will not guess one, because zsh has
no standard user completion directory and the directory has to be on $fpath
before compinit runs. --install prints the two lines to add. compinit
caches its scan, so a newly installed file may need
rm -f ~/.zcompdump && compinit before it appears.
It completes command names, the flags of the command you are on, enum values
for flags and positionals that take one, and ticket IDs. The IDs are the same
shortest-unique abbreviations list prints and show accepts, so completion
can never offer something the CLI would then reject. zsh shows each ticket's
title beside its ID, because a bare ULID is close to unpickable; bash shows the
IDs alone, which is what that shell gives you.
Everything it offers comes from git ticket completion --dump, so a command or
a flag added to the binary completes with no edit to the script. Reinstall
after upgrading and it catches up. The dump is an internal detail rather than a
published contract, and it is deliberately not one of the JSON kinds in section
10 of the plan.
One conflict worth knowing, and it is not ours. Git ships its own zsh
completion, and installing it ahead of zsh's on $fpath replaces the _git
that dispatches to _git-ticket. git ticket <TAB> then stops working with no
error anywhere, while git-ticket <TAB> is unaffected. Homebrew's git does this
on macOS, and git-lfs and git-extras both document hitting it. --install
warns when it can see that arrangement.
fish and PowerShell are not built yet.
| Phase | What | State |
|---|---|---|
| 0 | Format and fixtures | Done |
| 1 | Core library: parse, render, validate, query, Apply |
Done |
| 2 | Standalone CLI with --json |
Done, tagged v0.1.0. All 24 commands it scoped, and both exit criteria met |
| 3 | Terva integration | Started. v0.2.0 exports the cli package terva embeds, v0.3.0 adds the --parent filter its board needs, v0.4.1 reports the files a query could not read, v0.5.0 gives every unready ticket a reason and merges concurrent edits per field. Tracked in terva |
| 4 | MCP adapter, Backlog.md import, a local view | Started. The local view shipped as git ticket ui in v0.7.0 and renders styled Markdown since v0.8.0. The adapter and the import stay parked on their triggers in plan section 15 |
The two questions that blocked Phase 1 are settled. A blocked reason lives in
the status_reason field and in Notes, per plan 5.1 and 6.2. A references
path resolves against the root of the Git repository holding the store, and is
not checked at all when the store sits outside one, per plan 5.5.
Phase 2 settled three more, all in the plan. Store precedence is --store, then
GIT_TICKET_STORE, then discovery, and a named store that does not exist is an
error rather than a reason to go looking elsewhere, per 12.1. Exit statuses are
Git's: 0 or 1, with the detail in the error code, per 10.2. A ticket's JSON
carries every body section as raw text and derives two views from it, per 10.1:
checklists, each item numbered the way ac --check N counts, and comments,
one record per stamp so a reader draws a thread without parsing Markdown.
Wiring check settled a fourth, recorded as 10.3. --strict moves no finding
between errors and warnings, because those arrays report severity as the
format defines it. Strictness is a policy on top, visible in ok and the exit
status alone, so ok can be false with an empty errors array.
Writing the commands raised two more, and both are now settled. An archive
reason goes to Notes as well as the archive block, per 6.3, because
unarchive deletes the block and a status reason lands in two places for
exactly that reason. And update carries --type and --parent, per 12.1,
because every other field create sets already had an update flag.
The module path is settled too, by publishing rather than by argument. go.mod
declares github.com/terva-sh/git-ticket, a public mirror serves that path, and
so the import path a consumer writes is the one that was already there.
The compatibility policy is settled as well, in 12.4. The module version, the
file schema, and the JSON schemaVersion move independently; everything a
machine reads is covered and the human output is not; and a store never upgrades
itself, so a new binary cannot make a repository unreadable to a colleague still
on the old one. The module stays v0.x until Phase 3 lands.
The parent hierarchy is settled too, in section 8. list --parent ID gives an
epic's direct children that are still open, and list --parent none gives the
tickets with no parent, which is what a board needs for its top level. --parent
takes the open default like every other filter, so add --all for an epic's
whole roster. Nothing that reasons about children reads a listing: readiness
computes blockingChildren from the whole store, so a done child still counts
as satisfied. deps still walks dependencies alone, but when its answer is
empty and the ticket has children it now names the count and points at that
filter, because "It depends on nothing." is true and useless on an epic.
That question shrank when it was run rather than argued. list --json already
carried every parent edge, so the tree was always reconstructable from one
call and nothing was blocked. What was missing was the asking, not the data,
which is why the answer is a filter and not a new field on the ticket.
What remains is deferred rather than open. The parked questions are filed as
tickets carrying the question label, so git ticket list --label question
answers with the current set rather than a count somebody has to maintain. Plan
section 15 carries the prose for each question it has settled or parked, naming
the subject and the ULID of its ticket. Numbering was tried and dropped, because
a number assigned by hand collides when two agents work at once, and a ULID is
generated.
The schema migration question arrived the same way, out of settling the compatibility policy. Ruling that a store never upgrades itself leaves the migration a person would run undesigned, and nothing should bump the schema before that exists.
docs/plan.mdis the design of record. Sections 4 through 11 are the format itself, section 13 is the phase plan.testdata/README.mdexplains the fixture corpus and what an expectation sidecar means.AGENTS.mdis the standing rules for working in this repository.
just test runs everything, including the tests that hold the fixture corpus to
the plan: every code in section 11 has a fixture, and every fixture reproduces
the findings recorded beside it. just ci adds the race detector, gofmt, vet,
and check --fix --dry-run --strict over this repository's own store, which is
what CI runs. That plans every repair and writes nothing, so a pending one fails
the build and just fix settles it locally. Plan section 11 is why CI reports
the repair rather than committing it.
The recipes are plain go invocations, so working without just costs you
nothing but typing.
MIT, in LICENSE. Every release archive carries a copy beside the
binary, so something downloaded from a release page ships with its own terms and
needs nothing from this repository to be used.
Parts of the tui package are lifted or adapted from
terva's TUI, which is itself a hard fork
of zot, so credit belongs to both projects. NOTICE carries terva's
full license text and names every derived file, and each of those files opens
with a header saying so. TestNoticeAgreesWithTheHeaders holds the two lists
to each other.