refactor(kanban-cli): extract data-driven argparse tree to kanban_parser and output helpers to kanban_output

This commit is contained in:
Teknium
2026-09-02 16:25:20 -07:00
parent 464ee35248
commit 62a1607fbf
3 changed files with 613 additions and 708 deletions

View File

@@ -15,54 +15,22 @@ import shlex
import sys
import time
from pathlib import Path
from typing import Any, Optional
from typing import Optional
from hermes_cli import kanban_db as kb
from hermes_cli import kanban_swarm as ks
from hermes_cli.kanban_output import (
_ATTACHMENT_FIELDS, _RUNS_RUN_FIELDS, _SHOW_RUN_FIELDS, _bulk_apply, _err,
_fmt_counts, _fmt_task_line, _fmt_ts, _json_out, _obj_dict, _print_json,
_task_to_dict,
)
from hermes_cli.kanban_parser import build_parser # noqa: F401 (re-exported: hermes_cli.main, run_slash)
# ---------------------------------------------------------------------------
# Small formatting helpers
# Flag parsing helpers
# ---------------------------------------------------------------------------
_STATUS_ICONS = {
"todo": "◻",
"ready": "▶",
"running": "●",
"scheduled":"⏱",
"blocked": "⊘",
"done": "✓",
"archived": "—",
}
def _fmt_ts(ts: Optional[int]) -> str:
if not ts:
return ""
return time.strftime("%Y-%m-%d %H:%M", time.localtime(ts))
def _print_json(obj: Any, *, ascii: bool = False) -> None:
print(json.dumps(obj, indent=2, ensure_ascii=ascii))
def _json_out(args: argparse.Namespace, obj: Any, *, ascii: bool = False) -> bool:
"""Print ``obj`` as JSON and return True when ``--json`` was passed."""
if not getattr(args, "json", False):
return False
_print_json(obj, ascii=ascii)
return True
def _fmt_counts(counts: dict, empty: str = "") -> str:
return ", ".join(f"{k}={v}" for k, v in sorted(counts.items())) or empty
def _err(msg: str, rc: int = 1) -> int:
print(msg, file=sys.stderr)
return rc
def _none_profile(value: str) -> Optional[str]:
"""``none`` / ``-`` / ``null`` mean "unassign"."""
return None if value.lower() in {"none", "-", "null"} else value
@@ -81,55 +49,6 @@ def _parse_metadata_flag(raw: Optional[str]) -> tuple[Optional[dict], int]:
return metadata, 0
def _bulk_apply(ids, op, ok_msg, fail_msg) -> int:
"""Run ``op(tid) -> bool`` per id, print ok/fail lines, exit 1 if any failed."""
failed = False
for tid in ids:
if not op(tid):
failed = True
print(fail_msg(tid), file=sys.stderr)
else:
print(ok_msg(tid))
return 1 if failed else 0
def _fmt_task_line(t: kb.Task) -> str:
icon = _STATUS_ICONS.get(t.status, "?")
assignee = t.assignee or "(unassigned)"
tenant = f" [{t.tenant}]" if t.tenant else ""
return f"{icon} {t.id} {t.status:8s} {assignee:20s}{tenant} {t.title}"
_TASK_DICT_FIELDS = (
"id", "title", "body", "assignee", "status", "priority", "tenant",
"workspace_kind", "workspace_path", "branch_name", "project_id",
"created_by", "created_at", "started_at", "completed_at", "result",
"skills", "max_retries", "model_override", "provider_override",
"session_id", "workflow_template_id", "current_step_key",
)
_SHOW_RUN_FIELDS = (
"id", "profile", "step_key", "status", "outcome", "summary", "error",
"metadata", "worker_pid", "started_at", "ended_at",
)
_RUNS_RUN_FIELDS = (
"id", "profile", "status", "outcome", "started_at", "ended_at",
"summary", "error", "metadata", "worker_pid", "step_key",
)
_ATTACHMENT_FIELDS = (
"id", "filename", "content_type", "size", "uploaded_by", "stored_path", "created_at",
)
def _obj_dict(obj: Any, fields: tuple[str, ...]) -> dict[str, Any]:
return {k: getattr(obj, k) for k in fields}
def _task_to_dict(t: kb.Task) -> dict[str, Any]:
d = _obj_dict(t, _TASK_DICT_FIELDS)
d["skills"] = list(t.skills) if t.skills else []
return d
def _run_state_kwargs(args: argparse.Namespace) -> Optional[dict[str, str]]:
st = getattr(args, "state_type", None)
sn = getattr(args, "state_name", None)
@@ -241,625 +160,6 @@ def _check_dispatcher_presence(
)
# ---------------------------------------------------------------------------
# Argparse builder
# ---------------------------------------------------------------------------
def _add_run_state_filters(p: argparse.ArgumentParser, type_help: str) -> None:
p.add_argument(
"--state-type",
choices=("status", "outcome"),
default=None,
help=f"With --state-name: {type_help}",
)
p.add_argument(
"--state-name",
default=None,
metavar="VALUE",
help="With --state-type: keep runs whose column equals this value",
)
def _add_triage_sweep_args(p: argparse.ArgumentParser, verb: str, Verb: str, noun: str) -> None:
"""Shared ``specify`` / ``decompose`` arguments."""
p.add_argument("task_id", nargs="?", default=None,
help=f"Task id to {verb} (required unless --all is given)")
p.add_argument("--all", dest="all_triage", action="store_true",
help=f"{Verb} every task currently in the triage column")
p.add_argument("--tenant", default=None,
help="When used with --all, restrict the sweep to this tenant")
p.add_argument("--author", default=None,
help="Author name recorded on the audit comment "
f"(default: $HERMES_PROFILE or '{noun}')")
p.add_argument("--json", action="store_true",
help="Emit one JSON object per task on stdout")
def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.ArgumentParser:
"""Attach the ``kanban`` subcommand tree; returns the ``kanban`` parser."""
kanban_parser = parent_subparsers.add_parser(
"kanban",
help="Multi-profile collaboration board (tasks, links, comments)",
description=(
"Durable SQLite-backed task board shared across Hermes profiles. "
"Tasks are claimed atomically, can depend on other tasks, and "
"are executed by a named profile in an isolated workspace. "
"See https://hermes-agent.nousresearch.com/docs/user-guide/features/kanban "
"or docs/hermes-kanban-v1-spec.pdf for the full design."
),
)
# --board scopes every subcommand to one board's DB; when omitted the
# resolution is HERMES_KANBAN_BOARD, then the persisted current-board
# file, then "default" (kanban_db.get_current_board()).
kanban_parser.add_argument("--board", default=None, metavar="<slug>",
help="Board slug to operate on. Defaults to the current board (set "
"via `hermes kanban boards switch <slug>` or the "
"HERMES_KANBAN_BOARD env var). Use `hermes kanban boards "
"list` to see all boards.")
sub = kanban_parser.add_subparsers(dest="kanban_action")
# --- init ---
sub.add_parser("init", help="Create kanban.db if missing (idempotent)")
# --- boards ---
p_boards = sub.add_parser(
"boards",
help="Manage kanban boards (one board per project / workstream)",
description=(
"Boards let you separate unrelated streams of work "
"(projects, repos, domains) into isolated queues. Each "
"board has its own DB, workspaces directory, and dispatcher "
"loop — tasks on one board cannot collide with tasks on "
"another. The first board is 'default' and always exists."
),
)
boards_sub = p_boards.add_subparsers(dest="boards_action")
b_list = boards_sub.add_parser("list", aliases=["ls"], help="List all boards with task counts")
b_list.add_argument("--json", action="store_true")
b_list.add_argument("--all", action="store_true", help="Include archived boards too")
b_create = boards_sub.add_parser("create", aliases=["new"], help="Create a new board")
b_create.add_argument("slug", help="Board slug (kebab-case, e.g. atm10-server)")
b_create.add_argument("--name", default=None,
help="Human-readable display name (defaults to Title Case of slug)")
b_create.add_argument("--description", default=None, help="Optional description")
b_create.add_argument("--icon", default=None,
help="Optional emoji or single-character icon for the dashboard")
b_create.add_argument("--color", default=None,
help="Optional hex color (e.g. '#8b5cf6') for the dashboard")
b_create.add_argument("--switch", action="store_true",
help="Switch to the new board after creating it")
b_create.add_argument("--default-workdir", default=None,
help="Default workspace path for tasks created on this board")
b_rm = boards_sub.add_parser("rm", aliases=["remove", "delete"],
help="Archive (default) or delete a board")
b_rm.add_argument("slug")
b_rm.add_argument("--delete", action="store_true",
help="Hard-delete the board directory instead of archiving it. "
"Default is to move it to boards/_archived/ so it's recoverable.")
b_switch = boards_sub.add_parser("switch", aliases=["use"],
help="Set the active board for subsequent CLI calls")
b_switch.add_argument("slug")
boards_sub.add_parser("show", aliases=["current"], help="Print the currently-active board slug")
b_rename = boards_sub.add_parser("rename",
help="Change a board's human-readable display name (slug is "
"immutable)")
b_rename.add_argument("slug")
b_rename.add_argument("name", help="New display name")
b_set_wd = boards_sub.add_parser("set-default-workdir",
help="Set the default workspace path for tasks on a board")
b_set_wd.add_argument("slug")
b_set_wd.add_argument("path", nargs="?", default=None,
help="Absolute path to use as default workdir. Omit to clear.")
b_export = boards_sub.add_parser(
"export",
help="Export a board to a portable .tar.gz archive",
description=(
"Package a board's tasks, comments, links, history, and file "
"attachments into one archive that can be imported on another "
"machine. Claims, worker PIDs, chat subscriptions, and paths "
"belonging to this machine are stripped. Workspaces are never "
"included — they are rebuilt on demand."
),
)
b_export.add_argument("slug", nargs="?", default=None,
help="Board to export (default: the current board)")
b_export.add_argument("-o", "--output", default=None,
help="Archive path (default: ./<slug>.tar.gz)")
b_export.add_argument("--no-attachments", action="store_true",
help="Skip attachment files, keeping the archive small")
b_export.add_argument("--include-logs", action="store_true",
help="Include per-task worker logs")
b_export.add_argument("--json", action="store_true")
b_import = boards_sub.add_parser(
"import",
help="Import a board archive as a new board",
description=(
"Import a .tar.gz produced by `hermes kanban boards export`. "
"The board always lands as a NEW board — the slug gains a "
"numeric suffix if it is already taken — so an import can "
"never overwrite or merge into a board you already have."
),
)
b_import.add_argument("archive", help="Path to the .tar.gz archive")
b_import.add_argument("--as", dest="as_slug", default=None,
help="Slug for the imported board (default: from the archive)")
b_import.add_argument("--switch", action="store_true",
help="Switch to the imported board afterwards")
b_import.add_argument("--json", action="store_true")
# --- create ---
p_create = sub.add_parser("create", help="Create a new task")
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")
p_create.add_argument("--parent", action="append", default=[],
help="Parent task id (repeatable)")
p_create.add_argument("--workspace", default="scratch",
help="scratch | worktree | worktree:<path> | dir:<path> "
"(default: scratch)")
p_create.add_argument("--branch", default=None,
help="Branch name for worktree tasks, e.g. wt/t6-wire")
p_create.add_argument("--project", default=None,
help="Link to a project (id or slug). Anchors the task's "
"worktree under the project's primary repo with a "
"deterministic branch. See `hermes project list`.")
p_create.add_argument("--tenant", default=None, help="Tenant namespace")
p_create.add_argument("--priority", type=int, default=0, help="Priority tiebreaker")
p_create.add_argument("--triage", action="store_true",
help="Park in triage — a specifier will flesh out the spec and promote to todo")
p_create.add_argument("--idempotency-key", default=None,
help="Dedup key. If a non-archived task with this key exists, "
"its id is returned instead of creating a duplicate.")
p_create.add_argument("--max-runtime", default=None,
help="Per-task runtime cap. Accepts seconds (300) or durations (90s, "
"30m, 2h, 1d). When exceeded, the dispatcher SIGTERMs (then "
"SIGKILLs) the worker and re-queues the task.")
p_create.add_argument("--created-by", default="user",
help="Author name recorded on the task (default: user)")
p_create.add_argument("--skill", action="append", default=[], dest="skills",
help="Skill to force-load into the worker (repeatable). The kanban "
"lifecycle is already injected automatically. Example: --skill "
"translation --skill github-code-review")
p_create.add_argument("--max-retries", type=int, default=None,
metavar="N",
help="Per-task override for the consecutive-failure "
"circuit breaker. Trip on the Nth failure — "
"e.g. --max-retries 1 blocks on the first "
"failure (no retries), --max-retries 3 allows "
"two retries. Omit to use the dispatcher's "
"kanban.failure_limit config "
f"(default {kb.DEFAULT_FAILURE_LIMIT}).")
p_create.add_argument("--model", default=None, dest="model_override",
help="Pin the worker to this model (passed as -m <model>) without "
"changing the profile's configured model. Combine with --provider "
"when the model belongs to a different backend than the profile's "
"default.")
p_create.add_argument("--provider", default=None, dest="provider_override",
help="Provider the --model belongs to (passed as --provider <name> to "
"the worker). Requires --model.")
p_create.add_argument("--goal", action="store_true", dest="goal_mode",
help="Run the worker in a goal loop: after each turn a judge checks the "
"response against the card title/body and, if not done, the worker "
"keeps going in the same session until the judge agrees it's "
"complete (or the turn budget runs out, which blocks the card for "
"review). Best for open-ended cards one shot rarely finishes.")
p_create.add_argument("--goal-max-turns", type=int, default=None,
metavar="N", dest="goal_max_turns",
help="Turn budget for --goal workers (default 20). "
"Ignored without --goal.")
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")
# --- swarm ---
p_swarm = sub.add_parser("swarm",
help="Create a Kanban Swarm v1 graph (parallel workers → verifier → "
"synthesizer)")
p_swarm.add_argument("goal", help="Swarm goal / final outcome")
p_swarm.add_argument(
"--worker",
action="append",
default=[],
metavar="PROFILE:TITLE[:SKILL,SKILL]",
help="Parallel worker card (repeatable)",
)
p_swarm.add_argument("--verifier", required=True, help="Verifier profile")
p_swarm.add_argument("--synthesizer", required=True, help="Synthesizer/writer profile")
p_swarm.add_argument("--tenant", default=None, help="Tenant namespace")
p_swarm.add_argument("--priority", type=int, default=0, help="Priority tiebreaker")
p_swarm.add_argument("--created-by", default=None, help="Creator/anchor profile")
p_swarm.add_argument("--idempotency-key", default=None, help="Dedup key for the root card")
p_swarm.add_argument("--json", action="store_true", help="Emit JSON output")
# --- list ---
p_list = sub.add_parser("list", aliases=["ls"], help="List tasks")
p_list.add_argument("--mine", action="store_true", help="Filter by $HERMES_PROFILE as assignee")
p_list.add_argument("--assignee", default=None)
p_list.add_argument("--status", default=None, choices=sorted(kb.VALID_STATUSES))
p_list.add_argument("--tenant", default=None)
p_list.add_argument("--session", default=None,
help="Filter by originating chat/agent session id "
"(set on tasks created from inside an ACP loop)")
p_list.add_argument("--archived", action="store_true", help="Include archived tasks")
p_list.add_argument("--json", action="store_true")
p_list.add_argument("--sort", default=None, choices=sorted(kb.VALID_SORT_ORDERS.keys()),
help="Sort order for listed tasks (default: priority)")
p_list.add_argument("--workflow-template-id", default=None, metavar="ID",
help="Restrict to tasks with this workflow_template_id")
p_list.add_argument("--step-key", default=None, dest="current_step_key", metavar="KEY",
help="Restrict to tasks with this current_step_key")
# --- show ---
p_show = sub.add_parser("show", help="Show a task with comments + events")
p_show.add_argument("task_id")
p_show.add_argument("--json", action="store_true")
_add_run_state_filters(p_show, "filter listed runs by task_runs column")
# --- assign ---
p_assign = sub.add_parser("assign", help="Assign or reassign a task")
p_assign.add_argument("task_id")
p_assign.add_argument("profile", help="Profile name (or 'none' to unassign)")
# --- set-model (per-task model/provider override) ---
p_set_model = sub.add_parser("set-model",
help="Set or clear a task's model/provider override (takes "
"effect on the next dispatch)")
p_set_model.add_argument("task_id")
p_set_model.add_argument("model", nargs="?", default=None,
help="Model to pin the worker to (or 'none' to clear the override)")
p_set_model.add_argument("--provider", default=None,
help="Provider the model belongs to (worker is spawned with "
"--provider <name>). Cleared together with the model.")
# --- reclaim / reassign (recovery) ---
p_reclaim = sub.add_parser("reclaim", help="Release an active worker claim on a running task")
p_reclaim.add_argument("task_id")
p_reclaim.add_argument("--reason", default=None,
help="Human-readable reason (recorded on the reclaimed event)")
p_reassign = sub.add_parser("reassign",
help="Reassign a task to a different profile, optionally "
"reclaiming first")
p_reassign.add_argument("task_id")
p_reassign.add_argument("profile", help="New profile name (or 'none' to unassign)")
p_reassign.add_argument("--reclaim", action="store_true",
help="Release any active claim before reassigning (required if task "
"is running)")
p_reassign.add_argument("--reason", default=None,
help="Human-readable reason (recorded on the reclaimed event)")
# --- diagnostics (board-wide health) ---
p_diag = sub.add_parser("diagnostics", aliases=["diag"],
help="List active diagnostics on the current board")
p_diag.add_argument("--severity", choices=["warning", "error", "critical"], default=None,
help="Only show diagnostics at or above this severity")
p_diag.add_argument("--task", default=None, help="Only show diagnostics for one task id")
p_diag.add_argument("--json", action="store_true",
help="Emit JSON (structured) instead of the default human table")
# --- link / unlink ---
p_link = sub.add_parser("link", help="Add a parent->child dependency")
p_link.add_argument("parent_id")
p_link.add_argument("child_id")
p_unlink = sub.add_parser("unlink", help="Remove a parent->child dependency")
p_unlink.add_argument("parent_id")
p_unlink.add_argument("child_id")
# --- claim ---
p_claim = sub.add_parser("claim",
help="Atomically claim a ready task (prints resolved workspace path)")
p_claim.add_argument("task_id")
p_claim.add_argument("--ttl", type=int, default=kb.DEFAULT_CLAIM_TTL_SECONDS,
help="Claim TTL in seconds (default: 900)")
# --- comment / complete / block / unblock / archive ---
p_comment = sub.add_parser("comment", help="Append a comment")
p_comment.add_argument("task_id")
p_comment.add_argument("text", nargs="+", help="Comment body")
p_comment.add_argument("--author", default=None,
help="Author name (default: $HERMES_PROFILE or 'user')")
p_comment.add_argument("--max-len", type=int, default=None,
help="Trim the stored comment body to this many characters")
# --- attach / attachments / attach-rm ---
p_attach = sub.add_parser("attach", help="Attach a local file to a task")
p_attach.add_argument("task_id")
p_attach.add_argument("path", help="Path to the local file to attach")
p_attach.add_argument("--content-type", default=None,
help="MIME type (default: guessed from the file extension)")
p_attach.add_argument("--name", default=None,
help="Stored filename (default: the source file's basename)")
p_attach.add_argument("--author", default=None,
help="uploaded_by label (default: $HERMES_PROFILE or 'user')")
p_attachments = sub.add_parser("attachments", help="List a task's attachments")
p_attachments.add_argument("task_id")
p_attachments.add_argument("--json", action="store_true")
p_attach_rm = sub.add_parser("attach-rm", help="Delete an attachment by id")
p_attach_rm.add_argument("attachment_id", type=int)
p_complete = sub.add_parser("complete", help="Mark one or more tasks done")
p_complete.add_argument("task_ids", nargs="+",
help="One or more task ids (only --result applies to all of them)")
p_complete.add_argument("--result", default=None, help="Result summary")
p_complete.add_argument("--summary", default=None,
help="Structured handoff summary for downstream tasks. "
"Falls back to --result if omitted.")
p_complete.add_argument("--metadata", default=None,
help='JSON dict of structured facts (e.g. \'{"changed_files": [...], '
'"tests_run": 12}\'). Stored on the closing run.')
p_edit = sub.add_parser("edit", help="Edit recovery fields on an already-completed task")
p_edit.add_argument("task_id")
p_edit.add_argument("--result", required=True,
help="Backfilled task result text for a done task")
p_edit.add_argument("--summary", default=None,
help="Structured handoff summary. Falls back to --result if omitted.")
p_edit.add_argument("--metadata", default=None,
help="JSON dict of structured facts to store on the latest completed run.")
p_block = sub.add_parser("block", help="Mark one or more tasks blocked")
p_block.add_argument("task_id")
p_block.add_argument("reason", nargs="*", help="Reason (also appended as a comment)")
p_block.add_argument("--ids", nargs="+", default=None,
help="Additional task ids to block with the same reason (bulk mode)")
p_block.add_argument("--kind", default=None, choices=sorted(kb.VALID_BLOCK_KINDS),
help="Typed block reason. 'dependency' waits in todo (auto-promoted when "
"parents finish, no human); 'needs_input'/'capability' go to "
"blocked for a human; 'transient' marks a maybe-flaky failure. "
"Repeated same-kind re-blocks after unblock route the task to "
"triage to break unblock loops. Omit for a generic block.")
p_schedule = sub.add_parser("schedule", help="Park one or more tasks in Scheduled (waiting on time, not human input)")
p_schedule.add_argument("task_id")
p_schedule.add_argument("reason", nargs="*", help="Reason/timing note (also appended as a comment)")
p_schedule.add_argument("--ids", nargs="+", default=None,
help="Additional task ids to schedule with the same reason (bulk mode)")
p_unblock = sub.add_parser("unblock",
help="Return blocked/scheduled tasks to ready, or todo while "
"parents remain open")
p_unblock.add_argument("--reason", default=None,
help="Optional reason/note — recorded as a comment before unblocking. "
"Quote multi-word reasons.")
p_unblock.add_argument("task_ids", nargs="+")
p_request_review = sub.add_parser("request-review",
help="Move a task to 'review' (implementation done, "
"awaiting review) — NOT a block")
p_request_review.add_argument("task_id")
p_request_review.add_argument("--summary", default=None,
help="What was implemented and how it was verified — shown to "
"the reviewer.")
p_request_review.add_argument("--reviewer", default=None,
help="Optional reviewer profile; reassigns the task before "
"review dispatch.")
p_request_review.add_argument("--metadata", default=None,
help="JSON object with structured reviewer handoff facts.")
p_request_review.add_argument("--force", action="store_true",
help="Override the live-claim guard: move a running, claimed "
"task to review even without owning its run (clears the "
"worker's claim).")
p_request_changes = sub.add_parser("request-changes",
help="Reviewer verdict: return the active review run to "
"its implementer")
p_request_changes.add_argument("task_id")
p_request_changes.add_argument("reason", nargs="+",
help="Concrete changes required before re-review")
p_reopen_review = sub.add_parser("reopen-review",
help="Send one or more review tasks back for changes (review "
"-> ready/todo)")
p_reopen_review.add_argument("task_ids", nargs="+")
p_reopen_review.add_argument("--reason", default=None,
help="Optional reason/note — recorded as a comment before "
"reopening. Quote multi-word reasons.")
p_promote = sub.add_parser("promote",
help="Manually move one or more todo/blocked tasks to ready "
"(recovery path)")
p_promote.add_argument("task_id")
p_promote.add_argument("reason", nargs="*",
help="Audit-trail reason (recorded on the task_events row)")
p_promote.add_argument("--ids", nargs="+", default=None,
help="Additional task ids to promote with the same reason (bulk mode)")
p_promote.add_argument("--force", action="store_true",
help="Promote even if parent dependencies are not yet done/archived")
p_promote.add_argument("--dry-run", action="store_true",
help="Validate the promotion without mutating state")
p_promote.add_argument("--json", dest="json", action="store_true",
help="Emit machine-readable JSON result")
p_archive = sub.add_parser("archive", help="Archive one or more tasks")
p_archive.add_argument("task_ids", nargs="*", help="Task ids to archive (default mode)")
p_archive.add_argument("--rm", dest="purge_ids", nargs="+", default=None,
help="Permanently delete already-archived task ids from the board")
# --- tail ---
p_tail = sub.add_parser("tail", help="Follow a task's event stream")
p_tail.add_argument("task_id")
p_tail.add_argument("--interval", type=float, default=1.0)
# --- dispatch ---
p_disp = sub.add_parser("dispatch",
help="One dispatcher pass: reclaim stale, promote ready, spawn workers")
p_disp.add_argument("--dry-run", action="store_true",
help="Don't actually spawn processes; just print what would happen")
p_disp.add_argument("--max", type=int, default=None, help="Cap number of spawns this pass")
p_disp.add_argument("--failure-limit", type=int,
default=kb.DEFAULT_SPAWN_FAILURE_LIMIT,
help=f"Auto-block a task after this many consecutive non-success attempts "
f"(spawn_failed, timed_out, or crashed; default: {kb.DEFAULT_SPAWN_FAILURE_LIMIT})")
p_disp.add_argument("--json", action="store_true")
# --- daemon (deprecated) ---
p_daemon = sub.add_parser("daemon",
help="DEPRECATED — dispatcher now runs in the gateway. Use `hermes "
"gateway start`.")
p_daemon.add_argument("--interval", type=float, default=60.0,
help="Seconds between dispatch ticks (default: 60)")
p_daemon.add_argument("--max", type=int, default=None, help="Cap number of spawns per tick")
p_daemon.add_argument("--failure-limit", type=int, default=kb.DEFAULT_SPAWN_FAILURE_LIMIT)
p_daemon.add_argument("--pidfile", default=None,
help="Write the daemon's PID to this file on start")
p_daemon.add_argument("--verbose", "-v", action="store_true",
help="Log each tick's outcome to stdout")
# Escape hatch for hosts that truly cannot run the gateway; hidden from
# --help so nobody casually keeps the double-dispatcher pattern alive.
p_daemon.add_argument("--force", action="store_true", help=argparse.SUPPRESS)
# --- watch ---
p_watch = sub.add_parser("watch",
help="Live-stream task_events to the terminal (Ctrl+C to exit)")
p_watch.add_argument("--assignee", default=None,
help="Only show events for tasks assigned to this profile")
p_watch.add_argument("--tenant", default=None,
help="Only show events from tasks in this tenant")
p_watch.add_argument("--kinds", default=None,
help="Comma-separated event kinds to include "
"(e.g. 'completed,blocked,gave_up,crashed,timed_out')")
p_watch.add_argument("--interval", type=float, default=0.5,
help="Poll interval in seconds (default: 0.5)")
# --- stats ---
p_stats = sub.add_parser("stats", help="Per-status + per-assignee counts + oldest-ready age")
p_stats.add_argument("--json", action="store_true")
# --- notify subscribe / list / remove ---
p_nsub = sub.add_parser("notify-subscribe",
help="Subscribe a gateway source to a task's terminal events (used by "
"/kanban subscribe in the gateway adapter)")
p_nsub.add_argument("task_id")
p_nsub.add_argument("--platform", required=True)
p_nsub.add_argument("--chat-id", required=True)
p_nsub.add_argument("--thread-id", default=None)
p_nsub.add_argument("--user-id", default=None)
p_nsub.add_argument("--user-id-alt", default=None)
p_nsub.add_argument("--chat-type", choices=("dm", "group", "channel", "thread"), default=None,
help="Originating source chat_type, recorded so the active-wake delivery "
"modes resolve the operator's real session. Omit to leave an "
"existing sub unchanged (new subs default to 'dm').")
p_nsub.add_argument("--notifier-profile", default=None,
help="Profile gateway that owns/delivers this subscription (default: "
"active profile)")
p_nsub.add_argument(
"--delivery-mode",
# Single source of truth shared with the DB/watcher enum.
choices=kb._NOTIFY_DELIVERY_MODES,
default=None,
help="How the kanban-notifier reacts to terminal events for this "
"subscription: 'notify' (passive message only; default), "
"'notify+wake' (message AND wake the destination gateway agent so "
"it reads the full board context and replies in its own voice), or "
"'wake' (wake the agent only, no passive message). Omit to leave an "
"existing subscription's mode unchanged (new subs default to 'notify').",
)
p_nlist = sub.add_parser("notify-list",
help="List notification subscriptions (optionally for a single task)")
p_nlist.add_argument("task_id", nargs="?", default=None)
p_nlist.add_argument("--json", action="store_true")
p_nrm = sub.add_parser("notify-unsubscribe", help="Remove a gateway subscription from a task")
p_nrm.add_argument("task_id")
p_nrm.add_argument("--platform", required=True)
p_nrm.add_argument("--chat-id", required=True)
p_nrm.add_argument("--thread-id", default=None)
# --- log ---
p_log = sub.add_parser("log",
help="Print the worker log for a task (from <kanban-root>/kanban/logs/)")
p_log.add_argument("task_id")
p_log.add_argument("--tail", type=int, default=None, help="Only print the last N bytes")
# --- runs (per-attempt history for a task) ---
p_runs = sub.add_parser("runs",
help="Show attempt history for a task (one row per run: profile, "
"outcome, elapsed, summary)")
p_runs.add_argument("task_id")
p_runs.add_argument("--json", action="store_true")
_add_run_state_filters(p_runs, "filter runs by task_runs column")
# --- heartbeat (worker liveness signal) ---
p_hb = sub.add_parser("heartbeat",
help="Emit a heartbeat event for a running task (worker liveness signal)")
p_hb.add_argument("task_id")
p_hb.add_argument("--note", default=None,
help="Optional short note attached to the heartbeat event")
# --- assignees ---
p_asg = sub.add_parser("assignees",
help="List known profiles + per-profile task counts (union of "
"~/.hermes/profiles/ and current assignees on the board)")
p_asg.add_argument("--json", action="store_true")
# --- context --- (for spawned workers)
p_ctx = sub.add_parser("context",
help="Print the full context a worker sees for a task (title + body + "
"parent results + comments).")
p_ctx.add_argument("task_id")
# --- specify --- (triage → todo via auxiliary LLM)
p_specify = sub.add_parser("specify",
help="Flesh out a triage-column task into a concrete spec (title + "
"body) and promote it to todo. Uses the auxiliary LLM "
"configured under auxiliary.triage_specifier.")
_add_triage_sweep_args(p_specify, "specify", "Specify", "specifier")
# --- decompose --- (triage → fan-out via auxiliary LLM + orchestrator)
p_decompose = sub.add_parser("decompose",
help="Decompose a triage-column task into a graph of child tasks "
"routed to specialist profiles by description. Falls back "
"to specify-style single-task promotion when the task "
"doesn't benefit from fan-out. Uses "
"auxiliary.kanban_decomposer.")
_add_triage_sweep_args(p_decompose, "decompose", "Decompose", "decomposer")
# --- gc ---
p_gc = sub.add_parser("gc",
help="Garbage-collect archived-task workspaces, old events, and old logs")
p_gc.add_argument("--event-retention-days", type=int, default=30,
help="Delete task_events older than N days for terminal tasks (default: 30)")
p_gc.add_argument("--log-retention-days", type=int, default=30,
help="Delete worker log files older than N days (default: 30)")
# --- repair ---
p_repair = sub.add_parser(
"repair",
help="Check kanban.db integrity and auto-repair index-only corruption",
description=(
"Runs PRAGMA integrity_check on the board's DB and reports the "
"result. When the failure consists only of index-scoped errors "
"('wrong # of entries in index <name>' / 'row N missing from "
"index <name>'), the corrupt file is quarantined to a "
".corrupt.<hash>.bak sibling first and the damaged indexes are "
"rebuilt with REINDEX — the same narrow auto-repair the "
"connect-time guard applies. Any other corruption class is "
"reported and left untouched (fail-closed). Exits 0 when the DB "
"is healthy or was repaired, non-zero when it is still corrupt."
),
)
p_repair.add_argument("--json", action="store_true", help="Emit the repair report as JSON")
kanban_parser.set_defaults(_kanban_parser=kanban_parser)
return kanban_parser
# ---------------------------------------------------------------------------
# Command dispatch
# ---------------------------------------------------------------------------

