Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <question>`
or `STOP: BLOCKED <reason>`, 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.
Expand Down
40 changes: 40 additions & 0 deletions opencode_hawk/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <question> or STOP: BLOCKED <reason> 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
Expand Down Expand Up @@ -1202,6 +1214,14 @@ def evaluate(cfg, session, parts, messages, state):
_FRACTION = re.compile(r"(?<![\w/.])(\d{1,4})\s*/\s*(\d{1,4})(?![\w/.])")


_HUMAN_STOP = re.compile(r"STOP:\s*(NEEDS_DECISION|BLOCKED)\b[^\n]*")
# A line that reports something as finished counts as open only with a clear open signal; a word like "gaps"
# inside a completed item's name does not ("| S1 | ✅ complete | Shipable gaps (8 phases) |", 2026-10-06).
_COMPLETE = re.compile(r"\u2705|\u2714|\b(complete|completed|done|passed|pass|verified|committed|fixed)\b", re.I)
_STRONG_OPEN = re.compile(r"\u274c|\u26a0|\b(not|missing|pending|partial(?:ly)?|remaining|blocked|limitations?|"
r"deferred|skipped|todo|unverified|incomplete|failed|failing)\b", re.I)


def done_open_items(text: str, limit: int = 6) -> list:
"""The lines of a done message that still name open work (max `limit`)."""
found = []
Expand All @@ -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:
Expand Down Expand Up @@ -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.
Expand Down
34 changes: 34 additions & 0 deletions tests/selftest_coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading