hermes_cli/kanban.py 3,565 -> 2,912; kanban_diagnostics 1,216 -> 996; kanban_decompose 468 -> 393; kanban_transfer 478 -> 443; kanban_specify 264 -> 229; kanban_swarm 390 -> 378; active_sessions 871 -> 775. `hermes kanban [sub] --help` byte-identical for all 55 parsers. - kanban.py: _err / _print_json / _json_out / _fmt_counts / _bulk_apply / _obj_dict field tuples replace repeated print/JSON/exit-code blocks; action and board subcommand routing via dict dispatch; shared run-state and triage-sweep argparse blocks; argparse declarations re-packed (AST-identical). - specify/decompose: one _run_triage_sweep driver, shared _extract_json_blob / _truncate / _profile_author / _title_body / _resolve_profile_from_cfg. - diagnostics: rule helpers (_first_field / _latest_event_ts / _log_hint_action / _error_snippet), _rows_by_task fleet fetch; unreferenced DIAGNOSTIC_KINDS dropped. - swarm: graph nodes share one create_task kwarg set. - active_sessions: one _flock per platform, _pid_alive via _pid_liveness, shared _read_live_entries / _without_lease / _clean_metadata, table-driven strict registry validation. - Docstrings/comments hand-compacted (AST-identical), invariants kept.
2913 lines
122 KiB
Python
2913 lines
122 KiB
Python
"""CLI for the Hermes Kanban board — ``hermes kanban …`` subcommand.
|
|
|
|
All DB work is delegated to ``kanban_db``; this module adds argparse
|
|
construction (``build_parser``), dispatch (``kanban_command``), text/``--json``
|
|
output, and ``run_slash`` for ``/kanban …`` from the CLI and gateway.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import contextlib
|
|
import json
|
|
import os
|
|
import shlex
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any, Optional
|
|
|
|
from hermes_cli import kanban_db as kb
|
|
from hermes_cli import kanban_swarm as ks
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Small formatting 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
|
|
|
|
|
|
def _parse_metadata_flag(raw: Optional[str]) -> tuple[Optional[dict], int]:
|
|
"""Parse ``--metadata`` JSON; returns ``(dict|None, rc)`` with rc=2 on error."""
|
|
if not raw:
|
|
return None, 0
|
|
try:
|
|
metadata = json.loads(raw)
|
|
if not isinstance(metadata, dict):
|
|
raise ValueError("must be a JSON object")
|
|
except (ValueError, json.JSONDecodeError) as exc:
|
|
return None, _err(f"kanban: --metadata: {exc}", 2)
|
|
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)
|
|
if (st is None) != (sn is None):
|
|
return None
|
|
if st is None:
|
|
return {}
|
|
return {"state_type": st, "state_name": sn}
|
|
|
|
|
|
def _parse_workspace_flag(value: str) -> tuple[str, Optional[str]]:
|
|
"""Parse ``--workspace`` into ``(kind, path|None)``.
|
|
|
|
Accepts: ``scratch``, ``worktree``, ``worktree:<path>``, ``dir:<path>``.
|
|
"""
|
|
if not value:
|
|
return ("scratch", None)
|
|
v = value.strip()
|
|
if v in {"scratch", "worktree"}:
|
|
return (v, None)
|
|
for prefix, kind in (("dir:", "dir"), ("worktree:", "worktree")):
|
|
if not v.startswith(prefix):
|
|
continue
|
|
path = v[len(prefix):].strip()
|
|
if not path:
|
|
raise argparse.ArgumentTypeError(
|
|
f"--workspace {prefix} requires a path after the colon"
|
|
)
|
|
return (kind, os.path.expanduser(path))
|
|
raise argparse.ArgumentTypeError(
|
|
f"unknown --workspace value {value!r}: use scratch, worktree, "
|
|
"worktree:<path>, or dir:<path>"
|
|
)
|
|
|
|
|
|
def _parse_branch_flag(value: Optional[str]) -> Optional[str]:
|
|
"""Normalize an optional branch name from ``kanban create --branch``."""
|
|
if value is None:
|
|
return None
|
|
branch = value.strip()
|
|
if not branch:
|
|
raise argparse.ArgumentTypeError("--branch requires a non-empty name")
|
|
if branch.startswith("-"):
|
|
raise argparse.ArgumentTypeError("--branch must not start with '-'")
|
|
if any(ch.isspace() for ch in branch):
|
|
raise argparse.ArgumentTypeError("--branch must not contain whitespace")
|
|
return branch
|
|
|
|
|
|
def _check_dispatcher_presence(
|
|
hermes_home: Optional[Path] = None,
|
|
) -> tuple[bool, str]:
|
|
"""Return ``(running, message)`` for the "will anything dispatch this?" warning.
|
|
|
|
``running=True`` when a gateway is alive for this HERMES_HOME with
|
|
``kanban.dispatch_in_gateway`` on (message is a status line); otherwise
|
|
``False`` with human guidance. Fails OPEN — import/probe/config errors
|
|
return ``(True, "")`` — since a missed warning beats crying wolf.
|
|
|
|
``hermes_home`` scopes the probe to a profile's directory: the dashboard
|
|
backend may run under a different HERMES_HOME than the profile it serves,
|
|
which otherwise misreports a healthy gateway as absent. CLI callers pass
|
|
``None``.
|
|
"""
|
|
try:
|
|
from gateway.status import resolve_gateway_liveness # type: ignore
|
|
except Exception:
|
|
return (True, "") # can't probe — silent
|
|
try:
|
|
# Same ladder the dashboard status endpoints use, so PID-file-less or
|
|
# cross-container gateways aren't misreported. use_cache=False: this
|
|
# one-shot probe must see the gateway's state right now.
|
|
liveness = resolve_gateway_liveness(
|
|
profile_dir=hermes_home, use_cache=False
|
|
)
|
|
except Exception:
|
|
return (True, "") # probe errored — silent
|
|
if liveness.probe_error:
|
|
# The resolver swallows per-rung failures; "can't tell" != "no gateway".
|
|
return (True, "")
|
|
pid = liveness.pid
|
|
|
|
# Even if the gateway is up, dispatch_in_gateway may be off.
|
|
try:
|
|
from hermes_cli.config import load_config
|
|
cfg = load_config()
|
|
dispatch_on = bool(cfg.get("kanban", {}).get("dispatch_in_gateway", True))
|
|
except Exception:
|
|
dispatch_on = True # can't tell — assume default
|
|
|
|
if pid and dispatch_on:
|
|
return (True, f"gateway pid={pid}, dispatch enabled")
|
|
if pid and not dispatch_on:
|
|
return (
|
|
False,
|
|
"Gateway is running but kanban.dispatch_in_gateway=false in "
|
|
"config.yaml — the task will sit in 'ready' until you flip it "
|
|
"back on and restart the gateway, OR run the legacy "
|
|
"standalone daemon (`hermes kanban daemon --force`)."
|
|
)
|
|
return (
|
|
False,
|
|
"No gateway is running — the task will sit in 'ready' until you "
|
|
"start it. Run:\n"
|
|
" hermes gateway start\n"
|
|
"The gateway hosts an embedded dispatcher (tick interval 60s by "
|
|
"default); your task will be picked up on the next tick after "
|
|
"the gateway comes up."
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def kanban_command(args: argparse.Namespace) -> int:
|
|
"""Entry point from ``hermes kanban …``; returns a shell-style exit code."""
|
|
action = getattr(args, "kanban_action", None)
|
|
if not action:
|
|
# No subaction given: print help via the stored parser reference.
|
|
parser = getattr(args, "_kanban_parser", None)
|
|
if parser is not None:
|
|
parser.print_help()
|
|
else:
|
|
print(
|
|
"usage: hermes kanban <action> [options]\n"
|
|
"Run 'hermes kanban --help' for the full list of actions.",
|
|
file=sys.stderr,
|
|
)
|
|
return 0
|
|
|
|
# Fast-fail for clearer CLI UX only. The durable trust boundary is lower in
|
|
# hermes_cli.kanban_db, because children can import DB mutators directly.
|
|
if _is_delegated_child_cli_mutation(args):
|
|
return _err("kanban: delegate_task child contexts cannot mutate Kanban tasks via the CLI")
|
|
|
|
# Board-management commands operate on board metadata and the persisted
|
|
# current-board pointer itself, so they must ignore the shared `--board`
|
|
# task-routing override (else `--board beta boards show` reports beta).
|
|
if action == "boards":
|
|
return _dispatch_boards(args)
|
|
|
|
# `--board <slug>` applies to every subcommand below via an env-var pin
|
|
# (HERMES_KANBAN_BOARD) for the duration of this call, so it inherits the
|
|
# exact resolution the dispatcher uses for workers.
|
|
board_override = getattr(args, "board", None)
|
|
board_scope = contextlib.nullcontext()
|
|
if board_override:
|
|
try:
|
|
normed = kb._normalize_board_slug(board_override)
|
|
except ValueError as exc:
|
|
return _err(f"kanban: {exc}", 2)
|
|
if not normed:
|
|
return _err("kanban: --board requires a slug", 2)
|
|
# Boards other than 'default' must already exist — typoed slugs
|
|
# would otherwise silently create an empty board.
|
|
if normed != kb.DEFAULT_BOARD and not kb.board_exists(normed):
|
|
return _err(
|
|
f"kanban: board {normed!r} does not exist. "
|
|
f"Create it with `hermes kanban boards create {normed}`."
|
|
)
|
|
board_scope = kb.scoped_current_board(normed)
|
|
|
|
with board_scope:
|
|
# `repair` must dispatch BEFORE the auto-init: on a corrupt DB init_db()
|
|
# itself raises KanbanDbCorruptError, which would turn every
|
|
# `hermes kanban repair` into "could not initialize database".
|
|
if action == "repair":
|
|
return _cmd_repair(args)
|
|
# Auto-initialize the DB before any subcommand. init_db is idempotent
|
|
# (one SELECT against sqlite_master when tables exist) and prevents
|
|
# "no such table: tasks" on first use from a fresh HERMES_HOME.
|
|
try:
|
|
kb.init_db()
|
|
except Exception as exc:
|
|
return _err(f"kanban: could not initialize database: {exc}")
|
|
|
|
handler = _HANDLERS.get(action)
|
|
if not handler:
|
|
return _err(f"kanban: unknown action {action!r}", 2)
|
|
try:
|
|
return int(handler(args) or 0)
|
|
except (ValueError, RuntimeError) as exc:
|
|
return _err(f"kanban: {exc}")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Handlers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _profile_author() -> str:
|
|
"""Best-effort author name for an interactive CLI call."""
|
|
for env in ("HERMES_PROFILE_NAME", "HERMES_PROFILE"):
|
|
v = os.environ.get(env)
|
|
if v:
|
|
return v
|
|
try:
|
|
from hermes_cli.profiles import get_active_profile_name
|
|
return get_active_profile_name() or "user"
|
|
except Exception:
|
|
return "user"
|
|
|
|
|
|
_DELEGATED_CHILD_DENIED_ACTIONS: frozenset[str] = frozenset({
|
|
"init", "create", "swarm", "assign", "reclaim", "reassign", "link", "unlink",
|
|
"claim", "comment", "attach", "attach-rm", "complete", "edit", "block",
|
|
"schedule", "unblock", "promote", "archive", "dispatch", "daemon", "repair",
|
|
"heartbeat", "notify-subscribe", "notify-unsubscribe", "specify", "decompose",
|
|
"gc",
|
|
})
|
|
|
|
_DELEGATED_CHILD_DENIED_BOARD_ACTIONS: frozenset[str] = frozenset({
|
|
"create", "new", "rm", "remove", "delete", "switch", "use", "rename",
|
|
"set-default-workdir",
|
|
})
|
|
|
|
|
|
def _is_delegated_child_cli_mutation(args: argparse.Namespace) -> bool:
|
|
action = getattr(args, "kanban_action", None)
|
|
if action == "boards":
|
|
boards_action = getattr(args, "boards_action", None) or "list"
|
|
if boards_action not in _DELEGATED_CHILD_DENIED_BOARD_ACTIONS:
|
|
return False
|
|
elif action not in _DELEGATED_CHILD_DENIED_ACTIONS:
|
|
return False
|
|
try:
|
|
from agent.delegation_context import is_delegated_child_process_context
|
|
|
|
return is_delegated_child_process_context()
|
|
except Exception:
|
|
return bool(os.environ.get("HERMES_DELEGATED_CHILD_CONTEXT"))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Boards management (hermes kanban boards …)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _dispatch_boards(args: argparse.Namespace) -> int:
|
|
"""``hermes kanban boards <action>`` — filesystem-only (board dirs, the
|
|
``current`` pointer, ``board.json``), so it works before ``kanban init``."""
|
|
sub = getattr(args, "boards_action", None) or "list"
|
|
handler = _BOARD_HANDLERS.get(sub)
|
|
if handler is None:
|
|
return _err(f"kanban boards: unknown action {sub!r}", 2)
|
|
return handler(args)
|
|
|
|
|
|
def _board_task_counts(slug: str) -> dict[str, int]:
|
|
"""Return ``{status: count}`` for a board. Safe to call on an empty DB."""
|
|
try:
|
|
path = kb.kanban_db_path(board=slug)
|
|
if not path.exists():
|
|
return {}
|
|
with kb.connect_closing(board=slug) as conn:
|
|
rows = conn.execute(
|
|
"SELECT status, COUNT(*) AS n FROM tasks GROUP BY status"
|
|
).fetchall()
|
|
return {r["status"]: int(r["n"]) for r in rows}
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def _board_slug_arg(args: argparse.Namespace, cmd: str, *, must_exist: bool) -> tuple[Optional[str], int]:
|
|
"""Normalize ``args.slug`` for a ``boards`` subcommand; ``(slug, 0)`` or ``(None, rc)``."""
|
|
try:
|
|
normed = kb._normalize_board_slug(args.slug)
|
|
except ValueError as exc:
|
|
return None, _err(f"kanban boards {cmd}: {exc}", 2)
|
|
if must_exist:
|
|
if not normed or not kb.board_exists(normed):
|
|
return None, _err(f"kanban boards {cmd}: board {args.slug!r} does not exist")
|
|
elif not normed:
|
|
return None, _err(f"kanban boards {cmd}: slug is required", 2)
|
|
return normed, 0
|
|
|
|
|
|
def _cmd_boards_list(args: argparse.Namespace) -> int:
|
|
boards = kb.list_boards(include_archived=bool(getattr(args, "all", False)))
|
|
current = kb.get_current_board()
|
|
for b in boards:
|
|
b["is_current"] = (b["slug"] == current)
|
|
b["counts"] = _board_task_counts(b["slug"])
|
|
b["total"] = sum(b["counts"].values())
|
|
if _json_out(args, boards):
|
|
return 0
|
|
if not boards:
|
|
print("(no boards — create one with `hermes kanban boards create <slug>`)")
|
|
return 0
|
|
print(f"{'':2s} {'SLUG':24s} {'NAME':28s} COUNTS")
|
|
for b in boards:
|
|
marker = "●" if b["is_current"] else " "
|
|
name = b.get("name") or ""
|
|
if b.get("archived"):
|
|
name += " [archived]"
|
|
print(f"{marker:2s} {b['slug']:24s} {name:28s} {_fmt_counts(b['counts'] or {}, '(empty)')}")
|
|
print()
|
|
print(f"Current board: {current}")
|
|
if len(boards) > 1:
|
|
print("Switch boards with `hermes kanban boards switch <slug>`.")
|
|
return 0
|
|
|
|
|
|
def _cmd_boards_create(args: argparse.Namespace) -> int:
|
|
normed, rc = _board_slug_arg(args, "create", must_exist=False)
|
|
if rc:
|
|
return rc
|
|
already = kb.board_exists(normed) and normed != kb.DEFAULT_BOARD
|
|
meta = kb.create_board(
|
|
normed,
|
|
name=args.name,
|
|
description=args.description,
|
|
icon=args.icon,
|
|
color=args.color,
|
|
default_workdir=args.default_workdir,
|
|
)
|
|
verb = "already exists" if already else "created"
|
|
print(f"Board {meta['slug']!r} {verb}.")
|
|
print(f" Display name: {meta.get('name', '')}")
|
|
print(f" DB path: {meta['db_path']}")
|
|
if getattr(args, "switch", False):
|
|
kb.set_current_board(meta["slug"])
|
|
print(f" Switched to {meta['slug']!r}.")
|
|
else:
|
|
print(f" Use `hermes kanban boards switch {meta['slug']}` to make it current.")
|
|
return 0
|
|
|
|
|
|
def _cmd_boards_rm(args: argparse.Namespace) -> int:
|
|
# `boards delete <slug>` (alias) never sets args.delete because --delete
|
|
# belongs to the 'rm' subparser only; treat the alias as `rm --delete`.
|
|
force_delete = getattr(args, "delete", False) or getattr(args, "boards_action", "") == "delete"
|
|
try:
|
|
res = kb.remove_board(args.slug, archive=not force_delete)
|
|
except ValueError as exc:
|
|
return _err(f"kanban boards rm: {exc}")
|
|
if res["action"] == "archived":
|
|
print(f"Board {res['slug']!r} archived → {res['new_path']}")
|
|
print("Recover by moving the directory back to "
|
|
"<root>/kanban/boards/<slug>/.")
|
|
else:
|
|
print(f"Board {res['slug']!r} deleted.")
|
|
return 0
|
|
|
|
|
|
def _cmd_boards_switch(args: argparse.Namespace) -> int:
|
|
normed, rc = _board_slug_arg(args, "switch", must_exist=False)
|
|
if rc:
|
|
return rc
|
|
if not kb.board_exists(normed):
|
|
return _err(
|
|
f"kanban boards switch: board {normed!r} does not exist. "
|
|
f"Create it with `hermes kanban boards create {normed}`."
|
|
)
|
|
kb.set_current_board(normed)
|
|
print(f"Active board is now {normed!r}.")
|
|
return 0
|
|
|
|
|
|
def _cmd_boards_show(args: argparse.Namespace) -> int:
|
|
current = kb.get_current_board()
|
|
meta = kb.read_board_metadata(current)
|
|
counts = _board_task_counts(current)
|
|
print(f"Current board: {current}")
|
|
print(f" Display name: {meta.get('name', '')}")
|
|
if meta.get("description"):
|
|
print(f" Description: {meta['description']}")
|
|
print(f" DB path: {meta['db_path']}")
|
|
print(f" Tasks: {sum(counts.values())} total"
|
|
+ (f" ({_fmt_counts(counts)})" if counts else ""))
|
|
return 0
|
|
|
|
|
|
def _cmd_boards_rename(args: argparse.Namespace) -> int:
|
|
normed, rc = _board_slug_arg(args, "rename", must_exist=True)
|
|
if rc:
|
|
return rc
|
|
meta = kb.write_board_metadata(normed, name=args.name)
|
|
print(f"Board {normed!r} renamed to {meta['name']!r}.")
|
|
return 0
|
|
|
|
|
|
def _cmd_boards_set_default_workdir(args: argparse.Namespace) -> int:
|
|
normed, rc = _board_slug_arg(args, "set-default-workdir", must_exist=True)
|
|
if rc:
|
|
return rc
|
|
meta = kb.write_board_metadata(normed, default_workdir=args.path)
|
|
new_val = meta.get("default_workdir")
|
|
if new_val:
|
|
print(f"Board {normed!r} default workdir set to {new_val!r}.")
|
|
else:
|
|
print(f"Board {normed!r} default workdir cleared.")
|
|
return 0
|
|
|
|
|
|
def _cmd_boards_export(args: argparse.Namespace) -> int:
|
|
from hermes_cli import kanban_transfer
|
|
from hermes_cli.sizefmt import format_bytes
|
|
|
|
slug = args.slug or kb.get_current_board()
|
|
output = args.output or f"{slug}.tar.gz"
|
|
try:
|
|
res = kanban_transfer.export_board(
|
|
slug,
|
|
output,
|
|
include_attachments=not args.no_attachments,
|
|
include_logs=args.include_logs,
|
|
)
|
|
except (OSError, ValueError) as exc:
|
|
return _err(f"kanban boards export: {exc}")
|
|
|
|
if _json_out(args, res):
|
|
return 0
|
|
counts = res["counts"]
|
|
print(f"Exported board {res['board']!r} → {res['archive']}")
|
|
print(f" Size: {format_bytes(res['size'])}")
|
|
print(f" Tasks: {counts['tasks']}")
|
|
print(f" Comments: {counts['task_comments']}")
|
|
print(f" Attachments: {counts['attachment_files']}")
|
|
print("Import it with `hermes kanban boards import <archive>`.")
|
|
return 0
|
|
|
|
|
|
def _cmd_boards_import(args: argparse.Namespace) -> int:
|
|
from hermes_cli import kanban_transfer
|
|
|
|
try:
|
|
res = kanban_transfer.import_board(
|
|
args.archive, args.as_slug, activate=args.switch
|
|
)
|
|
except (OSError, ValueError) as exc:
|
|
return _err(f"kanban boards import: {exc}")
|
|
|
|
if _json_out(args, res):
|
|
return 0
|
|
print(f"Imported board {res['board']!r} ({res['name']}).")
|
|
if res["renamed"]:
|
|
print(f" Renamed from {res['requested_board']!r} — that slug was taken.")
|
|
print(f" Path: {res['path']}")
|
|
print(f" Tasks: {res['counts']['tasks']}")
|
|
for warning in res["warnings"]:
|
|
print(f" Note: {warning}")
|
|
if res["activated"]:
|
|
print(f" Active board is now {res['board']!r}.")
|
|
else:
|
|
print(f" Switch to it with `hermes kanban boards switch {res['board']}`.")
|
|
return 0
|
|
|
|
|
|
_BOARD_HANDLERS = {
|
|
"list": _cmd_boards_list, "ls": _cmd_boards_list,
|
|
"create": _cmd_boards_create, "new": _cmd_boards_create,
|
|
"rm": _cmd_boards_rm, "remove": _cmd_boards_rm, "delete": _cmd_boards_rm,
|
|
"switch": _cmd_boards_switch, "use": _cmd_boards_switch,
|
|
"show": _cmd_boards_show, "current": _cmd_boards_show,
|
|
"rename": _cmd_boards_rename,
|
|
"set-default-workdir": _cmd_boards_set_default_workdir,
|
|
"export": _cmd_boards_export,
|
|
"import": _cmd_boards_import,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _parse_duration(val) -> Optional[int]:
|
|
"""``30s`` / ``5m`` / ``2h`` / ``1d`` or a raw integer → seconds; None for
|
|
empty input; ValueError on malformed input."""
|
|
if val is None or val == "":
|
|
return None
|
|
s = str(val).strip().lower()
|
|
# Bare integer → seconds.
|
|
try:
|
|
return int(s)
|
|
except ValueError:
|
|
pass
|
|
# Suffixed form.
|
|
units = {"s": 1, "m": 60, "h": 3600, "d": 86400}
|
|
if s and s[-1] in units:
|
|
try:
|
|
n = float(s[:-1])
|
|
except ValueError as exc:
|
|
raise ValueError(f"malformed duration {val!r}") from exc
|
|
return int(n * units[s[-1]])
|
|
raise ValueError(f"malformed duration {val!r} (expected 30s, 5m, 2h, 1d, or a number)")
|
|
|
|
|
|
def _cmd_init(args: argparse.Namespace) -> int:
|
|
path = kb.init_db()
|
|
print(f"Kanban DB initialized at {path}")
|
|
|
|
print()
|
|
# Enumerate profiles on disk so the user knows what assignees are
|
|
# already addressable.
|
|
try:
|
|
profiles = kb.list_profiles_on_disk()
|
|
except Exception:
|
|
profiles = []
|
|
if profiles:
|
|
print(f"Discovered {len(profiles)} profile(s) on disk; any of these can "
|
|
f"be an --assignee:")
|
|
for name in profiles:
|
|
print(f" {name}")
|
|
else:
|
|
print("No profiles found under ~/.hermes/profiles/.")
|
|
print("Create one with `hermes -p <name> setup` before assigning tasks.")
|
|
print()
|
|
print("Next step: start the gateway so ready tasks actually get picked up.")
|
|
print(" hermes gateway start")
|
|
print()
|
|
print(
|
|
"The gateway hosts an embedded dispatcher that ticks every 60 seconds\n"
|
|
"by default (config: kanban.dispatch_interval_seconds). Without a\n"
|
|
"running gateway, tasks stay in 'ready' forever."
|
|
)
|
|
return 0
|
|
|
|
|
|
def _cmd_heartbeat(args: argparse.Namespace) -> int:
|
|
with kb.connect_closing() as conn:
|
|
ok = kb.heartbeat_worker(
|
|
conn,
|
|
args.task_id,
|
|
note=getattr(args, "note", None),
|
|
expected_run_id=_worker_run_id_for(args.task_id),
|
|
)
|
|
if not ok:
|
|
return _err(f"cannot heartbeat {args.task_id} (not running?)")
|
|
print(f"Heartbeat recorded for {args.task_id}")
|
|
return 0
|
|
|
|
|
|
def _cmd_assignees(args: argparse.Namespace) -> int:
|
|
with kb.connect_closing() as conn:
|
|
data = kb.known_assignees(conn)
|
|
if _json_out(args, data):
|
|
return 0
|
|
if not data:
|
|
print("(no assignees — create a profile with `hermes -p <name> setup`)")
|
|
return 0
|
|
print(f"{'NAME':20s} {'ON DISK':8s} COUNTS")
|
|
for entry in data:
|
|
on_disk = "yes" if entry["on_disk"] else "no"
|
|
print(f"{entry['name']:20s} {on_disk:8s} {_fmt_counts(entry['counts'] or {}, '(idle)')}")
|
|
return 0
|
|
|
|
|
|
def _cmd_create(args: argparse.Namespace) -> int:
|
|
try:
|
|
ws_kind, ws_path = _parse_workspace_flag(args.workspace)
|
|
branch_name = _parse_branch_flag(getattr(args, "branch", None))
|
|
except argparse.ArgumentTypeError as exc:
|
|
return _err(f"kanban: {exc}", 2)
|
|
if branch_name and ws_kind != "worktree":
|
|
return _err("kanban: --branch is only valid with --workspace worktree", 2)
|
|
try:
|
|
max_runtime = _parse_duration(getattr(args, "max_runtime", None))
|
|
except ValueError as exc:
|
|
return _err(f"kanban: --max-runtime: {exc}", 2)
|
|
max_retries = getattr(args, "max_retries", None)
|
|
if max_retries is not None and max_retries < 1:
|
|
return _err(
|
|
f"kanban: --max-retries must be >= 1 (got {max_retries}); "
|
|
"use 1 to trip on the first failure.",
|
|
2,
|
|
)
|
|
with kb.connect_closing() as conn:
|
|
task_id = kb.create_task(
|
|
conn,
|
|
title=args.title,
|
|
body=args.body,
|
|
assignee=args.assignee,
|
|
created_by=args.created_by or _profile_author(),
|
|
workspace_kind=ws_kind,
|
|
workspace_path=ws_path,
|
|
branch_name=branch_name,
|
|
project_id=getattr(args, "project", None),
|
|
tenant=args.tenant,
|
|
priority=args.priority,
|
|
parents=tuple(args.parent or ()),
|
|
triage=bool(getattr(args, "triage", False)),
|
|
idempotency_key=getattr(args, "idempotency_key", None),
|
|
max_runtime_seconds=max_runtime,
|
|
skills=getattr(args, "skills", None) or None,
|
|
max_retries=max_retries,
|
|
model_override=getattr(args, "model_override", None),
|
|
provider_override=getattr(args, "provider_override", None),
|
|
goal_mode=bool(getattr(args, "goal_mode", False)),
|
|
goal_max_turns=getattr(args, "goal_max_turns", None),
|
|
initial_status=getattr(args, "initial_status", "running"),
|
|
)
|
|
task = kb.get_task(conn, task_id)
|
|
if getattr(args, "json", False):
|
|
_print_json(_task_to_dict(task))
|
|
else:
|
|
print(f"Created {task_id} ({task.status}, assignee={task.assignee or '-'})")
|
|
|
|
# Warn when the task would sit in `ready` because no dispatcher is
|
|
# present. Only ready+assigned tasks — triage/todo idle by design,
|
|
# unassigned can't dispatch. Skipped in --json so stdout stays
|
|
# machine-parseable.
|
|
if task.status == "ready" and task.assignee:
|
|
running, message = _check_dispatcher_presence()
|
|
if not running and message:
|
|
print(f"\n⚠ {message}", file=sys.stderr)
|
|
return 0
|
|
|
|
|
|
def _cmd_swarm(args: argparse.Namespace) -> int:
|
|
try:
|
|
workers = [ks.parse_worker_arg(raw) for raw in (args.worker or [])]
|
|
except ValueError as exc:
|
|
return _err(f"kanban swarm: {exc}", 2)
|
|
if not workers:
|
|
return _err("kanban swarm: at least one --worker is required", 2)
|
|
with kb.connect_closing() as conn:
|
|
created = ks.create_swarm(
|
|
conn,
|
|
goal=args.goal,
|
|
workers=workers,
|
|
verifier_assignee=args.verifier,
|
|
synthesizer_assignee=args.synthesizer,
|
|
tenant=args.tenant,
|
|
created_by=args.created_by or _profile_author(),
|
|
priority=args.priority,
|
|
idempotency_key=getattr(args, "idempotency_key", None),
|
|
)
|
|
if getattr(args, "json", False):
|
|
_print_json(created.as_dict())
|
|
else:
|
|
print(f"Swarm root: {created.root_id}")
|
|
print("Workers: " + ", ".join(created.worker_ids))
|
|
print(f"Verifier: {created.verifier_id}")
|
|
print(f"Synthesizer: {created.synthesizer_id}")
|
|
return 0
|
|
|
|
|
|
def _cmd_list(args: argparse.Namespace) -> int:
|
|
assignee = args.assignee
|
|
if args.mine and not assignee:
|
|
assignee = _profile_author()
|
|
with kb.connect_closing() as conn:
|
|
# Cheap "mini-dispatch": recompute ready so list output reflects
|
|
# dependencies that may have cleared since the last dispatcher tick.
|
|
kb.recompute_ready(conn)
|
|
tasks = kb.list_tasks(
|
|
conn,
|
|
assignee=assignee,
|
|
status=args.status,
|
|
tenant=args.tenant,
|
|
session_id=args.session,
|
|
include_archived=args.archived,
|
|
order_by=getattr(args, "sort", None),
|
|
workflow_template_id=args.workflow_template_id,
|
|
current_step_key=args.current_step_key,
|
|
)
|
|
if _json_out(args, [_task_to_dict(t) for t in tasks]):
|
|
return 0
|
|
# Passive discoverability: only multi-board users see which board this is.
|
|
try:
|
|
all_boards = kb.list_boards(include_archived=False)
|
|
except Exception:
|
|
all_boards = []
|
|
if len(all_boards) > 1:
|
|
current = kb.get_current_board()
|
|
other_count = len(all_boards) - 1
|
|
print(
|
|
f"Board: {current} "
|
|
f"({other_count} other board{'s' if other_count != 1 else ''} — "
|
|
f"`hermes kanban boards list`)\n"
|
|
)
|
|
if not tasks:
|
|
print("(no matching tasks)")
|
|
return 0
|
|
for t in tasks:
|
|
print(_fmt_task_line(t))
|
|
return 0
|
|
|
|
|
|
def _print_diagnostics(diags, indent: str, *, with_kind: bool) -> None:
|
|
"""Shared human rendering for ``show`` and ``diagnostics`` (suggested actions only)."""
|
|
sev_marker = {"warning": "⚠", "error": "!!", "critical": "!!!"}
|
|
for d in diags:
|
|
head = f"{d.kind}: {d.title}" if with_kind else d.title
|
|
print(f"{indent}{sev_marker.get(d.severity, '?')} [{d.severity}] {head}")
|
|
if d.data:
|
|
bits = [
|
|
f"{k}={','.join(str(x) for x in v)}" if isinstance(v, list) else f"{k}={v}"
|
|
for k, v in d.data.items()
|
|
]
|
|
if bits:
|
|
print(f"{indent} data: {' | '.join(bits)}")
|
|
for a in d.actions:
|
|
if a.suggested:
|
|
print(f"{indent} → {a.label}")
|
|
|
|
|
|
def _cmd_show(args: argparse.Namespace) -> int:
|
|
rsk = _run_state_kwargs(args)
|
|
if rsk is None:
|
|
return _err("kanban show: pass both --state-type and --state-name, or omit both", 2)
|
|
graph = None
|
|
with kb.connect_closing() as conn:
|
|
task = kb.get_task(conn, args.task_id)
|
|
if not task:
|
|
return _err(f"no such task: {args.task_id}")
|
|
comments = kb.list_comments(conn, args.task_id)
|
|
events = kb.list_events(conn, args.task_id)
|
|
parents = kb.parent_ids(conn, args.task_id)
|
|
children = kb.child_ids(conn, args.task_id)
|
|
runs = kb.list_runs(conn, args.task_id, **rsk)
|
|
# Workers hand off via task_runs.summary; tasks.result stays NULL unless
|
|
# explicitly set, so surface the latest summary here.
|
|
latest_summary = kb.latest_summary(conn, args.task_id)
|
|
if not getattr(args, "json", False):
|
|
graph = kb.task_graph_context(conn, task.id)
|
|
|
|
if getattr(args, "json", False):
|
|
_print_json({
|
|
"task": _task_to_dict(task),
|
|
"latest_summary": latest_summary,
|
|
"parents": parents,
|
|
"children": children,
|
|
"comments": [_obj_dict(c, ("author", "body", "created_at")) for c in comments],
|
|
"events": [_obj_dict(e, ("kind", "payload", "created_at", "run_id")) for e in events],
|
|
"runs": [_obj_dict(r, _SHOW_RUN_FIELDS) for r in runs],
|
|
})
|
|
return 0
|
|
|
|
print(f"Task {task.id}: {task.title}")
|
|
print(f" status: {task.status}")
|
|
print(f" assignee: {task.assignee or '-'}")
|
|
if task.tenant:
|
|
print(f" tenant: {task.tenant}")
|
|
print(f" workspace: {task.workspace_kind}" +
|
|
(f" @ {task.workspace_path}" if task.workspace_path else ""))
|
|
if task.branch_name:
|
|
print(f" branch: {task.branch_name}")
|
|
if task.skills:
|
|
print(f" skills: {', '.join(task.skills)}")
|
|
if task.model_override:
|
|
_prov = f" (provider: {task.provider_override})" if task.provider_override else ""
|
|
print(f" model: {task.model_override}{_prov}")
|
|
# Effective retry threshold: per-task override, else config, else default —
|
|
# so operators can see why a task auto-blocked when it did.
|
|
if task.max_retries is not None:
|
|
print(f" max-retries: {task.max_retries} (task)")
|
|
else:
|
|
try:
|
|
from hermes_cli.config import load_config
|
|
cfg = load_config()
|
|
cfg_val = (cfg.get("kanban", {}) or {}).get("failure_limit")
|
|
except Exception:
|
|
cfg_val = None
|
|
if cfg_val is not None and int(cfg_val) != kb.DEFAULT_FAILURE_LIMIT:
|
|
print(f" max-retries: {int(cfg_val)} (config kanban.failure_limit)")
|
|
else:
|
|
print(f" max-retries: {kb.DEFAULT_FAILURE_LIMIT} (default)")
|
|
print(f" created: {_fmt_ts(task.created_at)} by {task.created_by or '-'}")
|
|
|
|
# Diagnostics up top so CLI users see distress signals before scrolling.
|
|
from hermes_cli import kanban_diagnostics as kd
|
|
diags = kd.compute_task_diagnostics(task, events, runs, graph=graph)
|
|
if diags:
|
|
print(f"\n Diagnostics ({len(diags)}):")
|
|
_print_diagnostics(diags, " ", with_kind=False)
|
|
if task.started_at:
|
|
print(f" started: {_fmt_ts(task.started_at)}")
|
|
if task.completed_at:
|
|
print(f" completed: {_fmt_ts(task.completed_at)}")
|
|
if parents:
|
|
print(f" parents: {', '.join(parents)}")
|
|
if children:
|
|
print(f" children: {', '.join(children)}")
|
|
if task.body:
|
|
print()
|
|
print("Body:")
|
|
print(task.body)
|
|
if task.result:
|
|
print()
|
|
print("Result:")
|
|
print(task.result)
|
|
elif latest_summary:
|
|
print()
|
|
print("Latest summary:")
|
|
print(latest_summary)
|
|
if comments:
|
|
print()
|
|
print(f"Comments ({len(comments)}):")
|
|
for c in comments:
|
|
print(f" [{_fmt_ts(c.created_at)}] {c.author}: {c.body}")
|
|
if events:
|
|
print()
|
|
print(f"Events ({len(events)}):")
|
|
for e in events[-20:]:
|
|
pl = f" {e.payload}" if e.payload else ""
|
|
run_tag = f" [run {e.run_id}]" if e.run_id else ""
|
|
print(f" [{_fmt_ts(e.created_at)}]{run_tag} {e.kind}{pl}")
|
|
if runs:
|
|
print()
|
|
print(f"Runs ({len(runs)}):")
|
|
for r in runs:
|
|
# Clamp to 0 so NTP backward-jumps don't print negative seconds.
|
|
elapsed = (max(0, r.ended_at - r.started_at)
|
|
if r.ended_at else None)
|
|
el = f"{elapsed}s" if elapsed is not None else "active"
|
|
outcome = r.outcome or r.status or "active"
|
|
print(f" #{r.id:<3} {outcome:<12} @{r.profile or '-'} {el} "
|
|
f"{_fmt_ts(r.started_at)}")
|
|
if r.summary:
|
|
print(f" → {r.summary.splitlines()[0][:160]}")
|
|
if r.error:
|
|
print(f" ! {r.error.splitlines()[0][:160]}")
|
|
return 0
|
|
|
|
|
|
def _cmd_assign(args: argparse.Namespace) -> int:
|
|
profile = _none_profile(args.profile)
|
|
with kb.connect_closing() as conn:
|
|
ok = kb.assign_task(conn, args.task_id, profile)
|
|
if not ok:
|
|
return _err(f"no such task: {args.task_id}")
|
|
print(f"Assigned {args.task_id} to {profile or '(unassigned)'}")
|
|
return 0
|
|
|
|
|
|
def _cmd_set_model(args: argparse.Namespace) -> int:
|
|
model = args.model
|
|
if model is not None and model.lower() in {"none", "-", "null", ""}:
|
|
model = None
|
|
provider = getattr(args, "provider", None)
|
|
try:
|
|
with kb.connect_closing() as conn:
|
|
ok = kb.set_model_override(conn, args.task_id, model, provider=provider)
|
|
except (ValueError, RuntimeError) as exc:
|
|
return _err(f"kanban: {exc}", 2)
|
|
if not ok:
|
|
return _err(f"no such task: {args.task_id}")
|
|
if model:
|
|
label = f"{provider}:{model}" if provider else model
|
|
print(f"Set model override on {args.task_id}: {label} "
|
|
"(applies on next dispatch)")
|
|
else:
|
|
print(f"Cleared model override on {args.task_id} "
|
|
"(worker uses its profile default)")
|
|
return 0
|
|
|
|
|
|
def _cmd_reclaim(args: argparse.Namespace) -> int:
|
|
with kb.connect_closing() as conn:
|
|
ok = kb.reclaim_task(
|
|
conn, args.task_id,
|
|
reason=getattr(args, "reason", None),
|
|
)
|
|
if not ok:
|
|
return _err(f"cannot reclaim {args.task_id} (not running or unknown id)")
|
|
print(f"Reclaimed {args.task_id}")
|
|
return 0
|
|
|
|
|
|
def _cmd_reassign(args: argparse.Namespace) -> int:
|
|
profile = _none_profile(args.profile)
|
|
with kb.connect_closing() as conn:
|
|
ok = kb.reassign_task(
|
|
conn, args.task_id, profile,
|
|
reclaim_first=bool(getattr(args, "reclaim", False)),
|
|
reason=getattr(args, "reason", None),
|
|
)
|
|
if not ok:
|
|
return _err(
|
|
f"cannot reassign {args.task_id} "
|
|
f"(unknown id, or still running — pass --reclaim to release first)"
|
|
)
|
|
print(
|
|
f"Reassigned {args.task_id} to "
|
|
f"{profile or '(unassigned)'}"
|
|
+ (" (claim reclaimed)" if getattr(args, "reclaim", False) else "")
|
|
)
|
|
return 0
|
|
|
|
|
|
def _rows_by_task(conn, table: str, ids: list[str]) -> dict[str, list]:
|
|
"""``{task_id: [rows ordered by id]}`` for every id (empty list when none)."""
|
|
by = {i: [] for i in ids}
|
|
placeholders = ",".join(["?"] * len(ids))
|
|
for row in conn.execute(
|
|
f"SELECT * FROM {table} WHERE task_id IN ({placeholders}) ORDER BY id", tuple(ids),
|
|
):
|
|
by.setdefault(row["task_id"], []).append(row)
|
|
return by
|
|
|
|
|
|
def _cmd_diagnostics(args: argparse.Namespace) -> int:
|
|
"""List active diagnostics on the board via the same rule engine the dashboard uses."""
|
|
from hermes_cli import kanban_diagnostics as kd
|
|
from hermes_cli.config import load_config
|
|
|
|
diag_config = kd.config_from_runtime_config(load_config())
|
|
|
|
with kb.connect_closing() as conn:
|
|
# Either one-task mode or fleet mode.
|
|
if getattr(args, "task", None):
|
|
task = kb.get_task(conn, args.task)
|
|
if task is None:
|
|
return _err(f"no such task: {args.task}")
|
|
diags_by_task = {
|
|
args.task: kd.compute_task_diagnostics(
|
|
task,
|
|
kb.list_events(conn, args.task),
|
|
kb.list_runs(conn, args.task),
|
|
graph=kb.task_graph_context(conn, args.task),
|
|
config=diag_config,
|
|
)
|
|
}
|
|
else:
|
|
# Fleet mode: pull all non-archived tasks + their events/runs.
|
|
rows = list(conn.execute(
|
|
"SELECT * FROM tasks WHERE status != 'archived'"
|
|
).fetchall())
|
|
ids = [r["id"] for r in rows]
|
|
diags_by_task = {}
|
|
if ids:
|
|
ev_by = _rows_by_task(conn, "task_events", ids)
|
|
run_by = _rows_by_task(conn, "task_runs", ids)
|
|
graph_by = kb.task_graph_contexts(conn, ids)
|
|
for r in rows:
|
|
tid = r["id"]
|
|
dl = kd.compute_task_diagnostics(
|
|
r,
|
|
ev_by.get(tid, []),
|
|
run_by.get(tid, []),
|
|
graph=graph_by.get(tid),
|
|
config=diag_config,
|
|
)
|
|
if dl:
|
|
diags_by_task[tid] = dl
|
|
|
|
# Severity filter.
|
|
sev = getattr(args, "severity", None)
|
|
if sev:
|
|
floor = kd.SEVERITY_ORDER.index(sev)
|
|
diags_by_task = {
|
|
tid: kept
|
|
for tid, dl in diags_by_task.items()
|
|
if (kept := [d for d in dl if kd.SEVERITY_ORDER.index(d.severity) >= floor])
|
|
}
|
|
|
|
# Map task_id → title/status/assignee for the table output.
|
|
meta: dict[str, dict] = {}
|
|
if diags_by_task:
|
|
placeholders = ",".join(["?"] * len(diags_by_task))
|
|
for r in conn.execute(
|
|
f"SELECT id, title, status, assignee FROM tasks WHERE id IN ({placeholders})",
|
|
tuple(diags_by_task.keys()),
|
|
):
|
|
meta[r["id"]] = {k: r[k] for k in ("title", "status", "assignee")}
|
|
|
|
if getattr(args, "json", False):
|
|
_print_json([
|
|
{
|
|
"task_id": tid,
|
|
**meta.get(tid, {}),
|
|
"diagnostics": [d.to_dict() for d in dl],
|
|
}
|
|
for tid, dl in diags_by_task.items()
|
|
])
|
|
return 0
|
|
|
|
if not diags_by_task:
|
|
print("No active diagnostics on this board.")
|
|
return 0
|
|
|
|
total = sum(len(dl) for dl in diags_by_task.values())
|
|
print(
|
|
f"{total} active diagnostic(s) across "
|
|
f"{len(diags_by_task)} task(s):\n"
|
|
)
|
|
for tid, dl in diags_by_task.items():
|
|
m = meta.get(tid, {})
|
|
title = m.get("title") or "(untitled)"
|
|
status = m.get("status") or "?"
|
|
assignee = m.get("assignee") or "(unassigned)"
|
|
print(f" {tid} {status:8s} @{assignee:18s} {title}")
|
|
_print_diagnostics(dl, " ", with_kind=True)
|
|
print()
|
|
return 0
|
|
|
|
|
|
def _cmd_link(args: argparse.Namespace) -> int:
|
|
with kb.connect_closing() as conn:
|
|
kb.link_tasks(conn, args.parent_id, args.child_id)
|
|
print(f"Linked {args.parent_id} -> {args.child_id}")
|
|
return 0
|
|
|
|
|
|
def _cmd_unlink(args: argparse.Namespace) -> int:
|
|
with kb.connect_closing() as conn:
|
|
ok = kb.unlink_tasks(conn, args.parent_id, args.child_id)
|
|
if not ok:
|
|
return _err(f"No such link: {args.parent_id} -> {args.child_id}")
|
|
print(f"Unlinked {args.parent_id} -> {args.child_id}")
|
|
return 0
|
|
|
|
|
|
def _cmd_claim(args: argparse.Namespace) -> int:
|
|
with kb.connect_closing() as conn:
|
|
task = kb.claim_task(conn, args.task_id, ttl_seconds=args.ttl)
|
|
if task is None:
|
|
existing = kb.get_task(conn, args.task_id)
|
|
if existing is None:
|
|
return _err(f"no such task: {args.task_id}")
|
|
return _err(
|
|
f"cannot claim {args.task_id}: status={existing.status} "
|
|
f"lock={existing.claim_lock or '(none)'}"
|
|
)
|
|
workspace = kb.resolve_workspace(task)
|
|
kb.set_workspace_path(conn, task.id, str(workspace))
|
|
print(f"Claimed {task.id}")
|
|
print(f"Workspace: {workspace}")
|
|
return 0
|
|
|
|
|
|
def _cmd_comment(args: argparse.Namespace) -> int:
|
|
body = " ".join(args.text).strip()
|
|
if args.max_len is not None:
|
|
if args.max_len < 1:
|
|
return _err("kanban: --max-len must be positive", 2)
|
|
if len(body) > args.max_len:
|
|
suffix = f"\n\n[trimmed to {args.max_len} chars by --max-len]"
|
|
body = body[: max(0, args.max_len - len(suffix))].rstrip() + suffix
|
|
author = args.author or _profile_author()
|
|
with kb.connect_closing() as conn:
|
|
kb.add_comment(conn, args.task_id, author, body)
|
|
print(f"Comment added to {args.task_id}")
|
|
return 0
|
|
|
|
|
|
def _cmd_attach(args: argparse.Namespace) -> int:
|
|
"""Attach a local file via the shared ``store_attachment_bytes`` path (same
|
|
25 MB cap and name sanitisation as the dashboard upload and agent tool)."""
|
|
import mimetypes
|
|
|
|
src = Path(args.path).expanduser()
|
|
if not src.is_file():
|
|
return _err(f"kanban: no such file: {src}")
|
|
data = src.read_bytes()
|
|
name = args.name or src.name
|
|
content_type = args.content_type or mimetypes.guess_type(name)[0]
|
|
uploaded_by = args.author or _profile_author()
|
|
try:
|
|
with kb.connect_closing() as conn:
|
|
att_id = kb.store_attachment_bytes(
|
|
conn,
|
|
args.task_id,
|
|
name,
|
|
data,
|
|
content_type=content_type,
|
|
uploaded_by=uploaded_by,
|
|
)
|
|
except kb.AttachmentTooLarge as exc:
|
|
return _err(f"kanban: {exc}")
|
|
print(f"Attached {name} to {args.task_id} (attachment {att_id}, {len(data)} bytes)")
|
|
return 0
|
|
|
|
|
|
def _cmd_attachments(args: argparse.Namespace) -> int:
|
|
with kb.connect_closing() as conn:
|
|
if kb.get_task(conn, args.task_id) is None:
|
|
return _err(f"no such task: {args.task_id}")
|
|
atts = kb.list_attachments(conn, args.task_id)
|
|
if _json_out(args, [_obj_dict(a, _ATTACHMENT_FIELDS) for a in atts], ascii=True):
|
|
return 0
|
|
if not atts:
|
|
print(f"No attachments on {args.task_id}")
|
|
return 0
|
|
print(f"Attachments on {args.task_id}:")
|
|
for a in atts:
|
|
ct = a.content_type or "-"
|
|
print(f" [{a.id}] {a.filename} ({a.size} bytes, {ct}, by {a.uploaded_by or '-'})")
|
|
print(f" {a.stored_path}")
|
|
return 0
|
|
|
|
|
|
def _cmd_attach_rm(args: argparse.Namespace) -> int:
|
|
with kb.connect_closing() as conn:
|
|
removed = kb.delete_attachment(conn, args.attachment_id)
|
|
if removed is None:
|
|
return _err(f"no such attachment: {args.attachment_id}")
|
|
print(f"Deleted attachment {args.attachment_id} ({removed.filename}) from {removed.task_id}")
|
|
return 0
|
|
|
|
|
|
def _worker_run_id_for(task_id: str) -> Optional[int]:
|
|
if os.environ.get("HERMES_KANBAN_TASK") != task_id:
|
|
return None
|
|
raw = os.environ.get("HERMES_KANBAN_RUN_ID")
|
|
if not raw:
|
|
return None
|
|
try:
|
|
return int(raw)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _goal_mode_handoff_rejection(task: Optional[kb.Task], evidence: str):
|
|
"""Apply the goal judge to every terminal worker handoff, including review.
|
|
|
|
Returns ``(verdict, reason_or_None)`` — ``"done"`` allows the handoff;
|
|
``"blocked"`` means the judge ruled the goal unachievable (#100954);
|
|
``"continue"``/``"wait"`` reject with the judge's reason.
|
|
"""
|
|
if task is None or not task.goal_mode:
|
|
return ("done", None)
|
|
try:
|
|
from agent.auxiliary_client import get_text_auxiliary_client
|
|
|
|
client, model = get_text_auxiliary_client("goal_judge")
|
|
except Exception:
|
|
return ("done", None)
|
|
if client is None or not model:
|
|
return ("done", None)
|
|
|
|
from hermes_cli.goals import judge_goal
|
|
|
|
verdict = "done"
|
|
reason = ""
|
|
try:
|
|
verdict, reason, _, _, _ = judge_goal(
|
|
goal=f"{task.title}\n\n{task.body or ''}".strip(),
|
|
last_response=evidence.strip(),
|
|
)
|
|
except Exception as judge_exc:
|
|
import logging as _logging
|
|
|
|
_logging.getLogger(__name__).warning(
|
|
"goal judge check failed, allowing lifecycle handoff: %s",
|
|
judge_exc,
|
|
exc_info=True,
|
|
)
|
|
return (verdict, None if verdict == "done" else reason)
|
|
|
|
|
|
def _cmd_complete(args: argparse.Namespace) -> int:
|
|
"""Mark one or more tasks done. Supports a single id or a list."""
|
|
ids = list(args.task_ids or [])
|
|
if not ids:
|
|
return _err("at least one task_id is required")
|
|
summary = getattr(args, "summary", None)
|
|
raw_meta = getattr(args, "metadata", None)
|
|
# Structured handoff fields are per-run; copying them across N runs is
|
|
# almost always a footgun, so refuse rather than silently do it.
|
|
if len(ids) > 1 and (summary or raw_meta):
|
|
return _err(
|
|
"kanban: --summary / --metadata are per-task and can't be used "
|
|
"with multiple ids (would apply the same handoff to every task). "
|
|
"Complete tasks one at a time, or drop the flags for the bulk close.",
|
|
2,
|
|
)
|
|
metadata, rc = _parse_metadata_flag(raw_meta)
|
|
if rc:
|
|
return rc
|
|
fail_msg: dict[str, str] = {}
|
|
with kb.connect_closing() as conn:
|
|
def op(tid):
|
|
# Goal-mode judge gate (mirrors tools/kanban_tools.py); applied to
|
|
# every terminal handoff so request-review can't bypass it.
|
|
gate_verdict, rejection = _goal_mode_handoff_rejection(
|
|
kb.get_task(conn, tid),
|
|
(summary or args.result or "").strip(),
|
|
)
|
|
if gate_verdict == "blocked":
|
|
fail_msg[tid] = (
|
|
f"kanban: goal completion of {tid} rejected: judge ruled "
|
|
f"the goal unachievable — {rejection}. Re-scope with "
|
|
f"kanban edit, or record the block with kanban block "
|
|
f"instead of completing."
|
|
)
|
|
return False
|
|
if rejection is not None:
|
|
fail_msg[tid] = (
|
|
f"kanban: goal completion of {tid} rejected by judge: {rejection}. "
|
|
f"Provide evidence matching the task's acceptance criteria."
|
|
)
|
|
return False
|
|
fail_msg[tid] = f"cannot complete {tid} (unknown id or terminal state)"
|
|
return kb.complete_task(
|
|
conn, tid,
|
|
result=args.result,
|
|
summary=summary,
|
|
metadata=metadata,
|
|
expected_run_id=_worker_run_id_for(tid),
|
|
)
|
|
|
|
return _bulk_apply(ids, op, lambda tid: f"Completed {tid}", fail_msg.__getitem__)
|
|
|
|
|
|
def _cmd_edit(args: argparse.Namespace) -> int:
|
|
metadata, rc = _parse_metadata_flag(getattr(args, "metadata", None))
|
|
if rc:
|
|
return rc
|
|
with kb.connect_closing() as conn:
|
|
if not kb.edit_completed_task_result(
|
|
conn,
|
|
args.task_id,
|
|
result=args.result,
|
|
summary=getattr(args, "summary", None),
|
|
metadata=metadata,
|
|
):
|
|
return _err(f"cannot edit {args.task_id} (unknown id or task is not done)")
|
|
print(f"Edited {args.task_id}")
|
|
return 0
|
|
|
|
|
|
def _cmd_block(args: argparse.Namespace) -> int:
|
|
reason = " ".join(args.reason).strip() if args.reason else None
|
|
kind = getattr(args, "kind", None)
|
|
author = _profile_author()
|
|
ids = [args.task_id] + list(getattr(args, "ids", None) or [])
|
|
suffix = f": {reason}" if reason else ""
|
|
with kb.connect_closing() as conn:
|
|
def op(tid):
|
|
if reason:
|
|
kb.add_comment(conn, tid, author, f"BLOCKED: {reason}")
|
|
return kb.block_task(
|
|
conn, tid, reason=reason, kind=kind,
|
|
expected_run_id=_worker_run_id_for(tid),
|
|
)
|
|
|
|
def ok_msg(tid):
|
|
# Report where the task actually landed — dependency blocks go
|
|
# to todo, and a tripped unblock-loop breaker routes to triage.
|
|
landed = kb.get_task(conn, tid)
|
|
where = landed.status if landed else "blocked"
|
|
if where == "todo":
|
|
return f"{tid} → todo (dependency wait){suffix}"
|
|
if where == "triage":
|
|
return (f"{tid} → triage (unblock loop detected — needs a "
|
|
f"human decision){suffix}")
|
|
return f"Blocked {tid}{suffix}"
|
|
|
|
return _bulk_apply(ids, op, ok_msg, lambda tid: f"cannot block {tid}")
|
|
|
|
|
|
def _cmd_schedule(args: argparse.Namespace) -> int:
|
|
reason = " ".join(args.reason).strip() if args.reason else None
|
|
author = _profile_author()
|
|
ids = [args.task_id] + list(getattr(args, "ids", None) or [])
|
|
suffix = f": {reason}" if reason else ""
|
|
with kb.connect_closing() as conn:
|
|
def op(tid):
|
|
if reason:
|
|
kb.add_comment(conn, tid, author, f"SCHEDULED: {reason}")
|
|
return kb.schedule_task(
|
|
conn, tid, reason=reason, expected_run_id=_worker_run_id_for(tid),
|
|
)
|
|
|
|
return _bulk_apply(
|
|
ids, op, lambda tid: f"Scheduled {tid}{suffix}", lambda tid: f"cannot schedule {tid}",
|
|
)
|
|
|
|
|
|
def _cmd_unblock(args: argparse.Namespace) -> int:
|
|
ids = list(args.task_ids or [])
|
|
if not ids:
|
|
return _err("at least one task_id is required")
|
|
reason = getattr(args, "reason", None)
|
|
if reason is not None:
|
|
reason = reason.strip() or None
|
|
author = _profile_author() if reason else None
|
|
suffix = f": {reason}" if reason else ""
|
|
with kb.connect_closing() as conn:
|
|
def op(tid):
|
|
if reason:
|
|
kb.add_comment(conn, tid, author, f"UNBLOCK: {reason}")
|
|
return kb.unblock_task(conn, tid)
|
|
|
|
return _bulk_apply(
|
|
ids, op, lambda tid: f"Unblocked {tid}{suffix}",
|
|
lambda tid: f"cannot unblock {tid} (not blocked/scheduled?)",
|
|
)
|
|
|
|
|
|
def _cmd_request_review(args: argparse.Namespace) -> int:
|
|
tid = args.task_id
|
|
summary = getattr(args, "summary", None)
|
|
if summary is not None:
|
|
summary = summary.strip() or None
|
|
metadata, rc = _parse_metadata_flag(getattr(args, "metadata", None))
|
|
if rc:
|
|
return rc
|
|
reviewer = getattr(args, "reviewer", None)
|
|
with kb.connect_closing() as conn:
|
|
gate_verdict, rejection = _goal_mode_handoff_rejection(
|
|
kb.get_task(conn, tid),
|
|
summary or "",
|
|
)
|
|
if gate_verdict == "blocked":
|
|
return _err(
|
|
f"kanban: goal review handoff of {tid} rejected: judge ruled "
|
|
f"the goal unachievable — {rejection}. Record the block with "
|
|
f"kanban block instead of requesting review."
|
|
)
|
|
if rejection is not None:
|
|
return _err(
|
|
f"kanban: goal review handoff of {tid} rejected by judge: "
|
|
f"{rejection}. Provide acceptance evidence matching the task."
|
|
)
|
|
ok, reason = kb.request_review(
|
|
conn,
|
|
tid,
|
|
summary=summary,
|
|
metadata=metadata,
|
|
reviewer=reviewer,
|
|
expected_run_id=_worker_run_id_for(tid),
|
|
force=bool(getattr(args, "force", False)),
|
|
with_reason=True,
|
|
)
|
|
if not ok:
|
|
return _err(f"cannot request review for {tid}: {reason or 'not running/ready?'}")
|
|
persisted_run = kb.latest_run(conn, tid)
|
|
display_summary = persisted_run.summary if persisted_run else None
|
|
print(
|
|
f"Requested review for {tid}"
|
|
+ (f": {display_summary}" if display_summary else "")
|
|
)
|
|
return 0
|
|
|
|
|
|
def _cmd_request_changes(args: argparse.Namespace) -> int:
|
|
tid = args.task_id
|
|
reason = " ".join(args.reason).strip()
|
|
with kb.connect_closing() as conn:
|
|
ok, detail = kb.request_changes(
|
|
conn,
|
|
tid,
|
|
reason=reason,
|
|
expected_run_id=_worker_run_id_for(tid),
|
|
)
|
|
if not ok:
|
|
return _err(f"cannot request changes for {tid}: {detail or 'invalid review state'}")
|
|
print(
|
|
f"Requested changes for {tid}"
|
|
+ (f"; routed to {detail}" if detail else "")
|
|
)
|
|
return 0
|
|
|
|
|
|
def _cmd_reopen_review(args: argparse.Namespace) -> int:
|
|
ids = list(args.task_ids or [])
|
|
if not ids:
|
|
return _err("at least one task_id is required")
|
|
reason = getattr(args, "reason", None)
|
|
if reason is not None:
|
|
reason = str(kb.redact_review_value(reason.strip())).strip() or None
|
|
author = _profile_author() if reason else None
|
|
suffix = f": {reason}" if reason else ""
|
|
with kb.connect_closing() as conn:
|
|
def op(tid):
|
|
if not kb.reopen_review_task(conn, tid):
|
|
return False
|
|
if reason:
|
|
kb.add_comment(conn, tid, author or "operator", f"CHANGES REQUESTED: {reason}")
|
|
return True
|
|
|
|
return _bulk_apply(
|
|
ids, op, lambda tid: f"Reopened {tid}{suffix}",
|
|
lambda tid: f"cannot reopen {tid} (not in review?)",
|
|
)
|
|
|
|
|
|
def _cmd_promote(args: argparse.Namespace) -> int:
|
|
reason = " ".join(args.reason).strip() if args.reason else None
|
|
author = _profile_author()
|
|
# Dedupe while preserving order; positional task_id always first.
|
|
ids = list(dict.fromkeys([args.task_id, *(getattr(args, "ids", None) or [])]))
|
|
|
|
results: list[dict[str, object]] = []
|
|
with kb.connect_closing() as conn:
|
|
for tid in ids:
|
|
ok, err = kb.promote_task(
|
|
conn,
|
|
tid,
|
|
actor=author,
|
|
reason=reason,
|
|
force=bool(args.force),
|
|
dry_run=bool(args.dry_run),
|
|
)
|
|
results.append({
|
|
"task_id": tid,
|
|
"promoted": ok,
|
|
"dry_run": bool(args.dry_run),
|
|
"forced": bool(args.force),
|
|
"reason": reason,
|
|
"error": err,
|
|
})
|
|
|
|
failed = [r for r in results if not r["promoted"]]
|
|
if getattr(args, "json", False):
|
|
# Single-id stays a flat object for back-compat; bulk emits a list.
|
|
_print_json(results[0] if len(results) == 1 else results)
|
|
return 0 if not failed else 1
|
|
|
|
tag = " (dry)" if args.dry_run else ""
|
|
label = "Would promote" if args.dry_run else "Promoted"
|
|
for r in results:
|
|
if r["promoted"]:
|
|
suffix = f": {reason}" if reason else ""
|
|
print(f"{label} {r['task_id']} -> ready{tag}{suffix}")
|
|
else:
|
|
print(f"cannot promote {r['task_id']}: {r['error']}", file=sys.stderr)
|
|
return 0 if not failed else 1
|
|
|
|
|
|
def _cmd_archive(args: argparse.Namespace) -> int:
|
|
ids = list(args.task_ids or [])
|
|
purge_ids = list(getattr(args, "purge_ids", None) or [])
|
|
if ids and purge_ids:
|
|
return _err("choose either task_ids to archive or --rm archived task_ids")
|
|
if not ids and not purge_ids:
|
|
return _err("at least one task_id is required")
|
|
with kb.connect_closing() as conn:
|
|
if purge_ids:
|
|
return _bulk_apply(
|
|
purge_ids, lambda tid: kb.delete_archived_task(conn, tid),
|
|
lambda tid: f"Deleted {tid}",
|
|
lambda tid: f"cannot delete {tid} (must already be archived)",
|
|
)
|
|
return _bulk_apply(
|
|
ids, lambda tid: kb.archive_task(conn, tid),
|
|
lambda tid: f"Archived {tid}", lambda tid: f"cannot archive {tid}",
|
|
)
|
|
|
|
|
|
def _cmd_tail(args: argparse.Namespace) -> int:
|
|
last_id = 0
|
|
print(f"Tailing events for {args.task_id}. Ctrl-C to stop.")
|
|
try:
|
|
while True:
|
|
with kb.connect_closing() as conn:
|
|
events = kb.list_events(conn, args.task_id)
|
|
for e in events:
|
|
if e.id > last_id:
|
|
pl = f" {e.payload}" if e.payload else ""
|
|
print(f"[{_fmt_ts(e.created_at)}] {e.kind}{pl}", flush=True)
|
|
last_id = e.id
|
|
time.sleep(max(0.1, args.interval))
|
|
except KeyboardInterrupt:
|
|
print("\n(stopped)")
|
|
return 0
|
|
|
|
|
|
def _coerce_positive_int(value):
|
|
if value is None:
|
|
return None
|
|
try:
|
|
ival = int(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
return ival if ival >= 1 else None
|
|
|
|
|
|
def _cmd_dispatch(args: argparse.Namespace) -> int:
|
|
# Honour kanban.default_assignee, kanban.max_in_progress,
|
|
# kanban.max_in_progress_per_profile and kanban.max_spawn with the same
|
|
# semantics as the gateway dispatch path.
|
|
try:
|
|
from hermes_cli.config import load_config
|
|
_cfg = load_config()
|
|
_kanban_cfg = _cfg.get("kanban", {}) if isinstance(_cfg, dict) else {}
|
|
default_assignee = (_kanban_cfg.get("default_assignee") or "").strip() or None
|
|
max_in_progress_per_profile = _coerce_positive_int(
|
|
_kanban_cfg.get("max_in_progress_per_profile")
|
|
)
|
|
# Memory-derived default when unset — same fallback the gateway applies.
|
|
max_in_progress = kb.resolve_max_in_progress(
|
|
_coerce_positive_int(_kanban_cfg.get("max_in_progress"))
|
|
)
|
|
# CLI --max is the more explicit signal, so it wins over kanban.max_spawn.
|
|
cli_max = getattr(args, "max", None)
|
|
max_spawn = cli_max if cli_max is not None else _coerce_positive_int(
|
|
_kanban_cfg.get("max_spawn")
|
|
)
|
|
except Exception:
|
|
default_assignee = None
|
|
max_in_progress_per_profile = None
|
|
max_in_progress = None
|
|
max_spawn = getattr(args, "max", None)
|
|
with kb.connect_closing() as conn:
|
|
res = kb.dispatch_once(
|
|
conn,
|
|
dry_run=args.dry_run,
|
|
max_spawn=max_spawn,
|
|
max_in_progress=max_in_progress,
|
|
failure_limit=getattr(args, "failure_limit", kb.DEFAULT_SPAWN_FAILURE_LIMIT),
|
|
default_assignee=default_assignee,
|
|
max_in_progress_per_profile=max_in_progress_per_profile,
|
|
)
|
|
if getattr(args, "json", False):
|
|
_print_json({
|
|
"reclaimed": res.reclaimed,
|
|
"crashed": res.crashed,
|
|
"timed_out": res.timed_out,
|
|
"stale": res.stale,
|
|
"auto_blocked": res.auto_blocked,
|
|
"promoted": res.promoted,
|
|
"spawned": [
|
|
{"task_id": tid, "assignee": who, "workspace": ws}
|
|
for (tid, who, ws) in res.spawned
|
|
],
|
|
"skipped_unassigned": res.skipped_unassigned,
|
|
"skipped_nonspawnable": res.skipped_nonspawnable,
|
|
"skipped_per_profile_capped": [
|
|
{"task_id": tid, "assignee": who, "current": current}
|
|
for (tid, who, current) in res.skipped_per_profile_capped
|
|
],
|
|
"auto_assigned_default": res.auto_assigned_default,
|
|
}, ascii=True)
|
|
return 0
|
|
print(f"Reclaimed: {res.reclaimed}")
|
|
for label, items in (
|
|
("Crashed: ", res.crashed),
|
|
("Timed out: ", res.timed_out),
|
|
("Stale: ", res.stale),
|
|
("Auto-blocked:", res.auto_blocked),
|
|
):
|
|
print(f"{label} {len(items)}")
|
|
if items:
|
|
print(f" {', '.join(items)}")
|
|
print(f"Promoted: {res.promoted}")
|
|
print(f"Spawned: {len(res.spawned)}")
|
|
tag = " (dry)" if args.dry_run else ""
|
|
for tid, who, ws in res.spawned:
|
|
print(f" - {tid} -> {who} @ {ws or '-'}{tag}")
|
|
if res.auto_assigned_default:
|
|
print(
|
|
f"Auto-assigned to kanban.default_assignee={default_assignee!r}: "
|
|
f"{', '.join(res.auto_assigned_default)}"
|
|
)
|
|
if res.skipped_unassigned:
|
|
print(f"Skipped (unassigned): {', '.join(res.skipped_unassigned)}")
|
|
for tid, who, current in res.skipped_per_profile_capped:
|
|
print(f"Deferred ({who} at per-profile cap, {current} running): {tid}")
|
|
if res.skipped_nonspawnable:
|
|
print(
|
|
f"Skipped (non-spawnable assignee — terminal lane, OK): "
|
|
f"{', '.join(res.skipped_nonspawnable)}"
|
|
)
|
|
return 0
|
|
|
|
|
|
def _cmd_daemon(args: argparse.Namespace) -> int:
|
|
"""Deprecated — the dispatcher now runs inside the gateway.
|
|
|
|
Kept as a stub so old scripts/systemd units get a clear migration message.
|
|
``--force`` (hidden from --help) keeps the standalone loop for hosts that
|
|
truly cannot run the gateway; the default path exits 2 so nobody
|
|
accidentally runs two dispatchers against the same kanban.db.
|
|
"""
|
|
if not getattr(args, "force", False):
|
|
return _err(
|
|
"hermes kanban daemon: DEPRECATED — the dispatcher now runs\n"
|
|
"inside the gateway. To use kanban:\n"
|
|
"\n"
|
|
" hermes gateway start # starts the gateway + embedded dispatcher\n"
|
|
"\n"
|
|
"Ready tasks will be picked up on the next dispatcher tick\n"
|
|
"(default: every 60 seconds). Configure via config.yaml:\n"
|
|
"\n"
|
|
" kanban:\n"
|
|
" dispatch_in_gateway: true # default\n"
|
|
" dispatch_interval_seconds: 60\n"
|
|
" failure_limit: 2 # consecutive non-success attempts before auto-block\n"
|
|
"\n"
|
|
"Running both the gateway AND this standalone daemon will\n"
|
|
"race for claims. If you truly need the old standalone\n"
|
|
"daemon (no gateway available), rerun with --force.",
|
|
2,
|
|
)
|
|
|
|
# Init before printing "started" so the DB path is right and init errors
|
|
# surface immediately.
|
|
kb.init_db()
|
|
|
|
pidfile = getattr(args, "pidfile", None)
|
|
if pidfile:
|
|
try:
|
|
Path(pidfile).parent.mkdir(parents=True, exist_ok=True)
|
|
Path(pidfile).write_text(str(os.getpid()), encoding="utf-8")
|
|
except OSError as exc:
|
|
print(f"warning: could not write pidfile {pidfile}: {exc}", file=sys.stderr)
|
|
|
|
verbose = bool(getattr(args, "verbose", False))
|
|
print(
|
|
f"Kanban dispatcher running STANDALONE via --force "
|
|
f"(interval={args.interval}s, pid={os.getpid()}). "
|
|
f"Ctrl-C to stop. NOTE: if a gateway is also running with "
|
|
f"dispatch_in_gateway=true (default), you have two dispatchers "
|
|
f"racing for claims.",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
# Health telemetry: warn when every tick finds ready work but spawns
|
|
# nothing (broken profile, PATH drift, missing venv, credential loss) —
|
|
# the per-task breaker auto-blocks quietly, so the operator needs a signal.
|
|
HEALTH_WINDOW = 6 # ticks (default 30s at interval=5)
|
|
health_state = {"bad_ticks": 0, "last_warn_at": 0}
|
|
|
|
def _on_tick(res):
|
|
ready_pending = bool(res.skipped_unassigned) or _ready_queue_nonempty()
|
|
spawned_any = bool(res.spawned)
|
|
if ready_pending and not spawned_any:
|
|
health_state["bad_ticks"] += 1
|
|
else:
|
|
health_state["bad_ticks"] = 0
|
|
# Warn once per HEALTH_WINDOW bad ticks, at most every 5 minutes.
|
|
if health_state["bad_ticks"] >= HEALTH_WINDOW:
|
|
now = int(time.time())
|
|
if now - health_state["last_warn_at"] >= 300:
|
|
print(
|
|
f"[{_fmt_ts(now)}] WARN dispatcher stuck: "
|
|
f"ready queue non-empty for {health_state['bad_ticks']} "
|
|
f"consecutive ticks but 0 workers spawned successfully. "
|
|
f"Check profile health (venv, PATH, credentials) and "
|
|
f"`hermes kanban list --status ready` / "
|
|
f"`hermes kanban list --status blocked` for recent "
|
|
f"spawn_failed tasks.",
|
|
file=sys.stderr, flush=True,
|
|
)
|
|
health_state["last_warn_at"] = now
|
|
if not verbose:
|
|
return
|
|
did_work = (
|
|
res.reclaimed or res.crashed or res.timed_out or res.promoted
|
|
or res.spawned or res.auto_blocked or res.stale
|
|
)
|
|
if did_work:
|
|
print(
|
|
f"[{_fmt_ts(int(time.time()))}] "
|
|
f"reclaimed={res.reclaimed} crashed={len(res.crashed)} "
|
|
f"timed_out={len(res.timed_out)} stale={len(res.stale)} "
|
|
f"promoted={res.promoted} spawned={len(res.spawned)} "
|
|
f"auto_blocked={len(res.auto_blocked)}",
|
|
flush=True,
|
|
)
|
|
|
|
def _ready_queue_nonempty() -> bool:
|
|
"""Is there a ready+assigned+unclaimed task the dispatcher would spawn for?
|
|
Control-plane lanes pulled via ``claim_task`` are correctly idle, not stuck."""
|
|
try:
|
|
with kb.connect_closing() as conn:
|
|
return kb.has_spawnable_ready(conn)
|
|
except Exception:
|
|
return False
|
|
|
|
try:
|
|
kb.run_daemon(
|
|
interval=args.interval,
|
|
max_spawn=args.max,
|
|
failure_limit=getattr(args, "failure_limit", kb.DEFAULT_SPAWN_FAILURE_LIMIT),
|
|
on_tick=_on_tick,
|
|
)
|
|
finally:
|
|
if pidfile:
|
|
try:
|
|
Path(pidfile).unlink()
|
|
except OSError:
|
|
pass
|
|
print("(dispatcher stopped)")
|
|
return 0
|
|
|
|
|
|
def _cmd_watch(args: argparse.Namespace) -> int:
|
|
"""Live-stream task_events to the terminal."""
|
|
kinds = (
|
|
{k.strip() for k in args.kinds.split(",") if k.strip()}
|
|
if args.kinds else None
|
|
)
|
|
print("Watching kanban events. Ctrl-C to stop.", flush=True)
|
|
# Seed cursor at the latest id so we don't replay history.
|
|
with kb.connect_closing() as conn:
|
|
row = conn.execute(
|
|
"SELECT COALESCE(MAX(id), 0) AS m FROM task_events"
|
|
).fetchone()
|
|
cursor = int(row["m"])
|
|
|
|
try:
|
|
while True:
|
|
with kb.connect_closing() as conn:
|
|
rows = conn.execute(
|
|
"SELECT e.id, e.task_id, e.kind, e.payload, e.created_at, "
|
|
" t.assignee, t.tenant "
|
|
"FROM task_events e LEFT JOIN tasks t ON t.id = e.task_id "
|
|
"WHERE e.id > ? ORDER BY e.id ASC LIMIT 200",
|
|
(cursor,),
|
|
).fetchall()
|
|
for r in rows:
|
|
cursor = max(cursor, int(r["id"]))
|
|
if kinds and r["kind"] not in kinds:
|
|
continue
|
|
if args.assignee and r["assignee"] != args.assignee:
|
|
continue
|
|
if args.tenant and r["tenant"] != args.tenant:
|
|
continue
|
|
try:
|
|
payload = json.loads(r["payload"]) if r["payload"] else None
|
|
except Exception:
|
|
payload = None
|
|
pl = f" {payload}" if payload else ""
|
|
print(
|
|
f"[{_fmt_ts(r['created_at'])}] {r['task_id']:10s} "
|
|
f"{r['kind']:18s} (@{r['assignee'] or '-'}){pl}",
|
|
flush=True,
|
|
)
|
|
time.sleep(max(0.1, args.interval))
|
|
except KeyboardInterrupt:
|
|
print("\n(stopped)")
|
|
return 0
|
|
|
|
|
|
def _cmd_stats(args: argparse.Namespace) -> int:
|
|
with kb.connect_closing() as conn:
|
|
stats = kb.board_stats(conn)
|
|
if _json_out(args, stats):
|
|
return 0
|
|
print("By status:")
|
|
for k in ("triage", "todo", "scheduled", "ready", "running", "blocked", "done"):
|
|
print(f" {k:8s} {stats['by_status'].get(k, 0)}")
|
|
if stats["by_assignee"]:
|
|
print("\nBy assignee:")
|
|
for who, counts in sorted(stats["by_assignee"].items()):
|
|
print(f" {who:20s} {_fmt_counts(counts)}")
|
|
age = stats["oldest_ready_age_seconds"]
|
|
if age is not None:
|
|
print(f"\nOldest ready task age: {int(age)}s")
|
|
return 0
|
|
|
|
|
|
def _cmd_notify_subscribe(args: argparse.Namespace) -> int:
|
|
with kb.connect_closing() as conn:
|
|
if kb.get_task(conn, args.task_id) is None:
|
|
return _err(f"no such task: {args.task_id}")
|
|
kb.add_notify_sub(
|
|
conn, task_id=args.task_id,
|
|
platform=args.platform, chat_id=args.chat_id,
|
|
chat_type=args.chat_type,
|
|
thread_id=args.thread_id, user_id=args.user_id,
|
|
user_id_alt=getattr(args, "user_id_alt", None),
|
|
notifier_profile=args.notifier_profile or _profile_author(),
|
|
delivery_mode=getattr(args, "delivery_mode", None),
|
|
)
|
|
print(f"Subscribed {args.platform}:{args.chat_id}"
|
|
+ (f":{args.thread_id}" if args.thread_id else "")
|
|
+ f" to {args.task_id}")
|
|
return 0
|
|
|
|
|
|
def _cmd_notify_list(args: argparse.Namespace) -> int:
|
|
with kb.connect_closing() as conn:
|
|
subs = kb.list_notify_subs(conn, args.task_id)
|
|
if _json_out(args, subs):
|
|
return 0
|
|
if not subs:
|
|
print("(no subscriptions)")
|
|
return 0
|
|
for s in subs:
|
|
thr = f":{s['thread_id']}" if s.get("thread_id") else ""
|
|
owner = f" owner={s['notifier_profile']}" if s.get("notifier_profile") else ""
|
|
dmode = s.get("delivery_mode") or "notify"
|
|
mode = "" if dmode == "notify" else f" mode={dmode}"
|
|
ctype = s.get("chat_type") or "dm"
|
|
ct = "" if ctype == "dm" else f" chat_type={ctype}"
|
|
uid_alt = f" user_id_alt={s['user_id_alt']}" if s.get("user_id_alt") else ""
|
|
print(f" {s['task_id']:10s} {s['platform']}:{s['chat_id']}{thr}"
|
|
f" (since event {s['last_event_id']}){owner}{ct}{uid_alt}{mode}")
|
|
return 0
|
|
|
|
|
|
def _cmd_notify_unsubscribe(args: argparse.Namespace) -> int:
|
|
with kb.connect_closing() as conn:
|
|
ok = kb.remove_notify_sub(
|
|
conn, task_id=args.task_id,
|
|
platform=args.platform, chat_id=args.chat_id,
|
|
thread_id=args.thread_id,
|
|
)
|
|
if not ok:
|
|
return _err("(no such subscription)")
|
|
print(f"Unsubscribed from {args.task_id}")
|
|
return 0
|
|
|
|
|
|
def _cmd_log(args: argparse.Namespace) -> int:
|
|
content = kb.read_worker_log(args.task_id, tail_bytes=args.tail)
|
|
if content is None:
|
|
return _err(f"(no log for {args.task_id} — task may not have spawned yet)")
|
|
sys.stdout.write(content)
|
|
if not content.endswith("\n"):
|
|
sys.stdout.write("\n")
|
|
return 0
|
|
|
|
|
|
def _cmd_runs(args: argparse.Namespace) -> int:
|
|
"""Show attempt history for a task."""
|
|
rsk = _run_state_kwargs(args)
|
|
if rsk is None:
|
|
return _err("kanban runs: pass both --state-type and --state-name, or omit both", 2)
|
|
with kb.connect_closing() as conn:
|
|
runs = kb.list_runs(conn, args.task_id, **rsk)
|
|
if _json_out(args, [_obj_dict(r, _RUNS_RUN_FIELDS) for r in runs]):
|
|
return 0
|
|
if not runs:
|
|
print(f"(no runs yet for {args.task_id})")
|
|
return 0
|
|
print(f"{'#':3s} {'OUTCOME':12s} {'PROFILE':16s} {'ELAPSED':>8s} STARTED")
|
|
for i, r in enumerate(runs, 1):
|
|
end = r.ended_at or int(time.time())
|
|
# Clamp to 0 so NTP backward-jumps don't print negative durations.
|
|
elapsed = max(0, end - r.started_at)
|
|
if elapsed < 60:
|
|
el = f"{elapsed}s"
|
|
elif elapsed < 3600:
|
|
el = f"{elapsed // 60}m"
|
|
else:
|
|
el = f"{elapsed / 3600:.1f}h"
|
|
outcome = r.outcome or ("(running)" if not r.ended_at else r.status)
|
|
print(f"{i:3d} {outcome:12s} {(r.profile or '-'):16s} {el:>8s} {_fmt_ts(r.started_at)}")
|
|
if r.summary:
|
|
print(f" → {r.summary.splitlines()[0][:100]}")
|
|
if r.error:
|
|
print(f" ✖ {r.error[:100]}")
|
|
return 0
|
|
|
|
|
|
def _cmd_context(args: argparse.Namespace) -> int:
|
|
with kb.connect_closing() as conn:
|
|
text = kb.build_worker_context(conn, args.task_id)
|
|
print(text)
|
|
return 0
|
|
|
|
|
|
def _triage_sweep_ids(args: argparse.Namespace, verb: str, list_triage_ids, json_key: str):
|
|
"""Shared arg validation for ``specify`` / ``decompose``: ``(ids|None, rc)``.
|
|
|
|
``ids is None`` with ``rc == 0`` means "nothing to do, already reported".
|
|
"""
|
|
all_flag = bool(getattr(args, "all_triage", False))
|
|
tenant = getattr(args, "tenant", None)
|
|
if args.task_id and all_flag:
|
|
return None, _err("kanban: pass either a task id OR --all, not both", 2)
|
|
if all_flag:
|
|
ids = list_triage_ids(tenant=tenant)
|
|
if not ids:
|
|
if getattr(args, "json", False):
|
|
print(json.dumps({json_key: 0, "total": 0}))
|
|
else:
|
|
print("No triage tasks" + (f" for tenant {tenant!r}" if tenant else "") + ".")
|
|
return None, 0
|
|
return ids, 0
|
|
if args.task_id:
|
|
return [args.task_id], 0
|
|
return None, _err(f"kanban: {verb} requires a task id or --all", 2)
|
|
|
|
|
|
def _run_triage_sweep(args: argparse.Namespace, verb: str, mod, run_one, json_key: str,
|
|
json_fields: tuple[str, ...], human_ok) -> int:
|
|
"""Shared driver for ``specify`` / ``decompose``: validate ids, run
|
|
``run_one(tid, author=...)`` per id, print JSON or human lines, exit code."""
|
|
all_flag = bool(getattr(args, "all_triage", False))
|
|
author = getattr(args, "author", None) or _profile_author()
|
|
want_json = bool(getattr(args, "json", False))
|
|
ids, rc = _triage_sweep_ids(args, verb, mod.list_triage_ids, json_key)
|
|
if ids is None:
|
|
return rc
|
|
|
|
ok_count = 0
|
|
for tid in ids:
|
|
outcome = run_one(tid, author=author)
|
|
if outcome.ok:
|
|
ok_count += 1
|
|
if want_json:
|
|
print(json.dumps(_obj_dict(outcome, json_fields)))
|
|
elif outcome.ok:
|
|
print(human_ok(outcome))
|
|
else:
|
|
print(f"kanban: {verb} {outcome.task_id}: {outcome.reason}", file=sys.stderr)
|
|
if not all_flag:
|
|
return 0 if ok_count == 1 else 1
|
|
# --all: succeed if at least one promotion landed; exit 1 only when
|
|
# every candidate failed (honest signal for scripts).
|
|
return 0 if (ok_count > 0 or not ids) else 1
|
|
|
|
|
|
def _retitled_suffix(outcome) -> str:
|
|
return f" — retitled: {outcome.new_title!r}" if outcome.new_title else ""
|
|
|
|
|
|
def _cmd_specify(args: argparse.Namespace) -> int:
|
|
"""Flesh out a triage task (or all of them) via auxiliary LLM, then
|
|
promote to todo. Thin wrapper over ``kanban_specify``."""
|
|
from hermes_cli import kanban_specify as spec
|
|
|
|
return _run_triage_sweep(
|
|
args, "specify", spec, spec.specify_task, "specified",
|
|
("task_id", "ok", "reason", "new_title"),
|
|
lambda o: f"Specified {o.task_id} → todo{_retitled_suffix(o)}",
|
|
)
|
|
|
|
|
|
def _decompose_ok_line(o) -> str:
|
|
if o.fanout and o.child_ids:
|
|
return (f"Decomposed {o.task_id} → {len(o.child_ids)} "
|
|
f"children ({', '.join(o.child_ids)}); root promoted to todo")
|
|
return f"Specified {o.task_id} → todo (no fanout){_retitled_suffix(o)}"
|
|
|
|
|
|
def _cmd_decompose(args: argparse.Namespace) -> int:
|
|
"""Fan a triage task (or all of them) out into a graph of child tasks via
|
|
the auxiliary LLM. Thin wrapper over ``kanban_decompose``."""
|
|
from hermes_cli import kanban_decompose as decomp
|
|
|
|
return _run_triage_sweep(
|
|
args, "decompose", decomp, decomp.decompose_task, "decomposed",
|
|
("task_id", "ok", "reason", "fanout", "child_ids", "new_title"),
|
|
_decompose_ok_line,
|
|
)
|
|
|
|
|
|
def _cmd_gc(args: argparse.Namespace) -> int:
|
|
"""Remove archived tasks' scratch workspaces, old events, and old worker logs."""
|
|
import shutil
|
|
scratch_root = kb.workspaces_root()
|
|
removed_ws = 0
|
|
with kb.connect_closing() as conn:
|
|
rows = conn.execute(
|
|
"SELECT id, workspace_kind, workspace_path, branch_name FROM tasks "
|
|
"WHERE status = 'archived'"
|
|
).fetchall()
|
|
for row in rows:
|
|
if row["workspace_kind"] == "worktree":
|
|
# Backstop for worktrees that escaped the completion/archive hook.
|
|
# Same safety predicate: only clean, fully-pushed worktrees go.
|
|
wt_path = row["workspace_path"]
|
|
if wt_path and Path(wt_path).is_dir():
|
|
kb._cleanup_worktree_workspace(row["id"], wt_path, row["branch_name"])
|
|
if not Path(wt_path).is_dir():
|
|
removed_ws += 1
|
|
continue
|
|
if row["workspace_kind"] != "scratch":
|
|
continue
|
|
path = Path(row["workspace_path"] or (scratch_root / row["id"]))
|
|
try:
|
|
path = path.resolve()
|
|
except OSError:
|
|
continue
|
|
try:
|
|
path.relative_to(scratch_root.resolve())
|
|
except ValueError:
|
|
# Safety: never delete outside the scratch root.
|
|
continue
|
|
if path.exists() and path.is_dir():
|
|
shutil.rmtree(path, ignore_errors=True)
|
|
removed_ws += 1
|
|
|
|
event_days = getattr(args, "event_retention_days", 30)
|
|
log_days = getattr(args, "log_retention_days", 30)
|
|
with kb.connect_closing() as conn:
|
|
removed_events = kb.gc_events(
|
|
conn, older_than_seconds=event_days * 24 * 3600,
|
|
)
|
|
removed_logs = kb.gc_worker_logs(
|
|
older_than_seconds=log_days * 24 * 3600,
|
|
)
|
|
print(f"GC complete: {removed_ws} workspace(s), "
|
|
f"{removed_events} event row(s), {removed_logs} log file(s) removed")
|
|
return 0
|
|
|
|
|
|
def _cmd_repair(args: argparse.Namespace) -> int:
|
|
"""Integrity check + narrow index-REINDEX auto-repair. Dispatched BEFORE
|
|
the auto ``kb.init_db()`` (init refuses corrupt DBs). Exit 0 = healthy /
|
|
repaired / no DB file, 1 = still corrupt."""
|
|
try:
|
|
report = kb.repair_db()
|
|
except Exception as exc: # locked/busy probe, unexpected I/O
|
|
return _err(f"kanban repair: {exc}")
|
|
|
|
if getattr(args, "json", False):
|
|
_print_json({
|
|
"status": report.status,
|
|
"db_path": str(report.db_path),
|
|
"messages": report.messages,
|
|
"post_repair_messages": report.post_repair_messages,
|
|
"backup_path": (
|
|
str(report.backup_path) if report.backup_path else None
|
|
),
|
|
"reindexed": report.reindexed,
|
|
}, ascii=True)
|
|
return 0 if report.status in {"ok", "repaired", "missing"} else 1
|
|
|
|
if report.status == "missing":
|
|
print(f"No kanban DB at {report.db_path} — nothing to repair.")
|
|
return 0
|
|
if report.status == "ok":
|
|
print(f"{report.db_path}: integrity_check ok — no repair needed.")
|
|
return 0
|
|
if report.status == "repaired":
|
|
print(f"{report.db_path}: repaired.")
|
|
print(f" reindexed: {', '.join(report.reindexed)}")
|
|
if report.backup_path:
|
|
print(f" pre-repair backup: {report.backup_path}")
|
|
print(" integrity_check now ok.")
|
|
return 0
|
|
# still corrupt
|
|
print(f"{report.db_path}: CORRUPT.", file=sys.stderr)
|
|
for line in (report.messages or [])[:10]:
|
|
print(f" {line}", file=sys.stderr)
|
|
if report.reindexed:
|
|
print(
|
|
f" REINDEX ({', '.join(report.reindexed)}) attempted but "
|
|
f"integrity_check is still failing:",
|
|
file=sys.stderr,
|
|
)
|
|
for line in (report.post_repair_messages or [])[:10]:
|
|
print(f" {line}", file=sys.stderr)
|
|
else:
|
|
print(
|
|
" Not an index-only failure — automatic REINDEX repair does "
|
|
"not apply (fail-closed).",
|
|
file=sys.stderr,
|
|
)
|
|
if report.backup_path:
|
|
print(f" corrupt copy quarantined at: {report.backup_path}",
|
|
file=sys.stderr)
|
|
print(
|
|
" Recover manually (e.g. `sqlite3 kanban.db \".recover\"` into a "
|
|
"fresh file) or move the file aside to start a new board.",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
|
|
_HANDLERS = {
|
|
"init": _cmd_init, "create": _cmd_create, "swarm": _cmd_swarm,
|
|
"list": _cmd_list, "ls": _cmd_list, "show": _cmd_show,
|
|
"assign": _cmd_assign, "set-model": _cmd_set_model,
|
|
"reclaim": _cmd_reclaim, "reassign": _cmd_reassign,
|
|
"diagnostics": _cmd_diagnostics, "diag": _cmd_diagnostics,
|
|
"link": _cmd_link, "unlink": _cmd_unlink, "claim": _cmd_claim,
|
|
"comment": _cmd_comment, "attach": _cmd_attach,
|
|
"attachments": _cmd_attachments, "attach-rm": _cmd_attach_rm,
|
|
"complete": _cmd_complete, "edit": _cmd_edit, "block": _cmd_block,
|
|
"schedule": _cmd_schedule, "unblock": _cmd_unblock,
|
|
"request-review": _cmd_request_review, "request-changes": _cmd_request_changes,
|
|
"reopen-review": _cmd_reopen_review, "promote": _cmd_promote,
|
|
"archive": _cmd_archive, "tail": _cmd_tail, "dispatch": _cmd_dispatch,
|
|
"daemon": _cmd_daemon, "watch": _cmd_watch, "stats": _cmd_stats,
|
|
"log": _cmd_log, "runs": _cmd_runs, "heartbeat": _cmd_heartbeat,
|
|
"assignees": _cmd_assignees, "notify-subscribe": _cmd_notify_subscribe,
|
|
"notify-list": _cmd_notify_list, "notify-unsubscribe": _cmd_notify_unsubscribe,
|
|
"context": _cmd_context, "specify": _cmd_specify, "decompose": _cmd_decompose,
|
|
"gc": _cmd_gc,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Slash-command entry point (used by /kanban from CLI and gateway)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_SLASH_KANBAN_HELP = """\
|
|
**/kanban** — manage the shared task board.
|
|
|
|
Common subcommands:
|
|
`list` (alias `ls`) List tasks on the current board
|
|
`show <id>` Task details + comments + events
|
|
`stats` Per-status / per-assignee counts
|
|
`create <title>…` Create a task (auto-subscribes you to events)
|
|
`comment <id> <msg>` Append a comment
|
|
`attach <id> <path>` Attach a local file; `attachments <id>` to list
|
|
`complete <id>…` Mark task(s) done
|
|
`request-review <id>` Enter first-class review; `request-changes <id> <reason>` returns an active review to its implementer
|
|
`block <id> [reason]` Mark blocked; `schedule <id> [reason]` parks time-delay work; `unblock <id>` to revive
|
|
`assign <id> <profile>` Reassign
|
|
`boards list` Show all boards
|
|
`assignees` Known profiles + counts
|
|
`context <id>` Full worker-context dump
|
|
`runs <id>` Attempt history
|
|
`log <id>` Worker log
|
|
|
|
Run `/kanban <subcommand> -h` for arguments. \
|
|
Read-only commands are safe while an agent is running.\
|
|
"""
|
|
|
|
|
|
def run_slash(rest: str) -> str:
|
|
"""Execute a ``/kanban …`` string and return captured stdout/stderr.
|
|
|
|
``rest`` is everything after ``/kanban``. Shared by the interactive CLI
|
|
and the gateway so formatting is identical.
|
|
"""
|
|
import io
|
|
|
|
tokens = shlex.split(rest) if rest and rest.strip() else []
|
|
|
|
# Bare ``/kanban`` / ``help`` / ``-h``: the curated short block, not
|
|
# argparse's full usage tree (garbage in a chat bubble). Per-subcommand
|
|
# help still works via ``/kanban foo -h``.
|
|
if not tokens or tokens[0] in {"help", "--help", "-h", "?"}:
|
|
return _SLASH_KANBAN_HELP
|
|
|
|
# build_parser() needs a subparsers action to attach to, so build a
|
|
# throwaway one and pull kanban_parser back out; drive it directly so
|
|
# usage/error text reads as ``/kanban`` (not ``/kanban-wrap kanban``).
|
|
_wrap = argparse.ArgumentParser(prog="/kanban-wrap", add_help=False)
|
|
_wrap.exit_on_error = False # type: ignore[attr-defined]
|
|
_top_sub = _wrap.add_subparsers(dest="_top")
|
|
kanban_parser = build_parser(_top_sub)
|
|
kanban_parser.prog = "/kanban"
|
|
kanban_parser.exit_on_error = False # type: ignore[attr-defined]
|
|
for _action in kanban_parser._actions:
|
|
if isinstance(_action, argparse._SubParsersAction):
|
|
for _name, _choice in _action.choices.items():
|
|
_choice.prog = f"/kanban {_name}"
|
|
_choice.exit_on_error = False # type: ignore[attr-defined]
|
|
|
|
def _usage_for_error() -> str:
|
|
if tokens:
|
|
for _action in kanban_parser._actions:
|
|
if isinstance(_action, argparse._SubParsersAction):
|
|
subparser = _action.choices.get(tokens[0])
|
|
if subparser is not None:
|
|
return subparser.format_usage().rstrip()
|
|
return kanban_parser.format_usage().rstrip()
|
|
|
|
buf_out = io.StringIO()
|
|
buf_err = io.StringIO()
|
|
# ``-h`` prints to stdout and SystemExit(0); capture both streams.
|
|
try:
|
|
with contextlib.redirect_stdout(buf_out), contextlib.redirect_stderr(buf_err):
|
|
args = kanban_parser.parse_args(tokens)
|
|
except SystemExit as exc:
|
|
out = buf_out.getvalue().rstrip()
|
|
err = buf_err.getvalue().rstrip()
|
|
# Help dump (exit 0) → return the captured help text directly.
|
|
if exc.code in {0, None} and out:
|
|
return out
|
|
body = err or out
|
|
return f"⚠ /kanban usage error\n{body}" if body else "⚠ /kanban usage error"
|
|
except argparse.ArgumentError as exc:
|
|
return f"⚠ /kanban usage error\n{_usage_for_error()}\n{exc}"
|
|
|
|
with contextlib.redirect_stdout(buf_out), contextlib.redirect_stderr(buf_err):
|
|
try:
|
|
kanban_command(args)
|
|
except SystemExit:
|
|
pass
|
|
except Exception as exc:
|
|
print(f"error: {exc}", file=sys.stderr)
|
|
|
|
out = buf_out.getvalue().rstrip()
|
|
err = buf_err.getvalue().rstrip()
|
|
if err and out:
|
|
return f"{out}\n{err}"
|
|
return err if err else (out or "(no output)")
|