Repository navigation
v0.243.0
Four fixes in this release were themselves half-wired — a second review pass found them
Deep-pass review of the arc above, by four reviewers who did not share the author's assumption about how the code gets called. Every finding below is a defect the arc introduced while closing defects of exactly this shape.
The lane score went to a file nobody opens. <lane_scoring> named review-lane-<lane_id>.json; review_file is slugified from the lane's scope, and both the write trailer and the consolidator's reader derive the sidecar from that. So the fix for the all-null score column produced an all-null score column — by writing the score correctly into the wrong file. Both instructions now interpolate one shared name. The gate could not have caught it: it asserted the block mentioned score_null_reason, never which file it named, and its own fixture (scope api, id L1) was already rendering the mismatch and passing.
A disabled guard counted as a live one. The liveness check matched the trace line by substring, but the hook runner traces hooks it skips too — enabled:false, reason:"disabled_by_profile_or_env" — and those records carry the script name. So at the minimal profile, or with the guard in DEVT_DISABLED_HOOKS, every skip record read as a firing and a zero count reported clean. That is the vacuous pass this release closed, restored through the back door, in the exact configuration the failure message tells the reader to go check. Needle hits are now parsed and skips rejected.
The unknown verdict still reached no operator. Lifting warn/evidence into the phase-gate projection was half the delivery: every workflow's finalize-gates reader filters select(.ok==false), so an advisory row was computed, projected, and printed by nothing. The three readers now echo select(.warn==true) as a non-blocking line. unknowns[] is also derived from the rows rather than accumulated beside them — the two encodings already disagreed for any gate returning ok:false and warn:true.
The axis gate counted axes in a file the verifier never read. It resolved the rubric from the plugin root alone, ignoring the documented project-local .devt/rubrics/ override — and this release made that strictly worse by pointing the verifier at an in-project copy instead. On any project using the escape hatch, the gate stopped enforcing and said gate does not apply. It now reads the same materialized copy the verifier was given, falls back to the shared resolver, and reports an unreadable rubric as unknown rather than as a pass.
Three more branches meant "evidence absent" and said "pass". warn arrived as the vocabulary for a gate that passed only because it could not look, then got applied to one branch. The anchor-absent paths in the raw-dispatch, claim-check, and council-budget gates are a larger blind spot than the one it closed — with no window anchor those gates cannot count anything at all — and all three returned a hard pass. They warn now. The claim-checks ledger-absent branch deliberately does not: it fires on every workflow type that legitimately never dispatches an output-writer, where a warn is noise rather than signal.
The verdict is announced by the producer, not re-derived by each reader. Adding the advisory echo to three workflows left the review path — where every field receipt in this arc came from, and the only path where the dispatch-warnings gate is even registered — still discarding it. assert-all and finalize-gates now write the unknown set to stderr themselves, so the guarantee holds at call sites that do not exist yet.
Axis H counted from a different window than the rubric told the reviewer to use. The rubric says count from first_created_at, and names a CLI that does; the gate anchored on created_at. Re-routing to the parallel path rotates created_at mid-review while first_created_at stays frozen, so records the reviewer correctly counted fell outside the gate's window and the gate blocked on the honest read — the same failure F62 fixed at the syntax level, still open at the semantic one. Both now read the same stamp, with a legacy fallback for a workflow.yaml written before it existed.
The lane was told to derive its score from a document that defines no score. The rubric grades six axes pass/fail; the 0–100 scale lives in the reviewer's scoring guide (100 minus per-severity deductions), which every lane already carries. So the block asking for a "rubric-derived" score was asking each lane to invent a mapping — manufacturing exactly the incomparable number its own next sentence forbids, and diverging silently from the single-dispatch scores the consolidated distribution invites the reader to compare against. It now names the real producer, and the null condition is keyed to completing the review rather than to reading the rubric, which a lane can fail independently.
Eleven more branches passed without looking. assert-preflight-fresh reported three distinct I/O failures as passes; assert-claude-mem-harvest and the scope-check gate passed on absent markers written by the very step they audit. All now warn. The claim-check and council ledger branches are deliberately left: fixing them properly means generalizing the hardened trace scanner to gate-trace.jsonl, which is its own change.
Two graphify honesty fixes were undone by ordering. The dropped-symbol list is reconciled against the locally generated args, and an attested override replaces plan.args forty lines later — restoring the reported defect exactly, where a reviewer finds the same symbol in both the submitted set and the dropped list. Reconciliation now runs after the override and against the args that shipped. And on the tiers that submit no symbols at all, "dropped and not submitted" implied a selective submission that never happened; that case now says so.
The context-init cache key went blind on uncommitted work. contextInitScopeSig was the third base...HEAD instance in this module — the two others were fixed, the one gating the cache was not. On an uncommitted branch the file list came back empty and the key degraded to HEAD-only, so every working-tree edit produced the same signature and the short-circuit re-served a stale brief for the whole phase. It now hashes the shared range- and untracked-aware collector, minus .devt/ — devt rewrites its own state continuously during the run this key is caching, and a signature that moves under its own workflow never hits.
Three findings the incremental pass left on the floor. The raw-dispatch gate gathered its evidence before reading the config that can disable it, so a project running dispatch_hygiene_mode: "off" paid a full backward walk of the hook trace per gate run — worst-case, since a disabled guard never fires and the walk therefore runs to the window's end before concluding so. materializeRubrics returned a map both callers discarded, and re-read the shared source once per aliased workflow_type; it now caches the body and each type still gets its own dest, so a project overriding one alias gets what it configured. And F64's reconciliation check was tautological — the generated args are disjoint from the tail by construction, which is why it passed while both of the honesty regressions above went in. It now drives the attested-override path and the no-symbols-submitted lede, and fails on each.
The resolution order is single-sourced for real. Three copies of "absolute → project-local → plugin default" existed, in a function whose own comment warned that two copies is how the pinned rubric and the inlined one come to disagree. grader.cjs owns it now; init.cjs's copy is deleted and both its callers go through the owner, which differ only in whether an absent plugin candidate comes back as a path or a null — now a parameter rather than a fork. F63 gained the project-override leg it never had, which is the only path where the copies could disagree, and K292's residue check was widened from the <rubric_path> tag to the prose around it: naming which rubric is pinned stays legal, claiming an agent reads it from the plugin root does not. That check now fails on both copies it previously walked past.
Also. code_review_parallel now points at its own materialized rubric, so <rubric_path> is .devt/state/rubric-<workflow_type>.md with no exceptions — a project overriding that key was getting the single reviewer's rubric. The liveness scan stops at the first firing instead of counting every one to answer a boolean, which gives it the right cost curve rather than a flat saving: on this repo's 1.26 MB trace a live guard is answered from one 64 KB chunk in 0.17 ms, and only the case that is about to warn anyway — a guard that never fired in the window — still walks it all (768 KB, 1.8 ms). branchDiffText honours the active range its sibling file-collector already honoured, and falls through only on git failure rather than on an empty diff, which cost a clean tree three forks to rediscover the same emptiness. The recovered-symbol count and the graph-filter status are rendered where a reader will see them, rather than only in a JSON field whose sole consumer was the gate asserting it. task_written is gone — no reader, and hardcoded true at one of its two sites. _workflowWindowAnchorMs absorbed a fourth copy via a field parameter, and two prose copies still naming references/rubrics/ were corrected.
A corrected review scope survives a cache hit, and init stops eating the task
Field: an operator passed a 1.6KB --scope and read back the literal code review. Two independent writers produced that, and they had been repairing each other on the common path — which is why nothing ever caught it.
init review with no task argument wrote its code review placeholder unconditionally. Every context-init bundle runs init at the top purely for its payload, so the real task was destroyed on the way past. On the recompute path the later activation write happened to restore it; on the cached path nothing rewrites it afterwards, so the damage stood. init now preserves an existing task when the caller supplies none and still honours an explicit one — same fix for init workflow, which nulled it instead.
The freshness short-circuit returned above the activation write, so re-running with corrected scope text re-served the stale task indefinitely and the only exit was state update task= by hand. The short-circuit now persists a supplied scope. The cache key stays the changed-file set: caching the expensive computation is right, caching the task text is not.
That same early return carried no scope_missing and hardcoded degraded_fields to [], so the degrade signal existed only on the path the operator was not on — and the CLI's own hint steers you onto the path without it. Both fields are real on both paths now, and an empty scope never overwrites a task an earlier call got right.
--fresh was recommended by the short-circuit's own stderr hint and parsed by nothing; review-context-init accepted three flags and silently discarded it. It now bypasses the cache, as the hint always claimed. The message also names what the cache key is derived from and whether task was written this call — the two facts that cost the reporter three re-runs to establish by reading workflow.yaml directly (F61).
The truncation notice reports what was actually withheld
args.symbols for the symbol_anchored tier is a union — the diff-symbol extractor's output merged with the exact-resolved topic anchors — while topic-symbols-dropped.json was the tail of the topic list alone. Two different lists. A symbol cut from the topic tail could therefore be submitted anyway through the diff leg, and the notice reported it as absent.
Field: a reviewer compared args.symbols against the dropped list, found two names in both, and reasonably concluded the artifact was lying about its own truncation. A lane's truncation claim built on that notice was then half-wrong, and the verifier adjudicating it had to work out which half. The list was never internally inconsistent — it was answering a question nobody asked it.
The notice is now reconciled against what actually went to the MCP call: only symbols no leg submitted are listed, and the ones recovered via the diff leg are counted rather than absorbed, so the reduction is visible.
Three adjacent honesty fixes in the same path. The section claimed its contents were "listed in original preflight ranking order", which stopped being true the moment diff-ranking shipped — it now states the ordering it actually used, read from the plan rather than assumed, and says nothing when the plan doesn't say. branchDiffText diffed base...HEAD only, so symbol ranking and the hunk census both silently no-opped on uncommitted work — the same empty-base...HEAD hole collectChangedFiles was already fixed for, whose comment says exactly that; it now falls back to the working tree (fallback, not union, because a change both committed and further modified would otherwise count twice and the census reads these hunks too). And the graph-node filter — the only thing that removes plausible-looking non-symbols the shape gate and verb denylist both pass, like Verify, Modern, or a project name — sat behind a catch-all that made "found nothing to remove" and "never ran" identical; preflight-brief.json::topic.symbol_graph_filter now says which (F64).
The rubric is reachable from where the reviewer actually runs
<rubric_path> pointed at the plugin root — outside the project under review. A lane declined to open it: "outside this repo and was not opened", a policy refusal rather than a filesystem error, which no path fix inside the plugin can reach. Making the path absolute was the earlier attempt at this and was not enough; the requirement is the project boundary, not the path shape.
Field: of five lanes on one review, two read the rubric fully, one partially, one refused, one never said. The ones that could not reach it self-graded against the task prose, so their grades were not comparable with the rest — and nothing noticed, because a silent self-grade looks exactly like a real one.
init and every dispatch render now materialize the resolved rubric to .devt/state/rubric-<workflow_type>.md (project-local .devt/rubrics/ override honoured, else the plugin default) and every <rubric_path> resolves there — both, because the documented register-lanes shortcut reaches render-* without a fresh init, and one producer for a two-path consumer is the same half-wiring this release is about — the same place agents already read code-review-input.md from. For both init verbs, since dev workflows never inline the rubric and the verifier's <rubric_path> is the only copy it sees. An unreachable rubric must now be declared: say so and state the grades are not rubric-derived, rather than self-grading in silence. The resolution order is single-sourced, because two copies of a resolution order is how the pinned rubric and the inlined one come to disagree.
Lanes are asked for the score the consolidated report already prints
The parallel report's headline carries a per-lane score distribution; the lane envelope never requested a score. Every run therefore produced an all-null column that read as a broken feature — a consumer specified with no producer.
Lanes now emit a rubric-derived score in their own sidecar, with the condition attached: a lane that could not read the rubric emits null plus a score_null_reason instead. A manufactured score is worse than a null because it looks comparable with the lanes that could read it, and the distribution is read as a coverage signal first — one null beside three real scores says immediately which lane's grade cannot be trusted. The consolidator copies both through rather than filling the gap with a number of its own.
The change set finally includes files git cannot diff
git diff cannot show untracked files at all. So every consumer reading diff text — symbol ranking, the hunk census — was blind to a branch whose centrepiece is a NEW module, while the truncation notice asserted the ordering it had derived from exactly that blind diff. The file-list collector had been fixed for the empty-committed-range hole twice; the diff-text path was the third private notion of "the change under review" in one module, and the one nothing had touched.
changeSetText now lives beside collectChangedFiles, in the module that owns change-set semantics, and branchDiffText delegates to it. Untracked files are surfaced by staging them intent-to-add into a scratch index via GIT_INDEX_FILE — one diff covering modified, committed and new files together, at a constant three forks however many new files there are, and the caller's git status provably unchanged. The alternative, one git diff --no-index per untracked file, forks per file and returns fragments the consumer has to stitch.
Both collectors also stop reporting .devt/state/ as a change. devt rewrites that layer during the run that inspects it — a plan written by compute-impact-plan fed straight back into the next call's census — and --exclude-standard only covers it where a project remembered to gitignore it. .devt/rules/ and .devt/config.json stay reviewable; they are author-maintained. The one-off filter added to contextInitScopeSig earlier in this release folds into that shared predicate.
Two gates were measuring the wrong thing and said so once the diff could see. K227/K228 share a fixture whose "cosmetic-heavy" ratio held only because the one real-code addition in it was invisible — the fixture gained genuine cosmetic hunks so the scenario it claims to test is the scenario it produces. And F64 wrote its probe script into the project it analyses, so the probe became part of that project's own change set; it now lives outside the fixture (F65).
The lane-skip policy is read out of the rubric instead of asserted beside it
The injected <lane_axis_policy> block named axis H against a document it never opened. That is correct for the shipped default — and the default rubric already carries the exemption, so the block was a second copy of something the rubric says itself. Where it is not a copy, it is wrong: code_review.v1.md is still shipped and still pinnable, carries no exemption, and requires the axis unconditionally, so a v1-pinned project was handing every lane an instruction to skip an axis its own rubric mandates. A project-local rubric that renames or reorders axes fails the same way.
render-lanes now parses the in-project rubric copy the lanes are pointed at, attributes each lane-skip marker to the axis block containing it, and emits the policy naming those axes with the titles the rubric gives them — or emits nothing at all when the rubric exempts none, which is the honest default. The by-reference stub already said "any <lane_axis_policy> block", so absence degrades without a second edit. The gate moved with it: F63 computes its expectation from the pinned rubric rather than matching a fixed string, and a v1 pin must produce no block.
Axis H is asked once
Marking the axis lane-skip in the rubric did not land: the shared envelope still told every reviewer to walk it, and the envelope won 4 of 5 lanes in the field because it sits closer to the task. The rubric, the envelope task text, and the by-reference stub now all defer to a <lane_axis_policy> block that render-lanes injects into lane dispatches only — so the single reviewer, who genuinely owns the axis, is unaffected. Fixing one end of a two-ended contract and reporting it closed is what produced the 4–1 (F63).
Two gates stop reporting on mechanisms they never consulted
Axis H punished explanation. Its n/a branch was disqualified by a bare substring test for counts: across the whole section, so a consolidator explaining why zeros would mislead failed on its own explanation while a terser answer passed — and the failure message claimed the section lacked a valid n/a while one sat in it, sending the reader to look for a formatting fault in the line that was already correct. The branch is now chosen by what parses, not by which tokens appear, and the failure names what was actually found. Field consequence worth recording: the retry hid the reasoning and shipped three zeros that read as "verified clean" when the honest claim was "nothing was recorded" — the gate rejected the better answer and accepted the misleading one.
The raw-dispatch gate passed vacuously. The hygiene guard appends only on a violation, so a violation-free ledger and a guard that never ran produce the same silence — and the gate reported the first. The universal hook trace already records every invocation, so the evidence existed and nothing read it. A zero count now reads clean only with an in-window guard firing behind it, unknown otherwise (advisory, never blocking), with guard_fired and evidence on the result. Firings are window-scoped, so a pre-anchor fire or a different hook is not mistaken for evidence (F62).