Skip to content

DevLoop event stream

The bash engine (devloop.sh) emits one structured event per pipeline boundary as a single line of NDJSON. This file is the contract between the engine and any consumer (the future devloop-tui binary, monitoring scripts, dashboards).

Sinks

Every event is appended to two files:

File Scope Purpose
.devloop/events.ndjson project-wide All sessions, newest at bottom. Single source of truth for TUI dashboards.
.devloop/sessions/<TASK-ID>/events.ndjson per-session Only events for one session. Useful for replay and devloop resume.

Both files are append-only. Writers use unbuffered printf >> ... so a concurrent tailer (tail -F, fsnotify) sees every line atomically.

Set DEVLOOP_EVENTS_DISABLED=1 to silence emission (engine continues normally; this is a debugging escape hatch, not a recommended runtime setting).

Envelope

Every event line is a JSON object with at least these fields:

{ "ts": "2026-05-16T14:32:11Z", "session": "TASK-20260516-143155", "kind": "phase.start", "...": "..." }
Field Type Notes
ts string ISO-8601 UTC, second precision
session string TASK-ID; empty string for events emitted before session init
kind string dotted name from the table below

Additional fields depend on kind.

Event kinds (Phase 1)

session.start

Emitted by _session_init after the session directory exists.

{ "ts": "...", "session": "TASK-...", "kind": "session.start", "feature": "add dark mode toggle" }

session.end

Emitted by _session_finish once status is final.

{ "ts": "...", "session": "TASK-...", "kind": "session.end", "status": "approved" }

statusapproved | rejected | needs-work | rejected-at-plan | rejected-at-diff.

phase.start

Emitted by _session_phase_start.

{ "ts": "...", "session": "TASK-...", "kind": "phase.start", "phase": "worker" }

phasearchitect | worker | reviewer | fix-N (fix-N where N is the round number, e.g., fix-1).

phase.end

Emitted by _session_phase_end. Includes duration_ms when the start timestamp could be recovered.

{ "ts": "...", "session": "TASK-...", "kind": "phase.end", "phase": "worker", "status": "done", "duration_ms": "4231" }

statusdone | failed | skipped | approved | needs-work | rejected.

Quirk — architect phase: the architect runs before the session ID is known, so it has no phase.start. Consumers should treat session.start.ts ≤ phase.end[phase=architect].ts as the architect's window.

approval.request

Emitted by _approval_gate before blocking on a decision.

{
  "ts": "...",
  "session": "TASK-...",
  "kind": "approval.request",
  "gate": "plan",
  "summary": "Add dark mode toggle to settings page\nFiles: src/Settings.tsx, src/theme.ts",
  "detail_path": ".devloop/specs/TASK-....md",
  "detail_size": "3942",
  "decision_file": ".devloop/sessions/TASK-.../approvals/plan.json"
}

gateplan | diff | fix (only plan and diff are wired in Phase 1).

decision_file is the path a consumer can pre-write (or watch) to settle the gate without an interactive prompt.

approval.decision

Emitted by _approval_resolve once a decision is reached.

{ "ts": "...", "session": "TASK-...", "kind": "approval.decision", "gate": "plan", "decision": "approve", "source": "gum" }

decisionapprove | reject | edit.

sourceauto | pre-written | gum | gum-cancel | tty | tty-bad | timeout | no-tty.

Decision files

For every approval.request, the resolver writes the final decision to .devloop/sessions/<TASK-ID>/approvals/<gate>.json:

{ "ts": "2026-05-16T14:35:02Z", "gate": "plan", "decision": "approve", "source": "gum" }

To settle a gate non-interactively (CI, scripted, pre-decided by an external UI), write this file before the gate runs:

mkdir -p .devloop/sessions/$ID/approvals
echo '{"decision":"approve"}' > .devloop/sessions/$ID/approvals/plan.json

The resolver picks it up via the pre-written source.

Ordering guarantees

Within a single session, the engine emits events in this order:

session.start
  phase.end{phase=architect}            ← architect ran before session existed
  approval.request{gate=plan}
  approval.decision{gate=plan}
  phase.start{phase=worker}
  phase.end{phase=worker}
  approval.request{gate=diff}
  approval.decision{gate=diff}
  phase.start{phase=reviewer}
  phase.end{phase=reviewer}
  [phase.start{phase=fix-N} / phase.end{phase=fix-N}]*   ← repeated on NEEDS_WORK
session.end

Events from different sessions may interleave in .devloop/events.ndjson — consumers must group by session.

Bypass switches

Variable Effect
DEVLOOP_AUTO=1 Auto-approve all gates this run (also via devloop run --auto / -y)
DEVLOOP_PLAN_GATE=off Skip plan gate entirely (no request, no decision)
DEVLOOP_DIFF_GATE=off Skip diff gate entirely
DEVLOOP_APPROVAL_TIMEOUT=N TTY prompt timeout in seconds (default 120)
DEVLOOP_EVENTS_DISABLED=1 Disable event emission entirely

session.resume

Emitted by cmd_resume before re-entering a pipeline.

{ "ts":"...", "session":"TASK-...", "kind":"session.resume", "from_phase":"worker", "next_phase":"reviewer" }

from_phase is the name of the last completed phase (or "(none)" if no phase.end events exist). next_phase is the first phase that will be executed on resume.

phase.escalate

Emitted by cmd_resume (and the equivalent path in cmd_run) when the fix loop exhausts its retry budget and the engine is about to enter a respec (re-architect) phase. Added in v5.3 so the TUI can react to escalation without scraping worker stdout.

{
  "ts": "2026-05-22T08:14:42Z",
  "session": "TASK-20260522-074500",
  "kind": "phase.escalate",
  "from": "fix",
  "to": "respec",
  "retries": "3",
  "reason": "max-retries-exhausted"
}
Field Type Notes
from string The phase being abandoned. Currently always "fix".
to string The phase being entered. Currently always "respec".
retries string The configured max-retries that was just hit.
reason string Symbolic cause. Currently always "max-retries-exhausted".

Emission order around an escalation:

phase.end{phase:"fix-N", status:"needs-work"}
phase.escalate{from:"fix", to:"respec", retries:"3", reason:"max-retries-exhausted"}
phase.start{phase:"respec"}
phase.end{phase:"respec", status:"approved" | "needs-work"}

The TUI's Focus Mode subscribes to this event: receipt flips the active phase card to blue (StylePhaseBoxReArch) and the footer to ⟳ re-architecting after retries exhausted · waiting for new spec…. The state clears on the next phase.start for the same session.

Future kinds (Phase 2+, not yet emitted)

Kind Purpose
phase.log Streaming log line for live tail in the TUI
permission.request / permission.decision Wraps the existing PreToolUse hook
inbox.append REJECTED / max-retries fan-out (already partially modelled)

Consumers should ignore unknown kinds for forward-compat.