Changelog
Unreleased
0.4.5 - 2026-09-02
Closes the v0.4.3 make-cli conformance sweep: six findings, all agent-facing
contract shape. One breaking change -- contract_version becomes a dotted
MAJOR.MINOR string ("1.0"), not the integer 1 -- landed now, before 1.0,
because changing the type is itself breaking and cheapest to pay pre-1.0.
Everything else is additive: doctor and status payload shape, a
run/replay meta.inferctl degradation pair, case add --stdin bulk import,
and init --force deletion reporting. Command names, envelope shape, error
codes, exit codes, case statuses, and report projection are otherwise unchanged;
report hashes are unaffected.
BREAKING:
contract_versionis now a dottedMAJOR.MINORstring ("1.0"), not the integer1. An integer had no minor channel: v0.2, v0.3, and v0.4 were all additive yet all stuck at1, so an agent could not tell those contracts apart through the field that exists for exactly that purpose, and the reference compatibility idiom (which splits the field on.) raised against an integer. Decision: adopt the dotted string now, before 1.0, since changing the type is itself breaking and cheapest to pay while pre-1.0 (the two rejected options were a second additive-counter field, and documenting that additive change is simply not signalled). Going forward MAJOR bumps on a breaking change and MINOR bumps on a purely additive one. The field moves in bothdata.contract_version(capabilities) andmeta.contract_version(every envelope); thecapabilitiesschema now types itstringwith a^\d+\.\d+$pattern; capabilities, all schemas, every golden, and the docs move together. Consumers doing an integer comparison must switch to splitting on.and comparing integer parts. See docs/agent-guide.md for the rule an agent should read.doctor.operation_outcomenow matches the DIAGNOSE reference shape, anddoctorreports a re-check cadence. The second key washealth_kind(all-clear/attention-needed), which no reference field named; it is nowexit_code_kind.kindstill carries the health outcome (healthy/degraded/unhealthy) whileexit_code_kindreports the exit-code family the command returns (alwayssuccess:doctorreports state, it never fails its own exit). A newdata.next_check_after_seconds(300when healthy,60otherwise) tells an agent when to poll again.statussurfaces its recommended action as a paste-readycommand. Both the completed and in-flight branches carrieddata.recommended_actionbut leftcommands[]without it (completed carried only pagination links; in-flight carried none). The recommended command now appears incommands[]as well, so an agent can run it without reshaping the payload.recommended_actionstays indata-- this is an addition, not a move.run/replaymeta now carries an inferctl requested-vs-actual pair when a capture was requested. When--inferctl-taskasked for preflight capture but inferctl was absent or incompatible, only aW_INFERCTL_ABSENT/W_INFERCTL_INCOMPATIBLEwarning marked the silent degradation.meta.inferctlnow reportsrequested_mode,actual_mode,available, anddegraded, so the degradation is detectable from meta structure instead of warning text. The pair is omitted entirely when no capture was requested.case add --stdinbulk-adds cases from pipedcases.jsonl-shaped records. Single-add takes one case per process; seeding a suite meant one fork per case.--stdinreads whole records (one JSON object per line, fieldsid/task/workspace/diff/expect), so a suite'scases.jsonlpipes straight into a fresh suite. The report is per-record --data.added/skipped/rejectedwithcounts-- because a bulk add is not transactional across records: an exact-duplicate record is skipped, an id-conflict or a malformed/invalid record is rejected with a per-line reason, and every accepted record lands in one atomic append. Any rejection raises the newW_CASE_ADD_REJECTEDwarning and exits1;--stdindoes not combine with the single-record flags (--task/--workspace/--id/--diff/--expect-json).init --forcenow reports the files it deletes.--forceremoves the existing sample suite to make room, but the envelope described only what was created and was silent about what was removed -- a destructive flag that under-reports its own effect.initdata now carriesremoved(the deleted paths, capped at a bounded sample),removed_count(the true total), andremoved_truncated. A plaininitreports an emptyremoved; it still refuses withE_RUN_CONFLICTwhenevals/exists and deletes nothing. Swept the other mutating verbs in the same pass:jobs prunealready reports its removals precisely, andreplay --forceoverwrites the exactrun_idthe caller named and reports that id.planreports a live reservation as an external blocker instead of proposing work that would be refused. With a live reservation held on the target run,plan(andplan --resume) marked the run blocked but proposed therun/run --resumecommand thatE_RUN_BUSYwould then reject, and the payload carried no branchable blocker. The payload now carriesdata.blocked_by_external-- populated only when a blocker exists -- naming the reservation, when it clears, and thestatus <run-id>command to inspect it; the affected cases are markedblockedand the recommendedcommandspoint at the blocker, not at the refused run. With no reservation held, the plan is unchanged.statusandreportnow see a run while it is still in flight. Both verbs keyed onmanifest.json, which is written only at finalize, so mid-run they returnedE_RUN_NOT_FOUND(exit1) for a run thatjobs getreported asrunning. Nowstatus <run-id>returns exit0with the livestateand per-caseprogress(case_count/terminal/pending), andreport <run-id>returns the newE_RUN_IN_FLIGHT(classtransient, exit4, retryable) naming the state and pointing atstatus, instead of pretending the run does not exist.E_RUN_NOT_FOUNDis now reserved for ids with no run directory, sojobs get,status, andreportnever disagree about run existence.contract_versionremains1; report projection and report hashes are unchanged.plan,status, andreportnow bound their per-case collections. These three verbs returned the full case array in one envelope -- roughly 660 bytes per case forplan-- so a large suite could emit a quarter-megabyte payload with no truncation notice and reject the bounding flag. They now accept--limit(default50, max1000) and--cursor, matchingjobs list, and reportmeta.paginationandmeta.truncatedwith the omitted count and a paste-ready next-page command whenever the collection is clipped. An unbounded call on a small suite is byte-identical to before: no pagination meta is emitted unless the output is clipped or a bounding flag is passed. A cursor the tool never issued -- on any of the four paginated verbs,jobs listincluded -- is nowE_CASE_INVALID(exit1) naming the offending value, instead of a silent empty page indistinguishable from the end of the collection. Forreport, onlycasesis paged;failuresandreport_hashstay computed over the full set, so the hash remains the integrity anchor across pages.validatenow checks the two things it claimed to check but did not. A suite whose scorer name was not a known scorer, or whose runner executable did not resolve, still returnedvalid: true-- so an agent following the prescribed validate-then-run workflow got a green validate and an errored run. An unknown scorer name is nowE_CASE_INVALID(exit1) naming the valid set (valid_values) with a near-matchdid_you_mean. An unresolvable runner executable is the newW_RUNNER_UNRESOLVEDwarning rather than an error, because it may still resolve in the run environment; an argv token carrying an env placeholder ($VAR) resolves only at run time and is left alone.suite add --runner-argvrejects a JSON array. The flag takes a shell-style argv string, but a JSON array ('["python3","runner.py"]') was silently coerced into a single literal argv token that validated and then errored at run time. It is nowE_CASE_INVALID(exit1) naming the shape the flag wants.Not-found and unknown-verb errors now name the valid set. A missing suite (
E_SUITE_NOT_FOUND), a missing run (E_RUN_NOT_FOUND), or an unknownschemaverb reported only the bad value. They now carryvalid_values-- the suites on disk, the runs on disk, the schema verbs the tool holds -- with a near-matchdid_you_meanwhere one exists;schema <bad-verb>also carries a paste-readycorrected_command. A suggestion is offered only within one vocabulary, never across unrelated flags.schema <bad-verb>is nowE_UNKNOWN_COMMAND(the code its dispatch sibling already used) instead of a generic parse error.initon an unwritable directory is a declared tool-environment error. Scaffolding into a read-only directory surfaced as an undeclared internal error. It is nowE_INIT_UNWRITABLE(classtool-env, exit3) naming the OS reason and pointing at the write-permission fix.schemanow pins every verb and publishes its output vocabularies. Only three of the fourteen JSON verbs had a schema golden, sodoctor,status,report,replayand the rest could drift silently; anddefinitionswas exported empty, so no output vocabulary was contract. Every JSON verb now has a pinned schema golden, and a test fails if a verb ships without one. Thedefinitionsblock now carriescase_status(error/fail/pass),run_state(completed/orphaned/running/stale) andplan_action(blocked/run/skip_terminal) as enums, referenced by$reffrom every schema that carries them -- so an agent branching on case status reads the full set from the contract instead of discovering it by observation. A test pins the publishedcase_statusenum to exactly the set a real run produces. The per-jobstateinsidequeue_jobsis deliberately left open: it is a spoolctl vocabulary evalctl passes through and does not own.contract_versionremains1; report projection and report hashes are unchanged.replayis now idempotent independently of the wall clock. The default destination id embedded a second-resolution timestamp, so a retry behaved differently depending on which second it landed in: tworeplaycalls in the same second collided on one id and the second returnedE_RUN_CONFLICT, while two calls across a second boundary produced two separate runs. The default id is now derived from the source run and the exact replayed case set, not the clock, so a retry always lands on the same id and returns the existing run -- matching howrun --run-idtreats a completed run. Idempotency keys on run identity, not the id string: a genuinely different case set on the same id is stillE_RUN_CONFLICTunless--forceis passed, and--forcealways rebuilds.SOURCE_DATE_EPOCHis still honored for run timestamps; it no longer affects the replay id at all. Report hashes are unchanged.
0.4.4 - 2026-09-01
Two exit codes that the contract advertised but that no code path exercised are
now wired to real behavior. contract_version remains 1; command names,
envelope shape, error codes, exit codes, case statuses, and report projection are
unchanged, and report hashes are unaffected.
- Exit
2(safety block) is now reachable:run/replayrefuse to execute an unacknowledged unsandboxed runner. Runner and scorer commands are local code that evalctl executes with the caller's privileges.run,run --resume, andreplaynow refuse withE_UNSANDBOXED_RUNNER_UNACK(classsafety, exit2) before running any command, unless the invoker acknowledges via the new--acknowledge-unsandboxed-runnerflag orEVALCTL_ACKNOWLEDGE_UNSANDBOXED_RUNNER=1. The acknowledgment deliberately lives with the invoker, not in the suite file: a suite-file field is controlled by whoever authored the suite, so it could not defend against an untrusted one. The former scaffold fieldacknowledged_unsandboxed_runneris removed, and the standingW_UNSANDBOXED_RUNNERwarning still rides everyrun/replayenvelope. This is a behavior change: existing callers ofrun/replaymust now acknowledge. - Exit
6(run --fail-on-fail) is machine-branchable. The envelope still reportsok: true(the harness succeeded even though the eval did not), and now carriesdata.fail_on_fail_triggered--trueonly when--fail-on-failwas passed and at least one case did not pass. evalctl also prints a one-lineeval failure:summary to stderr. Branch on the field, not onok.
0.4.3 - 2026-07-29
--help now works per verb and no longer runs the verb. Queued runs read
spoolctl's own failure_reason enum instead of inferring outcomes from two
proxies, and record real durations. contract_version remains 1; command
names, envelope shape, error codes, exit codes, case statuses, and report
projection are unchanged, and report hashes are byte-identical to v0.4.2.
evalctl <verb> --helpran the verb.--helpand--jsonwere registered as global flags, so they passed the unknown-flag check on every verb and reached handlers that never acted on them; only the top-level dispatcher acted on--help.evalctl init --helpscaffolded a tree andevalctl run <suite> --helpexecuted the suite -- the read-only probe an agent reaches for first was, on two verbs, a mutation.--helpand-hare now handled per verb, before the handler, and print that verb's own help at exit 0 with no side effects. Detection follows the flag grammar:--helpas the value of a dash-tolerant flag is still a value, and after--it is still a positional.robot-docs guide --jsonreturned markdown at exit 0.capabilitiescorrectly declares"json": falseforrobot-docs, but the flag was accepted and ignored. It is now rejected withE_UNKNOWN_FLAGat exit 1. The reject list is derived from the sameCommandSpec.jsonfield that feedscapabilities, so the accepted set cannot drift from the advertised one. The error carries nodid_you_mean: the nearest flag to--jsonis--version, which is valid syntax with unrelated semantics.Queued cases recorded
duration_ms: 0. The queued path readattempts[].duration_ms, a field real spoolctl has never emitted, so the value was always0on every queued case. Durations are now derived from the attempt'sstarted_atandfinished_at, clamping to0only when a timestamp is missing or unusable. This is the release's one change to recorded run data.duration_msappears incases/<id>/runner.jsonand in no scored output, so reports and report hashes are unaffected.Queued outcome classification keys on
(failure_reason, exit_code). spoolctl has emitted a machine-readableattempts[].failure_reasonsince its 0.4.2, and evalctl's contract>= 2floor guarantees it is present. The attempt'sstatefield and a prefix match on spoolctl's human-readable error text are no longer consulted. Both were contracts evalctl does not own: spoolctl could have reworded either in any release without evalctl failing loudly. For every outcome evalctl can reach, the new mapping agrees with the old one -- this is a robustness change, not a correction of past results.A canceled queued job was reported as a spoolctl incompatibility. A job canceled before a worker picked it up reports an empty
attemptslist, and the queued path raisedE_SPOOLCTL_INCOMPATIBLEwith exit3, telling the operator to upgrade spoolctl for what is an ordinary queue outcome. Such a job now recordsE_RUNNER_FAILEDon the case and the run completes. Only a payload with noattemptskey, or a non-list there, is still treated as a spoolctl evalctl cannot speak to.Per-case
job.jsonrecords the queue's state for the job, not the last attempt's. The value moves fromsucceededtodone, and fromfailedortimed_outtodead; a job canceled before it ran now recordscanceledinstead of nothing. evalctl readstateoff the top level ofspoolctl show, where real spoolctl does not put it -- the job is nested underjob-- so the read always failed and fell through to the attempt's state, a different vocabulary.job.jsonis queue provenance, and the job state is spoolctl's own verdict; the execution detail is inrunner.jsonand is unchanged.job.jsonis not part of the report projection, so report hashes are unaffected.A
failure_reasonevalctl does not recognize now maps toE_RUNNER_FAILEDrather than being an error, so a future spoolctl adding an enum member does not become an evalctl outage.E_SPOOLCTL_INCOMPATIBLEis never raised for an unrecognized reason, and the required spoolctl version is unchanged.The fake spoolctl test fixture now synthesizes real spoolctl's attempt record field-for-field. It previously emitted
duration_msand omittedfailure_reason-- the inverse of the real tool -- which is why the duration bug survived two releases undetected. Fake and real binary are asserted against one shared key set, and the real-spoolctl CI job asserts a queued 0.5s case records a real elapsed duration. The fixture'sshowenvelope was flat where the real one nests the job, which is what hid the dead job-state read above; the envelope's top-level key set is now part of the same two-sided check.
0.4.2 - 2026-07-28
Spoolctl compatibility fix, real-spoolctl CI coverage, and an internal module
split. contract_version remains 1; command names, envelope shape, artifact
layout, and report projection remain compatible with v0.4.
This release also carries everything listed under 0.4.1 below, which was
never tagged or published.
- Queued spoolctl runs now require
spoolctl >= 0.4.11speaking contract>= 2. The previous minimum,0.4.1, was never published to PyPI, and the compatibility gate pinned spoolctl's contract to exactly1. spoolctl moved to contract2in its 0.4.5, soevalctl run <suite> --queue spoolctlfailed withE_SPOOLCTL_INCOMPATIBLEagainst every installable spoolctl. - A spoolctl reporting a contract newer than
2is now accepted rather than rejected. The comparison is a numeric floor with no upper bound, so a future spoolctl release does not become an evalctl outage. - The compatibility gate reports three distinguishable causes instead of one
message. Version mismatches carry
observed_versionandminimum_version, contract mismatches carryobserved_contractandminimum_contract, and missingspoolctl addflags carrymissing_flags.E_SPOOLCTL_INCOMPATIBLEand exit3are unchanged. - Prerelease spoolctl versions at the floor are rejected.
0.4.11rc1no longer passes as0.4.11;0.4.12rc1,0.5.0a1, and0.4.11+localare accepted. capabilitiesreportsminimum_contractalongsideminimum_version, and the observedcontract_versionwhen spoolctl is available.evalctl doctorrecommends installing or upgrading spoolctl instead of re-runningevalctl doctor, and names a version that the command it prints can install. Doctor exit codes are unchanged.- Queued-run semantics are now covered against a real spoolctl binary, not only
the fake fixture. A
queue-integrationCI job installs pinnedspoolctl==0.4.11and runstests/test_real_spoolctl.py, which checks report hash parity between queued and in-process runs, timeout and spawn-failure mapping, thestdin:"task"wrapper, resume, and queue provenance. SettingEVALCTL_REQUIRE_REAL_SPOOLCTL=1turns a missing or below-floor binary into a hard failure, so the job cannot pass by skipping its own tests. Ordinary local test discovery still passes with spoolctl absent. - The fake spoolctl fixture writes its database through a same-directory
temporary file and
os.replace, serializes the full load-mutate-save under a lock, and assigns job ids from a persisted counter. This fixes intermittent Python 3.13 CI failures caused by a reader observing a partially written fixture database. evalctl/cli.pywas split into focused modules covering static contracts, process execution, artifacts, scoring, optional integrations, run state, runner orchestration, suite reports, doctor, and command handlers. No public behavior changed: the CLI grammar, envelopes, and goldens are identical.
0.4.1 - 2026-07-24
Never tagged or published; these changes first ship in 0.4.2 above.
CLI grammar hardening and refactor-safety patch. contract_version remains
1; command names, envelope shape, artifact layout, report projection, and
optional integration behavior remain compatible with v0.4.
- Added dev-only subprocess-aware coverage controls and normalized checked-in goldens for help, capabilities, representative schemas, robot docs, and malformed-input error envelopes.
- Added a typed internal command/flag registry and centralized parser for booleans, positive integers, enums, safe IDs, paths, JSON text, and free text while preserving the public CLI grammar.
- Reclassified malformed documented inputs as user-input errors. Invalid
integer values, zero/negative positive-integer flags, missing values,
empty-string values, and registered flags supplied where values are required
now return structured
E_CASE_INVALIDenvelopes with exit 1 instead of raw tracebacks, internal/environment failures, or silent fallbacks. - Unknown flags are rejected consistently across all commands and subcommands
before positional interpretation. This changes typo cases such as
init --forse --jsonfrom silent success toE_UNKNOWN_FLAGexit 1. - Unknown-flag suggestions now account for flag arity. Value-taking suggestions no longer emit syntactically invalid corrected commands unless a value can be preserved safely.
- Tightened safe IDs so leading-dash values such as
--jsoncannot be accepted as run, resume, case, scorer, or inferctl task IDs. - Treated
--formatas a report-only flag and made pre-dispatch JSON-mode detection non-raising; malformed--formatinputs now produce enveloped errors rather than raw tracebacks.
0.4.0 - 2026-07-23
Planning, diagnostics, CLI recovery, bounded job listing, and inferctl
provenance minor release. contract_version remains 1; the changes are
additive, and report projection remains unchanged for comparable runs.
- Added bounded
jobs listoutput with--limit,--cursor,total_count, pagination metadata, truncated metadata, and paste-ready next-page commands. - Added structured did-you-mean recovery for unknown top-level commands,
namespace subcommands, and checked flag typos, including
did_you_mean,corrected_command, andvalid_valuesfields. - Added
doctordiagnostics for runtime, suite root, runs root, reservations, spoolctl, inferctl, and runner-safety state. - Added side-effect-free
planoutput for fresh runs, explicit run ids, resume planning, spoolctl queue planning, concurrency tracks, and inferctl task intent. - Added
run --inferctl-task TASKbest-effort inferctl preflight provenance. Compatible runs write per-caseinferctl-preflight.jsonandinferctl-provenance.json; absent, incompatible, blocked, and failed capture states are warnings and do not prevent runner execution or scoring. - Updated README, robot docs, schemas, capabilities, help text, warning codes, and regression tests for the v0.4 surfaces.
0.3.0 - 2026-07-16
Durability and resume minor release. contract_version remains 1; the
plain synchronous report hash stays byte-identical to v0.2 and manifest-shape
parity is preserved with created_ts controlled by SOURCE_DATE_EPOCH.
Capabilities and schema hashes were re-pinned for additive flags, schemas, and
error-code registry entries.
- Added durable
run.jsonmetadata and per-case terminalstate.jsonmarkers so interrupted runs can be reconstructed. - Added TTL-based
.reservation.jsonliveness with background heartbeat and stale-reservation reclaim throughrun --resume. - Added
run --resume <run-id>to skip terminal cases, re-run unfinished cases, and finalize the original run id from snapshotted state. - Added
jobs list|get|prunefor local run/reservation/queue inspection and guarded cleanup. - Refactored case execution into prepare, execute, normalize, score, and marker phases.
- Added optional
run --queue spoolctlforspoolctl >= 0.4.1, using one ephemeral drain worker, per-run.spoolctl.db, at-most-once execution by default, and evalctl-owned artifact reconstruction/scoring. - Updated README, robot docs, schemas, capabilities, help text, and regression tests for the v0.3 surfaces.
0.2.0 - 2026-07-15
Authoring and execution-replay minor release. contract_version remains 1;
the universal envelope is unchanged. Capabilities and schema hashes were
re-pinned for additive verbs, schemas, and error-code registry entries.
- Added CLI authoring verbs:
suite add,case add, andscorer add. - Added
replay --failedto re-execute failed/errored cases into a fresh partial run linked bymanifest.replayed_from. - Added command-scorer protocol with captured per-case verdict artifacts that report/artifact replay read without re-executing scorer binaries.
- Added per-case command-scorer failure code
E_SCORER_CASE_FAILEDwithsurface:"score_json". - Added schemas, capabilities, help, robot docs, and regression coverage for the new v0.2 surfaces.
0.1.1 - 2026-07-15
Contract-hardening patch. contract_version remains 1.
- Added real bounded
--jobsexecution with deterministic evalctl-owned run surfaces. - Made
W_UNSANDBOXED_RUNNERpresent in everyrunenvelope, including completed-run reuse. - Replaced generic
schema <verb>stubs with real per-verb data payload schemas. - Killed runner process groups on timeout so child and grandchild processes do not survive.
- Wrote JSON artifacts with same-directory temp files and
os.replacefor atomic visibility. - Rejected conflicting completed
--run-idreuse when suite or case identity changes. - Capped
EVALCTL_OUTPUT_FILEraw bytes and setrunner.json.output_truncatedtruthfully. - Skipped non-UTF-8 workspace paths with
W_PATH_UNREADABLEinstead of crashing serialization. - Added replay and scorer regression coverage for corrupted
score.json, exact, regex, JSON, numeric threshold, and non-required advisory scorers.