From 7b10d805fc007daa8586a200988df8c624d53535 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:35:22 +0200 Subject: [PATCH] Decisions go to the user; finished items are not open because of a word 2026-10-06 HiddenSync run: - lead stopped with STOP: NEEDS_DECISION (implement WGC capture or accept the gap?); Hawk sent no question and 21 min later injected its standard prompt ("The user has approved all pending plans"), which settled the scope decision in the user's place. Now NEEDS_DECISION / BLOCKED is sent to the user once (ntfy, answerable by reply) and nothing is injected until an answer. - done claims were sent back for "| S1 | complete | Shipable gaps (8 phases) |": a line that reports a finished item counts as open only with a clear open signal (a count below its total, not/pending/partial/limitation, a cross or warning sign). Co-Authored-By: Claude Opus 5.5 --- README.md | 3 +++ opencode_hawk/coordinator.py | 40 +++++++++++++++++++++++++++++++++++ tests/selftest_coordinator.py | 34 +++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+) diff --git a/README.md b/README.md index a07cdcf..2a744ba 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,9 @@ Hawk keeps a run going without blindly spamming "continue". screens", "remaining items", "deferred", "known gaps"), Hawk sends those back and the agent keeps working. The confirmation asks for every plan item with its evidence. +- **Decisions go to you.** When the agent stops with `STOP: NEEDS_DECISION ` + or `STOP: BLOCKED `, Hawk sends it to your phone and sends nothing into the + session until you answer (reply in the ntfy app); it never answers in your place. - **Permissions and questions.** It auto-accepts external-directory permission prompts and answers question-tool prompts (choosing the "(Recommended)" option) for sessions you mark as monitored. diff --git a/opencode_hawk/coordinator.py b/opencode_hawk/coordinator.py index b75104e..58f7191 100644 --- a/opencode_hawk/coordinator.py +++ b/opencode_hawk/coordinator.py @@ -1134,6 +1134,18 @@ def evaluate(cfg, session, parts, messages, state): state.pop("done_rejects", None) state.pop("rejected_done_mid", None) + # ── A question for the human ───────────────────────────────────────────── + # The prompts tell the worker to stop with STOP: NEEDS_DECISION or STOP: BLOCKED only + # for what a human must settle. Ask the human (ntfy, answerable by reply) once, then send nothing until a + # reply (or other new input) arrives: a self-check prompt here would answer the question in the human's + # place (2026-10-06: "The user has approved all pending plans" settled a scope decision nobody had seen). + ask = _HUMAN_STOP.search(a["last_assistant"] or "") + # Only while the question is the last word: an answer (an ntfy reply) after it means the worker continues. + if ask and last_mid and messages and messages[-1].get("role") == "assistant": + if state.get("asked_mid") == last_mid: + return "nothing", "waiting for the user's answer (%s)" % ask.group(1), [] + return "ask_user", "worker needs the user: %s" % ask.group(0)[:200], [ask.group(0)[:300]] + # An unfinished turn that is still generating: leave it alone. A long single # generation may not write new parts for a while; only treat it as stopped # once it is both silent past the wedge threshold AND the model is not @@ -1202,6 +1214,14 @@ def evaluate(cfg, session, parts, messages, state): _FRACTION = re.compile(r"(? list: """The lines of a done message that still name open work (max `limit`).""" found = [] @@ -1211,6 +1231,8 @@ def done_open_items(text: str, limit: int = 6) -> list: continue short = any(int(a) < int(b) for a, b in _FRACTION.findall(line)) word = bool(_OPEN_WORDS.search(line)) and not _NEGATED.search(line) + if word and not short and _COMPLETE.search(line) and not _STRONG_OPEN.search(line): + word = False # a finished item whose text merely contains an open-sounding word if short or word: found.append(line.replace("**", "")[:200]) if len(found) >= limit: @@ -1530,6 +1552,24 @@ def apply_action(cfg, state, session, project_dir, parts, messages, action, pass return 7 if ok else 8 + if action == "ask_user": + last_mid = messages[-1]["id"] + if dry_run: + log("[dry-run] WOULD ASK THE USER: %s" % reason) + return 0 + target["asked_mid"] = last_mid + target["last_evaluated_mid"] = last_mid + save_state(state) + a = analyze(parts, messages) + path = write_escalation(session, project_dir, reason, rule_hits, a, cfg) + log("ASKED USER (no prompts until an answer): %s" % reason) + try: + from . import notify + notify.notify_escalation(cfg, session, reason, rule_hits, a, str(path), kind="escalation") + except Exception as e: + log("ask_user: notification failed: %s" % e) + return 9 + if action == "done": # Build plan finished — a POSITIVE stop, not an error. No further # self-checks; write a "complete" artifact and reset the counter. diff --git a/tests/selftest_coordinator.py b/tests/selftest_coordinator.py index da49e89..053c53c 100644 --- a/tests/selftest_coordinator.py +++ b/tests/selftest_coordinator.py @@ -319,6 +319,40 @@ def run_sustained(name, expect, slot_seq, gap=1, want_calls=None): print("%-22s expect=%-9s got=%-9s %s" % ("done_not_summary", "pass", "pass" if ok_sum else "fail", "OK" if ok_sum else "MISMATCH")) + # E12: STOP: NEEDS_DECISION / BLOCKED is put to the user, and then nothing is injected until an answer + parts, msgs = mkparts("Two options found.\nSTOP: NEEDS_DECISION Should I add WGC capture or accept the gap?") + run("needs_decision_asks", "ask_user", parts, msgs, {}) + parts, msgs = mkparts("Two options found.\nSTOP: NEEDS_DECISION Should I add WGC capture or accept the gap?") + run("needs_decision_waits", "nothing", parts, msgs, + {"asked_mid": "msg_test_final", "last_injected_at": int(time.time() * 1000) - 120 * 60 * 1000}) + parts, msgs = mkparts("STOP: BLOCKED the license key is missing") + run("blocked_asks", "ask_user", parts, msgs, {}) + # the user answered (a reply after the question): no second question + parts, msgs = mkparts("Two options.\nSTOP: NEEDS_DECISION A or B?") + _now_a = int(time.time() * 1000) + msgs.append({"id": "msg_user_answer", "time": _now_a - 400000, "role": "user", "time_data": {"created": _now_a - 400000}}) + parts.append({"time": _now_a - 400000, "message_id": "msg_user_answer", "role": "user", "type": "text", + "text": "Take B", "data": {"type": "text"}}) + _st_a = {"asked_mid": "msg_test_final"} + _act_a = None + _orig_idle_a = evaluate.__globals__["llama_idle"] + try: + evaluate.__globals__["llama_idle"] = (lambda c, s_, w=None: True) + _act_a = evaluate(mkcfg(mk_repo()), {"id": "ses_test", "title": "t"}, parts, msgs, _st_a)[0] + finally: + evaluate.__globals__["llama_idle"] = _orig_idle_a + ok_ans = _act_a != "ask_user" + results.append(("decision_answered", "not ask_user", str(_act_a), ok_ans, "an answer after the question", [])) + print("%-22s expect=%-9s got=%-9s %s" % ("decision_answered", "not ask", _act_a, "OK" if ok_ans else "MISMATCH")) + # E13: a completed line is not open because of a word in its name; with a clear open signal it is + ok_fp = (hawk.done_open_items("| S1 | \u2705 complete | Shipable gaps (8 phases) |\nSTOP: DONE") == [] + and len(hawk.done_open_items("GUI screenshots: 13/21 \u2705\n30 rows: 23 VERIFIED, 3 PARTIAL, 3 NOT VERIFIED\n" + "## Known Limitation (Documented)\nSTOP: DONE")) == 3) + results.append(("done_open_completed_lines", "pass", "pass" if ok_fp else "fail", ok_fp, + "finished items are not open because of a word; clear open signals still count", [])) + print("%-22s expect=%-9s got=%-9s %s" % ("done_open_completed_lines", "pass", "pass" if ok_fp else "fail", + "OK" if ok_fp else "MISMATCH")) + # E11: open tasks in the session's own task list block a done claim, even a clean-worded one orig_todos = evaluate.__globals__["open_todos"] try: