2026-08-29 · Updated 2026-08-29 · 9 min read

Git-native task management for coding agents

The storage contract for agent work that lives in your repository: one safe Markdown file per task with a validated schema, a five-status lifecycle with structural refusals, and completion bound to a commit hash instead of a checkbox.

By Juno AI INC · yylo-ledger · git-native

Somewhere between the chat transcript and the pull request, most agent work acquires a task record that Git never sees. It starts as a TODO comment, becomes a card on a hosted board, or settles in as a bullet list one agent maintains at the bottom of a README. The medium changes; the failure does not. What was attempted, what finished, and what evidence a completion carried all live outside the repository, so the one system of record you can actually audit — version control — holds no opinion about any of it, and the board drifts from the code the moment nobody reconciles them by hand.

Git-native task management closes that gap by making the task itself a first-class repository artifact: a validated file with a schema, a status lifecycle the tooling defends, and a completion that carries a commit hash instead of a checkbox. YYLO Ledger implements this contract for coding-agent workflows — its committed README describes the product in one line: "YYLO Ledger is a Git-native, shell-friendly task manager for developers and LLM workflows." This page is the storage reference: what a task is on disk, which fields the schema guarantees, how the lifecycle refuses lying states, and how a completion binds to Git evidence. The companion narrative — why agents need durable task memory at all, and how dependency-aware readiness turns a task list into an admission rule — belongs to the task-truth guide; read it for the why, this page for the format.

One file per task that Git can diff

The unit of storage is one Markdown file per task, never renamed, at a stable sharded path. The README compresses the layout into one sentence: "Stable .juno_task/tasks/<prefix>/<ID>.md files with safe round-trip YAML and hidden Markdown boundaries". The shard directory is the lowercased first two characters of the task ID and the filename is the ID itself, so task A1b2C3 lives at .juno_task/tasks/a1/A1b2C3.md. A finished task looks like this in outline:

markdown
---
id: A1b2C3
status: done
created_date: 2026-08-29T10:14:00Z
last_modified: 2026-08-29T16:45:30Z
commit_hash: 1a2b3c4d5e6f7a8
feature_tags:
  - backend
  - security
related_tasks: null
blocked_by:
  - X4y5Z6
schema_version: 1
---

<!-- juno:body:start -->
Wire the OAuth callback: accept the provider redirect, exchange the code,
and write the session cookie. The body is free Markdown; fences, lists,
and headings are all safe here.
<!-- juno:body:end -->

<!-- juno:response:start -->
Implemented the Google provider; tests and documentation updated. Exact
commands and results are recorded in the run evidence.
<!-- juno:response:end -->

Field names, their order, and the section boundaries are the contract; scalar styling is the codec's business. The format separates two kinds of writer. The YAML front matter is machine territory — structured fields the CLI validates and replaces atomically, never parsed out of prose. The two marker-delimited sections are human and agent territory: body holds the task's intent, agent_response holds the account of the work, and both accept arbitrary Markdown. The codec keeps that boundary hard. Each of the four marker comments must occur exactly once and in order; a file that loses or duplicates one fails validation instead of being guessed back together. Front matter sits between --- delimiters and pins schema_version: 1, timestamps must be timezone-aware ISO-8601, and the operations reference adds the rule that keeps diffs clean across operating systems: "Canonical task files remain LF-only."

Nothing derived gets to pretend it is canonical. The README draws the tier boundary in one sentence: "Canonical current state is one safe Markdown/YAML file per task; history is a separate append-only ledger and SQLite is disposable." In practice that means the SQLite index at .juno_task/cache/ and the lock directory at .juno_task/locks/ are per-worktree state you leave out of version control — a deleted or corrupted cache is simply rebuilt from the Markdown, which is the only place current truth ever lives.

The schema every command agrees on

Ten fields carry the whole current-state record, and each has a validator rather than a convention:

  • id — exactly six alphanumeric characters, never all letters and never all digits, so a task ID can never be mistaken for a word or a number.
  • status — one of the workflow's five values; the default is backlog.
  • body — the task's free-form Markdown description.
  • agent_response — the recorded account of the work, empty until a transition writes it.
  • commit_hashnull until a commit is linked, then a Git hash of seven to forty lowercase hexadecimal characters; completion is where it usually lands, but create and update accept a hash too.
  • created_date and last_modified — timezone-aware ISO-8601 timestamps the tooling maintains.
  • feature_tags — by default up to twenty categorization tags, each one to fifty characters of letters, digits, underscores, or hyphens.
  • blocked_by and related_tasks — lists of task IDs: blockers gate completion, related links never do, and an empty dependency list is stored as null rather than as an empty list.

Machine consumers get determinism for free. Rendered JSON and decoded task mappings emit the known fields in one stable order — id, status, body, created_date, last_modified, then the remaining core and extension fields — so pipelines can diff task records instead of re-normalizing them on every read. The schema also degrades forward instead of backward: an unknown top-level field written by a newer version is retained losslessly through every read-modify-write cycle, so an older checkout working the same board never destroys state it does not yet understand.

