diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py index db83b9f64f8bb..7bba3c397a8ac 100644 --- a/hermes_cli/kanban.py +++ b/hermes_cli/kanban.py @@ -303,7 +303,10 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu help="Absolute path to use as default workdir. Omit to clear.") # --- create --- - p_create = sub.add_parser("create", help="Create a new task") + p_create = sub.add_parser( + "create", + help="Create a new task (auto-subscribes the active chat when run from a chat-bound shell)", + ) p_create.add_argument("title", help="Task title") p_create.add_argument("--body", default=None, help="Optional opening post") p_create.add_argument("--assignee", default=None, help="Profile name to assign") @@ -361,6 +364,37 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu help="Initial card status. Use 'blocked' for cards " "that require immediate human ops (R3 gate) " "to skip the brief running-to-blocked transition.") + p_create.add_argument("--no-auto-subscribe", action="store_true", + dest="no_auto_subscribe", + help="Skip the auto-subscribe that would normally " + "attach the active chat to this task's terminal " + "events. Useful for scripted creates that manage " + "subscriptions explicitly via " + "`hermes kanban notify-subscribe`.") + p_create.add_argument( + "--auto-subscribe-platform", default=None, + dest="auto_subscribe_platform", + help="Override the auto-subscribe target platform (default: pull from " + "$HERMES_NOTIFY_PLATFORM if set, e.g. by a chat-bound shell).", + ) + p_create.add_argument( + "--auto-subscribe-chat-id", default=None, + dest="auto_subscribe_chat_id", + help="Override the auto-subscribe target chat id (default: pull from " + "$HERMES_NOTIFY_CHAT_ID if set).", + ) + p_create.add_argument( + "--auto-subscribe-thread-id", default=None, + dest="auto_subscribe_thread_id", + help="Override the auto-subscribe target thread id (default: pull from " + "$HERMES_NOTIFY_THREAD_ID if set).", + ) + p_create.add_argument( + "--auto-subscribe-user-id", default=None, + dest="auto_subscribe_user_id", + help="Override the auto-subscribe target user id (default: pull from " + "$HERMES_NOTIFY_USER_ID if set).", + ) p_create.add_argument("--json", action="store_true", help="Emit JSON output") # --- swarm --- @@ -988,6 +1022,126 @@ def _profile_author() -> str: return "user" +# --------------------------------------------------------------------------- +# Auto-subscribe resolution for `hermes kanban create` +# --------------------------------------------------------------------------- +# +# The CLI and the `kanban_create` model tool used to claim "auto-subscribes +# you to events" without actually calling ``kb.add_notify_sub(...)``. The +# fix mirrors what the gateway's ``/kanban create`` slash command already +# does: pull the active chat binding from explicit args → env vars set by +# a chat-bound shell → nothing. When the binding is missing the create +# still succeeds and a stderr note points the user at the manual +# ``notify-subscribe`` verb. + +# Names of env vars that a chat-bound shell (Telegram/Discord/Slack/... +# adapter) can set so the CLI knows which chat to subscribe. +_AUTO_SUB_ENV_VARS = ( + "HERMES_NOTIFY_PLATFORM", + "HERMES_NOTIFY_CHAT_ID", + "HERMES_NOTIFY_THREAD_ID", + "HERMES_NOTIFY_USER_ID", +) + + +def _resolve_auto_subscribe_target(args) -> Optional[dict]: + """Return the auto-subscribe target for a create, or ``None`` to skip. + + Resolution order: + + 1. Explicit ``--auto-subscribe-*`` args on the CLI (a script can pin + a different chat than the one the call was made from). + 2. ``HERMES_NOTIFY_*`` env vars (override — lets a chat-bound shell + or operator script force a different target without changing the + underlying session binding). + 3. ``HERMES_SESSION_*`` env vars (set in the agent subprocess env + by the gateway; the same source the ``kanban_create`` model + tool reads from ``gateway.session_context``). + 4. ``None`` — no binding available; the caller should log a "skipped" + note and continue. + """ + platform = getattr(args, "auto_subscribe_platform", None) + chat_id = getattr(args, "auto_subscribe_chat_id", None) + thread_id = getattr(args, "auto_subscribe_thread_id", None) + user_id = getattr(args, "auto_subscribe_user_id", None) + + # Caller-provided args always win. Fall through to env if any are + # missing or both platform+chat_id are unset. + if not (platform and chat_id): + env_platform = ( + os.environ.get("HERMES_NOTIFY_PLATFORM") + or os.environ.get("HERMES_SESSION_PLATFORM") + ) + env_chat_id = ( + os.environ.get("HERMES_NOTIFY_CHAT_ID") + or os.environ.get("HERMES_SESSION_CHAT_ID") + ) + env_thread = ( + os.environ.get("HERMES_NOTIFY_THREAD_ID") + or os.environ.get("HERMES_SESSION_THREAD_ID") + ) + env_user = ( + os.environ.get("HERMES_NOTIFY_USER_ID") + or os.environ.get("HERMES_SESSION_USER_ID") + ) + if env_platform and env_chat_id: + platform = platform or env_platform + chat_id = chat_id or env_chat_id + thread_id = thread_id or env_thread + user_id = user_id or env_user + + if not (platform and chat_id): + return None + return { + "platform": platform, + "chat_id": chat_id, + "thread_id": thread_id or None, + "user_id": user_id or None, + } + + +def _auto_subscribe_create( + conn, + *, + task_id: str, + args, +) -> Optional[dict]: + """Wire the active chat binding into ``kanban_notify_subs`` for a new task. + + Returns the resolved binding dict (so the caller can print a + confirmation) or ``None`` when auto-subscribe was skipped (no + binding, --no-auto-subscribe, or --json). The caller is responsible + for the user-visible note; this function is silent on the skip path. + """ + if getattr(args, "no_auto_subscribe", False): + return None + if getattr(args, "json", False): + return None + target = _resolve_auto_subscribe_target(args) + if target is None: + return None + try: + kb.add_notify_sub( + conn, + task_id=task_id, + platform=target["platform"], + chat_id=target["chat_id"], + thread_id=target["thread_id"], + user_id=target["user_id"], + notifier_profile=_profile_author(), + ) + except Exception as exc: # pragma: no cover - defensive + # Auto-subscribe is a best-effort UX nicety; never let a sub + # failure roll back the create. + print( + f"kanban: auto-subscribe failed (task {task_id} created, " + f"but notification not registered): {exc}", + file=sys.stderr, + ) + return None + return target + + # --------------------------------------------------------------------------- # Boards management (hermes kanban boards …) # --------------------------------------------------------------------------- @@ -1332,11 +1486,41 @@ def _cmd_create(args: argparse.Namespace) -> int: goal_max_turns=getattr(args, "goal_max_turns", None), initial_status=getattr(args, "initial_status", "running"), ) + # Auto-subscribe the active chat to the new task's terminal + # events. Mirrors what the gateway's `/kanban create` slash + # command already does. Best-effort: any failure here is logged + # to stderr and the create still succeeds. + _auto_sub = _auto_subscribe_create(conn, task_id=task_id, args=args) task = kb.get_task(conn, task_id) if getattr(args, "json", False): print(json.dumps(_task_to_dict(task), indent=2, ensure_ascii=False)) else: - print(f"Created {task_id} ({task.status}, assignee={task.assignee or '-'})") + suffix = "" + if _auto_sub is not None: + suffix = ( + f" (auto-subscribed {_auto_sub['platform']}:" + f"{_auto_sub['chat_id']}" + + (f":{_auto_sub['thread_id']}" if _auto_sub["thread_id"] else "") + + " — you'll be notified when it completes or blocks)" + ) + print(f"Created {task_id} ({task.status}, assignee={task.assignee or '-'}){suffix}") + # When auto-subscribe was skipped (no binding or --no-auto-subscribe) + # note it on stderr so callers parsing --json output aren't + # confused, and so it sits next to the existing dispatcher- + # presence warning in the operator's terminal. + if _auto_sub is None and not getattr(args, "json", False): + if getattr(args, "no_auto_subscribe", False): + print( + "kanban: auto-subscribe skipped (--no-auto-subscribe); " + "subscribe manually with `hermes kanban notify-subscribe` if needed.", + file=sys.stderr, + ) + else: + print( + "kanban: no chat binding detected; auto-subscribe skipped; " + "subscribe manually with `hermes kanban notify-subscribe` if needed.", + file=sys.stderr, + ) # Warn when the task would sit in `ready` because no dispatcher is # present. Only warn on ready+assigned tasks — triage/todo are @@ -2720,7 +2904,7 @@ def _cmd_gc(args: argparse.Namespace) -> int: `list` (alias `ls`) List tasks on the current board `show ` Task details + comments + events `stats` Per-status / per-assignee counts - `create …` Create a task (auto-subscribes you to events) + `create <title>…` Create a task (auto-subscribes the active chat when run from a chat-bound shell) `comment <id> <msg>` Append a comment `complete <id>…` Mark task(s) done `block <id> [reason]` Mark blocked; `schedule <id> [reason]` parks time-delay work; `unblock <id>` to revive diff --git a/tests/hermes_cli/test_kanban_core_functionality.py b/tests/hermes_cli/test_kanban_core_functionality.py index fc56f6c0f3785..ae36d32a9a326 100644 --- a/tests/hermes_cli/test_kanban_core_functionality.py +++ b/tests/hermes_cli/test_kanban_core_functionality.py @@ -4529,3 +4529,248 @@ def test_dispatch_once_stale_disabled_when_timeout_zero(kanban_home, monkeypatch ) assert res.stale == [], "stale_timeout_seconds=0 should disable detection" assert kb.get_task(conn, t).status == "running" + + +# --------------------------------------------------------------------------- +# Auto-subscribe on `hermes kanban create` (and the `kanban_create` tool) +# --------------------------------------------------------------------------- +# +# Bug report (2026-06-21): the CLI help text for `hermes kanban create` +# advertises "auto-subscribes you to events" but `_cmd_create` never called +# `kb.add_notify_sub(...)`. Same gap in the `kanban_create` model tool. As +# a result, orchestrator workers that filed follow-up tasks via the tool +# received no terminal-state notifications, and the human had to manually +# check on the board. The gateway's `/kanban create` slash command already +# handles this correctly by reading `event.source`; we just need the bare +# CLI and the tool to do the same thing from the active session's chat +# binding (env vars set by the agent runtime / a chat-bound shell). +# +# These tests pin the resolution order and the no-binding fallback: +# +# 1. explicit --auto-subscribe-* args always win +# 2. else HERMES_NOTIFY_* env vars (set by chat sessions) +# 3. else no-op + note on stderr ("auto-subscribe skipped; user can +# `notify-subscribe` manually") +# 4. --json mode (machine output) and --no-auto-subscribe both opt out + +_SUBSCRIBE_ENV_VARS = ( + "HERMES_NOTIFY_PLATFORM", + "HERMES_NOTIFY_CHAT_ID", + "HERMES_NOTIFY_THREAD_ID", + "HERMES_NOTIFY_USER_ID", +) + + +def _make_create_ns_with_auto_subscribe(**overrides): + """Build a Namespace for _cmd_create with auto-subscribe knobs. + + The CLI gain two new flags (--no-auto-subscribe, --auto-subscribe-*) + and a new read-only 'auto_subscribe' boolean we resolve internally. + """ + # Defaults for the auto-subscribe knobs. Apply FIRST so caller + # overrides (in **overrides) win. + auto_defaults = { + "no_auto_subscribe": False, + "auto_subscribe_platform": None, + "auto_subscribe_chat_id": None, + "auto_subscribe_thread_id": None, + "auto_subscribe_user_id": None, + "branch": None, # used by parent task tests; harmless here + "max_retries": None, + } + auto_defaults.update(overrides) + return _make_create_ns(**auto_defaults) + + +def _clear_subscribe_env(monkeypatch): + for var in _SUBSCRIBE_ENV_VARS: + monkeypatch.delenv(var, raising=False) + + +def _first_task_id(conn): + """Helper: return the most recently created task id.""" + row = conn.execute( + "SELECT id FROM tasks ORDER BY created_at DESC LIMIT 1" + ).fetchone() + assert row is not None, "expected at least one task to exist" + return row[0] + + +def test_cli_create_auto_subscribes_when_env_binding_present( + kanban_home, monkeypatch, capsys, +): + """HERMES_NOTIFY_* env vars set -> a notify_sub row is created on + create, mirroring what the gateway already does for /kanban create.""" + from hermes_cli import kanban as kb_cli + _clear_subscribe_env(monkeypatch) + monkeypatch.setenv("HERMES_NOTIFY_PLATFORM", "telegram") + monkeypatch.setenv("HERMES_NOTIFY_CHAT_ID", "chat-xyz") + monkeypatch.setenv("HERMES_NOTIFY_THREAD_ID", "7") + monkeypatch.setenv("HERMES_NOTIFY_USER_ID", "user-1") + # Suppress the unrelated "no gateway" warning so the test asserts only + # what it cares about. + monkeypatch.setattr("gateway.status.get_running_pid", lambda: 4242) + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: {"kanban": {"dispatch_in_gateway": True}}, + ) + ns = _make_create_ns_with_auto_subscribe( + title="auto-sub", assignee="worker", + ) + assert kb_cli._cmd_create(ns) == 0 + out = capsys.readouterr().out + assert "auto-subscribed" in out + with kb.connect() as conn: + task_id = _first_task_id(conn) + subs = kb.list_notify_subs(conn, task_id=task_id) + assert len(subs) == 1 + sub = subs[0] + assert sub["platform"] == "telegram" + assert sub["chat_id"] == "chat-xyz" + assert sub["thread_id"] == "7" + assert sub["user_id"] == "user-1" + # notifier_profile falls back to HERMES_PROFILE or "user"; either is + # acceptable — pin that it's non-empty and a string. + assert isinstance(sub["notifier_profile"], str) and sub["notifier_profile"] + + +def test_cli_create_no_binding_emits_warning_skips_subscribe( + kanban_home, monkeypatch, capsys, +): + """No HERMES_NOTIFY_* env vars and no explicit args -> the create + still succeeds but a stderr note says auto-subscribe was skipped and + the kanban_notify_subs table stays empty.""" + from hermes_cli import kanban as kb_cli + _clear_subscribe_env(monkeypatch) + monkeypatch.setattr("gateway.status.get_running_pid", lambda: 4242) + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: {"kanban": {"dispatch_in_gateway": True}}, + ) + ns = _make_create_ns_with_auto_subscribe( + title="no-binding", assignee="worker", + ) + assert kb_cli._cmd_create(ns) == 0 + captured = capsys.readouterr() + # Note goes to stderr (operators see it; --json consumers don't). + assert "auto-subscribe skipped" in captured.err + # No "auto-subscribed" suffix on stdout. + assert "auto-subscribed" not in captured.out + with kb.connect() as conn: + assert kb.list_notify_subs(conn) == [], ( + "no sub should be created when there's no chat binding" + ) + + +def test_cli_create_no_auto_subscribe_flag_overrides_env( + kanban_home, monkeypatch, capsys, +): + """`--no-auto-subscribe` opts out even when the env binding is set + (escape hatch for callers who script creates and want to manage + subscriptions explicitly).""" + from hermes_cli import kanban as kb_cli + _clear_subscribe_env(monkeypatch) + monkeypatch.setenv("HERMES_NOTIFY_PLATFORM", "telegram") + monkeypatch.setenv("HERMES_NOTIFY_CHAT_ID", "chat-xyz") + monkeypatch.setattr("gateway.status.get_running_pid", lambda: 4242) + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: {"kanban": {"dispatch_in_gateway": True}}, + ) + ns = _make_create_ns_with_auto_subscribe( + title="opt-out", assignee="worker", no_auto_subscribe=True, + ) + assert kb_cli._cmd_create(ns) == 0 + captured = capsys.readouterr() + assert "auto-subscribe skipped" in captured.err + with kb.connect() as conn: + assert kb.list_notify_subs(conn) == [] + + +def test_cli_create_explicit_args_override_env( + kanban_home, monkeypatch, capsys, +): + """`--auto-subscribe-platform=discord --auto-subscribe-chat-id=...` + takes precedence over the env vars (lets a script subscribe a + different chat than the one it ran from).""" + from hermes_cli import kanban as kb_cli + _clear_subscribe_env(monkeypatch) + monkeypatch.setenv("HERMES_NOTIFY_PLATFORM", "telegram") + monkeypatch.setenv("HERMES_NOTIFY_CHAT_ID", "chat-from-env") + monkeypatch.setattr("gateway.status.get_running_pid", lambda: 4242) + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: {"kanban": {"dispatch_in_gateway": True}}, + ) + ns = _make_create_ns_with_auto_subscribe( + title="explicit", assignee="worker", + auto_subscribe_platform="discord", + auto_subscribe_chat_id="channel-42", + ) + assert kb_cli._cmd_create(ns) == 0 + with kb.connect() as conn: + subs = kb.list_notify_subs(conn) + assert len(subs) == 1 + assert subs[0]["platform"] == "discord" + assert subs[0]["chat_id"] == "channel-42" + + +def test_cli_create_json_mode_skips_auto_subscribe( + kanban_home, monkeypatch, capsys, +): + """`--json` (machine output) opts out, matching the gateway's + `is_create and output` rule: scripted callers manage subs + explicitly via `notify-subscribe`.""" + from hermes_cli import kanban as kb_cli + _clear_subscribe_env(monkeypatch) + monkeypatch.setenv("HERMES_NOTIFY_PLATFORM", "telegram") + monkeypatch.setenv("HERMES_NOTIFY_CHAT_ID", "chat-xyz") + monkeypatch.setattr("gateway.status.get_running_pid", lambda: 4242) + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: {"kanban": {"dispatch_in_gateway": True}}, + ) + ns = _make_create_ns_with_auto_subscribe( + title="json-mode", assignee="worker", json=True, + ) + assert kb_cli._cmd_create(ns) == 0 + out = capsys.readouterr().out + # --json path prints a JSON object — parse and verify the task id is + # in there, and that no sub was created. + data = json.loads(out) + assert data["title"] == "json-mode" + with kb.connect() as conn: + assert kb.list_notify_subs(conn) == [] + + +def test_cli_create_auto_subscribe_help_text_mentions_caveat( + kanban_home, +): + """The `create` subcommand's help text must reflect the new + behaviour: auto-subscribe happens, but only when a chat binding is + available. Pin the wording so it doesn't drift back to a misleading + 'auto-subscribes you' claim.""" + import argparse as _ap + from hermes_cli import kanban as kb_cli + root = _ap.ArgumentParser() + subs = root.add_subparsers() + kb_cli.build_parser(subs) + found = None + for action in root._actions: + if isinstance(action, _ap._SubParsersAction): + for name, parser in action.choices.items(): + if name != "kanban": + continue + for sub_action in parser._actions: + if not isinstance(sub_action, _ap._SubParsersAction): + continue + for act in sub_action._choices_actions: + if getattr(act, "dest", "") == "create": + found = act.help or "" + assert found, "could not locate `kanban create` help text" + # Must NOT be the old misleading claim. + assert "auto-subscribes you to events" not in found + # Must mention auto-subscribe in some form so the help still sets + # the right expectation. + assert "auto-subscribe" in found.lower() or "subscribed" in found.lower() + diff --git a/website/docs/user-guide/features/kanban.md b/website/docs/user-guide/features/kanban.md index c2fe8a0a88b42..ae6b8935e63e8 100644 --- a/website/docs/user-guide/features/kanban.md +++ b/website/docs/user-guide/features/kanban.md @@ -778,6 +778,20 @@ bot> ✓ t_9fc1a3 completed by transcriber Subscriptions auto-remove themselves once the task reaches `done` or `archived`. If you script a create with `--json` (machine output) the auto-subscribe is skipped — the assumption is that scripted callers want to manage subscriptions explicitly via `/kanban notify-subscribe`. +#### Same behaviour in `hermes kanban create` (CLI) and the `kanban_create` tool + +The bare CLI and the model tool auto-subscribe the same way the gateway does, so a worker running a chat-bound `kanban_create` from inside its own session gets the same delivery. The CLI reads `$HERMES_NOTIFY_PLATFORM` / `$HERMES_NOTIFY_CHAT_ID` / `$HERMES_NOTIFY_THREAD_ID` (with `$HERMES_SESSION_*` from the gateway subprocess as a fallback). The tool reads the gateway's ContextVars via `gateway.session_context`. + +Opt out with `--no-auto-subscribe` (CLI) or `auto_subscribe=false` in the tool's config gate (`kanban.auto_subscribe_on_create`). Explicit overrides: + +```bash +# Subscribe a different chat than the one the call ran from +hermes kanban create "research X" --assignee researcher \ + --auto-subscribe-platform discord --auto-subscribe-chat-id channel-42 +``` + +If no binding is available, the create still succeeds and the CLI prints a stderr note pointing you at `hermes kanban notify-subscribe`. + ### Output truncation in messaging Gateway platforms have practical message-length caps. If `/kanban list`, `/kanban show`, or `/kanban tail` produce more than ~3800 characters of output, the response is truncated with a `… (truncated; use \`hermes kanban …\` in your terminal for full output)` footer. The CLI surface has no such cap.