From 6db77f5b1220e415664874c7b13d48e85f94fbcf Mon Sep 17 00:00:00 2001 From: Eric Lee Date: Sat, 4 Jul 2026 14:19:51 -0700 Subject: [PATCH 1/2] feat(skills): port the /batch bundled skill (SKILLS-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bundled/batch.ts is registered UNCONDITIONALLY in initBundledSkills but was absent in the port. Verbatim prompt-port mirroring the port's simplify.py: - src/skills/bundled/batch.py: /batch is a user-invocable slash (disable_model_invocation) whose prompt orchestrates a large parallel change — research+plan in plan mode, then 5–30 isolated-worktree Agent runs each opening a PR. get_prompt_for_command guards empty-instruction (MISSING_INSTRUCTION) and non-git (NOT_A_GIT_REPO via the sync get_is_git, since the port's builder signature is sync unlike TS's async). Tool-name literals (EnterPlanMode/ExitPlanMode/Agent/AskUserQuestion/Skill) verified against the port registry; WORKER_INSTRUCTIONS + both guard messages verbatim. - wired into bundled/__init__.py init_bundled_skills alongside debug/loop/simplify — the live slash-menu consumer. tests/skills/test_batch_skill.py (8): registration fields, missing-instruction + not-a-git-repo guards, built prompt (tool names, worktree isolation, 5–30, verbatim WORKER_INSTRUCTIONS, instruction interpolation). Broader skills suites green. Co-Authored-By: Claude Fable 5 --- src/skills/bundled/__init__.py | 3 + src/skills/bundled/batch.py | 150 +++++++++++++++++++++++++++++++ tests/skills/test_batch_skill.py | 91 +++++++++++++++++++ 3 files changed, 244 insertions(+) create mode 100644 src/skills/bundled/batch.py create mode 100644 tests/skills/test_batch_skill.py diff --git a/src/skills/bundled/__init__.py b/src/skills/bundled/__init__.py index 8296e7c16..74b3f1b60 100644 --- a/src/skills/bundled/__init__.py +++ b/src/skills/bundled/__init__.py @@ -15,6 +15,7 @@ import logging +from .batch import register_batch_skill from .debug import register_debug_skill from .loop import register_loop_skill from .simplify import register_simplify_skill @@ -48,6 +49,7 @@ def init_bundled_skills() -> None: register_simplify_skill() register_debug_skill() register_loop_skill() + register_batch_skill() register_stuck_skill() register_verify_content_skill() _INITIALIZED = True @@ -68,6 +70,7 @@ def reset_bundled_skills_init_flag() -> None: "register_simplify_skill", "register_debug_skill", "register_loop_skill", + "register_batch_skill", "register_stuck_skill", "register_verify_content_skill", ] diff --git a/src/skills/bundled/batch.py b/src/skills/bundled/batch.py new file mode 100644 index 000000000..5f5d98460 --- /dev/null +++ b/src/skills/bundled/batch.py @@ -0,0 +1,150 @@ +"""Bundled ``/batch`` skill — verbatim port of ``bundled/batch.ts``. + +Orchestrates a large, parallelizable change: research + plan in plan mode, +then spawn 5–30 isolated-worktree agents that each implement one unit and +open a PR. User-invocable only (``disable_model_invocation``). +""" + +from __future__ import annotations + +from ..bundled_skills import BundledSkillDefinition, register_bundled_skill + +# Port tool-name literals (TS interpolates the *_TOOL_NAME constants; the +# Python tool registry uses these exact names — verified in +# src/tool_system/tools/{plan_mode,agent,ask_user_question,skill}.py). +_AGENT_TOOL_NAME = "Agent" +_ASK_USER_QUESTION_TOOL_NAME = "AskUserQuestion" +_ENTER_PLAN_MODE_TOOL_NAME = "EnterPlanMode" +_EXIT_PLAN_MODE_TOOL_NAME = "ExitPlanMode" +_SKILL_TOOL_NAME = "Skill" + +_MIN_AGENTS = 5 +_MAX_AGENTS = 30 + +WORKER_INSTRUCTIONS = f"""After you finish implementing the change: +1. **Simplify** — Invoke the `{_SKILL_TOOL_NAME}` tool with `skill: "simplify"` to review and clean up your changes. +2. **Run unit tests** — Run the project's test suite (check for package.json scripts, Makefile targets, or common commands like `npm test`, `bun test`, `pytest`, `go test`). If tests fail, fix them. +3. **Test end-to-end** — Follow the e2e test recipe from the coordinator's prompt (below). If the recipe says to skip e2e for this unit, skip it. +4. **Commit and push** — Commit all changes with a clear message, push the branch, and create a PR with `gh pr create`. Use a descriptive title. If `gh` is not available or the push fails, note it in your final message. +5. **Report** — End with a single line: `PR: ` so the coordinator can track it. If no PR was created, end with `PR: none — `.""" + + +def _build_prompt(instruction: str) -> str: + return f"""# Batch: Parallel Work Orchestration + +You are orchestrating a large, parallelizable change across this codebase. + +## User Instruction + +{instruction} + +## Phase 1: Research and Plan (Plan Mode) + +Call the `{_ENTER_PLAN_MODE_TOOL_NAME}` tool now to enter plan mode, then: + +1. **Understand the scope.** Launch one or more subagents (in the foreground — you need their results) to deeply research what this instruction touches. Find all the files, patterns, and call sites that need to change. Understand the existing conventions so the migration is consistent. + +2. **Decompose into independent units.** Break the work into {_MIN_AGENTS}–{_MAX_AGENTS} self-contained units. Each unit must: + - Be independently implementable in an isolated git worktree (no shared state with sibling units) + - Be mergeable on its own without depending on another unit's PR landing first + - Be roughly uniform in size (split large units, merge trivial ones) + + Scale the count to the actual work: few files → closer to {_MIN_AGENTS}; hundreds of files → closer to {_MAX_AGENTS}. Prefer per-directory or per-module slicing over arbitrary file lists. + +3. **Determine the e2e test recipe.** Figure out how a worker can verify its change actually works end-to-end — not just that unit tests pass. Look for: + - A `claude-in-chrome` skill or browser-automation tool (for UI changes: click through the affected flow, screenshot the result) + - A `tmux` or CLI-verifier skill (for CLI changes: launch the app interactively, exercise the changed behavior) + - A dev-server + curl pattern (for API changes: start the server, hit the affected endpoints) + - An existing e2e/integration test suite the worker can run + + If you cannot find a concrete e2e path, use the `{_ASK_USER_QUESTION_TOOL_NAME}` tool to ask the user how to verify this change end-to-end. Offer 2–3 specific options based on what you found (e.g., "Screenshot via chrome extension", "Run `bun run dev` and curl the endpoint", "No e2e — unit tests are sufficient"). Do not skip this — the workers cannot ask the user themselves. + + Write the recipe as a short, concrete set of steps that a worker can execute autonomously. Include any setup (start a dev server, build first) and the exact command/interaction to verify. + +4. **Write the plan.** In your plan file, include: + - A summary of what you found during research + - A numbered list of work units — for each: a short title, the list of files/directories it covers, and a one-line description of the change + - The e2e test recipe (or "skip e2e because …" if the user chose that) + - The exact worker instructions you will give each agent (the shared template) + +5. Call `{_EXIT_PLAN_MODE_TOOL_NAME}` to present the plan for approval. + +## Phase 2: Spawn Workers (After Plan Approval) + +Once the plan is approved, spawn one background agent per work unit using the `{_AGENT_TOOL_NAME}` tool. **All agents must use `isolation: "worktree"` and `run_in_background: true`.** Launch them all in a single message block so they run in parallel. + +For each agent, the prompt must be fully self-contained. Include: +- The overall goal (the user's instruction) +- This unit's specific task (title, file list, change description — copied verbatim from your plan) +- Any codebase conventions you discovered that the worker needs to follow +- The e2e test recipe from your plan (or "skip e2e because …") +- The worker instructions below, copied verbatim: + +``` +{WORKER_INSTRUCTIONS} +``` + +Use `subagent_type: "general-purpose"` unless a more specific agent type fits. + +## Phase 3: Track Progress + +After launching all workers, render an initial status table: + +| # | Unit | Status | PR | +|---|------|--------|----| +| 1 | | running | — | +| 2 | <title> | running | — | + +As background-agent completion notifications arrive, parse the `PR: <url>` line from each agent's result and re-render the table with updated status (`done` / `failed`) and PR links. Keep a brief failure note for any agent that did not produce a PR. + +When all agents have reported, render the final table and a one-line summary (e.g., "22/24 units landed as PRs"). +""" + + +_NOT_A_GIT_REPO_MESSAGE = ( + "This is not a git repository. The `/batch` command requires a git repo " + "because it spawns agents in isolated git worktrees and creates PRs from " + "each. Initialize a repo first, or run this from inside an existing one." +) + +_MISSING_INSTRUCTION_MESSAGE = """Provide an instruction describing the batch change you want to make. + +Examples: + /batch migrate from react to vue + /batch replace all uses of lodash with native equivalents + /batch add type annotations to all untyped function parameters""" + + +def _get_prompt_for_command(args: str) -> str: + instruction = args.strip() + if not instruction: + return _MISSING_INSTRUCTION_MESSAGE + # TS awaits getIsGit(); the port's prompt-builder signature is sync, so + # use the sync get_is_git helper. + from src.context_system.git_context import get_is_git + + if not get_is_git(): + return _NOT_A_GIT_REPO_MESSAGE + return _build_prompt(instruction) + + +def register_batch_skill() -> None: + register_bundled_skill( + BundledSkillDefinition( + name="batch", + description=( + "Research and plan a large-scale change, then execute it in " + "parallel across 5–30 isolated worktree agents that each open " + "a PR." + ), + when_to_use=( + "Use when the user wants to make a sweeping, mechanical change " + "across many files (migrations, refactors, bulk renames) that " + "can be decomposed into independent parallel units." + ), + argument_hint="<instruction>", + user_invocable=True, + disable_model_invocation=True, + get_prompt_for_command=_get_prompt_for_command, + ) + ) diff --git a/tests/skills/test_batch_skill.py b/tests/skills/test_batch_skill.py new file mode 100644 index 000000000..4f49f7a69 --- /dev/null +++ b/tests/skills/test_batch_skill.py @@ -0,0 +1,91 @@ +"""SKILLS-1 — the ``/batch`` bundled skill (verbatim port of bundled/batch.ts). + +Registered unconditionally in TS ``initBundledSkills``; was absent in the +port. Pins registration + the missing-instruction / not-a-git-repo guards + +the built prompt. +""" +from __future__ import annotations + +import pytest + +import src.context_system.git_context as gc +from src.skills.bundled import init_bundled_skills +from src.skills.bundled.batch import ( + WORKER_INSTRUCTIONS, + _build_prompt, + _get_prompt_for_command, +) +from src.skills.bundled_skills import ( + clear_bundled_skills, + get_bundled_skill_by_name, +) + + +@pytest.fixture() +def _fresh_registry(): + clear_bundled_skills() + init_bundled_skills() + yield + clear_bundled_skills() + + +class TestRegistration: + def test_registered_with_fields(self, _fresh_registry): + b = get_bundled_skill_by_name("batch") + assert b is not None + assert b.user_invocable is True + assert b.disable_model_invocation is True + assert b.argument_hint == "<instruction>" + assert "parallel" in b.description.lower() + + +class TestGuards: + def test_missing_instruction(self): + out = _get_prompt_for_command("") + assert "Provide an instruction" in out + assert "/batch migrate from react to vue" in out + # whitespace-only is also "missing" + assert "Provide an instruction" in _get_prompt_for_command(" ") + + def test_not_a_git_repo(self, monkeypatch): + monkeypatch.setattr(gc, "get_is_git", lambda cwd=None: False) + out = _get_prompt_for_command("do a thing") + assert "not a git repository" in out + assert "/batch" in out + + def test_git_repo_builds_prompt(self, monkeypatch): + monkeypatch.setattr(gc, "get_is_git", lambda cwd=None: True) + out = _get_prompt_for_command("migrate lodash to native") + assert "Batch: Parallel Work Orchestration" in out + assert "migrate lodash to native" in out + + +class TestPrompt: + def test_contains_tool_names_and_worktree(self): + out = _build_prompt("some instruction") + for needle in ( + "EnterPlanMode", + "ExitPlanMode", + "Agent", + "AskUserQuestion", + "Skill", + 'isolation: "worktree"', + "run_in_background", + ): + assert needle in out, needle + + def test_agent_count_range(self): + out = _build_prompt("x") + assert "5" in out and "30" in out + assert "5–30" in out + + def test_worker_instructions_embedded_verbatim(self): + out = _build_prompt("x") + assert WORKER_INSTRUCTIONS in out + # the worker steps + assert 'skill: "simplify"' in WORKER_INSTRUCTIONS + assert "PR: <url>" in WORKER_INSTRUCTIONS + + def test_instruction_interpolated(self): + out = _build_prompt("REPLACE_ME_TOKEN") + assert "## User Instruction\n\nREPLACE_ME_TOKEN" in out From 70e73a69a82f737343777f7a4a31bde0cdcaebca Mon Sep 17 00:00:00 2001 From: Eric Lee <ericleepi314@gmail.com> Date: Sat, 4 Jul 2026 14:29:02 -0700 Subject: [PATCH 2/2] test(skills): batch golden-length pin + guard-order (critic NITs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_golden_length_and_anchors: length + boundary snapshot of _build_prompt as a drift canary (byte-identity to TS verified externally by the critic). - test_missing_instruction_wins_over_git_check: pins the guard ORDER (empty-args short-circuits BEFORE the git check, parity with TS 112→116) by spying that get_is_git is never reached. 10/10 batch tests. The critic's MINOR (SkillPromptCommand headless-fallback stub when tool_context is absent) is a pre-existing SHARED property of all bundled skills (both live surfaces thread tool_context), noted in the parity map — not a defect in this PR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- tests/skills/test_batch_skill.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/skills/test_batch_skill.py b/tests/skills/test_batch_skill.py index 4f49f7a69..b08ca46a4 100644 --- a/tests/skills/test_batch_skill.py +++ b/tests/skills/test_batch_skill.py @@ -89,3 +89,32 @@ def test_worker_instructions_embedded_verbatim(self): def test_instruction_interpolated(self): out = _build_prompt("REPLACE_ME_TOKEN") assert "## User Instruction\n\nREPLACE_ME_TOKEN" in out + + def test_golden_length_and_anchors(self): + # Golden pin (critic NIT): a length + boundary snapshot guards against + # silent whitespace/section drift on future edits (byte-identity to TS + # was verified externally by the critic). + out = _build_prompt("X") + assert out.startswith("# Batch: Parallel Work Orchestration\n\n") + assert out.rstrip().endswith('"22/24 units landed as PRs").') + assert out.count("## Phase") == 3 # Research/Plan, Spawn, Track + # stable size modulo the 1-char instruction (regression canary) + assert 4830 <= len(out) <= 4860, len(out) + + +class TestGuardOrder: + def test_missing_instruction_wins_over_git_check(self, monkeypatch): + # critic NIT: empty args must short-circuit to MISSING_INSTRUCTION + # BEFORE the git check runs (order parity with TS 112→116). If git were + # checked first, a non-git dir + empty args would wrongly return the + # git message. + called = {"git": False} + + def _spy(cwd=None): + called["git"] = True + return False + + monkeypatch.setattr(gc, "get_is_git", _spy) + out = _get_prompt_for_command(" ") + assert "Provide an instruction" in out + assert called["git"] is False # git-check never reached