From 62a1607fbfa4f421e02fc3c1ed40bce84997e700 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:25:20 -0700 Subject: [PATCH] refactor(kanban-cli): extract data-driven argparse tree to kanban_parser and output helpers to kanban_output --- hermes_cli/kanban.py | 716 +----------------------------------- hermes_cli/kanban_output.py | 95 +++++ hermes_cli/kanban_parser.py | 510 +++++++++++++++++++++++++ 3 files changed, 613 insertions(+), 708 deletions(-) create mode 100644 hermes_cli/kanban_output.py create mode 100644 hermes_cli/kanban_parser.py diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py index 806403224e..d70c5f8f11 100644 --- a/hermes_cli/kanban.py +++ b/hermes_cli/kanban.py @@ -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="", - help="Board slug to operate on. Defaults to the current board (set " - "via `hermes kanban boards switch ` 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: ./.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: | dir: " - "(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 ) 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 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 ). 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/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 ' / 'row N missing from " - "index '), the corrupt file is quarantined to a " - ".corrupt..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 # --------------------------------------------------------------------------- diff --git a/hermes_cli/kanban_output.py b/hermes_cli/kanban_output.py new file mode 100644 index 0000000000..f84a4805c3 --- /dev/null +++ b/hermes_cli/kanban_output.py @@ -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 diff --git a/hermes_cli/kanban_parser.py b/hermes_cli/kanban_parser.py new file mode 100644 index 0000000000..67c91aea75 --- /dev/null +++ b/hermes_cli/kanban_parser.py @@ -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: ./.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 `` 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: | dir: (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 ) 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 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 ). 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/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 ' / 'row N missing from " + "index '), the corrupt file is quarantined to a " + ".corrupt..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="", + help="Board slug to operate on. Defaults to the current board (set " + "via `hermes kanban boards switch ` 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