refactor(kanban): fold worker/orchestrator skills into injected guidance (#50473)

The kanban-worker and kanban-orchestrator bundled skills existed only to
be force-loaded into dispatcher-spawned workers, gated by
environments:[kanban] so they wouldn't leak into normal CLI listings.
That gating was fragile (the leak that #50443 patched) and the
--skills auto-load was already best-effort — most workers ran without it
because the bundled skill isn't present in profile-scoped skills dirs.

Remove the skills entirely and promote their load-bearing content
(workspace kinds, deliverable artifacts, created-card integrity, profile
discovery) into KANBAN_GUIDANCE, which is already injected into every
kanban worker's system prompt. Net result: every worker reliably gets
the guidance, nothing can leak into a CLI/blank-slate session, and the
gating machinery is gone.

- agent/prompt_builder.py: promote the 4 load-bearing rules into KANBAN_GUIDANCE
- hermes_cli/kanban_db.py: drop --skills kanban-worker auto-injection + _kanban_worker_skill_available probe
- hermes_cli/kanban_swarm.py: drop skills=[kanban-orchestrator] on the root card
- hermes_cli/kanban.py: drop kanban-init skill seeding; fix help text
- delete skills/devops/kanban-{worker,orchestrator}
- docs: delete the two skill pages (EN+zh), fix sidebars/catalog/kanban.md/kanban-worker-lanes.md and the video-orchestrator + codex-lane references
- tests: update spawn-argv expectations; re-bound the guidance-size guard

Supersedes the skill-leak half of #50443 (credit @helix4u for flagging the area).
This commit is contained in:
Teknium 2026-06-21 17:06:48 -07:00 committed by GitHub
parent e5e2583635
commit 84e1d31e54
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 160 additions and 1575 deletions

View file

@ -26,7 +26,7 @@ from typing import Any, Optional
from hermes_cli import kanban_db as kb
from hermes_cli import kanban_swarm as ks
from hermes_cli.profiles import get_active_profile_name, get_profile_dir, seed_profile_skills
from hermes_cli.profiles import get_active_profile_name
# ---------------------------------------------------------------------------
@ -330,8 +330,8 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
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). Appended to the built-in "
"kanban-worker skill. Example: "
"(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",
@ -1223,21 +1223,6 @@ def _cmd_init(args: argparse.Namespace) -> int:
path = kb.init_db()
print(f"Kanban DB initialized at {path}")
# Seed bundled skills (e.g. kanban-worker) into the active profile so
# the kanban dispatcher can use them without a separate `hermes profile
# create` step. This is best-effort — a missing or broken profile is
# not fatal to `kanban init`.
try:
profile_name = get_active_profile_name() or "default"
profile_dir = get_profile_dir(profile_name)
result = seed_profile_skills(profile_dir, quiet=True)
if result:
copied = result.get("copied", [])
if copied:
print(f"Seeded skill(s) into profile {profile_name}: {', '.join(copied)}")
except Exception:
pass # best-effort
print()
# Enumerate profiles on disk so the user knows what assignees are
# already addressable. Multica does this auto-detection on its
@ -1461,8 +1446,7 @@ def _cmd_show(args: argparse.Namespace) -> int:
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`` (kanban-worker skill);
# ``tasks.result`` is left NULL unless the caller explicitly passed
# Workers hand off via ``task_runs.summary``; ``tasks.result`` is left NULL unless the caller explicitly passed
# ``result=``. Surfacing the latest summary here keeps ``show`` from
# looking like a no-op when the worker actually did real work.
latest_summary = kb.latest_summary(conn, args.task_id)

View file

@ -804,10 +804,9 @@ class Task:
current_run_id: Optional[int] = None
workflow_template_id: Optional[str] = None
current_step_key: Optional[str] = None
# Force-loaded skills for the worker on this task (appended to the
# dispatcher's built-in `kanban-worker` via --skills). Stored as a
# JSON array of skill names. None = use only the defaults; empty
# list = explicitly no extra skills.
# Force-loaded skills for the worker on this task (passed via
# --skills). Stored as a JSON array of skill names. None = use only
# the defaults; empty list = explicitly no extra skills.
skills: Optional[list] = None
model_override: Optional[str] = None
# Per-task override for the consecutive-failure circuit breaker.
@ -1045,8 +1044,7 @@ CREATE TABLE IF NOT EXISTS tasks (
workflow_template_id TEXT,
current_step_key TEXT,
-- Force-loaded skills for the worker on this task, stored as JSON.
-- Appended to the dispatcher's built-in `--skills kanban-worker`.
-- NULL or empty array = no extras.
-- Passed to the worker via `--skills`. NULL or empty array = no extras.
skills TEXT,
-- Per-task model override. When set, the dispatcher passes -m <model>
-- to the worker, overriding the profile's default model. NULL = use
@ -1848,8 +1846,7 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None:
)
if "skills" not in cols:
# JSON array of skill names the dispatcher force-loads into the
# worker (additive to the built-in `kanban-worker`). NULL is fine
# for existing rows.
# worker via --skills. NULL is fine for existing rows.
_add_column_if_missing(conn, "tasks", "skills", "skills TEXT")
if "max_retries" not in cols:
@ -2285,9 +2282,8 @@ def create_task(
``skills`` is an optional list of skill names to force-load into
the worker when dispatched. Stored as JSON; the dispatcher passes
each name to ``hermes --skills ...`` alongside the built-in
``kanban-worker``. Use this to pin a task to a specialist skill
(e.g. ``skills=["translation"]`` so the worker loads the
each name to ``hermes --skills ...``. Use this to pin a task to a
specialist skill (e.g. ``skills=["translation"]`` so the worker loads the
translation skill regardless of the profile's default config).
"""
assignee = _canonical_assignee(assignee)
@ -2348,7 +2344,7 @@ def create_task(
f"{quoted} {noun}, not skill name(s). "
"Put toolsets in the assignee profile's `toolsets:` config "
"instead of per-task skills. Skills are named skill bundles "
"(e.g. `kanban-worker`, `blogwatcher`); toolsets are runtime "
"(e.g. `blogwatcher`, `github-code-review`); toolsets are runtime "
"capabilities (e.g. `web`, `browser`, `terminal`)."
)
skills_list = cleaned
@ -6994,11 +6990,11 @@ def _dispatch_once_locked(
if claimed.workspace_kind == "worktree":
set_branch_name(conn, claimed.id, resolved_branch_name or (claimed.branch_name or "").strip() or f"wt/{claimed.id}")
_maybe_emit_scratch_tip(conn, claimed.id, claimed.workspace_kind)
# Force-load sdlc-review skill for review agents. The
# _default_spawn function already auto-loads kanban-worker, and
# appends task.skills via --skills. Setting task.skills here
# means the review agent gets both kanban-worker (lifecycle)
# and sdlc-review (review logic: AC verification, merge, etc.).
# Force-load the sdlc-review skill for review agents — it carries
# the review logic (AC verification, merge, etc.). The mandatory
# kanban lifecycle is already injected into every worker's system
# prompt via KANBAN_GUIDANCE, so this is the only extra skill the
# review agent needs.
claimed.skills = ["sdlc-review"]
_spawn = spawn_fn if spawn_fn is not None else _default_spawn
try:
@ -7223,41 +7219,6 @@ def _resolve_hermes_argv() -> list[str]:
return _module_hermes_argv()
def _kanban_worker_skill_available(hermes_home: Optional[str]) -> bool:
"""True if the bundled ``kanban-worker`` skill resolves for the home the
spawned worker will run under.
The dispatcher injects ``--skills kanban-worker`` into every worker. When
the worker activates a profile (``hermes -p <name>``), its ``SKILLS_DIR``
becomes ``<profile_home>/skills`` which on many profiles does NOT contain
the bundled skill (it ships in the *default* root home, not every
profile-scoped skills dir). Preloading a missing skill is fatal at CLI
startup (``ValueError: Unknown skill(s): kanban-worker``), aborting the
worker before the agent loop runs. Gate the flag on actual resolvability;
the kanban lifecycle contract is still injected via ``KANBAN_GUIDANCE``, so
omitting the flag only drops the supplementary pattern library.
"""
from pathlib import Path as _Path
# An unset HERMES_HOME means the worker falls back to the default root
# home (``~/.hermes``), which ships the bundled skill.
base = _Path(hermes_home) if hermes_home else (_Path.home() / ".hermes")
skills_root = base / "skills"
if not skills_root.is_dir():
return False
# Canonical bundled location first (cheap), then a bounded scan for
# profiles that have it nested elsewhere.
if (skills_root / "devops" / "kanban-worker" / "SKILL.md").is_file():
return True
try:
for skill_md in skills_root.rglob("kanban-worker/SKILL.md"):
if skill_md.is_file():
return True
except OSError:
pass
return False
def _worker_terminal_timeout_env(
max_runtime_seconds: Optional[int],
current_timeout: Optional[str],
@ -7440,32 +7401,14 @@ def _default_spawn(
# profile-local worker sessions still register configured hooks.
"--accept-hooks",
]
# Auto-load the kanban-worker skill so every dispatched worker
# has the pattern library (good summary/metadata shapes, retry
# diagnostics, block-reason examples) in its context, even if
# the profile hasn't wired it into skills config. The MANDATORY
# lifecycle is already in the system prompt via KANBAN_GUIDANCE;
# this skill is the deeper reference. Users can point a profile
# at a different/additional skill via config if they want —
# --skills is additive to the profile's default skill set.
#
# Only add the flag when the skill actually resolves for the home
# the worker runs under: the bundled skill is absent from many
# profile-scoped skills dirs, and preloading a missing skill is
# fatal at CLI startup. Omitting it is safe — the lifecycle
# contract still ships via KANBAN_GUIDANCE.
if _kanban_worker_skill_available(env.get("HERMES_HOME")):
cmd.extend(["--skills", "kanban-worker"])
# Per-task force-loaded skills. Each name goes in its own
# `--skills X` pair rather than a single comma-joined arg: the CLI
# accepts both forms (action='append' + comma-split), but
# per-name pairs are easier to read in `ps` output and avoid any
# quoting ambiguity if a skill name ever contains unusual chars.
# Dedupe against the built-in so we don't double-load kanban-worker
# if a task author asks for it explicitly.
if task.skills:
for sk in task.skills:
if sk and sk != "kanban-worker":
if sk:
cmd.extend(["--skills", sk])
if task.model_override:
cmd.extend(["-m", task.model_override])
@ -8322,7 +8265,7 @@ def latest_run(conn: sqlite3.Connection, task_id: str) -> Optional[Run]:
def latest_summary(conn: sqlite3.Connection, task_id: str) -> Optional[str]:
"""Return the latest non-null ``task_runs.summary`` for ``task_id``.
The kanban-worker skill writes its handoff to ``task_runs.summary``
The worker writes its handoff to ``task_runs.summary``
via ``complete_task(summary=...)``; ``tasks.result`` is left empty
unless the caller passes ``result=`` explicitly. Dashboards and CLI
"show" views need this value to surface what a worker actually did

View file

@ -124,7 +124,6 @@ def create_swarm(
idempotency_key=idempotency_key,
workspace_kind=workspace_kind,
workspace_path=workspace_path,
skills=["kanban-orchestrator"],
)
# If idempotency returned an existing non-archived root, do not duplicate the