From 7c8c68dae3f8e3664004ae9c57cafb8833b38bf1 Mon Sep 17 00:00:00 2001 From: Pal Date: Tue, 19 May 2026 18:02:46 +0000 Subject: [PATCH] fix(kanban): pass skills from decomposer to child tasks The decomposer LLM was never able to pass skill bundles to child tasks because: 1. kanban_decompose.py did not extract `skills` from the LLM response and did not include it in the children dict 2. kanban_db.py `decompose_triage_task()` INSERT omitted the `skills` column entirely This commit: - Adds `skills` to the decomposer system prompt schema so the LLM knows it can include skill bundles in its response - Extracts and validates `skills` from the decomposer JSON - Inserts `skills` into the child task row (JSON-serialized) All 167 kanban tests pass (9 decompose + 158 DB). --- hermes_cli/kanban_db.py | 13 +++++++++++-- hermes_cli/kanban_decompose.py | 13 ++++++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 9cbc010ac64af..74d445d3b6404 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -3146,6 +3146,7 @@ def decompose_triage_task( "body": "...", # optional "assignee": "profile-name", # optional, None -> default fallback "parents": [0, 2], # indices into this same children list + "skills": ["skill-name"], # optional list of skill names } Returns the list of created child task ids (in input order) on @@ -3233,11 +3234,18 @@ def decompose_triage_task( title = child["title"].strip() body = child.get("body") assignee = _canonical_assignee(child.get("assignee")) + # Extract optional skills from the child dict. + raw_skills = child.get("skills") + skills_val = None + if isinstance(raw_skills, list) and raw_skills: + cleaned = [s for s in raw_skills if isinstance(s, str) and s.strip()] + if cleaned: + skills_val = json.dumps(cleaned) conn.execute( "INSERT INTO tasks " "(id, title, body, assignee, status, workspace_kind, " - " tenant, created_at, created_by) " - "VALUES (?, ?, ?, ?, 'todo', 'scratch', ?, ?, ?)", + " tenant, created_at, created_by, skills) " + "VALUES (?, ?, ?, ?, 'todo', 'scratch', ?, ?, ?, ?)", ( new_id, title, @@ -3246,6 +3254,7 @@ def decompose_triage_task( tenant, now, (author or "decomposer"), + skills_val, ), ) _append_event( diff --git a/hermes_cli/kanban_decompose.py b/hermes_cli/kanban_decompose.py index 063abcf7b513b..9ee0d75445ad3 100644 --- a/hermes_cli/kanban_decompose.py +++ b/hermes_cli/kanban_decompose.py @@ -70,7 +70,8 @@ "title": "", "body": "", "assignee": "", - "parents": [, ...] + "parents": [, ...], + "skills": ["", ...] # optional — skill bundles to force-load }, ... ] @@ -89,6 +90,9 @@ and the system will route to the default_assignee. - Each child task body is what a fresh worker will read with no other context — be specific about goal, approach, and acceptance criteria. + - Use "skills" to specify skill bundles the worker should force-load + (e.g. ["github-workflow", "test-driven-development"]). Omit if no + special skills are needed. When the task is genuinely a single unit of work (no useful decomposition), return: @@ -431,11 +435,18 @@ def decompose_task( parents = [] # Clean parent indices: drop non-int and out-of-range. clean_parents = [p for p in parents if isinstance(p, int) and 0 <= p < len(raw_tasks) and p != idx] + # Extract optional skills list from the decomposer response. + raw_skills = entry.get("skills") + if isinstance(raw_skills, list): + skills_list = [s for s in raw_skills if isinstance(s, str) and s.strip()] + else: + skills_list = [] children.append({ "title": title.strip()[:200], "body": body.strip(), "assignee": chosen, "parents": clean_parents, + "skills": skills_list, }) try: