feat(kanban): add initial-status for human-ops cards

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.
This commit is contained in:
shunsuke-hikiyama 2026-05-18 20:43:56 -07:00 committed by Teknium
parent e8ce7b83fa
commit fb96208892
3 changed files with 40 additions and 8 deletions

View file

@ -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):

View file

@ -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,

View file

@ -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"},