Repository navigation
feat: a stale draft is shown against what moved since it was drafted, and offers a new draft or your own words - #205
Merged
Merged
Conversation
This was referenced Oct 8, 2026
A stale draft was refused, but the refusal named only the first new message, called an edit "missing, reordered or ambiguous", and pointed at no way on. §FS-005-dispatch.13.2 now gives the draft a review, read from the baseline the binding already keeps: every arrival, the person's own replies, edits with their words before and after, and messages no longer shown. It says how messages are matched, and that nothing is guessed where they cannot be lined up. Freshness still decides what is refused. The stale draft offers a new draft, which is §FS-005-dispatch.5's reopen under its recipe while that recipe applies, or the person's own words, with `r` and `e` on the thread screen. There is no override. §FS-011-command-line.4 and .7 say what the reading prints and what `since`, `redraft` and `redraft_refused` carry. The tests port the issue's reproducer into E2E-052 and pin the review in `Binding::review`, on the thread screen and in the parity list. They fail today: `Binding::review` is a stub that returns nothing.
`Binding::review` lines the bound thread up against the baseline it kept at hand-off: every arrival, the person's own among them and an accepted send no refresh has shown, edits with their words before and after, and messages no longer shown. Where the messages cannot be lined up one to one it lists nothing. Freshness still decides which drafts are refused; its one-line reason now names the review's most telling entry and how many more there are. A reopen of conversation-bound work says what moved in review terms rather than quoting anybody's words.
…wn words The draft in `ephor thread`, its JSON and the thread screen now carries the review alongside the stale reason, and a refused reply carries it in the outcome's `since`. A stale draft names its two ways on: a new draft under the recipe that laid it, with the `ephor work dispatch --again` command, wherever that recipe still applies, or why none is offered; and the person's own words for the thread. On the thread screen `n` asks for the new draft. `r` types a reply in an editor, and `e` on a stale draft starts from its words. The status line then asks once, naming the thread and target, and `y` sends through the same move `ephor reply ID WORDS` makes. The draft file is left as it was. Parity lists `n` as an ability on a stale draft, carried by `work dispatch --again`, and `y` with the reply.
The manual's reply section shows a stale draft as `ephor thread` prints it: the review of what moved since it was drafted, how messages are matched, the reason it gives, and the two ways on, a new draft or the person's own words. The thread-key and parity tables gain `r`, `n` and the stale `e`, and the README says where to look.
… records it The recorded message now carries the source's `mine` where it is true (§FS-001-forge-interface.3), so the review lists the person's own reply as theirs and a send accepted here once (§FS-005-dispatch.13.2). Ownership is recorded in the baseline but never compared: a draft bound before it was recorded is neither refused nor reviewed for it, and the generation key reads it as every pre-upgrade key holds it. A message recorded with no time carries `""`; the review now treats it as missing and lines nothing up, so it is never called an edit. A send a forge rewrote on posting is listed once, as the reply it became.
Answering the thread screen's question with anything but `y` says the words are kept in the scratch file, and a forge that refuses a typed send keeps them there too. The next `r`, or `e` on a stale draft, now reopens them as they are; only an absent or empty scratch file starts from nothing or from the draft's words (§FS-005-dispatch.13.2).
vjovanov
force-pushed
the
fix/issue-190
branch
from
October 8, 2026 06:54
3b7ef32 to
26d66c3
Compare
vjovanov
marked this pull request as ready for review
October 8, 2026 06:55
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #190
This uses the example from the issue. A contact, dana, asks "Can you do the fence for €100?" The
answerrecipe drafts "I accept €100 and can start on Monday." Before anyone sends the draft, the conversation moves. The source is an out-of-process forge shaped like a GitHub issue, so its reply target is always{"issue": 7}and never moves with the conversation. Since the issue was filed, #188 has landed, somainalready refuses both posts. But the refusal does not say what moved.Case A: dana edits her comment to €150 after the draft was made. On
main(df6d0cbad7) the edit cannot be told apart from a broken thread:With this change (
3b7ef32c35), the draft is shown against what moved, with two ways on:ephor reply issue-like:k-7without--dry-runprints the sameSince the draft:block under its refusal and exits 1. Nothing reaches the forge in either version.Case B: dana adds two comments after the draft was made. On
mainthe refusal names only the first of them:Draft is stale: dana 2026-10-07T09:30:00+00:00: Sorry, the posts went up: it is €150 now.With this change, the reason line gains(and 1 more)and the review lists both:Both transcripts come from the plan's reproducer, with the throwaway site's path shortened to
<site>.What changed
Nothing new is refused.
Binding::freshnessstill decides on its own which drafts are refused. The newBinding::review(src/replies/review.rs) only explains a refusal, and a unit test pins that a draft with a non-empty review is always refused. The stale reason keeps itsDraft is stale:prefix and stays one line, but now names the review's most telling entry (§FS-005-dispatch.13.2):<author> edited [<n>] after it was drafted;no longer shown: <label>.The reason also changes wording in one more case. A thread that still lines up by author and time but no longer begins with the messages the draft saw, as after a slid window or a deletion, used to read
Draft is stale: bound thread is missing, reordered or ambiguous. It now names its first arrival, orno longer shown: <label>when nothing arrived. A script that matches the old sentence for that case no longer finds it.When more than one thing moved, it appends
(and N more). When the review is empty, the reason is exactly what it was.What the review lists. The review compares the baseline that #188 already saves at hand-off with the conversation as last refreshed. For each message the draft saw, that baseline holds the author, time, ownership and full words. There is no new storage, and no forge has to send anything new. The review lists four kinds of entry, in conversation order, each named by the position
ephor threadprints:How messages are matched. The review guesses nothing:
The same rule applies to every source that declares replies, whether or not its reply target moves (§FS-001-forge-interface.3).
Recorded messages now carry
mine: truewhere the source says the message is the person's own (policy::message_json). A message that is not theirs gets no key, so a recorded conversation with no own messages is byte-identical to before. This is how the review can call a replyyours. Every comparison reads messages throughbinding::identity, which ignoresmine, so a difference inminealone is never movement. A draft bound before the upgrade, whose baseline recorded no ownership, is neither freed nor newly refused, and its generation key is unchanged.The surfaces render the review and compute nothing. All changes are additive under §REQ-002-parity.4:
views::Draftgains three fields:since: always an array, empty on a fresh draft. Each entry haschange(new,yours,editedorno-longer-shown),message(the printed position, ornull),author,at,text, andbeforeon an edit;redraft:{recipe, command};redraft_refused: the reason no new draft is offered.redraftandredraft_refusedarenullunless the draft is stale.A refused reply outcome gains
since, which is omitted when it is empty.assets/ephor-views.schema.jsondeclares all four fields.stale_reasonstays.ephor threadand the humanephor replyrefusal printSince the draft:, then both ways on, then the path where the old words stay (§FS-011-command-line.4, §FS-011-command-line.7).The TUI thread card shows the same lines. It is now its own file,
src/feed/tui/thread_card.rs.The two ways on. Neither is taken by itself, and there is no override.
redraft.commandisephor work dispatch --item ID --recipe RECIPE --again, the reopen of §FS-005-dispatch.5 under the recipe that laid the draft. It is offered only while that recipe still applies to the matter.Session::redraftdecides that in-process, throughDispatcher::offered, the same filterwork dispatchuses;offers()now calls it. Where the recipe does not apply,redraft_refusedsays why: no recipe laid the draft, the recipe is no longer configured, the item is blocked, or a selector now refuses it (for example, the person's own reply means nothing awaits them).ephor reply ID '<words>'already existed. The TUI now has the same move:ropens$EDITORon a scratch file of its own. If words were kept there unsent, the nextrreopens them.ysends through the sameSession::replymove as the command. Any other key keeps the words and says where they are.eon a stale draft does whatrdoes, starting from the draft's words, and leaves the draft file as it was.nhands the recipe over again, or says why it cannot.pon a stale draft still refuses.eandpon a fresh draft are unchanged.randyjoinreply, andncarries the newwork dispatch --againability.nmoves out ofPRESENTATION, asldid before it; the comment says why, and]stays there as "the next project".The reopened ticket says what moved. For work bound to a conversation, the reopened ticket's
Since the previous ticket:line now names the movement in the review's terms (dana edited [0],dana wrote [1]) instead of "there is new activity". It quotes nobody's words outside the dossier's fences.The specification
§FS-005-dispatch.13 is extended in three ways:
§FS-005-dispatch.13.2, "A stale draft is shown against what moved since it was drafted", is new. It covers:
The new code cites it.
§FS-005-dispatch.5 gains one sentence: the reopen is also offered at a stale draft, and a bound conversation's reopened ticket says what changed in the review's terms.
§FS-011-command-line.4 gains the text form of
Since the draft:and the card'sn,r,eandp. §FS-011-command-line.7 gainssince,redraftandredraft_refused.§FS-001-forge-interface.1 is deliberately unchanged. The shared message shape gains no id and no revision, and the reply request (
{"target","text"}) gains no field. No forge implementation has to change.The docs follow the spec:
docs/manual.md§6.3 (the key table), §8.12 (the stale draft, its review and its two ways on) and §12.4 (the parity rows), plus one sentence inREADME.md. There is nodocs/changelog.mdedit, on purpose. §FS-002-release.1 says the release takes its line from this pull request's title.How it was verified
The branch is spec first.
9a69ffb983holds only the spec and the failing tests, withBinding::reviewstubbed to return nothing. On that commit, 10 of the 11 new unit tests and 8 of the 9 new end-to-end cases failed on their assertions. The one of each that passed is a regression guard: a thread that cannot be lined up keeps today's sentence. Then comefe90ce9e9d,062bed19ecand1cb30ed08a(the review, the surfaces, the docs) and two commits from review:9b01f29521recordsmine, so the person's own reply isyoursand an accepted send is listed once. It also makes an empty time count as missing, so an empty time can no longer be lined up into an edit.3b7ef32c35makesrandereopen words kept unsent instead of overwriting them.Review added cases to the contract's tests but changed none of its assertions.
The plan's reproducer (the issue's two cases, with a second arrival in case B) ended
REPRODUCED: refused, but what moved is not shown(exit 1) ondf6d0cbad7. It endsFIXED(exit 0) on3b7ef32c35.Unit tests:
replies::binding::tests: every kind of movement, including a slid window, a deletion, and an accepted send before and after a refresh shows it, and as the forge rewrote it. Also: an edit's before, arrivals with no time, flat positions across threads, the unalignable guard (a missing or empty time, a duplicate key, a changed order), the one-line reason,whatever_the_review_lists_freshness_refuses,the_persons_own_reply_is_theirs_as_a_refresh_records_it, andwhose_a_message_is_never_moves_a_draft. The last one pins that amine-only difference is fresh, keeps the pre-upgrade generation key, and still lets a later accepted send refuse the old draft.feed::tui::thread::review_tests: the card shows the review the API carries;prefuses a stale draft;e,pand the footer on a fresh draft are unchanged.feed::tui::thread::typed_tests:rasks once andysends through the move; any other answer keeps the words; kept words are reopened, never replaced;estarts from the draft's words and leaves the file;nhands over or says why;nandrrefuse where they do not apply.api::parity::tests::a_stale_drafts_ways_on_have_keys.End to end:
tests/e2e/reply_review_cases.rs, included in E2E-052 asreview_cases. It runs the real binary over a source whose target is{"issue": 7}, and every case asserts that nothing reaches the forge. The cases are:answer-2, whoseSince the previous ticket:saysdana edited [0]and contains none of dana's words. The stale draft is then superseded;The local gate on
3b7ef32c35. Five commands, 4m20s in all, every one exited 0:pre-commit run --all-files, with CI's pinned grund 0.14.0 forgrund checkandgrund fmt, plus fissile, the private-words hook and the attribution hook;check_boundary.py;check_parity.py: 27 abilities, 25 presentation keys, 55 bindings accounted for, and every command has--json;cargo test, withTMPDIRon a symlinked directory.An upgrade, end to end. The second review built the base commit
df6d0cbad7. With that binary it bound a draft on a thread that starts with the person's own message, then refreshed with this branch's binary:mine: true;sendable: truewithsince: [];work sync --dry-runreopens nothing, andreply --dry-run --jsonisok: true.A typed send accepted by the old binary still refuses the draft after the new binary refreshes.
minereaches no published shape:feed --json,thread --jsonandstatus --cached --jsoncarry no"mine".Found and deliberately not fixed
A send here can be hidden behind an earlier reply from elsewhere. This is minor, and deferred. While a send accepted here has not yet appeared in a refresh, any
yoursarrival after the baseline counts as that send. Take this sequence:The review lists only the earlier reply, and the reason line names the later send's words with no
(and N more). The draft stays refused throughout, and after a refresh both are listed. The remedy is to count only ayoursarrival at or after the send's own binding length as the send, trying exact words first. It is a candidate for a follow-up issue.The redraft offer and
work dispatchmeasure facts in two ways. This was rejected, and not reproduced.Session::redraftmeasures the facts withitem_trailing, andwork dispatchwithDispatcher::facts. The two could differ only for a recipe whosewhenasksbehindorbehind_upstream, andanswerasks neither. The action menu already pairs the same two measurements, so this predates this change.Adjacent: the in-process
github-threadsprovider records no ownership. Its messages never carrymine. Its threads take no reply, so no draft is ever bound to them.Adjacent: two sentences for one refusal, after the rebase. Since this branch was cut, fix: a recipe asked for by name and not offered says why #207 (the fix for work dispatch drops a named recipe the item does not offer with no row and no reason, then exits 1 on an empty outcome #206) landed on
mainwithDispatcher::why_not_offered(§FS-005-dispatch.27.1). With it,work dispatch --item X --recipe Rrefuses with a reason when R is not offered. This branch'sredraft_refusedpredates that function and builds its own sentence in-process. So after the rebase, the card's reason and the refusal from the printed command can be worded differently, though both say the recipe no longer applies. Making them one sentence is a follow-up.AI workflow: `rhei`, 17 agent invocations across 1 model; 10 tasks completed, 6 in progress.
github-issues-agent-grounds-ephor-190-implement-051f9493.ticketsupervising (visit 1) — cld, anthropic/claude-opus-5-5 — 3m16s — 1.8M in / 11.7k outgithub-issues-agent-grounds-ephor-190-implement-051f9493.ticketsupervising (visit 2) — cld, anthropic/claude-opus-5-5 — 2m35s — 1.8M in / 13.2k outgithub-issues-agent-grounds-ephor-190-implement-051f9493.ticket.planplanning (visit 1) — cld, anthropic/claude-opus-5-5 — 10m53s — 9.6M in / 65.6k outgithub-issues-agent-grounds-ephor-190-implement-051f9493.ticket.planplanning (visit 2) — cld, anthropic/claude-opus-5-5 — 1m29s — 1.1M in / 8.7k outgithub-issues-agent-grounds-ephor-190-implement-051f9493.ticketsupervising (visit 3) — cld, anthropic/claude-opus-5-5 — 1m14s — 1.4M in / 7.7k outgithub-issues-agent-grounds-ephor-190-implement-051f9493.ticketsupervising (visit 4) — cld, anthropic/claude-opus-5-5 — 1m52s — 1.7M in / 11.5k outgithub-issues-agent-grounds-ephor-190-implement-051f9493.ticket.specifyspecify — cld, anthropic/claude-opus-5-5 — 21m14s — 17.1M in / 107.7k outgithub-issues-agent-grounds-ephor-190-implement-051f9493.ticketsupervising (visit 5) — cld, anthropic/claude-opus-5-5 — 1m38s — 1.4M in / 10.4k outgithub-issues-agent-grounds-ephor-190-implement-051f9493.ticket.implementimplement — cld, anthropic/claude-opus-5-5 — 31m42s — 34.4M in / 173.0k outgithub-issues-agent-grounds-ephor-190-implement-051f9493.ticketsupervising (visit 6) — cld, anthropic/claude-opus-5-5 — 2m28s — 1.7M in / 9.5k outgithub-issues-agent-grounds-ephor-190-implement-051f9493.ticket.review-1review — cld, anthropic/claude-opus-5-5 — 8m44s — 12.0M in / 46.5k outgithub-issues-agent-grounds-ephor-190-implement-051f9493.ticketsupervising (visit 7) — cld, anthropic/claude-opus-5-5 — 2m15s — 2.0M in / 13.9k outgithub-issues-agent-grounds-ephor-190-implement-051f9493.ticket.fix-1fix — cld, anthropic/claude-opus-5-5 — 13m25s — 8.8M in / 56.7k outgithub-issues-agent-grounds-ephor-190-implement-051f9493.ticketsupervising (visit 8) — cld, anthropic/claude-opus-5-5 — 3m51s — 2.1M in / 11.7k outgithub-issues-agent-grounds-ephor-190-implement-051f9493.ticket.review-2review — cld, anthropic/claude-opus-5-5 — 6m01s — 7.3M in / 31.8k outgithub-issues-agent-grounds-ephor-190-implement-051f9493.ticketsupervising (visit 9) — cld, anthropic/claude-opus-5-5 — 3m24s — 2.2M in / 8.2k outgithub-issues-agent-grounds-ephor-190-implement-051f9493.ticketsupervising (visit 10) — cld, anthropic/claude-opus-5-5 — 2m07s — 1.7M in / 13.1k out