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: