Skip to content

feat: a stale draft is shown against what moved since it was drafted, and offers a new draft or your own words - #205

Merged
vjovanov merged 6 commits into
mainfrom
fix/issue-190
Oct 8, 2026
Merged

vjovanov merged 6 commits into
mainfrom
fix/issue-190

Conversation

@vjovanov

@vjovanov vjovanov commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #190

This uses the example from the issue. A contact, dana, asks "Can you do the fence for €100?" The answer recipe 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, so main already 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:

$ ephor thread issue-like:k-7
    [0] dana  Oct 7 09:00
        Can you do the fence for €150? (edited: the posts went up)

    ── a run drafted this reply, unsent ──
        I accept €100 and can start on Monday.

        Bound thread 0 target: {"issue":7}

        Draft is stale: bound thread is missing, reordered or ambiguous

        Copy or edit the words at <site>/demo/panta/runtime/ephor/issue-like-k-7-23bd8707.answer-1.reply.md.
$ ephor reply issue-like:k-7 --dry-run --json
{
  "ok": false,
  "says": "Draft is stale: bound thread is missing, reordered or ambiguous"
}

With this change (3b7ef32c35), the draft is shown against what moved, with two ways on:

$ ephor thread issue-like:k-7
    [0] dana  Oct 7 09:00
        Can you do the fence for €150? (edited: the posts went up)

    ── a run drafted this reply, unsent ──
        I accept €100 and can start on Monday.

        Bound thread 0 target: {"issue":7}

        Draft is stale: dana edited [0] after it was drafted

        Since the draft:
          dana edited [0]
            - Can you do the fence for €100?
            + Can you do the fence for €150? (edited: the posts went up)

        Draft it again: `ephor work dispatch --item issue-like:k-7 --recipe answer --again`
        Or type your own: `ephor reply issue-like:k-7 '<words>'` — the old words stay at <site>/demo/panta/runtime/ephor/issue-like-k-7-23bd8707.answer-1.reply.md
$ ephor reply issue-like:k-7 --dry-run --json
{
  "ok": false,
  "says": "Draft is stale: dana edited [0] after it was drafted",
  "since": [
    {
      "change": "edited",
      "message": 0,
      "author": "dana",
      "at": "2026-10-07T09:00:00+00:00",
      "text": "Can you do the fence for €150? (edited: the posts went up)",
      "before": "Can you do the fence for €100?"
    }
  ]
}

ephor reply issue-like:k-7 without --dry-run prints the same Since 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 main the 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:

        Since the draft:
          dana wrote [1]: Sorry, the posts went up: it is €150 now.
          dana wrote [2]: And Monday does not work, Tuesday?

Both transcripts come from the plan's reproducer, with the throwaway site's path shortened to <site>.

What changed

Nothing new is refused. Binding::freshness still decides on its own which drafts are refused. The new Binding::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 its Draft is stale: prefix and stays one line, but now names the review's most telling entry (§FS-005-dispatch.13.2):

  • an edit reads <author> edited [<n>] after it was drafted;
  • otherwise, an accepted send that advanced the thread keeps today's reason, and the first arrival is named as before;
  • failing both, it names the first message no longer shown, as 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, or no 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 thread prints:

  • new: every arrival, not only the first;
  • yours: the person's own messages, including a reply sent from elsewhere. A send accepted here that no refresh has shown yet is listed with no position, and once a refresh shows it, it is listed once;
  • edited: a message whose words changed, with its words before and after;
  • no longer shown: a message the draft saw that the conversation no longer holds. A slid window and a deletion look the same, so neither is ever called an edit.

