diff --git a/emrg/server/evolution_prompt.md b/emrg/server/evolution_prompt.md index d4f666d0..d6af5b8f 100644 --- a/emrg/server/evolution_prompt.md +++ b/emrg/server/evolution_prompt.md @@ -395,9 +395,9 @@ Every merged PR must trace back to the reason it exists. The chain has three lin rant (entry `progress`: `issue #N`) → issue (stands on its own) → PR (Closes #N) → merge → issue closes → rant completed ``` -**rant → issue.** Taking on a rant means first opening the issue that will finish it, and that issue is written for a reader who has never seen the record it came from — **it does not refer to it at all**: no timestamp, no *reported by the host*, no paraphrase of the report. Write out what was observed, where it was observed, what was expected instead, and what the fix must satisfy, so that reader can act on it without the record. A citation into the ledger is not context: the ledger is one file per installation, so a second reader cannot open what it names, and an issue that only makes sense beside it is incomplete however its first line reads. The moment is **taking it on**, not submitting: no agent is present when a rant lands from the GUI or CLI, so taking it on is the only moment that covers every rant. One rant is one issue by default — several rants are not bundled into one issue. Write the issue number into that rant's `progress` (through the tool, as `issue #N`) in that same step: the link is **one way** — the entry knows its issue, the issue does not know its entry — and that entry is where the pairing is durable, because the queue reading (R2) takes the issue from it. A rant this cycle decides **not** to do opens no issue: the reason goes into its progress and the rant is closed. The invariant is "every merged PR names its issue", not "every rant has an issue". +**rant → issue.** Taking on a rant means first opening the issue that will finish it, and that issue is written for a reader who has never seen the record it came from — **it does not refer to it at all**: no timestamp, no *reported by the host*, no paraphrase of the report. Write out what was observed, where it was observed, what was expected instead, and what the fix must satisfy, so that reader can act on it without the record. A citation into the ledger is not context: the ledger is one file per installation, so a second reader cannot open what it names, and an issue that only makes sense beside it is incomplete however its first line reads. The moment is **taking it on**, not submitting: no agent is present when a rant lands from the GUI or CLI, so taking it on is the only moment that covers every rant. **One rant is one issue, and one issue is one PR** — the chain stated as the single rule it is. Several rants are never bundled into one issue, and one rant is never carried by two. The cost is the reason: a PR here needs three approvals from three different cycles, so every stage a record is cut into spends three more votes on the same work while making no part of the review smaller. Write the issue number into that rant's `progress` (through the tool, as `issue #N`) in that same step: the link is **one way** — the entry knows its issue, the issue does not know its entry — and that entry is where the pairing is durable, because the queue reading (R2) takes the issue from it. A rant this cycle decides **not** to do opens no issue: the reason goes into its progress and the rant is closed. The invariant is "every merged PR names its issue", not "every rant has an issue". -**issue → PR.** An issue is **finished by exactly one PR**, and **the two name each other** — a PR that is rejected or asked to change is **updated in place, never replaced**. When a rant's acceptance items are independently verifiable they may be split across issues, and each issue's body says `Part: 1/2` so the reading can tell a deliberate split from a duplicate claim. +**issue → PR.** An issue is **finished by exactly one PR**, and **the two name each other** — a PR that is rejected or asked to change is **updated in place, never replaced**. An issue landed by two PRs is the same fault one link later, not a way to finish a large one: an issue too big for one reviewable PR was scoped wrong, and re-scoping it is the remedy. Independently verifiable acceptance items do not license a split either — an issue carried by two says nothing a reader can act on that one issue could not. **PR → issue, at creation.** The `gh pr create` body carries `Closes #N` — GitHub's own closing keyword, and the token the link reading treats as a **claim** rather than a mention. A body that cites another number as evidence writes `see #N`, never `Closes`, or that issue is recorded as claimed by a PR that never finished it. **No issue number, no PR**: go back and open or find the issue first, never open the PR and backfill. In the same action, comment `Handled by #` on that issue, so both halves are born together rather than waiting for a later cycle to claim one of them. diff --git a/scripts/check-issue-links.py b/scripts/check-issue-links.py index 463f4b64..12aaf108 100644 --- a/scripts/check-issue-links.py +++ b/scripts/check-issue-links.py @@ -120,9 +120,8 @@ that change, and the state is kept because silently dropping it would report those issues as if the line had never been written origin-duplicate - two or more open issues declare the same rant origin and at least one - carries no `Part: n/N` — which is what makes a deliberate split of one rant - across several issues distinguishable from a duplicate claim of it + two or more open issues declare the same rant origin: one rant is one + issue, so a second claim of it cannot be told from a duplicate of the first The last two read a **file**, not GitHub, and that difference is why they exist at all ---------------------------------------------------------------------------------------- @@ -411,13 +410,6 @@ r"(?P\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)" ) -#: The escape hatch R5 defines for a rant carried by more than one issue: `Part: 1/3`. Two -#: open issues naming the same rant are the shape the one-rant-one-issue default forbids, -#: *unless* each says which part it is — then the reading can tell a deliberate split from -#: a duplicate claim, which is the whole reason the clause exists. -_PART = re.compile(r"(?i)\bpart:[ \t]*\d+[ \t]*/[ \t]*\d+") - - def _without_code(text: str) -> str: """`text` with inline code spans and fenced blocks blanked out, same length.""" masked = _FENCED_BLOCK.sub(lambda m: " " * len(m.group(0)), text) @@ -914,11 +906,10 @@ def judge_origins( pruned the record at all, and whether the citation is newer than every record the ledger ranks: the fact that decides whether this store could have held the handle, or whether it was written on the machine that owns the ledger it resolves against. - * `origin-duplicate` — two or more **open** issues declare the same rant and at least - one of them carries no `Part: n/N`. R5's default is one rant, one issue; the split is - legitimate only when every part says which part it is, so an unlabelled one makes the - two indistinguishable from a duplicate claim — which is the reading `--json` and a - reviewer both need before they can tell deliberate from accidental. + * `origin-duplicate` — two or more **open** issues declare the same rant. One rant is one + issue, so every claimant after the first is indistinguishable from a duplicate of it, and + the reading says so about each rather than picking one to spare: folding them into the one + issue that finishes the rant is the remedy, and there is no label that substitutes for it. The subject is counted by **issue, not by occurrence**: one issue that writes its origin line twice (a body carrying the line at the top and again in a provenance @@ -941,12 +932,10 @@ def judge_origins( """ faults: dict[int, tuple[str, str]] = {} by_ts: dict[str, set[int]] = {} - labelled: dict[int, bool] = {} store = _ledger_timestamps(rows) for issue in issues: number = int(issue["number"]) body = issue.get("body") or "" - labelled[number] = bool(_PART.search(_without_code(body))) for ts in _origin_lines(body): by_ts.setdefault(ts, set()).add(number) if ts in store: @@ -965,17 +954,13 @@ def judge_origins( for ts, numbers in by_ts.items(): if len(numbers) < 2: continue - unlabelled = sorted(n for n in numbers if not labelled[n]) - if not unlabelled: - continue - for number in unlabelled: + for number in sorted(numbers): + others = sorted(n for n in numbers if n != number) faults[number] = ( "origin-duplicate", - f"{_numbers(numbers)} name the same rant origin `{ts}` and " - f"{_numbers(unlabelled)} carry no `Part: n/N` - one rant is one issue by " - "default, so either fold these into the one issue that finishes the rant, " - "or label each with the part it is (`Part: 1/2`) so a reader can tell a " - "deliberate split from a duplicate claim", + f"{_numbers(numbers)} name the same rant origin `{ts}` - one rant is one " + f"issue, so {_numbers(others)} cannot be told from a duplicate of it. Fold " + "them into the one issue that finishes the rant; no label substitutes for that", ) return faults diff --git a/tests/test_check_issue_links.py b/tests/test_check_issue_links.py index bee610a5..1de175ea 100644 --- a/tests/test_check_issue_links.py +++ b/tests/test_check_issue_links.py @@ -2310,13 +2310,14 @@ def test_a_near_match_names_the_ledgers_own_spelling(mod, monkeypatch, capsys, t assert RANT_TS in detail and "write it verbatim" in detail, detail -def test_two_unlabelled_issues_on_one_origin_are_a_duplicate( +def test_two_issues_on_one_origin_are_a_duplicate( mod, monkeypatch, capsys, tmp_path ) -> None: - """One rant, one issue: two unlabelled claims of the same handle cannot be told apart. + """One rant, one issue: a second claim of the same handle cannot be told from a duplicate. Both links are clean here, which is the point — without the origin clause this queue - reads `OK`, so the row's state is entirely the origin reading's verdict. + reads `OK`, so the row's state is entirely the origin reading's verdict. Neither row is + spared, because the reading cannot know which of the two a reader meant. """ _install( mod, @@ -2329,7 +2330,10 @@ def test_two_unlabelled_issues_on_one_origin_are_a_duplicate( assert rc == 1, out for subject in ("#10 issue ORIGIN-DUPLICATE", "#11 issue ORIGIN-DUPLICATE"): detail = _detail(out, subject) - assert RANT_TS in detail and "Part:" in detail, detail + assert RANT_TS in detail, detail + assert "Fold them into the one issue" in detail, ( + "the remedy is folding the claims into one issue, not labelling the parts" + ) def test_one_issue_writing_its_origin_twice_is_not_a_duplicate( @@ -2341,8 +2345,8 @@ def test_one_issue_writing_its_origin_twice_is_not_a_duplicate( section, or because an edit appended where it meant to replace — and that is one issue declaring one origin. The rule it is measured against says "two or more open issues", so a single issue can never be a duplicate of itself; grouping by occurrence made this queue - fail with a row reading *"#10 name the same rant origin ... and #10 #10 carry no - `Part: n/N`"*, three wrong numbers in the sentence a reader acts on. + fail with a row that named the same number twice, two wrong numbers in the sentence a + reader acts on. The queue is otherwise clean (`_linked_issues` holds every link `ok` and the ledger holds the timestamp), so `OK` here is the whole verdict: nothing but the origin reading can @@ -2363,12 +2367,13 @@ def test_one_issue_writing_its_origin_twice_is_not_a_duplicate( assert "DUPLICATE" not in out, out -def test_a_labelled_split_of_one_rant_is_allowed(mod, monkeypatch, capsys, tmp_path) -> None: - """R5's escape hatch: a rant carried by two issues, each saying which part it is. +def test_a_labelled_split_of_one_rant_is_a_fault_too(mod, monkeypatch, capsys, tmp_path) -> None: + """The marker that used to license a split no longer answers the reading. - Without this the reading would forbid the split the design explicitly permits when a - rant's requirements are independent — and a rule that cannot be satisfied is one a cycle - answers by editing the reading instead of the work. + A rant carried by two issues is one defect at either joint — two issues where one was + owed, or one issue owed two PRs later. Labelling the parts was how the old reading told a + deliberate split from a duplicate claim, so with splitting gone the label has no subject + left: the same two issues must fault exactly as the unlabelled pair does. """ _install( mod, @@ -2383,17 +2388,19 @@ def test_a_labelled_split_of_one_rant_is_allowed(mod, monkeypatch, capsys, tmp_p rc, out = _run(mod, capsys, ["--rants", str(_ledger(tmp_path, RANT_TS))]) - assert rc == 0, out - assert "OK" in out, out + assert rc == 1, out + for subject in ("#10 issue ORIGIN-DUPLICATE", "#11 issue ORIGIN-DUPLICATE"): + assert subject in out, out def test_one_labelled_and_one_unlabelled_part_is_still_a_duplicate( mod, monkeypatch, capsys, tmp_path ) -> None: - """The escape hatch has to be used by **every** part, not by one of them. + """A half-labelled pair was the shape that made the old rule depend on the reading order. - Otherwise "labelled" would depend on which issue a reader looked at first, and the - unlabelled one would still be indistinguishable from a duplicate claim. + It faults on both sides now, and the point of keeping the arm is that it holds the same + property for the new rule: neither claimant is excused, so the verdict cannot depend on + which issue a reader reached first. """ _install( mod, @@ -2410,7 +2417,9 @@ def test_one_labelled_and_one_unlabelled_part_is_still_a_duplicate( assert rc == 1, out assert "#11 issue ORIGIN-DUPLICATE" in out, out - assert "#10 issue ORIGIN-DUPLICATE" not in out, out + assert "#10 issue ORIGIN-DUPLICATE" in out, ( + "the labelled half is not spared any more - see the docstring" + ) def test_a_queue_that_declares_no_origin_never_reads_the_ledger(