diff --git a/factory/agents/plugin.py b/factory/agents/plugin.py index ada2e6d..838c140 100644 --- a/factory/agents/plugin.py +++ b/factory/agents/plugin.py @@ -8,6 +8,8 @@ import yaml +from factory.ace.injector import inject_playbook +from factory.ace.paths import DEFAULTS_DIR as _PLAYBOOKS_DIR from factory.agents.runner import _PROMPTS_DIR _AGENTS_YML = Path(__file__).parent / "agents.yml" @@ -17,7 +19,7 @@ @dataclass(frozen=True) class AgentMeta: description: str - model: str + model: str # from agents.yml; not emitted in frontmatter (subagents inherit parent model) tools: list[str] @@ -52,8 +54,15 @@ def generate_agent_content(role: str) -> str: meta = config[role] prompt = (_PROMPTS_DIR / f"{role}.md").read_text() + # Only inject factory-default playbooks (not user-local ~/.factory/playbooks/) + # so that sync_agents.py output is deterministic across machines. + playbook_path = _PLAYBOOKS_DIR / f"{role}.md" + if playbook_path.exists(): + playbook = playbook_path.read_text().strip() + if playbook: + prompt = inject_playbook(prompt, playbook) frontmatter = yaml.dump( - {"name": role, "description": meta.description, "model": meta.model, "tools": meta.tools}, + {"name": role, "description": meta.description, "tools": meta.tools}, default_flow_style=False, sort_keys=False, allow_unicode=True, diff --git a/factory/cli.py b/factory/cli.py index c8acb7c..71b05c3 100644 --- a/factory/cli.py +++ b/factory/cli.py @@ -1106,6 +1106,21 @@ def cmd_explain(args: argparse.Namespace) -> int: return 0 +def cmd_emit(args: argparse.Namespace) -> int: + from factory.events import emit_event + + project_path = Path(args.project).resolve() + data: dict = {} + if args.data: + try: + data = json.loads(args.data) + except json.JSONDecodeError as e: + print(f"Error: --data is not valid JSON: {e}", file=sys.stderr) + return 1 + emit_event(project_path, args.event_type, agent=args.agent, data=data) + return 0 + + def cmd_vault_init(args: argparse.Namespace) -> int: from factory.obsidian.notes import init_vault @@ -2357,6 +2372,13 @@ def build_parser() -> argparse.ArgumentParser: p.add_argument("--port", type=int, default=8420, help="Server port (default: 8420)") p.add_argument("--host", default="0.0.0.0", help="Server host (default: 0.0.0.0)") + # emit — emit a structured event to .factory/events.jsonl + p = sub.add_parser("emit", help="Emit a structured event to .factory/events.jsonl") + p.add_argument("event_type", help="Event type (e.g. agent.started, agent.completed)") + p.add_argument("--agent", default=None, help="Agent role name") + p.add_argument("--project", default=".", help="Project path") + p.add_argument("--data", default=None, help="JSON string of additional event data") + # agent — invoke a specialist agent directly p = sub.add_parser("agent", help="Invoke a specialist agent with a task") p.add_argument("role", choices=["researcher", "strategist", "builder", "reviewer", @@ -2552,6 +2574,7 @@ def main(argv: list[str] | None = None) -> int: "install": cmd_install, "serve-mcp": cmd_serve_mcp, "dashboard": cmd_dashboard, + "emit": cmd_emit, "agent": cmd_agent, "ceo": cmd_ceo, "run": cmd_run, diff --git a/skills/pipeline-subagents/SKILL.md b/skills/pipeline-subagents/SKILL.md index 77b28c4..71e584f 100644 --- a/skills/pipeline-subagents/SKILL.md +++ b/skills/pipeline-subagents/SKILL.md @@ -99,16 +99,23 @@ Process steps in topological order: 1. **Identify next batch** — steps whose dependencies are all complete 2. **Build prompts** — incorporate output from prior steps (agent results are returned directly) -3. **Invoke agents:** - - Single step: one Agent call - - Parallel batch: multiple Agent calls in same message - - Archival: can use `run_in_background: true` +3. **Emit start event** and **invoke agents:** + ```bash + factory emit agent.started --agent --project "$(pwd)" + ``` + Then invoke via Agent tool (single, parallel batch, or background for archival). 4. **Read results** — Agent tool returns subagent output directly -5. **Apply gate rule:** +5. **Persist review and emit completion:** + ```bash + mkdir -p .factory/reviews + factory emit agent.completed --agent --project "$(pwd)" + ``` + Then use the Write tool to save the agent output to `.factory/reviews/-latest.md` +6. **Apply gate rule:** - **PROCEED**: Move to next step - **REDIRECT**: Re-invoke with corrections (max 2 per step) - **ABORT**: Skip downstream steps, jump to summary -6. **Repeat** until done +7. **Repeat** until done ### Error Recovery diff --git a/tests/test_cli.py b/tests/test_cli.py index f6b9c00..206eb30 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -80,6 +80,21 @@ def test_finalize_with_scores(self): def test_no_command_returns_1(self): assert main([]) == 1 + def test_emit_subcommand(self): + parser = build_parser() + args = parser.parse_args(["emit", "agent.started", "--agent", "researcher", "--project", "/p"]) + assert args.command == "emit" + assert args.event_type == "agent.started" + assert args.agent == "researcher" + assert args.project == "/p" + + def test_emit_defaults(self): + parser = build_parser() + args = parser.parse_args(["emit", "cycle.started"]) + assert args.agent is None + assert args.project == "." + assert args.data is None + def test_ceo_mode_interactive(self): parser = build_parser() args = parser.parse_args(["ceo", "distributed eval runner", "--mode", "interactive"]) diff --git a/tests/test_events.py b/tests/test_events.py index 71ba596..93e6d3e 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +from argparse import Namespace from datetime import datetime, timezone from factory.events import discover_factory_projects, emit_event, load_events @@ -155,3 +156,77 @@ def test_returns_sorted(self, tmp_path): projects = discover_factory_projects(tmp_path) assert projects[0].name == "alpha" assert projects[1].name == "zeta" + + +class TestCmdEmit: + def test_cmd_emit_writes_event(self, tmp_path): + from factory.cli import cmd_emit + + (tmp_path / ".factory").mkdir() + args = Namespace( + event_type="agent.started", + agent="researcher", + project=str(tmp_path), + data=None, + ) + rc = cmd_emit(args) + assert rc == 0 + + events_file = tmp_path / ".factory" / "events.jsonl" + assert events_file.exists() + + event = json.loads(events_file.read_text().strip()) + assert event["type"] == "agent.started" + assert event["agent"] == "researcher" + + def test_cmd_emit_with_data(self, tmp_path): + from factory.cli import cmd_emit + + (tmp_path / ".factory").mkdir() + args = Namespace( + event_type="agent.completed", + agent="builder", + project=str(tmp_path), + data='{"task": "fix bug", "duration": 42}', + ) + rc = cmd_emit(args) + assert rc == 0 + + events_file = tmp_path / ".factory" / "events.jsonl" + event = json.loads(events_file.read_text().strip()) + assert event["type"] == "agent.completed" + assert event["agent"] == "builder" + assert event["data"]["task"] == "fix bug" + assert event["data"]["duration"] == 42 + + def test_cmd_emit_without_agent(self, tmp_path): + from factory.cli import cmd_emit + + (tmp_path / ".factory").mkdir() + args = Namespace( + event_type="cycle.started", + agent=None, + project=str(tmp_path), + data='{"cycle": 1}', + ) + rc = cmd_emit(args) + assert rc == 0 + + events_file = tmp_path / ".factory" / "events.jsonl" + event = json.loads(events_file.read_text().strip()) + assert event["type"] == "cycle.started" + assert event["agent"] is None + assert event["data"]["cycle"] == 1 + + def test_cmd_emit_rejects_invalid_json(self, tmp_path): + from factory.cli import cmd_emit + + (tmp_path / ".factory").mkdir() + args = Namespace( + event_type="agent.started", + agent="researcher", + project=str(tmp_path), + data="not valid json", + ) + rc = cmd_emit(args) + assert rc == 1 diff --git a/tests/test_plugin_agents.py b/tests/test_plugin_agents.py index c2f4a8a..3ce6963 100644 --- a/tests/test_plugin_agents.py +++ b/tests/test_plugin_agents.py @@ -86,7 +86,6 @@ def test_frontmatter_has_required_fields(self): fm = _parse_frontmatter(content) assert "name" in fm, f"{role}: missing name" assert "description" in fm, f"{role}: missing description" - assert "model" in fm, f"{role}: missing model" assert "tools" in fm, f"{role}: missing tools" def test_frontmatter_name_matches_role(self): @@ -108,8 +107,9 @@ def test_preserves_prompt_content(self): for role in ALL_ROLES: source = (_PROMPTS_DIR / f"{role}.md").read_text() generated = generate_agent_content(role) - assert generated.endswith(source), ( - f"{role}: generated file does not end with source prompt" + # Generated content may have playbook injected, so check source is in it + assert source in generated, ( + f"{role}: generated file does not include source prompt" ) def test_unknown_role_raises(self):