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}.statusgainsskipped— a real slot whose stage aborted before it ran (a failed serial fixer, or a fail-fast cancellation) — alongsideok/failed/noop.failed_stagenow distinguishesfixfrombuild(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
--jsonmode, stdout carries exactly one JSON object and nothing else; every heading, grouped phase output, and progress line goes to stderr (saved viaexec 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_parallelgains an opt-in side channel: whenDISCERN_JOBS_RESULTSnames 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
--jsonmode emits the object withok:falseand thefailed_stageset, then exits non-zero — instead of the humandie+gotchas path. The gotchas pointer is suppressed in--jsonmode (the structured object is the machine's guidance). - Human mode is unchanged. Without
--json, finish behaves exactly as before — same headings, same grouped output, samedie+gotchas on failure, same informational tail. The--jsonbranches 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_parallelcarries a small, opt-in results side channel; its human output contract is otherwise untouched.- Durations are second-granular (portable
date +%s); sub-second jobs report0. Adequate for a gate; finer timing would need non-portable tooling.
Alternatives considered
§- A separate
finish-jsonrecipe. Rejected: it would duplicate the phase sequence and drift fromfinish. 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.