mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-11 03:31:55 +00:00
feat(kanban): add specify — auxiliary LLM fleshes out triage tasks (#21435)
* feat(kanban): add `specify` — auxiliary LLM fleshes out triage tasks
The Triage column shipped with a placeholder 'a specifier will flesh
out the spec', but the specifier itself was never built. This wires
it up as a dedicated CLI verb.
`hermes kanban specify <id>` calls the auxiliary LLM (configured under
`auxiliary.triage_specifier`) to expand a rough one-liner into a
concrete spec — tightened title plus a body with Goal / Approach /
Acceptance criteria / Out-of-scope sections — then atomically flips
`status: triage -> todo` and recomputes ready so parent-free tasks
go straight to the dispatcher on the same tick.
Surface:
hermes kanban specify <task_id> # single task
hermes kanban specify --all [--tenant T] # sweep triage column
hermes kanban specify ... --author NAME # audit-comment author
hermes kanban specify ... --json # one JSON line per task
Design choices:
- Parent gating is preserved. specify_triage_task flips to 'todo',
then recompute_ready promotes to 'ready' only when parents are
done — same rule as a normal parent-gated todo.
- No daemon, no background watcher. Every invocation is explicit —
keeps cost predictable and doesn't fight the dispatcher loop.
- Response parse is lenient: strict JSON preferred, markdown-fence
tolerated, raw-body fallback on malformed JSON so the LLM can't
strand a task in triage.
- All failure modes (no aux client, API error, task moved out of
triage mid-call) return SpecifyOutcome(ok=False, reason=...) so
--all continues past individual failures.
Changes:
hermes_cli/kanban_db.py + specify_triage_task()
hermes_cli/kanban_specify.py NEW (~220 LOC — prompt, parse, call)
hermes_cli/kanban.py + specify subcommand + _cmd_specify
hermes_cli/config.py + auxiliary.triage_specifier task slot
website/docs/user-guide/features/kanban.md specify + config notes
website/docs/reference/cli-commands.md CLI reference entry
tests/hermes_cli/test_kanban_specify_db.py NEW (10 tests)
tests/hermes_cli/test_kanban_specify.py NEW (20 tests)
Validation: 30/30 targeted tests pass. E2E: triage task -> specify ->
ends in 'ready' with events [created, specified, promoted] and the
audit comment recorded under the configured author.
* feat(kanban): wire specifier into dashboard and gateway slash
Follow-ups to the initial PR #21435 — closes the two gaps I'd left as
post-merge: dashboard button and first-class gateway surface.
Dashboard (plugins/kanban/dashboard/)
- POST /tasks/:id/specify NEW endpoint. Thin wrapper around
kanban_specify.specify_task(). Returns the CLI outcome shape
({ok, task_id, reason, new_title}); ok=false with a human reason
is a 200, not a 4xx, so the UI can render it inline without
treating 'no aux client configured' as a crash.
- Runs sync in FastAPI's threadpool because the LLM call can take
tens of seconds on reasoning models.
- Pins HERMES_KANBAN_BOARD around the specify call so the module's
argless kb.connect() lands on the right board.
- dist/index.js: doSpecify callback threaded through the drawer →
TaskDetail → StatusActions prop chain. ✨ Specify button appears
ONLY when task.status === 'triage' (elsewhere the backend would
reject anyway — hide the button to keep the action row clean).
Busy state (Specifying…) + inline success/error banner under the
button using the response.reason text.
- dist/style.css: tiny hermes-kanban-msg-ok / -err classes using
existing --color vars so themes reskin cleanly.
Gateway slash (/kanban specify)
- Already works via the existing run_slash → build_parser →
kanban_command pipeline. No code change needed — slash commands
inherit the argparse tree automatically. Added coverage:
test_run_slash_specify_end_to_end (create --triage, specify, verify
promotion + retitle) and test_run_slash_specify_help_is_reachable.
Tests
- tests/plugins/test_kanban_dashboard_plugin.py: 3 new tests for the
REST endpoint — happy path, non-triage rejection as ok=false 200,
missing aux client as ok=false 200.
- tests/hermes_cli/test_kanban_cli.py: 2 new slash-surface tests.
Docs
- website/docs/user-guide/features/kanban.md: dashboard action row
description mentions ✨ Specify + all three surfaces. REST table
gains /tasks/:id/specify. Slash examples include /kanban specify.
Validation: 340/340 targeted tests pass. E2E via TestClient: create a
triage task over REST → POST /specify with mocked aux client → task
moves to 'ready' column on /board with new title and body applied.
This commit is contained in:
parent
732a6c45fa
commit
24d48ffb82
13 changed files with 1328 additions and 20 deletions
109
plugins/kanban/dashboard/dist/index.js
vendored
109
plugins/kanban/dashboard/dist/index.js
vendored
|
|
@ -1905,6 +1905,29 @@
|
|||
}).then(function () { load(); props.onRefresh(); });
|
||||
};
|
||||
|
||||
// Triage specifier — calls the auxiliary LLM to flesh out a rough
|
||||
// idea in the Triage column into a concrete spec (title + body with
|
||||
// goal, approach, acceptance criteria) and promotes it to todo.
|
||||
// Not a PATCH: runs through a dedicated POST endpoint because the
|
||||
// LLM call can take tens of seconds, and its outcome is richer than
|
||||
// a status flip (may update title AND body AND emit an audit
|
||||
// comment — or fail with a human-readable reason that the UI
|
||||
// surfaces inline without treating it as an HTTP error).
|
||||
const doSpecify = function () {
|
||||
return SDK.fetchJSON(
|
||||
withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}/specify`, boardSlug),
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
}
|
||||
).then(function (res) {
|
||||
load();
|
||||
props.onRefresh();
|
||||
return res;
|
||||
});
|
||||
};
|
||||
|
||||
const addLink = function (parentId) {
|
||||
return SDK.fetchJSON(withBoard(`${API}/links`, boardSlug), {
|
||||
method: "POST",
|
||||
|
|
@ -1994,6 +2017,7 @@
|
|||
assignees: props.assignees || [],
|
||||
boardSlug: boardSlug,
|
||||
onPatch: doPatch,
|
||||
onSpecify: doSpecify,
|
||||
onAddParent: addLink,
|
||||
onRemoveParent: removeLink,
|
||||
onAddChild: addChild,
|
||||
|
|
@ -2062,7 +2086,11 @@
|
|||
}) : null,
|
||||
t.created_by ? h(MetaRow, { label: "Created by", value: t.created_by }) : null,
|
||||
),
|
||||
h(StatusActions, { task: t, onPatch: props.onPatch }),
|
||||
h(StatusActions, {
|
||||
task: t,
|
||||
onPatch: props.onPatch,
|
||||
onSpecify: props.onSpecify,
|
||||
}),
|
||||
h(DiagnosticsSection, {
|
||||
task: t,
|
||||
boardSlug: props.boardSlug,
|
||||
|
|
@ -2495,6 +2523,8 @@
|
|||
|
||||
function StatusActions(props) {
|
||||
const t = props.task;
|
||||
const [specifyBusy, setSpecifyBusy] = useState(false);
|
||||
const [specifyMsg, setSpecifyMsg] = useState(null);
|
||||
const b = function (label, patch, enabled, confirmMsg) {
|
||||
return h(Button, {
|
||||
onClick: function () { if (enabled !== false) props.onPatch(patch, { confirm: confirmMsg }); },
|
||||
|
|
@ -2502,22 +2532,67 @@
|
|||
size: "sm",
|
||||
}, label);
|
||||
};
|
||||
return h("div", { className: "hermes-kanban-actions" },
|
||||
b("→ triage", { status: "triage" }, t.status !== "triage"),
|
||||
b("→ ready", { status: "ready" }, t.status !== "ready"),
|
||||
// No direct → running button: /tasks/:id PATCH rejects status=running
|
||||
// with 400 (issue #19535). Tasks enter running only through the
|
||||
// dispatcher's claim_task path, which atomically creates the run row,
|
||||
// claim lock, and worker process metadata.
|
||||
b("Block", { status: "blocked" },
|
||||
t.status === "running" || t.status === "ready",
|
||||
DESTRUCTIVE_TRANSITIONS.blocked),
|
||||
b("Unblock", { status: "ready" }, t.status === "blocked"),
|
||||
b("Complete", { status: "done" },
|
||||
t.status === "running" || t.status === "ready" || t.status === "blocked",
|
||||
DESTRUCTIVE_TRANSITIONS.done),
|
||||
b("Archive", { status: "archived" }, t.status !== "archived",
|
||||
DESTRUCTIVE_TRANSITIONS.archived),
|
||||
|
||||
// "Specify" appears only when the task is in the Triage column — the
|
||||
// one column where an auxiliary LLM pass is meaningful. Elsewhere
|
||||
// the backend would return ok:false with "not in triage" anyway,
|
||||
// so hiding the button keeps the action row uncluttered.
|
||||
const specifyButton = (t.status === "triage" && props.onSpecify)
|
||||
? h(Button, {
|
||||
onClick: function () {
|
||||
if (specifyBusy) return;
|
||||
setSpecifyBusy(true);
|
||||
setSpecifyMsg(null);
|
||||
props.onSpecify().then(function (res) {
|
||||
if (res && res.ok) {
|
||||
const suffix = res.new_title
|
||||
? ` — retitled: ${res.new_title}`
|
||||
: "";
|
||||
setSpecifyMsg({ ok: true, text: `Specified${suffix}` });
|
||||
} else {
|
||||
setSpecifyMsg({
|
||||
ok: false,
|
||||
text: "Specify failed: " + ((res && res.reason) || "unknown error"),
|
||||
});
|
||||
}
|
||||
}).catch(function (err) {
|
||||
setSpecifyMsg({
|
||||
ok: false,
|
||||
text: "Specify failed: " + (err.message || String(err)),
|
||||
});
|
||||
}).then(function () {
|
||||
setSpecifyBusy(false);
|
||||
});
|
||||
},
|
||||
disabled: specifyBusy,
|
||||
size: "sm",
|
||||
}, specifyBusy ? "Specifying…" : "✨ Specify")
|
||||
: null;
|
||||
|
||||
return h("div", null,
|
||||
h("div", { className: "hermes-kanban-actions" },
|
||||
specifyButton,
|
||||
b("→ triage", { status: "triage" }, t.status !== "triage"),
|
||||
b("→ ready", { status: "ready" }, t.status !== "ready"),
|
||||
// No direct → running button: /tasks/:id PATCH rejects status=running
|
||||
// with 400 (issue #19535). Tasks enter running only through the
|
||||
// dispatcher's claim_task path, which atomically creates the run row,
|
||||
// claim lock, and worker process metadata.
|
||||
b("Block", { status: "blocked" },
|
||||
t.status === "running" || t.status === "ready",
|
||||
DESTRUCTIVE_TRANSITIONS.blocked),
|
||||
b("Unblock", { status: "ready" }, t.status === "blocked"),
|
||||
b("Complete", { status: "done" },
|
||||
t.status === "running" || t.status === "ready" || t.status === "blocked",
|
||||
DESTRUCTIVE_TRANSITIONS.done),
|
||||
b("Archive", { status: "archived" }, t.status !== "archived",
|
||||
DESTRUCTIVE_TRANSITIONS.archived),
|
||||
),
|
||||
specifyMsg ? h("div", {
|
||||
className: specifyMsg.ok
|
||||
? "hermes-kanban-msg-ok"
|
||||
: "hermes-kanban-msg-err",
|
||||
}, specifyMsg.text) : null,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
20
plugins/kanban/dashboard/dist/style.css
vendored
20
plugins/kanban/dashboard/dist/style.css
vendored
|
|
@ -402,6 +402,26 @@
|
|||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
/* Specifier result banner — sits directly under the status action row. */
|
||||
.hermes-kanban-msg-ok,
|
||||
.hermes-kanban-msg-err {
|
||||
margin-top: 0.4rem;
|
||||
padding: 0.35rem 0.55rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.hermes-kanban-msg-ok {
|
||||
background: rgba(46, 160, 67, 0.12);
|
||||
color: #2ea043;
|
||||
border: 1px solid rgba(46, 160, 67, 0.35);
|
||||
}
|
||||
.hermes-kanban-msg-err {
|
||||
background: rgba(248, 81, 73, 0.12);
|
||||
color: #f85149;
|
||||
border: 1px solid rgba(248, 81, 73, 0.35);
|
||||
}
|
||||
|
||||
/* ---- Home channel subscription toggles (per-platform, per-task) ----- */
|
||||
|
||||
.hermes-kanban-home-subs {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import asyncio
|
|||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from dataclasses import asdict
|
||||
|
|
@ -1011,6 +1012,61 @@ def reclaim_task_endpoint(
|
|||
conn.close()
|
||||
|
||||
|
||||
class SpecifyBody(BaseModel):
|
||||
"""Optional author override. Nothing else is configurable from the
|
||||
dashboard — model + prompt come from ``auxiliary.triage_specifier``
|
||||
in config.yaml, same as the CLI."""
|
||||
|
||||
author: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/specify")
|
||||
def specify_task_endpoint(
|
||||
task_id: str,
|
||||
payload: SpecifyBody,
|
||||
board: Optional[str] = Query(None),
|
||||
):
|
||||
"""Flesh out a triage-column task via the auxiliary LLM and promote
|
||||
it to ``todo``. Maps 1:1 to ``hermes kanban specify <task_id>``.
|
||||
|
||||
Returns the outcome shape used by the CLI: ``{ok, task_id, reason,
|
||||
new_title}``. A non-OK outcome is NOT an HTTP error — the UI renders
|
||||
the reason inline (e.g. "no auxiliary client configured") so the
|
||||
operator knows what to fix, and retries without a page reload.
|
||||
|
||||
This endpoint runs in FastAPI's threadpool (sync ``def``) because
|
||||
the underlying LLM call can take tens of seconds to minutes on
|
||||
reasoning models, which would block the event loop if we used
|
||||
``async def`` without an explicit ``run_in_executor``.
|
||||
"""
|
||||
board = _resolve_board(board)
|
||||
# Pin the board for the duration of this call so the specifier module
|
||||
# (which calls ``kb.connect()`` with no args) hits the right DB.
|
||||
prev_env = os.environ.get("HERMES_KANBAN_BOARD")
|
||||
try:
|
||||
os.environ["HERMES_KANBAN_BOARD"] = board or kanban_db.DEFAULT_BOARD
|
||||
# Import lazily so a missing auxiliary client at import time
|
||||
# doesn't break plugin load.
|
||||
from hermes_cli import kanban_specify # noqa: WPS433 (intentional)
|
||||
|
||||
outcome = kanban_specify.specify_task(
|
||||
task_id,
|
||||
author=(payload.author or None),
|
||||
)
|
||||
finally:
|
||||
if prev_env is None:
|
||||
os.environ.pop("HERMES_KANBAN_BOARD", None)
|
||||
else:
|
||||
os.environ["HERMES_KANBAN_BOARD"] = prev_env
|
||||
|
||||
return {
|
||||
"ok": bool(outcome.ok),
|
||||
"task_id": outcome.task_id,
|
||||
"reason": outcome.reason,
|
||||
"new_title": outcome.new_title,
|
||||
}
|
||||
|
||||
|
||||
class ReassignBody(BaseModel):
|
||||
profile: Optional[str] = None # "" or None = unassign
|
||||
reclaim_first: bool = False
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue