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

Shell and JSON pipelines for agent task automation

Drive YYLO Ledger from scripts and CI with contracts that hold: honest exit codes, parseable stdout in NDJSON and JSON, projection and cursor discipline, stdin-safe task input, and a receipt for every mutation.

By Juno AI INC · yylo-ledger · automation · cli

An agent-task board earns its keep when the work around it stops being typed by hand. The loop you actually want is dull: a script reads the board, picks work, writes a claim, a worker finishes it, a gate checks the board's health, and every step is one shell line that either worked or did not. What breaks that loop is never the shell — it is a tool that prints prose where a script expected data, exits zero when it meant "nothing matched," or accepts a mutation from the wrong repository because an environment variable was set by something else. This page documents the script-facing surface of the ledger CLI end to end: the exit codes a pipeline can branch on, the stdout shapes a parser can rely on, the projection and pagination contracts that keep answers small and honest, the input path that keeps rich task text out of shell argument parsing, and the receipt every mutation leaves behind. Each command, output shape, and transcript here came out of live recording on 2026-08-29, exercising the ledger release this site's generated facts file pins; later sessions mint their own task ids, digests, and event identifiers, so quoted values belong to the recorded run and the behaviors are the contract.

Exit codes are the branching contract

A pipeline's if statements deserve better than "nonzero means bad." The CLI ships six exit codes, and the session provoked each one deliberately. Zero is success — including the quiet case scripts must expect: a filter that matches nothing prints one human sentence to stdout and still exits zero, so an empty page is a result, not an error, and the sentence on stdout is not JSON. One is a general failure: an exact read of a nonexistent ID exits one with Task not found: NOPE99 on stderr and nothing on stdout. Two is usage — a misspelled flag value, a cursor used in a way the contract forbids (three distinct refusals below), or a task body the CLI refuses to accept as a shell argument. Three is configuration: a board whose configuration file fails to parse answers exit three with Configuration error: Invalid JSON in config file: and the parser's complaint, before any command logic runs. Four is input/output — pointing --body-file at a path that does not exist exits four with Error reading body file: file not found: /nonexistent/x.md and writes nothing. Five is validation: a mark against a missing ID exits five with Error: Task NOPE99 not found, and create --reject-duplicates exits five with Duplicate task body matches open task O9SSC7 (status: backlog). rather than minting a second copy of open work.

The precision matters in both directions. A get miss is one and a mark miss is five, so a wrapper that collapses them into "not found" is lying to itself; and a failing health check — a doctor that finds broken invariants, or a reconcile check that finds drifted files — is also five, which is what lets a CI gate turn a board-integrity rule into one line under set -e. Every task ID is six characters and validated at the edges, so scripts can sanity-check an ID before spending a process on it.

Stdout stays parseable; stderr carries the human noise

The output contract is deliberate: data goes to stdout in shapes a parser can take literally, and everything directed at a person — summary blocks, reminders, audit announcements, warnings — goes to stderr. Run bare, a collection command answers with pretty-printed JSON, which is what a human in a terminal wants; --raw compacts the same document, and -f ndjson emits one JSON object per line, the natural feed for jq and for line-oriented shell loops. The format flag's own help states the composite contract: "Output format: ndjson, json, xml, table. JSON format includes tasks array and summary object." That second sentence is load-bearing. With -f json, the paged collection commands (list, search, ready) print *two* JSON documents: the task array, then a summary object carrying total_tasks, displayed_tasks, status_counts over the five canonical statuses, a help hint, and — when asked — the pagination cursor. Their unpaged neighbors answer with the array alone — order -f json and tags -f json each print a single document — so a parser should count documents rather than assume them. A script that reads a two-document answer with a single-document parser is already broken; one that slurps both documents (jq -s) gets the page and its totals in one read.

One placement sharp edge is worth recording because the session hit it: the collection commands (list, search, ready, order, tags) accept -f after the subcommand, but the format and raw flags also exist as global options that must come *before* commands like get, history, and create. Suffixing get with -f ndjson is a usage error — exit two — while yylo-ledger -f ndjson get ID answers in NDJSON. Scripts that pin one convention stay portable. The same discipline is what makes command substitution safe: in the recorded run of the claim pipeline below, the readiness query's human summary block landed on the script's stderr while $() captured only the NDJSON lines.

Ask only for the bytes your script needs

A dispatcher does not need a task's full body to pick an ID, and a dashboard does not need bodies at all. Two flags shrink every answer. --projection metadata reduces each task to identity and status fields — id, status, dates, commit pointer, tags, relations, blockers, custom fields — dropping the body and response entirely; summary (the default) keeps them truncated, and full exports everything after announcing the audited broad export on stderr. --fields narrows further, and its help text carries the invariant a pipeline leans on: "Comma-separated output fields (id is always retained)". The recorded run asked for identities and got exactly that, one line per task, in NDJSON a jq filter can reduce to a bare ID stream: yylo-ledger ready -f ndjson --fields id | jq -r .id.

Shape-wise, two neighbors complete the read side. deps --id ID answers with a machine-checkable dependency object — is_blocked, unmet and met blockers with their statuses, dependents, priority score — and the session's dangling forward reference surfaced exactly there, as a blocker whose status reads "unknown" because the referenced task has not landed yet, which is how a script catches a graph waiting on nothing. And tags -f json aggregates tag counts as {"tag": ..., "count": ...} records, the cheapest board census a dashboard needs. For what these records *mean* — the graph semantics, the readiness rule — the dependency and selection guides own that depth; this page only pins the wire shapes.

Pagination that refuses to guess

