Skip to content

ADR 0004: agent finish --json — structured gate output

Retired — superseded by ADR 0028 (and the per-job gate surface of ADR 0017). Kept for history; not current architecture.

Status: superseded by ADR 0028 (the bespoke GateReport shape is replaced by the universal DiscernResult envelope + normalized diagnostics); previously amended by the 1.0 redesign — see Update (1.0) below.

Update (1.0)

§

The original decision (below) reports gate results per phase, on the reasoning that slots within a phase ran joined (&&), so the engine could not see an individual slot's pass/fail.

The 1.0 redesign makes each slot its own tracked job (the fix slots serial, the rest concurrent within their stage — see ADR 0002's Update). The honest unit is now the slot, and the JSON reports it:

  • "phases" becomes "slots"; each entry is {name, phase, status, duration_s}.
  • status gains skipped — a real slot whose stage aborted before it ran (a failed serial fixer, or a fail-fast cancellation) — alongside ok / failed / noop.
  • failed_stage now distinguishes fix from build (they are separate stages): evidence | fix | build | check/test | side_gates | merge.

Everything else — stdout-carries-only-JSON, human mode unchanged, the side_gates[] entries, second-granular durations — stands.

Context

§

setup and doctor already speak --json; agent finish did not. But finish is the recipe an agent-driven workflow most wants to consume — it is the gate that says "is this work done?". Without machine-readable output, an agent has to scrape human text and the exit code, which conflates what failed into a single bit.

ADR 0002 made side-gates first-class and noted that folding their results into a structured gate result depends on finish having --json. This ADR adds it, so item 2's aggregation can complete.

The execution unit matters for what the JSON can honestly report. Slots within a phase run joined (fix slots run serially with && because one fixer's output feeds the next; check/test likewise run as one command). So run_parallel sees one job per phase, not one per slot — the engine does not know an individual slot's pass/fail when several share a phase. The honest unit of structured reporting is therefore the phase (plus each side-gate, which is a distinct job).

Decision

§

Add agent finish --json, emitting a single JSON object on stdout and routing all human output to stderr.

  • Contract. In --json mode, stdout carries exactly one JSON object and nothing else; every heading, grouped phase output, and progress line goes to stderr (saved via exec 3>&1 1>&2, with the JSON written to the saved descriptor). The process exit code still reflects pass/fail.
  • Shape.
  {
    "ok": true,
    "phases": [{ "name": "fix", "status": "ok|failed|noop", "duration_s": 0 }],
    "side_gates": [
      { "scope": "native", "status": "ok|failed|skipped", "duration_s": 3 }
    ],
    "scopes_changed": ["web", "native"],
    "failed_stage": null
  }

phases lists the gated phases that actually ran (a phase whose slots are all no-ops reports noop); side_gates lists every configured gate — ok/failed for those that fired, skipped for those whose scope didn't change (this is the "what ran vs was skipped by scope" view); failed_stage names the stage that failed (evidence | fix/build | check/test | side_gates | merge) or is null on success.

  • Reporting unit is the phase, not the slot. Because slots within a phase run joined, the JSON reports per-phase results (the genuine execution unit) plus per-side-gate results. This is faithful to what ran; a synthetic per-slot status would be a guess.
  • Durations are whole wall-clock seconds, captured per job by run_parallel. run_parallel gains an opt-in side channel: when DISCERN_JOBS_RESULTS names a file, it appends <label>\t<code>\t<seconds> per job. With the variable unset (every existing caller), behaviour is unchanged.
  • Failure still produces JSON. A failing stage in --json mode emits the object with ok:false and the failed_stage set, then exits non-zero — instead of the human die+gotchas path. The gotchas pointer is suppressed in --json mode (the structured object is the machine's guidance).
  • Human mode is unchanged. Without --json, finish behaves exactly as before — same headings, same grouped output, same die+gotchas on failure, same informational tail. The --json branches are purely additive.

Consequences

§
  • Agent-driven workflows — discern's reason for being — can consume gate results cleanly: which phase/side-gate failed, what was skipped by scope, how long each took, all without scraping.
  • Item 2's side-gate aggregation is now complete: side-gates appear in side_gates[] alongside their pass/fail and duration.
  • The JSON reports per-phase, not per-slot. Anyone wanting per-slot granularity would first need per-slot execution (the same path-scoped-execution work ADR 0002 deferred). Documented, not a silent gap.
  • run_parallel carries a small, opt-in results side channel; its human output contract is otherwise untouched.
  • Durations are second-granular (portable date +%s); sub-second jobs report 0. Adequate for a gate; finer timing would need non-portable tooling.

Alternatives considered

§
  • A separate finish-json recipe. Rejected: it would duplicate the phase sequence and drift from finish. A flag on the one recipe keeps a single source of truth for the gate's shape.
  • Per-slot results. Rejected for now: slots run joined within a phase, so per-slot pass/fail isn't known without changing the execution model. Per-phase is the honest unit.
  • Emit JSON to a file instead of stdout. Rejected: stdout with human output on stderr is the conventional, composable contract (matches setup/doctor), and needs no path coordination.
choose openEsc close