diff --git a/scripts/impasse_report.py b/scripts/impasse_report.py index 1dde22e..ba6b822 100644 --- a/scripts/impasse_report.py +++ b/scripts/impasse_report.py @@ -178,11 +178,22 @@ def render(run: dict) -> str: by = {"accepted": 0, "rejected": 0, "resolved": 0, "deadlocked": 0, "withdrawn": 0} for it in items.values(): by[it.get("state")] = by.get(it.get("state"), 0) + 1 + # An operator ruling counts as an escalation regardless of channel (SKILL.md): a resolved item + # carrying an escalation object was decided BY the operator, not settled between the two models. + # Credit it separately so the tally can't under-report the operator's own involvement (issue #5) โ€” + # `pending` is the still-deadlocked work, `resolved_shown` the truly model-settled resolutions. + decided_by_you = sum(1 for it in items.values() + if it.get("state") == "resolved" and isinstance(it.get("escalation"), dict)) + pending = by["deadlocked"] + resolved_shown = by["resolved"] - decided_by_you out.append("") - out.append( - f"๐Ÿ“Š Decisions: {n} finding(s) raised โ†’ โœ… {by['resolved']} resolved ยท ๐Ÿค {by['accepted']} accepted ยท " - f"โŒ {by['rejected']} rejected ยท โš–๏ธ {by['deadlocked']} escalated to you" + tally = ( + f"๐Ÿ“Š Decisions: {n} finding(s) raised โ†’ โœ… {resolved_shown} resolved ยท ๐Ÿค {by['accepted']} accepted ยท " + f"โŒ {by['rejected']} rejected ยท โš–๏ธ {pending} escalated to you" ) + if decided_by_you: + tally += f" ยท ๐Ÿง‘โ€โš–๏ธ {decided_by_you} decided by you" + out.append(tally) if rec.get("failure"): out.append(f"โš ๏ธ Failure: {rec['failure'].get('code')} โ€” {rec['failure'].get('message')}") out.append("โ”€" * 78) @@ -192,9 +203,15 @@ def render(run: dict) -> str: out += _render_finding(f, items.get(f.get("id"))) out.append("โ”€" * 78) - esc = by["deadlocked"] - if esc: - out.append(f"โš–๏ธ {esc} decision(s) need you; the rest the models settled between themselves.") + if pending: + if decided_by_you: + out.append(f"โš–๏ธ {pending} decision(s) need you; you decided {decided_by_you}; " + "the rest the models settled between themselves.") + else: + out.append(f"โš–๏ธ {pending} decision(s) need you; the rest the models settled between themselves.") + elif decided_by_you: + out.append(f"โœ… Models settled {len(items) - decided_by_you}; you decided {decided_by_you}. " + "Nothing is waiting on you.") elif items: out.append(f"โœ… Nothing needed you โ€” the models settled all {len(items)} between themselves.") else: diff --git a/tests/test_helpers.py b/tests/test_helpers.py index a75ff40..3c80712 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -1409,6 +1409,39 @@ def _spy_cwd(argv, **kw): check("Question for you" in out and "decision(s) need you" in out, "report: shows the escalated question") check(any(r["run_id"] == drid for r in lib.list_runs()), "run record: listed by list_runs") + # --- report credits operator-decided items (issue #5): a resolved item carrying an escalation + # object was decided BY the operator, not settled between the models โ€” the tally + footer must say so --- + _b_run1 = {"reviewer_response": {}, "reconciliation_result": {"review_id": "b-1", "items": [ + {"finding_id": "F1", "state": "resolved", "resolution": "Operator chose repair A over B.", + "escalation": {"dispute_kind": "value_or_priority_tradeoff", + "stop_reason": "operator_authority_required", "operator_question": "A or B?"}}, + {"finding_id": "F2", "state": "accepted"}]}} + _b_out1 = report.render(_b_run1) + check("decided by you" in _b_out1 and "you decided" in _b_out1 + and "Nothing is waiting on you" in _b_out1 and "Nothing needed you" not in _b_out1, + "report: operator-decided (resolved + escalation) item is credited, not counted as autonomous") + _b_run2 = {"reviewer_response": {}, "reconciliation_result": {"review_id": "b-2", "items": [ + {"finding_id": "F1", "state": "resolved", "resolution": "host fix"}, + {"finding_id": "F2", "state": "accepted"}]}} + _b_out2 = report.render(_b_run2) + check("Nothing needed you โ€” the models settled all 2 between themselves." in _b_out2 + and "decided by you" not in _b_out2, + "report: genuinely-autonomous run still says 'Nothing needed you'") + _b_run3 = {"reviewer_response": {}, "reconciliation_result": {"review_id": "b-3", "items": [ + {"finding_id": "F1", "state": "deadlocked", + "escalation": {"dispute_kind": "x", "stop_reason": "y", "operator_question": "q1?"}}, + {"finding_id": "F2", "state": "resolved", "resolution": "op ruling", + "escalation": {"dispute_kind": "z", "stop_reason": "w", "operator_question": "q2?"}}]}} + _b_out3 = report.render(_b_run3) + # Mixed case: pending deadlock AND an operator-decided item. The footer stays on the pending + # branch, but must CREDIT the operator ("you decided 1") โ€” not attribute their ruling to the + # models. The footer is the last rendered line; "you decided" appears only there (not the tally). + _b_foot3 = _b_out3.splitlines()[-1] + check("decision(s) need you" in _b_out3 and "escalated to you" in _b_out3 + and "decided by you" in _b_out3 + and "you decided 1" in _b_foot3 and "decision(s) need you" in _b_foot3, + "report: a deadlock still takes footer precedence over an operator-decided item") + # --- lifetime recap: aggregate value across reconciled runs (isolated config dir) --- recap_dir = tempfile.mkdtemp(prefix="impasse-recap-") _prev_cfg = os.environ["IMPASSE_CONFIG_DIR"]