Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -780,6 +780,19 @@ def _ensure_hermes_home_managed(home: Path):
"timeout": 30,
"extra_body": {},
},
# Triage specifier — flesh out a rough one-liner in the Kanban
# Triage column into a concrete spec, then promote it to ``todo``.
# Invoked by ``hermes kanban specify`` (single id or --all). Set a
# cheap, capable model here (gemini-flash works well); the main
# model is overkill for short spec expansion.
"triage_specifier": {
"provider": "auto",
"model": "",
"base_url": "",
"api_key": "",
"timeout": 120,
"extra_body": {},
},
# Curator — skill-usage review fork. Timeout is generous because the
# review pass can take several minutes on reasoning models (umbrella
# building over hundreds of candidate skills). "auto" = use main chat
Expand Down
111 changes: 111 additions & 0 deletions hermes_cli/kanban.py
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,42 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
)
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.",
)
p_specify.add_argument(
"task_id",
nargs="?",
default=None,
help="Task id to specify (required unless --all is given)",
)
p_specify.add_argument(
"--all",
dest="all_triage",
action="store_true",
help="Specify every task currently in the triage column",
)
p_specify.add_argument(
"--tenant",
default=None,
help="When used with --all, restrict the sweep to this tenant",
)
p_specify.add_argument(
"--author",
default=None,
help="Author name recorded on the audit comment "
"(default: $HERMES_PROFILE or 'specifier')",
)
p_specify.add_argument(
"--json",
action="store_true",
help="Emit one JSON object per task on stdout",
)

# --- gc ---
p_gc = sub.add_parser(
"gc", help="Garbage-collect archived-task workspaces, old events, and old logs",
Expand Down Expand Up @@ -684,6 +720,7 @@ def kanban_command(args: argparse.Namespace) -> int:
"notify-list": _cmd_notify_list,
"notify-unsubscribe": _cmd_notify_unsubscribe,
"context": _cmd_context,
"specify": _cmd_specify,
"gc": _cmd_gc,
}
handler = handlers.get(action)
Expand Down Expand Up @@ -1980,6 +2017,80 @@ def _cmd_context(args: argparse.Namespace) -> int:
return 0


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

all_flag = bool(getattr(args, "all_triage", False))
tenant = getattr(args, "tenant", None)
author = getattr(args, "author", None) or _profile_author()
want_json = bool(getattr(args, "json", False))

if args.task_id and all_flag:
print(
"kanban: pass either a task id OR --all, not both",
file=sys.stderr,
)
return 2

if all_flag:
ids = spec.list_triage_ids(tenant=tenant)
if not ids:
msg = (
"No triage tasks"
+ (f" for tenant {tenant!r}" if tenant else "")
+ "."
)
if want_json:
print(json.dumps({"specified": 0, "total": 0}))
else:
print(msg)
return 0
elif args.task_id:
ids = [args.task_id]
else:
print(
"kanban: specify requires a task id or --all",
file=sys.stderr,
)
return 2

ok_count = 0
fail_count = 0
for tid in ids:
outcome = spec.specify_task(tid, author=author)
if outcome.ok:
ok_count += 1
else:
fail_count += 1
if want_json:
print(json.dumps({
"task_id": outcome.task_id,
"ok": outcome.ok,
"reason": outcome.reason,
"new_title": outcome.new_title,
}))
else:
if outcome.ok:
title_suffix = (
f" — retitled: {outcome.new_title!r}"
if outcome.new_title
else ""
)
print(f"Specified {outcome.task_id} → todo{title_suffix}")
else:
print(
f"kanban: specify {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 _cmd_gc(args: argparse.Namespace) -> int:
"""Remove scratch workspaces of archived tasks, prune old events, and
delete old worker logs."""
Expand Down
85 changes: 85 additions & 0 deletions hermes_cli/kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@
if slug == DEFAULT_BOARD:
return True
d = board_dir(slug)
return d.is_dir() or (d / "kanban.db").exists()

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.
This path depends on a user-provided value.


def kanban_db_path(board: Optional[str] = None) -> Path:
Expand Down Expand Up @@ -2503,6 +2503,91 @@
return True


def specify_triage_task(
conn: sqlite3.Connection,
task_id: str,
*,
title: Optional[str] = None,
body: Optional[str] = None,
author: Optional[str] = None,
) -> bool:
"""Flesh out a triage task and promote it to ``todo``.

Atomically updates ``title`` / ``body`` (when provided) and transitions
``status: triage -> todo`` in a single write txn. Returns False when
the task is missing or not in the ``triage`` column — callers should
surface that as "nothing to specify" rather than an error.

``todo`` (not ``ready``) is the correct landing column: ``recompute_ready``
promotes parent-free / parent-done todos to ``ready`` on the next
dispatcher tick, which keeps the normal parent-gating behaviour intact
for specified tasks that happen to have open parents.

``author`` is recorded on an audit comment only when at least one of
``title`` / ``body`` actually changed — avoids noisy comment spam for
status-only promotions.
"""
if title is not None and not title.strip():
raise ValueError("title cannot be blank")
with write_txn(conn):
existing = conn.execute(
"SELECT title, body FROM tasks WHERE id = ? AND status = 'triage'",
(task_id,),
).fetchone()
if existing is None:
return False
sets: list[str] = ["status = 'todo'"]
params: list[Any] = []
changed_fields: list[str] = []
if title is not None and title.strip() != (existing["title"] or ""):
sets.append("title = ?")
params.append(title.strip())
changed_fields.append("title")
if body is not None and (body or "") != (existing["body"] or ""):
sets.append("body = ?")
params.append(body)
changed_fields.append("body")
params.append(task_id)
cur = conn.execute(
f"UPDATE tasks SET {', '.join(sets)} "
f"WHERE id = ? AND status = 'triage'",
tuple(params),
)
if cur.rowcount != 1:
return False
if changed_fields and author and author.strip():
# Inline INSERT (rather than ``add_comment``) because we're
# already inside this function's write_txn — nested BEGIN
# IMMEDIATE would raise OperationalError. We also skip the
# 'commented' event that ``add_comment`` emits, since the
# 'specified' event below already records the change.
conn.execute(
"INSERT INTO task_comments (task_id, author, body, created_at) "
"VALUES (?, ?, ?, ?)",
(
task_id,
author.strip(),
"Specified — updated "
+ ", ".join(changed_fields)
+ " and promoted to todo.",
int(time.time()),
),
)
_append_event(
conn,
task_id,
"specified",
{"changed_fields": changed_fields} if changed_fields else None,
)
# Outside the write_txn above, so we don't nest BEGIN IMMEDIATE — the
# ready-promotion pass opens its own IMMEDIATE txn. This runs the same
# logic the dispatcher would on its next tick, so a specified task
# with no open parents flips straight to 'ready' here instead of
# idling in 'todo' until the next sweep.
recompute_ready(conn)
return True


def archive_task(conn: sqlite3.Connection, task_id: str) -> bool:
with write_txn(conn):
cur = conn.execute(
Expand Down
Loading
Loading