This commit is contained in:
2026-10-08 18:29:45 -07:00
parent 8eedf03486
commit 5725de2dac
35 changed files with 3360 additions and 0 deletions

1
scripts/lib/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Shared automation library for PlanB task tooling."""

Binary file not shown.

View File

@@ -0,0 +1,52 @@
import os
from typing import Dict, List, Optional
from .base import BaseAgent
from .antigravity import AntigravityAgent
from .hermes import HermesAgent
_REGISTERED_AGENTS: List[BaseAgent] = [
AntigravityAgent(),
HermesAgent(),
]
_LOOKUP_MAP: Dict[str, BaseAgent] = {}
for agent in _REGISTERED_AGENTS:
_LOOKUP_MAP[agent.name.lower()] = agent
for alias in agent.aliases:
_LOOKUP_MAP[alias.lower()] = agent
def list_agents() -> List[BaseAgent]:
"""Return list of all registered agent instances."""
return list(_REGISTERED_AGENTS)
def get_agent(name_or_alias: str) -> Optional[BaseAgent]:
"""Look up an agent by its primary name or any alias."""
return _LOOKUP_MAP.get(name_or_alias.strip().lower())
def get_default_agent() -> BaseAgent:
"""
Resolve the default agent to use:
1. Check TASK_AGENT environment variable.
2. Fall back to the first available agent CLI found on PATH.
3. If none installed, return the first registered agent (which will report not found on launch).
"""
env_agent = os.environ.get("TASK_AGENT")
if env_agent:
matched = get_agent(env_agent)
if matched:
return matched
else:
raise ValueError(
f"Unknown agent specified in TASK_AGENT='{env_agent}'. "
f"Available agents: {', '.join([a.name for a in _REGISTERED_AGENTS])}"
)
# Auto-detect available agent
for agent in _REGISTERED_AGENTS:
if agent.is_available():
return agent
return _REGISTERED_AGENTS[0]

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,25 @@
import os
import shutil
from typing import List, Optional
from .base import BaseAgent
class AntigravityAgent(BaseAgent):
"""Adapter for Google Antigravity CLI (agy)."""
name = "antigravity"
aliases = ["agy"]
def is_available(self) -> bool:
return shutil.which("agy") is not None
def launch(self, prompt: str, extra_args: Optional[List[str]] = None) -> None:
bin_path = shutil.which("agy")
if not bin_path:
raise FileNotFoundError("Antigravity CLI ('agy') is not found on PATH.")
cmd = [bin_path, "-i", prompt]
if extra_args:
cmd.extend(extra_args)
os.execvp(bin_path, cmd)

View File

@@ -0,0 +1,22 @@
from abc import ABC, abstractmethod
from typing import List, Optional
class BaseAgent(ABC):
"""Abstract base class for AI CLI agents."""
name: str = ""
aliases: List[str] = []
@abstractmethod
def is_available(self) -> bool:
"""Check if the agent CLI binary is installed and reachable in PATH."""
pass
@abstractmethod
def launch(self, prompt: str, extra_args: Optional[List[str]] = None) -> None:
"""Launch the agent interactively with the pre-seeded prompt."""
pass
def __repr__(self) -> str:
return f"<{self.__class__.__name__} name={self.name}>"

View File

@@ -0,0 +1,25 @@
import os
import shutil
from typing import List, Optional
from .base import BaseAgent
class HermesAgent(BaseAgent):
"""Adapter for Hermes Agent CLI."""
name = "hermes"
aliases = ["hermes-agent"]
def is_available(self) -> bool:
return shutil.which("hermes") is not None
def launch(self, prompt: str, extra_args: Optional[List[str]] = None) -> None:
bin_path = shutil.which("hermes")
if not bin_path:
raise FileNotFoundError("Hermes CLI ('hermes') is not found on PATH.")
cmd = [bin_path, "chat", "-q", prompt]
if extra_args:
cmd.extend(extra_args)
os.execvp(bin_path, cmd)

View File

@@ -0,0 +1,36 @@
#!/usr/bin/env python3
import runpy
import sys
from pathlib import Path
def normalize_predicate(predicate, epics):
column, separator, values = predicate.partition(":")
if not separator or column.strip().lower() != "epic":
return predicate
labels = {}
for epic in epics:
value = epic.get("value", "").strip()
label = epic.get("label", "").strip() or value.replace("-", " ").replace("_", " ").title()
if value:
labels[value.lower()] = label
labels[label.lower()] = label
normalized = [labels.get(value.strip().lower(), value.strip()) for value in values.split(",")]
return f"Epic:{','.join(normalized)}"
def main():
try:
update = runpy.run_path(str(Path(__file__).resolve().parents[1] / "update-tasks"))
text = Path(sys.argv[1]).read_text(encoding="utf-8")
epics = update["read_frontmatter"](text).get("epics", [])
for predicate in sys.argv[2:]:
print(normalize_predicate(predicate, epics))
except (OSError, ValueError) as error:
print(f"Error normalizing task filters: {error}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

132
scripts/lib/task_context.py Normal file
View File

@@ -0,0 +1,132 @@
import argparse
import importlib.machinery
import importlib.util
import os
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional
from agents import BaseAgent, get_agent, get_default_agent, list_agents
def load_get_task_module():
"""Dynamically load scripts/get-task."""
repo_root = Path(__file__).resolve().parent.parent.parent
get_task_script = repo_root / "scripts" / "get-task"
if not get_task_script.exists():
raise FileNotFoundError(f"Could not find get-task script at {get_task_script}")
loader = importlib.machinery.SourceFileLoader("get_task_mod", str(get_task_script))
spec = importlib.util.spec_from_loader("get_task_mod", loader)
if spec is None:
raise ImportError(f"Could not load spec for {get_task_script}")
mod = importlib.util.module_from_spec(spec)
loader.exec_module(mod)
return mod
def fetch_task(task_id: str, tasks_file: Optional[str] = None) -> Dict[str, Any]:
"""Retrieve task dictionary using get_task."""
mod = load_get_task_module()
return mod.get_task(task_id, tasks_file=tasks_file)
def assert_task_status(task: Dict[str, Any], allowed_statuses: List[str], command_name: str) -> None:
"""Validate that the task status matches one of the allowed statuses, otherwise error and exit."""
current_status = task.get("status", "").strip().lower()
allowed_lower = [s.lower() for s in allowed_statuses]
if current_status not in allowed_lower:
if len(allowed_statuses) == 1:
req_str = f"'{allowed_statuses[0]}'"
else:
req_str = " or ".join([f"'{s}'" for s in allowed_statuses])
sys.stderr.write(
f"Error: Task {task['id']} is in '{task['status']}' status. "
f"{command_name.capitalize()} requires status to be {req_str}.\n"
)
sys.exit(1)
def build_workflow_prompt(
task: Dict[str, Any],
workflow_name: str,
workflow_objective: str,
extra_context: str = "",
) -> str:
"""Construct a standardized, high-fidelity prompt for an AI agent."""
desc = task["description"].strip() if task.get("description") else "(No description provided yet)"
subproj_line = f"- Subproject: {task['subproject']}\n" if task.get("subproject") else ""
epic_line = f"- Epic: {task.get('epic_label', task['epic'])} ({task['epic']})\n" if task.get("epic") else ""
epic_arg = f" --epic={task['epic']}" if task.get("epic") else ""
prompt = f"""You are {workflow_name} task {task['id']} in the PlanBForGreatJustice repository.
### Target Task:
- ID: {task['id']}
- Title: {task['title']}
- Project: {task['project']}
{subproj_line}{epic_line}- Current Status: {task['status']}
- Type: {task['type']}
### Current Description & Checklist:
{desc}
### Repository Context & Guidelines:
1. SSoT & Task Management (GEMINI.md):
- You MUST use `./scripts/update-task {task['id']} ...` to update task metadata, description, title, or checklist items.
- If this task needs to be split into subtasks, use `./scripts/add-task "Subtask Title" --project={task['project']} --type={task['type']}{epic_arg}`.
- Do NOT edit docs/tasks.md manually.
2. Architecture Overview:
- C++ Story Engine: Engine/PlanB/Engine/ (Actions, Expressions, Sequences, GameState, StoryModel).
- Desktop Editor: Editor/App/ (Tauri + Vite + React, Sequence Simulator, Expression Graph Canvas, XML converters).
- Content: Content/Episode 1/Episode1/Episode.xml.
- Specifications: docs/Schema/ (e.g., PBScript_Specification.md, grammar files).
- Legacy Reference: Editor/Sources/ (WPF C# editor for comparison if applicable).
3. Objectives for this session:
{workflow_objective}
"""
if extra_context:
prompt += f"\n### Additional Context:\n{extra_context}\n"
prompt += f"\nBegin by reviewing the task, inspecting the relevant code, and collaborating interactively with the user.\n"
return prompt
def add_common_agent_arguments(parser: argparse.ArgumentParser) -> None:
"""Register reusable CLI flags for agent selection and tasks.md path."""
registered = list_agents()
agent_names = [a.name for a in registered]
parser.add_argument(
"--agent",
choices=agent_names,
default=None,
help=f"AI agent CLI to use ({', '.join(agent_names)}). Defaults to $TASK_AGENT or first available.",
)
parser.add_argument("--hermes", action="store_true", help="Shortcut for --agent hermes")
parser.add_argument("--agy", action="store_true", help="Shortcut for --agent antigravity")
parser.add_argument("-f", "--file", default=None, help="Path to tasks.md (optional)")
def resolve_selected_agent(args: argparse.Namespace) -> BaseAgent:
"""Resolve which agent was requested via flags or environment."""
if getattr(args, "hermes", False):
agent = get_agent("hermes")
elif getattr(args, "agy", False):
agent = get_agent("antigravity")
elif getattr(args, "agent", None):
agent = get_agent(args.agent)
else:
agent = get_default_agent()
if not agent:
sys.stderr.write("Error: Could not resolve requested agent.\n")
sys.exit(1)
if not agent.is_available():
sys.stderr.write(
f"Error: Selected agent '{agent.name}' is not available on PATH. "
f"Please verify installation.\n"
)
sys.exit(1)
return agent