Two pagination mechanisms ship, for two loyalties. --limit with --offset is the stateless default — context-efficient, replayable, blind to intervening writes. The opt-in cursor is for walks that must not skip or double rows, and its help text states the trade honestly: "Opt in to emitting an opaque next-page cursor (use --offset for context-efficient pagination)". The cursor is not a row number. It is a signed, versioned token bound to the exact collection revision it was minted from and to the exact query identity that produced it — and it fails closed, in the session, three separate ways, each exit two with a one-line diagnosis on stderr. Reused after any mutation touched the board: Error: cursor collection revision is stale; restart pagination. Reused against a different query on the same board: Error: cursor does not belong to this collection query. Combined with an offset: Error: --cursor cannot be combined with non-zero --offset.

Read as a design, the refusals are the feature. A page token that silently resumed across a mutation would hand a pipeline rows that no longer exist and skip rows that arrived mid-walk; this one tells the script the world changed and makes re-querying an explicit act. The summary object carries next_cursor only when --show-cursor was passed, so a script that never opted in never has a stale token to misuse.

Rich input belongs on stdin, not in argv

Task bodies are markdown — fences, backticks, dollar-prefixed variables, the exact bytes a shell tries to interpret before any program sees them. The CLI takes the hazard seriously at the boundary, and its guard's own docstring is the precise statement: "Shell expansion happens before Python receives argv, so this guard cannot stop unquoted command substitutions that the shell already executed. It does reject dangerous literals that survive parsing and points users to the file/stdin path, which is the reliable single source of truth for rich markdown bodies." The refusal is live, recorded verbatim as the session provoked it:

text
python3 -m yylo_ledger.cli create 'Wipe `rm -rf /tmp/x` and $(reboot) now'
Error: refusing shell-sensitive inline task body (command substitution literal "$()"). Use --body-file PATH or --body-file - for rich markdown so your shell cannot expand backticks, $(), heredocs, or multiline content before yylo-ledger sees it.

Exit two, nothing written, and the error names the safe path. That path is a single mechanism behind every text field: "Read exact UTF-8 text from a file argument or stdin marker." — --body-file - and --response-file - read the full standard input, so heredocs and pipes deliver bodies and responses byte-for-byte, no quoting games, and the same reader backs every command that accepts text. One more input hazard is environmental rather than syntactic, and the session recorded it the hard way: an ambient JUNO_TASK_ROOT redirects storage, with the resolution rule being "1. JUNO_TASK_ROOT env var (highest priority)" — above the config file's own project root. In the recorded probe, with that variable aimed at a second board, a process sitting inside the first repository — even one passing the first board's config path explicitly — still read and wrote the second board. A script that runs where other agents have run must therefore pin its own root, which is why the pipeline below sets it before its first command. Routing work across several boards deliberately, without any ambient fallback, is a separate discipline this page does not absorb.

Every mutation leaves a receipt

Writes answer scripts twice. On stdout, a mutation prints its human sentence — the recorded claim ended Task KoA5z2 marked as done — while the machine record goes where the receipt flag points, and the flag's help is the whole contract: "Write the complete structured mutation receipt to PATH, or - for stderr". The receipt is one sorted-key JSON line, written through a temporary file and an atomic replace, so a pipeline reading it after exit zero never sees a half-written record. Its keys, read straight from the recorded claim with jq -c 'keys': ["after_sha256","before_sha256","changed_paths","ledger_event_id","operation","persisted_path","task_id","transaction"]. A dispatcher branches on operation and task_id; an audit joins ledger_event_id to the same event id in the task's history — the session's mark receipt carried the identical event id as the history tail, which is the evidence chain from claim to ledger in one comparison; and the transaction object records the board's Git identity at write time. What that chain *proves* — body to response to validation to commit — is the audit walkthrough this page links to, not one to restate.

The claim pipeline is the whole surface in eleven lines, executed as printed against a sandbox board whose readiness query answered one NDJSON identity per line:

sh
#!/bin/sh
# claim-next.sh — claim the first ready task on this repository's board
set -eu
cd "$(git rev-parse --show-toplevel)"   # pin the board to this checkout
export JUNO_TASK_ROOT="$PWD"            # and beat any ambient root

first="$(yylo-ledger ready -f ndjson --fields id | head -n 1)"
case "$first" in '{"id":'*) id="$(printf '%s' "$first" | jq -r .id)" ;; *) exit 0 ;; esac

printf 'Claimed by %s at %s for unattended work\n' \
  "${AGENT_NAME:-unattended}" "$(date -u +%FT%TZ)" |
  yylo-ledger mark in_progress --id "$id" --response-file - \
    --receipt-file "$id.claim.json" >/dev/null

yylo-ledger doctor >/dev/null
echo "claimed $id"

Three contracts meet in it: the empty-answer guard treats the human no-match sentence as "nothing to do" rather than feeding prose to jq; the claim's response arrives on stdin with a receipt beside it; and the run gates on doctor, whose JSON verdict {"ok": true, "failures": []} is exit zero — a failing board answers ok: false with the failures named and exits five, stopping the script before it claims more work. Its siblings for maintenance windows are reconcile --check, which exits five and names the drifted task ids when direct file edits are unrecorded, and cache rebuild, which rebuilds the disposable query index and reports the count. Interactive operators get the same surface tab-completed — "Generate shell completion script for bash, zsh, or fish" — because a pipeline and a person deserve the same commands. To start one of these boards where your agents run, follow the storage-format walkthrough in Git-native task management for coding agents; When a claim needs proving, the chain from intent through response to commit hash is what the task-evidence guide audits end to end; and the complete command reference lives in the Ledger documentation.