feat(kanban): task effort estimate via the auxiliary model

An "Estimate" action asks the auto-routed auxiliary model for a rough token
count + complexity band (S/M/L) with a one-line rationale — tokens, not
dollars, since providers don't report cost reliably. POST /estimate (typed
title/body, for the create dialog) and POST /tasks/{id}/estimate (existing
cards) share one core. Desktop renders it inline ("~15k tok · Medium") with a
"makes a model call" disclaimer; SDK exports compactNumber.
This commit is contained in:
Brooklyn Nicholson 2026-07-18 23:24:30 -04:00
parent 027ef381a4
commit 346149c4f8
8 changed files with 405 additions and 22 deletions

View file

@ -20,6 +20,7 @@ import type {
KanbanTask,
KanbanTaskDetail,
OrchestrationSettings,
TaskEstimate,
WorkerLog
} from './types'
@ -203,6 +204,15 @@ export const createBoard = (slug: string, name: string, projectId?: string) =>
body: { slug, name, ...(projectId ? { project_id: projectId } : {}) }
})
/** Rough auxiliary-model estimate for a task (tokens + complexity). Makes a
* model call gate behind an explicit user action + disclaimer. */
export const estimateTask = (id: string) =>
call<TaskEstimate>(withBoard(`/tasks/${id}/estimate`), { method: 'POST', body: {} })
/** Estimate from typed title/body before a task exists (create dialog). */
export const estimateNew = (title: string, body: string) =>
call<TaskEstimate>('/estimate', { method: 'POST', body: { title, body: body || undefined } })
/** Edit a board's display metadata + default project directory. Pass
* `default_workdir: ''` to clear it. Slug is immutable. */
export const updateBoard = (slug: string, patch: Record<string, unknown>) =>

View file

@ -12,6 +12,7 @@ import {
Button,
cn,
Codicon,
compactNumber,
ContextMenu,
ContextMenuContent,
ContextMenuItem,
@ -68,6 +69,7 @@ import {
bulkTasks,
createTask,
deleteTask,
estimateNew,
fetchBoard,
fetchBoards,
fetchProfiles,
@ -77,7 +79,7 @@ import {
import { BoardSwitcher } from './board-switcher'
import { TaskDrawer } from './drawer'
import { OrchestrationPanel } from './orchestration'
import { columnMeta, type KanbanBoard, type KanbanTask } from './types'
import { columnMeta, COMPLEXITY_LABEL, type KanbanBoard, type KanbanTask, type TaskEstimate } from './types'
import {
ago,
ARC_TITLES,
@ -539,9 +541,10 @@ function NewTaskDialog({
// unassigned — parking a card is the explicit choice, not the default.
const resolvedDefault = useOrchestration()?.resolved_default_assignee || 'default'
// Board-level workspace default: a task inherits the current board's project
// workspace (scratch when unscoped, worktree in a git repo, else dir) unless
// overridden below. Set the board's project in the switcher's "Board settings…".
// Board-level workspace default: a task inherits the current board's
// configured project dir (scratch when unset, worktree in a git repo, else
// dir) unless the operator overrides it below. Set the board default in the
// board switcher's "Board settings…".
const selectedSlug = useValue($boardSlug)
const { data: boards } = useQuery({ queryKey: BOARDS_KEY, queryFn: fetchBoards, staleTime: 30_000 })
const currentBoard = boards?.boards.find(b => b.slug === (selectedSlug || boards.current))
@ -555,13 +558,28 @@ function NewTaskDialog({
const [priority, setPriority] = useState('0')
const [skills, setSkills] = useState('')
const [workspaceKind, setWorkspaceKind] = useState<string>(boardDefaultKind)
// Empty = inherit the board's project dir (backend resolves it); a path here
// overrides just this task. Only meaningful for dir/worktree.
// Empty = inherit the board's default project dir (backend resolves it);
// a path here overrides just this task. Only meaningful for dir/worktree.
const [workspacePath, setWorkspacePath] = useState('')
const [parent, setParent] = useState('')
const [goalMode, setGoalMode] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<null | string>(null)
const [estimate, setEstimate] = useState<null | TaskEstimate>(null)
// Rough effort estimate from the typed title/body (before the task exists),
// via the auto-routed auxiliary model. Makes a model call — explicit action.
const estMut = useMutation({
mutationFn: () => estimateNew(title.trim(), bodyText.trim()),
onError: err => host.notify({ kind: 'error', message: errText(err) }),
onSuccess: r => {
if (r.ok) {
setEstimate(r)
} else {
host.notify({ kind: 'warning', message: r.reason || 'Could not estimate' })
}
}
})
// Reset per open — the dialog is externally controlled (open = target set),
// so onOpenChange(true) never fires; key the reset off `target` (and the
@ -579,6 +597,7 @@ function NewTaskDialog({
setGoalMode(false)
setError(null)
setBusy(false)
setEstimate(null)
}
}, [target, boardDefaultKind])
@ -610,7 +629,7 @@ function NewTaskDialog({
title: trimmed,
triage: isTriage,
workspace_kind: workspaceKind,
// Empty → backend inherits the board's project dir.
// Empty → backend inherits the board's default project dir.
workspace_path: workspaceKind !== 'scratch' && workspacePath.trim() ? workspacePath.trim() : undefined
})
@ -743,6 +762,41 @@ function NewTaskDialog({
{error && <span className="text-[0.75rem] text-destructive">{error}</span>}
</div>
<DialogFooter>
<div className="mr-auto flex items-center gap-1 text-[0.75rem] text-(--ui-text-tertiary)">
{estimate?.ok ? (
<>
<Tip label={estimate.rationale || 'Rough estimate'}>
<span className="font-medium tabular-nums text-(--ui-text-secondary)">
~{compactNumber(estimate.est_tokens)} tok
{estimate.complexity ? ` · ${COMPLEXITY_LABEL[estimate.complexity] ?? estimate.complexity}` : ''}
</span>
</Tip>
<Tip label="Re-estimate">
<Button
aria-label="Re-estimate"
disabled={!title.trim() || estMut.isPending}
onClick={() => estMut.mutate()}
size="icon-xs"
variant="ghost"
>
<Codicon name="refresh" size="0.7rem" spinning={estMut.isPending} />
</Button>
</Tip>
</>
) : (
<Tip label="Rough token + complexity estimate from the auxiliary model — makes a model call.">
<Button
disabled={!title.trim() || estMut.isPending}
onClick={() => estMut.mutate()}
size="xs"
variant="ghost"
>
<Codicon name={estMut.isPending ? 'loading' : 'dashboard'} size="0.75rem" spinning={estMut.isPending} />
{estMut.isPending ? 'Estimating…' : 'Estimate'}
</Button>
</Tip>
)}
</div>
<Button onClick={onClose} variant="text">
Cancel
</Button>

View file

@ -10,6 +10,7 @@ import {
Button,
cn,
Codicon,
compactNumber,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
@ -20,6 +21,7 @@ import {
Loader,
LogView,
Textarea,
Tip,
useMutation,
useQuery,
useQueryClient,
@ -31,6 +33,7 @@ import {
$boardSlug,
addComment,
deleteTask,
estimateTask,
fetchLog,
fetchProfiles,
fetchTask,
@ -44,12 +47,14 @@ import {
} from './api'
import {
columnMeta,
COMPLEXITY_LABEL,
type Diagnostic,
type DiagnosticAction,
type KanbanAttachment,
type KanbanEvent,
type KanbanTaskDetail,
SEVERITY_TONE
SEVERITY_TONE,
type TaskEstimate
} from './types'
import {
ago,
@ -426,6 +431,73 @@ function AttachmentsSection({
)
}
// Rough effort estimate via the auxiliary (auto-routed) model. Tokens +
// complexity, never dollars — providers don't report cost reliably. Gated
// behind an explicit click + disclaimer since it makes a model call. The
// control keeps a stable footprint (spinner swaps in place) so nothing jumps.
function EstimateSection({ id }: { id: string }) {
const [result, setResult] = useState<null | TaskEstimate>(null)
const est = useMutation({
mutationFn: () => estimateTask(id),
onError: err => host.notify({ kind: 'error', message: errText(err) }),
onSuccess: r => {
if (r.ok) {
setResult(r)
} else {
host.notify({ kind: 'warning', message: r.reason || 'Could not estimate' })
}
}
})
// A new task resets the cached estimate (the drawer reuses one instance).
useEffect(() => setResult(null), [id])
return (
<Section label="Estimate">
{result?.ok ? (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2 text-[0.8125rem]">
<span className="font-medium tabular-nums text-(--ui-text-secondary)">
~{compactNumber(result.est_tokens)} tok
</span>
{result.complexity && (
<span className="text-(--ui-text-tertiary)">
· {COMPLEXITY_LABEL[result.complexity] ?? result.complexity}
</span>
)}
<Tip label="Re-estimate">
<Button
aria-label="Re-estimate"
className="ml-auto"
disabled={est.isPending}
onClick={() => est.mutate()}
size="icon-xs"
variant="ghost"
>
<Codicon name="refresh" size="0.75rem" spinning={est.isPending} />
</Button>
</Tip>
</div>
{result.rationale && (
<p className="text-[0.6875rem] leading-relaxed text-(--ui-text-quaternary)">{result.rationale}</p>
)}
</div>
) : (
<div className="flex items-center gap-2">
<Button disabled={est.isPending} onClick={() => est.mutate()} size="xs" variant="outline">
<Codicon name={est.isPending ? 'loading' : 'dashboard'} size="0.75rem" spinning={est.isPending} />
{est.isPending ? 'Estimating…' : 'Estimate effort'}
</Button>
<Tip label="Runs a quick auxiliary-model call to estimate tokens + complexity. A rough guide, not a bill.">
<span className="text-[0.625rem] text-(--ui-text-quaternary)">makes a model call</span>
</Tip>
</div>
)}
</Section>
)
}
export function TaskDrawer({
columns,
id,
@ -666,6 +738,8 @@ export function TaskDrawer({
<DescriptionSection body={task.body} onSave={body => void mutate(() => patchTask(task.id, { body }))()} />
<EstimateSection id={task.id} />
{task.result && (
<Section label="Result">
<p className="whitespace-pre-wrap text-[0.8125rem] text-(--ui-text-secondary)">{task.result}</p>

View file

@ -23,6 +23,7 @@ import {
SIDEBAR_NAV_AREA,
type SidebarNavContribution,
STATUSBAR_AREAS,
Tip,
useQuery,
useValue
} from '@hermes/plugin-sdk'
@ -55,18 +56,19 @@ function KanbanCount() {
}
return (
<button
className={cn(
'inline-flex h-full items-center gap-1 rounded-none px-1.5 text-[0.6875rem] tabular-nums transition-colors',
'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
)}
onClick={() => host.navigate('/kanban')}
title={`Kanban — ${count('running')} running, ${count('ready')} ready`}
type="button"
>
<Codicon name="project" size="0.7rem" />
<span>{active}</span>
</button>
<Tip label={`Kanban — ${count('running')} running, ${count('ready')} ready`}>
<button
className={cn(
'inline-flex h-full items-center gap-1 rounded-none px-1.5 text-[0.6875rem] tabular-nums transition-colors',
'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
)}
onClick={() => host.navigate('/kanban')}
type="button"
>
<Codicon name="project" size="0.7rem" />
<span>{active}</span>
</button>
</Tip>
)
}

View file

@ -144,6 +144,18 @@ export interface KanbanProject {
color?: null | string
}
/** POST /tasks/:id/estimate — rough auxiliary-model estimate (never dollars). */
export interface TaskEstimate {
ok: boolean
reason?: null | string
est_tokens?: number
complexity?: 'L' | 'M' | 'S' | null
rationale?: null | string
model?: null | string
}
/** Human-readable complexity band (the backend returns the compact letter). */
export const COMPLEXITY_LABEL: Record<string, string> = { L: 'Large', M: 'Medium', S: 'Small' }
export interface BoardsResponse {
boards: BoardMeta[]

View file

@ -216,6 +216,9 @@ export {
useI18n,
usePluginI18n
} from '@/i18n'
/** THE compact-number formatter every user-facing count/token figure goes
* through here (1230 "1.2k", 1_500_000 "1.5M"). Don't hand-roll `/1000`. */
export { compactNumber } from '@/lib/format'
export { triggerHaptic as haptic } from '@/lib/haptics'
/** The app's lucide icon set (RefreshCw, LayoutDashboard, Activity, …). */
export * as icons from '@/lib/icons'
@ -237,8 +240,6 @@ export const TITLEBAR_AREAS = { center: 'titleBar.center', left: 'titleBar.left'
* setup.runtime_check, reconciled) pass `host.request`. Don't hand-roll
* readiness from raw RPC shapes. */
export { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness'
/** Canonical time formatting every timestamp/age string in the app comes
* from these (localized `Intl` under the hood). Don't hand-roll "Xm ago". */
export { coarseElapsed, fmtDateTime, fmtDayTime, relativeTime } from '@/lib/time'
export { cn } from '@/lib/utils'
export { THEMES_AREA } from '@/themes/user-themes'

View file

@ -1740,6 +1740,134 @@ def reassign_task_endpoint(
conn.close()
# ---------------------------------------------------------------------------
# Estimate — a rough token/complexity estimate for a task via the auxiliary
# (auto-routed) model. NOT a dollar cost: providers don't report cost
# reliably, so we estimate tokens + a complexity band with a one-line why.
# ---------------------------------------------------------------------------
_ESTIMATE_SYSTEM_PROMPT = (
"You estimate how much work an autonomous coding agent will spend on a "
"kanban task. Given the task title and description, respond with STRICT "
"JSON only (no prose, no code fence):\n"
'{"est_tokens": <integer total tokens across the whole run>, '
'"complexity": "S"|"M"|"L", '
'"rationale": "<one short sentence>"}\n'
"Base the token figure on a realistic multi-turn agent run (reading files, "
"tool calls, edits, retries) — not a single reply. S≈small/localized, "
"M≈multi-file, L≈broad or ambiguous. Be honest that this is a rough guess."
)
class EstimateBody(BaseModel):
title: str = ""
body: Optional[str] = None
@router.post("/estimate")
def estimate_text_endpoint(payload: EstimateBody):
"""Estimate from raw title/body — used by the create dialog before a task
exists yet. Same outcome shape as the per-task endpoint below."""
return _run_estimate(payload.title, payload.body)
@router.post("/tasks/{task_id}/estimate")
def estimate_task_endpoint(task_id: str, board: Optional[str] = Query(None)):
"""Rough token + complexity estimate for an existing task via the auxiliary
model. Returns ``{ok, est_tokens, complexity, rationale, model}``; a non-OK
outcome is NOT an HTTP error. Runs in FastAPI's threadpool (sync ``def``)
because the LLM call can take several seconds.
"""
board = _resolve_board(board)
conn = _conn(board=board)
try:
task = kanban_db.get_task(conn, task_id)
finally:
conn.close()
if task is None:
raise HTTPException(status_code=404, detail=f"task {task_id} not found")
return _run_estimate(task.title, task.body)
def _run_estimate(title: str, body: Optional[str]) -> dict:
"""Shared estimate core: ask the auto-routed auxiliary model for a rough
token + complexity read on a task described by ``title``/``body``.
Never raises a bad config / parse / API error becomes
``{"ok": False, "reason": ...}`` so the UI can render it inline.
"""
if not (title or "").strip():
return {"ok": False, "reason": "a title is required to estimate"}
try:
from agent.auxiliary_client import call_llm
except Exception:
return {"ok": False, "reason": "auxiliary client unavailable"}
def _cap(s: Optional[str], n: int) -> str:
s = (s or "").strip()
return s if len(s) <= n else s[:n] + ""
user_msg = (
f"Title: {_cap(title, 400)}\n\n"
f"Description:\n{_cap(body, 4000) or '(none)'}"
)
try:
resp = call_llm(
task="kanban_estimator",
messages=[
{"role": "system", "content": _ESTIMATE_SYSTEM_PROMPT},
{"role": "user", "content": user_msg},
],
temperature=0.0,
max_tokens=300,
timeout=60,
)
except Exception as exc:
return {"ok": False, "reason": f"LLM error: {type(exc).__name__}"}
try:
raw = (resp.choices[0].message.content or "").strip()
model = getattr(resp, "model", None)
except Exception:
raw, model = "", None
# Reuse the same tolerant JSON-blob extraction the specifier uses.
parsed: Optional[dict] = None
try:
import json as _json
import re as _re
blob = raw
if not blob.lstrip().startswith("{"):
m = _re.search(r"\{.*\}", blob, _re.DOTALL)
blob = m.group(0) if m else blob
obj = _json.loads(blob)
if isinstance(obj, dict):
parsed = obj
except Exception:
parsed = None
if not parsed:
return {"ok": False, "reason": "could not parse an estimate from the model"}
try:
est_tokens = int(parsed.get("est_tokens") or 0)
except (TypeError, ValueError):
est_tokens = 0
complexity = str(parsed.get("complexity") or "").strip().upper()
if complexity not in {"S", "M", "L"}:
complexity = None
rationale = str(parsed.get("rationale") or "").strip() or None
return {
"ok": True,
"est_tokens": est_tokens,
"complexity": complexity,
"rationale": rationale,
"model": model,
}
# ---------------------------------------------------------------------------
# Plugin config (read dashboard.kanban.* defaults from config.yaml)
# ---------------------------------------------------------------------------

View file

@ -0,0 +1,102 @@
"""Kanban dashboard plugin: task effort estimate.
The estimate endpoints call the auto-routed auxiliary model and parse a
compact JSON reply (tokens + complexity + rationale). Tests monkeypatch
``call_llm`` so no network is touched.
"""
from __future__ import annotations
import importlib.util
import sys
import types
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from hermes_cli import kanban_db as kb
def _load_plugin_router():
repo_root = Path(__file__).resolve().parents[2]
plugin_file = repo_root / "plugins" / "kanban" / "dashboard" / "plugin_api.py"
spec = importlib.util.spec_from_file_location("hermes_kanban_plugin_est_test", plugin_file)
mod = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = mod
spec.loader.exec_module(mod)
return mod.router
@pytest.fixture
def kanban_home(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
kb.init_db()
return home
@pytest.fixture
def client(kanban_home):
app = FastAPI()
app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban")
return TestClient(app)
def _fake_resp(content: str, model: str = "aux-mini"):
msg = types.SimpleNamespace(content=content)
return types.SimpleNamespace(choices=[types.SimpleNamespace(message=msg)], model=model)
def test_estimate_parses_model_json(client, monkeypatch):
task_id = client.post("/api/plugins/kanban/tasks", json={"title": "big refactor"}).json()["task"]["id"]
import agent.auxiliary_client as aux
def fake_call_llm(**kwargs):
assert kwargs.get("task") == "kanban_estimator"
return _fake_resp('{"est_tokens": 42000, "complexity": "M", "rationale": "multi-file edit"}')
monkeypatch.setattr(aux, "call_llm", fake_call_llm)
body = client.post(f"/api/plugins/kanban/tasks/{task_id}/estimate").json()
assert body["ok"] is True
assert body["est_tokens"] == 42000
assert body["complexity"] == "M"
assert body["rationale"] == "multi-file edit"
assert body["model"] == "aux-mini"
def test_estimate_tolerates_unparseable_reply(client, monkeypatch):
task_id = client.post("/api/plugins/kanban/tasks", json={"title": "vague"}).json()["task"]["id"]
import agent.auxiliary_client as aux
monkeypatch.setattr(aux, "call_llm", lambda **kw: _fake_resp("I cannot estimate this, sorry."))
assert client.post(f"/api/plugins/kanban/tasks/{task_id}/estimate").json()["ok"] is False
def test_estimate_unknown_task_404(client):
assert client.post("/api/plugins/kanban/tasks/t_missing/estimate").status_code == 404
def test_estimate_from_text_no_task(client, monkeypatch):
"""The create dialog estimates from typed title/body before a task exists."""
import agent.auxiliary_client as aux
monkeypatch.setattr(
aux, "call_llm",
lambda **kw: _fake_resp('{"est_tokens": 8000, "complexity": "S", "rationale": "localized"}'),
)
body = client.post(
"/api/plugins/kanban/estimate", json={"title": "tweak a label", "body": "in settings"}
).json()
assert body["ok"] is True
assert body["est_tokens"] == 8000
assert body["complexity"] == "S"
def test_estimate_from_text_requires_title(client):
assert client.post("/api/plugins/kanban/estimate", json={"title": " ", "body": "x"}).json()["ok"] is False