junco
contents

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/.

note — plan-lint rejections consume no agent tokens; the ticket's result block names the lint error.

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.

note — 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.