View File

@@ -0,0 +1,95 @@
"""Text / ``--json`` output helpers shared by the ``hermes kanban`` CLI modules."""
from __future__ import annotations
import argparse
import json
import sys
import time
from typing import Any, Callable, Iterable, Optional
from hermes_cli import kanban_db as kb
_STATUS_ICONS = {
"todo": "◻",
"ready": "▶",
"running": "●",
"scheduled":"⏱",
"blocked": "⊘",
"done": "✓",
"archived": "—",
}
_TASK_DICT_FIELDS = (
"id", "title", "body", "assignee", "status", "priority", "tenant",
"workspace_kind", "workspace_path", "branch_name", "project_id",
"created_by", "created_at", "started_at", "completed_at", "result",
"skills", "max_retries", "model_override", "provider_override",
"session_id", "workflow_template_id", "current_step_key",
)
_SHOW_RUN_FIELDS = (
"id", "profile", "step_key", "status", "outcome", "summary", "error",
"metadata", "worker_pid", "started_at", "ended_at",
)
_RUNS_RUN_FIELDS = (
"id", "profile", "status", "outcome", "started_at", "ended_at",
"summary", "error", "metadata", "worker_pid", "step_key",
)
_ATTACHMENT_FIELDS = (
"id", "filename", "content_type", "size", "uploaded_by", "stored_path", "created_at",
)
def _fmt_ts(ts: Optional[int]) -> str:
return time.strftime("%Y-%m-%d %H:%M", time.localtime(ts)) if ts else ""
def _print_json(obj: Any, *, ascii: bool = False) -> None:
print(json.dumps(obj, indent=2, ensure_ascii=ascii))
def _json_out(args: argparse.Namespace, obj: Any, *, ascii: bool = False) -> bool:
"""Print ``obj`` as JSON and return True when ``--json`` was passed."""
if not getattr(args, "json", False):
return False
_print_json(obj, ascii=ascii)
return True
def _fmt_counts(counts: dict, empty: str = "") -> str:
return ", ".join(f"{k}={v}" for k, v in sorted(counts.items())) or empty
def _err(msg: str, rc: int = 1) -> int:
print(msg, file=sys.stderr)
return rc
def _bulk_apply(ids: Iterable[str], op: Callable[[str], Any],
ok_msg: Callable[[str], str], fail_msg: Callable[[str], str]) -> int:
"""Run ``op(tid) -> bool`` per id, print ok/fail lines, exit 1 if any failed."""
failed = False
for tid in ids:
if not op(tid):
failed = True
print(fail_msg(tid), file=sys.stderr)
else:
print(ok_msg(tid))
return 1 if failed else 0
def _fmt_task_line(t: kb.Task) -> str:
icon = _STATUS_ICONS.get(t.status, "?")
assignee = t.assignee or "(unassigned)"
tenant = f" [{t.tenant}]" if t.tenant else ""
return f"{icon} {t.id} {t.status:8s} {assignee:20s}{tenant} {t.title}"
def _obj_dict(obj: Any, fields: tuple[str, ...]) -> dict[str, Any]:
return {k: getattr(obj, k) for k in fields}
def _task_to_dict(t: kb.Task) -> dict[str, Any]:
d = _obj_dict(t, _TASK_DICT_FIELDS)
d["skills"] = list(t.skills) if t.skills else []
return d