A lifecycle with terminal doors

Status is a five-value workflow — backlog, todo, in_progress, done, archive — and the shipped configuration states it as "Workflow: backlog → todo → in_progress → done → archive". The default transition map keeps the doors explicit:

  • backlog may move to todo or archive
  • todo may move to in_progress, backlog, or archive
  • in_progress may move to done, todo, or archive
  • done may move to archive
  • archive moves nowhere — it is terminal

Enforcement has layers, and the layers are not equal. The transition map itself ships as configuration and stays advisory until you switch enforce_transitions on, which is how a team graduates from a loose queue to a strict board without changing storage. But three protections are structural — they hold regardless of configuration, because they defend the meaning of the terminal states rather than the etiquette of the path. Completing a task whose declared blockers are missing or still open is refused before any task or ledger byte is written, with an error that names the task and lists the unmet blockers. Reopening a resolved blocker is likewise refused whenever dependents already sit at done — the error names each completed dependent the reopen would strand. And a task archived into an immutable cold pack is never edited or reopened; follow-up work takes a new task related to the archived ID. A board whose terminal states cannot lie is auditable even when nobody is watching the path.

Completion is bound to a commit

A done that means something must connect the claim to the repository, and the schema enforces the connection with two pieces of evidence. First, every state transition records a response: calling mark with neither --response nor --response-file fails with a usage error, and the task stays untouched. Second, completion carries the commit: on mark done, the --commit flag stores the hash in commit_hash, validated to a real hash shape — seven to forty lowercase hex characters — before it lands. Omitting the flag on mark still succeeds, but the CLI prints an exact reminder to standard error: "Commit Hash is empty, if you have committed something, please give commit hash as well" — a nudge rather than a gate, which is exactly why the field's null-versus-hash state stays visible in every listing and get.

Once stored, the hash is query state, not decoration. search --commit retrieves every task linked to a given change, and get hands the reviewer the body, the response, and the linked commit as one record — the triad worth reading as a unit. The discipline of reviewing that triad belongs to the workflow described in the task-truth guide. Two stronger forms exist for higher stakes. Multi-task finalization (umbrella-finalize) makes --commit mandatory and requires sealed admission and evidence receipts before any child closes. And the task-mutating commands — create, update, mark, archive — can each leave a receipt behind with --receipt-file: the task's content hash on both sides of the change, which fields moved, and the ledger event's identifier — evidence about the mutation itself, checkable by machine without trusting anyone's prose.

Why this survives concurrent agents

Splitting current state into per-task files is what makes parallel agents safe without a central coordinator. The README states the rule in one line: "Different-task worktree changes merge independently; status updates never rename files" — two agents working two tasks in two worktrees produce two independent diffs, and Git reconciles them the way it reconciles any other disjoint files. When two writers do meet on one task, both serialization and verification happen inside the tool: a mutation holds a board lock and that task's own lock while it runs, and a caller that supplies --expected-revision gets a compare-and-swap — a stale revision is refused with the expected and current hashes named, before a single byte is written. For combining whole boards, an explicit merge command plans before it touches anything: a dry run emits a sealed, content-hashed plan of exactly which tasks would change, applying that plan is the only path that writes, and the finished run leaves a receipt.

History sits beside current state, never above it: each mutation appends a hash-chained event to a segmented ledger under .juno_task/ledger/, and a chain that fails verification or a segment set with gaps fails loudly as an integrity error — unverifiable history is reported, never quietly dropped. How history, reconcile, and doctor turn that into auditable repair is part of the task-truth workflow; the parallel-execution pattern that leans on all of these properties is the worktree workflow guide.

Initialize task truth inside the repository

There is no separate initialization ceremony. The first command you run inside a repository creates the structure — the tasks directory, a default config.json carrying the status workflow, and the first task file. With the package installed from PyPI, a first slice of agent work looks like this:

sh
yylo-ledger create "Wire the OAuth callback" --status todo --tags backend auth --blocked-by X4y5Z6
yylo-ledger mark in_progress A1b2C3 --response "Claimed; starting from the provider redirect"
yylo-ledger mark done A1b2C3 --response-file response.md --commit 1a2b3c4
yylo-ledger get A1b2C3

From the first create onward, every transition is a committable diff: the task list becomes reviewable in the same pull request as the code it describes, and the board cannot drift from the repository because it never leaves the repository. The same task system is reachable as the yy ledger launcher from YYLO's control plane; the Ledger documentation covers installation and the full command surface, and the project page tracks the released package. If you are weighing this against the board you already have, the honest comparison is not feature-count parity — it is ownership. Ad-hoc lists and hosted boards keep the record of agent work where Git cannot defend it; Git-native state makes the work's memory part of the work's history.