Releases: wighawag/dorfl
Release list
dorfl@0.13.4
Patch Changes
-
fe812b5: Clear a stranded
needsAnswersgate on a shipped item that has no sidecar, and stop draining a drifted tasked spec.The terminal-state reconciliation added in 0.13.3 settles a bounced-then-rebuilt item's stranded question state, but it enumerates only
work/questions/and reasons outward to the item. So it can only ever see residue that still HAS a sidecar, and it explicitly declined the opposite shape on the grounds thatneedsAnswers: truewith no sidecar is the legal pre-surface state.That conflated the state with its POSITION, and it left the harmful half unreachable. A bare flag is legal in a POOL or STAGING folder, where it is the
surfacerung's input and clearing it would disarm gated work. Ontasks/done/it cannot mean that: the task has shipped, no rung enumerates it, sosurfacecan never run on it again and no answer can still be typed. There it is pure residue, and it is the half that actually gates anything, being the state the item would be re-opened into.Three ordinary routes produce it, each removing the sidecar while the flag survives, after which nothing in dorfl could reach it: the item was NEVER surfaced (the tasker sets
needsAnswers: trueon an uncertain task, and the flag is only a warning on the human claim path, so the task can be answered in conversation, built, and completed with the flag riding the done-move intotasks/done/); a human tidiedwork/questions/by hand, the obvious manual clean-up for the paired residue, which deletes exactly the handle the sweep needs; or the drain's own defense-in-depth guard, which removes the sidecar unconditionally but skips the flag clear on a body it cannot annotate.The pass now runs TWO enumerations against the same base and in the same commit: the sidecar set as before, plus a
git grepover the success-terminal folders for flagged bodies with no sidecar. The grep only SHORTLISTS (those folders are the repo's largest and this runs on the claim path); the decision is always the parsed frontmatter, since bodies discussneedsAnswersin prose constantly, and the value is matched loosely (optional quote, any case) becausetoBooleanunquotes and lower-cases, so a tighter needle would silently skip real gates.core.quotePath=falseand--full-namewith:(top,literal)pathspecs are pinned, or a non-ASCII path is skipped for ever and the whole half becomes a no-op when a command runs from a subdirectory. The two enumerations are kept disjoint, so the answered-sidecar carve-out cannot be bypassed by a path that clears the gate while leaving the unread answer, and a cleared flag is reported from what was actually STAGED rather than what was intended.It also FIXES a previously-shipped defect of the same family, found while reviewing the above:
specs/tasked/was treated as a terminal where question state is moot, so a drifted tasked spec's PENDING sidecar (an unanswered human decision) was deleted and its gate cleared. WORK-CONTRACT ("A SPEC that has drifted AFTER it was TASKED") makesneedsAnswers: trueon a tasked spec legal and load-bearing, andlifecycle-gather.tsenumerates tasked resting specs unconditionally, routing a bare flag to SURFACE and an answered sidecar to APPLY. Both halves are therefore live inputs to a rung that WILL run, sospecs/tasked/is now excluded from the question-residue terminal map entirely. The LOCK terminal set is unchanged: a tasked spec is still terminal for locks.Unchanged: the surface path and its atomicity; the
cancelled/droppedasymmetry (a bare flag on a won't-proceed terminal is accurate history and is KEPT); the answered-sidecar hold;advance-classify.ts'sinvariant-violationkind and both reason tags; andorphan-sidecar.ts's terminal-is-not-orphan guard.statusstays strictly read-only and now reports both shapes of the residue under one heading. -
f80e1e0: Stop
setFrontmatterMarkerdemoting a BOM-prefixed document's frontmatter into its body.The READER and the WRITER disagreed about whether a document has frontmatter.
extractBlock(behindparseFrontmatter) strips a leading BOM before looking for the---fence, so a BOM'd body parsed fine and reported itsslug,titleandspec.setFrontmatterMarkerdid not strip it, so the same body failed itsstartsWith('---\n')test, was judged FENCE-LESS, and had a SECOND fence prepended. The original frontmatter was pushed down into the body, where it is prose: every key except the one just written silently stopped existing.The corruption was invisible to the guards meant to catch exactly this. Both the surface path and the terminal-question drain re-parse their own output and refuse to write a body whose marker does not read back correctly, but the marker in the newly prepended fence reads back perfectly, so the write was committed with the rest of the metadata destroyed.
A BOM is rare in a
work/tree but entirely legal, and it is what a Windows editor or an external tool produces. It is now stripped for the analysis in the same way the reader strips it, and re-prepended to the result: it is the document's encoding marker, not the tool's to drop. A BOM'd fence-less document still gets its fence prepended, after the BOM.
dorfl@0.13.3
Patch Changes
-
c814e57: Release a propose-mode per-item lock once its item is terminal on
main, so completed, merged work stops reporting as in-progress for ever.complete --proposedeliberately keeps the lock held after opening the PR, and says so:keeping the per-item lock HELD (propose PR open; the work is not yet on main). It is released when the PR merges (reconciled against main).Holding it is right, because the done-move is on the PR branch andmainstill shows the body in the ready pool, so releasing there would let the next tick re-claim an item under review. The second sentence, however, never happened. Nobody runs a dorfl process at the moment a human clicks merge on GitHub, there is no merge hook and no daemon, so a release scheduled for merge-time could never fire.reconcileItemLockAgainstMainhad implemented exactly the right decision since the lock cutover, but had no caller on any ordinary path: it was reachable only from the opt-ingc --ledger --reap-stale-lockssweep, which an operator drivingdo/completeby hand never runs. Every propose build therefore leaked its lock ref permanently. On one arbiter this reached 26 refs, every one naming a task at rest inwork/tasks/done/, all listed bystatusunder "In progress (lock held)". The--mergepath never showed this because it lands onmaininline and releases in-process.The bug has two halves and they are fixed in two different places.
The mis-reporting half is fixed in the read commands.
statusandscannow classify each held lock against the arbiter'smainand report one whose item is already at rest as "Completed, lock not yet released", excluded from the in-flight list, so finished work no longer reads as in-progress. This classification writes nothing:statusandscanremain strictly read-only, as their own descriptions promise.The leaked-ref half is fixed on the claim path, which already writes to the arbiter, already fetches
main, and runs on every unit of work. The stale terminal locks are released there, so the leaked set drains continuously as a side effect of ordinary use, with no human ever routed to a clean-up verb. That routing failure is the whole reason the leak grew to 26:gc --ledgerhad been reporting these very locks and printing therelease-lockcommand all along.status --reconcile-locksandscan --reconcile-locksrun the same sweep on demand, for draining an existing backlog of refs immediately. They are a convenience, not the mechanism.What a lock means is unchanged. The terminal test is the item's position on
<arbiter>/mainand nothing else: a task resting intasks/done/ortasks/cancelled/, a spec inspecs/tasked/orspecs/dropped/. Never a branch, never a PR's existence or merge status, never the holder, never age. An item on an open PR still shows its body in the pool onmain, so it keeps its lock, as does a genuinely stuck one; releasing a non-terminal lock would let two claimants build the same item, which is a much worse failure than the leak. Every uncertainty (unreadablemain, an unclassifiable pre-cutover entry, any fault) resolves to keep, and--reconcile-locksauthorises the sweep without widening that predicate by one inch. Only the terminal class is swept: the crash-window orphan (non-terminal but surfaced withneedsAnswers:trueplus a sidecar) remains the business ofgc --ledger --reap-stale-locks.Every delete is the same
--force-with-leasedeleterelease-lockandrequeuealready use, so a lock a concurrent writer moved is reported rather than stolen, never force-deleted. The sweep never throws and never fails a claim, which is unrelated work: it is opportunistic hygiene, not a precondition. It is idempotent. The recovery verbs are untouched:requeue,requeue --reset,release-lockandrelease-lock --entry <literal>keep their exact semantics, and a pre-cutover entry with no derivable item-form is left held for the--entryescape hatch.Reading the arbiter's
mainis ref-shape dependent, and getting it wrong fails silently, so it is handled explicitly. A working clone holds the arbiter's main atrefs/remotes/<arbiter>/main; a bare hub mirror (what the registry stores) has norefs/remotes/*namespace at all and holds it atrefs/heads/main. Probing the wrong ref fails withinvalid object name, which is indistinguishable from "not terminal", so every lock would classify as in-flight and the whole sweep would become a permanent no-op on the registry surface. The refresh therefore uses an explicit refspec that writes exactly the ref the probe reads, and the classifier verifies that ref resolves before probing, reporting an error rather than quietly answering "nothing is terminal". Regression tests cover both repo shapes.complete --propose's own message is corrected too. It used to promise "It is released when the PR merges (reconciled against main)", the sentence that was never true; it now says the item comes to rest on main when the PR merges and the lock is released by the next claim, naming a trigger that actually fires.The design decision, including why the automatic release belongs on a write path rather than on
status, is recorded indocs/adr/terminal-state-reconciled-by-claim-not-by-read-commands.md.Verified against the live 26-ref corpus, cloned into a throwaway sandbox so the real arbiter was never written to. In both repo shapes, a working clone and a bare mirror, the read-only classifier identifies all 26 as terminal, zero in flight, zero errors, and leaves all 26 refs in place; the sweep then releases all 26 with zero kept and zero errors, and a second pass is a clean no-op.
The same change also clears the STRANDED QUESTION STATE a bounced-then-rebuilt item leaves behind, because it is the same defect wearing a second hat and is settled by the same pass at the same moment.
When a build bounces, the surface path atomically writes both halves of the item's question state in one commit: the sidecar
work/questions/<type>-<slug>.md, andneedsAnswers: trueon the item body. That is correct, and its atomicity is what makes reconciliation decidable at all. But if the human disagrees with the agent, re-dispatches, and the rebuild SUCCEEDS (PR opened, gate green, merged, body done-moved), neither half is ever cleared. Items come to rest intasks/done/still carrying a question asking whether to CANCEL them, with a destructive default. The flag is the worse half: it is a gate left armed over shipped work, and it makesstatusreport finished (sometimes released) work under "open questions block autonomous work".Dorfl already knew this state was illegal.
advance-classify.tsrefuses it asinvariant-violationwith the tagsidecar-without-needsAnswers. The detector simply lived in theadvancetick's classifier, and a human drivingdoand merging a PR never enters that loop. Both defects share a cause (cleared by a step that only runs on a path the item did not take), a moment (the done-move landing onmain), and a blind spot (detectable only from a loop the manual path never enters), so there is ONE reconciliation,reconcileTerminalState, not two mechanisms. The classifier keeps itsinvariant-violationkind and both reason tags: reconciliation now prevents the state arising, and the classifier remains the backstop.The drain publishes ONE tree-less commit to the arbiter's
mainthrough the same contention-retry and CAS-publish core the surface path uses, so there is no second write mechanism, and the batch lands or does not land atomically.The trap this had to avoid is that the MIRROR state is legal and common:
needsAnswers: truewith no sidecar is exactly what an item authored with open questions looks like beforesurfaceruns, and that flagged-but-unsurfaced item is thesurfacerung's own input. Reconciling "flag without sidecar" would silently disarm every un-surfaced item in the repo and hand gated work to agents. So the terminal POSITION is the discriminator, never the flag/sidecar disagreement on its own; the enumeration is anchored on the sidecar set rather than on flags; and an item resting in a pool or staging folder keeps whatever state it has. Two negative regression tests pin this, and a simulated careless fix (treating a pool item as terminal) makes one of them fail.Two asymmetries are deliberate. A
cancelled/droppeditem has its stale sidecar removed but KEEPSneedsAnswers: true, because an item can be cancelled precisely because its questions were never answered, so there the flag is accurate history rather than residue; it gates nothing, since a terminal item is in no pool. And a sidecar carrying any ANSWERED entry is never auto-drained: in the field one had been answered in writing, ending "Close this sidecar", and was still sitting there, which is evidence the drain does not run on the human-answer path either. That is a separate defect, and this pass refuses to paper over it by destroying the evidence, so such an item is left untouched and reported instead.statusreports both halves of the residue read-only, under "Completed, question state not yet cleared" and "Answered but never applied".The question drain is strictly opportunistic on the claim path. It is guarded so that no fault in it can fail the operator's actual work: a review found that a pre-existing loose ref at
refs/dorfl/question-drainmakes the batch scratch ref un-creatable, and the git plumbing threw out of the claim path, surfacing as exit 1 with no lock taken. That is now caught at two levels and reported, and a regression test pins the claim still succeeding. A protectedmaindegrades the same way: the drain cannot land, so it reports and leaves the residue exactly as it was.The residue is RE-DERIVED against the base each contention attempt commits on, rather than being carried over from t...
dorfl@0.13.2
Patch Changes
-
81528cb: Release the tasking lock when the tasking agent crashes, so a failed
do spec:<slug>no longer strands a lock that every retry then loses the CAS to.Observed in the field on one spec across three consecutive runs, each killed by a model-API fault (
Connection error.,overloaded_error,api_error).do spec:<slug>takesrefs/dorfl/lock/spec-<slug>(action: task,state: active) and then invokes the tasking agent; when the agent died,performTaskreturnedagent-failedwhile leaving the lock held, on the stated grounds that "surfacing it is the review/edit loop's job". That loop lives at step 3.5, strictly AFTER the agent-failure return, so it never ran. The lock outlived every process that knew about it and each retry refused with'spec-<slug>' is already locked (held by another). Back off.The lock is now released on that path. A crashed tasking run publishes NOTHING to the arbiter (the work branch is created with a local
git switch -Cand is not pushed before the integrate band, and the durablespecs/ready → specs/taskedmove also happens only at integrate), so releasing discards nothing and returns the spec to the taskable pool: the recovery is simply to re-run the command. The release is deliberately a PLAIN release, not theneedsAnswers:true+ question-sidecar surface the siblingReviewParseErrorpath uses. That surface is right for a review VERDICT (a judgement a human must resolve) and wrong for a transport crash, which carries no judgement and would otherwise be turned into mandatory human paperwork behind a contentless question. A release fault is reported but never masks the agent failure, and the crash is still reported faithfully rather than laundered into a success.The capability to clear such a lock by hand already existed and was simply undiscoverable:
dorfl release-lock spec:<slug>releases aspec-entry today, andgc --ledgerreports every held lock and prints that exact command. The operator hit three dead ends instead. Those messages are fixed rather than duplicated into a second release mechanism:- The contention refusal now names the recovery.
Back off.remains the default, because contention between two live runners is the common case and a runner must never be nudged into stealing a healthy peer's lock; the escape is offered strictly under the operator-asserted condition "if the holder is DEAD", pointing atgc --ledgerto inspect andrelease-lockto clear. The suggestion is derived purely (no extra round-trip on a hot path) and falls back to the--entryescape hatch for a pre-cutover entry. requeue <slug>no longer misdirects. It is a task-only verb, so a bare slug resolves totask-<slug>and found nothing whilerefs/dorfl/lock/spec-<slug>sat on the arbiter; the blunt "wrong slug, or already at rest?" sent the operator hunting for a typo. It now probes the spec namespace and, on a hit, names the held lock, its action/state/holder/since, andrelease-lock spec:<slug>. It still refuses to act:requeue's contract is keep/continue/rebase/reset/reconcile of a WORK BRANCH, and a tasking run has no work branch, so it emits a pointer rather than forking a second way to delete the refrelease-lockalready owns.
Note for anyone reading the diff: an existing unit test asserted
released === falseon this path, pinning the defect. Its justification ("recoverable/re-run") was exactly what the stranded lock made impossible, so the assertion is inverted deliberately, with the reasoning recorded at the test. - The contention refusal now names the recovery.
dorfl@0.13.1
Patch Changes
-
d683dc8: A
do(including--isolated/--remote) job's record now says what actually ran and how the run ended, instead ofcreateJob's placeholder forever.Until now the whole
dopath never calledupdateJobRecord, so every job record kept its initial values —harness: {adapter: "null"}andstate: "running"— for its entire life, healthy run or not. Onlyrunfinalised the record (run.tswrites the launch's real harness record and maps the terminal outcome ontostate). In the field this sent an investigation down the wrong path twice: a healthy pi run's record read as "the null harness was launched with an empty agentCmd", and a run that had reached a terminal decision read as "still in flight" because nothing ever moved it offrunning. Both were the placeholder, not facts.Two changes, mirroring
run's discipline exactly:runDoAgent/launchAgentUnderWriterLocknow thread the launch's REALHarnessRecord(adapter + pid/session liveness anchor) out, and the no-checkout pipeline writes it withupdateJobRecord(cwd, {harness: ...})the moment the launch settles. The pid/session anchor is also whatstatus/gcneed to answer liveness for a do-path job at all.performDoRemotefinalises the record to match the terminal outcome before teardown:completed→done; the needs-attention family, the failure-cause axis,agent-stopped,deadline-surfaced, and refusals →needs-attentionwith the pipeline's own message as the recorded reason (sostatussurfaces WHY without re-deriving it).deadline-auto-continuedand lost/contended claims are deliberately untouched — nothing needs attention, and a retained checkpoint job genuinely is not over, the next claim continues it. A pipeline that THREW skips the write: that failure is already loud, and the retained record now carries the real harness anchor sostatusreads it as crashed-running-but-dead, which is honest.
The in-place
dohas no job record (there is no job worktree), so nothing changes there;updateJobRecordis a no-op without an existing record either way.Adds end-to-end tests driving the real
performDoRemotewith a stub harness and the arbiter taken offline mid-run (so the worktree + record are retained and readable): the record carries the stub's adapter/pid/session rather than the placeholder, a failed agent landsneeds-attentionwith the failure detail as the reason, and a deadline route records itself instead of a barerunning. All three fail against the pre-fix code. -
4efa3f8: Stop the runner exiting 0 in the middle of its own deadline checkpoint, leaving the item locked, the work uncommitted, and the run reported as a success.
Observed in the field twice on one task (dorfl 0.13.0, harness
pi,do task:<slug> --isolated --allow-backlog --propose --no-review,agentDeadlineMinutes: 90): both runs printed only the claim/onboard preamble, then went silent and exited 0 after exactly 90 minutes. No acceptance gate, no commit, no pushed branch, no PR. The item lock was leftimplement/active, the job record left"state": "running", and roughly 35 files of real agent work were left unsaved in the job worktree. Ten shorter tasks in the same session with the same config were fine, because only a run that actually reaches the deadline takes this path.An
awaitis not a handle. Node keeps a process alive for referenced HANDLES (timers, sockets, child processes); a suspended promise is none of those. On the deadline pathPiHarness.launchAsyncdeliberately drops every handle it owns the moment pi exits (it destroys the stdio pipes andunrefs the child so a leaked grandchild's inherited FDs cannot pin the loop) and then keeps the launch promise PENDING acrossreapProcessGroup, whose poll timer was itselfunref'd. So whenever any group member was still alive at the reap's first probe (a tool subshell, a test runner, an MCP server, or in the observed run thesleepthe agent was parked in), the event loop had nothing referenced left, node did the correct thing with an empty loop and exited normally with code 0, and the whole suspended pipeline never ran: no WIP commit, no branch push, no lock release or needs-attention surface, no worktree-writer-sentinel release, no job-record update. The exit status was a genuine 0, so nothing upstream could tell it from a completed build.The reap being the sole remaining handle-holder is not an accident of that one timer, so the fix has two independent layers:
reap-agent-tree.ts's sleep timer is REFERENCED. The reap is bounded by construction (sigtermGrace + sigkillTimeout), which is what made theunreflook free; it was not, because by then it is the only thing standing between the runner and an empty loop.PiHarness.launchAsyncholds an explicit referenced keep-alive for exactly as long as the launch is unsettled, and arms a processexitguard that turns any OTHER way of exiting mid-launch into a LOUD, non-zero failure naming the recovery (requeue) instead of a silent success. The keep-alive prevents the known mechanism; the guard refuses to let a future variant of it be mistaken for a clean run. It is released on both settle paths, so a failed spawn cannot pin the loop.
Two field beliefs this corrects, both wrong and both costly at the time: the run's
harness: {"adapter": "null"}in~/.dorfl/work/<work-id>.jsonis NOT evidence that a null harness ran (createJobwrites{adapter: 'null'}as the initial value,jobWorktreeStrategypasses no harness, and the wholedopath never callsupdateJobRecordat all, so everydojob reads that way whether healthy or not; onlyrunfinalises the record), and theoptions.harness ?? new NullHarness()fallback cannot fire from any CLI path, sincecreateHarnessalways constructs and threads an instance. Neither was the cause.Adds regression tests that run a BARE runner process, because the defect is invisible in-process: vitest's own handles keep the loop alive, which is exactly why the existing in-process deadline/reap suite passed throughout. Without the fix the new test reproduces the field symptom byte for byte (preamble printed, nothing after the
await, nofinally, exit 0).
dorfl@0.13.0
Minor Changes
-
6d0fbc4: Stop the
advanceengine re-asking a question the human already answered: aresolved note now reststriaged:, andresolvemeans the QUESTION-LOOP is settled, not that the note is spent.An observation that a human triaged and the apply rung resolved with the
resolve(resolve-no-mint) verdict was re-triaged and re-asked the IDENTICAL question on every later cycle, forever. Observed live in a consumer repo: forobservation:any-casts-in-deploy-proxy-diamond, one commit surfaced a question, the human answered it, the next resolved it, and the next surfaced the same question again. Diffing the sidecar the resolve deleted against the one the re-surface created showed the only difference was the human's answer prose being absent; the question text was byte-identical. A single cycle hit four notes, with eleven more resting in the same shape behind them.The cause is that a resolved-and-kept note is INDISTINGUISHABLE from a never-triaged one.
classifyTickreads exactly two signals,needsAnswersand the active sidecar, and its first branch is:needsAnswersnot true, no sidecar, therefore ANALYSE, thereforetriage-observation. Theresolveverdict produces precisely that state (it clearsneedsAnswers, harvests the answers into## Applied answers, and deletes the sidecar) while KEEPING the note in the inbox the triage rung re-scans. The re-asked question is not an agent flake: it is the DETERMINISTIC, engine-built triage question, which is why the sidecars diff byte-identical.The read side of the fix already existed end to end and only the writer was missing.
frontmatter.tsparses thetriaged:settled marker,ledger-read.tscarries it,lifecycle-pools.tshas an explicit branch dropping a marked observation out of the create-side triage pool, and the triage rung already no-ops on it for an explicitobs:<slug>, with a test already pinning that end state. Thetriaged:WRITER was removed when the human-stampeddisposition=/promote-*TOKEN vocabulary was retired, andresolvewas added afterwards without noticing it had re-created the one case that needs it. That earlier change fixed anaskloop (the decider had no valid verdict for "answered, mint nothing, keep it" and looped onask) and traded it for this triage loop, because it treatedresolveas purely a routing question, which existing persist path to call, when it is also a lifecycle question, what the note looks like at rest.Four changes:
- The resolve-fully path stamps
triaged: resolveon an OBSERVATION, in the SAME atomic commit that clearsneedsAnswersand deletes the sidecar, so no tick can observe a torn "kept but still untriaged" state. The stamp is structural inapply-persist.ts(derived from the item identity, never passed by the caller), so no route into that path can forget it. A TASK or SPEC is deliberately NOT stamped: its status is its folder, it is not enumerated from the observation inbox, and there is no triage rung to re-ask it. resolvenow MEANS "the question-loop is settled AND this note is still a live signal". The decision prompt picks betweenresolveanddisposeon LIVENESS, not politeness: if the answer means the signal is finished (the thing was fixed, it was already covered elsewhere, it is obsolete, or it is now wholly carried by a task/spec/ADR/commit), the verdict isdisposeand the note leaves the inbox by deletion. Keeping a note merely to show it was handled is the backward-artifact-in-a-forward-bucket the contract forbids.WORK-CONTRACT.mdgains the matching cell. It said there is "notriaged:/needsAnswers:falseresting state", but said it of a DISCHARGED note; it had no cell for a note an answer deliberately KEEPS, which is why the engine had nowhere honest to record one. A kept, triaged note now rests astriaged: <disposition>+needsAnswers: false+ no sidecar + the harvested answers in its body, and the marker explicitly means the question-loop is settled, NEVER that the signal is finished. The absolute claim that "a note annotated resolved and kept is a contradiction" is narrowed to a note kept only to narrate that it was dealt with.- Notes resolved-and-kept BEFORE the stamp existed are back-filled by the triage rung, once each. A forward-only fix would leave them re-asked forever and they cannot fix themselves, since answering again just re-resolves into the same shape. The trigger is proof rather than a heuristic: only the apply persist writes the
## Applied answersheading, and only after a human answered every open question, so its presence on an unmarked note means the engine already resolved it. The rung stamps what the apply rung would have written and no-ops; the stamped note then drops out of the pool, so the arm cannot fire twice for one note.
Deleting the note on
resolveinstead was considered and rejected on the evidence. The four real answers in the consumer repo explicitly instruct retention ("Verified still live and still accurate, so it remains a useful standing map"; and, three times, "Keep the note until the residue above is either acted on or judged not worth acting on; it is the only record of these choices outside the code"). Those notes have not stopped being live signals; their QUESTION has been answered. Deleting them would destroy the only record of accepted, user-visible residue against an explicit instruction to keep it, and the deletion would be the agent's judgement rather than the human's answer, which is the one thing the capture-bucket rule bars. The honest half of that argument is kept where it belongs, in the verdict's guidance, soresolvecannot become a softdispose.The
needsAnswers:falseimplies no-active-sidecar invariant is untouched: the resolve path still clears the flag and deletes the sidecar in one commit, and the marker is an independent third frontmatter axis the classifier never reads, soclassifyTickkeeps its two-signal contract. Settling is not silencing either: an ANSWERED sidecar still dominates the marker, so a genuinely new question about a settled note still routes toapplyand is still acted on, and re-resolving simply re-stamps. To put a note back into ordinary triage, delete itstriaged:line.work/protocol/is re-synced from theskills/setup/protocol/source of truth. A consumer repo converges by runningdorfl sync(or re-runningsetup) for the docs, plus upgrading thedorflpackage for the stamp and the back-fill; already-stranded notes then self-heal at one no-op advance leg each.Adds regression tests: two full advance cycles over one resolved note, where the second is a no-op rather than a re-surface (with a surface gate that throws, so merely reaching the question agent fails the test); a genuinely new answered question on an already-resolved note still routing to
applyand minting; the persist stamping an observation (including a fence-less one) but not a task, and not on a re-pause; and the legacy back-fill stamping once, committing, and staying idempotent. - The resolve-fully path stamps
dorfl@0.12.0
Minor Changes
-
f9ab9a4: Give a build agent's rationale ONE reachable home: the runner now transcribes the agent's
## Decisionsblock into the done record.Three separate symptoms in a consumer repo turned out to be one defect. Seven notes named
decisions-<task-slug>.mdhad been invented inwork/notes/observations/(a bucket documented as "spotted, unverified, append-only") to hold judgement calls awaiting ratification; the identical review finding "no Decisions block in the commit body" had been raised on seven separate tasks; and a load-bearing measurement sat indocs/spikes/<slug>/whilework/notes/findings/stood empty.The root cause was structural, not agent error. The contract offered a builder three homes for an in-scope decision, and TWO of them were unreachable BY CONSTRUCTION: the done record (the builder must not edit the task body, and the
ready → donemove is the runner's) and the PR body (runner-authored). Only "a JSDoc at the choice site" and "an observation note" were reachable, so that is where rationale went. An acceptance criterion phrased "rationale recorded in the done record" was therefore unsatisfiable as written, which is why reviewers could re-raise the same finding indefinitely without any agent being at fault. The parser for the intended mechanism already existed (extractDecisionsBlockinagent-stop.ts, fully unit-tested) and was called from nowhere: the channel was specified and never wired.Four changes:
- The runner transcribes the block.
performIntegrationnow appends the agent's## Decisionsblock verbatim towork/tasks/done/<slug>.md, AFTER thegit mv(so it is written at the done path) and BEFOREgit add -A(so it rides the ONE atomic completion commit, never a second one). It is idempotent (a record already carrying a## Decisionssection is left untouched, so arequeuecontinue or a re-run over an already-done-moved branch cannot double-append), skipped for a tasking transition (a spec has no done record), and best-effort (a missing/unreadable record is a silent no-op: a rationale note must never fail an otherwise-green completion). The builder still does no git and still never edits the task body; it emits the block on the one surface it owns, its final report. - The contract states the rule in one place.
WORK-CONTRACT.mdgains "Where a BUILDER's RATIONALE lives": the## Decisionsblock is the single sanctioned channel, an ADR indocs/adr/is the escalation for decisions meeting the ADR gate, and a JSDoc at the choice site is a welcome code-adjacent copy but never a substitute. It also states explicitly that there is NO pending-ratification note kind and that adecisions-<slug>.mdnote must not be opened: a decision you made and verified is neither spotted nor unverified, and such a note is a BACKWARD artifact parked in a LIVE, FORWARD bucket, so it can never be discharged and accumulates forever. - Taskers stop minting the unsatisfiable criterion.
TASKING-PROTOCOL.mdforbids an acceptance criterion that asks the builder to write the done record, the commit message, or the PR body, and directs taskers to name an artifact the builder CAN create (an ADR) when a decision must be durable in its own right.CLAIM-PROTOCOL.mdandtask-template.mdare updated to match, so the in-band prompt names the block as the channel. The reviewer prompt now reviews the DECISION rather than the filing, and is told not to raise findings about where rationale was written, since those locations are the runner's to author. findings/accepts a measurement whose provenance is a committed script. What makes a finding correctable is that itssource:is nameable and re-runnable, not that it is an external URL. The evidence (probe script, raw output) stays atdocs/spikes/<slug>/(the stable, non-flowing home of conflict-safety rule 8) while the knowledge lives inwork/notes/findings/<slug>.mdwith asource:naming the script, its commit, what it ran against and when. When a measurement is LOAD-BEARING, meaning some capability is withheld or enabled because of it, the finding is now REQUIRED: a spike folder alone leaves the reason undiscoverable, so the next agent re-litigates the measurement or silently reverses the behaviour it justifies.
Because
prompt.tsextracts the work-agent wrapper directly fromCLAIM-PROTOCOL.md, the doc edit IS the runtime prompt, so there is no second copy to drift.work/protocol/is re-synced from theskills/setup/protocol/source of truth andVERSIONis bumped to2026-08-11. A consumer repo converges by runningdorfl sync(or re-runningsetup) for the docs, plus upgrading thedorflpackage for the transcription behaviour; until the package is upgraded a builder's block still reaches the reviewer through the PR body, just not the done record.Adds a regression suite driving the shared integration core: a block is appended verbatim to the done record in the completion commit (without dragging in surrounding report prose, and without adding a second commit), a report with no block leaves the record untouched, an absent report is a no-op, and a record already carrying a
## Decisionssection is not double-appended. - The runner transcribes the block.
dorfl@0.11.3
Patch Changes
-
72963e1: Fix
dorfl gc --ledgerreporting locks that do not exist on the arbiter.gc --ledgerreported "STALE / crash-window orphan" locks and printed adorfl release-lock <item>for each, which then failed with "(stale info)" / "already absent on origin" — naming locks a human should delete that did not actually exist. Verified:git ls-remote origin 'refs/dorfl/lock/*'returned nothing while the hub mirror held 8refs/dorfl/lock/*refs, three of them for locks released on origin earlier in the same session.The root cause was structural. The lock readers (
listItemLocks,listItemLockEntries,readItemLock,fetchHeldEntry, the acquire/release/reconcile paths) didgit fetch <arbiter> +refs/dorfl/lock/*:refs/dorfl/lock/*— force-update with NO--prune— then read the LOCALrefs/dorfl/lock/*viafor-each-ref/git show <ref>. A lock released on the arbiter (its ref deleted there) survived locally indefinitely, so the readers reported locks the arbiter no longer held. The same accumulation also affected the SELECTION path (heldTaskSlugsStrict/heldSpecSlugsStrictvialistItemLocks): a released task's stale local ref kept it subtracted from the eligible pool, so a released task could not be re-tasked. Separately,ensureMirrorsynced onlyrefs/heads/*(andmain), neverrefs/dorfl/lock/*, so the mirror's lock namespace was never pruned and released locks accumulated there too.Two fixes:
- Prune
refs/dorfl/lock/*when syncing the mirror.ensureMirrornow best-effort--prunefetches the per-item lock namespace from the arbiter, so released locks cannot accumulate on the mirror. - The ledger reads the arbiter directly.
listItemLockEntries(thegc --ledgerreport reader) now takes the authoritative lock list fromgit ls-remote <arbiter> refs/dorfl/lock/*— ONLY the refs that actually exist on the arbiter right now — so the report can never name a lock that does not exist. The content is materialized by a--prunefetch (which also keeps the local namespace clean).listItemLocks,readItemLock,fetchHeldEntry, and the acquire/release/reconcile fetches all add--prune, so a released lock's stale local ref is pruned andfor-each-ref/git showread the arbiter's actual state.listItemLockskeeps materializing the refs locally (notls-remote-only) becausemigrateStuckLocksreads a lock's body viagit show <ref>:lock.mdafter it.
A report whose entire purpose is to name locks a human should delete can no longer name locks that do not exist. Adds regression tests: a mirror holding a lock ref the arbiter does not is pruned on the next
ensureMirrorsync; andlistItemLocks/listItemLockEntries/reportItemLocks/readItemLockdo not report a stale local lock ref the arbiter no longer holds (while a genuinely held lock is still reported). - Prune
-
b2576c0: Fix the tasker-review leg crashing
dorfl do spec:<slug>on any large spec.Running
dorfl do spec:<slug> --isolated --proposeon a substantial spec printedreview agent produced no parseable {verdict, findings} resultand died — no task branch, no PR, nothing emitted — leaving the tasking lock held (cleared only by hand viadorfl release-lock) and writing nowork/questions/spec-<slug>.mdsidecar. It was deterministic, not a flaky model: the tasker itself produced a good decomposition, but the review leg failed and the whole run was discarded with it.The root cause was structural and upstream of the parser.
verdictContractPromptasked the review agent to emit the FULL replacement body of every edited task file inline asedits: [{path, content: "<full replacement>"}]in the SAME single JSON object as the verdict — an unbounded payload sharing one capped model response with the verdict. The richer the spec, the more edit body bytes, the more certain the response cap-truncated mid-object before the verdict closed.extractJsonObjectSpanthen returnedundefined,parseReviewVerdictthrew, and the throw propagated out ofperformTask(there was no try/catch around the review loop). The parser was correct to refuse to invent a verdict and is left unchanged.Four fixes, in priority order:
- The unbounded full-file
editspayload is moved off the capped response. The review agent now WRITES each edited task body to a scratch file underwork/tasks/.review-edits/and references it bysrcin the verdict JSON (a new optionalTaskEdit.srcchannel; inlinecontentis kept as the legacy small-edit form). The verdict JSON now carries only paths, so its size is bounded by the NUMBER of edits, not the total body size — a large decomposition can no longer cap-truncate the verdict. The runner reads the scratch body, applies it through the SAME scope fence (unchanged), and reaps the scratch so the integrate'sgit add -Anever sweeps it. Because the agent writes to scratch (not the target), the tasker's pristine candidate tasks stay untouched until the verdict parses. - Cap-truncation is now detected and NAMED at the harness seam.
LaunchResultcarries a newoutputCappedsignal (the observedusage.outputtoken count), populated by the pi adapter from the session log's last assistant turn'sstop_reason(null/None/max_tokens) +usage.output. When a parse fails AND that signal is present, the gate throws the newReviewOutputCappedError("review agent output hit the model output cap (16384 tokens) and was truncated before emitting its verdict") instead of the generic parse error — so an operator no longer mis-reads it as a flake and retries blindly, burning a second full tasking run. A verdict that DID close on a capped turn is still honored. When the adapter cannot see the signal (the null/shell adapter), the parse still fails as a genericReviewParseError— still needs-attention, never a silent approve. - The tasker's work is no longer discarded on this path.
performTaskcatches the review failure andpersistTaskingCandidatescommits the candidate tasks to the work branch (work/spec-<slug>) with a marker commit and pushes best-effort, so a retry or a human can recover them. - The lock is released and the documented bounce completed. The catch routes through the existing
surfaceTaskingBlock(release the tasking lock + write thework/questions/spec-<slug>.mdsidecar), identical to the decomposition-unclear path — the lock is released and the sidecar written, honouring the WORK-CONTRACT end-of-leg release on success OR bounce.
ReviewOutputCappedErrorsubclassesReviewParseErrorso every existingcatch (ReviewParseError)site routes it to needs-attention uniformly — a parse failure is never turned into a silent approve, and the narrow control-character repair pass inparseReviewVerdictis left exactly as narrow as it was. Adds regression tests driving the tasker-review gate with a response truncated at the output cap (asserting the named failure + lock release + candidate-task persistence) and a non-review throw is re-thrown. - The unbounded full-file
dorfl@0.11.2
Patch Changes
-
4e890e6: Fix the deadline checkpoint leaving two agents alive in the same worktree.
The harness sent
SIGTERMto a single pid and resolved the launch on that process's own exit, which the runner treats as "the agent is done" — so it saved the WIP, released the item lock, and let the next tick dispatch a continuation agent into the same worktree. But an agent is a process TREE (subagents, MCP servers, model proxies, tool subshells) whose members never receive the parent's signal and, once the parent exits, are re-parented to init and can no longer even be found by walkingppid. In the field a predecessor kept writing for roughly four minutes into its successor's run, and its final write landed on a file the successor had already read as clean in its openinggit status. Nothing was lost only because the two happened not to touch the same file.A deadline stop now spawns the agent as a process-group leader, signals the whole group, and polls until the group is verifiably gone (escalating to
SIGKILLafter a grace, bounded by construction, with a loud failure naming what must not happen next if it will not die). The launch does not resolve until then, so the runner is never told the agent is done while it is still writing. The checkpoint refuses to release the item lock or dispatch a successor without that proof: it still saves and pushes the WIP, and surfaces adeadline checkpoint (agent NOT verifiably stopped)question instead, so only the hand-off is withheld.A normal (non-deadline) exit is unchanged: nothing was signalled, so nothing is reaped — in particular a process an agent deliberately left running behind a successful run is not killed. Because spawning detached would otherwise take the agent out of the runner's foreground process group and widen the documented "aborting
dodoes not kill the spawned agent tree" gap, the launch forwards the parent's ownSIGINT/SIGTERMto the group while the child is live.Adds a per-worktree writer sentinel as an independent backstop, since the item lock guards the ITEM and nothing guarded the WORKING TREE. It refuses to onboard a second agent into a tree that already has a live holder, keyed on the tree rather than the item. It lives in the worktree's private git directory, so it can never appear in
git statusand needs no new exclusion in the empty-diff backstop orgc's cleanliness predicate, and a dead holder's sentinel is treated as stale so a killed runner cannot poison a worktree. -
3782a87: Fix the post-write state check reporting dorfl's own successful write as absent.
Two call sites in the checkpoint / surface path answered a state question by reading a remote-tracking ref (
refs/remotes/<arbiter>/…) after a plaingit fetch <arbiter>. That is unsound in the configuration--isolateditself creates: a job worktree is cut from the bare hub mirror, whoseorigincarries the mirror refspec+refs/heads/*:refs/heads/*, so a plain fetch never populates that namespace — and, when awork/<slug>branch is checked out, it fails outright (refusing to fetch into branch …) and refreshes nothing.Two consequences in the field. A surface commit that LANDED was reported
push reported up-to-date / no change of our making — treating as rejected, retried to the retry cap (landing one identical commit per attempt), then declareddid not land on origin/mainwith a recommendation to run an unnecessary recovery command. And a requeue announced'<slug>' has no work branch on origin — nothing to continue fromover a branch holding an hour of agent work, one line before correctly announcing that the next tick would continue from that same branch; acting on the first message re-drives the task from scratch and discards the work.Post-write verification is now arbiter-authoritative through one shared seam: prune-fetch with an explicit per-branch refspec into the namespace readers actually read, then ask the arbiter itself via
git ls-remote. A push that succeeded can never be reported as not landed, and an unreachable arbiter after a green push reports published rather than inventing a rejection. Tree-less transitions are idempotent at the commit level — an empty diff lands nothing, and re-surfacing a bounce whose questions are already present and unanswered is a no-op — so the commit count no longer scales with the retry budget. The requeue resolves the continue-branch exactly once and publishes that single resolved state, which callers derive their messages from, so mutually contradictory lines are structurally impossible; an unreadable arbiter now says so instead of asserting there is nothing to continue from.This also fixes a third consequence that had gone unnoticed: because the surface is surface-first / release-second, a surface mis-reported as failed meant the lock release never ran, leaving a bounced item both surfaced for a human and still holding a live
activeclaim lock, which blocks re-claiming.
dorfl@0.11.1
Patch Changes
-
2c557ab: Add an end-of-drive triage pass to the
drive-tasksskill.Per-task Gate-3 (step 4b) already triages each task's
review-nits-<slug>-*.mdin isolation at the moment its PR merges, but nothing looked at the drive's nits and off-path observations as a whole.drive-tasksnow runs one cross-drive triage pass (step 5.5) after the loop is exhausted and before the report: it gathers every built task's nit set plus every observation filed during the drive, reads them together (grouped by theme, so a nit recurring across PRs is the signal), and routes each item to exactly one destination (already-handled / benign-noise / kept as its own committed observation / task-worthy forto-task/ a stuck-set question folded into the same batched-questions surface). It preserves golden rule 3 (no fix-in-place), runs even on a clean drive, and its disposition is a named section of the conductor's report.
dorfl@0.11.0
Minor Changes
-
181182f: Scope
gcto a single arbiter by default instead of reaping worktrees across every arbiter globally.gcused to default to a GLOBAL sweep of every job worktree under the work dir, across every repo/arbiter. But it is almost always run from inside one project, where the operator's intent is "clean up THIS repo", so a routinegc --force --yesrun from repo A could silently and irreversibly discard un-pushed work belonging to an unrelated repo B (this actually happened).gcis now arbiter-scoped by default: it acts only on the worktrees of the arbiter resolved from the cwd (the same arbiter-resolutiondo --isolated/the mirror uses), so agcin repo A can never reap repo B's worktrees. New--all-arbitersflag restores the old global cross-arbiter sweep behind a LOUD banner that names every arbiter it will touch before doing anything (and combining it with--forcestill requires--yes, so the destructive cross-repo path must be opted into explicitly).--arbiter <remote-or-url>targets a specific arbiter that is not the cwd's. If no cwd arbiter is resolvable and neither flag is given,gcnow REFUSES with an actionable error rather than silently falling back to global.Behavior change: a bare
gc(including in existing scripts) now scopes to the cwd's arbiter. Because this only ever makesgcLESS destructive (it can no longer reach another repo's worktrees by default), it is safe, but scripts that relied on a baregcsweeping every arbiter must now pass--all-arbiters.