How messages are matched. The review guesses nothing:

  1. If the thread still begins with every message the draft saw, word for word, everything after them is an arrival.
  2. Otherwise the messages are lined up by author and time.
  3. If they cannot be lined up one to one, the review lists nothing and the refusal keeps today's "missing, reordered or ambiguous" sentence. That happens when a time is missing or empty, when two messages share an author and time, or when the order changed.

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: true where 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 reply yours. Every comparison reads messages through binding::identity, which ignores mine, so a difference in mine alone 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::Draft gains three fields:

    • since: always an array, empty on a fresh draft. Each entry has change (new, yours, edited or no-longer-shown), message (the printed position, or null), author, at, text, and before on an edit;
    • redraft: {recipe, command};
    • redraft_refused: the reason no new draft is offered.

    redraft and redraft_refused are null unless the draft is stale.

  • A refused reply outcome gains since, which is omitted when it is empty.

  • assets/ephor-views.schema.json declares all four fields. stale_reason stays.

  • ephor thread and the human ephor reply refusal print Since 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.

  • A new draft. redraft.command is ephor 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::redraft decides that in-process, through Dispatcher::offered, the same filter work dispatch uses; offers() now calls it. Where the recipe does not apply, redraft_refused says 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).
  • The person's own words. ephor reply ID '<words>' already existed. The TUI now has the same move:
    • r opens $EDITOR on a scratch file of its own. If words were kept there unsent, the next r reopens them.
    • When the editor closes with words in it, the status line asks once, naming the thread and the target. y sends through the same Session::reply move as the command. Any other key keeps the words and says where they are.
    • e on a stale draft does what r does, starting from the draft's words, and leaves the draft file as it was.
    • n hands the recipe over again, or says why it cannot.
    • p on a stale draft still refuses. e and p on a fresh draft are unchanged.
  • Parity (§REQ-002-parity.2). r and y join reply, and n carries the new work dispatch --again ability. n moves out of PRESENTATION, as l did 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:

    • a change to a message the draft saw makes the draft stale, as a newer message does;
    • the refusal names what moved, whatever kind of movement it was;
    • the "same facts on every surface" list gains the review and the ways on.
  • §FS-005-dispatch.13.2, "A stale draft is shown against what moved since it was drafted", is new. It covers:

    • the baseline;
    • the four kinds of entry;
    • the matching rule;
    • that the review explains and freshness decides;
    • the one-line reason;
    • the two ways on;
    • that there is no override.

    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's n, r, e and p. §FS-011-command-line.7 gains since, redraft and redraft_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 in README.md. There is no docs/changelog.md edit, 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. 9a69ffb983 holds only the spec and the failing tests, with Binding::review stubbed 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 come fe90ce9e9d, 062bed19ec and 1cb30ed08a (the review, the surfaces, the docs) and two commits from review:

  • 9b01f29521 records mine, so the person's own reply is yours and 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.
  • 3b7ef32c35 makes r and e reopen 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) on df6d0cbad7. It ends FIXED (exit 0) on 3b7ef32c35.

  • 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, and whose_a_message_is_never_moves_a_draft. The last one pins that a mine-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; p refuses a stale draft; e, p and the footer on a fresh draft are unchanged.
    • feed::tui::thread::typed_tests: r asks once and y sends through the move; any other answer keeps the words; kept words are reopened, never replaced; e starts from the draft's words and leaves the file; n hands over or says why; n and r refuse 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 as review_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:

    • an edit on every surface;
    • every arrival listed;
    • the person's own reply from elsewhere;
    • a send accepted here, listed once before and after a refresh;
    • a slid window, read as new plus no longer shown, never an edit;
    • an unalignable thread, including a missing time through a real refresh;
    • the redraft. It runs the printed command verbatim and gets answer-2, whose Since the previous ticket: says dana edited [0] and contains none of dana's words. The stale draft is then superseded;
    • a recipe that no longer applies, which carries no command and gives its reason;
    • the published schema, which declares the new fields.
  • 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 for grund check and grund fmt, plus fissile, the private-words hook and the attribution hook;
    • the Python integration suite, 101 tests;
    • check_boundary.py;
    • check_parity.py: 27 abilities, 25 presentation keys, 55 bindings accounted for, and every command has --json;
    • the full cargo test, with TMPDIR on 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:

    • the first refresh records mine: true;
    • with nothing else moved, the draft stays sendable: true with since: [];
    • the feed shows no resurfacing, work sync --dry-run reopens nothing, and reply --dry-run --json is ok: true.

    A typed send accepted by the old binary still refuses the draft after the new binary refreshes. mine reaches no published shape: feed --json, thread --json and status --cached --json carry 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 yours arrival after the baseline counts as that send. Take this sequence:

    1. the person replied from elsewhere, and a refresh showed it;
    2. they then sent from ephor, and no refresh has shown that yet.

    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 a yours arrival 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 dispatch measure facts in two ways. This was rejected, and not reproduced. Session::redraft measures the facts with item_trailing, and work dispatch with Dispatcher::facts. The two could differ only for a recipe whose when asks behind or behind_upstream, and answer asks neither. The action menu already pairs the same two measurements, so this predates this change.

  • Adjacent: the in-process github-threads provider records no ownership. Its messages never carry mine. 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 main with Dispatcher::why_not_offered (§FS-005-dispatch.27.1). With it, work dispatch --item X --recipe R refuses with a reason when R is not offered. This branch's redraft_refused predates 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.
  1. github-issues-agent-grounds-ephor-190-implement-051f9493.ticket supervising (visit 1) — cld, anthropic/claude-opus-5-5 — 3m16s — 1.8M in / 11.7k out
  2. github-issues-agent-grounds-ephor-190-implement-051f9493.ticket supervising (visit 2) — cld, anthropic/claude-opus-5-5 — 2m35s — 1.8M in / 13.2k out
  3. github-issues-agent-grounds-ephor-190-implement-051f9493.ticket.plan planning (visit 1) — cld, anthropic/claude-opus-5-5 — 10m53s — 9.6M in / 65.6k out
  4. github-issues-agent-grounds-ephor-190-implement-051f9493.ticket.plan planning (visit 2) — cld, anthropic/claude-opus-5-5 — 1m29s — 1.1M in / 8.7k out
  5. github-issues-agent-grounds-ephor-190-implement-051f9493.ticket supervising (visit 3) — cld, anthropic/claude-opus-5-5 — 1m14s — 1.4M in / 7.7k out
  6. github-issues-agent-grounds-ephor-190-implement-051f9493.ticket supervising (visit 4) — cld, anthropic/claude-opus-5-5 — 1m52s — 1.7M in / 11.5k out
  7. github-issues-agent-grounds-ephor-190-implement-051f9493.ticket.specify specify — cld, anthropic/claude-opus-5-5 — 21m14s — 17.1M in / 107.7k out
  8. github-issues-agent-grounds-ephor-190-implement-051f9493.ticket supervising (visit 5) — cld, anthropic/claude-opus-5-5 — 1m38s — 1.4M in / 10.4k out
  9. github-issues-agent-grounds-ephor-190-implement-051f9493.ticket.implement implement — cld, anthropic/claude-opus-5-5 — 31m42s — 34.4M in / 173.0k out
  10. github-issues-agent-grounds-ephor-190-implement-051f9493.ticket supervising (visit 6) — cld, anthropic/claude-opus-5-5 — 2m28s — 1.7M in / 9.5k out
  11. github-issues-agent-grounds-ephor-190-implement-051f9493.ticket.review-1 review — cld, anthropic/claude-opus-5-5 — 8m44s — 12.0M in / 46.5k out
  12. github-issues-agent-grounds-ephor-190-implement-051f9493.ticket supervising (visit 7) — cld, anthropic/claude-opus-5-5 — 2m15s — 2.0M in / 13.9k out
  13. github-issues-agent-grounds-ephor-190-implement-051f9493.ticket.fix-1 fix — cld, anthropic/claude-opus-5-5 — 13m25s — 8.8M in / 56.7k out
  14. github-issues-agent-grounds-ephor-190-implement-051f9493.ticket supervising (visit 8) — cld, anthropic/claude-opus-5-5 — 3m51s — 2.1M in / 11.7k out
  15. github-issues-agent-grounds-ephor-190-implement-051f9493.ticket.review-2 review — cld, anthropic/claude-opus-5-5 — 6m01s — 7.3M in / 31.8k out
  16. github-issues-agent-grounds-ephor-190-implement-051f9493.ticket supervising (visit 9) — cld, anthropic/claude-opus-5-5 — 3m24s — 2.2M in / 8.2k out
  17. github-issues-agent-grounds-ephor-190-implement-051f9493.ticket supervising (visit 10) — cld, anthropic/claude-opus-5-5 — 2m07s — 1.7M in / 13.1k out
Accounting Value
cost $47.95
total tokens 108.5M
input tokens (incl. cache) 107.9M
input cache read 105.0M
input cache write 3.0M
output tokens (incl. cache) 600.9k
output cache read -
output cache write -
coverage Complete

@vjovanov vjovanov changed the title spec: a stale draft is shown against what moved since it was drafted feat: a stale draft is shown against what moved since it was drafted, and offers a new draft or your own words 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
vjovanov marked this pull request as ready for review October 8, 2026 06:55
@vjovanov
vjovanov merged commit 0d2d696 into main Oct 8, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant