diff --git a/src/command_system/workflows_integration.py b/src/command_system/workflows_integration.py index 641fb786d..53c64eee1 100644 --- a/src/command_system/workflows_integration.py +++ b/src/command_system/workflows_integration.py @@ -63,32 +63,40 @@ def _workflow_to_command(path: Path, loaded_from: str) -> Optional[PromptCommand _ULTRACODE_DIRECTIVE = ( - "The user invoked /ultracode — they are explicitly opting into multi-agent " - 'workflow orchestration and want you to AUTHOR and run a workflow (a "pipeline") ' - "for the task below, instead of doing it turn by turn.\n\n" - "Design a workflow script for the task: decompose it into phases, fan out " - "subagents for the independent parts (parallel/pipeline), and adversarially verify " - "findings before synthesizing. Then launch it by calling the Workflow tool with " - "your script passed inline as `script` (see the Workflow tool's own description for " - "the script shape — it begins with `export const meta = {...}`). If the task below " - "is empty, ask the user what they want the workflow to do instead of guessing.\n\n" - "The Workflow tool launches the run in the BACKGROUND and returns a `run_id` " - "immediately. As soon as it returns, STOP: reply with one short sentence confirming " - "the workflow started (mention the run_id) and END YOUR TURN. Do NOT wait for it, " - "poll it, call another tool, or do the work yourself — the finished result is " - "delivered automatically when the run completes.\n\n" + "The user invoked /ultracode — AUTHOR a reusable multi-agent workflow (a " + '"pipeline") for the task below and SAVE it as a slash command. Do NOT run it ' + "now, and do NOT call the Workflow tool — the user will run it themselves with " + "`/` (the same way `/deep-research` runs).\n\n" + "Steps:\n" + "1. Read `src/workflow/bundled/deep_research.py` first and mirror its exact " + "format: a top-level `meta = {\"name\": ..., \"description\": ..., \"phases\": " + "[{\"title\": ...}, ...]}` dict, then an async body that uses ONLY the injected " + "primitives — `await agent(prompt, schema=...)`, `parallel`, `pipeline`, " + "`phase`, `log`, `budget` — and ends with `return `. It is sandboxed " + "Python: no `import`, no `open`, no `Date.now()`/`random` (the runtime withholds " + "them).\n" + "2. Design the workflow for the task: decompose into phases, fan out subagents " + "for the independent parts, verify, then synthesize. Give `meta.description` a " + "clear one-line summary — it becomes the slash command's description.\n" + "3. Choose a short kebab-case name (e.g. `hn-scraper`) and Write the script to " + "`.claude/workflows/.py` with the Write tool (create the directory if " + "needed). The filename stem IS the command name.\n" + "4. Reply in two or three lines: confirm the workflow is saved and tell the user " + "to run it with `/ ` (runs in the background like /deep-research). " + "If the task below is empty, ask what the workflow should do instead of guessing.\n\n" + "Author and SAVE only — do not launch it.\n\n" "Task:\n$ARGUMENTS" ) def _ultracode_command() -> PromptCommand: - """The ``/ultracode`` command: a discoverable, autocompleting slash form of the - ``ultracode`` authoring keyword. ``/ultracode `` directs the model to - author a fresh workflow script for the task and launch it via the Workflow tool - (vs. ``/deep-research`` / saved ``/``, which run an *existing* script).""" + """The ``/ultracode`` command: author a fresh workflow and SAVE it as a reusable + ``/`` slash command (it does **not** run — the user launches it later with + ``/``, exactly like ``/deep-research``). ``/deep-research`` and saved + ``/`` run an *existing* script; ``/ultracode`` is the *generator*.""" return PromptCommand( name="ultracode", - description="Author and run a multi-agent workflow (pipeline) for a task", + description="Author a multi-agent workflow (pipeline) and save it as a / command", kind="workflow", loaded_from="bundled", source="bundled", diff --git a/src/repl/core.py b/src/repl/core.py index 85135f393..0016bba7d 100644 --- a/src/repl/core.py +++ b/src/repl/core.py @@ -513,6 +513,10 @@ def _get_mcp_servers_for_prompt() -> list[str]: # permission dialog or an in-progress chat turn. self._at_prompt = False self._notif_stop: threading.Event | None = None + # mtime signature of the .claude/workflows dirs, so a workflow just + # authored via /ultracode is picked up as a / command mid-session + # (see _refresh_workflow_commands) without re-globbing every turn. + self._wf_dirs_sig: tuple | None = None # Permission dialogs can be requested from different worker paths # (e.g. subagents/tools). Serialize interactive prompts so we never # mount competing prompt_toolkit applications at once. @@ -1982,6 +1986,38 @@ def _notification_watcher(self, stop: threading.Event) -> None: pass stop.wait(0.4) + def _refresh_workflow_commands(self) -> None: + """Pick up newly-saved ``.claude/workflows/*.py`` as ``/`` commands + without a restart — so a workflow just authored via ``/ultracode`` is + runnable this session, exactly like ``/deep-research``. + + mtime-gated on the project + personal workflow dirs so it only re-discovers + when one actually changes (idempotent: the shadowing guard in + ``load_and_register_workflows`` skips already-registered names).""" + try: + from pathlib import Path + + dirs = [Path.cwd() / ".claude" / "workflows", Path.home() / ".claude" / "workflows"] + sig = tuple((str(d), d.stat().st_mtime_ns) for d in dirs if d.is_dir()) + except Exception: + return + if sig == self._wf_dirs_sig: + return + self._wf_dirs_sig = sig + try: + from src.command_system.workflows_integration import load_and_register_workflows + + # Global registry → dispatch (`/ `) + autocomplete. + load_and_register_workflows(registry=None) + # Local registry → bare `/` routes to execution (parity with + # /deep-research), not the slash palette. + registry = getattr(self, "command_registry", None) + if registry is not None: + load_and_register_workflows(registry=registry) + self._update_built_in_commands_with_command_system() + except Exception: + pass + def _start_notification_watcher(self) -> None: if self._notif_stop is not None: return @@ -2332,6 +2368,9 @@ def run(self): while True: try: + # Pick up a workflow just authored via /ultracode so its / + # command is runnable this turn (before the completer rebuilds). + self._refresh_workflow_commands() self._refresh_completer() # Surface any background task (workflow/agent) that finished # since the last turn: print its banner and let the agent diff --git a/tests/test_ultracode.py b/tests/test_ultracode.py index c11763038..5ddcd912b 100644 --- a/tests/test_ultracode.py +++ b/tests/test_ultracode.py @@ -162,14 +162,20 @@ def test_ultracode_is_a_registered_command(): assert getattr(cmd, "kind", None) == "workflow" -def test_ultracode_command_directive_authors_and_launches(): +def test_ultracode_command_directive_authors_and_saves(): from src.command_system.workflows_integration import _ultracode_command - directive = _ultracode_command().markdown_content or "" - assert "AUTHOR" in directive # author a fresh workflow… - assert "Workflow tool" in directive # …and launch it via the tool - assert "$ARGUMENTS" in directive # the task is substituted in - assert "END YOUR TURN" in directive # background run → don't block + cmd = _ultracode_command() + directive = cmd.markdown_content or "" + assert "AUTHOR" in directive # author a fresh workflow… + assert ".claude/workflows" in directive # …saved as a / command + assert "Write" in directive # via the Write tool + assert "do NOT call the Workflow tool" in directive # NOT run immediately + assert "deep_research.py" in directive # format template referenced + assert "$ARGUMENTS" in directive # the task is substituted in + # description reflects "save as a / command", not "run" + desc = (cmd.description or "").lower() + assert "save" in desc and "/" in desc def test_ultracode_command_absent_when_workflows_disabled(monkeypatch): @@ -177,3 +183,41 @@ def test_ultracode_command_absent_when_workflows_disabled(monkeypatch): monkeypatch.setenv("CLAUDE_CODE_DISABLE_WORKFLOWS", "1") assert "ultracode" not in _names(get_builtin_commands()) + + +# ── in-session pickup of a freshly-authored / workflow ────────────────── + + +def test_refresh_workflow_commands_picks_up_new_file(tmp_path, monkeypatch): + """A workflow saved into .claude/workflows/ mid-session becomes a / + command without a restart (the /ultracode → / handoff).""" + from src.command_system.registry import CommandRegistry, get_command_registry + from src.repl.core import ClawcodexREPL + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(Path, "home", lambda: tmp_path / "home") # empty personal dir + + wfdir = tmp_path / ".claude" / "workflows" + wfdir.mkdir(parents=True) + (wfdir / "ultratest-scraper.py").write_text( + 'meta = {"name": "ultratest-scraper", "description": "scrape something"}\n' + 'return await agent("x")\n', + encoding="utf-8", + ) + + repl = ClawcodexREPL.__new__(ClawcodexREPL) + repl._wf_dirs_sig = None + repl.command_registry = CommandRegistry() + repl._original_built_ins = [] + repl._built_in_commands = [] + + repl._refresh_workflow_commands() + + # dispatchable via the global registry, and bare / routes to execution + assert get_command_registry().get("ultratest-scraper") is not None + assert "/ultratest-scraper" in repl._built_in_commands + + # mtime-gated: a second call with no dir change is a no-op (signature unchanged) + sig_after = repl._wf_dirs_sig + repl._refresh_workflow_commands() + assert repl._wf_dirs_sig == sig_after