510
hermes_cli/kanban_parser.py Normal file
View File

@@ -0,0 +1,510 @@
"""Argparse tree for ``hermes kanban …`` (``build_parser``).
The subcommand tree is declared as data — one ``_cmd(...)`` record per
subcommand holding its ``add_parser`` kwargs and an ordered tuple of
``add_argument`` specs — and materialised by ``_add_commands``. Order of
records and arguments is the order argparse renders in ``--help``.
"""
from __future__ import annotations
import argparse
from hermes_cli import kanban_db as kb
def _arg(*flags: str, **kw):
return (flags, kw)
def _cmd(name: str, args=(), *, children=None, **parser_kw):
"""``children`` = ``(dest, [specs])`` for a nested subparser group."""
return (name, parser_kw, tuple(args), children)
def _add_commands(sub: argparse._SubParsersAction, specs) -> None:
for name, parser_kw, args, children in specs:
p = sub.add_parser(name, **parser_kw)
for flags, kw in args:
p.add_argument(*flags, **kw)
if children:
dest, child_specs = children
_add_commands(p.add_subparsers(dest=dest), child_specs)
def _run_state_args(type_help: str):
return (
_arg("--state-type", choices=("status", "outcome"), default=None,
help=f"With --state-name: {type_help}"),
_arg("--state-name", default=None, metavar="VALUE",
help="With --state-type: keep runs whose column equals this value"),
)
def _triage_sweep_args(verb: str, Verb: str, noun: str):
"""Shared ``specify`` / ``decompose`` arguments."""
return (
_arg("task_id", nargs="?", default=None,
help=f"Task id to {verb} (required unless --all is given)"),
_arg("--all", dest="all_triage", action="store_true",
help=f"{Verb} every task currently in the triage column"),
_arg("--tenant", default=None,
help="When used with --all, restrict the sweep to this tenant"),
_arg("--author", default=None,
help="Author name recorded on the audit comment "
f"(default: $HERMES_PROFILE or '{noun}')"),
_arg("--json", action="store_true", help="Emit one JSON object per task on stdout"),
)
def _json_flag(**kw):
return _arg("--json", action="store_true", **kw)
def _bulk_ids(verb: str):
return _arg("--ids", nargs="+", default=None,
help=f"Additional task ids to {verb} with the same reason (bulk mode)")
def _board_specs():
return [
_cmd("list", [
_json_flag(),
_arg("--all", action="store_true", help="Include archived boards too"),
], aliases=["ls"], help="List all boards with task counts"),
_cmd("create", [
_arg("slug", help="Board slug (kebab-case, e.g. atm10-server)"),
_arg("--name", default=None,
help="Human-readable display name (defaults to Title Case of slug)"),
_arg("--description", default=None, help="Optional description"),
_arg("--icon", default=None,
help="Optional emoji or single-character icon for the dashboard"),
_arg("--color", default=None,
help="Optional hex color (e.g. '#8b5cf6') for the dashboard"),
_arg("--switch", action="store_true", help="Switch to the new board after creating it"),
_arg("--default-workdir", default=None,
help="Default workspace path for tasks created on this board"),
], aliases=["new"], help="Create a new board"),
_cmd("rm", [
_arg("slug"),
_arg("--delete", action="store_true",
help="Hard-delete the board directory instead of archiving it. "
"Default is to move it to boards/_archived/ so it's recoverable."),
], aliases=["remove", "delete"], help="Archive (default) or delete a board"),
_cmd("switch", [_arg("slug")], aliases=["use"],
help="Set the active board for subsequent CLI calls"),
_cmd("show", aliases=["current"], help="Print the currently-active board slug"),
_cmd("rename", [
_arg("slug"),
_arg("name", help="New display name"),
], help="Change a board's human-readable display name (slug is immutable)"),
_cmd("set-default-workdir", [
_arg("slug"),
_arg("path", nargs="?", default=None,
help="Absolute path to use as default workdir. Omit to clear."),
], help="Set the default workspace path for tasks on a board"),
_cmd("export", [
_arg("slug", nargs="?", default=None, help="Board to export (default: the current board)"),
_arg("-o", "--output", default=None, help="Archive path (default: ./<slug>.tar.gz)"),
_arg("--no-attachments", action="store_true",
help="Skip attachment files, keeping the archive small"),
_arg("--include-logs", action="store_true", help="Include per-task worker logs"),
_json_flag(),
], help="Export a board to a portable .tar.gz archive", description=(
"Package a board's tasks, comments, links, history, and file "
"attachments into one archive that can be imported on another "
"machine. Claims, worker PIDs, chat subscriptions, and paths "
"belonging to this machine are stripped. Workspaces are never "
"included — they are rebuilt on demand."
)),
_cmd("import", [
_arg("archive", help="Path to the .tar.gz archive"),
_arg("--as", dest="as_slug", default=None,
help="Slug for the imported board (default: from the archive)"),
_arg("--switch", action="store_true", help="Switch to the imported board afterwards"),
_json_flag(),
], help="Import a board archive as a new board", description=(
"Import a .tar.gz produced by `hermes kanban boards export`. "
"The board always lands as a NEW board — the slug gains a "
"numeric suffix if it is already taken — so an import can "
"never overwrite or merge into a board you already have."
)),
]
def _specs():
"""Top-level ``hermes kanban <action>`` records, in ``--help`` order."""
return [
_cmd("init", help="Create kanban.db if missing (idempotent)"),
_cmd("boards", children=("boards_action", _board_specs()),
help="Manage kanban boards (one board per project / workstream)",
description=(
"Boards let you separate unrelated streams of work "
"(projects, repos, domains) into isolated queues. Each "
"board has its own DB, workspaces directory, and dispatcher "
"loop — tasks on one board cannot collide with tasks on "
"another. The first board is 'default' and always exists."
)),
_cmd("create", [
_arg("title", help="Task title"),
_arg("--body", default=None, help="Optional opening post"),
_arg("--assignee", default=None, help="Profile name to assign"),
_arg("--parent", action="append", default=[], help="Parent task id (repeatable)"),
_arg("--workspace", default="scratch",
help="scratch | worktree | worktree:<path> | dir:<path> (default: scratch)"),
_arg("--branch", default=None, help="Branch name for worktree tasks, e.g. wt/t6-wire"),
_arg("--project", default=None,
help="Link to a project (id or slug). Anchors the task's "
"worktree under the project's primary repo with a "
"deterministic branch. See `hermes project list`."),
_arg("--tenant", default=None, help="Tenant namespace"),
_arg("--priority", type=int, default=0, help="Priority tiebreaker"),
_arg("--triage", action="store_true",
help="Park in triage — a specifier will flesh out the spec and promote to todo"),
_arg("--idempotency-key", default=None,
help="Dedup key. If a non-archived task with this key exists, "
"its id is returned instead of creating a duplicate."),
_arg("--max-runtime", default=None,
help="Per-task runtime cap. Accepts seconds (300) or durations (90s, "
"30m, 2h, 1d). When exceeded, the dispatcher SIGTERMs (then "
"SIGKILLs) the worker and re-queues the task."),
_arg("--created-by", default="user",
help="Author name recorded on the task (default: user)"),
_arg("--skill", action="append", default=[], dest="skills",
help="Skill to force-load into the worker (repeatable). The kanban "
"lifecycle is already injected automatically. Example: --skill "
"translation --skill github-code-review"),
_arg("--max-retries", type=int, default=None, metavar="N",
help="Per-task override for the consecutive-failure "
"circuit breaker. Trip on the Nth failure — "
"e.g. --max-retries 1 blocks on the first "
"failure (no retries), --max-retries 3 allows "
"two retries. Omit to use the dispatcher's "
"kanban.failure_limit config "
f"(default {kb.DEFAULT_FAILURE_LIMIT})."),
_arg("--model", default=None, dest="model_override",
help="Pin the worker to this model (passed as -m <model>) without "
"changing the profile's configured model. Combine with --provider "
"when the model belongs to a different backend than the profile's "
"default."),
_arg("--provider", default=None, dest="provider_override",
help="Provider the --model belongs to (passed as --provider <name> to "
"the worker). Requires --model."),
_arg("--goal", action="store_true", dest="goal_mode",
help="Run the worker in a goal loop: after each turn a judge checks the "
"response against the card title/body and, if not done, the worker "
"keeps going in the same session until the judge agrees it's "
"complete (or the turn budget runs out, which blocks the card for "
"review). Best for open-ended cards one shot rarely finishes."),
_arg("--goal-max-turns", type=int, default=None, metavar="N", dest="goal_max_turns",
help="Turn budget for --goal workers (default 20). Ignored without --goal."),
_arg("--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."),
_json_flag(help="Emit JSON output"),
], help="Create a new task"),
_cmd("swarm", [
_arg("goal", help="Swarm goal / final outcome"),
_arg("--worker", action="append", default=[], metavar="PROFILE:TITLE[:SKILL,SKILL]",
help="Parallel worker card (repeatable)"),
_arg("--verifier", required=True, help="Verifier profile"),
_arg("--synthesizer", required=True, help="Synthesizer/writer profile"),
_arg("--tenant", default=None, help="Tenant namespace"),
_arg("--priority", type=int, default=0, help="Priority tiebreaker"),
_arg("--created-by", default=None, help="Creator/anchor profile"),
_arg("--idempotency-key", default=None, help="Dedup key for the root card"),
_json_flag(help="Emit JSON output"),
], help="Create a Kanban Swarm v1 graph (parallel workers → verifier → synthesizer)"),
_cmd("list", [
_arg("--mine", action="store_true", help="Filter by $HERMES_PROFILE as assignee"),
_arg("--assignee", default=None),
_arg("--status", default=None, choices=sorted(kb.VALID_STATUSES)),
_arg("--tenant", default=None),
_arg("--session", default=None,
help="Filter by originating chat/agent session id "
"(set on tasks created from inside an ACP loop)"),
_arg("--archived", action="store_true", help="Include archived tasks"),
_json_flag(),
_arg("--sort", default=None, choices=sorted(kb.VALID_SORT_ORDERS.keys()),
help="Sort order for listed tasks (default: priority)"),
_arg("--workflow-template-id", default=None, metavar="ID",
help="Restrict to tasks with this workflow_template_id"),
_arg("--step-key", default=None, dest="current_step_key", metavar="KEY",
help="Restrict to tasks with this current_step_key"),
], aliases=["ls"], help="List tasks"),
_cmd("show", [
_arg("task_id"),
_json_flag(),
*_run_state_args("filter listed runs by task_runs column"),
], help="Show a task with comments + events"),
_cmd("assign", [
_arg("task_id"),
_arg("profile", help="Profile name (or 'none' to unassign)"),
], help="Assign or reassign a task"),
_cmd("set-model", [
_arg("task_id"),
_arg("model", nargs="?", default=None,
help="Model to pin the worker to (or 'none' to clear the override)"),
_arg("--provider", default=None,
help="Provider the model belongs to (worker is spawned with "
"--provider <name>). Cleared together with the model."),
], help="Set or clear a task's model/provider override (takes effect on the next dispatch)"),
_cmd("reclaim", [
_arg("task_id"),
_arg("--reason", default=None, help="Human-readable reason (recorded on the reclaimed event)"),
], help="Release an active worker claim on a running task"),
_cmd("reassign", [
_arg("task_id"),
_arg("profile", help="New profile name (or 'none' to unassign)"),
_arg("--reclaim", action="store_true",
help="Release any active claim before reassigning (required if task is running)"),
_arg("--reason", default=None, help="Human-readable reason (recorded on the reclaimed event)"),
], help="Reassign a task to a different profile, optionally reclaiming first"),
_cmd("diagnostics", [
_arg("--severity", choices=["warning", "error", "critical"], default=None,
help="Only show diagnostics at or above this severity"),
_arg("--task", default=None, help="Only show diagnostics for one task id"),
_json_flag(help="Emit JSON (structured) instead of the default human table"),
], aliases=["diag"], help="List active diagnostics on the current board"),
_cmd("link", [_arg("parent_id"), _arg("child_id")], help="Add a parent->child dependency"),
_cmd("unlink", [_arg("parent_id"), _arg("child_id")], help="Remove a parent->child dependency"),
_cmd("claim", [
_arg("task_id"),
_arg("--ttl", type=int, default=kb.DEFAULT_CLAIM_TTL_SECONDS,
help="Claim TTL in seconds (default: 900)"),
], help="Atomically claim a ready task (prints resolved workspace path)"),
_cmd("comment", [
_arg("task_id"),
_arg("text", nargs="+", help="Comment body"),
_arg("--author", default=None, help="Author name (default: $HERMES_PROFILE or 'user')"),
_arg("--max-len", type=int, default=None,
help="Trim the stored comment body to this many characters"),
], help="Append a comment"),
_cmd("attach", [
_arg("task_id"),
_arg("path", help="Path to the local file to attach"),
_arg("--content-type", default=None,
help="MIME type (default: guessed from the file extension)"),
_arg("--name", default=None, help="Stored filename (default: the source file's basename)"),
_arg("--author", default=None, help="uploaded_by label (default: $HERMES_PROFILE or 'user')"),
], help="Attach a local file to a task"),
_cmd("attachments", [_arg("task_id"), _json_flag()], help="List a task's attachments"),
_cmd("attach-rm", [_arg("attachment_id", type=int)], help="Delete an attachment by id"),
_cmd("complete", [
_arg("task_ids", nargs="+",
help="One or more task ids (only --result applies to all of them)"),
_arg("--result", default=None, help="Result summary"),
_arg("--summary", default=None,
help="Structured handoff summary for downstream tasks. "
"Falls back to --result if omitted."),
_arg("--metadata", default=None,
help='JSON dict of structured facts (e.g. \'{"changed_files": [...], '
'"tests_run": 12}\'). Stored on the closing run.'),
], help="Mark one or more tasks done"),
_cmd("edit", [
_arg("task_id"),
_arg("--result", required=True, help="Backfilled task result text for a done task"),
_arg("--summary", default=None,
help="Structured handoff summary. Falls back to --result if omitted."),
_arg("--metadata", default=None,
help="JSON dict of structured facts to store on the latest completed run."),
], help="Edit recovery fields on an already-completed task"),
_cmd("block", [
_arg("task_id"),
_arg("reason", nargs="*", help="Reason (also appended as a comment)"),
_bulk_ids("block"),
_arg("--kind", default=None, choices=sorted(kb.VALID_BLOCK_KINDS),
help="Typed block reason. 'dependency' waits in todo (auto-promoted when "
"parents finish, no human); 'needs_input'/'capability' go to "
"blocked for a human; 'transient' marks a maybe-flaky failure. "
"Repeated same-kind re-blocks after unblock route the task to "
"triage to break unblock loops. Omit for a generic block."),
], help="Mark one or more tasks blocked"),
_cmd("schedule", [
_arg("task_id"),
_arg("reason", nargs="*", help="Reason/timing note (also appended as a comment)"),
_bulk_ids("schedule"),
], help="Park one or more tasks in Scheduled (waiting on time, not human input)"),
_cmd("unblock", [
_arg("--reason", default=None,
help="Optional reason/note — recorded as a comment before unblocking. "
"Quote multi-word reasons."),
_arg("task_ids", nargs="+"),
], help="Return blocked/scheduled tasks to ready, or todo while parents remain open"),
_cmd("request-review", [
_arg("task_id"),
_arg("--summary", default=None,
help="What was implemented and how it was verified — shown to the reviewer."),
_arg("--reviewer", default=None,
help="Optional reviewer profile; reassigns the task before review dispatch."),
_arg("--metadata", default=None, help="JSON object with structured reviewer handoff facts."),
_arg("--force", action="store_true",
help="Override the live-claim guard: move a running, claimed "
"task to review even without owning its run (clears the "
"worker's claim)."),
], help="Move a task to 'review' (implementation done, awaiting review) — NOT a block"),
_cmd("request-changes", [
_arg("task_id"),
_arg("reason", nargs="+", help="Concrete changes required before re-review"),
], help="Reviewer verdict: return the active review run to its implementer"),
_cmd("reopen-review", [
_arg("task_ids", nargs="+"),
_arg("--reason", default=None,
help="Optional reason/note — recorded as a comment before "
"reopening. Quote multi-word reasons."),
], help="Send one or more review tasks back for changes (review -> ready/todo)"),
_cmd("promote", [
_arg("task_id"),
_arg("reason", nargs="*", help="Audit-trail reason (recorded on the task_events row)"),
_bulk_ids("promote"),
_arg("--force", action="store_true",
help="Promote even if parent dependencies are not yet done/archived"),
_arg("--dry-run", action="store_true", help="Validate the promotion without mutating state"),
_arg("--json", dest="json", action="store_true", help="Emit machine-readable JSON result"),
], help="Manually move one or more todo/blocked tasks to ready (recovery path)"),
_cmd("archive", [
_arg("task_ids", nargs="*", help="Task ids to archive (default mode)"),
_arg("--rm", dest="purge_ids", nargs="+", default=None,
help="Permanently delete already-archived task ids from the board"),
], help="Archive one or more tasks"),
_cmd("tail", [
_arg("task_id"),
_arg("--interval", type=float, default=1.0),
], help="Follow a task's event stream"),
_cmd("dispatch", [
_arg("--dry-run", action="store_true",
help="Don't actually spawn processes; just print what would happen"),
_arg("--max", type=int, default=None, help="Cap number of spawns this pass"),
_arg("--failure-limit", type=int, default=kb.DEFAULT_SPAWN_FAILURE_LIMIT,
help=f"Auto-block a task after this many consecutive non-success attempts "
f"(spawn_failed, timed_out, or crashed; default: {kb.DEFAULT_SPAWN_FAILURE_LIMIT})"),
_json_flag(),
], help="One dispatcher pass: reclaim stale, promote ready, spawn workers"),
_cmd("daemon", [
_arg("--interval", type=float, default=60.0, help="Seconds between dispatch ticks (default: 60)"),
_arg("--max", type=int, default=None, help="Cap number of spawns per tick"),
_arg("--failure-limit", type=int, default=kb.DEFAULT_SPAWN_FAILURE_LIMIT),
_arg("--pidfile", default=None, help="Write the daemon's PID to this file on start"),
_arg("--verbose", "-v", action="store_true", help="Log each tick's outcome to stdout"),
# Escape hatch for hosts that truly cannot run the gateway; hidden from
# --help so nobody casually keeps the double-dispatcher pattern alive.
_arg("--force", action="store_true", help=argparse.SUPPRESS),
], help="DEPRECATED — dispatcher now runs in the gateway. Use `hermes gateway start`."),
_cmd("watch", [
_arg("--assignee", default=None, help="Only show events for tasks assigned to this profile"),
_arg("--tenant", default=None, help="Only show events from tasks in this tenant"),
_arg("--kinds", default=None,
help="Comma-separated event kinds to include "
"(e.g. 'completed,blocked,gave_up,crashed,timed_out')"),
_arg("--interval", type=float, default=0.5, help="Poll interval in seconds (default: 0.5)"),
], help="Live-stream task_events to the terminal (Ctrl+C to exit)"),
_cmd("stats", [_json_flag()], help="Per-status + per-assignee counts + oldest-ready age"),
_cmd("notify-subscribe", [
_arg("task_id"),
_arg("--platform", required=True),
_arg("--chat-id", required=True),
_arg("--thread-id", default=None),
_arg("--user-id", default=None),
_arg("--user-id-alt", default=None),
_arg("--chat-type", choices=("dm", "group", "channel", "thread"), default=None,
help="Originating source chat_type, recorded so the active-wake delivery "
"modes resolve the operator's real session. Omit to leave an "
"existing sub unchanged (new subs default to 'dm')."),
_arg("--notifier-profile", default=None,
help="Profile gateway that owns/delivers this subscription (default: active profile)"),
# choices: single source of truth shared with the DB/watcher enum.
_arg("--delivery-mode", choices=kb._NOTIFY_DELIVERY_MODES, default=None,
help="How the kanban-notifier reacts to terminal events for this "
"subscription: 'notify' (passive message only; default), "
"'notify+wake' (message AND wake the destination gateway agent so "
"it reads the full board context and replies in its own voice), or "
"'wake' (wake the agent only, no passive message). Omit to leave an "
"existing subscription's mode unchanged (new subs default to 'notify')."),
], help="Subscribe a gateway source to a task's terminal events (used by "
"/kanban subscribe in the gateway adapter)"),
_cmd("notify-list", [
_arg("task_id", nargs="?", default=None),
_json_flag(),
], help="List notification subscriptions (optionally for a single task)"),
_cmd("notify-unsubscribe", [
_arg("task_id"),
_arg("--platform", required=True),
_arg("--chat-id", required=True),
_arg("--thread-id", default=None),
], help="Remove a gateway subscription from a task"),
_cmd("log", [
_arg("task_id"),
_arg("--tail", type=int, default=None, help="Only print the last N bytes"),
], help="Print the worker log for a task (from <kanban-root>/kanban/logs/)"),
_cmd("runs", [
_arg("task_id"),
_json_flag(),
*_run_state_args("filter runs by task_runs column"),
], help="Show attempt history for a task (one row per run: profile, outcome, elapsed, summary)"),
_cmd("heartbeat", [
_arg("task_id"),
_arg("--note", default=None, help="Optional short note attached to the heartbeat event"),
], help="Emit a heartbeat event for a running task (worker liveness signal)"),
_cmd("assignees", [_json_flag()],
help="List known profiles + per-profile task counts (union of "
"~/.hermes/profiles/ and current assignees on the board)"),
_cmd("context", [_arg("task_id")],
help="Print the full context a worker sees for a task (title + body + "
"parent results + comments)."),
_cmd("specify", _triage_sweep_args("specify", "Specify", "specifier"),
help="Flesh out a triage-column task into a concrete spec (title + "
"body) and promote it to todo. Uses the auxiliary LLM "
"configured under auxiliary.triage_specifier."),
_cmd("decompose", _triage_sweep_args("decompose", "Decompose", "decomposer"),
help="Decompose a triage-column task into a graph of child tasks "
"routed to specialist profiles by description. Falls back "
"to specify-style single-task promotion when the task "
"doesn't benefit from fan-out. Uses "
"auxiliary.kanban_decomposer."),
_cmd("gc", [
_arg("--event-retention-days", type=int, default=30,
help="Delete task_events older than N days for terminal tasks (default: 30)"),
_arg("--log-retention-days", type=int, default=30,
help="Delete worker log files older than N days (default: 30)"),
], help="Garbage-collect archived-task workspaces, old events, and old logs"),
_cmd("repair", [_json_flag(help="Emit the repair report as JSON")],
help="Check kanban.db integrity and auto-repair index-only corruption",
description=(
"Runs PRAGMA integrity_check on the board's DB and reports the "
"result. When the failure consists only of index-scoped errors "
"('wrong # of entries in index <name>' / 'row N missing from "
"index <name>'), the corrupt file is quarantined to a "
".corrupt.<hash>.bak sibling first and the damaged indexes are "
"rebuilt with REINDEX — the same narrow auto-repair the "
"connect-time guard applies. Any other corruption class is "
"reported and left untouched (fail-closed). Exits 0 when the DB "
"is healthy or was repaired, non-zero when it is still corrupt."
)),
]
def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.ArgumentParser:
"""Attach the ``kanban`` subcommand tree; returns the ``kanban`` parser."""
kanban_parser = parent_subparsers.add_parser(
"kanban",
help="Multi-profile collaboration board (tasks, links, comments)",
description=(
"Durable SQLite-backed task board shared across Hermes profiles. "
"Tasks are claimed atomically, can depend on other tasks, and "
"are executed by a named profile in an isolated workspace. "
"See https://hermes-agent.nousresearch.com/docs/user-guide/features/kanban "
"or docs/hermes-kanban-v1-spec.pdf for the full design."
),
)
# --board scopes every subcommand to one board's DB; when omitted the
# resolution is HERMES_KANBAN_BOARD, then the persisted current-board
# file, then "default" (kanban_db.get_current_board()).
kanban_parser.add_argument("--board", default=None, metavar="<slug>",
help="Board slug to operate on. Defaults to the current board (set "
"via `hermes kanban boards switch <slug>` or the "
"HERMES_KANBAN_BOARD env var). Use `hermes kanban boards "
"list` to see all boards.")
_add_commands(kanban_parser.add_subparsers(dest="kanban_action"), _specs())
kanban_parser.set_defaults(_kanban_parser=kanban_parser)
return kanban_parser