Phase 3: approval gate, execute intent, approve/reject routes

- New: app/approval.py — HMAC-signed tokens, pending action store, 15-min TTL
- New: /approve/{token} and /reject/{token} GET routes (public, for Discord links)
- New: /internal/queue-action POST (for Citadel propose_file_change)
- New: execute intent in classifier — restart/rebuild + agent name queues action
- Updated: tools.py — notify_raven_with_actions, call_qyburn_rebuild/restart
- Updated: intent.py — EXECUTE_VERBS, STACK_NAMES, extract_execute_target
- Updated: SYSTEM_PROMPT reflects Phase 3 capabilities
- Updated: docker-compose.yml — JON_SECRET, JON_PUBLIC_URL, RAVEN_URL, QYBURN_URL
This commit is contained in:
2026-05-27 12:26:52 +00:00
parent a25deeb8f4
commit 53b52a2337
5 changed files with 710 additions and 16 deletions
+189 -3
View File
@@ -1,4 +1,4 @@
AGENT_NAMES = {"hodor", "bran", "varys", "sam", "raven", "qyburn", "citadel", "jon"}
AGENT_NAMES = {"hodor", "bran", "varys", "sam", "raven", "qyburn", "citadel", "jon", "hermes"}
STATUS_PHRASES = {
"status", "health", "running", "last run", "what did", "when did",
@@ -9,7 +9,17 @@ STATUS_PHRASES = {
TASK_PHRASES = {
"create task", "add task", "add issue", "create issue", "log task",
"log this", "new task", "new issue", "add to plane", "add to backlog",
"plan", "schedule", "remind", "track", "todo", "to do",
"schedule", "remind", "track", "todo", "to do",
}
# "plan" removed from TASK_PHRASES — it substring-matches "plane" (the tool name)
TASK_WORDS = {"plan"}
QUERY_PHRASES = {
"list", "list all", "show all", "what are", "give me", "get",
"my projects", "all projects", "list projects", "show projects",
"issues", "tasks", "backlog", "what's in", "whats in", "show issues",
"list issues", "issues in", "tasks in", "work items", "work item",
}
RESEARCH_PHRASES = {
@@ -17,12 +27,61 @@ RESEARCH_PHRASES = {
"how does", "documentation", "docs",
}
CREATE_PROJECT_PHRASES = {
"create project", "new project", "make project", "add project",
"create a project", "new project called", "create project called",
"start a project", "set up a project",
"create a plane project", "create plane project",
"create a new project", "make a new project",
}
# Execute intent — words that signal a request to run/deploy a container
EXECUTE_VERBS = {"restart", "rebuild", "redeploy", "reload", "deploy"}
# Short agent name → Docker stack directory name
STACK_NAMES = {
"hodor": "hodor-gateway",
"bran": "bran-changelog",
"varys": "varys-monitor",
"sam": "sam-research",
"raven": "raven-notify",
"qyburn": "qyburn-coder",
"citadel": "citadel-mcp",
"jon": "jon-snow",
"hermes": "hermes-cloud",
"monitoring": "monitoring",
"netbox": "netbox",
"plane": "plane",
"gitea": "gitea",
"searxng": "searxng",
}
def classify_intent(message: str) -> str:
msg = message.lower()
words = set(msg.split())
# Agent name + query word → status
# Project creation — check before task/query
if any(p in msg for p in CREATE_PROJECT_PHRASES):
return "create_project"
# Execute intent — agent/stack name + action verb → queue for approval
if words & EXECUTE_VERBS and words & set(STACK_NAMES.keys()):
return "execute"
if any(v in msg for v in EXECUTE_VERBS) and any(s in msg for s in STACK_NAMES):
return "execute"
# Plane query — list/show projects or issues (check before task to avoid "plan" ⊂ "plane" bug)
if ("project" in msg or "projects" in msg) and any(p in msg for p in QUERY_PHRASES):
return "query"
if "plane" in words and any(p in msg for p in QUERY_PHRASES):
return "query"
# Issue list query — "show issues in X", "list tasks for X", "work items for X"
has_issue_word = bool(words & ISSUE_KEYWORDS) or any(p in msg for p in ISSUE_PHRASES)
if has_issue_word and any(p in msg for p in {"show", "list", "get", "what", "give", "in", "for"}):
return "query"
# Agent name + status word → status
if words & AGENT_NAMES:
if any(p in msg for p in STATUS_PHRASES) or words & {"status", "check", "output", "run"}:
return "status"
@@ -30,6 +89,12 @@ def classify_intent(message: str) -> str:
# Explicit task phrases → task
if any(p in msg for p in TASK_PHRASES):
return "task"
# Word-boundary match for single-word task terms (avoids "plan" matching "plane")
if words & TASK_WORDS:
return "task"
# Natural task creation: "add X to [project]"
if msg.startswith("add ") and " to " in msg:
return "task"
# Generic status signal words
if any(p in msg for p in STATUS_PHRASES) and words & AGENT_NAMES:
@@ -54,6 +119,36 @@ def extract_agent_name(message: str) -> str | None:
return None
def extract_execute_target(message: str) -> tuple[str, str] | None:
"""Extract (action_type, stack_dir_name) from an execute message.
Examples:
'restart citadel' → ('docker_restart', 'citadel-mcp')
'rebuild raven' → ('docker_rebuild', 'raven-notify')
'redeploy hermes' → ('docker_rebuild', 'hermes-cloud')
Returns None if action type or stack name cannot be determined.
"""
msg = message.lower()
words = set(msg.split())
action_type = None
if words & {"restart", "reload"}:
action_type = "docker_restart"
elif words & {"rebuild", "redeploy", "deploy"}:
action_type = "docker_rebuild"
stack_dir = None
for short, full in STACK_NAMES.items():
if short in words:
stack_dir = full
break
if action_type and stack_dir:
return (action_type, stack_dir)
return None
PROJECT_KEYWORDS = {
"bni": "BNI Scheduler",
"scheduler": "BNI Scheduler",
@@ -67,9 +162,100 @@ PROJECT_KEYWORDS = {
"portal": "Nexum Portal",
"authelia": "Nexum Portal",
"nexum": "Nexum Portal",
"general": "General / Admin",
"admin": "General / Admin",
"agent": "Agent Ecosystem",
"ecosystem": "Agent Ecosystem",
"milkwood": "Milkwood Primary",
"grgl": "GRGL",
"private": "Private Clients",
}
ISSUE_KEYWORDS = {"issue", "issues", "task", "tasks", "backlog", "ticket", "tickets"}
ISSUE_PHRASES = {"work items", "work item"}
def is_issue_query(message: str) -> bool:
msg = message.lower()
return bool(set(msg.split()) & ISSUE_KEYWORDS) or any(p in msg for p in ISSUE_PHRASES)
def extract_task_title(message: str) -> str:
"""Extract clean task title from natural phrasing like 'add buy milk to General'."""
msg = message.strip()
lower = msg.lower()
if lower.startswith("add ") and " to " in lower:
parts = msg.rsplit(" to ", 1)
title = parts[0][4:].strip()
return title if title else msg
for prefix in ("create task:", "add task:", "log task:", "new task:", "log this:"):
if lower.startswith(prefix):
return msg[len(prefix):].strip()
return msg
def extract_compound_task(message: str) -> str | None:
"""Extract a task title from compound messages like 'create project X and add [todo item,] Y'.
Returns the task title or None if no compound task found."""
lower = message.lower()
# Split on compound markers
for marker in ("and add to do item,", "and add todo item,", "and add a task,",
"and add task,", "and add issue,", "and add to do,",
"and add to-do,", "and add,"):
idx = lower.find(marker)
if idx != -1:
return message[idx + len(marker):].strip().strip('"\'')
# Simpler: "and add [title]" — only if no comma separator
idx = lower.find(" and add ")
if idx != -1:
candidate = message[idx + 9:].strip()
# Skip if it looks like a project destination ("and add to X")
if not candidate.lower().startswith("to "):
return candidate.strip('"\'')
return None
def _trim_at_compound(text: str) -> str:
"""Trim text at compound boundaries like 'and add'."""
lower = text.lower()
for stop in (" and add", " then add", ", and", " also add"):
idx = lower.find(stop)
if idx != -1:
text = text[:idx]
return text.strip().strip('"\',.')
def extract_new_project_name(message: str) -> str | None:
"""Extract project name from 'create project called X', 'new project: X', etc."""
msg = message.strip()
lower = msg.lower()
for marker in ("called ", "named ", "name ", ": ", "- "):
idx = lower.find(marker)
if idx != -1:
name = _trim_at_compound(msg[idx + len(marker):])
if name:
return name
for prefix in ("create a new project ", "create a plane project ", "create plane project ",
"create a project ", "create project ", "new project ", "make a new project ",
"make project ", "add project ", "start a project ", "set up a project "):
if lower.startswith(prefix):
name = _trim_at_compound(msg[len(prefix):])
if name:
return name
return None
def extract_task_destination(message: str) -> str | None:
"""Extract the destination project from 'add X to Y' → returns Y, or None."""
lower = message.strip().lower()
if lower.startswith("add ") and " to " in lower:
parts = message.strip().rsplit(" to ", 1)
if len(parts) == 2:
return parts[1].strip()
return None
def extract_project_name(message: str) -> str | None:
msg = message.lower()
for kw, project in PROJECT_KEYWORDS.items():