Pre-release Harn is pre-1.0 — the language, standard library, and CLI may change between releases. See the release notes

Completion gate (std/agent/judge)

A tool-using agent stops when the model says it's done. That is the model's opinion, not a fact. The completion gate turns "done" into something you can check: a deterministic veto built from write and verifier evidence, with an optional bounded LLM judge on top. It is the peer of the governors and lanes and overlays — one concern, one module, one value you fold into the agent_loop options dict.

agent_completion_gate composes existing loop seams rather than adding a hook: the deterministic part rides the verify_completion closure the loop already consults at done-time, and the optional judge rides the capped verify_completion_judge / done_judge seam. Harn owns the veto arithmetic; your host supplies every domain fact (what counts as a source write, whether the verifier is green) through callbacks.

agent_completion_gate

agent_completion_gate(options: CompletionGateOptions = {}) -> dict

Returns an options fragment you spread into your agent_loop options. The fragment carries a verify_completion closure (the deterministic ladder) plus a _completion_gate metadata row; when judge is set it also carries the judge seam config.

import { agent_completion_gate } from "std/agent/judge"

const gate = agent_completion_gate({
  facts: { ctx -> {source_write_count: 1, verify: {ok: true}} },
  max_vetoes: 3,
})
log(gate._completion_gate.facts_available)
log(gate._completion_gate.max_vetoes)

To use it, spread the fragment into your base options:

import { agent_completion_gate } from "std/agent/judge"

agent_loop(task, system, base_opts + agent_completion_gate({
  facts: fn(ctx) { return host_completion_facts(ctx.session_id) },
  verify_command: fn() { return host_run_verify() },
  judge: true,          // optional bounded LLM judge, capped at 5 by default
}))

Options

CompletionGateOptions splits into host-fact callbacks and plain-data policy knobs. Every field is optional.

FieldTypeDefaultMeaning
factsfn(ctx) -> CompletionFactsThe primary fact supplier. ctx is {session_id, task, stop_reason, text, messages}.
classify_writefn(path, diff?) -> WriteKindLabels one write when facts returns a writes list without counts.
verify_commandfn() -> CompletionVerifyVerdictRuns the verifier oracle when facts carries no verify verdict.
require_source_writebooltrueEnforce the source-write evidence requirement.
requires_writeboolper-task factOverride the "this task needs a source change" fact.
max_vetoesint3Per-session soft-veto budget; 0 disables.
veto_combinefn(verdicts) -> CompletionVerifyVerdictAND-of-oraclesOverride how multiple verifier verdicts combine.
judgebool / dictoffAttach a bounded LLM judge (true or a judge-config dict).
judge_seamstring"verify_completion_judge"Which capped LLM seam the judge rides ("verify_completion_judge" or "done_judge").

The fact types:

  • CompletionFacts{source_write_count?, cosmetic_write_count?, writes?, verify?, requires_write?}. Supply counts directly, or a writes list of CompletionWriteFact for the gate to classify. verify is one CompletionVerifyVerdict or a list of them.
  • CompletionWriteFact{path?, diff?, kind?}. kind is a WriteKind string; "source" and "cosmetic" are load-bearing, anything else is treated as non-source.
  • CompletionVerifyVerdict{ok?, findings?}. findings is optional red-detail text.

The deterministic ladder

The gate never keys on a done-sentinel string. It decides purely from write and verifier facts, first match wins:

ReasonResultCondition
no_source_writeveto (soft)task requires a source change, but only cosmetic / zero source writes so far
verification_after_write_redveto (strict)a source write with a red verifier — the budget never releases it
verified_after_write / verifiedallowverifier is green
missing_verificationveto (strict)source written, verifier configured, not yet run — the budget never releases it
no_workspace_writeallowtask does not require a source change
veto_budget_exhaustedallowa soft veto after max_vetoes, converted to an attributable end

Only source writes count as progress toward done. A cosmetic final write (a comment, a .md typo) is not evidence: it can't flip an already-green run back to unverified, and a run that wrote only cosmetics can't claim done. A strict veto (a source write with a red or missing verifier) is never released by the veto budget — the run does not get to declare victory on a failing build.

Each deterministic decision emits a judge_decision event with trigger: "verify_completion", confirm, and the stable reason above. When the budget converts a soft veto into an allow, the event carries reason: "veto_budget_exhausted" plus converted_from with the original class.

Degraded mode

With no host-fact callbacks (facts, verify_command, and classify_write all absent), the deterministic gate has nothing to assert, so it abstains — it allows, and marks _completion_gate.facts_available = false with a verdict reason of facts_unavailable. It never fabricates a pass. Any configured LLM judge still runs, so this is judge-only mode, not no-op mode.

The optional bounded judge

Set judge to add an LLM check on top of the deterministic ladder. judge: true uses defaults; a dict passes through provider, model, system prompt, and cap overrides. The judge rides the capped verify_completion_judge seam by default (or done_judge via judge_seam), so it inherits that seam's per-session veto cap — 5 by default. Past the cap the judge stops firing and the loop ends with status verify_capped; set max_invocations: 0 to disable the cap. Two helpers surface the resolved cap for run records:

agent_verify_completion_judge_cap(judge_cfg) -> int | nil
agent_done_judge_cap(judge_cfg) -> int | nil

Both return nil when the cap is disabled.

See also