How junco works
junco is a local daemon that turns Markdown tickets — dropped in a folder, or bridged from labeled GitHub issues — into draft pull requests by driving a coding agent in-process against an OpenAI-compatible inference endpoint. This page follows a ticket end to end; trust boundaries live in security model.
The queue
The queue is four directories under the data root:
inbox/, processing/, done/,
failed/. junco submit —
or any tool that writes a file atomically — drops a ticket into
inbox/. The daemon claims it by atomic rename into
processing/: the rename succeeds for exactly one
claimant or not at all, so two workers can never own the same
ticket — no lock file, no window, no race.
junco submit reef-color-fix.md junco list junco status
Claiming is gated twice more: a not_before stamp
defers a ticket until that UTC instant, and endpoint readiness is
probed before every claim — a down endpoint leaves work queued.
Finalize appends a result block and renames the ticket
into done/ or failed/. Transient
failures with no commits — connection errors, 5xx, truncated
streams — requeue to inbox/ with
retry_count incremented and a not_before
backoff, up to worker.maxTransientRetries.
Crash-stranded tickets in processing/ rejoin the
inbox at startup under the same budget.
Ticket flavors
A ticket is Markdown with YAML frontmatter;
junco schema prints the typed
contract. Selection is ordered: an assess: mapping
selects a read-only audit whose findings park for review instead
of filing; analyze: selects a read-only investigation
of one issue whose drafted comment parks instead of posting — both
are checked before repo:. A repo: path
selects the PR flow: agent in an isolated worktree, draft PR on
success. None of the three means Q&A: read-only tool
subset, answer appended to the ticket file, no git. Frontmatter
reference in tickets.
The PR-flow pipeline
Flow description: junco submit or the GitHub bridge places a ticket in inbox. The daemon claims it into processing by atomic rename. A git worktree is cut from the origin base branch and the agent session runs under loop guards. Verification, the critic, and the push follow; a draft PR opens and the ticket finalizes to done or failed. A transient failure returns the ticket from processing to inbox with retry_count incremented and a not_before backoff stamp.
Plan-lint validates frontmatter deterministically before any
agent runs: a repo: that is not a git repository,
unknown label names, a cd inside
## Verification — straight to failed/.
The claimed ticket gets a worktree carved off
origin/<base_branch>, one per ticket id. The
agent session runs in-process inside it, guards attached. Then the
## Verification bash blocks run in the worktree, and
the critic reads the final diff against the spec — PASS or
MISSING. On MISSING with retries remaining, exactly one corrective
agent pass is dispatched and re-evaluated. The branch is pushed;
gh pr create --draft opens the pull request.
Loop guards & supervisor
Four guards subscribe to the agent's event stream: repetition, tool-call loop, tool-error loop, and output budget (a token ceiling). On a trip the supervisor nudges first — a mid-run steering prompt injected without aborting. A repeat trip inside the escalation window kills the session.
A guard kill is a soft abort, and so is a timeout: both continue
through post-processing, so commits made before the cutoff are
salvaged — pushed and opened as a draft PR (statuses
aborted_partial / timeout_partial,
routed to done/). Every decision leaves a trace:
log line, transcript entry, metrics increment.
The provider gate
junco classifies inference-endpoint failures and, for the ones
only an operator fixes, pauses claiming across the whole daemon.
Three states latch: auth_error,
quota_exhausted, and misconfig (model
not found) hold until a successful session, a config hot-reload
apply, or a daemon restart — they never expire on their own. Two
states back off and clear themselves: rate_limited
doubles its delay on each further report, capped at 900 s;
outage_backoff waits one
worker.retryBackoffSeconds interval. A latch always
outranks a backoff.
A ticket that trips the gate requeues with a fresh
not_before but an untouched retry counter — provider
faults never burn a ticket's retry budget. /health
reports the gate state; the full table is in
operations.
The spend ledger
Every completed session's resolved cost — main run, critic
pass, corrective re-dispatch, and Q&A/assess/analyze runs
alike — is tallied in USD against the local calendar day, cap or
no cap.
worker.dailyBudgetUsd (default 0, disabled) caps the
day: at the cap the gate latches budget_exhausted and
claiming pauses until local midnight, or until an operator raises
the cap via config hot-reload.
budget_exhausted is
the one gate state a successful session does not clear — finishing
a session does not un-spend money.The data root
Everything junco writes resolves under one root,
dataDir (default ~/.junco): the queue and
parked review items at the top, durable state under
data/ (outbox, histories, transcripts,
spend.json), regenerable state under
cache/ (managed clones, PR-flow worktrees, mirrors —
safe to delete wholesale), and logs under logs/. A
not-yet-migrated legacy root (~/.local/state/junco)
keeps working until junco data migrate unifies it.
The tree is created eagerly at daemon startup, and the root
writes itself a .gitignore containing *
(when none exists) — a data root inside a git checkout never
dirties a commit.
junco data prints the tree, read-only. Layout and
legacy overrides in configuration.
The offline outbox
When GitHub is unreachable, side effects queue instead of
failing: labels, comments, issue creation, and the push-plus-PR
endgame land as FIFO op files under outbox/, and the
ticket still finalizes to done/ locally. Replay on
reconnect is idempotent — comment ops carry idempotency markers,
composite PR ops checkpoint — and an op that fails three attempts
dead-letters under outbox/dead/.
junco outbox lists the backlog;
junco outbox flush pushes it now. Finished work is
never lost to a dead network. The label-driven loop this feeds
lives in the GitHub loop.