From fb9620889238d6aa9c6844c21a86c9b677c14bd0 Mon Sep 17 00:00:00 2001 From: shunsuke-hikiyama <264541274+shunsuke-hikiyama@users.noreply.github.com> Date: Mon, 18 May 2026 20:43:56 -0700 Subject: [PATCH] feat(kanban): add initial-status for human-ops cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Salvages #27526 by @shunsuke-hikiyama. Adds an --initial-status flag (running|blocked, default running) to 'kanban create', threaded through kanban_db.create_task() and the kanban_create tool schema. 'blocked' parks the task directly in the blocked column for R3 human-ops review, skipping the brief running-to-blocked transition. Dropped the unrelated 'add' alias, WIFEXITED Windows compat, and slash-handler error formatting changes that were bundled in the original PR — those should ship as their own focused changes if still wanted. --- hermes_cli/kanban.py | 7 +++++++ hermes_cli/kanban_db.py | 29 +++++++++++++++++++++-------- tools/kanban_tools.py | 12 ++++++++++++ 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py index a85636cf8b9..e23d036b379 100644 --- a/hermes_cli/kanban.py +++ b/hermes_cli/kanban.py @@ -305,6 +305,12 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu "two retries. Omit to use the dispatcher's " "kanban.failure_limit config " f"(default {kb.DEFAULT_FAILURE_LIMIT}).") + p_create.add_argument("--initial-status", + choices=sorted(kb.VALID_INITIAL_STATUSES), + default="running", + 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("--json", action="store_true", help="Emit JSON output") # --- list --- @@ -1173,6 +1179,7 @@ def _cmd_create(args: argparse.Namespace) -> int: max_runtime_seconds=max_runtime, skills=getattr(args, "skills", None) or None, max_retries=max_retries, + initial_status=getattr(args, "initial_status", "running"), ) task = kb.get_task(conn, task_id) if getattr(args, "json", False): diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 56e01739df1..e72dae65d0f 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -92,6 +92,7 @@ from toolsets import get_toolset_names # --------------------------------------------------------------------------- VALID_STATUSES = {"triage", "todo", "scheduled", "ready", "running", "blocked", "done", "archived"} +VALID_INITIAL_STATUSES = {"running", "blocked"} VALID_WORKSPACE_KINDS = {"scratch", "worktree", "dir"} KNOWN_TOOLSET_NAMES = frozenset(name.casefold() for name in get_toolset_names()) _IS_WINDOWS = sys.platform == "win32" @@ -1307,6 +1308,7 @@ def create_task( max_runtime_seconds: Optional[int] = None, skills: Optional[Iterable[str]] = None, max_retries: Optional[int] = None, + initial_status: str = "running", board: Optional[str] = None, ) -> str: """Create a new task and optionally link it under parent tasks. @@ -1336,6 +1338,10 @@ def create_task( assignee = _canonical_assignee(assignee) if not title or not title.strip(): raise ValueError("title is required") + if initial_status not in VALID_INITIAL_STATUSES: + raise ValueError( + f"initial_status must be one of {sorted(VALID_INITIAL_STATUSES)}" + ) if workspace_kind not in VALID_WORKSPACE_KINDS: raise ValueError( f"workspace_kind must be one of {sorted(VALID_WORKSPACE_KINDS)}, " @@ -1419,12 +1425,19 @@ def create_task( task_id = _new_task_id() try: with write_txn(conn): - # Determine initial status from parent status, unless the - # caller is parking this task in triage for a specifier. - if triage: - initial_status = "triage" + # Determine task status from parent status, unless the caller + # parks it directly in blocked for human-ops review or in + # triage for a specifier. + if initial_status == "blocked": + task_status = "blocked" + if parents: + missing = _find_missing_parents(conn, parents) + if missing: + raise ValueError(f"unknown parent task(s): {', '.join(missing)}") + elif triage: + task_status = "triage" else: - initial_status = "ready" + task_status = "ready" if parents: missing = _find_missing_parents(conn, parents) if missing: @@ -1436,7 +1449,7 @@ def create_task( parents, ).fetchall() if any(r["status"] != "done" for r in rows): - initial_status = "todo" + task_status = "todo" # Even in triage mode we still need to validate parent ids # so the eventual link rows don't dangle. if triage and parents: @@ -1458,7 +1471,7 @@ def create_task( title.strip(), body, assignee, - initial_status, + task_status, priority, created_by, now, @@ -1482,7 +1495,7 @@ def create_task( "created", { "assignee": assignee, - "status": initial_status, + "status": task_status, "parents": list(parents), "tenant": tenant, "skills": list(skills_list) if skills_list else None, diff --git a/tools/kanban_tools.py b/tools/kanban_tools.py index 8f0f8334b64..e23d86b8e91 100644 --- a/tools/kanban_tools.py +++ b/tools/kanban_tools.py @@ -632,6 +632,7 @@ def _handle_create(args: dict, **kw) -> str: return tool_error(bool_error) idempotency_key = args.get("idempotency_key") max_runtime_seconds = args.get("max_runtime_seconds") + initial_status = args.get("initial_status") or "running" skills = args.get("skills") if isinstance(skills, str): # Accept a single skill name as a string for convenience. @@ -666,6 +667,7 @@ def _handle_create(args: dict, **kw) -> str: if max_runtime_seconds is not None else None ), skills=skills, + initial_status=str(initial_status), created_by=os.environ.get("HERMES_PROFILE") or "worker", ) new_task = kb.get_task(conn, new_tid) @@ -1077,6 +1079,16 @@ KANBAN_CREATE_SCHEMA = { "task with outcome='timed_out'." ), }, + "initial_status": { + "type": "string", + "enum": ["running", "blocked"], + "description": ( + "Initial card status. Use 'blocked' for tasks that " + "require immediate human ops (R3 gate) to skip the " + "brief running-to-blocked transition. Defaults to " + "'running', which preserves the usual dispatch path." + ), + }, "skills": { "type": "array", "items": {"type": "string"},