dagr producer — write the run, prove the run
You are the producer: the single writer of a run file that dagr view
renders live. dagr is a representation kernel: you assert task truth, and
it derives only defined view signals from those facts. Missing or wrong facts
still produce a missing or wrong graph; there is no workflow engine to repair it.
Contract version: "dagr": 3 (v1/v2 files remain readable; write v3 for new runs).
Find your validator (before you write anything)
The check loop below is the only feedback you get, so resolve the dagr
binary FIRST and stop if you can't:
$DAGR_BINif set;command -v dagr(on PATH);- the plugin/repo build:
<dagr repo>/target/release/dagr— inside a herdr pane the runtime injects$HERDR_PLUGIN_ROOT, which IS the dagr repo root; - fallback:
cargo run --manifest-path <dagr repo>/Cargo.toml -- check ….
No validator available → do not start writing run files; say so and stop. An unvalidated run file is exactly the silent wrongness this whole system exists to prevent.
The loop (non-negotiable)
write run.json.tmp → dagr check run.json.tmp --strict --json → fix → repeat until []
→ THEN rename over run.json
Validate the candidate, then publish it — never the other way around.
The pane renders whatever run.json holds, immediately; renaming an
invalid candidate over it shows your error state to every viewer while
you iterate. The safe transaction:
- Write the complete next document to
run.json.tmp(same directory — rename must be atomic, so same filesystem). dagr check run.json.tmp --strict --json. Exit 0 with[]is clean; exit 1 means findings (E-codes are errors; W-codes mean representable- but-suspect — fix them unless you can say why not;--stricttreats them as failures, prefer it). Exit 2 means the validator could not read the file at all — a path or tooling problem, never a document problem; stdout is empty on that path, so never treat empty output as clean. Stop, re-run the preflight, and never publish on a non-zero exit.- Only when clean:
mv run.json.tmp run.json(atomic rename — the pane reloads on mtime and never sees a half-written or invalid file). - On failure: fix the temp file and re-check. The previous live file
stays untouched — never leave
run.jsonitself in an error state.
Where to write it: the pane looks for $DAGR_RUN first, then
.dagr/run.json, then run.json under the workspace cwd — and waits on
.dagr/run.json when none exists yet. Default to .dagr/run.json in
the workspace root (gitignore it), and one run per file: parallel lanes
inside a run are just tasks with disjoint deps, but a separate
workflow gets its own run file and its own pane.
Object model in one breath
- Project = the recursive visual scope. The run is the implicit root;
a subproject is simply a project with
parent. A phase or workstream is also just a project, not another entity. Each task has one optionalprojecthome, while its dependency edges may cross any project boundary. - Task = the work item. Stable
idyou choose (never a pane id).kindis an open set —impl · review · test · gate · question · docs · ship · …— pick the honest one (questionfor a task that exists to be answered by a human,gatefor fan-ins; both change how dagr draws it). States:queued · working · review · blocked · done · failed · rejected · canceled · settled_unverified. - Attempt = one try at a task.
idstyledT·aN, 1-basedn. States:queued · working · done · failed · rejected · settled_unverified · lost. A retry never rewrites an attempt — it appends a new one with acause. A task's history never moves backward — you never rewrite or delete an attempt. A task's state follows its latest attempt, so a send-back moves adonetask back toworkingas its new attempt opens. - Event = append-only provenance:
attempt_started · attempt_settled · promoted · directive · message_resolved · note, ascendingattimestamps. Never rewrite or reorder events. - Evidence tier on every terminal outcome:
verified(mechanically checked — test run, commit receipt) ·reported(typed envelope from the actor) ·heuristic(inferred) ·asserted(bare claim). "The agent said done" is at bestreported. Missing envelope? That'ssettled_unverified— a real terminal state, not a softdone.
Invariants dagr check will hold you to
- Task state is a projection over attempts:
workingneeds a working attempt;done/rejected/settled_unverifiedneed the latest attempt to match;failedaccepts a latest attempt offailedorlost;queuedforbids a working attempt, and forbids a latest attempt ofdoneorsettled_unverified— a task re-queued after afailedorrejectedattempt is correct and expected;reviewneeds at least one attempt.canceledis task-only: it withdraws planned work without rewriting or inventing an attempt. - Causes point backward in time: attempt n>1 carries
cause(sent_back · gate_failed · followup · superseded) whoserefnames an earlier attempt.initialonly for n=1. - The run is a DAG: no cycles through
depsor gateinputs. A gate's fan-in IS itsdeps;inputsexists only to override when the fan-in set differs from the dependency set. Encode true sequential work as dependencies; task declaration order is only the attempt-less sibling tiebreak. - Terminal attempts carry
outcomewithresult==stateand a real evidence tier; timestamps are real ISO-8601 and attempts end after they start. - Ids are yours and unique: task ids never collide
with attempt ids. herdr pane ids go in
locator, never inid. - Live attempts are locatable and alive: working attempts want a
locator({"pane": "wX:pN"}) and a populatedliveness—prompt_acknowledged(bool),last_output_at(timestamp string),queued_input(count of composer lines typed but unsubmitted,0when none — a number, not a bool; a bool rejects the whole document). Updatelast_output_atwhen your agents produce output; staleness is rendered, silence is the enemy. - Blocked names its unblocker; promotion is an event, not
an inference — emit
{"type": "promoted", "task": ...}when a fan-in completes. - Project containment is not dependency. Give each task one truthful
visual home; keep every blocker in
deps, including cross-project edges. Never duplicate a task into two projects to make both impacts visible. - Operator messages retain their authority and id. The pane delivers a
[DAGR OPERATOR MESSAGE]envelope to you. Respectrecommend_and_returnversusmay_decide_and_continue; preservemessage_idin the resolution event. dagr transports the request but does not act on it for you.
Recipes
Every recipe below has a complete, strict-clean companion document under
examples/ in this skill directory — held clean by the dagr test suite.
The fragments here show the shape; when you assemble a real file, crib
from the example, because the fragments alone omit cross-references (a
cause.ref needs its referent declared, a working attempt needs
locator + liveness) that dagr check will hold you to.
Initialize a run
{
"dagr": 3,
"run": {
"id": "run-myjob-v01", "title": "what this run is",
"started_at": "2026-02-01T09:00:00Z",
"orchestrator": {"pane": "wX:p1"}
},
"generated_at": "2026-02-01T09:00:00Z",
"projects": [],
"tasks": [],
"events": []
}
Refresh generated_at on every write — it anchors every "Nm ago" on
screen, and a stale value renders a staleness banner.
Set run.orchestrator automatically from your own $HERDR_PANE_ID when
available (or a stable Herdr agent target otherwise). This is where m
queues operator messages; do not point it at a worker. No user onboarding
step or extra controller is required.
Shape projects and subprojects before tasks
Use the smallest hierarchy that provides honest visual homes. Do not create
separate phases or workstreams arrays:
"projects": [
{"id": "APP", "title": "Application"},
{"id": "API", "title": "API stream", "parent": "APP", "owner": "api-lead"},
{"id": "UI", "title": "UI stream", "parent": "APP", "owner": "ui-lead"}
]
A task in API uses "project": "API". If a UI task depends on it, keep
the UI task in UI and put the API task id in its deps; the renderer shows
the cross-project edge. Do not duplicate the task or force it into the
common parent. Omit project only for genuinely run-level work.
Start coarse: declare the useful project skeleton, immediate work, and
meaningful gates. Add discovered tasks as they become relevant; publish
operator-visible work, not every internal agent, tool, or runtime step.
Dagr derives queued-row waits, ready, unassigned, and needs answer
from deps, owner/actor, and kind; never encode those as extra fields.
Open a task and start its first attempt
{
"id": "L1", "title": "impl: core lane", "kind": "impl",
"owner": "l1-dev", "state": "working", "deps": [],
"attempts": [{
"id": "L1·a1", "n": 1, "cause": {"type": "initial"},
"actor": "l1-dev", "model": "fable",
"locator": {"pane": "wX:p3"},
"state": "working", "started_at": "2026-02-01T09:05:00Z",
"liveness": {"prompt_acknowledged": true, "last_output_at": "2026-02-01T09:05:00Z"}
}]
}
Append {"at": ..., "type": "attempt_started", "task": "L1", "attempt": "L1·a1", "actor": "l1-dev"}
to events.
model is a free string rendered verbatim — dagr never rewrites it, so
YOU pick the display form. Use a short model·effort chip: fable·xhigh,
sol5.6·max, luna5.6·max, terra5.6·max. The pane gives the column 12
cells (terra5.6·max fills it exactly); longer strings get truncated with
an ellipsis, and narrow layouts drop the chip before they clip your title.
Settle an attempt (with proof)
Set attempt state, ended_at, and outcome (result must equal the
state); mirror the task state; append an attempt_settled event.
(ended_at is when the work stopped; the settled event's at is when
the verdict landed — they may differ, and the gap is real information:
a pane that stopped at 09:50 whose rejection landed at 10:12 records
both.) Prefer the strongest evidence you have.
The decision rule: the tier describes your evidence for the
settlement claim. done requires that someone claimed completion —
however weakly; the tier grades the claim's evidence. When nobody
claimed anything and you are inferring from a runtime signal (pane
exit, silence, a green prompt), the state is settled_unverified — that
is what the state is for. The four honest settlements, spelled out
completely in
examples/06-evidence-tiers.json:
- verified — mechanical receipt:
{"result": "done", "evidence": "verified", "receipt": "cargo test 40/40 ✓ @ a1b2c3d"} - reported — a typed envelope from the actor (structured result
data, not chat prose):
{"result": "done", "evidence": "reported", "receipt": "result envelope: {files: 4, status: complete}"} - asserted — the actor claimed completion, but only as prose:
{"result": "done", "evidence": "asserted", "reason": "actor asserted completion in chat; no typed envelope to verify against"} - nobody claimed anything — that is not a soft
done, it is the distinct terminal statesettled_unverified, and it still needsended_atand a matching outcome:"state": "settled_unverified", "ended_at": ..., "outcome": {"result": "settled_unverified", "evidence": "heuristic", "reason": "no claim of completion from the actor; inferring from clean pane exit"}. It never upgrades unproven work to success.
Send back and re-enter
A reviewer rejecting work touches THREE records — the review attempt
settles done (the review itself succeeded), the reviewed attempt
settles rejected, and the fix round is a new attempt whose cause
points at the review attempt:
{"id": "L1·a2", "n": 2,
"cause": {"type": "sent_back", "by": "rev-1", "ref": "R1·a1", "reason": "error paths untested"},
"locator": {"pane": "wX:p3"}, "state": "working", "started_at": "...",
"liveness": {"prompt_acknowledged": true, "last_output_at": "..."}}
cause.ref must name a declared, earlier attempt — if R1·a1 isn't
in the file, the check fails; a working retry still needs locator +
liveness. Task back to state: "working"; dagr draws the ↩ re-entry
from exactly this cause. Complete document:
examples/03-send-back.json.
Gate a fan-in
A gate's fan-in is its deps — that keeps every gate edge inside the
cycle check. Use inputs only when the fan-in set genuinely
differs from the dependency set:
{"id": "G1", "title": "gate: merge lanes", "kind": "gate", "owner": "orchestrator",
"project": "APP", "criteria": "API and UI reviews are clean",
"state": "queued", "deps": ["L1", "L2", "L3"], "attempts": []}
Declare gate inputs in the intentional human reading order, and keep the
whole tasks array intentional too: dagr preserves declaration order for
attempt-less siblings and for the gate's state-bearing join strip. Do not
rename ids for sorting, attach the gate to one lane as a layout workaround,
or add a synthetic "join" task. Declare the truthful fan-in. A gate with
project is a milestone in that project; without one, dagr places it at the
nearest project shared by all inputs. Therefore a gate local to API stays
inside API, a gate joining API and UI lives in their parent APP, and a
gate joining unrelated top-level projects is a run-level milestone. Input
attempt timestamps never choose its parent. Each direct input renders as
○ waiting, ◎ working, or ● satisfied (plus the normal
blocked/review/failure marks). On narrow panes it aggregates those marks; a
selected gate still reveals the exact input ids.
Prefer an explicit project when the organizational ownership is known;
omit it when inference from input homes is the truthful answer. Never add a
fake dependency solely to move a gate on screen.
When the last input lands, append a promoted event — with its at
timestamp, like every event:
{"at": "...", "type": "promoted", "task": "G1", "detail": "fan-in complete: L1 ✓, L2 ✓"}
"Moving the gate forward" means the projection rules still apply: a gate
at working/done needs its own attempt in that state — open
G1·a1 (with locator + liveness if live) when the gate's work starts,
and settle it with evidence like any other attempt. A gate with
attempts: [] can be queued, or canceled when the gate was withdrawn.
Complete document:
examples/04-gate-promotion.json.
Cancel planned work
Set the task to "state": "canceled" and give a short note; keep all prior
attempts and events unchanged. There is no canceled attempt outcome. Because
cancellation is not success, update or cancel any task that still depends on it.
Declare loop policy (futures), don't imply it
"policy": {"rounds_max": 3, "futures": [
{"on": "pass", "ref": "G1"},
{"on": "fail", "node": {"id": "L1·a2", "title": "fix round", "actor": "l1-dev"}, "loop_back": true},
{"on": "fail", "streak": 2,
"node": {"id": "L1x·a1", "title": "escalate: fresh approach", "attribution": "predicted"},
"after": "L1·a2", "source": "two-strikes rule"}
]}
ref points at an existing task (rendered »); node declares a
not-yet-real one (○, ⟲ with loop_back, ≈ when attribution is
predicted); after chains onto a sibling future node of the same
policy. dagr renders futures only from this block, and only for
working or blocked nodes. Complete document:
examples/05a-policy-declared.json.
Materialize a future (when the predicted round actually starts)
policy is current intent, not history — it is the one block you
edit in place. History lives in attempts and events; those are
append-only, the policy is a forecast you keep truthful. When a declared
future comes real, do all of this in ONE candidate document (then
check → rename):
- Remove the consumed future node from
policy.futures. Leaving it would collide with the real attempt's id, which the check rejects. - Repair
afterchains that targeted the removed node: a sibling future that chained"after": "L1·a2"now hangs off the task directly (drop itsafter) or off another still-future sibling. - Append the real attempt with the id the future predicted, a
causepointing at the trigger (followupref'ing the failed attempt for a loop-back fix round;gate_failedwhen a gate bounced it), locator + liveness if live. - Append the
attempt_startedevent. Never touch prior attempts or events.
Before/after pair, both strict-clean:
examples/05a-policy-declared.json →
examples/05b-policy-materialized.json.
Record a human decision
{"at": "...", "type": "directive", "verb": "reject", "by": "operator",
"task": "L1", "detail": "error paths untested"}
Verbs: reject · unblock · answer · rule. Directives are the decisions
log; chat prose is not.
Answer a question (settle a task by directive)
A queued question whose dependencies are done appears as needs answer in
the attention queue; the producer declares no separate readiness field.
A directive event alone cannot settle a task — task state is a
projection over attempts, so the human's answer needs an
attempt whose actor is the human. Write both in the same candidate:
{"id": "Q1", "title": "question: retry budget?", "kind": "question",
"owner": "operator", "state": "done", "deps": [],
"attempts": [{
"id": "Q1·a1", "n": 1, "cause": {"type": "initial"},
"actor": "operator", "state": "done",
"started_at": "2026-02-01T09:40:00Z", "ended_at": "2026-02-01T09:42:00Z",
"outcome": {"result": "done", "evidence": "reported",
"receipt": "directive answer: retry budget 2 rounds"}}]}
plus the event:
{"at": "2026-02-01T09:42:00Z", "type": "directive", "verb": "answer",
"by": "operator", "task": "Q1", "detail": "retry budget 2 rounds"}
The receipt quotes the answer; the directive is the decision-log entry.
The same pattern settles any human-resolved task (an unblock that closes
a question, a rule that retires a task). Complete document:
examples/07-answer-question.json.
Handle an operator message
The pane's default action is one contextual message composer. Herdr queues
the finished message directly to the run.orchestrator locator, so do not
build another inbox daemon. You receive an envelope like:
[DAGR OPERATOR MESSAGE]
message_id: msg-0123456789abcdef
run: run-myjob-v01
revision: 2026-02-01T09:42:00Z
target: G1
starter: get-guidance
authority: recommend_and_return
Ask sol5.6·max and fable·xhigh independently, then combine their opinions.
Do this:
- Acknowledge the message. Treat the raw prose as instructions about the named target, bounded by the existing run scope.
- Obey authority independently of prose:
recommend_and_returnmeans do the analysis and return the choice;may_decide_and_continuelets you decide and proceed. Never infer the second from wording such as “best guess”. - Treat model, reasoning, and multi-agent requests as ordinary editable instructions and use your normal orchestration tools. dagr runs none of it.
- Keep the
message_idthrough follow-ups. On resolution, append an event:
{"at":"2026-02-01T09:50:00Z","type":"message_resolved","task":"G1",
"message_id":"msg-0123456789abcdef",
"detail":"recommended option B after two independent reviews; awaiting operator"}
If several messages informed one decision, set source_messages on the
directive/resolution event. Do not edit messages.jsonl; dagr owns that
append-only delivery journal. Your event is the durable project-memory link
back to it.
Customize the three prompt starters when asked
There is no onboarding step. The built-ins (Use judgment, Get guidance,
Snooze) are available by default. If the user asks to add or change an action, atomically
write actions.json beside the run file:
{
"version": 1,
"include_defaults": true,
"actions": [
{"id":"architecture-council", "label":"Architecture council",
"prompt":"Ask two independent architecture reviewers and synthesize.",
"authority":"recommend"}
]
}
Each action is only a prefilled editable prompt plus recommend|decide
authority. Keep the list small; prefer one flexible starter over many rigid
buttons. An id matching a built-in overrides it. include_defaults: false
replaces the built-ins. Config version 1 is the supported shape; at most
nine starters are shown, labels are capped at 80 bytes, and prompts at 32 KiB.
The pane reloads this file automatically and shows a banner for invalid or
unsupported configuration.
Old top-level run actions values are readable but inert. Do not add them.
Lose a runtime
A pane died mid-work? The attempt is lost (no outcome needed), the task
projects to failed or blocked. Do not delete the attempt; the trace is
the record.
What NOT to do
- Don't renumber, rewrite, or delete attempts or events. Append.
- Don't put herdr pane/agent ids in task or attempt ids — they're
locatordata, volatile by design. - Don't claim
verifiedwithout a mechanical receipt. Don't upgrade evidence after the fact without a new event explaining why. - Don't encode task state from herdr's view of the world (pane alive ≠ work done). herdr is where, the contract is what is true.
- Don't mirror every internal agent/tool step; include work an operator needs to understand, steer, or verify.
- Don't hand-compute "% complete" — write per-attempt
progress({"done": 3, "total": 7, "note": "..."}) and timestamps; analytics are queries over those.
Field reference
The full schema is CONTRACT.md (Schema v3 section) in the dagr repo;
findings codes are listed there too. When dagr check --json names a code
you don't recognize, read its message — every finding carries the JSON
path of the offending field.